selectlist-polyfill
Version:
Polyfill for the selectlist element
455 lines (439 loc) • 17 kB
JavaScript
(() => {
// 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>
{
${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;
}
})();