UNPKG

melt

Version:

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

327 lines (326 loc) 11.7 kB
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); } }