Finish implementing all planned features
This commit is contained in:
parent
2bc9764ae0
commit
4807fd039b
4 changed files with 217 additions and 45 deletions
35
index.html
35
index.html
|
@ -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>
|
||||||
|
|
||||||
|
<p>A better muli-option input box for HTML</p>
|
||||||
|
|
||||||
|
<better-select class="search">
|
||||||
|
<span slot="placeholder">Placeholder...</span>
|
||||||
<option value="first">First value</option>
|
<option value="first">First value</option>
|
||||||
<option value="second">Second value</option>
|
<option value="second">Second value</option>
|
||||||
<option value="third">Third value</option>
|
<option value="third">Third value</option>
|
||||||
</better-select>
|
</better-select>
|
||||||
|
|
||||||
|
<vertical-spacer triple></vertical-spacer>
|
||||||
|
|
||||||
|
<md-block src="readme.md">
|
||||||
|
</md-block>
|
||||||
|
</main>
|
||||||
|
|
23
readme.md
Normal file
23
readme.md
Normal 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
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
[part="display-text"]:empty {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
: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>`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue