UNPKG

@taufik-nurrohman/option-picker

Version:

Accessible custom `<select>` (and `<input list>`) element.

1,416 lines (1,344 loc) 77.5 kB
import {/* focusTo, */insertAtSelection, redoState, resetState, saveState, selectTo, selectToNone, undoState} 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 KEY_Y = 'y'; const KEY_Z = 'z'; 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 => { if ("" !== q && (q === toCaseLower(getText(v[2]).replace(/\u200C/g, "")).slice(0, toCount(q)) || q === toCaseLower(getOptionValue(v[2])).slice(0, toCount(q))) && !getAria(v[2], TOKEN_DISABLED)) { selectToOption(v[2], $); return 0; } --count; }); } else { forEachMap(_options, v => { if ("" === q || hasValue(q, toCaseLower(getText(v[2]).replace(/\u200C/g, "") + '\t' + getOptionValue(v[2])))) { 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 [saveStateLazy] = delay(function ($) { saveState($); }, 1); 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); saveState($), toggleHint(1, picker), saveStateLazy($); } 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), saveStateLazy($); } else if ('insertText' === inputType) { toggleHintByValue(picker, 1), saveStateLazy($); } } 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, keyIsShift = e.shiftKey, picker = getReference($), {_active, _fix} = picker; if (!_active || _fix) { return; } let {_options, mask, self, state} = picker, {strict, time} = state, {error, 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) { letError(isInteger(error) && error > 0 ? error : 0, picker); selectToNone(), picker.exit(); } else if (keyIsCtrl) { if (!keyIsShift && KEY_Z === toCaseLower(key)) { exit = true; undoState($); } else if (keyIsShift && KEY_Z === toCaseLower(key) || KEY_Y === toCaseLower(key)) { exit = true; redoState($); } } 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); saveState($), toggleHint(1, picker), insertAtSelection($, e.clipboardData.getData('text/plain'), -1), saveStateLazy($); } // 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() { forEachSet(getReference(this), $ => $.reset()); } function onSubmitForm(e) { forEachSet(getReference(this), picker => { let {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), tick; if (picker) { if (!tick) { W.requestAnimationFrame(() => { picker.fit(), (tick = 0); }), (tick = 1); } } } 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.10'; 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 {_mask} = $, {text} = _mask; if (!text) { return $; } let {input} = _mask, v; return set