Finish implementing all planned features

This commit is contained in:
Talia 2024-12-18 13:41:00 +01:00
parent 2bc9764ae0
commit 4807fd039b
4 changed files with 217 additions and 45 deletions

View file

@ -1,19 +1,42 @@
<script type="module" src="https://md-block.verou.me/md-block.js"></script>
<script type="module"> <script type="module">
import {BetterSelect} from "/src/BetterSelect.js" import {BetterSelect} from "./src/BetterSelect.js"
customElements.define("better-select", BetterSelect) customElements.define("better-select", BetterSelect)
</script> </script>
<link rel="stylesheet" href="/src/BetterSelect.css"> <link rel="stylesheet" href="src/BetterSelect.css">
<style> <style>
:root { @import
font-family: sans-serif; url('https://cdn.jsdelivr.net/gh/darkwiiplayer/css@main/all.css')
layer(framework);
@import
url('https://cdn.jsdelivr.net/gh/darkwiiplayer/css@main/schemes/talia.css')
layer(theme);
better-select {
color: black;
background: white;
} }
md-block:not([rendered]) { display: none }
</style> </style>
<better-select> <main class="content-width">
<span slot="placeholder">Select some stuff here</span> <h1>Better Select</h1>
<option value="first">First value</option>
<option value="second">Second value</option> <p>A better muli-option input box for HTML</p>
<option value="third">Third value</option>
</better-select> <better-select class="search">
<span slot="placeholder">Placeholder...</span>
<option value="first">First value</option>
<option value="second">Second value</option>
<option value="third">Third value</option>
</better-select>
<vertical-spacer triple></vertical-spacer>
<md-block src="readme.md">
</md-block>
</main>

23
readme.md Normal file
View file

@ -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

View file

@ -1,13 +1,82 @@
better-select { better-select {
display: inline-block; --faded: color-mix(in oklab, currentcolor, transparent 60%);
border-radius: 3px; --highlight: color-mix(in oklab, currentcolor, transparent 90%);
border: 1px solid color-mix(in oklab, currentcolor, transparent 60%); --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) { &::part(drop-down) {
transition-behavior: allow-discrete;
transition-duration: .2s;
transition-property: transform, display, opacity;
transition-timing-function: var(--overshoot);
margin-top: .2em; 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);
} }
} }

View file

@ -32,6 +32,7 @@ const childObserver = new MutationObserver(mutations => {
}) })
export class BetterSelect extends HTMLElement { export class BetterSelect extends HTMLElement {
/** @type {AbortController} */
#abortOpen #abortOpen
#value = {} #value = {}
@ -41,43 +42,42 @@ export class BetterSelect extends HTMLElement {
static styleSheet = css` static styleSheet = css`
:host { :host {
position: relative; position: relative;
z-index: 100;
display: inline-block;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
[part="display"] { [part="display"] {
min-width: 100%;
/* Layout */
align-items: center;
display: inline-flex; display: inline-flex;
flex-flow: row nowrap; flex-flow: row nowrap;
min-width: 20em; /* Styling */
padding: .4em;
cursor: pointer; cursor: pointer;
}
:last-child { [part="display-text"]:empty {
display: block; display: none;
margin-left: auto;
}
} }
:not(:empty + *)[name="placeholder"] { :not(:empty + *)[name="placeholder"] {
display: none; display: none;
} }
[part="drop-down"] { [part="drop-down"], [part="item"] {
/* Resets */ /* Resets */
border: unset; border: unset;
outline: unset;
padding: unset; padding: unset;
}
[part="drop-down"] {
background: inherit; background: inherit;
color: inherit; color: inherit;
position: absolute; position: absolute;
width: 100%;
flex-flow: column; flex-flow: column;
--gap: .4em;
margin: 0; margin: 0;
gap: var(--gap);
padding-top: var(--gap);
} }
[part="drop-down"]:modal { [part="drop-down"]:modal {
margin: auto; margin: auto;
@ -95,6 +95,7 @@ export class BetterSelect extends HTMLElement {
[part="item"] { [part="item"] {
display: block; display: block;
cursor: pointer; cursor: pointer;
white-space: nowrap;
} }
[part="item"]:focus { [part="item"]:focus {
font-weight: bold; font-weight: bold;
@ -105,30 +106,53 @@ export class BetterSelect extends HTMLElement {
slot[name="loading"] { slot[name="loading"] {
display: none; 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() { constructor() {
super() super()
childObserver.observe(this, {childList: true}) childObserver.observe(this, {childList: true})
this.attachShadow({mode: "open"}).innerHTML = ` this.attachShadow({mode: "open"}).innerHTML = `
<div id="display" part="display"> <div id="display" part="display">
<slot name="before"></slot>
<span part="display-text" id="text"></span> <span part="display-text" id="text"></span>
<slot name="placeholder" aria-hidden="true"></slot> <slot name="placeholder" aria-hidden="true">
<slot name="after">🔽</slot> <span id="placeholder" aria-hidden="true"></span>
</slot>
</div> </div>
<dialog id="dialog" part="drop-down"> <dialog id="dialog" part="drop-down">
<input type="search" id="input" part="input" type="text"></input> <input type="search" id="input" part="search" type="text"></input>
<ul id="list" part="list"></ul> <ul id="list" part="list"></ul>
<slot name="loading"></slot> <slot id="loading" name="loading"></slot>
</dialog> </dialog>
` `
this.shadowRoot.adoptedStyleSheets = [BetterSelect.styleSheet] this.shadowRoot.adoptedStyleSheets = [BetterSelect.styleSheet]
this.tabindex = 0 this.tabIndex = 0
this.#internals.role = "combobox"
this.options = this.getElementsByTagName("option") 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 => { this.shadowRoot.addEventListener("click", event => {
const item = event.target.closest("#list > li") 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 => { this.shadowRoot.addEventListener("input", event => {
const item = event.target.closest("#input") const item = event.target.closest("#input")
if (item) { if (item) {
@ -150,19 +186,16 @@ export class BetterSelect extends HTMLElement {
event.stopPropagation() event.stopPropagation()
} }
}) })
this.addEventListener("focus", event => {
this.open()
})
} }
open() { async open() {
if (this.#abortOpen) return if (this.#abortOpen) return
this.#abortOpen = new AbortController() this.#abortOpen = new AbortController()
const signal = this.#abortOpen.signal const signal = this.closeSignal
window.addEventListener("click", event => { window.addEventListener("click", event => {
if (!this.contains(event.target)) { if (event.target instanceof HTMLElement && !this.contains(event.target)) {
this.close() this.close()
} }
}, {signal}) }, {signal})
@ -174,6 +207,12 @@ export class BetterSelect extends HTMLElement {
this.dialog.show() this.dialog.show()
this.#internals.states.add("--open") this.#internals.states.add("--open")
if ("populate" in this) {
this.#internals.states.add("--loading")
await this.populate()
this.#internals.states.delete("--loading")
}
} }
close() { close() {
@ -186,6 +225,9 @@ export class BetterSelect extends HTMLElement {
this.dialog.close() this.dialog.close()
} }
get closeSignal() { return this.#abortOpen?.signal }
/** @param {String} value */
search(value) { search(value) {
for (const item of this.list.children) { for (const item of this.list.children) {
item.toggleAttribute("hidden", !this.match(value, item)) item.toggleAttribute("hidden", !this.match(value, item))
@ -193,6 +235,11 @@ export class BetterSelect extends HTMLElement {
} }
selectDefault() { 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")) const candidates = [...this.list.children].filter(child => !child.hasAttribute("hidden"))
if (candidates.length) { if (candidates.length) {
this.setOption(candidates[0]) this.setOption(candidates[0])
@ -200,6 +247,10 @@ export class BetterSelect extends HTMLElement {
} }
} }
/**
* @param {string} value
* @param {HTMLElement} item
*/
match(value, item) { match(value, item) {
return item.innerText.toLowerCase().match(value.toLowerCase()) return item.innerText.toLowerCase().match(value.toLowerCase())
} }
@ -212,10 +263,15 @@ export class BetterSelect extends HTMLElement {
this.setOptions() this.setOptions()
} }
/** @param {HTMLElement} option */
setOption(option) { setOption(option) {
this.setValue(option.dataset.value, option.innerHTML) this.setValue(option.dataset.value, option.innerHTML)
} }
/**
* @param {string} value
* @param {string} state
*/
setValue(value, state=value) { setValue(value, state=value) {
this.#value = {value, state} this.#value = {value, state}
this.#internals.setFormValue(value, state) this.#internals.setFormValue(value, state)
@ -226,7 +282,8 @@ export class BetterSelect extends HTMLElement {
set value(value) { set value(value) {
for (const option of this.options) { for (const option of this.options) {
if (option.value === value) { if (option.value === value) {
return this.setOption(option) this.setOption(option)
return
} }
} }
throw `No option with value ${value}` throw `No option with value ${value}`
@ -237,7 +294,7 @@ export class BetterSelect extends HTMLElement {
setOptions() { setOptions() {
this.list.replaceChildren() this.list.replaceChildren()
for (const option of this.options) { for (const option of this.options) {
this.list.append(f`<li tab-index="-1" part="item" data-value="${option.value}">${option.innerText}</li>`) this.list.append(f`<li tabindex="0" part="item" data-value="${option.value}">${option.innerText}</li>`)
} }
} }
} }