diff --git a/cspell.json b/cspell.json index f36763fe..48394a5c 100644 --- a/cspell.json +++ b/cspell.json @@ -79,6 +79,7 @@ "monospace", "mousedown", "mouseup", + "multiselectable", "nextjs", "nocheck", "noopener", @@ -116,6 +117,7 @@ "textareas", "textfield", "transitionend", + "treeitem", "Triaging", "turbolinks", "unbundles", diff --git a/docs/components/tree-item.md b/docs/components/tree-item.md index 5a3cc753..303b066d 100644 --- a/docs/components/tree-item.md +++ b/docs/components/tree-item.md @@ -2,94 +2,164 @@ [component-header:sl-tree-item] -A tree item is a hierarchical node of a tree. +A tree item serves as a hierarchical node that lives inside a [tree](/components/tree). ```html preview - Tree node + + + Item 1 + Item A + Item B + Item C + + Item 2 + Item 3 + +``` + + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + Item 1 + Item A + Item B + Item C + + Item 2 + Item 3 + +); ``` ## Examples ### Nested tree items -A tree item can contain other items, this allow the user to expand or collapse nested nodes accordingly. +A tree item can contain other tree items. This allows the node to be expanded or collapsed by the user. ```html preview - - Parent Node - Child 1 - Child 2 - Child 3 - + + + Item 1 + + Item A + Item Z + Item Y + Item X + + Item B + Item C + + Item 2 + Item 3 + ``` -### Expanded + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; -Use the `expanded` attribute to display the nested items. - -```html preview - - Parent Node - Child 1 - Child 2 - Child 3 - +const App = () => ( + + + Item 1 + + Item A + Item Z + Item Y + Item X + + Item B + Item C + + Item 2 + Item 3 + +); ``` ### Selected -Use the `selected` attribute to the mark the item as selected. +Use the `selected` attribute to select a tree item initially. ```html preview - - Parent Node - Child 1 - Child 2 - Child 3 - + + + Item 1 + Item A + Item B + Item C + + Item 2 + Item 3 + ``` -### Selectable + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; -Use the `selectable` attribute to display the checkbox. - -```html preview - - Parent Node - Child 1 - Child 2 - Child 3 - - - +const App = () => ( + + + Item 1 + Item A + Item B + Item C + + Item 2 + Item 3 + +); ``` -### Lazy +### Expanded -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. +Use the `expanded` attribute to expand a tree item initially. ```html preview - Parent Node + + + Item 1 + + Item A + Item Z + Item Y + Item X + + Item B + Item C + + Item 2 + Item 3 + ``` -### Indentation size + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; -Use the `--indentation-size` custom property to set the tree item's indentation. - -```html preview - - Parent Node - Child 1 - Child 2 - Child 3 - +const App = () => ( + + + Item 1 + + Item A + Item Z + Item Y + Item X + + Item B + Item C + + Item 2 + Item 3 + +); ``` [component-metadata:sl-tree-item] diff --git a/docs/components/tree.md b/docs/components/tree.md index bf7afcb9..7024c3eb 100644 --- a/docs/components/tree.md +++ b/docs/components/tree.md @@ -2,181 +2,259 @@ [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. +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. ```html preview - - Getting Started + + Deciduous + Birch - Overview - Quick Start - New to Web Components? - What Problem Does This Solve? - Browser Support - License - Attribution + Maple + Field maple + Red maple + Sugar maple - Installation - Usage + Oak - Frameworks - React - Vue - Angular + Coniferous + Cedar + Pine + Spruce - Resources + + Non-trees + Bamboo + Cactus + Fern + ``` + ```jsx react import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - - Getting Started - Overview - Installation - Usage + + Deciduous + Birch + + Maple + Field maple + Red maple + Sugar maple + + Oak - Frameworks - React - Vue - Angular + Coniferous + Cedar + Pine + Spruce - Resources + + Non-trees + Bamboo + Cactus + Fern + ); ``` ## Examples -### Selection modes +### Selection Modes -Use the `selection` attribute to specify the selection behavior of the tree +Use the `selection` attribute to change 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 `single` to allow the selection of a single item (default). - Set `multiple` to allow the selection of multiple items. +- Set `leaf` to allow the selection of a single leaf node. Clicking on a parent node will expand/collapse the node. ```html preview - - none - single - leaf - multiple + + Single + Multiple + Leaf - - - - Parent - - Parent 1 - Child 1 - Child 2 - + + + + + Item 1 - Parent 2 - Child 1 - Child 2 - Child 3 + Item A + Item Z + Item Y + Item X + Item B + Item C + Item 2 + Item 3 - + ``` + ```jsx react import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; const App = () => { - const [selection, setSelection] = useState('none'); + const [selection, setSelection] = useState('single'); return ( <> setSelection(event.target.value)}> - none single - leaf multiple + leaf + - - - Parent - - Parent 1 Child 1 - Child 2 - + + + + Item 1 - Parent 2 Child 1 - Child 2 - Child 3 + Item A + Item Z + Item Y + Item X + Item B + Item C + Item 2 + Item 3 > ); }; ``` -### Lazy loading +### Showing Indent Guides -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`. +Indent guides can be drawn by setting `--indent-guide-width`. You can also change the color, offset, and style, using `--indent-guide-color`, `--indent-guide-style`, and `--indent-guide-offset`, respectively. ```html preview - - Getting Started + + + Deciduous + Birch + + Maple + Field maple + Red maple + Sugar maple + + Oak + + + + Coniferous + Cedar + Pine + Spruce + + + + Non-trees + Bamboo + Cactus + Fern + + + + +``` + + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + Deciduous + Birch + + Maple + Field maple + Red maple + Sugar maple + + Oak + + + + Coniferous + Cedar + Pine + Spruce + + + + Non-trees + Bamboo + Cactus + Fern + + +); +``` + +### Lazy Loading + +Use the `lazy` attribute on a tree 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 the `sl-lazy-load` event will be emitted to allow you to load data asynchronously. The item will remain in a loading state until its content is changed. + +If you want to disable this behavior after the first load, simply remove the `lazy` attribute and, on the next expand, the existing content will be shown instead. + +```html preview + + Available Trees ``` @@ -191,17 +269,17 @@ const App = () => { const handleLazyLoad = () => { // Simulate asynchronous loading setTimeout(() => { - setChildItems(['Overview', 'Installation', 'Usage']); + setChildItems(['Birch', 'Cedar', 'Maple', 'Pine']); - // Disable lazy mode since the content has been loaded + // Disable lazy mode once the content has been loaded setLazy(false); - }, 2000); + }, 1000); }; return ( - + - Getting Started + Available Trees {childItems.map(item => ( {item} ))} @@ -211,133 +289,109 @@ const App = () => { }; ``` -### Styling trees +### With Icons -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. +Decorative icons can be used before labels to provide hints for each node. ```html preview - - + - Getting Started + + Root + - Overview - Quick Start - New to Web Components? - What Problem Does This Solve? - Browser Support - License - Attribution + + Folder 1 + + + File 1 - 1 + + + + File 1 - 2 + + + + File 1 - 3 + - 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 2 - 1 + + + + File 2 - 2 + + + + + File 1 - File 1 ``` +```jsx react +import { SlIcon, 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 once the content has been loaded + setLazy(false); + }, 1000); + }; + + return ( + + + + 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/docs/resources/changelog.md b/docs/resources/changelog.md index c4ee59b8..7e81e6fd 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -10,6 +10,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis ## Next +- Added experimental `` and `` components [#823](https://github.com/shoelace-style/shoelace/pull/823) - Added `--indicator-width` custom property to `` [#837](https://github.com/shoelace-style/shoelace/issues/837) - Added Swedish translation [#838](https://github.com/shoelace-style/shoelace/pull/838) - Changed the type of component styles from `CSSResult` to `CSSResultGroup` [#828](https://github.com/shoelace-style/shoelace/issues/828) diff --git a/src/components/tree-item/tree-item.styles.ts b/src/components/tree-item/tree-item.styles.ts index e4c0c750..fe3d6963 100644 --- a/src/components/tree-item/tree-item.styles.ts +++ b/src/components/tree-item/tree-item.styles.ts @@ -7,23 +7,25 @@ export default css` :host { display: block; outline: 0; + z-index: 0; } :host(:focus) { outline: 0; } + ::slotted(sl-icon) { + margin-inline-end: var(--sl-spacing-x-small); + } + .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 { @@ -56,33 +58,46 @@ export default css` align-items: center; justify-content: center; box-sizing: content-box; - color: var(--sl-color-neutral-400); + color: var(--sl-color-neutral-500); padding: var(--sl-spacing-x-small); width: 1rem; height: 1rem; + cursor: pointer; + } + + .tree-item__expand-button--visible { + cursor: pointer; } .tree-item__item { display: flex; align-items: center; - cursor: pointer; + border-inline-start: solid 3px transparent; } - .tree-item__item--disabled { - color: var(--sl-color-neutral-400); + .tree-item--disabled .tree-item__item { + opacity: 0.5; outline: none; cursor: not-allowed; } + :host(:not([aria-disabled='true'])) .tree-item__item:hover { + background-color: var(--sl-color-neutral-100); + } + :host(:not([aria-disabled='true']):focus-visible) .tree-item__item { outline: var(--sl-focus-ring); outline-offset: var(--sl-focus-ring-offset); + z-index: 2; } - :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); + :host(:not([aria-disabled='true'])) .tree-item--selected .tree-item__item { + background-color: var(--sl-color-neutral-100); + border-inline-start-color: var(--sl-color-primary-600); + } + + :host(:not([aria-disabled='true'])) .tree-item__expand-button { + color: var(--sl-color-neutral-600); } .tree-item__label { @@ -92,6 +107,26 @@ export default css` } .tree-item__children { - font-size: calc(1em + var(--indentation-size, var(--sl-spacing-medium))); + font-size: calc(1em + var(--indent-size, var(--sl-spacing-medium))); + } + + /* Indentation lines */ + .tree-item__children { + position: relative; + } + + .tree-item__children::before { + content: ''; + position: absolute; + top: var(--indent-guide-offset); + bottom: var(--indent-guide-offset); + left: calc(1em - (var(--indent-guide-width) / 2) - 1px); + border-inline-end: var(--indent-guide-width) var(--indent-guide-style) var(--indent-guide-color); + z-index: 1; + } + + .tree-item--rtl .tree-item__children::before { + left: auto; + right: 1em; } `; diff --git a/src/components/tree-item/tree-item.test.ts b/src/components/tree-item/tree-item.test.ts index 894777fd..3f26d514 100644 --- a/src/components/tree-item/tree-item.test.ts +++ b/src/components/tree-item/tree-item.test.ts @@ -26,7 +26,7 @@ describe('', () => { expect(leafItem).to.have.attribute('aria-disabled', 'false'); }); - describe('when contain child tree items', () => { + describe('when it contains child tree items', () => { it('should set isLeaf to false', () => { // Assert expect(parentItem.isLeaf).to.be.false; @@ -156,7 +156,7 @@ describe('', () => { }); }); - describe('when a item is disabled', () => { + describe('when the item is disabled', () => { it('should update the aria-disabled attribute', async () => { // Act leafItem.disabled = true; @@ -175,4 +175,15 @@ describe('', () => { expect(leafItem.shadowRoot?.querySelector('.tree-item__item')?.part.contains('item--disabled')).to.be.true; }); }); + + describe('when the item is expanded', () => { + it('should set item--expanded part', async () => { + // Act + leafItem.expanded = true; + await leafItem.updateComplete; + + // Assert + expect(leafItem.shadowRoot?.querySelector('.tree-item__item')?.part.contains('item--expanded')).to.be.true; + }); + }); }); diff --git a/src/components/tree-item/tree-item.ts b/src/components/tree-item/tree-item.ts index f60f58d4..6fab18b9 100644 --- a/src/components/tree-item/tree-item.ts +++ b/src/components/tree-item/tree-item.ts @@ -1,16 +1,16 @@ -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/icon/icon'; import '../../components/spinner/spinner'; import { emit } from '../../internal/event'; import { watch } from '../../internal/watch'; +import { LocalizeController } from '../../utilities/localize'; import styles from './tree-item.styles'; import type { PropertyValueMap } from 'lit'; @@ -23,6 +23,7 @@ export function isTreeItem(element: Element) { * @status experimental * * @dependency sl-checkbox + * @dependency sl-icon * @dependency sl-spinner * * @event sl-expand - Emitted when the item expands. @@ -35,13 +36,13 @@ export function isTreeItem(element: Element) { * * @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) + * @csspart item--disabled - Applied when the item is disabled. + * @csspart item--expanded - Applied when the item is expanded. + * @csspart item--indeterminate - Applied when the selection is indeterminate. + * @csspart item--selected - Applied when the item is selected. + * @csspart indentation - The item's indentation container. + * @csspart label - The item's label. + * @csspart children - The item's children container. */ @customElement('sl-tree-item') export default class SlTreeItem extends LitElement { @@ -49,30 +50,23 @@ export default class SlTreeItem extends LitElement { private readonly localize = new LocalizeController(this); - /** Expands the item when is set */ + @state() indeterminate = false; + @state() isLeaf = false; + @state() loading = false; + @state() selectable = false; + + /** Expands the tree item. */ @property({ type: Boolean, reflect: true }) expanded = false; - /** Sets the treeitem's selected state */ + /** Draws the tree item in a selected state. */ @property({ type: Boolean, reflect: true }) selected = false; - /** Disables the treeitem */ + /** Disables the tree item. */ @property({ type: Boolean, reflect: true }) disabled = false; - /** When set, enables the lazy mode behavior */ + /** Enables lazy loading 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; @@ -173,9 +167,7 @@ export default class SlTreeItem extends LitElement { emit(this, 'sl-after-collapse'); } - /** - * @internal Gets all the nested tree items - */ + // Gets all the nested tree items getChildrenItems({ includeDisabled = true }: { includeDisabled?: boolean } = {}): SlTreeItem[] { return this.childrenSlot ? ([...this.childrenSlot.assignedElements({ flatten: true })].filter( @@ -184,17 +176,15 @@ export default class SlTreeItem extends LitElement { : []; } - /** - * @internal Checks whether the item is nested into an item - */ + // 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(); + handleToggleExpand(event: Event) { + event.preventDefault(); + event.stopImmediatePropagation(); if (!this.disabled) { this.expanded = !this.expanded; @@ -203,7 +193,6 @@ export default class SlTreeItem extends LitElement { handleChildrenSlotChange() { this.loading = false; - this.isLeaf = this.getChildrenItems().length === 0; } @@ -214,28 +203,47 @@ export default class SlTreeItem extends LitElement { } render() { + const isRtl = this.localize.dir() === 'rtl'; + return html` - + - + ${when(this.loading, () => html` `)} ${when( !this.loading && (!this.isLeaf || this.lazy), () => html` - + ` )} diff --git a/src/components/tree/tree.styles.ts b/src/components/tree/tree.styles.ts index 883fda5b..61a40e89 100644 --- a/src/components/tree/tree.styles.ts +++ b/src/components/tree/tree.styles.ts @@ -5,10 +5,22 @@ export default css` ${componentStyles} :host { + /* + * These are actually used by tree item, but we define them here so they can more easily be set and all tree items + * stay consistent. + */ + --indent-guide-color: var(--sl-color-neutral-200); + --indent-guide-offset: 0; + --indent-guide-style: solid; + --indent-guide-width: 0; + --indent-size: var(--sl-spacing-large); + 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. + isolation: isolate; + + /* + * Tree item indentation uses the "em" unit to increment its width on each level, so setting the font size to zero + * here removes the indentation for all the nodes on the first level. */ font-size: 0; } diff --git a/src/components/tree/tree.test.ts b/src/components/tree/tree.test.ts index fb61baa1..0494e327 100644 --- a/src/components/tree/tree.test.ts +++ b/src/components/tree/tree.test.ts @@ -207,78 +207,6 @@ describe('', () => { }); 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 @@ -353,25 +281,6 @@ describe('', () => { }); 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 @@ -484,26 +393,6 @@ describe('', () => { }); 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 @@ -622,13 +511,13 @@ describe('', () => { }); describe('when an tree item gets selected or deselected', () => { - it('should emit a `sl-selected-change` event', async () => { + it('should emit a `sl-selection-change` event', async () => { // Arrange el.selection = 'single'; await el.updateComplete; const selectedChangeSpy = sinon.spy(); - el.addEventListener('sl-selected-change', selectedChangeSpy); + el.addEventListener('sl-selection-change', selectedChangeSpy); const node = el.children[0] as SlTreeItem; diff --git a/src/components/tree/tree.ts b/src/components/tree/tree.ts index 5bdd8712..cdf006bf 100644 --- a/src/components/tree/tree.ts +++ b/src/components/tree/tree.ts @@ -3,6 +3,7 @@ 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 { LocalizeController } from '../../utilities/localize'; import { isTreeItem } from '../tree-item/tree-item'; import styles from './tree.styles'; import type SlTreeItem from '../tree-item/tree-item'; @@ -38,12 +39,17 @@ function syncCheckboxes(changedTreeItem: SlTreeItem) { * @since 2.0 * @status experimental * - * @event sl-selected-change - Emitted when an item gets selected or deselected + * @event {{ selection: this.selectedItems }} sl-selection-change - Emitted when an item gets selected or deselected * * @slot - The default slot. * * @csspart base - The component's internal 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. */ @customElement('sl-tree') export default class SlTree extends LitElement { @@ -52,14 +58,15 @@ export default class SlTree extends LitElement { @query('slot') defaultSlot: HTMLSlotElement; /** Specifies the selection behavior of the Tree */ - @property() selection: 'none' | 'single' | 'multiple' | 'leaf' = 'none'; + @property() selection: 'single' | 'multiple' | 'leaf' = 'single'; - /** - * @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. - */ + // + // 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 treeItems: HTMLCollectionOf = this.getElementsByTagName('sl-tree-item'); private lastFocusedItem: SlTreeItem; + private readonly localize = new LocalizeController(this); private mutationObserver: MutationObserver; connectedCallback(): void { @@ -125,8 +132,6 @@ export default class SlTree extends LitElement { } selectItem(selectedItem: SlTreeItem) { - if (this.selection === 'none') return; - if (this.selection === 'multiple') { selectedItem.selected = !selectedItem.selected; if (selectedItem.lazy) { @@ -141,12 +146,10 @@ export default class SlTree extends LitElement { selectedItem.expanded = !selectedItem.expanded; } - emit(this, 'sl-selected-change', { detail: this.selectedItems }); + emit(this, 'sl-selection-change', { detail: { selection: this.selectedItems } }); } - /** - * Returns the list of tree items that are selected in the tree - */ + // 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; @@ -171,8 +174,13 @@ export default class SlTree extends LitElement { } handleKeyDown(event: KeyboardEvent) { - if (!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' '].includes(event.key)) return; + if (!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' '].includes(event.key)) { + return; + } + const items = this.getFocusableItems(); + const isLtr = this.localize.dir() === 'ltr'; + const isRtl = this.localize.dir() === 'rtl'; if (items.length > 0) { event.preventDefault(); @@ -188,69 +196,48 @@ export default class SlTree extends LitElement { }; if (event.key === 'ArrowDown') { - /** - * Moves focus to the next node that is focusable without opening or closing a node. - */ + // 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. - */ + // 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. - */ + } 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.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. - */ + } 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.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. - */ + // 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. - */ + // 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. - */ + } else if (event.key === 'Enter' || event.key === ' ') { + // Selects the focused node. this.selectItem(activeItem); } } } - handleClick(e: Event) { - const target = e.target as HTMLElement; + handleClick(event: Event) { + const target = event.target as HTMLElement; const treeItem = target.closest('sl-tree-item')!; if (!treeItem.disabled) { @@ -258,8 +245,8 @@ export default class SlTree extends LitElement { } } - handleFocusOut = (e: FocusEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement; + 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)) { @@ -267,11 +254,11 @@ export default class SlTree extends LitElement { } }; - handleFocusIn = (e: FocusEvent) => { - const target = e.target as SlTreeItem; + handleFocusIn = (event: FocusEvent) => { + const target = event.target as SlTreeItem; // If the tree has been focused, move the focus to the last focused item - if (e.target === this) { + if (event.target === this) { this.focusItem(this.lastFocusedItem || this.treeItems[0]); } diff --git a/src/internal/string.ts b/src/internal/string.ts index 3b40c5d2..55f682a8 100644 --- a/src/internal/string.ts +++ b/src/internal/string.ts @@ -1,11 +1,3 @@ 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 e72b29b8..8da0fd0c 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -50,9 +50,9 @@ export { default as SlTabPanel } from './components/tab-panel/tab-panel'; 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'; +export { default as SlVisuallyHidden } from './components/visually-hidden/visually-hidden'; /* plop:component */ // Utilities