commit 2bc9764ae0e9d118ecaa2b52864f33e2b165c847 Author: DarkWiiPlayer Date: Tue Dec 17 18:36:02 2024 +0100 ✨ Initial commit ✨ diff --git a/index.html b/index.html new file mode 100644 index 0000000..75b8480 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + Select some stuff here + + + + diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..60266fe --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "checkJs": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..80f0666 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "better-select", + "module": "BetterSelect.js", + "type": "module" +} diff --git a/src/BetterSelect.css b/src/BetterSelect.css new file mode 100644 index 0000000..2d29df8 --- /dev/null +++ b/src/BetterSelect.css @@ -0,0 +1,13 @@ +better-select { + display: inline-block; + border-radius: 3px; + border: 1px solid color-mix(in oklab, currentcolor, transparent 60%); + + &::part(display), &::part(drop-down) { + } + + &::part(drop-down) { + margin-top: .2em; + padding-inline: .5em; + } +} diff --git a/src/BetterSelect.js b/src/BetterSelect.js new file mode 100644 index 0000000..8ac4f50 --- /dev/null +++ b/src/BetterSelect.js @@ -0,0 +1,243 @@ +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) + } +} + +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 => { + for (const mutation of mutations) { + mutation.target.mutationCallback() + } +}) + +export class BetterSelect extends HTMLElement { + #abortOpen + #value = {} + + #internals = this.attachInternals() + static formAssociated = true + + static styleSheet = css` + :host { + position: relative; + } + * { + box-sizing: border-box; + } + [part="display"] { + display: inline-flex; + flex-flow: row nowrap; + + min-width: 20em; + + padding: .4em; + + cursor: pointer; + + :last-child { + display: block; + margin-left: auto; + } + } + :not(:empty + *)[name="placeholder"] { + display: none; + } + [part="drop-down"] { + /* Resets */ + border: unset; + padding: unset; + background: inherit; + color: inherit; + + position: absolute; + width: 100%; + flex-flow: column; + --gap: .4em; + margin: 0; + + gap: var(--gap); + padding-top: var(--gap); + } + [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; + } + [part="item"]:focus { + font-weight: bold; + } + [part="item"][hidden] { + display: none; + } + slot[name="loading"] { + display: none; + } + ` + + constructor() { + super() + childObserver.observe(this, {childList: true}) + this.attachShadow({mode: "open"}).innerHTML = ` +
+ + + + 🔽 +
+ + + + + + ` + this.shadowRoot.adoptedStyleSheets = [BetterSelect.styleSheet] + + this.tabindex = 0 + + 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.shadowRoot.addEventListener("input", event => { + const item = event.target.closest("#input") + if (item) { + this.search(item.value) + event.stopPropagation() + } + }) + this.addEventListener("focus", event => { + this.open() + }) + } + + open() { + if (this.#abortOpen) return + + this.#abortOpen = new AbortController() + + const signal = this.#abortOpen.signal + window.addEventListener("click", event => { + if (!this.contains(event.target)) { + this.close() + } + }, {signal}) + this.addEventListener("keypress", event => { + if (event.key == "Enter") { + this.selectDefault() + } + }, {signal}) + + this.dialog.show() + this.#internals.states.add("--open") + } + + 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() + } + + search(value) { + for (const item of this.list.children) { + item.toggleAttribute("hidden", !this.match(value, item)) + } + } + + selectDefault() { + const candidates = [...this.list.children].filter(child => !child.hasAttribute("hidden")) + if (candidates.length) { + this.setOption(candidates[0]) + this.close() + } + } + + match(value, item) { + return item.innerText.toLowerCase().match(value.toLowerCase()) + } + + connectedCallback() { + this.setOptions() + } + + mutationCallback() { + this.setOptions() + } + + setOption(option) { + this.setValue(option.dataset.value, option.innerHTML) + } + + setValue(value, state=value) { + this.#value = {value, state} + 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 === value) { + return this.setOption(option) + } + } + 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}
  • `) + } + } +}