UNPKG

melt

Version:

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

446 lines (445 loc) 15 kB
import { mapAndFilter } from "../utils/array"; import { dataAttr } from "../utils/attribute"; import { Collection } from "../utils/collection"; import { extract } from "../utils/extract"; import { createDataIds, createId } from "../utils/identifiers"; import { isString } from "../utils/is"; import { first, last } from "../utils/iterator"; import { isControlOrMeta } from "../utils/platform"; import { SelectionState } from "../utils/selection-state.svelte"; import { createTypeahead, letterRegex } from "../utils/typeahead.svelte"; const identifiers = createDataIds("tree", ["root", "item", "group"]); /** * Main tree component class that handles selection, expansion, and keyboard navigation * @template I - Array type extending TreeItem * @template Multiple - Boolean indicating if multiple selection is enabled */ export class Tree { #props; /** The items contained in the tree */ collection; /** If `true`, the user can select multiple items holding `Control`/`Meta` or `Shift` */ multiple = $derived(extract(this.#props.multiple, false)); /** If `true`, groups (items with children) expand on click */ expandOnClick = $derived(extract(this.#props.expandOnClick, true)); typeaheadTimeout = $derived(extract(this.#props.typeaheadTimeout, 500)); typeahead = $derived(createTypeahead({ timeout: this.#props.typeaheadTimeout, getItems: () => { const activeEl = document.activeElement; if (!isString(activeEl?.getAttribute(identifiers.item))) return []; const visibleChildren = getAllChildren(this, true); return mapAndFilter(visibleChildren, (curr) => { return { child: curr, value: curr.el?.innerText ?? "", typeahead: curr.el?.innerText ?? "", current: curr.el?.id === activeEl.id, }; }, (c) => !!c.typeahead); }, })); #selected; #expanded; #id = createId(); /** * Creates a new Tree instance * @param props - Configuration props for the tree */ constructor(props) { this.#props = props; this.collection = new Collection(props.items); this.#selected = new SelectionState({ value: props.selected, onChange: props.onSelectedChange, multiple: props.multiple, }); this.#expanded = new SelectionState({ value: props.expanded, onChange: props.onExpandedChange, multiple: true, }); } get items() { return [...this.collection]; } /** * Currently selected item(s) * For multiple selection, returns a Set of IDs * For single selection, returns a single ID or undefined */ get selected() { return this.#selected.current; } set selected(v) { this.#selected.current = v; } /** * Set of currently expanded item IDs */ get expanded() { return this.#expanded.current; } set expanded(v) { this.#expanded.current = v; } /** * Checks if an item is currently selected * @param id - ID of the item to check */ isSelected(id) { return this.#selected.has(id); } /** * Checks if an item is currently expanded * @param id - ID of the item to check */ isExpanded(id) { return this.#expanded.has(id); } /** * Expands a specific item * @param id - ID of the item to expand */ expand(id) { this.#expanded.add(id); } /** * Collapses a specific item * @param id - ID of the item to collapse */ collapse(id) { this.#expanded.delete(id); } /** * Toggles the expanded state of an item * @param id - ID of the item to toggle */ toggleExpand(id) { this.#expanded.toggle(id); } /** * Selects a specific item * @param id - ID of the item to select */ select(id) { this.#selected.add(id); } /** * Deselects a specific item * @param id - ID of the item to deselect */ deselect(id) { this.#selected.delete(id); } /** * Clears all current selections */ clearSelection() { this.#selected.clear(); } /** * Toggles the selected state of an item * @param id - ID of the item to toggle */ toggleSelect(id) { this.#selected.toggle(id); } /** * Selects all visible items. * If all items are already selected, clears the selection. */ selectAll() { const ids = getAllChildren(this, true).map((c) => c.id); const alreadySelected = ids.every((id) => this.#selected.has(id)); if (alreadySelected) { this.clearSelection(); } else { this.#selected.addAll(ids); } } /** * Gets the DOM ID for a specific tree item * @param id - ID of the item */ getItemId(id) { return `melt-tree-${this.#id}-item--${id}`; } /** * Gets the DOM element for a specific tree item * @param id - ID of the item */ getItemEl(id) { return document.getElementById(this.getItemId(id)); } /** * Selects all items between the last selected item and the specified item * @param id - ID of the item to select until */ selectUntil(id) { // TODO: Use a direction constant to ensure correct order? if (!this.#selected.size()) return this.select(id); const allChildren = getAllChildren(this); const to = allChildren.find((c) => c.id === id); if (!to) return; const from = allChildren.find((c) => c.id === first(this.#selected.toSet())); if (!from) return this.select(id); const fromIdx = allChildren.indexOf(from); const toIdx = allChildren.indexOf(to); const [start, end] = fromIdx < toIdx ? [from, to] : [to, from]; let current = start; this.clearSelection(); // Ensure from remains the same this.select(from.id); this.select(start.id); while (current.id !== end.id && current.next) { current = current.next; this.select(current.id); } } /** * Gets ARIA attributes for the root tree element */ get root() { return { role: "tree", [identifiers.root]: "", }; } /** * ARIA attributes for group elements */ get group() { return { role: "group", [identifiers.group]: "", }; } /** * Array of Child instances representing the top-level items */ get children() { return [...this.collection].map((i) => new Child({ tree: this, item: i, parent: this, selectedState: this.#selected })); } } /** * Helper function to get all child items in a tree or subtree * @param treeOrChild - Tree or Child instance to get children from * @param onlyVisible - If true, only returns visible (expanded) children */ function getAllChildren(treeOrChild, onlyVisible = false) { const children = !onlyVisible || treeOrChild instanceof Tree || treeOrChild.expanded ? treeOrChild.children : []; return (children?.reduce((acc, c) => { return [...acc, c, ...getAllChildren(c, onlyVisible)]; }, []) || []); } /** * Class representing a single item in the tree * @template I - Array type extending TreeItem */ class Child { #props; tree = $derived(this.#props.tree); selectedState = $derived(this.#props.selectedState); item = $derived(this.#props.item); elId = $derived(this.tree.getItemId(this.item.id)); id = $derived(this.item.id); parent = $derived(this.#props.parent); /** * Creates a new Child instance * @param props - Configuration props for the child */ constructor(props) { this.#props = props; } /** The DOM element representing this item */ get el() { return document.getElementById(this.elId); } /** Whether this item is currently selected */ selected = $derived(this.tree.isSelected(this.id)); /** Whether this item is currently expanded */ expanded = $derived(this.tree.isExpanded(this.id)); /** Whether this item can be expanded (has children) */ canExpand = $derived(Boolean(this.item.children && this.item.children?.length > 0)); /** Collapses this item */ collapse = () => this.tree.collapse(this.id); /** Expands this item */ expand = () => this.tree.expand(this.id); /** Toggles the expanded state of this item */ toggleExpand = () => this.tree.toggleExpand(this.id); /** Selects this item */ select = () => this.tree.select(this.id); /** Deselects this item */ deselect = () => this.tree.deselect(this.id); /** Toggles the selected state of this item */ toggleSelect = () => this.tree.toggleSelect(this.id); /** Focuses this item's DOM element */ focus = () => this.el?.focus(); idx = $derived(this.parent?.children?.findIndex((c) => c.id === this.id) ?? -1); /** Gets all sibling items */ get siblings() { return this.parent?.children ?? []; } /** Gets the previous sibling item */ get previousSibling() { return this.siblings[this.idx - 1]; } /** Gets the next sibling item */ get nextSibling() { return this.siblings[this.idx + 1]; } /** Gets the previous item in the tree (including parent/child relationships) */ get previous() { let current = this.previousSibling; if (!current) return this.parent instanceof Child ? this.parent : undefined; while (current?.expanded) { current = last(current?.children ?? []); } return current; } /** Gets the next item in the tree (including parent/child relationships) */ get next() { if (this.expanded) { return this.children?.[0]; } if (this.nextSibling) { return this.nextSibling; } if (this.parent instanceof Child) { let p = this.parent; while (p && !p.nextSibling) { if (p.parent instanceof Tree) break; p = p.parent; } return p?.nextSibling; } } /** Gets the tabindex for this item's DOM element */ get tabindex() { if (this.selectedState.size()) { return this.tree.isSelected(this.id) ? 0 : -1; } return this.parent instanceof Tree && this.idx === 0 ? 0 : -1; } /** Gets DOM and ARIA attributes for this item */ get attrs() { return { id: this.elId, [identifiers.item]: "", "data-selected": dataAttr(this.selected), tabindex: this.tabindex, role: "treeitem", onclick: (e) => { e.stopPropagation(); if (!isControlOrMeta(e) && !e.shiftKey) this.tree.clearSelection(); if (this.tree.expandOnClick && this.canExpand && (!this.tree.multiple || (!isControlOrMeta(e) && !e.shiftKey))) { this.toggleExpand(); } if (isControlOrMeta(e)) this.toggleSelect(); else this.tree.select(this.id); if (e.shiftKey) this.tree.selectUntil(this.id); this.focus(); }, onkeydown: (e) => { let shouldPrevent = true; switch (e.key) { case "ArrowLeft": { if (this.expanded) { this.collapse(); break; } if (!(this.parent instanceof Child)) return; this.parent?.focus(); break; } case "ArrowRight": { if (!this.canExpand) break; if (this.expanded) { this.children?.[0]?.focus(); break; } this.expand(); break; } case "ArrowUp": { this.previous?.focus(); if (e.shiftKey) this.previous?.toggleSelect(); break; } case "ArrowDown": { this.next?.focus(); if (e.shiftKey) this.next?.toggleSelect(); break; } case " ": { if (!this.tree.multiple) break; if (e.shiftKey) { this.tree.selectUntil(this.id); break; } this.toggleSelect(); break; } case "Enter": { this.tree.clearSelection(); this.select(); break; } case "Home": { first(getAllChildren(this.tree))?.focus(); break; } case "End": { last(getAllChildren(this.tree, true))?.focus(); break; } case "*": { this.siblings.forEach((s) => s.expand()); break; } default: { if (letterRegex.test(e.key)) { if (e.ctrlKey) { if (e.key === "a") { this.tree.selectAll(); } break; } const next = this.tree.typeahead(e.key); next?.child.el?.focus(); break; } shouldPrevent = false; } } if (shouldPrevent) { e.preventDefault(); e.stopPropagation(); } }, }; } /** The item's sub-items, if any */ get children() { return this.item.children?.map((i) => new Child({ ...this.#props, item: i, parent: this })); } }