UNPKG

selectlist-polyfill

Version:
549 lines (433 loc) 13.8 kB
const popoverSupported = typeof HTMLElement !== 'undefined' && typeof HTMLElement.prototype === 'object' && 'popover' in HTMLElement.prototype; const popoverStyles = popoverSupported ? '' : /* css */` [popover] { position: fixed; z-index: 2147483647; padding: .25em; width: fit-content; border: solid; background: canvas; color: canvastext; overflow: auto; margin: auto; } [popover]:not(.\\:popover-open) { display: none; } `; const listboxStyles = /* css */` [behavior="listbox"] { box-sizing: border-box; margin: 0; min-block-size: 1lh; max-block-size: inherit; min-inline-size: inherit; inset: inherit; } `; /** * CSS @layer: any styles declared outside of a layer will override styles * declared in a layer, regardless of specificity. * https://developer.mozilla.org/en-US/docs/Web/CSS/@layer */ const headTemplate = document.createElement('template'); headTemplate.innerHTML = /* html */` <style> @layer { ${popoverStyles} x-selectlist ${listboxStyles} } </style> `; document.head.prepend(headTemplate.content.cloneNode(true)); const template = document.createElement('template'); template.innerHTML = /* html */` <style> ${popoverStyles} ${listboxStyles} :host { display: inline-block; position: relative; font-size: .875em; } [part="button"] { display: inline-flex; align-items: center; background-color: field; cursor: default; appearance: none; border-radius: .25em; padding: .25em; border-width: 1px; border-style: solid; border-color: rgb(118, 118, 118); border-image: initial; color: buttontext; line-height: min(1.3em, 15px); } :host([disabled]) [part="button"] { background-color: rgba(239, 239, 239, .3); color: graytext; opacity: .7; border-color: rgba(118, 118, 118, .3); } [part="marker"] { height: 1em; margin-inline-start: 4px; opacity: 1; padding-bottom: 2px; padding-inline-start: 3px; padding-inline-end: 3px; padding-top: 2px; width: 1.2em; } slot[name="listbox"], ::slotted([slot="listbox"]) { ${/* min-inline-size overridden below by selectlist width */''} } [part="listbox"] { box-sizing: border-box; box-shadow: rgba(0, 0, 0, .13) 0px 12.8px 28.8px, rgba(0, 0, 0, .11) 0px 0px 9.2px; min-block-size: 1lh; border-width: 1px; border-style: solid; border-color: rgba(0, 0, 0, .15); border-image: initial; border-radius: .25em; padding: .25em 0; } </style> <slot name="button"> <button part="button" behavior="button" aria-haspopup="listbox"> <slot name="selected-value"> <div part="selected-value" behavior="selected-value"></div> </slot> <slot name="marker"> <svg part="marker" width="20" height="14" viewBox="0 0 20 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M4 6 L10 12 L 16 6" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/> </svg> </slot> </button> </slot> <slot name="listbox"> <div popover part="listbox" behavior="listbox" role="listbox"> <slot></slot> </div> </slot> `; class SelectListElement extends globalThis.HTMLElement { static formAssociated = true; static observedAttributes = ['disabled', 'required', 'multiple']; #internals; constructor() { super(); this.#internals = this.attachInternals?.() ?? {}; this.#internals.role = 'combobox'; this.#internals.ariaExpanded = 'false'; this.#internals.ariaDisabled = 'false'; this.#internals.ariaRequired = 'false'; this.attachShadow({ mode: 'open' }); this.shadowRoot.append(template.content.cloneNode(true)); this.addEventListener('click', this.#onClick, true); this.addEventListener('keydown', this.#onKeydown); } get #buttonSlot() { return this.shadowRoot.querySelector('slot[name=button]'); } get #listboxSlot() { return this.shadowRoot.querySelector('slot[name=listbox]'); } get #defaultSlot() { return this.shadowRoot.querySelector('slot:not([name])'); } get #selectedValue() { let selectedValue = this.querySelector('[behavior=selected-value]'); if (!selectedValue) { selectedValue = this.shadowRoot.querySelector('[behavior=selected-value]'); } return selectedValue; } get #buttonEl() { let button = this.querySelector('[behavior=button]'); if (!button) { button = this.shadowRoot.querySelector('[behavior=button]'); } return button; } get #listboxEl() { let listbox = this.querySelector('[behavior=listbox]'); if (!listbox) { listbox = this.shadowRoot.querySelector('[behavior=listbox]'); } return listbox; } get form() { return this.#internals.form; } get name() { return this.getAttribute('name'); } get options() { return [...this.querySelectorAll('x-option')]; } get selectedOptions() { return this.options.filter(option => option.selected); } get selectedOption() { return this.options.find(option => option.selected); } get selectedIndex() { return this.options.findIndex(option => option.selected); } set selectedIndex(index) { const option = this.options.find(option => option.index === index); if (option) { this.#selectOption(option); } } get value() { return this.options.find(option => option.selected)?.value ?? ''; } set value(val) { const option = this.options.find(option => option.value === val); if (option) { this.#selectOption(option); } } get required() { return this.hasAttribute('required'); } set required(flag) { this.toggleAttribute('required', Boolean(flag)); } get disabled() { return this.hasAttribute('disabled'); } set disabled(flag) { this.toggleAttribute('disabled', Boolean(flag)); } get multiple() { return this.hasAttribute('multiple'); } set multiple(flag) { this.toggleAttribute('multiple', Boolean(flag)); } attributeChangedCallback(name, oldVal, newVal) { const attrToAria = { disabled: 'ariaDisabled', required: 'ariaRequired', }; if (name in attrToAria) { this.#internals[attrToAria[name]] = newVal != null ? 'true' : 'false'; } } _optionSelectionChanged(option, selected) { if (selected) { this.#selectOption(option); } } #selectOption(option) { const allOptions = this.options; const newSelectedOptions = [option].flat(); allOptions.forEach(opt => (opt._setSelectedState(false))); newSelectedOptions.forEach(opt => (opt._setSelectedState(true))); this.#selectionChanged(); } #selectionChanged() { this.#selectedValue.textContent = this.selectedOption?.label; } /** * Reset for a selectlist is the selectedness setting algorithm. * Child Options's that are added and removed request this. * @see https://html.spec.whatwg.org/multipage/form-elements.html#selectedness-setting-algorithm */ reset() { let firstOption; let selectedOption; for (let option of this.options) { if (option.selected) { if (selectedOption && !this.multiple) { selectedOption._setSelectedState?.(false); } option._setSelectedState?.(true); selectedOption = option; } else { option._setSelectedState?.(false); } if (!firstOption && !option.disabled) { firstOption = option; } } if (!selectedOption && firstOption && !this.multiple) { firstOption._setSelectedState?.(true); } this.#selectionChanged(); } #onClick = (event) => { if (this.disabled) return; const path = event.composedPath(); let selectedOption; // Open / Close if (path.some(el => el === this.#buttonEl)) { if (!this.#isOpen()) { this.#show(); } } else if (path.some(el => this.options.includes(el) && (selectedOption = el))) { this.#userSelect(selectedOption); this.#hide(); } } #onBlur = (event) => { if (event.composedPath().some(el => el === this)) return; this.#hide(); } #onKeydown = (event) => { if (this.disabled) return; const { key } = event; const activeOptions = this.options.filter(opt => !opt.disabled); let currentOption = activeOptions.find(el => el.tabIndex === 0) ?? activeOptions[0]; if (key === 'Escape') { this.#hide(); return; } if (key === 'Enter' || key === ' ') { event.preventDefault(); if (!this.#isOpen()) { this.#show(); return; } if (key === 'Enter' && !this.multiple) { this.#userSelect(currentOption); this.#hide(); } return; } if (this.#isOpen() && ['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key)) { // Prevent scrolling event.preventDefault(); const currentIndex = activeOptions.indexOf(currentOption); let newIndex = Math.max(0, currentIndex); if (key === 'ArrowDown') { newIndex = Math.min(currentIndex + 1, activeOptions.length - 1); } else if (key === 'ArrowUp') { newIndex = Math.max(0, currentIndex - 1); } else if (event.key === 'Home') { newIndex = 0; } else if (event.key === 'End') { newIndex = activeOptions.length - 1; } this.options.forEach(option => (option.tabIndex = '-1')); currentOption = activeOptions[newIndex]; currentOption.tabIndex = 0; currentOption.focus(); } }; #userSelect(option) { const oldSelectedOptions = [...this.selectedOptions]; option.selected = true; if (this.selectedOptions.some((opt, i) => opt != oldSelectedOptions[i])) { this.dispatchEvent(new Event('input', { bubbles: true, composed: true })); this.dispatchEvent(new Event('change', { bubbles: true })); } } #handleReposition = () => { if (this.#isOpen()) { reposition(this, this.#listboxEl); } } #isOpen() { try { return this.#listboxEl.matches(':popover-open'); } catch { return this.#listboxEl.matches('.\\:popover-open'); } } #show() { this.#internals.ariaExpanded = 'true'; if (this.#listboxEl.showPopover) { this.#listboxEl.showPopover(); } else { this.#listboxEl.classList.add(':popover-open'); } reposition(this, this.#listboxEl); const activeOptions = this.options.filter(opt => !opt.disabled); const currentOption = this.selectedOption ?? activeOptions.find(el => el.tabIndex === 0) ?? activeOptions[0]; this.options.forEach(option => (option.tabIndex = '-1')); currentOption.tabIndex = 0; currentOption.focus(); document.addEventListener('click', this.#onBlur); window.addEventListener('resize', this.#handleReposition); window.addEventListener('scroll', this.#handleReposition); } #hide() { this.#buttonEl.focus(); this.#internals.ariaExpanded = 'false'; if (this.#isOpen()) { if (this.#listboxEl.hidePopover) { this.#listboxEl.hidePopover(); } else { this.#listboxEl.classList.remove(':popover-open'); } } document.removeEventListener('click', this.#onBlur); window.removeEventListener('resize', this.#handleReposition); window.removeEventListener('scroll', this.#handleReposition); } } function reposition(reference, popover) { let { style } = getCSSRule(reference.shadowRoot, 'slot[name="listbox"], ::slotted([slot="listbox"])'); style.maxBlockSize = 'initial'; style.insetBlockStart = 'initial'; style.insetBlockEnd = 'initial'; const container = { top: 0, height: window.innerHeight }; const refBox = reference.getBoundingClientRect(); style.minInlineSize = `${refBox.width}px`; style.insetBlockStart = `${refBox.bottom}px`; let popBox = popover.getBoundingClientRect(); style.insetInlineStart = `${refBox.left}px`; const bottomOverflow = popBox.bottom - container.height; if (bottomOverflow > 0) { let minHeightBeforeFlip = (reference.options[0]?.offsetHeight ?? 50) * 1.3; let newHeight = popBox.height - bottomOverflow; if (newHeight > minHeightBeforeFlip) { style.maxBlockSize = `${newHeight}px`; } else { style.insetBlockStart = 'auto'; style.insetBlockEnd = `${container.height - refBox.top}px`; popBox = popover.getBoundingClientRect(); const topOverflow = container.top - popBox.top; newHeight = popBox.height - topOverflow; if (topOverflow > 0) { style.insetBlockStart = container.top; style.insetBlockEnd = 'auto'; style.maxBlockSize = `${newHeight}px`; } } } } /** * Get a CSS rule with a selector in an element containing <style> tags. * @param {Element|ShadowRoot} styleParent * @param {string} selectorText * @return {CSSStyleRule} */ function getCSSRule(styleParent, selectorText) { let style; for (style of styleParent.querySelectorAll('style')) { // Catch this error. e.g. browser extension adds style tags. // Uncaught DOMException: CSSStyleSheet.cssRules getter: // Not allowed to access cross-origin stylesheet let cssRules; try { cssRules = style.sheet?.cssRules; } catch { continue; } for (let rule of cssRules ?? []) if (rule.selectorText === selectorText) return rule; } return {}; } if (!globalThis.customElements.get('x-selectlist')) { globalThis.customElements.define('x-selectlist', SelectListElement); } export default SelectListElement;