diff --git a/index.html b/index.html index 75b8480..fc50a85 100644 --- a/index.html +++ b/index.html @@ -1,19 +1,42 @@ + + - + - - Select some stuff here - - - - +
+

Better Select

+ +

A better muli-option input box for HTML

+ + + Placeholder... + + + + + + + + + +
diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..bd04c1f --- /dev/null +++ b/readme.md @@ -0,0 +1,23 @@ +## Slots + +* `placeholder`: Only shown when nothing is selected +* `loading`: Hidden by default, shown instead of items while `populate()` runs + +## Parts + +* `display`: The outer display box that is always shown +* `display-text`: The text representing the currently selected value +* `drop-down`: The dialog element that pops up when the list is opened +* `search`: The search input box +* `list`: The wrapper containing the items +* `item`: The individual selectable list items + +## Hooks + +* `populate()`: If present, gets called after opening to populate the options list +* `search(string)`: Called on search input to update the list of options +* `match(string, element)`: Used by `search` to compare each option to the search string + +## Attributes + +* `closeSignal`: An AbortSignal that fires when the drop-down closes diff --git a/src/BetterSelect.css b/src/BetterSelect.css index 2d29df8..83e0cf0 100644 --- a/src/BetterSelect.css +++ b/src/BetterSelect.css @@ -1,13 +1,82 @@ better-select { - display: inline-block; - border-radius: 3px; - border: 1px solid color-mix(in oklab, currentcolor, transparent 60%); + --faded: color-mix(in oklab, currentcolor, transparent 60%); + --highlight: color-mix(in oklab, currentcolor, transparent 90%); + --overshoot: cubic-bezier(.2, 0, .2, 1.4); - &::part(display), &::part(drop-down) { + --h-padding: .8em; + + min-width: 14em; + + outline: none; + + &:focus { + border-color: currentcolor; + } + + [slot="placeholder"] { + color: var(--faded); + } + + &::part(display) { + padding: .4rem var(--h-padding); + + gap: .4em; + + background: linear-gradient(to top, var(--highlight), transparent); + } + + &, &::part(drop-down) { + border: 1px solid var(--faded); + border-radius: 3px; } &::part(drop-down) { + transition-behavior: allow-discrete; + transition-duration: .2s; + transition-property: transform, display, opacity; + transition-timing-function: var(--overshoot); margin-top: .2em; - padding-inline: .5em; + padding: .4rem 0; + + min-width: 100%; + } + + &::part(search) { + margin-block: .4em; + line-height: 1.4em; + margin-inline: var(--h-padding); + display: none; + } + + &.search::part(search) { + display: block; + } + + &::part(item) { + line-height: 2em; + transition: background-color .3s; + padding-inline: var(--h-padding); + } + + &::part(item):is(:hover, :focus) { + background-color: var(--highlight); + } + + &:not(:state(--open))::part(drop-down) { + transform: scale(100%, 70%) translate(0, -30%); + opacity: 0; + } + + &::part(display)::after { + margin-left: auto; + display: inline-block; + content: '\25be'; + color: var(--faded); + line-height: 1em; + transition: transform .3s var(--overshoot); + } + + &:state(--open)::part(display)::after { + transform: rotate(180deg); } } diff --git a/src/BetterSelect.js b/src/BetterSelect.js index 8ac4f50..79f7969 100644 --- a/src/BetterSelect.js +++ b/src/BetterSelect.js @@ -32,6 +32,7 @@ const childObserver = new MutationObserver(mutations => { }) export class BetterSelect extends HTMLElement { + /** @type {AbortController} */ #abortOpen #value = {} @@ -41,43 +42,42 @@ export class BetterSelect extends HTMLElement { static styleSheet = css` :host { position: relative; + z-index: 100; + display: inline-block; } * { box-sizing: border-box; } [part="display"] { + min-width: 100%; + + /* Layout */ + align-items: center; display: inline-flex; flex-flow: row nowrap; - min-width: 20em; - - padding: .4em; - + /* Styling */ cursor: pointer; - - :last-child { - display: block; - margin-left: auto; - } + } + [part="display-text"]:empty { + display: none; } :not(:empty + *)[name="placeholder"] { display: none; } - [part="drop-down"] { + [part="drop-down"], [part="item"] { /* Resets */ border: unset; + outline: unset; padding: unset; + } + [part="drop-down"] { 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; @@ -95,6 +95,7 @@ export class BetterSelect extends HTMLElement { [part="item"] { display: block; cursor: pointer; + white-space: nowrap; } [part="item"]:focus { font-weight: bold; @@ -105,30 +106,53 @@ export class BetterSelect extends HTMLElement { slot[name="loading"] { display: none; } + :host(:state(--loading)) { + [part="list"] { display: none; } + slot[name="loading"] { display: block; } + } ` + /** @type {HTMLElement} */ + display + /** @type {HTMLElement} */ + text + /** @type {HTMLElement} */ + list + /** @type {HTMLElement} */ + placeholder + /** @type {HTMLInputElement} */ + input + /** @type {HTMLDialogElement} */ + dialog + /** @type {HTMLDialogElement} */ + loading + constructor() { super() childObserver.observe(this, {childList: true}) this.attachShadow({mode: "open"}).innerHTML = `
- - - 🔽 +
- + - + ` this.shadowRoot.adoptedStyleSheets = [BetterSelect.styleSheet] - this.tabindex = 0 + this.tabIndex = 0 + + this.#internals.role = "combobox" this.options = this.getElementsByTagName("option") - for (const element of this.shadowRoot.querySelectorAll(`[id]`)) this[element.id] = element + for (const element of this.shadowRoot.querySelectorAll(`[id]`)) { + this[element.id] = element + } this.shadowRoot.addEventListener("click", event => { const item = event.target.closest("#list > li") @@ -143,6 +167,18 @@ export class BetterSelect extends HTMLElement { } }) + this.addEventListener("keydown", event => { + if (event.key == " " && !this.input.contains(this.shadowRoot.activeElement)) { + if (this.#internals.states.has("--open")) { + this.close() + } else { + this.open() + } + } else if (event.key == "Escape") { + this.close() + } + }) + this.shadowRoot.addEventListener("input", event => { const item = event.target.closest("#input") if (item) { @@ -150,19 +186,16 @@ export class BetterSelect extends HTMLElement { event.stopPropagation() } }) - this.addEventListener("focus", event => { - this.open() - }) } - open() { + async open() { if (this.#abortOpen) return this.#abortOpen = new AbortController() - const signal = this.#abortOpen.signal + const signal = this.closeSignal window.addEventListener("click", event => { - if (!this.contains(event.target)) { + if (event.target instanceof HTMLElement && !this.contains(event.target)) { this.close() } }, {signal}) @@ -174,6 +207,12 @@ export class BetterSelect extends HTMLElement { 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() { @@ -186,6 +225,9 @@ export class BetterSelect extends HTMLElement { 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)) @@ -193,6 +235,11 @@ export class BetterSelect extends HTMLElement { } 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]) @@ -200,6 +247,10 @@ export class BetterSelect extends HTMLElement { } } + /** + * @param {string} value + * @param {HTMLElement} item + */ match(value, item) { return item.innerText.toLowerCase().match(value.toLowerCase()) } @@ -212,10 +263,15 @@ export class BetterSelect extends HTMLElement { this.setOptions() } + /** @param {HTMLElement} option */ setOption(option) { this.setValue(option.dataset.value, option.innerHTML) } + /** + * @param {string} value + * @param {string} state + */ setValue(value, state=value) { this.#value = {value, state} this.#internals.setFormValue(value, state) @@ -226,7 +282,8 @@ export class BetterSelect extends HTMLElement { set value(value) { for (const option of this.options) { if (option.value === value) { - return this.setOption(option) + this.setOption(option) + return } } throw `No option with value ${value}` @@ -237,7 +294,7 @@ export class BetterSelect extends HTMLElement { setOptions() { this.list.replaceChildren() for (const option of this.options) { - this.list.append(f`
  • ${option.innerText}
  • `) + this.list.append(f`
  • ${option.innerText}
  • `) } } }