`
this.shadowRoot.adoptedStyleSheets = [BetterSelect.styleSheet]
this.#internals.setFormValue("", "")
this.tabIndex = 0
this.#internals.role = "combobox"
this.options = this.getElementsByTagName("option")
for (const element of this.shadowRoot.querySelectorAll(`[id]`)) {
this[element.id] = element
}
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()
}
})
this.addEventListener("keydown", event => {
const key = event.key
if (this.#internals.states.has("--open")) {
if (key == " " && this.list.contains(this.shadowRoot.activeElement)) {
this.close()
event.preventDefault()
event.stopPropagation()
} else if (key == "Escape") {
this.close()
}
} else {
if (key == " ") {
this.open()
event.preventDefault()
event.stopPropagation()
} 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()
}
}
})
this.shadowRoot.addEventListener("input", event => {
const item = event.target.closest("#input")
if (item) {
this.search(item.value)
event.stopPropagation()
}
})
}
/**
* @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
}
}
}
async open() {
if (this.#abortOpen) return
this.#abortOpen = new AbortController()
const signal = this.closeSignal
// 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})
}
this.addEventListener("keypress", event => {
if (event.key == "Enter") {
this.selectDefault()
this.dispatchEvent(new InputEvent("input", {bubbles: true}))
}
}, {signal})
this.dialog.show()
this.#internals.states.add("--open")
if ("populate" in this) {
this.#internals.states.add("--loading")
await this.populate()
this.#internals.states.delete("--loading")
}
}
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()
}
get closeSignal() { return this.#abortOpen?.signal }
/** @param {String} value */
search(value) {
for (const item of this.list.children) {
item.toggleAttribute("hidden", !this.match(value, item))
}
}
selectDefault() {
if (this.shadowRoot.activeElement?.matches(`[part="item"]`)) {
this.setOption(this.shadowRoot.activeElement)
this.close()
return
}
const candidates = [...this.list.children].filter(child => !child.hasAttribute("hidden"))
if (candidates.length) {
this.setOption(candidates[0])
this.close()
}
}
/**
* @param {string} value
* @param {HTMLElement} item
*/
match(value, item) {
return item.innerText.toLowerCase().match(value.toLowerCase())
}
connectedCallback() {
this.setOptions()
}
mutationCallback() {
this.setOptions()
}
/** @param {HTMLElement} option */
setOption(option) {
this.setValue(option.dataset.value, option.innerText)
}
/**
* @param {string} value
* @param {string} state
*/
setValue(value, state=value) {
this.#value = {value, state}
this.dispatchEvent(new Event("change", {bubbles: true}));
this.#internals.setFormValue(value, state)
this.text.innerText = state
}
get value() { return this.#value.value }
set value(value) {
for (const option of this.options) {
if (option.value === String(value)) {
this.setOption(option)
return
}
}
throw `No option with value ${value}`
}
get valueText() { return this.#value.state }
setOptions() {
this.list.replaceChildren()
for (const option of this.options) {
this.list.append(f`
${option.innerText}
`)
if (option.selected) {
this.value = option.value
}
}
}
/** 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
}
clear() {
this.setValue(undefined, "")
}
/**
* @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)
}
}