UNPKG

@seznam/szn-select

Version:

Accessible HTML selectbox with customizable UI. Based on web components and easy to integrate with various frameworks like React or Angular.

1,437 lines (1,161 loc) 101 kB
'use strict'; (global => { const SznElements = global.SznElements = global.SznElements || {}; /** * @typedef {{width: number, height: number}} ContentDimensions */ /** * @typedef {{screenX: number, screenY: number, x: number, y: number, width: number, height: number}} TetherBounds */ const CSS_STYLES = ` szn-tethered { display: block; position: absolute; left: 0; top: 0; width: 0; height: 0; transform: translate(0, 0); } szn-tethered > [data-szn-tethered--content] { position: absolute; left: 0; top: 0; } szn-tethered[data-horizontal-align='right'] > [data-szn-tethered--content] { left: auto; right: 0; } szn-tethered[data-vertical-align='top'] > [data-szn-tethered--content] { top: auto; bottom: 0; } `; const HORIZONTAL_ALIGN = { LEFT: 'HORIZONTAL_ALIGN.LEFT', RIGHT: 'HORIZONTAL_ALIGN.RIGHT' }; const VERTICAL_ALIGN = { TOP: 'VERTICAL_ALIGN.TOP', BOTTOM: 'VERTICAL_ALIGN.BOTTOM' }; const MIN_BOTTOM_SPACE = 160; // px const OBSERVED_DOM_EVENTS = ['resize', 'scroll', 'wheel', 'touchmove']; if (Object.freeze) { Object.freeze(HORIZONTAL_ALIGN); Object.freeze(VERTICAL_ALIGN); } let transformsSupported = null; /** * The <code>szn-tethered</code> element is used to tether the on-screen position of content to another element * located elsewhere in the document (i.e. when such positioning is not feasible through CSS alone). */ SznElements['szn-tethered'] = class SznTethered { constructor(rootElement) { if (transformsSupported === null) { transformsSupported = 'transform' in rootElement.style; } rootElement.HORIZONTAL_ALIGN = HORIZONTAL_ALIGN; rootElement.VERTICAL_ALIGN = VERTICAL_ALIGN; rootElement.setTether = tether => this.setTether(tether); rootElement.updatePosition = () => this.updatePosition(); if (!rootElement.hasOwnProperty('horizontalAlign')) { Object.defineProperty(rootElement, 'horizontalAlign', { get: () => rootElement._broker.horizontalAlignment }); } if (!rootElement.hasOwnProperty('verticalAlignment')) { Object.defineProperty(rootElement, 'verticalAlignment', { get: () => rootElement._broker.verticalAlignment }); } if (!rootElement.hasOwnProperty('minBottomSpace')) { Object.defineProperty(rootElement, 'minBottomSpace', { get: () => rootElement._broker.minBottomSpace, set: value => { rootElement._broker.minBottomSpace = value; } }); } rootElement.onVerticalAlignmentChange = null; rootElement.onHorizontalAlignmentChange = null; /** * The currently used horizontal alignment of the content to the tethering element. * * @type {string} */ this.horizontalAlignment = HORIZONTAL_ALIGN.LEFT; /** * The currently used vertical alignment of the content to the tethering element. * * @type {string} */ this.verticalAlignment = VERTICAL_ALIGN.BOTTOM; /** * The minimum number of pixels that must be available below the current tethering element in the viewport for * the content to be tethered to the bottom edge of the tethering element. If there is less space available, the * content will be tethered to the top edge of the tethering element. * * @type {number} */ this.minBottomSpace = MIN_BOTTOM_SPACE; /** * The szn-tethered element itself (the DOM element instance). */ this._root = rootElement; /** * The current tethering element. * * @type {?Element} */ this._tether = null; /** * Whether or not this szn-tethered element is currently mounted into the document. * * @type {boolean} */ this._mounted = false; /** * The previously set horizontal alignment of the tethered content to the tether, before updating the alignment * data attributes of the szn-tethered element. * * @type {string} */ this._lastHorizontalAlignment = null; /** * The previously set vertical alignment of the tethered content to the tether, before updating the alignment * data attributes of the szn-tethered element. * * @type {string} */ this._lastVerticalAlignment = null; SznElements.injectStyles(CSS_STYLES, 'szn-tethered'); } onMount() { for (const eventType of OBSERVED_DOM_EVENTS) { addEventListener(eventType, this._root.updatePosition); } this._mounted = true; this.updatePosition(); } onUnmount() { for (const eventType of OBSERVED_DOM_EVENTS) { removeEventListener(eventType, this._root.updatePosition); } this._mounted = false; } /** * Sets the element to which this element will be tethered. * * @param {Element} tether The element to which this szn-tethered element should be tethered. */ setTether(tether) { if (tether === this._tether) { return; } this._tether = tether; this.updatePosition(); } /** * Updates the location and alignment of the tethered content to the tethering element. The method may be invoked * manually if updating the location of the tethered content is needed for any reason. * * Note that this method is automatically invoked whenever any of the following events occur on the page: resize, * scroll, wheel, touchmove. * * This method has no effect if the szn-tethered element is unmounted or no tethering element is currently set. */ updatePosition() { if (!this._mounted || !this._tether) { return; } const tetherBounds = getTetherBounds(this._tether); const contentSize = getContentDimensions(this); const viewportWidth = document.documentElement.clientWidth; // window.innerWidth - scrollbars // Lets hope the document element (<html>) is at least 100vh high - which it usually is. const viewportHeight = Math.min(document.documentElement.clientHeight, // window.innerHeight - scrollbars window.innerHeight); const lastHorizontalAlignment = this.horizontalAlignment; const lastVerticalAlignment = this.verticalAlignment; if (tetherBounds.screenX + contentSize.width > viewportWidth && tetherBounds.screenX + tetherBounds.width - contentSize.width >= 0) { this.horizontalAlignment = HORIZONTAL_ALIGN.RIGHT; } else { this.horizontalAlignment = HORIZONTAL_ALIGN.LEFT; } if (viewportHeight - (tetherBounds.screenY + tetherBounds.height) < this.minBottomSpace) { this.verticalAlignment = VERTICAL_ALIGN.TOP; } else { this.verticalAlignment = VERTICAL_ALIGN.BOTTOM; } updateAttributes(this); updatePosition(this, tetherBounds); if (this.horizontalAlignment !== lastHorizontalAlignment && this._root.onHorizontalAlignmentChange) { this._root.onHorizontalAlignmentChange(this.horizontalAlignment); } if (this.verticalAlignment !== lastVerticalAlignment && this._root.onVerticalAlignmentChange) { this._root.onVerticalAlignmentChange(this.verticalAlignment); } } }; /** * Updates the position of the szn-tethered element according to the current tethering alignment and the provided * bounds of the tethering element. * * @param {SznElements.SznTethered} instance The szn-tethered element instance. * @param {TetherBounds} tetherBounds The bounds (location and size) of the tethering element. */ function updatePosition(instance, tetherBounds) { const x = tetherBounds.x + (instance.horizontalAlignment === HORIZONTAL_ALIGN.LEFT ? 0 : tetherBounds.width); const y = tetherBounds.y + (instance.verticalAlignment === VERTICAL_ALIGN.TOP ? 0 : tetherBounds.height); if (transformsSupported) { instance._root.style.transform = `translate(${x}px, ${y}px)`; } else { instance._root.style.left = `${x}px`; instance._root.style.top = `${y}px`; } } /** * Updates the attributes on the szn-tethered element reporting the current alignment to the tethering element. * * @param {SznElements.SznTethered} instance The szn-tethered element instance. */ function updateAttributes(instance) { if (instance.horizontalAlignment !== instance._lastHorizontalAlignment) { const horizontalAlignment = instance.horizontalAlignment === HORIZONTAL_ALIGN.LEFT ? 'left' : 'right'; instance._root.setAttribute('data-horizontal-align', horizontalAlignment); instance._lastHorizontalAlignment = instance.horizontalAlignment; } if (instance.verticalAlignment !== instance._lastVerticalAlignment) { const verticalAlignment = instance.verticalAlignment === VERTICAL_ALIGN.TOP ? 'top' : 'bottom'; instance._root.setAttribute('data-vertical-align', verticalAlignment); instance._lastVerticalAlignment = instance.verticalAlignment; } } /** * Calculates and returns both the on-screen and on-page location and dimensions of the provided tether element. * * @param {Element} tether The tethering element. * @return {TetherBounds} The on-screen and on-page location and dimensions of the element. */ function getTetherBounds(tether) { const bounds = tether.getBoundingClientRect(); const width = bounds.width; const height = bounds.height; let x = 0; let y = 0; let tetherOffsetContainer = tether; while (tetherOffsetContainer) { x += tetherOffsetContainer.offsetLeft; y += tetherOffsetContainer.offsetTop; tetherOffsetContainer = tetherOffsetContainer.offsetParent; } return { screenX: bounds.left, screenY: bounds.top, x, y, width, height }; } /** * Returns the dimensions of the content of the provided szn-tethered element. * * @param {SznElements.SznTethered} instance The instance of the szn-tethered element. * @return {ContentDimensions} The dimensions of the tethered content. */ function getContentDimensions(instance) { const contentElement = instance._root.firstElementChild; if (!contentElement) { return { width: 0, height: 0 }; } let width; let height; if (window.devicePixelRatio > 1) { // This is much less performant, so we use in only on HiDPi displays const bounds = contentElement.getBoundingClientRect(); width = bounds.width; height = bounds.height; } else { width = contentElement.scrollWidth; height = contentElement.scrollHeight; } return { width, height }; } if (SznElements.init) { SznElements.init(); } })(self); ; 'use strict'; (global => { const SznElements = global.SznElements = global.SznElements || {}; const CSS_STYLES = ` szn-select--button{display:block;position:relative;box-sizing:border-box;border:1px solid #ccc;border:var(--szn-select--button--border-width,1px) solid var(--szn-select--button--border-color,#ccc);border-radius:4px;border-radius:var(--szn-select--button--border-radius,4px);width:100%;height:100%;line-height:30px;line-height:calc(var(--szn-select--button--height-px, 32px) - 2 * var(--szn-select--button--border-width, 1px));line-height:calc(2em - 2 * 1px);line-height:calc(var(--szn-select--button--height, 2em) - 2 * var(--szn-select--button--border-width, 1px));color:#000;color:var(--szn-select--button--text-color,#000);background:#fff;background:var(--szn-select--button--background-color,#fff);cursor:pointer;user-select:none;-moz-user-select:none}szn-select--button[data-szn-select--button--open]{border-radius:4px 4px 0 0;border-radius:var(--szn-select--button--border-radius,4px) var(--szn-select--button--border-radius,4px) 0 0}szn-select--button[data-szn-select--button--open-at-top]{border-radius:0 0 4px 4px;border-radius:0 0 var(--szn-select--button--border-radius,4px) var(--szn-select--button--border-radius,4px)}szn-select--button[disabled]{color:#ccc;color:var(--szn-select--button--disabled-text-color,#ccc);cursor:default}szn-select--button [data-szn-select--button--label]{display:block;box-sizing:border-box;border-right:2em solid transparent;border-right:var(--szn-select--button--mark-width,2em) transparent solid;padding:0 5px 0 12px;padding:var(--szn-select--button--padding,0 5px 0 12px);width:100%;height:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}szn-select--button [data-szn-select--button--mark]{display:block;position:absolute;right:0;top:0;width:2em;width:var(--szn-select--button--mark-width,2em);height:100%;text-align:center}szn-select--button [data-szn-select--button--mark]:after{display:inline;content:"▼";content:var(--szn-select--button--icon-closed,"▼")}szn-select--button[data-szn-select--button--open] [data-szn-select--button--mark]:after{content:"▲";content:var(--szn-select--button--icon-opened,"▲")} `; const OPENING_POSITION = { UP: 'OPENING_POSITION.UP', DOWN: 'OPENING_POSITION.DOWN' }; if (Object.freeze) { Object.freeze(OPENING_POSITION); } SznElements['szn-select--button'] = class SznSelectButton { constructor(rootElement) { this.OPENING_POSITION = OPENING_POSITION; this._root = rootElement; this._select = null; this._label = null; this._observer = new MutationObserver(() => { updateLabel(this); updateDisabledStatus(this); }); rootElement.OPENING_POSITION = OPENING_POSITION; rootElement.setSelectElement = this.setSelectElement.bind(this); rootElement.setOpen = this.setOpen.bind(this); rootElement.setOpeningPosition = this.setOpeningPosition.bind(this); this._onChange = onChange.bind(null, this); SznElements.injectStyles(CSS_STYLES, 'szn-select--button'); } onMount() { observeSelect(this); addEventListeners(this); updateLabel(this); updateDisabledStatus(this); } onUnmount() { this._observer.disconnect(); removeEventListeners(this); } setSelectElement(selectElement) { if (selectElement === this._select) { return; } if (!this._label) { this._root.appendChild(buildUI(this)); } if (this._select) { removeEventListeners(this); this._observer.disconnect(); } this._select = selectElement; observeSelect(this); addEventListeners(this); updateLabel(this); updateDisabledStatus(this); } setOpen(isOpen) { if (isOpen) { this._root.setAttribute('data-szn-select--button--open', ''); } else { this._root.removeAttribute('data-szn-select--button--open'); this._root.removeAttribute('data-szn-select--button--open-at-top'); } } setOpeningPosition(openingPosition) { if (openingPosition === OPENING_POSITION.UP) { this._root.setAttribute('data-szn-select--button--open-at-top', ''); } else { this._root.removeAttribute('data-szn-select--button--open-at-top'); } } }; function addEventListeners(instance) { if (!instance._select) { return; } instance._select.addEventListener('change', instance._onChange); } function removeEventListeners(instance) { if (!instance._select) { return; } instance._select.removeEventListener('change', instance._onChange); } function onChange(instance) { updateLabel(instance); } function observeSelect(instance) { if (!instance._select) { return; } instance._observer.observe(instance._select, { childList: true, attributes: true, characterData: true, subtree: true, attributeFilter: ['disabled', 'selected'] }); } function buildUI(instance) { return SznElements.buildDom(` <szn- data-szn-select--button--label data-szn-ref></szn-> <szn- data-szn-select--button--mark></szn-> `, label => { instance._label = label; }, false); } function updateLabel(instance) { if (!instance._select) { return; } const selectedOption = instance._select.options.item(instance._select.selectedIndex); const selectedOptionText = selectedOption.text; if (instance._label.innerText !== selectedOptionText) { instance._label.innerText = selectedOptionText; } } function updateDisabledStatus(instance) { if (!instance._select) { return; } if (instance._select.disabled) { instance._root.setAttribute('disabled', ''); } else { instance._root.removeAttribute('disabled'); } } })(self); 'use strict'; (global => { const SznElements = global.SznElements = global.SznElements || {}; const CSS_STYLES = ` szn-select--options{display:block;z-index:1;border:solid #ccc;border:solid var(--szn-select--options--border-color,#ccc);border-width:1px;border-width:var(--szn-select--options--border-width,1px);border-radius:0 0 4px 4px;border-radius:var(--szn-select--options--border-radius-dropdown,0 0 4px 4px);height:100%;box-sizing:border-box;white-space:nowrap;color:#000;color:var(--szn-select--options--text-color,#000);background:#fff;background:var(--szn-select--options--background-color,#fff);user-select:none;-moz-user-select:none}szn-select--options[data-szn-select--options--multiple]{border-radius:4px;border-radius:var(--szn-select--options--border-radius-mutli-select,4px)}szn-select--options [data-szn-select--options--option]{display:block;padding:5px 5px 5px 12px;padding:var(--szn-select--options--item-padding,5px 5px 5px 12px);overflow:auto;overflow-x:hidden;text-overflow:ellipsis;cursor:pointer}szn-select--options [data-szn-select--options--option][disabled],szn-select--options [disabled] [data-szn-select--options--option],szn-select--options[disabled] [data-szn-select-options--option]{cursor:default}szn-select--options [data-szn-select--options--selected]{background:#eee;background:var(--szn-select--options--selected-background,#eee)}szn-select--options [data-szn-select--options--optgroup]{display:block}szn-select--options [data-szn-select--options--optgroup] [data-szn-select--options--option]{padding-left:24px;padding-left:calc(2 * var(--szn-select--options--item-indent, 12px))}szn-select--options [data-szn-select--options--optgroup-label]:before{display:block;padding:5px 5px 5px 12px;padding:var(--szn-select--options--item-padding,5px 5px 5px 12px);content:attr(data-szn-select--options--optgroup-label);font-weight:700}szn-select--options[data-szn-select--options--highlighting] [data-szn-select-options--selected]{background:none}szn-select--options[data-szn-select--options--highlighting] [data-szn-select--options--highlighted]{background:#eee;background:var(--szn-select--options--selected-background,#eee)}szn-select--options[disabled],szn-select--options [disabled]{color:#ccc;color:var(--szn-select--options--disabled-text,#ccc)}szn-tethered[data-vertical-align=top] szn-select--options{border-radius:4px 4px 0 0;border-radius:var(--szn-select--options--border-radius-dropup,4px 4px 0 0)} `; class SznSelectOptions { /** * Initializes the szn-options element's implementation. * * @param {Element} rootElement The root HTML element of this custom element's implementation. */ constructor(rootElement) { rootElement.setOptions = options => this.setOptions(options); rootElement.updateUi = () => updateUi(this); /** * The root HTML element of this custom element's implementation. * * @type {Element} */ this._root = rootElement; /** * The container of the options this element provides the UI for. * * @type {?HTMLElement} */ this._options = null; /** * The option represented the element over which the user started to drag the mouse cursor to perform a * multiple-items selection. * * This field is used only for multi-selects. * * @type {?HTMLOptionElement} */ this._dragSelectionStartOption = null; /** * Flag signalling whether the element is currently mounted. * * @type {boolean} */ this._mounted = false; /** * The DOM mutation observer used to observe modifications to the associated options. * * @type {MutationObserver} */ this._observer = new MutationObserver(rootElement.updateUi); /** * The previously used indexes when the <code>scrollToSelection</code> function has been called for this * instance. * * @type {{start: number, end: number}} * @see scrollToSelection */ this._lastSelectionIndexes = { start: -1, end: -1 /** * The indexes of options that are to be selected as well while performing an additive multi-select (dragging the * mouse over a multi-select while holding the Ctrl key). * * @type {Array<number>} */ }; this._additionalSelectedIndexes = []; /** * Set to <code>true</code> if the user started to drag the mouse pointer over an already selected item while * holding the Ctrl key. The items selected by the user using the current action will be deselected. * * @type {boolean} */ this._invertSelection = false; /** * The index of the option at which the multi-items selection started the last time. * * @type {number} */ this._previousSelectionStartIndex = -1; /** * The ID of the current touch being observed for possible option interaction. * * @type {?number} */ this._observedTouchId = null; this._onItemHovered = event => onItemHovered(this, event.target); this._onItemClicked = event => onItemClicked(this, event.target); this._onItemSelectionStart = event => onItemSelectionStart(this, event.target, event); this._onTouchStart = onTouchStart.bind(null, this); this._onTouchEnd = onTouchEnd.bind(null, this); this._onSelectionEnd = () => { this._dragSelectionStartOption = null; this._additionalSelectedIndexes = []; }; this._onSelectionChange = () => { this._root.removeAttribute('data-szn-select--options--highlighting'); updateUi(this); }; SznElements.injectStyles(CSS_STYLES, 'szn-options'); } onMount() { this._mounted = true; addEventListeners(this); updateUi(this); registerOptionsObserver(this); if (this._options) { scrollToSelection(this, this._options.selectedIndex, this._options.selectedIndex); } } onUnmount() { removeEventListeners(this); this._root.removeAttribute('data-szn-select--options--highlighting'); this._mounted = false; this._observer.disconnect(); } /** * Sets the element containing the options to display in this szn-options element. * * @param {HTMLElement} options The element containing the options to display. */ setOptions(options) { if (options === this._options) { return; } if (this._options) { removeEventListeners(this); this._observer.disconnect(); } this._options = options; addEventListeners(this); updateUi(this); registerOptionsObserver(this); const selectedIndex = typeof options.selectedIndex === 'number' ? options.selectedIndex : -1; this._previousSelectionStartIndex = selectedIndex; if (this._mounted) { scrollToSelection(this, selectedIndex, selectedIndex); } } } /** * Registers the provided szn-options element's DOM mutation observer to observe the related options for changes. * * @param {SznSelectOptions} instance The szn-options element instance. */ function registerOptionsObserver(instance) { if (!instance._mounted || !instance._options) { return; } instance._observer.observe(instance._options, { childList: true, attributes: true, characterData: true, subtree: true, attributeFilter: ['disabled', 'label', 'selected', 'title', 'multiple'] }); } /** * Registers event listeners that the provided szn-options instance requires to function correctly. * * The function has no effect if the provided szn-options element is not mounted into the document or has not been * provided with its options yet. * * @param {SznSelectOptions} instance The szn-options element instance. */ function addEventListeners(instance) { if (!instance._mounted || !instance._options) { return; } instance._options.addEventListener('change', instance._onSelectionChange); instance._root.addEventListener('mouseover', instance._onItemHovered); instance._root.addEventListener('mousedown', instance._onItemSelectionStart); instance._root.addEventListener('mouseup', instance._onItemClicked); instance._root.addEventListener('touchstart', instance._onTouchStart); addEventListener('mouseup', instance._onSelectionEnd); addEventListener('touchend', instance._onTouchEnd); } /** * Deregisters all event listeners used by the provided szn-options element. * * @param {SznSelectOptions} instance The szn-options element instance. */ function removeEventListeners(instance) { if (instance._options) { instance._options.removeEventListener('change', instance._onSelectionChange); } instance._root.removeEventListener('mouseover', instance._onItemHovered); instance._root.removeEventListener('mousedown', instance._onItemSelectionStart); instance._root.removeEventListener('mouseup', instance._onItemClicked); instance._root.removeEventListener('touchstart', instance._onTouchStart); removeEventListener('mouseup', instance._onSelectionEnd); removeEventListener('touchend', instance._onTouchEnd); } /** * @param {SznSelectOptions} instance * @param {TouchEvent} event */ function onTouchStart(instance, event) { if (instance._observedTouchId) { return; } const touch = event.changedTouches[0]; instance._observedTouchId = touch.identifier; } function onTouchEnd(instance, event) { if (!instance._options.multiple) { return; } const touch = Array.from(event.changedTouches).find(someTouch => someTouch.identifier === instance._observedTouchId); if (!touch) { return; } instance._observedTouchId = null; event.preventDefault(); // prevent mouse events const option = event.target._option; if (!isOptionEnabled(option)) { return; } option.selected = !option.selected; instance._options.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: true })); } /** * Handles the user moving the mouse pointer over an option in the szn-options element's UI. The function updates the * current multiple-items selection if the element represents a multi-select, or updates the currently highlighted * item in the UI of a single-select. * * @param {SznSelectOptions} instance The szn-options element instance. * @param {Element} itemUi The element which's area the mouse pointer entered. */ function onItemHovered(instance, itemUi) { if (instance._options.disabled || !isEnabledOptionUi(itemUi)) { return; } if (instance._options.multiple) { if (instance._dragSelectionStartOption) { updateMultiSelection(instance, itemUi); } return; } instance._root.setAttribute('data-szn-select--options--highlighting', ''); const previouslyHighlighted = instance._root.querySelector('[data-szn-select--options--highlighted]'); if (previouslyHighlighted) { previouslyHighlighted.removeAttribute('data-szn-select--options--highlighted'); } itemUi.setAttribute('data-szn-select--options--highlighted', ''); } /** * Handles the user releasing the primary mouse button over an element representing an item. * * The function ends multiple-items selection for multi-selects, ends options highlighting and marks the the selected * option for single-selects. * * @param {SznSelectOptions} instance The szn-options element instance. * @param {Element} itemUi The element at which the user released the primary mouse button. */ function onItemClicked(instance, itemUi) { if (instance._dragSelectionStartOption) { // multi-select instance._dragSelectionStartOption = null; return; } if (instance._options.disabled || !isEnabledOptionUi(itemUi)) { return; } instance._root.removeAttribute('data-szn-select--options--highlighting'); instance._options.selectedIndex = itemUi._option.index; instance._options.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: true })); } /** * Handles start of the user dragging the mouse pointer over the UI of a multi-selection szn-options element. The * function marks the starting item. * * The function marks the starting item used previously as the current starting item if the Shift key is pressed. The * function marks the indexes of the currently selected items if the Ctrl key is pressed and the Shift key is not. * Also, if the Ctrl key pressed, the Shift key is not, and the user starts at an already selected item, the function * will mark this as inverted selection. * * The function has no effect for single-selects. * * @param {SznSelectOptions} instance The szn-options element instance. * @param {Element} itemUi The element at which the user pressed the primary mouse button down. * @param {MouseEvent} event The mouse event representing the user's action. */ function onItemSelectionStart(instance, itemUi, event) { if (instance._options.disabled || !instance._options.multiple || !isEnabledOptionUi(itemUi)) { return; } const options = instance._options.options; if (event.shiftKey && instance._previousSelectionStartIndex > -1) { instance._dragSelectionStartOption = options.item(instance._previousSelectionStartIndex); } else { if (event.ctrlKey) { instance._additionalSelectedIndexes = []; for (let i = 0, length = options.length; i < length; i++) { if (options.item(i).selected) { instance._additionalSelectedIndexes.push(i); } } instance._invertSelection = itemUi._option.selected; } else { instance._invertSelection = false; } instance._dragSelectionStartOption = itemUi._option; } instance._previousSelectionStartIndex = instance._dragSelectionStartOption.index; updateMultiSelection(instance, itemUi); } /** * Scrolls, only if necessary, the UI of the provided szn-options element to make the last selected option visible. * Which option is the last selected one is determined by comparing the provided index with the indexes passed to the * previous call of this function. * * @param {SznSelectOptions} instance The szn-options element instance. * @param {number} selectionStartIndex The index of the first selected option. The index must be a non-negative * integer and cannot be greater that the total number of options; or set to <code>-1</code> if there is no * option currently selected. * @param {number} selectionEndIndex The index of the last selected option. The index must be a non-negative integer, * cannot be greater than the total number of options and must not be lower than the * <code>selectionStartIndex</code>; or set to <code>-1</code> if there is no option currently selected. */ function scrollToSelection(instance, selectionStartIndex, selectionEndIndex) { const lastSelectionIndexes = instance._lastSelectionIndexes; if (selectionStartIndex !== -1 && (selectionStartIndex !== lastSelectionIndexes.start || selectionEndIndex !== lastSelectionIndexes.end)) { const changedIndex = selectionStartIndex !== lastSelectionIndexes.start ? selectionStartIndex : selectionEndIndex; scrollToOption(instance, changedIndex); } lastSelectionIndexes.start = selectionStartIndex; lastSelectionIndexes.end = selectionEndIndex; } /** * Scrolls, only if necessary, the UI of the provided szn-options element to make the option at the specified index * fully visible. * * @param {SznSelectOptions} instance The szn-options element instance. * @param {number} optionIndex The index of the option to select. The index must be a non-negative integer and cannot * be greater than the total number of options. */ function scrollToOption(instance, optionIndex) { const ui = instance._root; if (ui.clientHeight >= ui.scrollHeight) { return; } const uiBounds = ui.getBoundingClientRect(); const options = instance._root.querySelectorAll('[data-szn-select--options--option]'); const optionBounds = options[optionIndex].getBoundingClientRect(); if (optionBounds.top >= uiBounds.top && optionBounds.bottom <= uiBounds.bottom) { return; } const delta = optionBounds.top < uiBounds.top ? optionBounds.top - uiBounds.top : optionBounds.bottom - uiBounds.bottom; ui.scrollTop += delta; } /** * Updates the multiple-items selection. This function is meant to be used with multi-selects when the user is * selecting multiple items by dragging the mouse pointer over them. * * Any item which's index is in the provided instance's list of additionally selected items will be marked as * selected as well. * * @param {SznSelectOptions} instance The szn-options element instance. * @param {Element} lastHoveredItem The element representing the UI of the last option the user has hovered using * their mouse pointer. */ function updateMultiSelection(instance, lastHoveredItem) { const startIndex = instance._dragSelectionStartOption.index; const lastIndex = lastHoveredItem._option.index; const minIndex = Math.min(startIndex, lastIndex); const maxIndex = Math.max(startIndex, lastIndex); const options = instance._options.options; const additionalIndexes = instance._additionalSelectedIndexes; for (let i = 0, length = options.length; i < length; i++) { const option = options.item(i); if (isOptionEnabled(option)) { let isOptionSelected = additionalIndexes.indexOf(i) > -1; if (i >= minIndex && i <= maxIndex) { isOptionSelected = !instance._invertSelection; } option.selected = isOptionSelected; } } instance._options.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: true })); } /** * Tests whether the provided elements represents the UI of an enabled option. * * @param {Element} optionUi The UI element to test. * @return {boolean} <code>true</code> iff the option is enabled and can be interacted with. * @see isOptionEnabled */ function isEnabledOptionUi(optionUi) { return optionUi.hasAttribute('data-szn-select--options--option') && isOptionEnabled(optionUi._option); } /** * Tests whether the provided option is enabled - it is not disabled nor it is a child of a disabled options group. * The provided option cannot be an orphan. * * @param {HTMLOptionElement} option The option element to test. * @return {boolean} <code>true</code> iff the option is enabled and can be interacted with. */ function isOptionEnabled(option) { return !option.disabled && !option.parentNode.disabled; } /** * Updates the UI, if the provided szn-options element has already been provided with the options to display. The * functions synchronizes the displayed UI to reflect the available options, their status, and scrolls to the last * selected option if it is not visible. * * @param {SznSelectOptions} instance The szn-options element's instance. */ function updateUi(instance) { if (!instance._options) { return; } if (instance._options.disabled) { instance._root.setAttribute('disabled', ''); } else { instance._root.removeAttribute('disabled'); } if (instance._options.multiple) { instance._root.setAttribute('data-szn-select--options--multiple', ''); } else { instance._root.removeAttribute('data-szn-select--options--multiple'); } updateGroupUi(instance._root, instance._options); if (instance._mounted) { const options = instance._options.options; let lastSelectedIndex = -1; for (let i = options.length - 1; i >= 0; i--) { if (options.item(i).selected) { lastSelectedIndex = i; break; } } scrollToSelection(instance, instance._options.selectedIndex, lastSelectedIndex); } } /** * Updates the contents of the provided UI to reflect the options in the provided options container. The function * removes removed options from the UI, updates the existing and adds the missing ones. * * @param {Element} uiContainer The element containing the constructed UI reflecting the provided options. * @param {HTMLElement} optionsGroup The element containing the options to be reflected in the UI. */ function updateGroupUi(uiContainer, optionsGroup) { removeRemovedItems(uiContainer, optionsGroup); updateExistingItems(uiContainer); addMissingItems(uiContainer, optionsGroup); } /** * Removes UI items from the UI that have been representing the options and option groups that have been removed from * the provided container of options. * * @param {Element} uiContainer The element containing the elements reflecting the provided options and providing the * UI for the options. * @param {HTMLElement} optionsGroup The element containing the options for which this szn-options element is * providing the UI. */ function removeRemovedItems(uiContainer, optionsGroup) { const options = Array.prototype.slice.call(optionsGroup.children); let currentItemUi = uiContainer.firstElementChild; while (currentItemUi) { if (options.indexOf(currentItemUi._option) > -1) { currentItemUi = currentItemUi.nextElementSibling; continue; } const itemToRemove = currentItemUi; currentItemUi = currentItemUi.nextElementSibling; uiContainer.removeChild(itemToRemove); } } /** * Updates all items in the provided UI container to reflect the current state of their associated options. * * @param {Element} groupUi The element containing the elements representing the UIs of the options. */ function updateExistingItems(groupUi) { let itemUi = groupUi.firstElementChild; while (itemUi) { updateItem(itemUi); itemUi = itemUi.nextElementSibling; } } /** * Updates the UI item to reflect the current state of its associated <code>option</code>/<code>optgroup</code> * element. * * If the element represents an option group (<code>optgroup</code>), the children options will be updated as well. * * @param {HTMLElement} itemUi The element representing the UI of an <code>option</code>/<code>optgroup</code> * element. */ function updateItem(itemUi) { const option = itemUi._option; if (option.disabled) { itemUi.setAttribute('disabled', ''); } else { itemUi.removeAttribute('disabled'); } if (option.tagName === 'OPTGROUP') { updateGroupUi(itemUi, option); itemUi.setAttribute('data-szn-select--options--optgroup-label', option.label); return; } itemUi.innerText = option.text; if (option.title) { itemUi.setAttribute('title', option.title); } else { itemUi.removeAttribute('title'); } if (option.selected) { itemUi.setAttribute('data-szn-select--options--selected', ''); } else { itemUi.removeAttribute('data-szn-select--options--selected'); } } /** * Adds the options present in the options container missing the UI into the UI, while preserving the order of the * options. Option groups are added recursively. * * @param {Element} groupUi The element containing the UIs of the options. The new options will be inserted into this * element's children. * @param {HTMLElement} options An element containing the <code>option</code> and <code>optgroup</code> elements that * the UI reflects. */ function addMissingItems(groupUi, options) { let nextItemUi = groupUi.firstElementChild; let nextOption = options.firstElementChild; while (nextOption) { if (!nextItemUi || nextItemUi._option !== nextOption) { const newItemUi = document.createElement('szn-'); newItemUi._option = nextOption; newItemUi.setAttribute('data-szn-select--options--' + (nextOption.tagName === 'OPTGROUP' ? 'optgroup' : 'option'), ''); updateItem(newItemUi); groupUi.insertBefore(newItemUi, nextItemUi); } else { nextItemUi = nextItemUi && nextItemUi.nextElementSibling; } nextOption = nextOption.nextElementSibling; } } SznElements['szn-select--options'] = SznSelectOptions; })(self); 'use strict'; (global => { const SznElements = global.SznElements = global.SznElements || {}; const CSS_STYLES = ` szn-select--ui{display:inline-block;width:100%;height:100%}szn-select--ui[data-szn-select--ui--active] szn-select--button,szn-select--ui[data-szn-select--ui--active] szn-select--options{border-color:#7dbfff;border-color:var(--szn-select--ui--active-border-color,#7dbfff);box-shadow:0 0 3px rgba(0,132,255,.4);box-shadow:var(--szn-select--ui--active-box-shadow,0 0 3px rgba(0,132,255,.4))}body>szn-tethered>[data-szn-select--ui--dropdown]{display:block;top:-1px;top:var(--szn-select--ui--dropdown-offset,-1px);min-width:109px;min-width:var(--szn-select--ui--dropdown-min-width,109px)}body>szn-tethered[data-vertical-align=top]>[data-szn-select--ui--dropdown]{bottom:-1px;bottom:var(--szn-select--ui--dropdown-offset,-1px)} `; const MIN_BOTTOM_SPACE = 160; // px const INTERACTION_DOM_EVENTS = ['mousedown', 'click', 'touchstart']; const RESIZE_RELATED_DOM_EVENTS = ['resize', 'scroll', 'wheel', 'touchmove']; const DEFAULT_DROPDOWN_CONTAINER = document.body; SznElements['szn-select--ui'] = class SznSelectUi { constructor(rootElement) { if (!rootElement.hasOwnProperty('minBottomSpace')) { Object.defineProperty(rootElement, 'minBottomSpace', { get: () => rootElement._broker._minBottomSpace, set: value => { if (rootElement._broker._dropdown && rootElement._broker._dropdown._broker) { rootElement._broker._dropdown.minBottomSpace = value; } rootElement._broker._minBottomSpace = value; } }); } if (!rootElement.hasOwnProperty('dropdownClassName')) { Object.defineProperty(rootElement, 'dropdownClassName', { get: () => rootElement._broker._dropdownClassName, set: value => { const broker = rootElement._broker; broker._dropdownClassName = value; if (broker._dropdownOptions && broker._select && !broker._select.multiple) { broker._dropdownOptions.className = value; } } }); } if (!rootElement.hasOwnProperty('dropdownContainer')) { Object.defineProperty(rootElement, 'dropdownContainer', { get: () => rootElement._broker._dropdownContainer, set: value => { if (typeof Node === 'function' /* IE8 */ && !(value instanceof Node)) { throw new TypeError('The provided dropdown container is not a DOM node: ' + value); } const instance = rootElement._broker; if (!instance || value === instance._dropdownContainer) { return; } const isDropdownRebuildNeeded = [instance._dropdownContainer, value].includes(DEFAULT_DROPDOWN_CONTAINER); instance._dropdownContainer = value; if (!instance._dropdown || !instance._dropdown.parentNode) { return; } if (isDropdownRebuildNeeded) { instance._dropdown = createDropdown(instance); value.appendChild(instance._dropdown); if (value === DEFAULT_DROPDOWN_CONTAINER) { onDropdownPositionChange(instance, instance._dropdown.verticalAlignment); } else { instance._dropdownContent.style.height = ''; } } else { value.appendChild(instance._dropdown); } } }); } rootElement.setSelectElement = this.setSelectElement.bind(this); rootElement.setFocus = this.setFocus.bind(this); rootElement.setOpen = this.setOpen.bind(this); rootElement.onUiInteracted = rootElement.onUiInteracted || null; this._root = rootElement; this._select = null; this._button = null; this._dropdown = null; this._dropdownClassName = ''; this._dropdownPosition = null; this._dropdownContent = SznElements.buildDom('<szn- data-szn-select--ui--dropdown data-szn-tethered--content></szn->'); this._dropdownOptions = null; this._dropdownContainer = DEFAULT_DROPDOWN_CONTAINER; this._minBottomSpace = MIN_BOTTOM_SPACE; this._observer = new MutationObserver(onDomMutated.bind(this)); this._onDropdownPositionChange = onDropdownPositionChange.bind(null, this); this._onDropdownSizeUpdateNeeded = onDropdownSizeUpdateNeeded.bind(null, this); this._onUiInteracted = onUiInteracted.bind(null, this); SznElements.injectStyles(CSS_STYLES, 'szn-select--ui'); } onMount() { this._root.setAttribute('aria-hidden', 'true'); addEventListeners(this); this._observer.observe(this._root, { childList: true, attributes: true, characterData: true, subtree: true, attributeFilter: ['disabled', 'multiple', 'selected'] }); } onUnmount() { if (this._dropdown && this._dropdown.parentNode) { this._dropdown.parentNode.removeChild(this._dropdown); } removeEventListeners(this); this._observer.disconnect(); } setSelectElement(select) { if (select === this._select) { return; } this._select = select; createUI(this); } setOpen(isOpen) { if (this._select.multiple || this._select.disabled) { return; } if (isOpen) { if (this._button._broker) { this._button.setOpen(true); } this._dropdownContainer.appendChild(this._dropdown); let dropdownReady = false; let optionsReady = false; SznElements.awaitElementReady(this._dropdown, () => { dropdownReady = true; if (optionsReady) { initDropdown(this, this._dropdown, this._dropdownOptions); } }); SznElements.awaitElementReady(this._dropdownOptions, () => { optionsReady = true; if (dropdownReady) { initDropdown(this, this._dropdown, this._dropdownOptions); } }); } else { if (!this._dropdown.parentNode) { return; } if (this._button._broker) { this._button.setOpen(false); } this._dropdown.parentNode.removeChild(this._dropdown); } } setFocus(hasFocus) { if (hasFocus) { this._root.setAttribute('data-szn-select--ui--active', ''); } else { this._root.removeAttribute('data-szn-select--ui--active'); } } }; function addEventListeners(instance) { for (const eventType of INTERACTION_DOM_EVENTS) { instance._root.addEventListener(eventType, instance._onUiInteracted); } for (const eventType of RESIZE_RELATED_DOM_EVENTS) { addEventListener(eventType, instance._onDropdownSizeUpdateNeeded); } } function removeEventListeners(instance) { for (const eventType of INTERACTION_DOM_EVENTS) { instance._root.removeEventListener(eventType, instance._onUiInteracted); instance._dropdownContent.removeEventListener(eventType, instance._onUiInteracted); instance._dropdownContainer.removeEventListener(eventType, instance._onUiInteracted); } for (const eventType of RESIZE_RELATED_DOM_EVENTS) { removeEventListener(eventType, instance._onDropdownSizeUpdateNeeded); } } function onUiInteracted(instance, event) { if (instance._root.onUiInteracted) { instance._root.onUiInteracted(event); } } function onDomMutated(instance) { // Since we are mutating our subtree, there will be false positives, so we always need to check what has changed const select = instance._select; if (select && (select.multiple && instance._button || !select.multiple && !instance._button)) { createUI(instance); } } function onDropdownSizeUpdateNeeded(instance) { if (!instance._dropdown || !instance._dropdown._broker || !instance._dropdownOptions._broker || instance._dropdownContainer !== DEFAULT_DROPDOWN_CONTAINER) { return; } const