diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 9e10d813e..87c356765 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -64,6 +64,8 @@ - [Tag](/components/tag) - [Textarea](/components/textarea) - [Tooltip](/components/tooltip) + - [Tree](/components/tree) + - [Tree Item](/components/tree-item) - Utilities diff --git a/docs/components/tree-item.md b/docs/components/tree-item.md new file mode 100644 index 000000000..5a3cc753f --- /dev/null +++ b/docs/components/tree-item.md @@ -0,0 +1,95 @@ +# Tree Item + +[component-header:sl-tree-item] + +A tree item is a hierarchical node of a tree. + +```html preview + Tree node +``` + +## Examples + +### Nested tree items + +A tree item can contain other items, this allow the user to expand or collapse nested nodes accordingly. + +```html preview + + Parent Node + Child 1 + Child 2 + Child 3 + +``` + +### Expanded + +Use the `expanded` attribute to display the nested items. + +```html preview + + Parent Node + Child 1 + Child 2 + Child 3 + +``` + +### Selected + +Use the `selected` attribute to the mark the item as selected. + +```html preview + + Parent Node + Child 1 + Child 2 + Child 3 + +``` + +### Selectable + +Use the `selectable` attribute to display the checkbox. + +```html preview + + Parent Node + Child 1 + Child 2 + Child 3 + + + +``` + +### Lazy + +Use the `lazy` to specify that the content is not yet loaded. When the user tries to expand the node, +a `sl-lazy-load` event is emitted. + +```html preview + Parent Node +``` + +### Indentation size + +Use the `--indentation-size` custom property to set the tree item's indentation. + +```html preview + + Parent Node + Child 1 + Child 2 + Child 3 + +``` + +[component-metadata:sl-tree-item] diff --git a/docs/components/tree.md b/docs/components/tree.md new file mode 100644 index 000000000..bf7afcb91 --- /dev/null +++ b/docs/components/tree.md @@ -0,0 +1,343 @@ +# Tree + +[component-header:sl-tree] + +A tree component allow the user to display a hierarchical list of items, expanding and collapsing the nodes that have nested items. +The user can select one or more items from the list. + +```html preview + + + Getting Started + + Overview + Quick Start + New to Web Components? + What Problem Does This Solve? + Browser Support + License + Attribution + + Installation + Usage + + + + Frameworks + React + Vue + Angular + + + Resources + +``` + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + Getting Started + Overview + Installation + Usage + + + + Frameworks + React + Vue + Angular + + + Resources + +); +``` + +## Examples + +### Selection modes + +Use the `selection` attribute to specify the selection behavior of the tree + +- Set `none` (_default_) to disable the selection. +- Set `single` to allow the selection of a single item. +- Set `leaf` to allow the selection of a single leaf node. Clicking on a parent node will expand/collapse the node. +- Set `multiple` to allow the selection of multiple items. + +```html preview + + none + single + leaf + multiple + +
+ + + Parent + + Parent 1 + Child 1 + Child 2 + + + + Parent 2 + Child 1 + Child 2 + Child 3 + + + + + +``` + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; + +const App = () => { + const [selection, setSelection] = useState('none'); + + return ( + <> + setSelection(event.target.value)}> + none + single + leaf + multiple + +
+ + + Parent + + Parent 1 Child 1 + Child 2 + + + Parent 2 Child 1 + Child 2 + Child 3 + + + + + ); +}; +``` + +### Lazy loading + +Use the `lazy` attribute on a item to indicate that the content is not yet present and will be loaded later. +When the user tries to expand the node, the `loading` state is set to `true` and a special event named +`sl-lazy-load` is emitted to let the loading of the content. The item will remain in a loading state until its content +is changed. + +If you want to disable this behavior, for example after the content has been loaded, it will be sufficient to set +`lazy` to `false`. + +```html preview + + Getting Started + + + +``` + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; + +const App = () => { + const [childItems, setChildItems] = useState([]); + const [lazy, setLazy] = useState(true); + + const handleLazyLoad = () => { + // Simulate asynchronous loading + setTimeout(() => { + setChildItems(['Overview', 'Installation', 'Usage']); + + // Disable lazy mode since the content has been loaded + setLazy(false); + }, 2000); + }; + + return ( + + + Getting Started + {childItems.map(item => ( + {item} + ))} + + + ); +}; +``` + +### Styling trees + +Using CSS parts is possible to apply custom styles to the tree. +For example, it is possible to change the hover effect and to highlight the selected item. + +```html preview + + + + Getting Started + + Overview + Quick Start + New to Web Components? + What Problem Does This Solve? + Browser Support + License + Attribution + + Installation + Usage + + + + Frameworks + React + Vue + Angular + + + Resources + +``` + +### With indentation lines + +```html preview + + + + Getting Started + + Overview + Quick Start + New to Web Components? + What Problem Does This Solve? + Browser Support + License + Attribution + + Installation + Usage + + + + Frameworks + React + Vue + Angular + + + Resources + +``` + +### With icons + +```html preview + + + + Root + + Folder 1 + File 1 - 1 + File 1 - 2 + File 1 - 3 + + + + Folder 2 + File 2 - 1 + File 2 - 2 + + File 1 + + +``` + +[component-metadata:sl-tree] diff --git a/src/components/tree-item/tree-item.styles.ts b/src/components/tree-item/tree-item.styles.ts new file mode 100644 index 000000000..e4c0c750f --- /dev/null +++ b/src/components/tree-item/tree-item.styles.ts @@ -0,0 +1,97 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; + +export default css` + ${componentStyles} + + :host { + display: block; + outline: 0; + } + + :host(:focus) { + outline: 0; + } + + .tree-item { + position: relative; + display: flex; + align-items: stretch; + flex-direction: column; + + color: var(--sl-color-neutral-700); + + user-select: none; + white-space: nowrap; + cursor: pointer; + } + + .tree-item__checkbox { + pointer-events: none; + } + + .tree-item__expand-button, + .tree-item__checkbox, + .tree-item__label { + font-family: var(--sl-font-sans); + font-size: var(--sl-font-size-medium); + font-weight: var(--sl-font-weight-normal); + line-height: var(--sl-line-height-normal); + letter-spacing: var(--sl-letter-spacing-normal); + } + + .tree-item__checkbox::part(base) { + display: flex; + align-items: center; + } + + .tree-item__indentation { + display: block; + width: 1em; + flex-shrink: 0; + } + + .tree-item__expand-button { + display: flex; + align-items: center; + justify-content: center; + box-sizing: content-box; + color: var(--sl-color-neutral-400); + padding: var(--sl-spacing-x-small); + width: 1rem; + height: 1rem; + } + + .tree-item__item { + display: flex; + align-items: center; + cursor: pointer; + } + + .tree-item__item--disabled { + color: var(--sl-color-neutral-400); + outline: none; + cursor: not-allowed; + } + + :host(:not([aria-disabled='true']):focus-visible) .tree-item__item { + outline: var(--sl-focus-ring); + outline-offset: var(--sl-focus-ring-offset); + } + + :host(:not([aria-disabled='true'])) .tree-item__item--selected, + :host(:not([aria-disabled='true'])) .tree-item__item:hover, + :host(:not([aria-disabled='true'])) .tree-item__item:hover sl-checkbox::part(label) { + color: var(--sl-color-primary-600); + } + + .tree-item__label { + display: flex; + align-items: center; + transition: var(--sl-transition-fast) color; + } + + .tree-item__children { + font-size: calc(1em + var(--indentation-size, var(--sl-spacing-medium))); + } +`; diff --git a/src/components/tree-item/tree-item.test.ts b/src/components/tree-item/tree-item.test.ts new file mode 100644 index 000000000..894777fd5 --- /dev/null +++ b/src/components/tree-item/tree-item.test.ts @@ -0,0 +1,178 @@ +import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; +import sinon from 'sinon'; +import type SlTreeItem from './tree-item'; + +describe('', () => { + let leafItem: SlTreeItem; + let parentItem: SlTreeItem; + + beforeEach(async () => { + leafItem = await fixture(html` Node 1 `); + parentItem = await fixture(html` + + Parent Node + Node 1 + Node 1 + + `); + }); + + it('should render a component', () => { + expect(leafItem).to.exist; + expect(parentItem).to.exist; + + expect(leafItem).to.have.attribute('role', 'treeitem'); + expect(leafItem).to.have.attribute('aria-selected', 'false'); + expect(leafItem).to.have.attribute('aria-disabled', 'false'); + }); + + describe('when contain child tree items', () => { + it('should set isLeaf to false', () => { + // Assert + expect(parentItem.isLeaf).to.be.false; + }); + + it('should show the expand button', () => { + // Arrange + const expandButton = parentItem.shadowRoot?.querySelector('.tree-item__expand-button'); + + // Act + + // Assert + expect(expandButton?.childElementCount).to.be.greaterThan(0); + }); + + it('should set the aria-expanded attribute', () => { + expect(parentItem).to.have.attribute('aria-expanded', 'false'); + }); + }); + + describe('when the user clicks the expand button', () => { + describe('and the item is collapsed', () => { + it('should expand the item', async () => { + // Arrange + const expandButton: HTMLElement = parentItem.shadowRoot!.querySelector('.tree-item__expand-button')!; + + // Act + expandButton.click(); + await parentItem.updateComplete; + + // Assert + expect(parentItem).to.have.attribute('expanded'); + expect(parentItem).to.have.attribute('aria-expanded', 'true'); + }); + + it('should emit sl-expand and sl-after-expand events', async () => { + // Arrange + const expandSpy = sinon.spy(); + const afterExpandSpy = sinon.spy(); + + parentItem.addEventListener('sl-expand', expandSpy); + parentItem.addEventListener('sl-after-expand', afterExpandSpy); + + // Act + parentItem.expanded = true; + await waitUntil(() => expandSpy.calledOnce); + await waitUntil(() => afterExpandSpy.calledOnce); + + // Assert + expect(expandSpy).to.have.been.calledOnce; + expect(afterExpandSpy).to.have.been.calledOnce; + }); + }); + + describe('and the item is expanded', () => { + it('should collapse the item', async () => { + // Arrange + const expandButton: HTMLElement = parentItem.shadowRoot!.querySelector('.tree-item__expand-button')!; + parentItem.expanded = true; + await parentItem.updateComplete; + + // Act + expandButton.click(); + await parentItem.updateComplete; + + // Assert + expect(parentItem).not.to.have.attribute('expanded'); + expect(parentItem).to.have.attribute('aria-expanded', 'false'); + }); + + it('should emit sl-collapse and sl-after-collapse events', async () => { + // Arrange + const collapseSpy = sinon.spy(); + const afterCollapseSpy = sinon.spy(); + + parentItem.addEventListener('sl-collapse', collapseSpy); + parentItem.addEventListener('sl-after-collapse', afterCollapseSpy); + + parentItem.expanded = true; + await oneEvent(parentItem, 'sl-after-expand'); + + // Act + parentItem.expanded = false; + await waitUntil(() => collapseSpy.calledOnce); + await waitUntil(() => afterCollapseSpy.calledOnce); + + // Assert + expect(collapseSpy).to.have.been.calledOnce; + expect(afterCollapseSpy).to.have.been.calledOnce; + }); + + describe('and the item is disabled', () => { + it('should not expand', async () => { + // Arrange + const expandButton: HTMLElement = parentItem.shadowRoot!.querySelector('.tree-item__expand-button')!; + parentItem.disabled = true; + + // Act + expandButton.click(); + await parentItem.updateComplete; + + // Assert + expect(parentItem).not.to.have.attribute('expanded'); + expect(parentItem).to.have.attribute('aria-expanded', 'false'); + }); + }); + }); + }); + + describe('when the item is selected', () => { + it('should update the aria-selected attribute', async () => { + // Act + leafItem.selected = true; + await leafItem.updateComplete; + + // Assert + expect(leafItem).to.have.attribute('aria-selected', 'true'); + }); + + it('should set item--selected part', async () => { + // Act + leafItem.selected = true; + await leafItem.updateComplete; + + // Assert + expect(leafItem.shadowRoot?.querySelector('.tree-item__item')?.part.contains('item--selected')).to.be.true; + }); + }); + + describe('when a item is disabled', () => { + it('should update the aria-disabled attribute', async () => { + // Act + leafItem.disabled = true; + await leafItem.updateComplete; + + // Assert + expect(leafItem).to.have.attribute('aria-disabled', 'true'); + }); + + it('should set item--disabled part', async () => { + // Act + leafItem.disabled = true; + await leafItem.updateComplete; + + // Assert + expect(leafItem.shadowRoot?.querySelector('.tree-item__item')?.part.contains('item--disabled')).to.be.true; + }); + }); +}); diff --git a/src/components/tree-item/tree-item.ts b/src/components/tree-item/tree-item.ts new file mode 100644 index 000000000..f60f58d4d --- /dev/null +++ b/src/components/tree-item/tree-item.ts @@ -0,0 +1,295 @@ +import { LocalizeController } from '@shoelace-style/localize'; +import { LitElement, html } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { live } from 'lit/directives/live.js'; +import { when } from 'lit/directives/when.js'; +import { animateTo, shimKeyframesHeightAuto, stopAnimations } from 'src/internal/animate'; +import { stringMap } from 'src/internal/string'; +import { getAnimation, setDefaultAnimation } from 'src/utilities/animation-registry'; +import '../../components/checkbox/checkbox'; +import '../../components/spinner/spinner'; +import { emit } from '../../internal/event'; +import { watch } from '../../internal/watch'; +import styles from './tree-item.styles'; +import type { PropertyValueMap } from 'lit'; + +export function isTreeItem(element: Element) { + return element && element.getAttribute('role') === 'treeitem'; +} + +/** + * @since 2.0 + * @status experimental + * + * @dependency sl-checkbox + * @dependency sl-spinner + * + * @event sl-expand - Emitted when the item expands. + * @event sl-after-expand - Emitted after the item expands and all animations are complete. + * @event sl-collapse - Emitted when the item collapses. + * @event sl-after-collapse - Emitted after the item collapses and all animations are complete. + * @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. + * + * @slot - The default slot. + * + * @csspart base - The component's internal wrapper. + * @csspart item - The item main container. + * @csspart item--selected - The `selected` state of the main container. + * @csspart item--disabled - The `disabled` state of the main container. + * @csspart indentation - The item indentation. + * @csspart label - The item label. + * @csspart children - The item children container. + * + * @cssproperty --indentation-size - The size of the indentation for nested items. (Default: --sl-spacing-medium) + */ +@customElement('sl-tree-item') +export default class SlTreeItem extends LitElement { + static styles = styles; + + private readonly localize = new LocalizeController(this); + + /** Expands the item when is set */ + @property({ type: Boolean, reflect: true }) expanded = false; + + /** Sets the treeitem's selected state */ + @property({ type: Boolean, reflect: true }) selected = false; + + /** Disables the treeitem */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** When set, enables the lazy mode behavior */ + @property({ type: Boolean, reflect: true }) lazy = false; + + /** Shows the checkbox when set */ + @property({ type: Boolean }) selectable = false; + + /** Draws the checkbox in a indeterminate state. */ + @state() indeterminate = false; + + /** Specifies whether the node has children nodes */ + @state() isLeaf = false; + + /** Draws the expand button in a loading state. */ + @state() loading = 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; + + connectedCallback(): void { + 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.getChildrenItems().length === 0; + this.handleExpandedChange(); + } + + @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; + + emit(this, 'sl-lazy-load'); + } else { + this.animateExpand(); + } + } else { + this.animateCollapse(); + } + } + + private async animateExpand() { + emit(this, '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'; + + emit(this, 'sl-after-expand'); + } + + private async animateCollapse() { + emit(this, '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; + + emit(this, 'sl-after-collapse'); + } + + /** + * @internal Gets all the nested tree items + */ + getChildrenItems({ includeDisabled = true }: { includeDisabled?: boolean } = {}): SlTreeItem[] { + return this.childrenSlot + ? ([...this.childrenSlot.assignedElements({ flatten: true })].filter( + (item: SlTreeItem) => isTreeItem(item) && (includeDisabled || !item.disabled) + ) as SlTreeItem[]) + : []; + } + + /** + * @internal Checks whether the item is nested into an item + */ + private isNestedItem(): boolean { + const parent = this.parentElement; + return !!parent && isTreeItem(parent); + } + + handleToggleExpand(e: Event) { + e.preventDefault(); + e.stopImmediatePropagation(); + + if (!this.disabled) { + this.expanded = !this.expanded; + } + } + + handleChildrenSlotChange() { + this.loading = false; + + this.isLeaf = this.getChildrenItems().length === 0; + } + + protected willUpdate(changedProperties: PropertyValueMap | Map): void { + if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) { + this.indeterminate = false; + } + } + + render() { + return html` +
+
+
+ + + + ${when( + this.selectable, + () => + html` + +
+ +
+
+ `, + () => 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)' } +}); + +declare global { + interface HTMLElementTagNameMap { + 'sl-tree-item': SlTreeItem; + } +} diff --git a/src/components/tree/tree.styles.ts b/src/components/tree/tree.styles.ts new file mode 100644 index 000000000..883fda5bd --- /dev/null +++ b/src/components/tree/tree.styles.ts @@ -0,0 +1,15 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; + +export default css` + ${componentStyles} + + :host { + display: block; + /** + * tree-item indentation uses the "em" unit in order to increment its width on each level, + * so setting the font size to zero here, removes the indentation for all the nodes in the first level. + */ + font-size: 0; + } +`; diff --git a/src/components/tree/tree.test.ts b/src/components/tree/tree.test.ts new file mode 100644 index 000000000..fb61baa1b --- /dev/null +++ b/src/components/tree/tree.test.ts @@ -0,0 +1,643 @@ +import { expect, fixture, html, triggerBlurFor, triggerFocusFor } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import sinon from 'sinon'; +import type SlTreeItem from '../tree-item/tree-item'; +import type SlTree from './tree'; + +describe('', () => { + let el: SlTree; + + beforeEach(async () => { + el = await fixture(html` + + Node 1 + Node 2 + + Parent Node + Child Node 1 + Child Node 2 + + Node 3 + + `); + }); + + it('should render a component', () => { + expect(el).to.exist; + expect(el).to.have.attribute('role', 'tree'); + expect(el).to.have.attribute('tabindex', '0'); + }); + + it('should pass accessibility tests', async () => { + await expect(el).to.be.accessible(); + }); + + describe('Keyboard navigation', () => { + describe('when ArrowDown is pressed', () => { + it('should move the focus to the next tree item', async () => { + // Arrange + el.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'ArrowDown' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.children[0]).to.have.attribute('tabindex', '-1'); + expect(el.children[1]).to.have.attribute('tabindex', '0'); + }); + }); + + describe('when ArrowUp is pressed', () => { + it('should move the focus to the prev tree item', async () => { + // Arrange + (el.children[1] as HTMLElement).focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'ArrowUp' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.children[0]).to.have.attribute('tabindex', '0'); + expect(el.children[1]).to.have.attribute('tabindex', '-1'); + }); + }); + + describe('when ArrowRight is pressed', () => { + describe('and node is a leaf', () => { + it('should move the focus to the next tree item', async () => { + // Arrange + (el.children[0] as HTMLElement).focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'ArrowRight' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.children[0]).to.have.attribute('tabindex', '-1'); + expect(el.children[1]).to.have.attribute('tabindex', '0'); + }); + }); + + describe('and node is collapsed', () => { + it('should expand the tree item', async () => { + // Arrange + const parentNode = el.children[2] as SlTreeItem; + parentNode.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'ArrowRight' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(parentNode).to.have.attribute('tabindex', '0'); + expect(parentNode).to.have.attribute('expanded'); + }); + }); + + describe('and node is expanded', () => { + it('should move the focus to the next tree item', async () => { + // Arrange + const parentNode = el.children[2] as SlTreeItem; + parentNode.expanded = true; + parentNode.focus(); + + await el.updateComplete; + + // Act + await sendKeys({ press: 'ArrowRight' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(parentNode).to.have.attribute('tabindex', '-1'); + expect(parentNode.children[0]).to.have.attribute('tabindex', '0'); + }); + }); + }); + + describe('when ArrowLeft is pressed', () => { + describe('and node is a leaf', () => { + it('should move the focus to the prev tree item', async () => { + // Arrange + (el.children[1] as HTMLElement).focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'ArrowLeft' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.children[0]).to.have.attribute('tabindex', '0'); + expect(el.children[1]).to.have.attribute('tabindex', '-1'); + }); + }); + + describe('and node is collapsed', () => { + it('should move the focus to the prev tree item', async () => { + // Arrange + (el.children[2] as HTMLElement).focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'ArrowLeft' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.children[1]).to.have.attribute('tabindex', '0'); + expect(el.children[2]).to.have.attribute('tabindex', '-1'); + }); + }); + + describe('and node is expanded', () => { + it('should collapse the tree item', async () => { + // Arrange + const parentNode = el.children[2] as SlTreeItem; + parentNode.expanded = true; + parentNode.focus(); + + await el.updateComplete; + + // Act + await sendKeys({ press: 'ArrowLeft' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(parentNode).to.have.attribute('tabindex', '0'); + expect(parentNode).not.to.have.attribute('expanded'); + }); + }); + }); + + describe('when Home is pressed', () => { + it('should move the focus to the first tree item in the tree', async () => { + // Arrange + const parentNode = el.children[3] as SlTreeItem; + parentNode.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'Home' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.children[0]).to.have.attribute('tabindex', '0'); + expect(el.children[3]).to.have.attribute('tabindex', '-1'); + }); + }); + + describe('when End is pressed', () => { + it('should move the focus to the last tree item in the tree', async () => { + // Arrange + const parentNode = el.children[0] as SlTreeItem; + parentNode.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'End' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.children[0]).to.have.attribute('tabindex', '-1'); + expect(el.children[3]).to.have.attribute('tabindex', '0'); + }); + }); + + describe('when Enter is pressed', () => { + describe('and selection is "none"', () => { + describe('and node is expanded', () => { + it('should not select the tree item', async () => { + // Arrange + const parentNode = el.children[2] as SlTreeItem; + parentNode.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'Enter' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(parentNode).to.have.attribute('tabindex', '0'); + expect(parentNode).to.have.attribute('expanded'); + expect(parentNode).not.to.have.attribute('selected'); + }); + + it('should collapse the tree item', async () => { + // Arrange + const parentNode = el.children[2] as SlTreeItem; + parentNode.expanded = true; + parentNode.focus(); + + await el.updateComplete; + + // Act + await sendKeys({ press: 'Enter' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(parentNode).to.have.attribute('tabindex', '0'); + expect(parentNode).not.to.have.attribute('expanded'); + }); + }); + + describe('and node is collapsed', () => { + describe('and selection is "none"', () => { + it('should not select the tree item', async () => { + // Arrange + const parentNode = el.children[2] as SlTreeItem; + parentNode.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'Enter' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(parentNode).to.have.attribute('tabindex', '0'); + expect(parentNode).to.have.attribute('expanded'); + expect(parentNode).not.to.have.attribute('selected'); + }); + + it('should expand the tree item', async () => { + // Arrange + const parentNode = el.children[2] as SlTreeItem; + parentNode.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'Enter' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(parentNode).to.have.attribute('tabindex', '0'); + expect(parentNode).to.have.attribute('expanded'); + }); + }); + }); + }); + + describe('and selection is "single"', () => { + it('should select only one tree item', async () => { + // Arrange + el.selection = 'single'; + const node = el.children[1] as SlTreeItem; + node.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'Enter' }); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: 'Enter' }); + + // Assert + expect(el.selectedItems.length).to.eq(1); + expect(el.children[2]).to.have.attribute('selected'); + expect(el.children[2]).not.to.have.attribute('expanded'); + }); + }); + + describe('and selection is "leaf"', () => { + it('should select only one tree item', async () => { + // Arrange + el.selection = 'leaf'; + const node = el.children[0] as SlTreeItem; + node.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'Enter' }); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: 'Enter' }); + + // Assert + expect(el.selectedItems.length).to.eq(1); + }); + + it('should expand/collapse a parent node', async () => { + // Arrange + el.selection = 'leaf'; + const parentNode = el.children[2] as SlTreeItem; + parentNode.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'Enter' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.selectedItems.length).to.eq(0); + expect(parentNode).to.have.attribute('expanded'); + }); + }); + + describe('and selection is "multiple"', () => { + it('should toggle the selection on the tree item', async () => { + // Arrange + el.selection = 'multiple'; + const node = el.children[1] as SlTreeItem; + node.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: 'Enter' }); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: 'Enter' }); + + // Assert + expect(el.selectedItems.length).to.eq(4); + }); + }); + }); + + describe('when Space is pressed', () => { + describe('and selection is "none"', () => { + it('should not select the tree item', async () => { + // Arrange + el.selection = 'none'; + const node = el.children[0] as SlTreeItem; + node.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: ' ' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(node).to.have.attribute('tabindex', '0'); + expect(node).not.to.have.attribute('expanded'); + expect(node).not.to.have.attribute('selected'); + }); + }); + + describe('and selection is "single"', () => { + it('should select only one tree item', async () => { + // Arrange + el.selection = 'single'; + const node = el.children[1] as SlTreeItem; + node.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: ' ' }); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: ' ' }); + + // Assert + expect(el.selectedItems.length).to.eq(1); + }); + }); + + describe('and selection is "leaf"', () => { + it('should select only one tree item', async () => { + // Arrange + el.selection = 'leaf'; + const node = el.children[0] as SlTreeItem; + node.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: ' ' }); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: ' ' }); + + // Assert + expect(el.selectedItems.length).to.eq(1); + }); + + it('should expand/collapse a parent node', async () => { + // Arrange + el.selection = 'leaf'; + const parentNode = el.children[2] as SlTreeItem; + parentNode.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: ' ' }); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(el.selectedItems.length).to.eq(0); + expect(parentNode).to.have.attribute('expanded'); + }); + }); + + describe('and selection is "multiple"', () => { + it('should toggle the selection on the tree item', async () => { + // Arrange + el.selection = 'multiple'; + const node = el.children[0] as SlTreeItem; + node.focus(); + await el.updateComplete; + + // Act + await sendKeys({ press: ' ' }); + await sendKeys({ press: 'ArrowRight' }); + await sendKeys({ press: ' ' }); + + // Assert + expect(el.selectedItems.length).to.eq(2); + }); + }); + }); + }); + + describe('Interactions', () => { + describe('when the tree is about to receive the focus', () => { + it('should set the focus to the last focused item', async () => { + // Arrange + const node = el.children[1] as SlTreeItem; + node.focus(); + await el.updateComplete; + + // Act + triggerBlurFor(node); + triggerFocusFor(el); + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(node).to.have.attribute('tabindex', '0'); + }); + }); + + describe('when the user clicks the expand button', () => { + it('should expand the tree item', async () => { + // Arrange + el.selection = 'single'; + await el.updateComplete; + + const node = el.children[2] as SlTreeItem; + await node.updateComplete; + + const expandButton: HTMLElement = node.shadowRoot!.querySelector('.tree-item__expand-button')!; + + // Act + expandButton.click(); + await el.updateComplete; + + // Assert + expect(node).not.to.have.attribute('selected'); + expect(node).to.have.attribute('expanded'); + }); + }); + + describe('when the user clicks on a tree item', () => { + describe('and selection is "none"', () => { + it('should not select the tree item', async () => { + // Arrange + const node = el.children[1] as SlTreeItem; + const selectedChangeSpy = sinon.spy(); + el.addEventListener('sl-selected-change', selectedChangeSpy); + + // Act + node.focus(); + node.click(); + await el.updateComplete; + + // Assert + expect(el).to.have.attribute('tabindex', '-1'); + expect(node).to.have.attribute('tabindex', '0'); + expect(node).not.to.have.attribute('selected'); + expect(selectedChangeSpy).not.to.have.been.called; + }); + }); + + describe('and selection is "single"', () => { + it('should select only one tree item', async () => { + // Arrange + el.selection = 'single'; + const node0 = el.children[0] as SlTreeItem; + const node1 = el.children[1] as SlTreeItem; + + await el.updateComplete; + + // Act + node0.click(); + await el.updateComplete; + + node1.click(); + await el.updateComplete; + + // Assert + expect(el.selectedItems.length).to.eq(1); + }); + }); + + describe('and selection is "leaf"', () => { + it('should select only one tree item', async () => { + // Arrange + el.selection = 'leaf'; + const node0 = el.children[0] as SlTreeItem; + const node1 = el.children[1] as SlTreeItem; + + await el.updateComplete; + + // Act + node0.click(); + await el.updateComplete; + + node1.click(); + await el.updateComplete; + + // Assert + expect(el.selectedItems.length).to.eq(1); + }); + + it('should expand/collapse a parent node', async () => { + // Arrange + el.selection = 'leaf'; + const parentNode = el.children[2] as SlTreeItem; + + await el.updateComplete; + + // Act + parentNode.click(); + await parentNode.updateComplete; + + // Assert + expect(el.selectedItems.length).to.eq(0); + expect(parentNode).to.have.attribute('expanded'); + }); + }); + + describe('and selection is "multiple"', () => { + it('should toggle the selection on the tree item', async () => { + // Arrange + el.selection = 'multiple'; + const node0 = el.children[0] as SlTreeItem; + const node1 = el.children[1] as SlTreeItem; + + await el.updateComplete; + + // Act + node0.click(); + await el.updateComplete; + + node1.click(); + await el.updateComplete; + + // Assert + expect(el.selectedItems.length).to.eq(2); + }); + + it('should select all the child tree items', async () => { + // Arrange + el.selection = 'multiple'; + await el.updateComplete; + + const parentNode = el.children[2] as SlTreeItem; + + // Act + parentNode.click(); + await el.updateComplete; + + // Assert + expect(parentNode).to.have.attribute('selected'); + expect(parentNode.indeterminate).to.be.false; + parentNode.getChildrenItems().forEach(child => { + expect(child).to.have.attribute('selected'); + }); + }); + + it('should set the indeterminate state to tree items if a child is selected', async () => { + // Arrange + el.selection = 'multiple'; + await el.updateComplete; + + const parentNode = el.children[2] as SlTreeItem; + const childNode = parentNode.children[0] as SlTreeItem; + + // Act + childNode.click(); + await el.updateComplete; + + // Assert + expect(parentNode).not.to.have.attribute('selected'); + expect(parentNode.indeterminate).to.be.true; + }); + }); + }); + }); + + describe('when an tree item gets selected or deselected', () => { + it('should emit a `sl-selected-change` event', async () => { + // Arrange + el.selection = 'single'; + await el.updateComplete; + + const selectedChangeSpy = sinon.spy(); + el.addEventListener('sl-selected-change', selectedChangeSpy); + + const node = el.children[0] as SlTreeItem; + + // Act + node.click(); + await el.updateComplete; + + // Assert + expect(selectedChangeSpy).to.have.been.called; + }); + }); +}); diff --git a/src/components/tree/tree.ts b/src/components/tree/tree.ts new file mode 100644 index 000000000..5bdd87129 --- /dev/null +++ b/src/components/tree/tree.ts @@ -0,0 +1,303 @@ +import { LitElement, html } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { emit } from 'src/internal/event'; +import { clamp } from 'src/internal/math'; +import { watch } from 'src/internal/watch'; +import { isTreeItem } from '../tree-item/tree-item'; +import styles from './tree.styles'; +import type SlTreeItem from '../tree-item/tree-item'; + +function syncCheckboxes(changedTreeItem: SlTreeItem) { + function syncAncestors(treeItem: SlTreeItem) { + const parentItem: SlTreeItem | null = treeItem.parentElement as SlTreeItem; + + if (isTreeItem(parentItem)) { + const children = parentItem.getChildrenItems({ includeDisabled: false }); + const allChecked = children.every(item => item.selected); + const allUnchecked = children.every(item => !item.selected && !item.indeterminate); + + parentItem.selected = allChecked; + parentItem.indeterminate = !allChecked && !allUnchecked; + + syncAncestors(parentItem); + } + } + + function syncDescendants(treeItem: SlTreeItem) { + for (const childItem of treeItem.getChildrenItems()) { + childItem.selected = !childItem.disabled && treeItem.selected; + syncDescendants(childItem); + } + } + + syncAncestors(changedTreeItem); + syncDescendants(changedTreeItem); +} + +/** + * @since 2.0 + * @status experimental + * + * @event sl-selected-change - Emitted when an item gets selected or deselected + * + * @slot - The default slot. + * + * @csspart base - The component's internal wrapper. + * + */ +@customElement('sl-tree') +export default class SlTree extends LitElement { + static styles = styles; + + @query('slot') defaultSlot: HTMLSlotElement; + + /** Specifies the selection behavior of the Tree */ + @property() selection: 'none' | 'single' | 'multiple' | 'leaf' = 'none'; + + /** + * @internal A collection of all the items in the tree, in the order they appear. + * The collection is live, it means that it is automatically updated when the underlying document is changed. + */ + private treeItems: HTMLCollectionOf = this.getElementsByTagName('sl-tree-item'); + private lastFocusedItem: SlTreeItem; + private mutationObserver: MutationObserver; + + connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'tree'); + this.setAttribute('tabindex', '0'); + + this.mutationObserver = new MutationObserver(this.handleTreeChanged); + + this.addEventListener('focusin', this.handleFocusIn); + this.addEventListener('focusout', this.handleFocusOut); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + + this.mutationObserver.disconnect(); + + this.removeEventListener('focusin', this.handleFocusIn); + this.removeEventListener('focusout', this.handleFocusOut); + } + + protected firstUpdated(): void { + this.mutationObserver.observe(this, { childList: true, subtree: true }); + } + + handleTreeChanged = (mutations: MutationRecord[]) => { + for (const mutation of mutations) { + const addedNodes: SlTreeItem[] = [...mutation.addedNodes].filter(isTreeItem) as SlTreeItem[]; + const removedNodes = [...mutation.removedNodes].filter(isTreeItem) as SlTreeItem[]; + + for (const item of addedNodes) { + item.selectable = this.selection === 'multiple'; + syncCheckboxes(item); + } + + // If the focused item has been removed form the DOM, move the focus on the first node + if (removedNodes.includes(this.lastFocusedItem)) { + this.focusItem(this.treeItems[0]); + } + } + }; + + @watch('selection') + handleSelectionChange() { + this.setAttribute('aria-multiselectable', this.selection === 'multiple' ? 'true' : 'false'); + + for (const item of this.treeItems) { + item.selectable = this.selection === 'multiple'; + } + } + + syncTreeItems(selectedItem: SlTreeItem) { + if (this.selection === 'multiple') { + syncCheckboxes(selectedItem); + } else { + for (const item of this.treeItems) { + if (item !== selectedItem) { + item.selected = false; + } + } + } + } + + selectItem(selectedItem: SlTreeItem) { + if (this.selection === 'none') return; + + if (this.selection === 'multiple') { + selectedItem.selected = !selectedItem.selected; + if (selectedItem.lazy) { + selectedItem.expanded = true; + } + this.syncTreeItems(selectedItem); + } else if (this.selection === 'single' || selectedItem.isLeaf) { + selectedItem.selected = true; + + this.syncTreeItems(selectedItem); + } else if (this.selection === 'leaf') { + selectedItem.expanded = !selectedItem.expanded; + } + + emit(this, 'sl-selected-change', { detail: this.selectedItems }); + } + + /** + * Returns the list of tree items that are selected in the tree + */ + get selectedItems(): SlTreeItem[] { + const items = [...this.treeItems]; + const isSelected = (item: SlTreeItem) => item.selected; + + return items.filter(isSelected); + } + + getFocusableItems() { + return [...this.treeItems].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]'); + + return !parent || (parent.expanded && !parent.loading); + }); + } + + focusItem(item?: SlTreeItem | null) { + item?.focus(); + } + + handleKeyDown(event: KeyboardEvent) { + if (!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' '].includes(event.key)) return; + const items = this.getFocusableItems(); + + if (items.length > 0) { + event.preventDefault(); + const activeItemIndex = items.findIndex(item => document.activeElement === item); + 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 (event.key === 'ArrowRight') { + /** + * 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.expanded || (activeItem.isLeaf && !activeItem.lazy)) { + focusItemAt(activeItemIndex + 1); + } else { + toggleExpand(true); + } + } else if (event.key === 'ArrowLeft') { + /** + * 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.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') { + /** + * Performs the default action of the currently focused node. For parent nodes, it opens or closes the node. + * In single-select trees, if the node has no children, selects the current node if not already selected (which + * is the default action). + */ + if (['none', 'leaf'].includes(this.selection) && !activeItem.isLeaf) { + toggleExpand(!activeItem.expanded); + } else { + this.selectItem(activeItem); + } + } else if (event.key === ' ') { + /** + * Toggles the selection state of the focused node. + */ + this.selectItem(activeItem); + } + } + } + + handleClick(e: Event) { + const target = e.target as HTMLElement; + const treeItem = target.closest('sl-tree-item')!; + + if (!treeItem.disabled) { + this.selectItem(treeItem); + } + } + + handleFocusOut = (e: FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + + // If the element that got the focus is not in the tree + if (!relatedTarget || !this.contains(relatedTarget)) { + this.tabIndex = 0; + } + }; + + handleFocusIn = (e: FocusEvent) => { + const target = e.target as SlTreeItem; + + // If the tree has been focused, move the focus to the last focused item + if (e.target === this) { + this.focusItem(this.lastFocusedItem || this.treeItems[0]); + } + + // If the target is a tree item, update the tabindex + if (isTreeItem(target) && !target.disabled) { + if (this.lastFocusedItem) { + this.lastFocusedItem.tabIndex = -1; + } + this.lastFocusedItem = target; + this.tabIndex = -1; + + target.tabIndex = 0; + } + }; + + render() { + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-tree': SlTree; + } +} diff --git a/src/internal/string.ts b/src/internal/string.ts index 55f682a82..3b40c5d20 100644 --- a/src/internal/string.ts +++ b/src/internal/string.ts @@ -1,3 +1,11 @@ export function uppercaseFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } + +export function stringMap(...parts: (string | Record)[]): string | undefined { + return parts + .map(part => (typeof part === 'object' ? Object.keys(part).filter((key: string) => part[key]) : part)) + .flat() + .filter(Boolean) + .join(' '); +} diff --git a/src/shoelace.ts b/src/shoelace.ts index 59d1b9dbb..e72b29b8d 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -51,6 +51,8 @@ export { default as SlTag } from './components/tag/tag'; export { default as SlTextarea } from './components/textarea/textarea'; export { default as SlTooltip } from './components/tooltip/tooltip'; export { default as SlVisuallyHidden } from './components/visually-hidden/visually-hidden'; +export { default as SlTree } from './components/tree/tree'; +export { default as SlTreeItem } from './components/tree-item/tree-item'; /* plop:component */ // Utilities