import { animateTo, shimKeyframesHeightAuto, stopAnimations } from '../../internal/animate.js'; import { classMap } from 'lit/directives/class-map.js'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js'; import { html } from 'lit'; import { live } from 'lit/directives/live.js'; import { LocalizeController } from '../../utilities/localize.js'; import { property, query, state } from 'lit/decorators.js'; import { watch } from '../../internal/watch.js'; import { when } from 'lit/directives/when.js'; import componentStyles from '../../styles/component.styles.js'; import ShoelaceElement from '../../internal/shoelace-element.js'; import SlCheckbox from '../checkbox/checkbox.component.js'; import SlIcon from '../icon/icon.component.js'; import SlSpinner from '../spinner/spinner.component.js'; import styles from './tree-item.styles.js'; import type { CSSResultGroup, PropertyValueMap } from 'lit'; /** * @summary A tree item serves as a hierarchical node that lives inside a [tree](/components/tree). * @documentation https://shoelace.style/components/tree-item * @status stable * @since 2.0 * * @dependency sl-checkbox * @dependency sl-icon * @dependency sl-spinner * * @event sl-expand - Emitted when the tree item expands. * @event sl-after-expand - Emitted after the tree item expands and all animations are complete. * @event sl-collapse - Emitted when the tree item collapses. * @event sl-after-collapse - Emitted after the tree item collapses and all animations are complete. * @event sl-lazy-change - Emitted when the tree item's lazy state changes. * @event sl-lazy-load - Emitted when a lazy item is selected. Use this event to asynchronously load data and append * items to the tree before expanding. After appending new items, remove the `lazy` attribute to remove the loading * state and update the tree. * * @slot - The default slot. * @slot expand-icon - The icon to show when the tree item is expanded. * @slot collapse-icon - The icon to show when the tree item is collapsed. * * @csspart base - The component's base wrapper. * @csspart item - The tree item's container. This element wraps everything except slotted tree item children. * @csspart item--disabled - Applied when the tree item is disabled. * @csspart item--expanded - Applied when the tree item is expanded. * @csspart item--indeterminate - Applied when the selection is indeterminate. * @csspart item--selected - Applied when the tree item is selected. * @csspart indentation - The tree item's indentation container. * @csspart expand-button - The container that wraps the tree item's expand button and spinner. * @csspart spinner - The spinner that shows when a lazy tree item is in the loading state. * @csspart spinner__base - The spinner's base part. * @csspart label - The tree item's label. * @csspart children - The container that wraps the tree item's nested children. * @csspart checkbox - The checkbox that shows when using multiselect. * @csspart checkbox__base - The checkbox's exported `base` part. * @csspart checkbox__control - The checkbox's exported `control` part. * @csspart checkbox__control--checked - The checkbox's exported `control--checked` part. * @csspart checkbox__control--indeterminate - The checkbox's exported `control--indeterminate` part. * @csspart checkbox__checked-icon - The checkbox's exported `checked-icon` part. * @csspart checkbox__indeterminate-icon - The checkbox's exported `indeterminate-icon` part. * @csspart checkbox__label - The checkbox's exported `label` part. */ export default class SlTreeItem extends ShoelaceElement { static styles: CSSResultGroup = [componentStyles, styles]; static dependencies = { 'sl-checkbox': SlCheckbox, 'sl-icon': SlIcon, 'sl-spinner': SlSpinner }; static isTreeItem(node: Node) { return node instanceof Element && node.getAttribute('role') === 'treeitem'; } private readonly localize = new LocalizeController(this); @state() indeterminate = false; @state() isLeaf = false; @state() loading = false; @state() selectable = false; /** Expands the tree item. */ @property({ type: Boolean, reflect: true }) expanded = false; /** Draws the tree item in a selected state. */ @property({ type: Boolean, reflect: true }) selected = false; /** Disables the tree item. */ @property({ type: Boolean, reflect: true }) disabled = false; /** Enables lazy loading behavior. */ @property({ type: Boolean, reflect: true }) lazy = false; @query('slot:not([name])') defaultSlot: HTMLSlotElement; @query('slot[name=children]') childrenSlot: HTMLSlotElement; @query('.tree-item__item') itemElement: HTMLDivElement; @query('.tree-item__children') childrenContainer: HTMLDivElement; @query('.tree-item__expand-button slot') expandButtonSlot: HTMLSlotElement; connectedCallback() { super.connectedCallback(); this.setAttribute('role', 'treeitem'); this.setAttribute('tabindex', '-1'); if (this.isNestedItem()) { this.slot = 'children'; } } firstUpdated() { this.childrenContainer.hidden = !this.expanded; this.childrenContainer.style.height = this.expanded ? 'auto' : '0'; this.isLeaf = !this.lazy && this.getChildrenItems().length === 0; this.handleExpandedChange(); } private async animateCollapse() { this.emit('sl-collapse'); await stopAnimations(this.childrenContainer); const { keyframes, options } = getAnimation(this, 'tree-item.collapse', { dir: this.localize.dir() }); await animateTo( this.childrenContainer, shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight), options ); this.childrenContainer.hidden = true; this.emit('sl-after-collapse'); } // Checks whether the item is nested into an item private isNestedItem(): boolean { const parent = this.parentElement; return !!parent && SlTreeItem.isTreeItem(parent); } private handleChildrenSlotChange() { this.loading = false; this.isLeaf = !this.lazy && this.getChildrenItems().length === 0; } protected willUpdate(changedProperties: PropertyValueMap | Map) { if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) { this.indeterminate = false; } } private async animateExpand() { this.emit('sl-expand'); await stopAnimations(this.childrenContainer); this.childrenContainer.hidden = false; const { keyframes, options } = getAnimation(this, 'tree-item.expand', { dir: this.localize.dir() }); await animateTo( this.childrenContainer, shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight), options ); this.childrenContainer.style.height = 'auto'; this.emit('sl-after-expand'); } @watch('loading', { waitUntilFirstUpdate: true }) handleLoadingChange() { this.setAttribute('aria-busy', this.loading ? 'true' : 'false'); if (!this.loading) { this.animateExpand(); } } @watch('disabled') handleDisabledChange() { this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); } @watch('selected') handleSelectedChange() { this.setAttribute('aria-selected', this.selected ? 'true' : 'false'); } @watch('expanded', { waitUntilFirstUpdate: true }) handleExpandedChange() { if (!this.isLeaf) { this.setAttribute('aria-expanded', this.expanded ? 'true' : 'false'); } else { this.removeAttribute('aria-expanded'); } } @watch('expanded', { waitUntilFirstUpdate: true }) handleExpandAnimation() { if (this.expanded) { if (this.lazy) { this.loading = true; this.emit('sl-lazy-load'); } else { this.animateExpand(); } } else { this.animateCollapse(); } } @watch('lazy', { waitUntilFirstUpdate: true }) handleLazyChange() { this.emit('sl-lazy-change'); } /** Gets all the nested tree items in this node. */ getChildrenItems({ includeDisabled = true }: { includeDisabled?: boolean } = {}): SlTreeItem[] { return this.childrenSlot ? ([...this.childrenSlot.assignedElements({ flatten: true })].filter( (item: SlTreeItem) => SlTreeItem.isTreeItem(item) && (includeDisabled || !item.disabled) ) as SlTreeItem[]) : []; } render() { const isRtl = this.localize.dir() === 'rtl'; const showExpandButton = !this.loading && (!this.isLeaf || this.lazy); return html`
${when( this.selectable, () => html` ` )}
`; } } setDefaultAnimation('tree-item.expand', { keyframes: [ { height: '0', opacity: '0', overflow: 'hidden' }, { height: 'auto', opacity: '1', overflow: 'hidden' } ], options: { duration: 250, easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)' } }); setDefaultAnimation('tree-item.collapse', { keyframes: [ { height: 'auto', opacity: '1', overflow: 'hidden' }, { height: '0', opacity: '0', overflow: 'hidden' } ], options: { duration: 200, easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)' } });