melt
Version:
The next generation of Melt UI. Built for Svelte 5.
327 lines (326 loc) • 11.7 kB
JavaScript
import { Synced } from "../Synced.svelte";
import { findNext, findPrev, mapAndFilter } from "../utils/array";
import { dataAttr, idAttr } from "../utils/attribute";
import { extract } from "../utils/extract";
import { createBuilderMetadata } from "../utils/identifiers";
import { isHtmlElement } from "../utils/is";
import { kbd } from "../utils/keyboard";
import { pick } from "../utils/object";
import { SelectionState, } from "../utils/selection-state.svelte";
import { unique } from "../utils/string";
import { createTypeahead, letterRegex } from "../utils/typeahead.svelte";
import { dequal } from "dequal";
import { tick } from "svelte";
import { BasePopover } from "./Popover.svelte";
import { createAttachmentKey } from "svelte/attachments";
const { dataAttrs, dataSelectors, createIds } = createBuilderMetadata("select", [
"trigger",
"content",
"option",
]);
export class Select extends BasePopover {
#props;
multiple = $derived(extract(this.#props.multiple, false));
scrollAlignment = $derived(extract(this.#props.scrollAlignment, "nearest"));
/* State */
#value;
#highlighted;
typeaheadTimeout = $derived(extract(this.#props.typeaheadTimeout, 500));
typeahead = $derived(createTypeahead({
timeout: this.#props.typeaheadTimeout,
getItems: () => {
return mapAndFilter(this.#getOptionsEls(), (curr) => {
return {
value: curr.dataset.value ? JSON.parse(curr.dataset.value ?? "") : "",
typeahead: curr.dataset.label,
current: curr.dataset.value === JSON.stringify(this.highlighted),
};
}, (v) => {
return !!v.value;
});
},
}));
constructor(props = {}) {
super({
sameWidth: true,
...props,
onOpenChange: async (open) => {
props.onOpenChange?.(open);
await tick();
if (!open) {
this.highlighted = null;
return;
}
if (!this.highlighted) {
const lastSelected = this.#value.toArray().at(-1);
if (lastSelected)
this.highlighted = lastSelected;
else
this.#highlightFirst();
}
const content = document.getElementById(this.ids.content);
if (!content)
return;
content.focus();
tick().then(() => {
content.focus();
});
},
});
this.#props = props;
this.#value = new SelectionState({
value: props.value,
onChange: props.onValueChange,
multiple: props.multiple,
});
this.#highlighted = new Synced({
value: props.highlighted,
onChange: props.onHighlightChange,
defaultValue: null,
});
const oldIds = this.ids;
const newIds = createIds();
this.ids = {
...oldIds,
trigger: newIds.trigger,
content: oldIds.popover,
option: newIds.option,
};
}
getOptionLabel = (value) => {
const key = unique(value);
if (this.#valueLabelMap.has(key)) {
return this.#valueLabelMap.get(key);
}
return typeof value === "string" ? value : "";
};
#setOptionLabel(value, label) {
return this.#valueLabelMap.set(unique(value), label);
}
get value() {
return this.#value.current;
}
set value(value) {
this.#value.current = value;
}
get highlighted() {
return this.#highlighted.current;
}
set highlighted(v) {
this.#highlighted.current = v;
}
get valueAsString() {
return this.#value.toArray().map(this.getOptionLabel).join(", ");
}
isSelected = (value) => {
return this.#value.has(value);
};
select = (value) => {
if (this.multiple) {
this.#value.toggle(value);
return;
}
this.#value.add(value);
this.open = false;
tick().then(() => {
document.getElementById(this.ids.trigger)?.focus();
});
};
get label() {
return {
for: this.ids.trigger,
onclick: (e) => {
e.preventDefault();
document.getElementById(this.ids.trigger)?.focus();
},
};
}
#triggerAttachment = {
[createAttachmentKey()]: (node) => {
this.triggerEl = node;
return () => {
if (this.triggerEl === node) {
this.triggerEl = null;
}
};
},
};
get trigger() {
return Object.assign(super.getInvoker(), {
[dataAttrs.trigger]: "",
id: this.ids.trigger,
role: "combobox",
"aria-expanded": this.open,
"aria-controls": this.ids.content,
"aria-owns": this.ids.content,
onkeydown: (e) => {
const kbdSubset = pick(kbd, "ARROW_DOWN", "ARROW_UP");
if (Object.values(kbdSubset).includes(e.key))
e.preventDefault();
switch (e.key) {
case kbdSubset.ARROW_DOWN: {
this.open = true;
tick().then(() => {
if (!this.value)
this.#highlightFirst();
});
break;
}
case kbdSubset.ARROW_UP: {
this.open = true;
tick().then(() => {
if (!this.value)
this.#highlightLast();
});
break;
}
}
},
...this.#triggerAttachment,
});
}
get content() {
return Object.assign(super.getPopover(), {
[dataAttrs.content]: "",
role: "listbox",
"aria-expanded": this.open,
"aria-activedescendant": this.highlighted ? this.getOptionId(this.highlighted) : undefined,
onkeydown: (e) => {
const kbdSubset = pick(kbd, "HOME", "END", "ARROW_DOWN", "ARROW_UP", "ESCAPE", "ENTER", "SPACE");
if (Object.values(kbdSubset).includes(e.key))
e.preventDefault();
switch (e.key) {
case kbdSubset.HOME: {
this.#highlightFirst();
break;
}
case kbdSubset.END: {
this.#highlightLast();
break;
}
case kbdSubset.ARROW_DOWN: {
this.#highlightNext();
break;
}
case kbdSubset.ARROW_UP: {
this.#highlightPrev();
break;
}
case kbdSubset.SPACE:
case kbdSubset.ENTER: {
if (!this.highlighted)
break;
this.select(this.highlighted);
break;
}
case kbdSubset.ESCAPE: {
this.open = false;
tick().then(() => {
document.getElementById(this.ids.trigger)?.focus();
});
break;
}
default: {
if (!letterRegex.test(e.key))
break;
e.preventDefault();
e.stopPropagation();
const next = this.typeahead(e.key);
if (next)
this.highlighted = next.value;
}
}
},
});
}
getOptionId(value) {
return idAttr(unique(value));
}
#valueLabelMap = new Map();
getOption(value, label) {
if (label)
this.#setOptionLabel(value, label);
return {
[dataAttrs.option]: "",
"data-value": dataAttr(JSON.stringify(value)),
"data-label": dataAttr(label ?? `${value}`),
"aria-hidden": this.open ? undefined : true,
"aria-selected": this.#value.has(value),
"data-highlighted": dataAttr(dequal(this.highlighted, value)),
role: "option",
tabindex: -1,
onmouseover: () => {
this.highlighted = value;
},
onclick: () => {
this.select(value);
},
};
}
#getOptionsEls() {
const contentEl = document.getElementById(this.ids.content);
if (!contentEl)
return [];
return [...contentEl.querySelectorAll(dataSelectors.option)].filter(isHtmlElement);
}
#highlight(el) {
if (!el.dataset.value)
return;
this.highlighted = JSON.parse(el.dataset.value);
if (this.scrollAlignment !== null) {
el.scrollIntoView({ block: this.scrollAlignment });
}
}
#highlightNext() {
if (this.#props.onNavigate) {
const next = this.#props.onNavigate(this.highlighted, "next");
if (next !== null) {
this.highlighted = next;
// Try to scroll the element into view if it exists in DOM
const id = this.getOptionId(next);
const el = document.getElementById(id);
if (el && this.scrollAlignment !== null) {
el.scrollIntoView({ block: this.scrollAlignment });
}
return;
}
// Fall through to default behavior when null is returned
}
// Fallback to current DOM-based implementation
const options = this.#getOptionsEls();
const next = findNext(options, (o) => o.dataset.value === JSON.stringify(this.highlighted));
if (isHtmlElement(next))
this.#highlight(next);
}
#highlightPrev() {
if (this.#props.onNavigate) {
const prev = this.#props.onNavigate(this.highlighted, "prev");
if (prev !== null) {
this.highlighted = prev;
// Try to scroll the element into view if it exists in DOM
const id = this.getOptionId(prev);
const el = document.getElementById(id);
if (el && this.scrollAlignment !== null) {
el.scrollIntoView({ block: this.scrollAlignment });
}
return;
}
// Fall through to default behavior when null is returned
}
// Fallback to current DOM-based implementation
const options = this.#getOptionsEls();
const prev = findPrev(options, (o) => o.dataset.value === JSON.stringify(this.highlighted));
if (isHtmlElement(prev))
this.#highlight(prev);
}
#highlightFirst() {
const first = this.#getOptionsEls()[0];
if (first)
this.#highlight(first);
}
#highlightLast() {
const last = this.#getOptionsEls().at(-1);
if (last)
this.#highlight(last);
}
}