2025-01-17 12:21:36 +00:00
|
|
|
|
2024-12-17 17:36:02 +00:00
|
|
|
const template = fn => {
|
|
|
|
return (arr, ...params) => {
|
|
|
|
if (arr instanceof Array) {
|
|
|
|
const buffer = []
|
|
|
|
for (let i = 0; i < params.length; i++) {
|
|
|
|
buffer.push(arr[i])
|
|
|
|
buffer.push(params[i])
|
|
|
|
}
|
|
|
|
buffer.push(arr[arr.length - 1])
|
|
|
|
return fn(buffer.join(""))
|
|
|
|
}
|
|
|
|
return fn(arr)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-12-20 13:21:51 +00:00
|
|
|
/** Outwards iterator over an element's root nodes across shadow DOM boundaries
|
|
|
|
* @param {Element} element
|
|
|
|
*/
|
|
|
|
const ancestorRoots = function*(element) {
|
|
|
|
while (true) {
|
|
|
|
const root = element.getRootNode()
|
|
|
|
yield {root,element}
|
|
|
|
if (root instanceof ShadowRoot) {
|
|
|
|
element = root.host
|
|
|
|
} else {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-12-17 17:36:02 +00:00
|
|
|
const f = template(string => {
|
|
|
|
const template = document.createElement("template")
|
|
|
|
template.innerHTML = string
|
|
|
|
return template.content
|
|
|
|
})
|
|
|
|
|
|
|
|
const css = template(string => {
|
|
|
|
const styleSheet = new CSSStyleSheet
|
|
|
|
styleSheet.replaceSync(string)
|
|
|
|
return styleSheet
|
|
|
|
})
|
|
|
|
|
|
|
|
const childObserver = new MutationObserver(mutations => {
|
2025-01-09 13:21:00 +00:00
|
|
|
const targets = new Set()
|
|
|
|
for (const {target} of mutations) {
|
|
|
|
if (target instanceof BetterSelect)
|
|
|
|
targets.add(target)
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
2025-01-09 13:21:00 +00:00
|
|
|
for (const target of targets)
|
|
|
|
target.mutationCallback()
|
2024-12-17 17:36:02 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
export class BetterSelect extends HTMLElement {
|
2024-12-18 12:41:00 +00:00
|
|
|
/** @type {AbortController} */
|
2024-12-17 17:36:02 +00:00
|
|
|
#abortOpen
|
|
|
|
#value = {}
|
|
|
|
|
|
|
|
#internals = this.attachInternals()
|
|
|
|
static formAssociated = true
|
2025-01-17 12:21:36 +00:00
|
|
|
static observedAttributes = Object.freeze(["placeholder", "search-placeholder"])
|
2024-12-17 17:36:02 +00:00
|
|
|
|
|
|
|
static styleSheet = css`
|
|
|
|
:host {
|
|
|
|
position: relative;
|
2024-12-18 12:41:00 +00:00
|
|
|
display: inline-block;
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
* {
|
|
|
|
box-sizing: border-box;
|
|
|
|
}
|
|
|
|
[part="display"] {
|
2024-12-18 12:41:00 +00:00
|
|
|
min-width: 100%;
|
|
|
|
|
|
|
|
/* Layout */
|
|
|
|
align-items: center;
|
2024-12-17 17:36:02 +00:00
|
|
|
display: inline-flex;
|
|
|
|
flex-flow: row nowrap;
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
/* Styling */
|
2024-12-17 17:36:02 +00:00
|
|
|
cursor: pointer;
|
2024-12-18 12:41:00 +00:00
|
|
|
}
|
|
|
|
[part="display-text"]:empty {
|
|
|
|
display: none;
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
:not(:empty + *)[name="placeholder"] {
|
|
|
|
display: none;
|
|
|
|
}
|
2024-12-18 12:41:00 +00:00
|
|
|
[part="drop-down"], [part="item"] {
|
2024-12-17 17:36:02 +00:00
|
|
|
/* Resets */
|
|
|
|
border: unset;
|
2024-12-18 12:41:00 +00:00
|
|
|
outline: unset;
|
2024-12-17 17:36:02 +00:00
|
|
|
padding: unset;
|
2024-12-18 12:41:00 +00:00
|
|
|
}
|
|
|
|
[part="drop-down"] {
|
2024-12-17 17:36:02 +00:00
|
|
|
background: inherit;
|
|
|
|
color: inherit;
|
|
|
|
|
|
|
|
position: absolute;
|
|
|
|
flex-flow: column;
|
|
|
|
margin: 0;
|
2024-12-19 13:53:01 +00:00
|
|
|
z-index: var(--layer-dropdown, 100);
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
[part="drop-down"]:modal {
|
|
|
|
margin: auto;
|
|
|
|
&::backdrop {
|
|
|
|
background-color: #fff2;
|
|
|
|
backdrop-filter: blur(2px);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
[part="drop-down"][open] {
|
|
|
|
display: flex;
|
|
|
|
}
|
|
|
|
[part="list"] {
|
|
|
|
display: contents;
|
|
|
|
}
|
|
|
|
[part="item"] {
|
|
|
|
display: block;
|
|
|
|
cursor: pointer;
|
2024-12-18 12:41:00 +00:00
|
|
|
white-space: nowrap;
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
[part="item"]:focus {
|
|
|
|
font-weight: bold;
|
|
|
|
}
|
|
|
|
[part="item"][hidden] {
|
|
|
|
display: none;
|
|
|
|
}
|
|
|
|
slot[name="loading"] {
|
|
|
|
display: none;
|
|
|
|
}
|
2024-12-18 12:41:00 +00:00
|
|
|
:host(:state(--loading)) {
|
|
|
|
[part="list"] { display: none; }
|
|
|
|
slot[name="loading"] { display: block; }
|
|
|
|
}
|
2024-12-17 17:36:02 +00:00
|
|
|
`
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
/** @type {HTMLElement} */
|
|
|
|
display
|
|
|
|
/** @type {HTMLElement} */
|
|
|
|
text
|
|
|
|
/** @type {HTMLElement} */
|
|
|
|
list
|
|
|
|
/** @type {HTMLElement} */
|
|
|
|
placeholder
|
|
|
|
/** @type {HTMLInputElement} */
|
|
|
|
input
|
|
|
|
/** @type {HTMLDialogElement} */
|
|
|
|
dialog
|
|
|
|
/** @type {HTMLDialogElement} */
|
|
|
|
loading
|
|
|
|
|
2024-12-17 17:36:02 +00:00
|
|
|
constructor() {
|
|
|
|
super()
|
|
|
|
childObserver.observe(this, {childList: true})
|
|
|
|
this.attachShadow({mode: "open"}).innerHTML = `
|
|
|
|
<div id="display" part="display">
|
|
|
|
<span part="display-text" id="text"></span>
|
2024-12-18 12:41:00 +00:00
|
|
|
<slot name="placeholder" aria-hidden="true">
|
2024-12-19 14:19:32 +00:00
|
|
|
<span part="placeholder" id="placeholder" aria-hidden="true"></span>
|
2024-12-18 12:41:00 +00:00
|
|
|
</slot>
|
2024-12-17 17:36:02 +00:00
|
|
|
</div>
|
|
|
|
<dialog id="dialog" part="drop-down">
|
2025-01-15 15:34:38 +00:00
|
|
|
<slot name="top"></slot>
|
2024-12-19 14:26:07 +00:00
|
|
|
<input type="search" id="input" part="search" type="search"></input>
|
2025-01-15 15:34:38 +00:00
|
|
|
<slot name="below-search"></slot>
|
2024-12-17 17:36:02 +00:00
|
|
|
<ul id="list" part="list"></ul>
|
2025-01-15 14:29:43 +00:00
|
|
|
<slot name="bottom"></slot>
|
2024-12-18 12:41:00 +00:00
|
|
|
<slot id="loading" name="loading"></slot>
|
2024-12-17 17:36:02 +00:00
|
|
|
</dialog>
|
|
|
|
`
|
|
|
|
this.shadowRoot.adoptedStyleSheets = [BetterSelect.styleSheet]
|
2024-12-18 14:38:11 +00:00
|
|
|
this.#internals.setFormValue("", "")
|
2024-12-17 17:36:02 +00:00
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
this.tabIndex = 0
|
|
|
|
|
|
|
|
this.#internals.role = "combobox"
|
2024-12-17 17:36:02 +00:00
|
|
|
|
|
|
|
this.options = this.getElementsByTagName("option")
|
2024-12-18 12:41:00 +00:00
|
|
|
for (const element of this.shadowRoot.querySelectorAll(`[id]`)) {
|
|
|
|
this[element.id] = element
|
|
|
|
}
|
2024-12-17 17:36:02 +00:00
|
|
|
|
|
|
|
this.shadowRoot.addEventListener("click", event => {
|
|
|
|
const item = event.target.closest("#list > li")
|
|
|
|
if (item) {
|
|
|
|
this.setOption(item)
|
|
|
|
this.dispatchEvent(new InputEvent("input", {bubbles: true}))
|
|
|
|
this.close()
|
|
|
|
} else if (!this.#internals.states.has("--open")) {
|
|
|
|
this.open()
|
|
|
|
} else if (this.display.contains(event.target) || this.display.contains(event.target.closest("[slot]")?.assignedSlot)) {
|
|
|
|
this.close()
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
this.addEventListener("keydown", event => {
|
2025-01-16 15:16:56 +00:00
|
|
|
const key = event.key
|
|
|
|
if (this.#internals.states.has("--open")) {
|
|
|
|
if (key == " " && this.list.contains(this.shadowRoot.activeElement)) {
|
2024-12-18 12:41:00 +00:00
|
|
|
this.close()
|
2025-01-16 15:16:56 +00:00
|
|
|
event.preventDefault()
|
|
|
|
event.stopPropagation()
|
|
|
|
} else if (key == "Escape") {
|
|
|
|
this.close()
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (key == " ") {
|
2024-12-18 12:41:00 +00:00
|
|
|
this.open()
|
2025-01-16 15:26:59 +00:00
|
|
|
event.preventDefault()
|
|
|
|
event.stopPropagation()
|
2025-01-16 15:16:56 +00:00
|
|
|
} else if (key == "Escape") {
|
|
|
|
this.keyboardSearchBuffer = ""
|
|
|
|
event.preventDefault()
|
|
|
|
event.stopPropagation()
|
|
|
|
} else if (key == "Backspace") {
|
|
|
|
event.preventDefault()
|
|
|
|
event.stopPropagation()
|
|
|
|
} else if (!event.ctrlKey && !event.altKey && key.match(/^[a-zA-Z0-9]$/)) {
|
|
|
|
this.keyboardSearchAppend(key)
|
|
|
|
event.preventDefault()
|
|
|
|
event.stopPropagation()
|
2024-12-18 12:41:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2024-12-17 17:36:02 +00:00
|
|
|
this.shadowRoot.addEventListener("input", event => {
|
|
|
|
const item = event.target.closest("#input")
|
|
|
|
if (item) {
|
|
|
|
this.search(item.value)
|
|
|
|
event.stopPropagation()
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2025-01-16 15:16:56 +00:00
|
|
|
/**
|
|
|
|
* @param {String} key
|
|
|
|
*/
|
|
|
|
keyboardSearchAppend(key) {
|
|
|
|
this.searchTimeout?.abort()
|
|
|
|
this.searchTimeout = new AbortController()
|
|
|
|
|
|
|
|
const timeout = 1000 * (Number(this.getAttribute("search-timeout")) || 1)
|
|
|
|
const ref = setTimeout(()=> {
|
|
|
|
this.keyboardSearchBuffer = ""
|
|
|
|
}, timeout)
|
|
|
|
this.searchTimeout.signal.addEventListener("abort", () => {
|
|
|
|
window.clearTimeout(ref)
|
|
|
|
})
|
|
|
|
|
|
|
|
this.keyboardSearchBuffer = (this.keyboardSearchBuffer || "") + key
|
|
|
|
|
|
|
|
this.closedSearch(this.keyboardSearchBuffer)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} search
|
|
|
|
*/
|
|
|
|
closedSearch(search) {
|
|
|
|
for (const item of this.list.children) {
|
|
|
|
if (this.match(search, item)) {
|
|
|
|
this.setOption(item)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
async open() {
|
2024-12-17 17:36:02 +00:00
|
|
|
if (this.#abortOpen) return
|
|
|
|
|
|
|
|
this.#abortOpen = new AbortController()
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
const signal = this.closeSignal
|
2024-12-20 13:21:51 +00:00
|
|
|
|
|
|
|
// Click events don't properly cross shadow-DOM boundaries.
|
|
|
|
// Therefore: one event is needed for each nested shadow-DOM
|
|
|
|
// in the element's ancestry.
|
|
|
|
for (const {root, element} of ancestorRoots(this)) {
|
|
|
|
root.addEventListener("click", event => {
|
|
|
|
if (event.target instanceof HTMLElement) {
|
|
|
|
// This can only happen within the same root as the
|
|
|
|
// current element so can be handled trivially
|
|
|
|
if (this.contains(event.target)) return
|
|
|
|
|
|
|
|
// On every level, if an event originates from the containing
|
|
|
|
// shadow host, it can get ignored, as the corresponding
|
|
|
|
// shadow root event handler has already handled it.
|
|
|
|
if (event.target == element) return
|
|
|
|
|
|
|
|
// The event target wasn't inside the element
|
|
|
|
// nor is indirectly hosting it.
|
|
|
|
this.close()
|
|
|
|
}
|
|
|
|
}, {signal})
|
|
|
|
}
|
|
|
|
|
2024-12-17 17:36:02 +00:00
|
|
|
this.addEventListener("keypress", event => {
|
|
|
|
if (event.key == "Enter") {
|
|
|
|
this.selectDefault()
|
2024-12-18 14:38:58 +00:00
|
|
|
this.dispatchEvent(new InputEvent("input", {bubbles: true}))
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
}, {signal})
|
|
|
|
|
|
|
|
this.dialog.show()
|
|
|
|
this.#internals.states.add("--open")
|
2024-12-18 12:41:00 +00:00
|
|
|
|
|
|
|
if ("populate" in this) {
|
|
|
|
this.#internals.states.add("--loading")
|
|
|
|
await this.populate()
|
|
|
|
this.#internals.states.delete("--loading")
|
|
|
|
}
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
close() {
|
|
|
|
this.input.value = null
|
|
|
|
for (const hidden of this.list.querySelectorAll("[hidden]"))
|
|
|
|
hidden.removeAttribute("hidden")
|
|
|
|
this.#abortOpen?.abort()
|
|
|
|
this.#abortOpen = null
|
|
|
|
this.#internals.states.delete("--open")
|
|
|
|
this.dialog.close()
|
|
|
|
}
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
get closeSignal() { return this.#abortOpen?.signal }
|
|
|
|
|
|
|
|
/** @param {String} value */
|
2024-12-17 17:36:02 +00:00
|
|
|
search(value) {
|
|
|
|
for (const item of this.list.children) {
|
|
|
|
item.toggleAttribute("hidden", !this.match(value, item))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
selectDefault() {
|
2024-12-18 12:41:00 +00:00
|
|
|
if (this.shadowRoot.activeElement?.matches(`[part="item"]`)) {
|
|
|
|
this.setOption(this.shadowRoot.activeElement)
|
|
|
|
this.close()
|
|
|
|
return
|
|
|
|
}
|
2024-12-17 17:36:02 +00:00
|
|
|
const candidates = [...this.list.children].filter(child => !child.hasAttribute("hidden"))
|
|
|
|
if (candidates.length) {
|
|
|
|
this.setOption(candidates[0])
|
|
|
|
this.close()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
/**
|
|
|
|
* @param {string} value
|
|
|
|
* @param {HTMLElement} item
|
|
|
|
*/
|
2024-12-17 17:36:02 +00:00
|
|
|
match(value, item) {
|
|
|
|
return item.innerText.toLowerCase().match(value.toLowerCase())
|
|
|
|
}
|
|
|
|
|
|
|
|
connectedCallback() {
|
|
|
|
this.setOptions()
|
|
|
|
}
|
|
|
|
|
|
|
|
mutationCallback() {
|
|
|
|
this.setOptions()
|
|
|
|
}
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
/** @param {HTMLElement} option */
|
2024-12-17 17:36:02 +00:00
|
|
|
setOption(option) {
|
2024-12-19 14:55:03 +00:00
|
|
|
this.setValue(option.dataset.value, option.innerText)
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
|
2024-12-18 12:41:00 +00:00
|
|
|
/**
|
|
|
|
* @param {string} value
|
|
|
|
* @param {string} state
|
|
|
|
*/
|
2024-12-17 17:36:02 +00:00
|
|
|
setValue(value, state=value) {
|
|
|
|
this.#value = {value, state}
|
2024-12-18 14:38:58 +00:00
|
|
|
this.dispatchEvent(new Event("change", {bubbles: true}));
|
2024-12-17 17:36:02 +00:00
|
|
|
this.#internals.setFormValue(value, state)
|
|
|
|
this.text.innerText = state
|
|
|
|
}
|
|
|
|
|
|
|
|
get value() { return this.#value.value }
|
|
|
|
set value(value) {
|
|
|
|
for (const option of this.options) {
|
2025-01-15 15:44:50 +00:00
|
|
|
if (option.value === String(value)) {
|
2024-12-18 12:41:00 +00:00
|
|
|
this.setOption(option)
|
|
|
|
return
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
throw `No option with value ${value}`
|
|
|
|
}
|
|
|
|
|
|
|
|
get valueText() { return this.#value.state }
|
|
|
|
|
|
|
|
setOptions() {
|
|
|
|
this.list.replaceChildren()
|
|
|
|
for (const option of this.options) {
|
2024-12-18 12:41:00 +00:00
|
|
|
this.list.append(f`<li tabindex="0" part="item" data-value="${option.value}">${option.innerText}</li>`)
|
2024-12-19 14:44:39 +00:00
|
|
|
if (option.selected) {
|
|
|
|
this.value = option.value
|
|
|
|
}
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
|
|
|
}
|
2025-01-16 15:16:56 +00:00
|
|
|
|
2025-01-17 12:21:36 +00:00
|
|
|
/** Changes the placeholder displayed in the display area
|
|
|
|
* @param {string} text
|
|
|
|
*/
|
|
|
|
placeholderChanged(text) {
|
|
|
|
this.placeholder.innerText = text
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Changes the placeholder displayed in the search box when the drop-down is open
|
|
|
|
* @param {string} text
|
|
|
|
*/
|
|
|
|
searchPlaceholderChanged(text) {
|
|
|
|
this.input.placeholder = text
|
|
|
|
}
|
|
|
|
|
2025-01-16 15:16:56 +00:00
|
|
|
clear() {
|
|
|
|
this.setValue(undefined, "")
|
|
|
|
}
|
2025-01-17 12:21:36 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {String} name
|
|
|
|
* @param {String} before
|
|
|
|
* @param {String} after
|
|
|
|
*/
|
|
|
|
attributeChangedCallback(name, before, after) {
|
|
|
|
const methodName = name.replace(/-([a-z])/g, (_all, letter) => (letter.toUpperCase())) + "Changed"
|
|
|
|
if (methodName in this) this[methodName](after, before)
|
|
|
|
}
|
2024-12-17 17:36:02 +00:00
|
|
|
}
|
2025-01-17 12:21:36 +00:00
|
|
|
|