melt
Version:
The next generation of Melt UI. Built for Svelte 5.
388 lines (387 loc) • 13.3 kB
JavaScript
import { dataAttr, idAttr } from "../utils/attribute";
import { extract } from "../utils/extract";
import { createBuilderMetadata } from "../utils/identifiers";
import { isHtmlElement, isHtmlInputElement, isNode } from "../utils/is";
import { kbd } from "../utils/keyboard";
import { pick } from "../utils/object";
import { SelectionState, } from "../utils/selection-state.svelte";
import { letterRegex } from "../utils/typeahead.svelte";
import { tick } from "svelte";
import { BasePopover } from "./Popover.svelte";
import { findNext, findPrev } from "../utils/array";
import { Synced } from "../Synced.svelte";
import { safeEffect } from "../utils/effect.svelte";
import { dequal } from "dequal";
import { unique } from "../utils/string";
import { createAttachmentKey } from "svelte/attachments";
const { dataAttrs, dataSelectors, createIds } = createBuilderMetadata("combobox", [
"input",
"trigger",
"content",
"option",
]);
export class Combobox extends BasePopover {
/* Props */
#props;
multiple = $derived(extract(this.#props.multiple, false));
scrollAlignment = $derived(extract(this.#props.scrollAlignment, "nearest"));
/* State */
#value;
#inputValue;
#highlighted;
touched = $state(false);
onSelectMap = new Map();
constructor(props = {}) {
super({
sameWidth: true,
closeOnOutsideClick: (el) => {
const triggerEl = document.getElementById(this.ids.trigger);
if (triggerEl && isNode(el) && triggerEl.contains(el))
return false;
return true;
},
closeOnEscape: () => this.open,
focus: {
onOpen: () => this.ids.input,
onClose: null,
},
...props,
onOpenChange: async (open) => {
this.touched = false;
props.onOpenChange?.(open);
await tick();
if (!open) {
this.highlighted = null;
return;
}
tick().then(() => {
if (this.highlighted)
return;
const lastSelected = this.#value.toArray().at(-1);
if (lastSelected)
this.highlight(lastSelected);
else
this.highlightFirst();
});
},
});
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,
});
this.#inputValue = new Synced({
value: props.inputValue,
onChange: props.onInputValueChange,
defaultValue: "",
});
const oldIds = this.ids;
const newIds = createIds();
this.ids = {
...oldIds,
input: newIds.input,
content: oldIds.popover,
trigger: newIds.trigger,
};
}
get value() {
return this.#value.current;
}
set value(value) {
this.#value.current = value;
}
get inputValue() {
return this.#inputValue.current;
}
set inputValue(v) {
this.#inputValue.current = v;
}
get highlighted() {
return this.#highlighted.current;
}
set highlighted(v) {
this.#highlighted.current = v;
}
isSelected = (value) => {
return this.#value.has(value);
};
select(value) {
const onSelect = this.onSelectMap.get(value);
if (!this.isSelected(value) && onSelect) {
onSelect();
return;
}
if (this.multiple) {
this.#value.toggle(value);
this.inputValue = "";
}
else {
this.#value.add(value);
this.inputValue = this.getOptionLabel(value);
this.open = false;
}
}
get label() {
return {
for: this.ids.input,
onclick: (e) => {
e.preventDefault();
document.getElementById(this.ids.input)?.focus();
},
};
}
#inputAttachment = {
[createAttachmentKey()]: (node) => {
this.triggerEl = node;
return () => {
if (this.triggerEl === node) {
this.triggerEl = null;
}
};
},
};
get input() {
// using object.assign breaks types here
return {
...super.getInvoker(),
[dataAttrs.input]: "",
id: this.ids.input,
role: "combobox",
"aria-expanded": this.open,
"aria-controls": this.ids.content,
"aria-owns": this.ids.content,
onclick: () => {
this.open = true;
tick().then(() => this.highlightFirst());
},
value: this.inputValue,
oninput: (e) => {
const input = e.currentTarget;
if (!isHtmlInputElement(input))
return;
this.open = true;
this.inputValue = input.value;
tick().then(() => this.highlightFirst());
this.touched = true;
},
onkeydown: (e) => {
if (this.open) {
const kbdSubset = pick(kbd, "ARROW_DOWN", "ARROW_UP", "ESCAPE", "ENTER");
if (Object.values(kbdSubset).includes(e.key))
e.preventDefault();
switch (e.key) {
case kbdSubset.ARROW_DOWN: {
this.highlightNext();
break;
}
case kbdSubset.ARROW_UP: {
this.highlightPrev();
break;
}
case kbdSubset.ESCAPE: {
this.open = false;
break;
}
case kbdSubset.ENTER: {
if (this.highlighted === null)
return;
this.select(this.highlighted);
break;
}
}
}
else {
const kbdSubset = pick(kbd, "ARROW_DOWN", "ARROW_UP", "ESCAPE");
if (Object.values(kbdSubset).includes(e.key))
e.preventDefault();
else if (letterRegex.test(e.key))
this.open = true;
switch (e.key) {
case kbdSubset.ARROW_DOWN: {
if (this.open) {
return this.highlightNext();
}
this.open = true;
tick().then(() => {
if (!this.value)
this.highlightFirst();
});
break;
}
case kbdSubset.ARROW_UP: {
if (this.open) {
return this.highlightNext();
}
this.open = true;
tick().then(() => {
if (!this.value)
this.highlightLast();
});
break;
}
case kbdSubset.ESCAPE: {
this.#value.clear();
this.inputValue = "";
break;
}
}
}
},
...this.#inputAttachment,
};
}
get trigger() {
return {
[dataAttrs.trigger]: "",
id: this.ids.trigger,
onclick: () => {
this.open = !this.open;
document.getElementById(this.ids.input)?.focus();
},
...super.sharedProps,
};
}
get content() {
return Object.assign(super.getPopover(), {
[dataAttrs.content]: "",
role: "listbox",
"aria-expanded": this.open,
"aria-activedescendant": this.highlighted ? this.getOptionId(this.highlighted) : undefined,
});
}
scrollIntoView(value) {
if (this.scrollAlignment === null)
return;
const v = value ?? this.highlighted;
if (!v)
return;
const id = this.getOptionId(v);
const el = document.getElementById(id);
if (el)
el.scrollIntoView({ block: this.scrollAlignment });
}
getOptionId(value) {
return idAttr(unique(value));
}
#valueLabelMap = new Map();
get valueAsString() {
return this.#value
.toArray()
.map((value) => this.getOptionLabel(value))
.join(", ");
}
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);
}
/**
* Gets the attributes for the option element.
* @param value The value of the option.
* @param label The label to display for the option. If not provided, the value will be stringified.
* @param onSelect An optional callback to call when the option is selected, overriding the default behavior.
* @returns The attributes for the option element.
*/
getOption(value, label, onSelect) {
if (label)
this.#setOptionLabel(value, label);
safeEffect(() => {
if (onSelect)
this.onSelectMap.set(value, onSelect);
return () => {
this.onSelectMap.delete(value);
};
});
return {
id: this.getOptionId(value),
[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)),
tabindex: -1,
role: "option",
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);
}
getOptions() {
const els = this.getOptionsEls();
return els
.map((el) => {
try {
return el.dataset.value ? JSON.parse(el.dataset.value) : undefined;
}
catch {
return undefined;
}
})
.filter((v) => v !== undefined);
}
highlight(value) {
this.highlighted = value;
this.scrollIntoView(value);
}
highlightNext() {
if (this.#props.onNavigate) {
const next = this.#props.onNavigate(this.highlighted, "next");
if (next !== null) {
this.highlight(next);
return;
}
// Fall through to default behavior when null is returned
}
// Fallback to current DOM-based implementation
const options = this.getOptions();
const next = findNext(options, (v) => dequal(v, this.highlighted));
if (next !== undefined)
this.highlight(next);
}
highlightPrev() {
if (this.#props.onNavigate) {
const prev = this.#props.onNavigate(this.highlighted, "prev");
if (prev !== null) {
this.highlight(prev);
return;
}
// Fall through to default behavior when null is returned
}
// Fallback to current DOM-based implementation
const options = this.getOptions();
const prev = findPrev(options, (v) => dequal(v, this.highlighted));
if (prev !== undefined)
this.highlight(prev);
}
highlightFirst() {
const first = this.getOptions()[0];
if (first)
this.highlight(first);
}
highlightLast() {
const last = this.getOptions().at(-1);
if (last)
this.highlight(last);
}
}