UNPKG

selectlist-polyfill

Version:
455 lines (439 loc) 17 kB
(() => { // src/selectlist.js var popoverSupported = typeof HTMLElement < "u" && typeof HTMLElement.prototype == "object" && "popover" in HTMLElement.prototype, 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; } ` ), listboxStyles = ( /* css */ ` [behavior="listbox"] { box-sizing: border-box; margin: 0; min-block-size: 1lh; max-block-size: inherit; min-inline-size: inherit; inset: inherit; } ` ), headTemplate = document.createElement("template"); headTemplate.innerHTML = /* html */ ` <style> @layer { ${popoverStyles} x-selectlist ${listboxStyles} } </style> `; document.head.prepend(headTemplate.content.cloneNode(!0)); var 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"]) { } [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> `; var SelectListElement = class extends globalThis.HTMLElement { static formAssociated = !0; 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(!0)), this.addEventListener("click", this.#onClick, !0), 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]"); return selectedValue || (selectedValue = this.shadowRoot.querySelector("[behavior=selected-value]")), selectedValue; } get #buttonEl() { let button = this.querySelector("[behavior=button]"); return button || (button = this.shadowRoot.querySelector("[behavior=button]")), button; } get #listboxEl() { let listbox = this.querySelector("[behavior=listbox]"); return listbox || (listbox = this.shadowRoot.querySelector("[behavior=listbox]")), 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) { let option = this.options.find((option2) => option2.index === index); option && this.#selectOption(option); } get value() { return this.options.find((option) => option.selected)?.value ?? ""; } set value(val) { let option = this.options.find((option2) => option2.value === val); option && this.#selectOption(option); } get required() { return this.hasAttribute("required"); } set required(flag) { this.toggleAttribute("required", !!flag); } get disabled() { return this.hasAttribute("disabled"); } set disabled(flag) { this.toggleAttribute("disabled", !!flag); } get multiple() { return this.hasAttribute("multiple"); } set multiple(flag) { this.toggleAttribute("multiple", !!flag); } attributeChangedCallback(name, oldVal, newVal) { let attrToAria = { disabled: "ariaDisabled", required: "ariaRequired" }; name in attrToAria && (this.#internals[attrToAria[name]] = newVal != null ? "true" : "false"); } _optionSelectionChanged(option, selected) { selected && this.#selectOption(option); } #selectOption(option) { let allOptions = this.options, newSelectedOptions = [option].flat(); allOptions.forEach((opt) => opt._setSelectedState(!1)), newSelectedOptions.forEach((opt) => opt._setSelectedState(!0)), 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, selectedOption; for (let option of this.options) option.selected ? (selectedOption && !this.multiple && selectedOption._setSelectedState?.(!1), option._setSelectedState?.(!0), selectedOption = option) : option._setSelectedState?.(!1), !firstOption && !option.disabled && (firstOption = option); !selectedOption && firstOption && !this.multiple && firstOption._setSelectedState?.(!0), this.#selectionChanged(); } #onClick = (event) => { if (this.disabled) return; let path = event.composedPath(), selectedOption; path.some((el) => el === this.#buttonEl) ? this.#isOpen() || this.#show() : path.some((el) => this.options.includes(el) && (selectedOption = el)) && (this.#userSelect(selectedOption), this.#hide()); }; #onBlur = (event) => { event.composedPath().some((el) => el === this) || this.#hide(); }; #onKeydown = (event) => { if (this.disabled) return; let { key } = event, activeOptions = this.options.filter((opt) => !opt.disabled), currentOption = activeOptions.find((el) => el.tabIndex === 0) ?? activeOptions[0]; if (key === "Escape") { this.#hide(); return; } if (key === "Enter" || key === " ") { if (event.preventDefault(), !this.#isOpen()) { this.#show(); return; } key === "Enter" && !this.multiple && (this.#userSelect(currentOption), this.#hide()); return; } if (this.#isOpen() && ["ArrowUp", "ArrowDown", "Home", "End"].includes(key)) { event.preventDefault(); let currentIndex = activeOptions.indexOf(currentOption), newIndex = Math.max(0, currentIndex); key === "ArrowDown" ? newIndex = Math.min(currentIndex + 1, activeOptions.length - 1) : key === "ArrowUp" ? newIndex = Math.max(0, currentIndex - 1) : event.key === "Home" ? newIndex = 0 : event.key === "End" && (newIndex = activeOptions.length - 1), this.options.forEach((option) => option.tabIndex = "-1"), currentOption = activeOptions[newIndex], currentOption.tabIndex = 0, currentOption.focus(); } }; #userSelect(option) { let oldSelectedOptions = [...this.selectedOptions]; option.selected = !0, this.selectedOptions.some((opt, i) => opt != oldSelectedOptions[i]) && (this.dispatchEvent(new Event("input", { bubbles: !0, composed: !0 })), this.dispatchEvent(new Event("change", { bubbles: !0 }))); } #handleReposition = () => { 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", this.#listboxEl.showPopover ? this.#listboxEl.showPopover() : this.#listboxEl.classList.add(":popover-open"), reposition(this, this.#listboxEl); let activeOptions = this.options.filter((opt) => !opt.disabled), 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", this.#isOpen() && (this.#listboxEl.hidePopover ? this.#listboxEl.hidePopover() : 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"; let container = { top: 0, height: window.innerHeight }, refBox = reference.getBoundingClientRect(); style.minInlineSize = `${refBox.width}px`, style.insetBlockStart = `${refBox.bottom}px`; let popBox = popover.getBoundingClientRect(); style.insetInlineStart = `${refBox.left}px`; let bottomOverflow = popBox.bottom - container.height; if (bottomOverflow > 0) { let minHeightBeforeFlip = (reference.options[0]?.offsetHeight ?? 50) * 1.3, 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(); let topOverflow = container.top - popBox.top; newHeight = popBox.height - topOverflow, topOverflow > 0 && (style.insetBlockStart = container.top, style.insetBlockEnd = "auto", style.maxBlockSize = `${newHeight}px`); } } } function getCSSRule(styleParent, selectorText) { let style; for (style of styleParent.querySelectorAll("style")) { let cssRules; try { cssRules = style.sheet?.cssRules; } catch { continue; } for (let rule of cssRules ?? []) if (rule.selectorText === selectorText) return rule; } return {}; } globalThis.customElements.get("x-selectlist") || globalThis.customElements.define("x-selectlist", SelectListElement); var selectlist_default = SelectListElement; // src/option.js var template2 = document.createElement("template"); template2.innerHTML = /* html */ ` <style> :host { display: block; list-style: none; line-height: revert; white-space: nowrap; white-space-collapse: collapse; text-wrap: nowrap; min-height: 1.2em; padding: .25em; font-size: .875em; } :host(:hover) { background-color: selecteditem; color: selecteditemtext; cursor: default; user-select: none; } :host([disabled]) { pointer-events: none; color: rgba(16, 16, 16, 0.3); } :host(.\\:checked[disabled]) { background-color: rgb(176, 176, 176); } :host(:focus-visible) { outline: -webkit-focus-ring-color auto 1px; } </style> <slot></slot> `; var OptionElement = class extends globalThis.HTMLElement { static formAssociated = !0; static observedAttributes = ["disabled", "selected"]; /** @see https://html.spec.whatwg.org/multipage/form-elements.html#concept-option-dirtiness */ #dirty = !1; #internals; #selected = !1; constructor() { super(), this.#internals = this.attachInternals?.() ?? {}, this.#internals.role = "option", this.attachShadow({ mode: "open" }), this.shadowRoot.append(template2.content.cloneNode(!0)); } attributeChangedCallback(name, oldVal, newVal) { oldVal !== newVal && (name === "selected" && !this.#dirty && (this._setSelectedState(newVal != null), this.#ownerElement()?.reset()), name === "disabled" && (this.#internals.ariaDisabled = this.disabled ? "true" : "false", this.#ownerElement()?.reset())); } connectedCallback() { this.#ownerElement()?.reset(); } disconnectedCallback() { this.#ownerElement()?.reset(); } #ownerElement() { return this.closest("x-selectlist"); } get index() { return this.#ownerElement()?.options.findIndex((option) => option === this) ?? 0; } get label() { return this.getAttribute("label") ?? this.text; } set label(val) { this.setAttribute("label", val); } get value() { return this.getAttribute("value") ?? this.text; } set value(val) { this.setAttribute("value", val); } get text() { return (this.textContent ?? "").trim(); } get selected() { return this.#selected; } set selected(selected) { this.#dirty = !0, this._setSelectedState(selected), this.#ownerElement()?._optionSelectionChanged(this, selected); } get defaultSelected() { return this.hasAttribute("selected"); } set defaultSelected(flag) { this.toggleAttribute("selected", !!flag); } get disabled() { return this.hasAttribute("disabled"); } set disabled(flag) { this.toggleAttribute("disabled", !!flag); } _setSelectedState(selected) { selected ? (this.#selected = !0, this.#internals.ariaSelected = "true", this.classList.add(":checked")) : (this.#selected = !1, this.#internals.ariaSelected = "false", this.classList.remove(":checked")); } }; globalThis.customElements.get("x-option") || globalThis.customElements.define("x-option", OptionElement); var option_default = OptionElement; // src/polyfill.js globalThis.HTMLSelectListElement || (observeElement(document, "selectlist", (element) => { element.replaceWith(convertElementToType(element, "x-selectlist")); }), observeElement(document, "option", (element) => { (element.closest("x-selectlist") || element.closest("selectlist")) && element.replaceWith(convertElementToType(element, "x-option")); })); function observeElement(rootNode, type, callback) { let upgrade = (node) => { rootNode.querySelectorAll?.(type).forEach(callback), node.localName === type && callback(node); }; new MutationObserver((mutationsList) => { for (let mutation of mutationsList) mutation.type === "childList" && mutation.addedNodes.forEach(upgrade); }).observe(rootNode, { childList: !0, subtree: !0 }), rootNode.querySelectorAll(type).forEach(callback); } function convertElementToType(el, type) { let childNodes = [...el.childNodes], attributes = [...el.attributes], replacement = document.createElement(type); for (let { name, value } of attributes) replacement.setAttribute(name, value); return replacement.append(...childNodes), replacement; } })();