UNPKG

melt

Version:

The next generation of Melt UI. Built for Svelte 5.

369 lines (368 loc) 12.6 kB
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, ...props, closeOnOutsideClick: (el) => { const triggerEl = document.getElementById(this.ids.trigger); if (triggerEl && isNode(el) && triggerEl.contains(el)) return false; return true; }, closeOnEscape: () => this.open, 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(); }); // const content = document.getElementById(this.ids.content); // if (!content) return; // 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, }); 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() { const options = this.getOptions(); const next = findNext(options, (v) => dequal(v, this.highlighted)); if (next !== undefined) this.highlight(next); } highlightPrev() { 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); } }