/*
* This file is part of AUX.
*
* AUX 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 3 of the License, or (at your option) any later version.
*
* AUX 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
*/
/* jshint -W014 */
import { defineChildWidget } from './../child_widget.js';
import { Button } from './button.js';
import { Label } from './label.js';
import { setDelayedFocus, createID } from '../utils/dom.js';
import { Timer } from '../utils/timers.js';
import {
element,
addClass,
outerWidth,
width,
height,
scrollLeft,
scrollTop,
setStyles,
outerHeight,
positionTop,
positionLeft,
setStyle,
getDuration,
empty,
removeClass,
} from '../utils/dom.js';
import { typecheckInteger } from '../utils/typecheck.js';
/**
* The <code>useraction</code> event is emitted when a widget gets modified by user interaction.
* The event is emitted for the options <code>selected</code> and <code>value</code>.
*
* @event Select#useraction
*
* @param {string} name - The name of the option which was changed due to the users action
* @param {mixed} value - The new value of the option
*/
function hideList() {
this.__transition = false;
this.__timeout = false;
if (!this.__open) {
this._list.remove();
setDelayedFocus(this.element);
this.element.removeAttribute('aria-expanded');
} else {
document.addEventListener('touchstart', this._globalTouchStart);
document.addEventListener('mousedown', this._globalTouchStart);
}
}
function showList(show) {
const E = this.element;
const O = this.options;
if (show) {
const ew = outerWidth(E, true);
if (O.list_class) this._list.classList.add(O.list_class);
document.body.appendChild(this._list);
const cw = width();
const ch = height();
const sx = scrollLeft();
const sy = scrollTop();
setStyles(this._list, {
opacity: '0',
maxHeight: ch + 'px',
maxWidth: cw + 'px',
minWidth: ew + 'px',
});
const lw = outerWidth(this._list, true);
const lh = outerHeight(this._list, true);
setStyles(this._list, {
top: Math.min(positionTop(E) + outerHeight(E, true), ch + sy - lh) + 'px',
left: Math.min(positionLeft(E), cw + sx - lw) + 'px',
});
E.setAttribute('aria-expanded', 'true');
} else {
document.removeEventListener('touchstart', this._globalTouchStart);
document.removeEventListener('mousedown', this._globalTouchStart);
setDelayedFocus(E);
E.removeAttribute('aria-expanded');
}
setStyle(this._list, 'opacity', show ? '1' : '0');
this.__transition = true;
this.__open = show;
if (this.__timeout !== false) window.clearTimeout(this.__timeout);
const dur = getDuration(this._list);
this.__timeout = window.setTimeout(hideList.bind(this), dur);
if (this.current())
this._list.scrollTop =
this.current().element.offsetTop - this._list.offsetHeight / 2;
}
function setLabelSize() {
if (!this.label || !this.sizer) return;
outerWidth(
this.label.element,
true,
outerWidth(this.sizer.element, true)
);
}
/**
* Select provides a {@link Button} with a select list to choose from
* a list of {@link SelectEntry}.
*
* @class Select
*
* @extends Button
*
* @param {Object} [options={ }] - An object containing initial options.
*
* @property {Integer|Boolean} [options.selected=false] - The index of the selected {@link SelectEntry}.
* Set to `-1` to unselect any already selected entries.
* @property {mixed} [options.value] - The value of the selected entry.
* @property {SelectEntry} [options.selected_entry] - The currently selected
* entry.
* @property {Boolean} [options.auto_size=true] - If `true`, the Select is
* auto-sized to be as wide as the widest {@link SelectEntry}.
* @property {Array<Object>} [options.entries=[]] - The list of {@link SelectEntry}. Each member is an
* object with the two properties <code>label</code> and <code>value</code>, a string used
* as label for constructing a {@link SelectEntry} or an instance of {@link SelectEntry}.
* @property {String|Boolean} [options.placeholder=false] - Placeholder
* for the button label. Set to <code>false</code> to have an empty
* placeholder. This placeholder is shown when no entry is selected.
* @property {String|Boolean} [options.list_class] - A CSS class to be set on the list. This is
* a static option and can only be set once on initializaion.
*
*/
export class Select extends Button {
static get _options() {
return Object.assign({}, Button.getOptionTypes(), {
entries: 'array',
selected: 'int',
selected_entry: 'object',
value: 'mixed',
auto_size: 'boolean',
show_list: 'boolean',
sort: 'function',
resized: 'boolean',
placeholder: 'string|boolean',
list_class: 'string',
typing_delay: 'number',
});
}
static get options() {
return {
entries: [], // A list of strings or objects {label: "Title", value: 1} or SelectEntry instance
selected: -1,
value: void 0,
selected_entry: null,
auto_size: false,
show_list: false,
icon: 'arrowdown',
placeholder: false,
list_class: '',
label: '',
role: 'select',
typing_delay: 250,
};
}
static get static_events() {
return {
click: function () {
this.set('show_list', !this.options.show_list);
},
set_show_list: function (v) {
this.set('icon', v ? 'arrowup' : 'arrowdown');
if (v) {
const entry = this.get('selected_entry') || this.entries[0];
if (entry)
setDelayedFocus(entry.element);
}
},
set_selected_entry: function (entry) {
const entries = this.entries;
entries.forEach(v => v.element.removeAttribute('aria-selected'));
if (entry) {
this.update('selected', entries.indexOf(entry));
this.update('value', entry.get('value'));
this.update('label', entry.get('label'));
this._list.setAttribute('aria-activedescendant', entry.get('id'));
entry.element.setAttribute('aria-selected', 'true')
} else {
this.update('selected', -1);
this.update('value', void 0);
this.update('label', this.get('placeholder'));
this._list.removeAttribute('aria-activedescendant');
}
},
set_selected: function (index) {
if (index === -1) {
this.update('selected_entry', null);
this.update('value', void 0);
} else {
const entries = this.entries;
if (index >= entries.length) return;
// this will set value
this.update('selected_entry', entries[index]);
}
},
set_value: function (value) {
const entries = this.entries;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (value === entry.get('value')) {
this.update('selected_entry', entry);
return;
}
}
},
set_placeholder: function (label) {
const selected_entry = this.get('selected_entry');
if (!selected_entry) this.update('label', label);
},
set_label: function () {
if (this.options.auto_size)
setLabelSize.call(this);
},
};
}
initialize(options) {
this.__open = false;
this.__timeout = -1;
/**
* @member {Array} Select#entries - An array containing all entry objects with members <code>label</code> and <code>value</code>.
*/
this.entries = [];
this._active = null;
super.initialize(options);
/**
* @member {HTMLDivElement} Select#element - The main DIV container.
* Has class <code>.aux-select</code>.
*/
/**
* @member {HTMLListElement} Select#_list - A HTML list for displaying the entry labels.
* Has class <code>.aux-selectlist</code>.
*/
this._list = element('div', 'aux-selectlist');
this._list.setAttribute('role', 'listbox');
this._globalTouchStart = function (e) {
if (
this.__open &&
!this.__transition &&
!this._list.contains(e.target) &&
!this.element.contains(e.target)
) {
this.showList(false);
}
}.bind(this);
const sel = this.options.selected;
const val = this.options.value;
this.set('entries', this.options.entries);
if (sel !== -1) {
this.set('selected', sel);
} else if (val !== void 0) {
this.set('value', val);
} else {
this.set('selected_entry');
}
this.__typing = '';
this._timer = new Timer(() => {
this.__typing = '';
});
}
destroy() {
this.clear();
this._list.remove();
super.destroy();
}
/**
* Show or hide the select list
*
* @method Select#showList
*
* @param {boolean} show - `true` to show and `false` to hide the list
* of {@link SelectEntry}.
*/
showList(s) {
this.set('show_list', !!s);
}
/**
* Select a {@link SelectEntry} by its index.
*
* @method Select#select
*
* @param {Integer} index - The index of the {@link SelectEntry} to select.
*/
select(id) {
if (!Number.isInteger(id) && !id) id = -1;
this.set('selected', id);
}
/**
* Select a {@link SelectEntry} by its value.
*
* @method Select#selectValue
*
* @param {mixed} value - The value of the {@link SelectEntry} to select.
*/
selectValue(value) {
const id = this.indexByValue(value);
this.set('selected', id);
}
/**
* Select a {@link SelectEntry} by its label.
*
* @method Select#selectLabel
*
* @param {mixed} label - The label of the {@link SelectEntry} to select.
*/
selectLabel(label) {
const id = this.indexByLabel(label);
this.set('selected', id);
}
/**
* Replaces the list of {@link SelectEntry} to select from with an entirely new one.
*
* @method Select#setEntries
*
* @param {Array} entries - An array of {@link SelectEntry} to set as the new list to select from.
* Please refer to {@link Select#addEntry} for more details.
*/
setEntries(entries) {
const value = this.get('value');
this.clear();
this.addEntries(entries);
if (value !== void 0) {
const index = this.indexByValue(value);
if (index !== -1) this.select(index);
}
}
/**
* Adds new {@link SelectEntry} to the end of the list to select from.
*
* @method Select#addEntries
*
* @param {Array} entries - An array of {@link SelectEntry} to add to the end of the list
* of {@link SelectEntry} to select from. Please refer to {@link Select#addEntry}
* for more details.
*/
addEntries(entries) {
for (let i = 0; i < entries.length; i++) this.addEntry(entries[i]);
}
/**
* Adds a single {@link SelectEntry} to the end of the list.
*
* @method Select#addEntry
*
* @param {mixed} entry - A string to be displayed and used as the value,
* an object with members <code>label</code> and <code>value</code>
* or an instance of {@link SelectEntry}.
* @param {integer} [position] - The position in the list to add the new
* entry at. If omitted, the entry is added at the end.
*
* @emits Select.entryadded
*/
addEntry(ent, position) {
let entry;
if (position !== void 0) {
typecheckInteger(position);
if (position < 0 || position > this.entries.length)
throw new TypeError('Index out of bounds.');
}
if (typeof ent === 'object' && ent instanceof SelectEntry) {
entry = ent;
} else if (typeof ent === 'string') {
entry = new SelectEntry({
value: ent,
label: ent,
});
} else if (typeof ent === 'object' && 'value' in ent && 'label' in ent) {
ent.element = null;
entry = new SelectEntry(ent);
} else {
throw new TypeError('Unsupported type of entry.');
}
if (position !== void 0) {
this.entries.splice(position, 0, entry);
}
this.addChild(entry);
return entry;
}
addChild(child) {
super.addChild(child);
if (!(child instanceof SelectEntry)) return;
const O = this.options;
const entries = this.entries;
const entry = child;
if (!entries.includes(entry)) entries.push(entry);
if (O.sort) entries.sort(O.sort);
const index = entries.indexOf(entry);
// invalidate entries.
this.invalid.entries = true;
const selected = this.options.selected;
// adjust selected
if (selected !== -1 && selected >= index) {
this.set('selected', selected + 1);
}
this.triggerDraw();
/**
* Is fired when a new {@link SelectEntry} is added to the list.
*
* @event Select#entryadded
*
* @param {SelectEntry} entry - A new {@link SelectEntry}.
*/
this.emit('entryadded', entry);
}
/**
* Remove a {@link SelectEntry} from the list by its index.
*
* @method Select#removeIndex
*
* @param {Integer} index - The index of the {@link SelectEntry} to be removed from the list.
*
* @emits Select#entryremoved
*/
removeIndex(index) {
const entry = this.entries[index];
if (!entry) {
throw new Error('Index does not exist.');
}
this.removeChild(entry);
}
/**
* Remove a {@link SelectEntry} from the list by its value.
*
* @method Select#removeValue
*
* @param {mixed} value - The value of the {@link SelectEntry} to be removed from the list.
*
* @emits Select#entryremoved
*/
removeValue(val) {
this.removeIndex(this.indexByValue(val));
}
/**
* Remove an entry from the list by its label.
*
* @method Select#removeLabel
*
* @param {string} label - The label of the entry to be removed from the list.
*
* @emits Select#entryremoved
*/
removeLabel(label) {
this.removeIndex(this.indexByLabel(label));
}
/**
* Remove an entry from the list.
*
* @method Select#removeEntry
*
* @param {SelectEntry} entry - The {@link SelectEntry} to be removed from the list.
*
* @emits Select#entryremoved
*/
removeEntry(entry) {
this.removeChild(entry);
}
removeEntries(a) {
a.forEach((entry) => {
this.removeEntry(entry);
});
}
_removeEntry(entry) {
const entries = this.entries;
const index = entries.indexOf(entry);
if (index === -1) throw new Error('Unknown entry.');
const selected = this.get('selected');
this.entries = entries.filter((_entry) => _entry !== entry);
if (selected === index) {
// unselect current entry
this.set('selected_entry', null);
} else if (selected > index) {
this.set('selected', selected - 1);
}
const li = entry.element;
// remove from DOM
if (li.parentElement == this._list) li.remove();
this.invalid.entries = true;
this.triggerDraw();
/**
* Is fired when an entry was removed from the list.
*
* @event Select.entryremoved
*
* @param {SelectEntry} entry - The removed select entry.
*/
this.emit('entryremoved', entry);
}
removeChild(child) {
super.removeChild(child);
if (child instanceof SelectEntry) {
this._removeEntry(child);
}
}
/**
* Get the index of a {@link SelectEntry} by its value.
*
* @method Select#indexByValue
*
* @param {Mixed} value - The value of the {@link SelectEntry}.
*
* @returns {Integer|Boolean} The index of the entry or `-1`.
*/
indexByValue(val) {
const entries = this.entries;
for (let i = 0; i < entries.length; i++) {
if (entries[i].options.value === val) return i;
}
return -1;
}
/**
* Get the index of a {@link SelectEntry} by its label/label.
*
* @method Select#indexByLabel
*
* @param {String} label - The label/label of the {@link SelectEntry}.
*
* @returns {Integer} The index of the entry or `-1`.
*/
indexByLabel(label) {
const entries = this.entries;
for (let i = 0; i < entries.length; i++) {
if (entries[i].options.label === label) return i;
}
return -1;
}
/**
* Get the index of a {@link SelectEntry} by the {@link SelectEntry} itself.
*
* @method Select#indexByEntry
*
* @param {SelectEntry} entry - The {@link SelectEntry}.
*
* @returns {Integer|Boolean} The index of the entry or `-1`.
*/
indexByEntry(entry) {
return this.entries.indexOf(entry);
}
/**
* Get the index of a {@link SelectEntry} by its HTMLElement.
*
* @method Select#indexByElement
*
* @param {HTMLElement} element - The element of the {@link SelectEntry}.
*
* @returns {Integer} The index of the entry or `-1`.
*/
indexByElement(element) {
const entries = this.entries;
for (let i = 0; i < entries.length; i++) {
if (entries[i].element === element) return i;
}
return -1;
}
/**
* Get a {@link SelectEntry} by its value.
*
* @method Select#entryByValue
*
* @param {Mixed} value - The value of the {@link SelectEntry}.
*
* @returns {SelectEntry|False} The {@link SelectEntry} or `null`.
*/
entryByValue(val) {
const entries = this.entries;
for (let i = 0; i < entries.length; i++) {
if (entries[i].options.value === val) return entries[i];
}
return null;
}
/**
* Get a {@link SelectEntry} by its label/label.
*
* @method Select#entryByLabel
*
* @param {String} label - The label of the {@link SelectEntry}.
*
* @returns {SelectEntry|Boolean} The {@link SelectEntry} or `null`.
*/
entryByLabel(label) {
const entries = this.entries;
for (let i = 0; i < entries.length; i++) {
if (entries[i].options.label === label) return entries[i];
}
return null;
}
/**
* Get the next {@link SelectEntry} whose label starts with the given string.
*
* @method Select#nextEntryByPartialLabel
*
* @param {String} label - The label of the {@link SelectEntry}.
* @param {Number} current - the current index to start searching.
*
* @returns {SelectEntry|Boolean} The {@link SelectEntry} or `null`.
*/
nextEntryByPartialLabel(label, current) {
const entries = this.entries;
current = current || -1;
current = Math.max(0, Math.min(this.entries.length - 1, current + 1));
for (let i = current; i < entries.length; i++) {
if (entries[i].options.label.toLowerCase().startsWith(label))
return entries[i];
}
return null;
}
/**
* Get a {@link SelectEntry} by its index.
*
* @method Select#entryByIndex
*
* @param {Integer} index - The index of the {@link SelectEntry}.
*
* @returns {SelectEntry|Boolean} The {@link SelectEntry} or `null`.
*/
entryByIndex(index) {
const entries = this.entries;
if (index >= 0 && index < entries.length && entries[index])
return entries[index];
return null;
}
/**
* Get a value by its {@link SelectEntry} index.
*
* @method Select#valueByIndex
*
* @param {Integer} index - The index of the {@link SelectEntry}.
*
* @returns {Mixed|Boolean} The value of the {@link SelectEntry} or `undefined`.
*/
valueByIndex(index) {
const entries = this.entries;
if (index >= 0 && index < entries.length && entries[index]) {
return entries[index].options.value;
}
return void 0;
}
/**
* Get the value of a {@link SelectEntry}.
*
* @method Select#valueByEntry
*
* @param {SelectEntry} entry - The {@link SelectEntry}.
*
* @returns {mixed} The value of the {@link SelectEntry}.
*/
valueByEntry(entry) {
return entry.options.value;
}
/**
* Get the value of a {@link SelectEntry} by its label/label.
*
* @method Select#valueByLabel
*
* @param {String} label - The label of the {@link SelectEntry}.
*
* @returns {Mixed|Boolean} The value of the {@link SelectEntry} or `undefined`.
*/
valueByLabel(label) {
const entries = this.entries;
for (let i = 0; i < entries.length; i++) {
if (entries[i].options.label === label) return entries[i].options.value;
}
return void 0;
}
/**
* Remove all {@link SelectEntry} from the list.
*
* @method Select#clear
*
* @emits Select#cleared
*/
clear() {
empty(this._list);
this.entries.forEach((entry) => {
this.removeChild(entry);
});
/**
* Is fired when the list is cleared.
*
* @event Select.cleared
*/
this.emit('cleared');
}
draw(O, element) {
addClass(element, 'aux-select');
this.element.setAttribute('aria-haspopup', 'listbox');
super.draw(O, element);
}
resize() {
super.resize();
this.invalidate('auto_size');
}
redraw() {
super.redraw();
const I = this.invalid;
const O = this.options;
if (I.entries || I.auto_size) {
if (O.auto_size) {
I.show_list = true;
I.auto_size = false;
const S = this.sizer.element;
const v = O.entries;
empty(S);
const frag = document.createDocumentFragment();
for (let i = 0, m = v.length; i < m; ++i) {
const s = element('span', { class: 'aux-sizerentry' });
s.textContent = typeof v[i] == 'string' ? v[i] : v[i].label;
frag.appendChild(s);
}
S.appendChild(frag);
setLabelSize.call(this);
} else if (this.label) {
this.label.element.style.width = null;
}
}
if (I.entries) {
I.entries = false;
const _list = this._list;
const entries = this.entries;
for (let i = 0; i < entries.length; i++) {
_list.appendChild(entries[i].element);
}
}
if (I.selected || I.value) {
I.selected = I.value = false;
if (this._active) {
removeClass(this._active, 'aux-active');
}
const entry = this.entries[O.selected];
if (entry) {
this._active = entry.element;
addClass(entry.element, 'aux-active');
} else {
this._active = null;
}
}
if (I.validate('show_list', 'resized')) {
showList.call(this, O.show_list);
}
}
/**
* Get the currently selected {@link SelectEntry}.
*
* @method Select#current
*
* @returns {SelectEntry|Boolean} The currently selected {@link SelectEntry} or `null`.
*/
current() {
return this.get('selected_entry');
}
/**
* Get the currently selected {@link SelectEntry}'s index. Just for the sake of completeness, this
* function abstracts `options.selected`.
*
* @method Select#currentIndex
*
* @returns {Integer|Boolean} The index of the currently selected {@link SelectEntry} or `-1`.
*/
currentIndex() {
return this.get('selected');
}
/**
* Get the currently selected {@link SelectEntry}'s value.
*
* @method Select#currentValue
*
* @returns {Mixed|Boolean} The value of the currently selected {@link SelectEntry} or `undefined`.
*/
currentValue() {
const entry = this.current();
return entry ? entry.get('value') : void 0;
}
focusWhileTyping(key) {
this._timer.restart(this.options.typing_delay);
this.__typing += key;
let entry;
if (this.__typing.length > 1)
entry = this.nextEntryByPartialLabel(this.__typing.toLowerCase());
else
entry = this.nextEntryByPartialLabel(
this.__typing.toLowerCase(),
this.indexByElement(document.activeElement),
);
if (entry)
entry.element.focus();
}
set(key, value) {
if (key === 'selected') {
typecheckInteger(value);
if (value < -1) throw new TypeError('expected Integer >= -1.');
}
value = super.set(key, value);
switch (key) {
case 'entries':
this.setEntries(value);
break;
}
return value;
}
}
function onSelect(e) {
const w = this.parent;
const id = w.indexByEntry(this);
const entry = this;
e.stopPropagation();
e.preventDefault();
if (w.userset('selected', id) === false) return false;
w.userset('value', this.options.value);
/**
* Is fired when a selection was made by the user. The arguments
* are the value of the currently selected {@link SelectEntry}, its index, its label and the {@link SelectEntry} instance.
*
* @event Select#select
*
* @param {mixed} value - The value of the selected entry.
* @param {number} value - The ID of the selected entry.
* @param {string} value - The label of the selected entry.
*/
w.emit('select', entry.options.value, id, entry.options.label);
w.showList(false);
return false;
}
function onFocusMove(O) {
const {direction, speed} = O;
const parent = this.parent;
const last = parent.entries.length - 1;
let i;
if (speed === 'full') {
if (direction === 'up' || direction === 'right')
i = last;
else
i = 0;
} else {
i = parent.indexByEntry(this);
if (direction === 'up' || direction === 'right')
i -= 1;
else
i += 1;
i = Math.max(0, Math.min(i, last));
}
setDelayedFocus(parent.entries[i].element);
}
function onKeyDown(e) {
if (e.code === 'Tab')
this.parent.set('show_list', false);
if (e.code === 'Escape')
this.parent.set('show_list', false);
if (e.key.length === 1)
this.parent.focusWhileTyping(e.key);
if (e.code === 'Enter' || e.code === 'Space')
this.element.click();
}
/**
* SelectEntry provides a {@link Label} as an entry for {@link Select}.
*
* @class SelectEntry
*
* @extends Label
*
* @param {Object} [options={ }] - An object containing initial options.
*
* @property {String} [options.label=""] - The label of the entry. Kept for backward compatibility, deprecated, use label instead.
* @property {mixed} [options.value] - The value of the selected entry.
*
*/
export class SelectEntry extends Label {
static get _options() {
return Object.assign({}, Label.getOptionTypes(), {
value: 'mixed',
});
}
static get options() {
return {
value: null,
role: 'option',
tabindex: 0,
};
}
initialize(options) {
if (!options.element) options.element = element('div');
super.initialize(options);
addClass(this.element, 'aux-selectentry');
this.set('id', createID('aux-select-entry-'));
}
static get static_events() {
return {
click: onSelect,
focus_move: onFocusMove,
keydown: onKeyDown,
};
}
}
/**
* @member {Select} Select#sizer - A blind element for `auto_size`.
*/
defineChildWidget(Select, 'sizer', {
create: Label,
option: 'auto_size',
toggle_class: true,
default_options: {
class: 'aux-sizer',
},
});