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}`)
}
}
}