UNPKG

@melt-ui/svelte

Version:
295 lines (294 loc) 11.5 kB
import { addMeltEventListener, makeElement, createElHelpers, executeCallbacks, getElementByMeltId, isHTMLElement, isHidden, isLetter, kbd, last, overridable, styleToString, } from '../../internal/helpers/index.js'; import { derived, writable } from 'svelte/store'; import { generateIds } from '../../internal/helpers/id.js'; const defaults = { forceVisible: false, defaultExpanded: [], }; const ATTRS = { TABINDEX: 'tabindex', EXPANDED: 'aria-expanded', LABELLEDBY: 'aria-labelledby', DATAID: 'data-id', }; const { name } = createElHelpers('tree-view'); export function createTreeView(args) { const withDefaults = { ...defaults, ...args }; const { forceVisible } = withDefaults; /** * Track currently focused item in the tree. */ const lastFocusedId = writable(null); const selectedItem = writable(null); const expandedWritable = withDefaults.expanded ?? writable(withDefaults.defaultExpanded); const expanded = overridable(expandedWritable, withDefaults?.onExpandedChange); const selectedId = derived([selectedItem], ([$selectedItem]) => { return $selectedItem?.getAttribute('data-id'); }); /** * Determines if the tree view item is selected. * This is useful for displaying additional markup. */ const isSelected = derived([selectedItem], ([$value]) => { return (itemId) => $value?.getAttribute('data-id') === itemId; }); /** * Determines if a tree view item is collapsed or not. * This is useful for displaying additional markup or using Svelte transitions * on the group item. */ const isExpanded = derived([expanded], ([$expanded]) => { return (itemId) => $expanded.includes(itemId); }); const metaIds = generateIds(['tree']); const rootTree = makeElement(name(), { returned: () => { return { role: 'tree', 'data-melt-id': metaIds.tree, }; }, }); let isKeydown = false; const item = makeElement(name('item'), { stores: [expanded, selectedId, lastFocusedId], returned: ([$expanded, $selectedId, $lastFocusedId]) => { return (opts) => { // Have some default options that can be passed to the create() const { id, hasChildren } = opts; let tabindex = 0; if ($lastFocusedId !== null) { tabindex = $lastFocusedId === id ? 0 : -1; } return { role: 'treeitem', 'aria-selected': $selectedId === id, 'data-id': id, tabindex, 'aria-expanded': hasChildren ? $expanded.includes(id) : undefined, }; }; }, action: (node) => { const unsubEvents = executeCallbacks(addMeltEventListener(node, 'keydown', (e) => { const { key } = e; const keyIsLetter = isLetter(key); const keys = [ kbd.ARROW_DOWN, kbd.ARROW_UP, kbd.ARROW_LEFT, kbd.ARROW_RIGHT, kbd.ENTER, kbd.SPACE, kbd.END, kbd.HOME, kbd.ASTERISK, ]; if (!keys.includes(key) && !keyIsLetter) { return; } const rootEl = getElementByMeltId(metaIds.tree); if (!rootEl || !isHTMLElement(node) || node.getAttribute('role') !== 'treeitem') { return; } const items = getItems(); const nodeIdx = items.findIndex((item) => item === node); if (key !== kbd.ENTER && key !== kbd.SPACE) { e.preventDefault(); } if (key === kbd.ENTER || key === kbd.SPACE) { // Select el updateSelectedElement(node); isKeydown = true; } else if (key === kbd.ARROW_DOWN && nodeIdx !== items.length - 1) { // Focus next el const nextItem = items[nodeIdx + 1]; if (!nextItem) return; setFocusedItem(nextItem); } else if (key === kbd.ARROW_UP && nodeIdx !== 0) { // Focus previous el const prevItem = items[nodeIdx - 1]; if (!prevItem) return; setFocusedItem(prevItem); } else if (key === kbd.HOME && nodeIdx !== 0) { // Focus first el const item = items[0]; if (!item) return; setFocusedItem(item); } else if (key === kbd.END && nodeIdx != items.length - 1) { // Focus last el const item = last(items); if (!item) return; setFocusedItem(item); } else if (key === kbd.ARROW_LEFT) { if (elementIsExpanded(node)) { // Collapse group toggleChildrenElements(node); } else { // Focus parent group const parentGroup = node?.closest('[role="group"]'); const groupId = parentGroup?.getAttribute('data-group-id'); const item = items.find((item) => item.getAttribute('data-id') === groupId); if (!item) return; setFocusedItem(item); } } else if (key === kbd.ARROW_RIGHT) { if (elementIsExpanded(node)) { // Focus first child const nextItem = items[nodeIdx + 1]; if (!nextItem) return; setFocusedItem(nextItem); } else if (elementHasChildren(node)) { // Expand group toggleChildrenElements(node); } } else if (keyIsLetter) { /** * Check whether a value with the letter exists * after the current focused element and focus it, * if it does exist. If it does not exist, we check * previous values. */ const values = items.map((item) => { return { value: item.textContent?.toLowerCase().trim(), id: item.getAttribute('data-id'), }; }); let nextFocusIdx = -1; // Check elements after currently focused one. let foundNextFocusable = values.slice(nodeIdx + 1).some((item, i) => { if (item.value?.toLowerCase()[0] === key) { nextFocusIdx = nodeIdx + 1 + i; return true; } return false; }); if (!foundNextFocusable) { /** * Check elements before currently focused one, * if no index has been found yet. * */ foundNextFocusable = values.slice(0, nodeIdx).some((item, i) => { if (item.value?.toLowerCase().at(0) === key) { nextFocusIdx = i; return true; } return false; }); } if (foundNextFocusable && values[nextFocusIdx].id) { const nextFocusEl = items[nextFocusIdx]; if (!nextFocusEl) return; setFocusedItem(nextFocusEl); } } }), addMeltEventListener(node, 'click', async () => { updateSelectedElement(node); setFocusedItem(node); if (!isKeydown) { toggleChildrenElements(node); } isKeydown = false; }), addMeltEventListener(node, 'focus', () => { lastFocusedId.update((p) => node.getAttribute('data-id') ?? p); })); return { destroy() { unsubEvents(); }, }; }, }); const group = makeElement(name('group'), { stores: [expanded], returned: ([$expanded]) => { return (opts) => ({ role: 'group', 'data-group-id': opts.id, hidden: !forceVisible && !$expanded.includes(opts.id) ? true : undefined, style: styleToString({ display: !forceVisible && !$expanded.includes(opts.id) ? 'none' : undefined, }), }); }, }); function setFocusedItem(el) { lastFocusedId.update((p) => el.getAttribute('data-id') ?? p); el.focus(); } function updateSelectedElement(el) { const id = el.getAttribute(ATTRS.DATAID); if (!id) return; selectedItem.set(el); } function getItems() { let items = []; const rootEl = getElementByMeltId(metaIds.tree); if (!rootEl) return items; // Select all 'treeitem' li elements within our root element. items = Array.from(rootEl.querySelectorAll('[role="treeitem"]')).filter((el) => !isHidden(el)); return items; } function getElementAttributes(el) { const hasChildren = el.hasAttribute(ATTRS.EXPANDED); const expanded = el.getAttribute(ATTRS.EXPANDED); const dataId = el.getAttribute(ATTRS.DATAID); return { hasChildren, expanded, dataId, }; } function toggleChildrenElements(el) { const { hasChildren, expanded: expandedAttr, dataId } = getElementAttributes(el); if (!hasChildren || expandedAttr === null || dataId === null) return; if (expandedAttr === 'false') { expanded.update((prev) => [...prev, dataId]); } else { expanded.update((prev) => prev.filter((item) => item !== dataId)); } } function elementHasChildren(el) { return el.hasAttribute(ATTRS.EXPANDED); } function elementIsExpanded(el) { return el.getAttribute(ATTRS.EXPANDED) === 'true'; } return { ids: metaIds, elements: { tree: rootTree, item, group, }, states: { expanded, selectedItem, }, helpers: { isExpanded, isSelected, }, }; }