@taufik-nurrohman/option-picker
Version:
Accessible custom `<select>` (and `<input list>`) element.
1,427 lines (1,356 loc) • 76.3 kB
JavaScript
import {/* focusTo, */insertAtSelection, selectTo, selectToNone} from '@taufik-nurrohman/selection';
import {R, W, getAria, getAttributes, getChildFirst, getChildLast, getChildren, getDatum, getElement, getElementIndex, getHTML, getID, getName, getNext, getParent, getParentForm, getPrev, getRole, getState, getStyle, getText, getValue, hasState, isDisabled, isReadOnly, isRequired, letAria, letAttribute, letClass, letDatum, letElement, letID, letStyle, setAria, setAttribute, setChildLast, setClass, setDatum, setElement, setHTML, setID, setNext, setStyle, setStyles, setText, setValue} from '@taufik-nurrohman/document';
import {debounce, delay} from '@taufik-nurrohman/tick';
import {forEachArray, forEachMap, forEachObject, forEachSet, getPrototype, getReference, getValueInMap, hasKeyInMap, letReference, letValueInMap, onAnimationsEnd, setObjectAttributes, setObjectMethods, setReference, setValueInMap, toValuesFromMap, toValueFirstFromMap} from '@taufik-nurrohman/f';
import {fromStates, fromValue} from '@taufik-nurrohman/from';
import {getRect, getScroll, setScroll} from '@taufik-nurrohman/rect';
import {hasValue} from '@taufik-nurrohman/has';
import {hook} from '@taufik-nurrohman/hook';
import {isArray, isBoolean, isFloat, isFunction, isInstance, isInteger, isObject, isSet, isString} from '@taufik-nurrohman/is';
import {offEvent, offEventDefault, offEventPropagation, onEvent} from '@taufik-nurrohman/event';
import {toCaseLower, toCount, toMapCount, toSetCount, toValue} from '@taufik-nurrohman/to';
import {toPattern} from '@taufik-nurrohman/pattern';
const EVENT_DOWN = 'down';
const EVENT_MOVE = 'move';
const EVENT_UP = 'up';
const EVENT_BLUR = 'blur';
const EVENT_CUT = 'cut';
const EVENT_FOCUS = 'focus';
const EVENT_INPUT = 'input';
const EVENT_INVALID = 'invalid';
const EVENT_KEY = 'key';
const EVENT_KEY_DOWN = EVENT_KEY + EVENT_DOWN;
const EVENT_MOUSE = 'mouse';
const EVENT_MOUSE_DOWN = EVENT_MOUSE + EVENT_DOWN;
const EVENT_MOUSE_MOVE = EVENT_MOUSE + EVENT_MOVE;
const EVENT_MOUSE_UP = EVENT_MOUSE + EVENT_UP;
const EVENT_PASTE = 'paste';
const EVENT_RESET = 'reset';
const EVENT_RESIZE = 'resize';
const EVENT_SCROLL = 'scroll';
const EVENT_SUBMIT = 'submit';
const EVENT_TOUCH = 'touch';
const EVENT_TOUCH_END = EVENT_TOUCH + 'end';
const EVENT_TOUCH_MOVE = EVENT_TOUCH + EVENT_MOVE;
const EVENT_TOUCH_START = EVENT_TOUCH + 'start';
const EVENT_WHEEL = 'wheel';
const KEY_DOWN = 'Down';
const KEY_LEFT = 'Left';
const KEY_RIGHT = 'Right';
const KEY_UP = 'Up';
const KEY_ARROW = 'Arrow';
const KEY_ARROW_DOWN = KEY_ARROW + KEY_DOWN;
const KEY_ARROW_LEFT = KEY_ARROW + KEY_LEFT;
const KEY_ARROW_RIGHT = KEY_ARROW + KEY_RIGHT;
const KEY_ARROW_UP = KEY_ARROW + KEY_UP;
const KEY_BEGIN = 'Home';
const KEY_DELETE_LEFT = 'Backspace';
const KEY_DELETE_RIGHT = 'Delete';
const KEY_END = 'End';
const KEY_ENTER = 'Enter';
const KEY_ESCAPE = 'Escape';
const KEY_PAGE = 'Page';
const KEY_PAGE_DOWN = KEY_PAGE + KEY_DOWN;
const KEY_PAGE_UP = KEY_PAGE + KEY_UP;
const KEY_TAB = 'Tab';
const OPTION_SELF = 0;
const OPTION_TEXT = 1;
const TOKEN_CONTENTEDITABLE = 'contenteditable';
const TOKEN_DISABLED = 'disabled';
const TOKEN_FALSE = 'false';
const TOKEN_GROUP = 'group';
const TOKEN_INVALID = 'invalid';
const TOKEN_OPTGROUP = 'opt' + TOKEN_GROUP;
const TOKEN_READONLY = 'readonly';
const TOKEN_READ_ONLY = 'readOnly';
const TOKEN_REQUIRED = 'required';
const TOKEN_SELECTED = 'selected';
const TOKEN_TABINDEX = 'tabindex';
const TOKEN_TAB_INDEX = 'tabIndex';
const TOKEN_TEXT = 'text';
const TOKEN_TRUE = 'true';
const TOKEN_VALUE = 'value';
const TOKEN_VALUES = TOKEN_VALUE + 's';
const TOKEN_VISIBILITY = 'visibility';
const VALUE_SELF = 0;
const VALUE_TEXT = 1;
const VALUE_X = 2;
const filter = debounce(($, input, _options, selectOnly) => {
let query = isString(input) ? input : getText(input) || "",
q = toCaseLower(query),
{_mask, mask, self, state} = $,
{options} = _mask,
{pattern} = self,
{strict} = state, option;
let count = _options.count();
if (selectOnly) {
forEachMap(_options, v => {
let text = toCaseLower(getText(v[2]) + '\t' + getOptionValue(v[2]));
if ("" !== q && q === text.slice(0, toCount(q)) && !getAria(v[2], TOKEN_DISABLED)) {
selectToOption(v[2], $);
return 0;
}
--count;
});
} else {
forEachMap(_options, v => {
let text = toCaseLower(getText(v[2]) + '\t' + getOptionValue(v[2]));
if ("" === q || hasValue(q, text)) {
v[2].hidden = false;
} else {
v[2].hidden = true;
--count;
}
});
options.hidden = !count;
selectToOptionsNone($);
if (strict) {
// Silently select the first option without affecting the currently typed query and focus/select state
if (count && "" !== q && (option = goToOptionFirst($))) {
letAria(mask, TOKEN_INVALID);
setAria(option, TOKEN_SELECTED, true);
option.$[OPTION_SELF][TOKEN_SELECTED] = true;
setValue(self, getOptionValue(option));
} else {
// No other option(s) are available to query
if ("" !== q) {
setAria(mask, TOKEN_INVALID, true);
} else {
letAria(mask, TOKEN_INVALID);
}
setValue(self, "");
}
} else {
letAria(mask, TOKEN_INVALID);
setValue(self, query);
if (pattern) {
if (!count && "" !== q && !toPattern('^' + pattern + '$', "").test(query)) {
setAria(mask, TOKEN_INVALID, true);
}
}
}
}
$.fire('search', [query = "" !== query ? query : null]);
let call = state.options;
// Only fetch when no other option(s) are available to query, or when the current search query is empty
if ((0 === count || "" === q) && isFunction(call)) {
setAria(mask, 'busy', true);
call = call.call($, query);
if (isInstance(call, Promise)) {
call.then(v => {
createOptions($, v);
letAria(mask, 'busy');
$.fire('load', [query, $[TOKEN_VALUES]])[goToOptionFirst($) ? 'enter' : 'exit']().fit();
});
} else {
createOptions($, call);
}
}
})[0];
const [letError, letErrorAbort] = delay(function (picker) {
letAria(picker.mask, TOKEN_INVALID);
});
const setError = function (picker) {
let {mask, state} = picker,
{time} = state,
{error} = time;
if (isInteger(error) && error > 0) {
setAria(mask, TOKEN_INVALID, true);
}
};
const [toggleHint] = delay(function (picker) {
let {_mask} = picker,
{input} = _mask;
toggleHintByValue(picker, getText(input, 0));
});
const toggleHintByValue = function (picker, value) {
let {_mask} = picker,
{hint} = _mask;
value ? setStyle(hint, TOKEN_VISIBILITY, 'hidden') : letStyle(hint, TOKEN_VISIBILITY);
};
const name = 'OptionPicker';
function createOptions($, options) {
const map = isInstance(options, Map) ? options : new Map;
if (isArray(options)) {
forEachArray(options, option => {
if (isArray(option)) {
option[0] = option[0] ?? "";
option[1] = option[1] ?? {};
setValueInMap(toValue(option[1][TOKEN_VALUE] ?? option[0]), option, map);
} else {
setValueInMap(toValue(option), [option, {}], map);
}
});
} else if (isObject(options, 0)) {
forEachObject(options, (v, k) => {
if (isArray(v)) {
options[k][0] = v[0] ?? "";
options[k][1] = v[1] ?? {};
setValueInMap(toValue(v[1][TOKEN_VALUE] ?? k), v, map);
} else {
setValueInMap(toValue(k), [v, {}], map);
}
});
}
let {_options, self, state} = $,
{n} = state,
r = [], value = getValue(self);
n += '__option';
// Reset the option(s) data, but leave the typed query in place, and do not fire the `let.options` hook
_options.let(null, 0, 0);
forEachMap(map, (v, k) => {
if (isArray(v) && v[1] && (!getState(v[1], 'active') || v[1].active) && v[1].mark) {
r.push(v[1][TOKEN_VALUE] ?? k);
}
// Set the option data, but do not fire the `set.option` hook
_options.set(toValue(isArray(v) && v[1] ? (v[1][TOKEN_VALUE] ?? k) : k), v, 0);
});
if (!isFunction(state.options)) {
state.options = map;
}
if (0 === toCount(r)) {
// If there is no selected option(s), get it from the current value
if (hasKeyInMap(toValue(value), map)) {
return [value];
}
// Or get it from the first option
if (value = getOptionSelected($)) {
return [getOptionValue(value)];
}
}
return r;
}
function focusTo(node) {
return node.focus(), node;
}
function focusToOption(option, picker) {
if (option) {
return focusTo(option), option;
}
}
function focusToOptionFirst(picker, k) {
let option;
if (option = goToOptionFirst(picker, k)) {
return focusToOption(option, picker);
}
}
function focusToOptionLast(picker) {
return focusToOptionFirst(picker, 'Last');
}
function getOptionNext(option) {
let optionNext = getNext(option), optionParent;
// Skip disabled and hidden option(s)…
while (optionNext && (getAria(optionNext, TOKEN_DISABLED) || optionNext.hidden)) {
optionNext = getNext(optionNext);
}
if (optionNext) {
// Next option is a group?
if (TOKEN_GROUP === getRole(optionNext)) {
optionNext = getChildFirst(optionNext);
}
// Is the last option?
} else {
// Is in a group?
if ((optionParent = getParent(option)) && TOKEN_GROUP === getRole(optionParent)) {
optionNext = getNext(optionParent);
}
// Next option is a group?
if (optionNext && TOKEN_GROUP === getRole(optionNext)) {
optionNext = getChildFirst(optionNext);
}
}
// Skip disabled and hidden option(s)…
while (optionNext && (getAria(optionNext, TOKEN_DISABLED) || optionNext.hidden)) {
optionNext = getNext(optionNext);
}
return optionNext;
}
function getOptionPrev(option) {
let optionParent, optionPrev = getPrev(option);
// Skip disabled and hidden option(s)…
while (optionPrev && (getAria(optionPrev, TOKEN_DISABLED) || optionPrev.hidden)) {
optionPrev = getPrev(optionPrev);
}
if (optionPrev) {
// Previous option is a group?
if (TOKEN_GROUP === getRole(optionPrev)) {
optionPrev = getChildLast(optionPrev);
}
// Is the first option?
} else {
// Is in a group?
if ((optionParent = getParent(option)) && TOKEN_GROUP === getRole(optionParent)) {
optionPrev = getPrev(optionParent);
}
// Previous option is a group?
if (optionPrev && TOKEN_GROUP === getRole(optionPrev)) {
optionPrev = getChildLast(optionPrev);
}
}
// Skip disabled and hidden option(s)…
while (optionPrev && (getAria(optionPrev, TOKEN_DISABLED) || optionPrev.hidden)) {
optionPrev = getPrev(optionPrev);
}
return optionPrev;
}
function getOptionSelected($, strict) {
let {_options, self} = $, selected;
forEachMap(_options, (v, k) => {
if (isArray(v) && v[2] && !getAria(v[2], TOKEN_DISABLED) && getAria(v[2], TOKEN_SELECTED)) {
return (selected = v[2]), 0;
}
});
if (!isSet(selected) && (strict || !isInput(self))) {
// Select the first option
forEachMap(_options, (v, k) => {
return (selected = v[2]), 0;
});
}
return selected;
}
function getOptionValue(option, parseValue) {
return getValue(option, parseValue);
}
function getOptions(self) {
const map = new Map;
let item, items, itemsParent, selected = [],
value = getValue(self);
if (isInput(self)) {
items = (itemsParent = self.list) ? getChildren(itemsParent) : [];
} else {
items = getChildren(itemsParent = self);
}
forEachArray(items, (v, k) => {
let attributes = getAttributes(v);
attributes.active = true;
attributes.mark = false;
if (hasState(attributes, TOKEN_DISABLED)) {
attributes.active = "" === attributes[TOKEN_DISABLED] ? false : !!attributes[TOKEN_DISABLED];
delete attributes[TOKEN_DISABLED];
} else if (hasState(attributes, TOKEN_SELECTED)) {
attributes.mark = "" === attributes[TOKEN_SELECTED] ? true : !!attributes[TOKEN_SELECTED];
delete attributes[TOKEN_SELECTED];
}
if (TOKEN_OPTGROUP === getName(v)) {
forEachMap(getOptions(v), (vv, kk) => {
vv[1]['&'] = v.label;
setValueInMap(toValue(kk), vv, map);
});
} else {
setValueInMap(toValue(v[TOKEN_VALUE]), [getText(v) || v[TOKEN_VALUE], attributes, null, v], map);
}
});
// If there is no selected option(s), get it from the current value
if (0 === toCount(selected) && (item = getValueInMap(value = toValue(value), map))) {
item[1].mark = true;
setValueInMap(value, item, map);
}
return map;
}
function getOptionsValues(options, parseValue) {
return options.map(v => getOptionValue(v, parseValue));
}
function getOptionsSelected($) {
let {_options} = $, selected = [];
return forEachMap(_options, (v, k) => {
if (isArray(v) && v[2] && !getAria(v[2], TOKEN_DISABLED) && getAria(v[2], TOKEN_SELECTED)) {
selected.push(v[2]);
}
}), selected;
}
function goToOptionFirst(picker, k) {
let {_options} = picker, option;
if (option = toValuesFromMap(_options)['find' + (k || "")](v => !getAria(v[2], TOKEN_DISABLED) && !v[2].hidden)) {
return option[2];
}
}
function goToOptionLast(picker) {
return goToOptionFirst(picker, 'Last');
}
function isInput(self) {
return 'input' === getName(self);
}
function onBlurTextInput() {
let $ = this,
picker = getReference($),
{_mask, mask, state} = picker,
{options} = _mask,
{strict, time} = state,
{error} = time, option;
onEvent(EVENT_MOUSE_DOWN, mask, onPointerDownMask);
onEvent(EVENT_TOUCH_START, mask, onPointerDownMask);
if (strict) {
if (!options.hidden && (option = getOptionSelected(picker, 1))) {
selectToOption(option, picker);
} else {
letError(isInteger(error) && error > 0 ? error : 0, picker);
options.hidden = false;
selectToOptionsNone(picker, 1);
}
}
}
function onCutTextInput() {
let $ = this,
picker = getReference($),
{self, state} = picker,
{strict} = state;
delay(() => {
if (!strict) {
setValue(self, getText($));
}
})[0](1);
toggleHint(1, picker);
}
function onFocusOption() {
selectToNone();
}
// Focus on the “visually hidden” self will move its focus to the mask, maintains the natural flow of the tab(s)!
function onFocusSelf() {
focusTo(getReference(this));
}
function onFocusTextInput() {
letErrorAbort();
let $ = this,
picker = getReference($),
{mask, options} = picker;
if (options.open) {
offEvent(EVENT_MOUSE_DOWN, mask, onPointerDownMask);
offEvent(EVENT_TOUCH_START, mask, onPointerDownMask);
return;
}
getText($, 0) ? selectTo($) : picker.enter().fit();
}
function onInvalidSelf(e) {
e && offEventDefault(e);
let $ = this,
picker = getReference($),
{state} = picker,
{time} = state,
{error} = time;
letError(isInteger(error) && error > 0 ? error : 0, picker), setError(picker);
}
let searchQuery = "";
function onInputTextInput(e) {
let $ = this,
{inputType} = e,
picker = getReference($),
{_active, _fix} = picker;
if (!_active || _fix) {
return offEventDefault(e);
}
if ('deleteContent' === inputType.slice(0, 13) && !getText($, 0)) {
toggleHintByValue(picker, 0);
} else if ('insertText' === inputType) {
toggleHintByValue(picker, 1);
}
}
function onKeyDownArrow(e) {
let $ = this,
picker = getReference($),
{options} = picker,
key = e.key, exit;
if (KEY_ENTER === key || ' ' === key) {
picker[options.open ? 'exit' : 'enter'](!(exit = true)).fit();
} else if (KEY_ESCAPE === key) {
picker.exit(exit = true);
} else if (KEY_ARROW_DOWN === key || KEY_ARROW_UP === key || KEY_TAB === key) {
picker.enter(exit = true);
}
exit && offEventDefault(e);
}
function onKeyDownTextInput(e) {
let $ = this, exit,
key = e.key,
keyIsCtrl = e.ctrlKey,
picker = getReference($),
{_active, _fix} = picker;
if (!_active || _fix) {
return;
}
let {_options, mask, self, state} = picker,
{strict, time} = state,
{search} = time;
if (KEY_DELETE_LEFT === key || KEY_DELETE_RIGHT === key || 1 === toCount(key) && !keyIsCtrl) {
picker.enter().fit();
searchQuery = 0; // This will make a difference and force the filter to execute
}
if (KEY_ARROW_DOWN === key || KEY_ARROW_UP === key || KEY_ENTER === key) {
let currentOption = _options.at(getValue(self));
currentOption = currentOption ? currentOption[2] : 0;
if (!currentOption || currentOption.hidden) {
currentOption = toValueFirstFromMap(_options);
currentOption = currentOption ? currentOption[2] : 0;
while (currentOption && (getAria(currentOption, TOKEN_DISABLED) || currentOption.hidden)) {
currentOption = getNext(currentOption);
}
}
exit = true;
if (!getAria(mask, 'expanded')) {
picker.enter(false).fit();
currentOption && focusTo(currentOption);
} else if (strict && KEY_ENTER === key) {
// Automatically select the first option!
selectToOptionFirst(picker) && picker.exit(exit);
} else {
currentOption && focusTo(currentOption);
}
} else if (KEY_TAB === key) {
selectToNone(), picker.exit();
} else {
delay(() => {
// Only execute the filter if the previous search query is different from the current search query
if ("" === searchQuery || searchQuery !== getText($) + "") {
filter(search[0], picker, $, _options);
searchQuery = getText($) + "";
}
})[0](1);
}
exit && offEventDefault(e);
}
let searchTerm = "",
searchTermClear = debounce(() => searchTerm = "")[0];
function onKeyDownOption(e) {
let $ = this, exit,
key = e.key,
keyIsAlt = e.altKey,
keyIsCtrl = e.ctrlKey,
keyIsShift = e.shiftKey,
picker = getReference($),
{_mask, max, self} = picker,
{value} = _mask,
optionNext, optionParent, optionPrev, valueCurrent;
if (KEY_DELETE_LEFT === key || KEY_DELETE_RIGHT === key) {
exit = true;
if (value && (valueCurrent = getElement('[value="' + (getOptionValue($) + "").replace(/"/g, '\\"') + '"]', getParent(value)))) {
focusTo(valueCurrent);
} else {
picker.exit(exit);
}
} else if (KEY_ENTER === key || KEY_ESCAPE === key || KEY_TAB === key || ' ' === key) {
if (max > 1) {
if (KEY_ESCAPE === key) {
picker.exit(exit = true);
} else if (KEY_TAB === key) {
picker.exit(exit = false);
} else {
exit = true;
toggleToOption($, picker);
}
} else {
if (KEY_ESCAPE !== key) {
selectToOption($, picker);
}
picker.exit(exit = KEY_TAB !== key);
}
} else if (KEY_ARROW_DOWN === key || KEY_PAGE_DOWN === key) {
exit = true;
if (KEY_PAGE_DOWN === key && TOKEN_GROUP === getRole(optionParent = getParent($))) {
optionNext = getOptionNext(optionParent);
} else {
optionNext = getOptionNext($);
}
optionNext ? focusToOption(optionNext, picker) : focusToOptionFirst(picker);
} else if (KEY_ARROW_UP === key || KEY_PAGE_UP === key) {
exit = true;
if (KEY_PAGE_UP === key && TOKEN_GROUP === getRole(optionParent = getParent($))) {
optionPrev = getOptionPrev(optionParent);
} else {
optionPrev = getOptionPrev($);
}
optionPrev ? focusToOption(optionPrev, picker) : focusToOptionLast(picker);
} else if (KEY_BEGIN === key) {
exit = true;
focusToOptionFirst(picker);
} else if (KEY_END === key) {
exit = true;
focusToOptionLast(picker);
} else {
if (!keyIsCtrl) {
if (1 === toCount(key) && !keyIsAlt) {
if (isInput(self)) {
toggleHintByValue(picker, key);
} else {
searchTerm += key; // Initialize search term, right before exit
}
}
!keyIsShift && picker.exit(!(exit = false));
}
}
exit && offEventDefault(e);
}
function onKeyDownValue(e) {
let $ = this,
picker = getReference($),
{_active, _fix} = picker;
if (!_active || _fix) {
return;
}
let key = e.key,
keyIsAlt = e.altKey,
keyIsCtrl = e.ctrlKey,
{_mask, _options, max, min, self, state} = picker,
{arrow, options, values} = _mask,
{time} = state,
{search} = time,
exit, valueCurrent, valueNext, valuePrev;
searchTermClear(search[1]);
if (KEY_ARROW_DOWN === key || KEY_ARROW_UP === key || KEY_ENTER === key || KEY_PAGE_DOWN === key || KEY_PAGE_UP === key || ("" === searchTerm && ' ' === key)) {
let focus = exit = true;
if (KEY_ENTER === key || ' ' === key) {
if (valueCurrent = _options.at(getOptionValue($))) {
focus = false;
onAnimationsEnd(options, () => focusTo(valueCurrent[2]), scrollTo(valueCurrent[2]));
}
}
if (picker.size < 2) {
setStyle(options, 'max-height', 0);
}
picker.enter(focus).fit();
} else if (KEY_ARROW_LEFT === key) {
exit = true;
if ((valuePrev = getPrev($)) && hasKeyInMap(valuePrev, values)) {
focusTo(valuePrev);
}
} else if (KEY_ARROW_RIGHT === key) {
exit = true;
if ((valueNext = getNext($)) && hasKeyInMap(valueNext, values)) {
focusTo(valueNext);
}
} else if (KEY_BEGIN === key) {
exit = true;
forEachSet(values, v => {
valueCurrent = v;
return 0; // Break
});
valueCurrent && focusTo(valueCurrent);
} else if (KEY_DELETE_LEFT === key) {
exit = true;
searchTerm = "";
let countValues = toSetCount(values);
if (min >= countValues) {
onInvalidSelf.call(self);
picker.fire('min.options', [countValues, min]);
} else if (valueCurrent = _options.at(getOptionValue($))) {
letAria(valueCurrent[2], TOKEN_SELECTED);
valueCurrent[3][TOKEN_SELECTED] = false;
if ((valuePrev = getPrev($)) && hasKeyInMap(valuePrev, values) || (valueNext = getNext($)) && hasKeyInMap(valueNext, values)) {
focusTo(_mask[TOKEN_VALUE] = valuePrev || valueNext);
offEvent(EVENT_KEY_DOWN, $, onKeyDownValue);
offEvent(EVENT_MOUSE_DOWN, $, onPointerDownValue);
offEvent(EVENT_MOUSE_DOWN, $.$[VALUE_X], onPointerDownValueX);
offEvent(EVENT_TOUCH_START, $, onPointerDownValue);
offEvent(EVENT_TOUCH_START, $.$[VALUE_X], onPointerDownValueX);
letValueInMap($, values), letElement($);
// Do not remove the only option value
} else {
letAttribute(_mask[TOKEN_VALUE] = $, TOKEN_VALUE);
setHTML($.$[VALUE_TEXT], "");
// No option(s) selected
if (0 === min) {
selectToOptionsNone(picker, 1);
}
}
if (max !== Infinity && max > countValues) {
forEachMap(_options, (v, k) => {
if (!v[3][TOKEN_DISABLED]) {
letAria(v[2], TOKEN_DISABLED);
setAttribute(v[2], TOKEN_TABINDEX, 0);
}
});
}
}
} else if (KEY_DELETE_RIGHT === key) {
exit = true;
searchTerm = "";
let countValues = toSetCount(values);
if (min >= countValues) {
onInvalidSelf.call(self);
picker.fire('min.options', [countValues, min]);
} else if (valueCurrent = _options.at(getOptionValue($))) {
letAria(valueCurrent[2], TOKEN_SELECTED);
valueCurrent[3][TOKEN_SELECTED] = false;
if ((valueNext = getNext($)) && hasKeyInMap(valueNext, values) || (valuePrev = getPrev($)) && hasKeyInMap(valuePrev, values)) {
focusTo(_mask[TOKEN_VALUE] = valueNext && valueNext !== arrow ? valueNext : valuePrev);
offEvent(EVENT_KEY_DOWN, $, onKeyDownValue);
offEvent(EVENT_MOUSE_DOWN, $, onPointerDownValue);
offEvent(EVENT_MOUSE_DOWN, $.$[VALUE_X], onPointerDownValueX);
offEvent(EVENT_TOUCH_START, $, onPointerDownValue);
offEvent(EVENT_TOUCH_START, $.$[VALUE_X], onPointerDownValueX);
letValueInMap($, values), letElement($);
// Do not remove the only option value
} else {
letAttribute(_mask[TOKEN_VALUE] = $, TOKEN_VALUE);
setHTML($.$[VALUE_TEXT], "");
// No option(s) selected
if (0 === min) {
selectToOptionsNone(picker, 1);
}
}
if (max !== Infinity && max > countValues) {
forEachMap(_options, (v, k) => {
if (!v[3][TOKEN_DISABLED]) {
letAria(v[2], TOKEN_DISABLED);
setAttribute(v[2], TOKEN_TABINDEX, -1);
}
});
}
}
} else if (KEY_END === key) {
exit = true;
forEachSet(values, v => (valueCurrent = v));
valueCurrent && focusTo(valueCurrent);
} else if (KEY_ESCAPE === key) {
searchTerm = "";
picker.exit(exit = true);
} else if (KEY_TAB === key) {
searchTerm = "";
picker.exit(exit = false);
} else if (1 === toCount(key) && !keyIsAlt) {
if (keyIsCtrl) {
// Keep native key combination
} else {
exit = true;
searchTerm += key;
}
}
if ("" !== searchTerm) {
filter(search[0], picker, searchTerm, _options, true);
}
exit && offEventDefault(e);
}
function onPointerDownValue(e) {
offEventDefault(e);
let $ = this,
picker = getReference($),
{_mask, _options} = picker,
{options} = _mask, option;
if (_options.open) {
focusTo($);
} else {
if (option = _options.at(getOptionValue($))) {
onAnimationsEnd(options, () => delay(() => (focusTo(option[2]), scrollTo(option[2])))[0](1));
}
}
}
function onPointerDownValueX(e) {
let $ = this,
value = getParent($),
picker = getReference(value),
{_options} = picker,
option = _options.at(getOptionValue(value))[2];
option && toggleToOption(option, picker);
picker.enter(true).fit(), offEventDefault(e), offEventPropagation(e);
}
function onPasteTextInput(e) {
offEventDefault(e);
let $ = this,
picker = getReference($),
{self, state} = picker,
{strict} = state;
delay(() => {
if (!strict) {
setValue(self, getText($));
}
})[0](1);
toggleHint(1, picker), insertAtSelection($, e.clipboardData.getData('text/plain'));
}
// The default state is `0`. When the pointer is pressed on the option mask, its value will become `1`. This check is
// done to distinguish between a “touch only” and a “touch move” on touch device(s). It is also checked on pointer
// device(s) and should not give a wrong result.
let currentPointerState = 0,
touchTop = false,
touchTopCurrent = false;
function onPointerDownMask(e) {
// This is necessary for device(s) that support both pointer and touch control so that they will not execute both
// `mousedown` and `touchstart` event(s), causing the option picker’s option(s) to open and then close immediately.
// Note that this will also disable the native pane scrolling feature on touch device(s).
offEventDefault(e);
let $ = this,
picker = getReference($),
{_active, _fix} = picker;
if (_fix) {
return focusTo(picker);
}
if (!_active || getDatum($, 'size')) {
return;
}
let {_mask, _options, max, self} = picker,
{arrow, options} = _mask,
{target} = e, focusToArrow;
if (arrow === target) {
focusToArrow = 1;
}
// The user is likely browsing through the available option(s) by dragging the scroll bar
if (options === target) {
return;
}
while ($ !== target) {
target = getParent(target);
if (arrow === target) {
focusToArrow = 1;
break;
}
if (!target || options === target) {
return;
}
}
forEachMap(_options, v => v[2].hidden = false);
if (getReference(R) !== picker) {
if (picker.size < 2) {
setStyle(options, 'max-height', 0);
}
picker.enter(!focusToArrow).fit();
if (focusToArrow) {
focusTo(arrow);
}
} else {
picker.exit(!focusToArrow ? 1 === max || isInput(self) : 0);
if (focusToArrow) {
focusTo(arrow);
}
}
}
function onPointerDownOption(e) {
let $ = this;
// Add an “active” effect on `touchstart` to indicate which option is about to be selected. We don’t need this
// indication on `mousedown` because pointer device(s) already have a hover state that is clear enough to indicate
// which option is about to be selected.
if (EVENT_TOUCH_START === e.type && !getAria($, TOKEN_DISABLED)) {
setAria($, TOKEN_SELECTED, true);
}
currentPointerState = 1; // Pointer is “down”
}
function onPointerDownRoot(e) {
if (EVENT_TOUCH_START === e.type) {
touchTop = e.touches[0].clientY;
}
let $ = this,
picker = getReference($);
if (!picker) {
return;
}
let {mask, state} = picker,
{n} = state,
{target} = e;
if (mask !== target && mask !== getParent(target, '.' + n)) {
picker.exit();
}
}
function onPointerMoveRoot(e) {
touchTopCurrent = EVENT_TOUCH_MOVE === e.type ? e.touches[0].clientY : false;
let $ = this,
picker = getReference($);
if (!picker) {
return;
}
let {_mask} = picker,
{lot} = _mask, v;
if (false !== touchTop && false !== touchTopCurrent) {
if (1 === currentPointerState && touchTop !== touchTopCurrent) {
++currentPointerState;
}
// Programmatically re-enable the swipe feature in the option(s) list because the default `touchstart` event
// has been disabled. It does not have the innertia effect as in the native after-swipe reaction, but it is
// still better than doing nothing :\
v = getScroll(lot);
v[1] -= (touchTopCurrent - touchTop);
setScroll(lot, v);
touchTop = touchTopCurrent;
}
}
// The actual option selection happens when the pointer is released, to clearly identify whether we want to select an
// option or just want to scroll through the option(s) list by swiping over the option on touch device(s).
function onPointerUpOption() {
let $ = this,
picker = getReference($);
// A “touch only” event is valid only if the pointer has not been “move(d)” up to this event
if (1 === currentPointerState) {
if (!getAria($, TOKEN_DISABLED)) {
if (picker.max > 1) {
toggleToOption($, picker), focusTo($);
} else {
selectToOption($, picker), (picker.size < 2 ? picker.exit(true) : focusTo($));
}
}
} else {
// Remove the “active” effect that was previously added on `touchstart`
letAria($, TOKEN_SELECTED);
}
currentPointerState = 0; // Reset current pointer state
}
function onPointerUpRoot() {
currentPointerState = 0; // Reset current pointer state
touchTop = false;
}
function onResetForm() {
getReference(this).reset();
}
function onSubmitForm(e) {
let $ = this,
picker = getReference($),
{max, min, self} = picker,
count = toCount(getOptionsSelected(picker)), exit;
if (count < min) {
exit = true;
picker.fire('min.options', [count, min]);
} else if (count > max) {
exit = true;
picker.fire('max.options', [count, max]);
}
exit && (onInvalidSelf.call(self), offEventDefault(e));
}
function onResizeWindow() {
let picker = getReference(R);
picker && picker.fit();
}
function onScrollWindow() {
onResizeWindow.call(this);
}
function onWheelMask(e) {
let $ = this,
picker = getReference($),
{_active, _fix, max} = picker;
if (!_active || _fix || max > 1) {
return;
}
let {_mask} = picker,
{options} = _mask,
{deltaY, target} = e,
optionCurrent, optionNext, optionPrev;
if (options === target) {
return;
}
while ($ !== target) {
target = getParent(target);
if (options === target) {
return;
}
}
if (!(optionCurrent = getOptionSelected(picker) || goToOptionFirst(picker))) {
return;
}
offEventDefault(e);
if (deltaY < 0) {
if (optionPrev = getOptionPrev(optionCurrent)) {
focusTo(selectToOption(optionPrev, picker));
} else {
focusTo(selectToOptionLast(picker));
}
} else {
if (optionNext = getOptionNext(optionCurrent)) {
focusTo(selectToOption(optionNext, picker));
} else {
focusTo(selectToOptionFirst(picker));
}
}
}
function scrollTo(node) {
node.scrollIntoView({
block: 'nearest'
});
}
function selectToOption(option, picker) {
let {_mask, mask, self} = picker,
{input, value} = _mask, optionReal, v;
if (option) {
optionReal = option.$[OPTION_SELF];
selectToOptionsNone(picker);
optionReal[TOKEN_SELECTED] = true;
setAria(option, TOKEN_SELECTED, true);
setValue(self, v = getOptionValue(option));
if (isInput(self)) {
letAria(mask, TOKEN_INVALID);
setAria(input, 'activedescendant', getID(option));
setText(input, getText(option.$[OPTION_TEXT]));
toggleHintByValue(picker, 1);
} else {
setHTML(value.$[VALUE_TEXT], getHTML(option.$[OPTION_TEXT]));
setValue(value, v);
}
return picker.fire('change', ["" !== v ? v : null]), option;
}
}
function selectToOptionFirst(picker) {
let option;
if (option = goToOptionFirst(picker)) {
return selectToOption(option, picker);
}
}
function selectToOptionLast(picker) {
let option;
if (option = goToOptionLast(picker)) {
return selectToOption(option, picker);
}
}
function selectToOptionsNone(picker, fireValue) {
let {_mask, _options, self} = picker,
{input, value} = _mask, v;
forEachMap(_options, v => {
letAria(v[2], TOKEN_SELECTED);
v[3][TOKEN_SELECTED] = false;
});
if (fireValue) {
setValue(self, v = "");
if (isInput(self)) {
letAria(input, 'activedescendant');
setText(input, "");
toggleHintByValue(picker, 0);
} else {
letAttribute(value, TOKEN_VALUE);
setHTML(value.$[VALUE_TEXT], v);
if (v = value.$[VALUE_X]) {
letElement(v);
}
}
}
}
function toggleToOption(option, picker) {
let {_mask, _options, max, min, self, state} = picker,
{value, values} = _mask,
{n} = state,
selected, selectedFirst, valueCurrent, valueNext, valueNextX;
if (option) {
let optionReal = option.$[OPTION_SELF],
a = getOptionsValues(getOptionsSelected(picker)), b, c;
if (getAria(option, TOKEN_SELECTED) && optionReal[TOKEN_SELECTED]) {
if (min > 0 && (c = toCount(a)) <= min) {
onInvalidSelf.call(self);
picker.fire('min.options', [c, min]);
} else {
letAria(option, TOKEN_SELECTED);
optionReal[TOKEN_SELECTED] = false;
}
} else {
setAria(option, TOKEN_SELECTED, true);
optionReal[TOKEN_SELECTED] = true;
}
if (!isInput(self)) {
b = getOptionsValues(getOptionsSelected(picker));
if (max !== Infinity && (c = toCount(b)) === max) {
forEachMap(_options, (v, k) => {
if (!getAria(v[2], TOKEN_SELECTED)) {
letAttribute(v[2], TOKEN_TABINDEX);
setAria(v[2], TOKEN_DISABLED, true);
}
});
} else if (c > max) {
letAria(option, TOKEN_SELECTED);
optionReal[TOKEN_SELECTED] = false;
forEachMap(_options, (v, k) => {
if (!getAria(v[2], TOKEN_SELECTED)) {
letAttribute(v[2], TOKEN_TABINDEX);
setAria(v[2], TOKEN_DISABLED, true);
}
});
onInvalidSelf.call(self);
picker.fire('max.options', [c, max]);
} else {
forEachMap(_options, (v, k) => {
if (!v[3][TOKEN_DISABLED]) {
letAria(v[2], TOKEN_DISABLED);
setAttribute(v[2], TOKEN_TABINDEX, -1);
}
});
}
selected = getOptionsSelected(picker);
selectedFirst = selected.shift();
if (selectedFirst) {
setChildLast(value, value.$[VALUE_X]);
setHTML(value.$[VALUE_TEXT], getHTML(selectedFirst.$[OPTION_TEXT]));
setValue(value, getOptionValue(selectedFirst));
letValueInMap(value, values);
forEachSet(values, v => {
offEvent(EVENT_KEY_DOWN, v, onKeyDownValue);
offEvent(EVENT_MOUSE_DOWN, v, onPointerDownValue);
offEvent(EVENT_MOUSE_DOWN, v.$[VALUE_X], onPointerDownValueX);
offEvent(EVENT_TOUCH_START, v, onPointerDownValue);
offEvent(EVENT_TOUCH_START, v.$[VALUE_X], onPointerDownValueX);
letReference(v, picker), letElement(v);
return -1; // Remove
});
values.add(valueCurrent = value); // Add the only value to the set
forEachArray(selected, (v, k) => {
valueNext = setID(letID(value.cloneNode(true)));
valueNext[TOKEN_TAB_INDEX] = -1;
valueNext.$ = {};
valueNext.$[VALUE_SELF] = null;
valueNext.$[VALUE_TEXT] = getElement('.' + n + '__v', valueNext);
valueNext.$[VALUE_X] = valueNextX = getElement('.' + n + '__x', valueNext);
onEvent(EVENT_KEY_DOWN, valueNext, onKeyDownValue);
onEvent(EVENT_MOUSE_DOWN, valueNext, onPointerDownValue);
onEvent(EVENT_MOUSE_DOWN, valueNextX, onPointerDownValueX);
onEvent(EVENT_TOUCH_START, valueNext, onPointerDownValue);
onEvent(EVENT_TOUCH_START, valueNextX, onPointerDownValueX);
setHTML(valueNext.$[VALUE_TEXT], getHTML(v.$[OPTION_TEXT]));
setReference(valueNext, picker), values.add(setNext(valueCurrent, valueNext));
setValue(valueNext, getOptionValue(v));
valueCurrent = valueNext;
});
} else {
selectToOptionsNone(picker, 1);
}
}
return picker.fire('change', [b]), option;
}
}
function OptionPicker(self, state) {
const $ = this;
if (!self) {
return $;
}
// Return new instance if `OptionPicker` was called without the `new` operator
if (!isInstance($, OptionPicker)) {
return new OptionPicker(self, state);
}
setReference(self, hook($, OptionPicker._));
return $.attach(self, fromStates({}, OptionPicker.state, isBoolean(state) ? {
strict: state
} : (state || {})));
}
function OptionPickerOptions(of, options) {
const $ = this;
// Return new instance if `OptionPickerOptions` was called without the `new` operator
if (!isInstance($, OptionPickerOptions)) {
return new OptionPickerOptions(of, options);
}
$.of = of;
$[TOKEN_VALUES] = new Map;
if (options) {
createOptions(of, options);
}
return $;
}
OptionPicker.from = function (self, state) {
return new OptionPicker(self, state);
};
OptionPicker.of = getReference;
OptionPicker.state = {
'max': null,
'min': null,
'n': 'option-picker',
'options': null,
'size': null,
'strict': false,
'time': {
'error': 1000,
'search': [10, 500]
},
'with': []
};
OptionPicker.version = '2.2.6';
setObjectAttributes(OptionPicker, {
name: {
value: name
}
}, 1);
setObjectAttributes(OptionPicker, {
active: {
get: function () {
return this._active;
},
set: function (value) {
selectToNone();
let $ = this,
{_mask, mask, self} = $,
{input, value: inputReadOnly} = _mask,
v = !!value;
self[TOKEN_DISABLED] = !($._active = v);
if (v) {
letAria(mask, TOKEN_DISABLED);
if (input) {
letAria(input, TOKEN_DISABLED);
setAttribute(input, TOKEN_CONTENTEDITABLE, "");
} else if (inputReadOnly) {
setAttribute(inputReadOnly, TOKEN_TABINDEX, 0);
}
} else {
setAria(mask, TOKEN_DISABLED, true);
if (input) {
setAria(input, TOKEN_DISABLED, true);
letAttribute(input, TOKEN_CONTENTEDITABLE);
} else if (inputReadOnly) {
letAttribute(inputReadOnly, TOKEN_TABINDEX);
}
}
return $;
}
},
fix: {
get: function () {
return this._fix;
},
set: function (value) {
selectToNone();
let $ = this,
{_mask, mask, self} = $,
{input} = _mask,
v = !!value;
$._fix = v;
if (!isInput(self)) {
return $;
}
self[TOKEN_READ_ONLY] = v;
if (v) {
letAttribute(input, TOKEN_CONTENTEDITABLE);
setAria(input, TOKEN_READONLY, true);
setAria(mask, TOKEN_READONLY, true);
setAttribute(input, TOKEN_TABINDEX, 0);
} else {
letAria(input, TOKEN_READONLY);
letAria(mask, TOKEN_READONLY);
letAttribute(input, TOKEN_TABINDEX);
setAttribute(input, TOKEN_CONTENTEDITABLE, "");
}
return $;
}
},
max: {
get: function () {
let $ = this,
{state} = $,
{max} = state;
return Infinity === max || isInteger(max) && max > 0 ? max : 1;
},
set: function (value) {
let $ = this,
{self} = $;
if (isInput(self)) {
return $;
}
let {mask, state} = $;
value = (Infinity === value || isInteger(value)) && value > 0 ? value : 0;
self.multiple = value > 1;
state.max = value;
value > 1 ? setAria(mask, 'multiselectable', true) : letAria(mask, 'multiselectable');
return $;
}
},
min: {
get: function () {
let $ = this,
{state} = $,
{min} = state;
return !isInteger(min) || min < 0 ? 0 : min;
},
set: function (value) {
let $ = this,
{state} = $;
state.min = isInteger(value) && value > 0 ? value : 0;
return $;
}
},
options: {
get: function () {
return this._options;
},
set: function (options) {
selectToNone();
let $ = this,
{_active, _fix} = $;
if (!_active || _fix) {
return $;
}
let {max} = $, selected;
if (isFloat(options) || isInteger(options) || isString(options)) {
options = [options];
}
if (toCount(selected = createOptions($, options))) {
let isMultipleSelect = max > 1;
$[TOKEN_VALUE + (isMultipleSelect ? 's' : "")] = $['_' + TOKEN_VALUE + (isMultipleSelect ? 's' : "")] = isMultipleSelect ? selected : selected[0];
}
let optionsValues = [];
forEachMap($._options, v => optionsValues.push(getOptionValue(v[2], 1)));
return $.fire('set.options', [optionsValues]);
}
},
size: {
get: function () {
let $ = this,
{self, state} = $, size;
if (isInput(self)) {
return null;
}
size = self.size ?? (state.size || 1);
return !isInteger(size) || size < 1 ? 1 : size; // <https://html.spec.whatwg.org#attr-select-size>
},
set: function (value) {
selectToNone();
let $ = this,
{self} = $;
if (isInput(self)) {
return $;
}
let {_active, _mask, mask, state} = $,
{options} = _mask,
size = !isInteger(value) || value < 1 ? 1 : value;
self.size = state.size = size;
if (1 === size) {
letDatum(mask, 'size');
letStyle(options, 'max-height');
_active && letReference(R);
} else {
let option = goToOptionFirst($);
if (option) {
let optionsBorderBottom = getStyle(options, 'border-bottom-width', false),
optionsBorderTop = getStyle(options, 'border-top-width', false),
optionsGap = getStyle(options, 'gap', false),
optionHeight = getStyle(option, 'height', false) ?? getStyle(option, 'min-height', false) ?? getStyle(option, 'line-height', false);
setDatum(mask, 'size', size);
setStyle(options, 'max-height', 'calc(' + optionsBorderTop + ' + ' + optionsBorderBottom + ' + (' + optionHeight + '*' + size + ') + calc(' + optionsGap + '*' + size + '))');
_active && setReference(R, $);
}
}
return $;
}
},
text: {
get: function () {
let $ = this,
{_mask} = $,
{input, text} = _mask;
return text ? getText(input) : null;
},
set: function (value) {
let $ = this,
{_active, _fix} = $;
if (!_active || _fix) {
return $;
}
let {text} = _mask;
if (!text) {
return $;
}
let {input} = _mask, v;
return setText(input, v = fromValue(value)), toggleHintByValue($, v), $;
}
},
value: {
get: function () {
let value = getValue(this.self);
return "" !== value ? value : null;
},
set: function (value) {
let $ = this,
{_active} = $;
if (!_active) {
return $;
}
let {_options} = $, option;
if (option = _options.at(value)) {
selectToOption(option[2], $);
}
return $;
}
},
values: {
get: function () {
return getOptionsValues(getOptionsSelected(this));
},
set: function (values) {
let $ = this,
{_active} = $;
if (!_active || $.max < 2) {
return $;
}
selectToOptionsNone($);
let {_options} = $, op