You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
715 lines
18 KiB
715 lines
18 KiB
/*
|
|
** Zabbix
|
|
** Copyright (C) 2001-2023 Zabbix SIA
|
|
**
|
|
** This program is free software; you can redistribute it and/or modify
|
|
** it under the terms of the GNU General Public License as published by
|
|
** the Free Software Foundation; either version 2 of the License, or
|
|
** (at your option) any later version.
|
|
**
|
|
** This program is distributed in the hope that it will be useful,
|
|
** but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
** GNU General Public License for more details.
|
|
**
|
|
** You should have received a copy of the GNU General Public License
|
|
** along with this program; if not, write to the Free Software
|
|
** Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
**/
|
|
|
|
|
|
class ZSelect extends HTMLElement {
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this._options_map = new Map();
|
|
|
|
this._option_template = "#{label}";
|
|
this._selected_option_template = "#{label}";
|
|
|
|
this._highlighted_index = -1;
|
|
this._preselected_index = -1;
|
|
|
|
this._expanded = false;
|
|
this._list_hovered = false;
|
|
|
|
this._button = document.createElement('button');
|
|
this._input = document.createElement('input');
|
|
this._list = document.createElement('ul');
|
|
|
|
this._events = {};
|
|
|
|
this._is_connected = false;
|
|
}
|
|
|
|
connectedCallback() {
|
|
this._is_connected = true;
|
|
|
|
this.init();
|
|
this.registerEvents();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.unregisterEvents();
|
|
}
|
|
|
|
/**
|
|
* @return {array}
|
|
*/
|
|
static get observedAttributes() {
|
|
return ['name', 'value', 'disabled', 'readonly', 'width'];
|
|
}
|
|
|
|
attributeChangedCallback(name, old_value, new_value) {
|
|
switch (name) {
|
|
case 'name':
|
|
this._input.name = new_value;
|
|
break;
|
|
|
|
case 'value':
|
|
if (!this._is_connected || this._input.value !== new_value) {
|
|
const option = this.getOptionByValue(new_value);
|
|
|
|
this._highlight(option ? option._index : -1);
|
|
this._preselect(this._highlighted_index);
|
|
|
|
if (option && !option.disabled) {
|
|
this._input.value = option.value;
|
|
this.dispatchEvent(new Event('change', {bubbles: true}));
|
|
}
|
|
else {
|
|
this._input.value = null;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'disabled':
|
|
this._button.disabled = (new_value !== null);
|
|
this._input.disabled = (new_value !== null);
|
|
break;
|
|
|
|
case 'readonly':
|
|
this._button.readOnly = (new_value !== null);
|
|
this._input.readOnly = (new_value !== null);
|
|
break;
|
|
|
|
case 'width':
|
|
if (new_value === 'auto') {
|
|
this.style.width = '100%';
|
|
}
|
|
else if (new_value !== null) {
|
|
this.style.width = `${new_value}px`;
|
|
}
|
|
else {
|
|
this.style.width = '';
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
init() {
|
|
this._button.type = 'button';
|
|
this._button.classList.add('focusable');
|
|
this.appendChild(this._button);
|
|
|
|
this._input.type = 'hidden';
|
|
this.appendChild(this._input);
|
|
|
|
this._list.classList.add('list');
|
|
this.appendChild(this._list);
|
|
|
|
if (this.hasAttribute('focusable-element-id')) {
|
|
this._button.id = this.getAttribute('focusable-element-id');
|
|
this.removeAttribute('focusable-element-id');
|
|
}
|
|
|
|
if (this.hasAttribute('option-template')) {
|
|
this._option_template = this.getAttribute('option-template');
|
|
this.removeAttribute('option-template');
|
|
}
|
|
|
|
if (this.hasAttribute('selected-option-template')) {
|
|
this._selected_option_template = this.getAttribute('selected-option-template');
|
|
this.removeAttribute('selected-option-template');
|
|
}
|
|
|
|
if (this.hasAttribute('data-options')) {
|
|
const options = JSON.parse(this.getAttribute('data-options'));
|
|
|
|
for (const option of options) {
|
|
option.options instanceof Array
|
|
? this.addOptionGroup(option)
|
|
: this.addOption(option);
|
|
}
|
|
|
|
this.removeAttribute('data-options');
|
|
}
|
|
|
|
if (!this.hasAttribute('width')) {
|
|
this.setAttribute('width', this._listWidth());
|
|
}
|
|
|
|
this._preselect(this._highlighted_index >= 0 ? this._highlighted_index : this._first(this._highlighted_index));
|
|
this._input.value = this.getValueByIndex(this._preselected_index);
|
|
}
|
|
|
|
getOptions() {
|
|
return [...this._options_map.values()];
|
|
}
|
|
|
|
getOptionByIndex(index) {
|
|
return this.getOptions()[index] || null;
|
|
}
|
|
|
|
getOptionByValue(value) {
|
|
return this._options_map.get(value.toString()) || null;
|
|
}
|
|
|
|
getValueByIndex(index) {
|
|
const option = this.getOptionByIndex(index);
|
|
|
|
return option ? option.value : null;
|
|
}
|
|
|
|
addOption({value, label, extra, class_name, is_disabled}, container, template) {
|
|
value = value.toString();
|
|
|
|
if (this._options_map.has(value)) {
|
|
throw new Error('Duplicate option value: ' + value);
|
|
}
|
|
|
|
const option = {value, label, extra, class_name, is_disabled, template};
|
|
const li = document.createElement('li');
|
|
|
|
li._index = this._options_map.size;
|
|
li.setAttribute('value', value);
|
|
li.innerHTML = new Template(template || this._option_template).evaluate(
|
|
Object.assign({label: label.trim()}, extra || {})
|
|
);
|
|
class_name && li.classList.add(class_name);
|
|
is_disabled && li.setAttribute('disabled', 'disabled');
|
|
|
|
this._options_map.set(value, Object.defineProperties(option, {
|
|
_node: {
|
|
get: () => li
|
|
},
|
|
_index: {
|
|
get: () => li._index
|
|
},
|
|
value: {
|
|
get: () => value
|
|
},
|
|
disabled: {
|
|
get: () => option.is_disabled === true,
|
|
set: (is_disabled) => {
|
|
option.is_disabled = is_disabled;
|
|
li.toggleAttribute('disabled', is_disabled);
|
|
}
|
|
},
|
|
hidden: {
|
|
set: (is_hidden) => {
|
|
option.disabled = is_hidden;
|
|
is_hidden ? li.setAttribute('disabled', 'disabled') : li.removeAttribute('disabled');
|
|
li.style.display = is_hidden ? 'none' : '';
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Should accept both integer and string.
|
|
if ((this.getAttribute('value') || 0) == value) {
|
|
this._highlighted_index = li._index;
|
|
}
|
|
|
|
(container || this._list).appendChild(li);
|
|
}
|
|
|
|
addOptionGroup({label, option_template, options}) {
|
|
const li = document.createElement('li');
|
|
const ul = document.createElement('ul');
|
|
|
|
li.setAttribute('optgroup', label);
|
|
li.appendChild(ul);
|
|
|
|
for (const option of options) {
|
|
this.addOption(option, ul, option_template);
|
|
}
|
|
|
|
this._list.appendChild(li);
|
|
}
|
|
|
|
focus() {
|
|
this._button.focus();
|
|
}
|
|
|
|
get name() {
|
|
return this._input.name;
|
|
}
|
|
|
|
set name(name) {
|
|
this.setAttribute('name', name);
|
|
}
|
|
|
|
get options() {
|
|
return this.getOptions();
|
|
}
|
|
|
|
get value() {
|
|
return !this._is_connected
|
|
? this.getAttribute('value')
|
|
: this._input.value;
|
|
}
|
|
|
|
set value(value) {
|
|
this.setAttribute('value', value);
|
|
}
|
|
|
|
get disabled() {
|
|
return this._input.disabled;
|
|
}
|
|
|
|
set disabled(is_disabled) {
|
|
this.toggleAttribute('disabled', is_disabled);
|
|
}
|
|
|
|
get readOnly() {
|
|
return this._input.readOnly;
|
|
}
|
|
|
|
set readOnly(is_readonly) {
|
|
this.toggleAttribute('readonly', is_readonly);
|
|
}
|
|
|
|
get selectedIndex() {
|
|
return this._preselected_index;
|
|
}
|
|
|
|
set selectedIndex(index) {
|
|
this._change(index);
|
|
}
|
|
|
|
_expand() {
|
|
const {height: button_height, y: button_y, left: button_left} = this._button.getBoundingClientRect();
|
|
const {height: document_height} = document.body.getBoundingClientRect();
|
|
|
|
if (button_y + button_height < 0 && document_height - button_y < 0) {
|
|
return;
|
|
}
|
|
|
|
this._expanded = true;
|
|
this.classList.add('is-expanded');
|
|
|
|
const list_max_height = 362;
|
|
const offset_top = 4;
|
|
const offset_bottom = 38;
|
|
const list_height = Math.min(this._list.scrollHeight, list_max_height);
|
|
const space_below = document_height - button_y - button_height;
|
|
const space_above = button_y;
|
|
|
|
this._list.style.left = `${button_left}px`;
|
|
this._list.style.maxHeight = '';
|
|
|
|
if (space_below - list_height > offset_bottom || space_below > space_above) {
|
|
this._list.classList.remove('fall-upwards');
|
|
this._list.style.top = `${button_y + button_height}px`;
|
|
this._list.style.bottom = '';
|
|
|
|
if (space_below < list_height) {
|
|
this._list.style.maxHeight = `${space_below - offset_bottom}px`;
|
|
}
|
|
}
|
|
else {
|
|
this._list.classList.add('fall-upwards');
|
|
this._list.style.top = '';
|
|
this._list.style.bottom = `${document_height - button_y}px`;
|
|
|
|
if (space_above < list_height) {
|
|
this._list.style.maxHeight = `${space_above - offset_top}px`;
|
|
}
|
|
}
|
|
this._list.style.width = `${this.scrollWidth}px`;
|
|
|
|
this._highlight(this._preselected_index);
|
|
|
|
document.addEventListener('wheel', this._events.document_wheel);
|
|
setTimeout(() => document.querySelector('.wrapper').addEventListener('scroll', this._events.document_wheel));
|
|
}
|
|
|
|
_collapse() {
|
|
this._expanded = false;
|
|
this.classList.remove('is-expanded');
|
|
|
|
document.removeEventListener('wheel', this._events.document_wheel);
|
|
document.querySelector('.wrapper').removeEventListener('scroll', this._events.document_wheel);
|
|
}
|
|
|
|
_highlight(index) {
|
|
const old_option = this.getOptionByIndex(this._highlighted_index);
|
|
const new_option = this.getOptionByIndex(index);
|
|
|
|
if (old_option) {
|
|
old_option._node.classList.remove('hover');
|
|
}
|
|
|
|
if (new_option && !new_option.disabled) {
|
|
new_option._node.classList.add('hover');
|
|
|
|
this._expanded && new_option._node.scrollIntoView({block: 'nearest'});
|
|
this._highlighted_index = index;
|
|
}
|
|
else {
|
|
this._highlighted_index = -1;
|
|
}
|
|
}
|
|
|
|
_preselect(index) {
|
|
const option = this.getOptionByIndex(index);
|
|
|
|
if (option) {
|
|
this._button.innerHTML = new Template(this._selected_option_template).evaluate(
|
|
Object.assign({label: option.label.trim()}, option.extra || {})
|
|
);
|
|
this._input.disabled = this.hasAttribute('disabled');
|
|
}
|
|
else {
|
|
this._button.innerText = '';
|
|
this._input.disabled = true;
|
|
}
|
|
|
|
this._preselected_index = index;
|
|
this._highlighted_index = index;
|
|
}
|
|
|
|
_change(index) {
|
|
const option = this.getOptionByIndex(index);
|
|
|
|
if (option !== null) {
|
|
this.value = option.value;
|
|
}
|
|
}
|
|
|
|
_prev(current_index) {
|
|
const options = this.getOptions();
|
|
|
|
for (let index = current_index - 1; index >= 0; index--) {
|
|
if (!options[index].disabled) {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
return current_index;
|
|
}
|
|
|
|
_next(current_index) {
|
|
const options = this.getOptions();
|
|
|
|
for (let index = current_index + 1; index < this._options_map.size; index++) {
|
|
if (!options[index].disabled) {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
return current_index;
|
|
}
|
|
|
|
_prevPage(current_index) {
|
|
let index = current_index;
|
|
|
|
for (let i = 0; i < (this._expanded ? 14 : 3); i++) {
|
|
index = this._prev(index);
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
_nextPage(current_index) {
|
|
let index = current_index;
|
|
|
|
for (let i = 0; i < (this._expanded ? 14 : 3); i++) {
|
|
index = this._next(index);
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
_first(current_index) {
|
|
const option = this.getOptionByIndex(this._next(-1));
|
|
|
|
return option ? option._index : current_index;
|
|
}
|
|
|
|
_last(current_index) {
|
|
const option = this.getOptionByIndex(this._prev(this._options_map.size));
|
|
|
|
return option ? option._index : current_index;
|
|
}
|
|
|
|
_search(char) {
|
|
const options = this.getOptions();
|
|
const size = this._options_map.size;
|
|
let start = this._highlighted_index - size;
|
|
const end = start + size;
|
|
|
|
while (start++ < end) {
|
|
const index = (start + size) % size;
|
|
const {label, disabled} = options[index];
|
|
|
|
if (!disabled && label[0].toLowerCase() === char.toLowerCase()) {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
_listWidth() {
|
|
const container = document.createElement('div');
|
|
const list = this._list.cloneNode(true);
|
|
|
|
container.classList.add('z-select', 'is-expanded');
|
|
container.style.position = 'fixed';
|
|
container.style.left = '-9999px';
|
|
container.appendChild(list);
|
|
|
|
document.body.appendChild(container);
|
|
|
|
const width = Math.ceil(list.scrollWidth) + 34; // 34 = scrollbar + padding + border width
|
|
|
|
container.remove();
|
|
|
|
return width;
|
|
}
|
|
|
|
_isVisible() {
|
|
const {bottom, top} = this.getBoundingClientRect();
|
|
return !(bottom < 0 || top - Math.max(document.documentElement.clientHeight, window.innerHeight) >= 0);
|
|
}
|
|
|
|
_closestIndex(node) {
|
|
while (node !== null && node._index === undefined) {
|
|
node = node.parentNode.closest('li');
|
|
}
|
|
|
|
return node ? node._index : null;
|
|
}
|
|
|
|
registerEvents() {
|
|
this._events = {
|
|
button_mousedown: (e) => {
|
|
if (this._button.readOnly) {
|
|
return;
|
|
}
|
|
|
|
// Safari fix - event needs to be prevented, else blur event is fired on this button.
|
|
e.preventDefault();
|
|
// Safari fix - a click button on label would not focus button element.
|
|
document.activeElement !== this._button && this._button.focus();
|
|
|
|
if (e.which === 1) {
|
|
if (this._expanded) {
|
|
this._change(this._preselected_index);
|
|
this._collapse();
|
|
}
|
|
else {
|
|
this._expand();
|
|
}
|
|
}
|
|
},
|
|
|
|
button_keydown: (e) => {
|
|
if (this._button.readOnly) {
|
|
return;
|
|
}
|
|
|
|
!this._isVisible() && this.scrollIntoView({block: 'nearest'})
|
|
|
|
if (e.which !== KEY_SPACE && !e.metaKey && !e.ctrlKey && e.key.length === 1) {
|
|
const index = this._search(e.key);
|
|
if (index !== null) {
|
|
this._highlight(index);
|
|
this._preselect(this._highlighted_index);
|
|
!this._expanded && this._change(this._preselected_index);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
switch (e.which) {
|
|
case KEY_ARROW_UP:
|
|
case KEY_ARROW_DOWN:
|
|
case KEY_PAGE_UP:
|
|
case KEY_PAGE_DOWN:
|
|
case KEY_HOME:
|
|
case KEY_END:
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
let new_index, scroll = 0;
|
|
switch (e.which) {
|
|
case KEY_ARROW_UP:
|
|
new_index = this._prev(this._highlighted_index);
|
|
scroll = -48;
|
|
break;
|
|
|
|
case KEY_ARROW_DOWN:
|
|
new_index = this._next(this._highlighted_index);
|
|
scroll = 48;
|
|
break;
|
|
|
|
case KEY_PAGE_UP:
|
|
new_index = this._prevPage(this._highlighted_index);
|
|
break;
|
|
|
|
case KEY_PAGE_DOWN:
|
|
new_index = this._nextPage(this._highlighted_index);
|
|
break;
|
|
|
|
case KEY_HOME:
|
|
new_index = this._first(this._highlighted_index);
|
|
break;
|
|
|
|
case KEY_END:
|
|
new_index = this._last(this._highlighted_index);
|
|
break;
|
|
}
|
|
|
|
if (scroll !== 0 && this._highlighted_index === new_index) {
|
|
this._list.scrollTop += scroll;
|
|
}
|
|
else {
|
|
this._highlight(new_index);
|
|
this._preselect(this._highlighted_index);
|
|
!this._expanded && this._change(this._preselected_index);
|
|
}
|
|
break;
|
|
|
|
case KEY_ENTER:
|
|
if (this._expanded) {
|
|
this._preselect(this._highlighted_index);
|
|
this._change(this._preselected_index);
|
|
this._collapse();
|
|
}
|
|
else {
|
|
this._isVisible() && this._expand();
|
|
}
|
|
break;
|
|
|
|
case KEY_TAB:
|
|
if (this._expanded) {
|
|
e.preventDefault();
|
|
this._preselect(this._highlighted_index);
|
|
this._change(this._preselected_index);
|
|
this._collapse();
|
|
}
|
|
break;
|
|
|
|
case KEY_SPACE:
|
|
!this._expanded && this._isVisible() && this._expand();
|
|
break;
|
|
|
|
case KEY_ESCAPE:
|
|
this._expanded && e.stopPropagation();
|
|
this._change(this._preselected_index);
|
|
this._collapse();
|
|
break;
|
|
}
|
|
},
|
|
|
|
button_click: () => {
|
|
// Safari fix - a click button on label would not focus button element.
|
|
document.activeElement !== this._button && this._button.focus();
|
|
},
|
|
|
|
focus: (e) => {
|
|
this._button.focus();
|
|
},
|
|
|
|
button_blur: () => {
|
|
if (this._button.readOnly) {
|
|
return;
|
|
}
|
|
|
|
this._change(this._preselected_index);
|
|
this._collapse();
|
|
},
|
|
|
|
list_mouseenter: () => {
|
|
this._list_hovered = true;
|
|
},
|
|
|
|
list_mouseleave: () => {
|
|
this._list_hovered = false;
|
|
},
|
|
|
|
list_mousedown: (e) => {
|
|
e.preventDefault();
|
|
},
|
|
|
|
list_mouseup: (e) => {
|
|
const option = this.getOptionByIndex(this._closestIndex(e.target));
|
|
|
|
if (option && !option.disabled) {
|
|
this._change(option._index);
|
|
this._collapse();
|
|
}
|
|
|
|
e.preventDefault();
|
|
},
|
|
|
|
list_mousemove: (e) => {
|
|
const option = this.getOptionByIndex(this._closestIndex(e.target));
|
|
|
|
if (option && this._highlighted_index !== option._index) {
|
|
!option.disabled && this._highlight(option._index);
|
|
}
|
|
},
|
|
|
|
document_wheel: () => {
|
|
if (!this._list_hovered) {
|
|
this._change(this._preselected_index);
|
|
this._collapse();
|
|
}
|
|
},
|
|
|
|
window_resize: () => {
|
|
this._change(this._preselected_index);
|
|
this._collapse();
|
|
}
|
|
}
|
|
|
|
this._button.addEventListener('click', this._events.button_click);
|
|
this._button.addEventListener('mousedown', this._events.button_mousedown);
|
|
this._button.addEventListener('keydown', this._events.button_keydown);
|
|
this._button.addEventListener('blur', this._events.button_blur);
|
|
|
|
this._list.addEventListener('mouseenter', this._events.list_mouseenter);
|
|
this._list.addEventListener('mouseleave', this._events.list_mouseleave);
|
|
this._list.addEventListener('mousedown', this._events.list_mousedown);
|
|
this._list.addEventListener('mouseup', this._events.list_mouseup);
|
|
this._list.addEventListener('mousemove', this._events.list_mousemove);
|
|
|
|
this.addEventListener('focus', this._events.focus);
|
|
|
|
window.addEventListener('resize', this._events.window_resize);
|
|
}
|
|
|
|
unregisterEvents() {
|
|
this._button.removeEventListener('click', this._events.button_click);
|
|
this._button.removeEventListener('mousedown', this._events.button_mousedown);
|
|
this._button.removeEventListener('keydown', this._events.button_keydown);
|
|
this._button.removeEventListener('blur', this._events.button_blur);
|
|
|
|
this._list.removeEventListener('mouseenter', this._events.list_mouseenter);
|
|
this._list.removeEventListener('mouseleave', this._events.list_mouseleave);
|
|
this._list.removeEventListener('mousedown', this._events.list_mousedown);
|
|
this._list.removeEventListener('mouseup', this._events.list_mouseup);
|
|
this._list.removeEventListener('mousemove', this._events.list_mousemove);
|
|
|
|
this.removeEventListener('focus', this._events.focus);
|
|
|
|
window.removeEventListener('resize', this._events.window_resize);
|
|
}
|
|
}
|
|
|
|
customElements.define('z-select', ZSelect);
|