import { clamp } from '../../internal/math.js'; import { html } from 'lit'; import { LocalizeController } from '../../utilities/localize.js'; import { property, query } from 'lit/decorators.js'; import { watch } from '../../internal/watch.js'; import componentStyles from '../../styles/component.styles.js'; import ShoelaceElement from '../../internal/shoelace-element.js'; import SlTreeItem from '../tree-item/tree-item.component.js'; import styles from './tree.styles.js'; import type { CSSResultGroup } from 'lit'; function syncCheckboxes(changedTreeItem: SlTreeItem, initialSync = false) { function syncParentItem(treeItem: SlTreeItem) { const children = treeItem.getChildrenItems({ includeDisabled: false }); if (children.length) { const allChecked = children.every(item => item.selected); const allUnchecked = children.every(item => !item.selected && !item.indeterminate); treeItem.selected = allChecked; treeItem.indeterminate = !allChecked && !allUnchecked; } } function syncAncestors(treeItem: SlTreeItem) { const parentItem: SlTreeItem | null = treeItem.parentElement as SlTreeItem; if (SlTreeItem.isTreeItem(parentItem)) { syncParentItem(parentItem); syncAncestors(parentItem); } } function syncDescendants(treeItem: SlTreeItem) { for (const childItem of treeItem.getChildrenItems()) { childItem.selected = initialSync ? treeItem.selected || childItem.selected : !childItem.disabled && treeItem.selected; syncDescendants(childItem); } if (initialSync) { syncParentItem(treeItem); } } syncDescendants(changedTreeItem); syncAncestors(changedTreeItem); } /** * @summary Trees allow you to display a hierarchical list of selectable [tree items](/components/tree-item). Items with children can be expanded and collapsed as desired by the user. * @documentation https://shoelace.style/components/tree * @status stable * @since 2.0 * * @event {{ selection: SlTreeItem[] }} sl-selection-change - Emitted when a tree item is selected or deselected. * * @slot - The default slot. * @slot expand-icon - The icon to show when the tree item is expanded. Works best with ``. * @slot collapse-icon - The icon to show when the tree item is collapsed. Works best with ``. * * @csspart base - The component's base wrapper. * * @cssproperty [--indent-size=var(--sl-spacing-medium)] - The size of the indentation for nested items. * @cssproperty [--indent-guide-color=var(--sl-color-neutral-200)] - The color of the indentation line. * @cssproperty [--indent-guide-offset=0] - The amount of vertical spacing to leave between the top and bottom of the * indentation line's starting position. * @cssproperty [--indent-guide-style=solid] - The style of the indentation line, e.g. solid, dotted, dashed. * @cssproperty [--indent-guide-width=0] - The width of the indentation line. */ export default class SlTree extends ShoelaceElement { static styles: CSSResultGroup = [componentStyles, styles]; @query('slot:not([name])') defaultSlot: HTMLSlotElement; @query('slot[name=expand-icon]') expandedIconSlot: HTMLSlotElement; @query('slot[name=collapse-icon]') collapsedIconSlot: HTMLSlotElement; /** * The selection behavior of the tree. Single selection allows only one node to be selected at a time. Multiple * displays checkboxes and allows more than one node to be selected. Leaf allows only leaf nodes to be selected. */ @property() selection: 'single' | 'multiple' | 'leaf' = 'single'; // // A collection of all the items in the tree, in the order they appear. The collection is live, meaning it is // automatically updated when the underlying document is changed. // private lastFocusedItem: SlTreeItem | null; private readonly localize = new LocalizeController(this); private mutationObserver: MutationObserver; private clickTarget: SlTreeItem | null = null; constructor() { super(); this.addEventListener('focusin', this.handleFocusIn); this.addEventListener('focusout', this.handleFocusOut); this.addEventListener('sl-lazy-change', this.handleSlotChange); } async connectedCallback() { super.connectedCallback(); this.setAttribute('role', 'tree'); this.setAttribute('tabindex', '0'); await this.updateComplete; this.mutationObserver = new MutationObserver(this.handleTreeChanged); this.mutationObserver.observe(this, { childList: true, subtree: true }); } disconnectedCallback() { super.disconnectedCallback(); this.mutationObserver.disconnect(); } // Generates a clone of the expand icon element to use for each tree item private getExpandButtonIcon(status: 'expand' | 'collapse') { const slot = status === 'expand' ? this.expandedIconSlot : this.collapsedIconSlot; const icon = slot.assignedElements({ flatten: true })[0] as HTMLElement; // Clone it, remove ids, and slot it if (icon) { const clone = icon.cloneNode(true) as HTMLElement; [clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id')); clone.setAttribute('data-default', ''); clone.slot = `${status}-icon`; return clone; } return null; } // Initializes new items by setting the `selectable` property and the expanded/collapsed icons if any private initTreeItem = (item: SlTreeItem) => { item.selectable = this.selection === 'multiple'; ['expand', 'collapse'] .filter(status => !!this.querySelector(`[slot="${status}-icon"]`)) .forEach((status: 'expand' | 'collapse') => { const existingIcon = item.querySelector(`[slot="${status}-icon"]`); const expandButtonIcon = this.getExpandButtonIcon(status); if (!expandButtonIcon) return; if (existingIcon === null) { // No separator exists, add one item.append(expandButtonIcon); } else if (existingIcon.hasAttribute('data-default')) { // A default separator exists, replace it existingIcon.replaceWith(expandButtonIcon); } else { // The user provided a custom icon, leave it alone } }); }; private handleTreeChanged = (mutations: MutationRecord[]) => { for (const mutation of mutations) { const addedNodes: SlTreeItem[] = [...mutation.addedNodes].filter(SlTreeItem.isTreeItem) as SlTreeItem[]; const removedNodes = [...mutation.removedNodes].filter(SlTreeItem.isTreeItem) as SlTreeItem[]; addedNodes.forEach(this.initTreeItem); if (this.lastFocusedItem && removedNodes.includes(this.lastFocusedItem)) { this.lastFocusedItem = null; } } }; private selectItem(selectedItem: SlTreeItem) { const previousSelection = [...this.selectedItems]; if (this.selection === 'multiple') { selectedItem.selected = !selectedItem.selected; if (selectedItem.lazy) { selectedItem.expanded = true; } syncCheckboxes(selectedItem); } else if (this.selection === 'single' || selectedItem.isLeaf) { const items = this.getAllTreeItems(); for (const item of items) { item.selected = item === selectedItem; } } else if (this.selection === 'leaf') { selectedItem.expanded = !selectedItem.expanded; } const nextSelection = this.selectedItems; if ( previousSelection.length !== nextSelection.length || nextSelection.some(item => !previousSelection.includes(item)) ) { // Wait for the tree items' DOM to update before emitting Promise.all(nextSelection.map(el => el.updateComplete)).then(() => { this.emit('sl-selection-change', { detail: { selection: nextSelection } }); }); } } private getAllTreeItems() { return [...this.querySelectorAll('sl-tree-item')]; } private focusItem(item?: SlTreeItem | null) { item?.focus(); } private handleKeyDown(event: KeyboardEvent) { // Ignore key presses we aren't interested in if (!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' '].includes(event.key)) { return; } // Ignore key presses when focus is inside a text field. This prevents the component from hijacking nested form // controls that exist inside tree items. if (event.composedPath().some((el: HTMLElement) => ['input', 'textarea'].includes(el?.tagName?.toLowerCase()))) { return; } const items = this.getFocusableItems(); const isLtr = this.localize.dir() === 'ltr'; const isRtl = this.localize.dir() === 'rtl'; if (items.length > 0) { event.preventDefault(); const activeItemIndex = items.findIndex(item => item.matches(':focus')); const activeItem: SlTreeItem | undefined = items[activeItemIndex]; const focusItemAt = (index: number) => { const item = items[clamp(index, 0, items.length - 1)]; this.focusItem(item); }; const toggleExpand = (expanded: boolean) => { activeItem.expanded = expanded; }; if (event.key === 'ArrowDown') { // Moves focus to the next node that is focusable without opening or closing a node. focusItemAt(activeItemIndex + 1); } else if (event.key === 'ArrowUp') { // Moves focus to the next node that is focusable without opening or closing a node. focusItemAt(activeItemIndex - 1); } else if ((isLtr && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft')) { // // When focus is on a closed node, opens the node; focus does not move. // When focus is on a open node, moves focus to the first child node. // When focus is on an end node (a tree item with no children), does nothing. // if (!activeItem || activeItem.disabled || activeItem.expanded || (activeItem.isLeaf && !activeItem.lazy)) { focusItemAt(activeItemIndex + 1); } else { toggleExpand(true); } } else if ((isLtr && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight')) { // // When focus is on an open node, closes the node. // When focus is on a child node that is also either an end node or a closed node, moves focus to its parent node. // When focus is on a closed `tree`, does nothing. // if (!activeItem || activeItem.disabled || activeItem.isLeaf || !activeItem.expanded) { focusItemAt(activeItemIndex - 1); } else { toggleExpand(false); } } else if (event.key === 'Home') { // Moves focus to the first node in the tree without opening or closing a node. focusItemAt(0); } else if (event.key === 'End') { // Moves focus to the last node in the tree that is focusable without opening the node. focusItemAt(items.length - 1); } else if (event.key === 'Enter' || event.key === ' ') { // Selects the focused node. if (!activeItem.disabled) { this.selectItem(activeItem); } } } } private handleClick(event: Event) { const target = event.target as SlTreeItem; const treeItem = target.closest('sl-tree-item')!; const isExpandButton = event .composedPath() .some((el: HTMLElement) => el?.classList?.contains('tree-item__expand-button')); // // Don't Do anything if there's no tree item, if it's disabled, or if the click doesn't match the initial target // from mousedown. The latter case prevents the user from starting a click on one item and ending it on another, // causing the parent node to collapse. // // See https://github.com/shoelace-style/shoelace/issues/1082 // if (!treeItem || treeItem.disabled || target !== this.clickTarget) { return; } if (isExpandButton) { treeItem.expanded = !treeItem.expanded; } else { this.selectItem(treeItem); } } handleMouseDown(event: MouseEvent) { // Record the click target so we know which item the click initially targeted this.clickTarget = event.target as SlTreeItem; } private handleFocusOut = (event: FocusEvent) => { const relatedTarget = event.relatedTarget as HTMLElement; // If the element that got the focus is not in the tree if (!relatedTarget || !this.contains(relatedTarget)) { this.tabIndex = 0; } }; private handleFocusIn = (event: FocusEvent) => { const target = event.target as SlTreeItem; // If the tree has been focused, move the focus to the last focused item if (event.target === this) { this.focusItem(this.lastFocusedItem || this.getAllTreeItems()[0]); } // If the target is a tree item, update the tabindex if (SlTreeItem.isTreeItem(target) && !target.disabled) { if (this.lastFocusedItem) { this.lastFocusedItem.tabIndex = -1; } this.lastFocusedItem = target; this.tabIndex = -1; target.tabIndex = 0; } }; private handleSlotChange() { const items = this.getAllTreeItems(); items.forEach(this.initTreeItem); } @watch('selection') async handleSelectionChange() { const isSelectionMultiple = this.selection === 'multiple'; const items = this.getAllTreeItems(); this.setAttribute('aria-multiselectable', isSelectionMultiple ? 'true' : 'false'); for (const item of items) { item.selectable = isSelectionMultiple; } if (isSelectionMultiple) { await this.updateComplete; [...this.querySelectorAll(':scope > sl-tree-item')].forEach((treeItem: SlTreeItem) => syncCheckboxes(treeItem, true) ); } } /** @internal Returns the list of tree items that are selected in the tree. */ get selectedItems(): SlTreeItem[] { const items = this.getAllTreeItems(); const isSelected = (item: SlTreeItem) => item.selected; return items.filter(isSelected); } /** @internal Gets focusable tree items in the tree. */ getFocusableItems() { const items = this.getAllTreeItems(); const collapsedItems = new Set(); return items.filter(item => { // Exclude disabled elements if (item.disabled) return false; // Exclude those whose parent is collapsed or loading const parent: SlTreeItem | null | undefined = item.parentElement?.closest('[role=treeitem]'); if (parent && (!parent.expanded || parent.loading || collapsedItems.has(parent))) { collapsedItems.add(item); } return !collapsedItems.has(item); }); } render() { return html`
`; } }