melt
Version:
The next generation of Melt UI. Built for Svelte 5.
446 lines (445 loc) • 15 kB
JavaScript
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 }));
}
}