Merge branch 'feat/tree-view' of github.com:alenaksu/shoelace into alenaksu-feat/tree-view

pull/823/head
Cory LaViska 2022-07-26 08:17:41 -04:00
commit 33587f51d3
11 zmienionych plików z 1981 dodań i 0 usunięć

Wyświetl plik

@ -64,6 +64,8 @@
- [Tag](/components/tag)
- [Textarea](/components/textarea)
- [Tooltip](/components/tooltip)
- [Tree](/components/tree)
- [Tree Item](/components/tree-item)
<!--plop:component-->
- Utilities

Wyświetl plik

@ -0,0 +1,95 @@
# Tree Item
[component-header:sl-tree-item]
A tree item is a hierarchical node of a tree.
```html preview
<sl-tree-item> Tree node </sl-tree-item>
```
## Examples
### Nested tree items
A tree item can contain other items, this allow the user to expand or collapse nested nodes accordingly.
```html preview
<sl-tree-item>
Parent Node
<sl-tree-item> Child 1 </sl-tree-item>
<sl-tree-item> Child 2 </sl-tree-item>
<sl-tree-item> Child 3 </sl-tree-item>
</sl-tree-item>
```
### Expanded
Use the `expanded` attribute to display the nested items.
```html preview
<sl-tree-item expanded>
Parent Node
<sl-tree-item> Child 1 </sl-tree-item>
<sl-tree-item> Child 2 </sl-tree-item>
<sl-tree-item> Child 3 </sl-tree-item>
</sl-tree-item>
```
### Selected
Use the `selected` attribute to the mark the item as selected.
```html preview
<sl-tree-item expanded>
Parent Node
<sl-tree-item> Child 1 </sl-tree-item>
<sl-tree-item selected> Child 2 </sl-tree-item>
<sl-tree-item> Child 3 </sl-tree-item>
</sl-tree-item>
```
### Selectable
Use the `selectable` attribute to display the checkbox.
```html preview
<sl-tree-item class="selectable" selectable>
Parent Node
<sl-tree-item selectable> Child 1 </sl-tree-item>
<sl-tree-item selectable> Child 2 </sl-tree-item>
<sl-tree-item selectable> Child 3 </sl-tree-item>
</sl-tree-item>
<script>
document.querySelector('sl-tree-item.selectable').addEventListener('click', ({ target }) => {
if (target.hasAttribute('selectable')) {
target.selected = !target.selected;
}
});
</script>
```
### 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
<sl-tree-item lazy> Parent Node </sl-tree-item>
```
### Indentation size
Use the `--indentation-size` custom property to set the tree item's indentation.
```html preview
<sl-tree-item style="--indentation-size: 3rem" expanded>
Parent Node
<sl-tree-item> Child 1 </sl-tree-item>
<sl-tree-item> Child 2 </sl-tree-item>
<sl-tree-item> Child 3 </sl-tree-item>
</sl-tree-item>
```
[component-metadata:sl-tree-item]

Wyświetl plik

@ -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
<sl-tree>
<sl-tree-item expanded>
Getting Started
<sl-tree-item>
Overview
<sl-tree-item>Quick Start</sl-tree-item>
<sl-tree-item>New to Web Components?</sl-tree-item>
<sl-tree-item>What Problem Does This Solve?</sl-tree-item>
<sl-tree-item>Browser Support</sl-tree-item>
<sl-tree-item>License</sl-tree-item>
<sl-tree-item>Attribution</sl-tree-item>
</sl-tree-item>
<sl-tree-item> Installation </sl-tree-item>
<sl-tree-item> Usage </sl-tree-item>
</sl-tree-item>
<sl-tree-item>
Frameworks
<sl-tree-item> React</sl-tree-item>
<sl-tree-item> Vue</sl-tree-item>
<sl-tree-item> Angular</sl-tree-item>
</sl-tree-item>
<sl-tree-item disabled> Resources </sl-tree-item>
</sl-tree>
```
```jsx react
import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlTree>
<SlTreeItem expanded>
Getting Started
<SlTreeItem> Overview </SlTreeItem>
<SlTreeItem> Installation </SlTreeItem>
<SlTreeItem> Usage </SlTreeItem>
</SlTreeItem>
<SlTreeItem>
Frameworks
<SlTreeItem> React</SlTreeItem>
<SlTreeItem> Vue</SlTreeItem>
<SlTreeItem> Angular</SlTreeItem>
</SlTreeItem>
<SlTreeItem disabled> Resources </SlTreeItem>
</SlTree>
);
```
## 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
<sl-select id="selection-mode" value="none" label="Selection">
<sl-menu-item value="none">none</sl-menu-item>
<sl-menu-item value="single">single</sl-menu-item>
<sl-menu-item value="leaf">leaf</sl-menu-item>
<sl-menu-item value="multiple">multiple</sl-menu-item>
</sl-select>
<br />
<sl-tree class="selectable">
<sl-tree-item expanded>
Parent
<sl-tree-item expanded>
Parent 1
<sl-tree-item> Child 1 </sl-tree-item>
<sl-tree-item> Child 2 </sl-tree-item>
</sl-tree-item>
<sl-tree-item>
Parent 2
<sl-tree-item> Child 1</sl-tree-item>
<sl-tree-item> Child 2</sl-tree-item>
<sl-tree-item> Child 3</sl-tree-item>
</sl-tree-item>
</sl-tree-item>
</sl-tree>
<style>
.selectable sl-tree-item::part(item--selected) {
color: var(--sl-color-primary-600);
}
</style>
<script>
(() => {
const selectionMode = document.querySelector('#selection-mode');
const treeItem = document.querySelector('.selectable');
selectionMode.addEventListener('sl-change', () => {
treeItem.selection = selectionMode.value;
});
})();
</script>
```
```jsx react
import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react';
const App = () => {
const [selection, setSelection] = useState('none');
return (
<>
<SlSelect label="Selection" value={value} onSlChange={event => setSelection(event.target.value)}>
<SlMenuItem value="none">none</SlMenuItem>
<SlMenuItem value="single">single</SlMenuItem>
<SlMenuItem value="leaf">leaf</SlMenuItem>
<SlMenuItem value="multiple">multiple</SlMenuItem>
</SlSelect>
<br />
<SlTree selection={selection} class="selectable">
<SlTreeItem expanded>
Parent
<SlTreeItem expanded>
Parent 1<SlTreeItem> Child 1 </SlTreeItem>
<SlTreeItem> Child 2 </SlTreeItem>
</SlTreeItem>
<SlTreeItem>
Parent 2<SlTreeItem> Child 1</SlTreeItem>
<SlTreeItem> Child 2</SlTreeItem>
<SlTreeItem> Child 3</SlTreeItem>
</SlTreeItem>
</SlTreeItem>
</SlTree>
</>
);
};
```
### 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
<sl-tree>
<sl-tree-item lazy> Getting Started </sl-tree-item>
</sl-tree>
<script type="module">
const lazyItem = document.querySelector('sl-tree-item[lazy]');
lazyItem.addEventListener('sl-lazy-load', () => {
// Simulate an asynchronous loading
setTimeout(() => {
const subItems = ['Overview', 'Installation', 'Usage'];
const fragment = document.createDocumentFragment();
for (const item of subItems) {
const treeItem = document.createElement('sl-tree-item');
treeItem.innerText = item;
fragment.appendChild(treeItem);
}
lazyItem.appendChild(fragment);
// Disable lazy mode since the content has been loaded
lazyItem.lazy = false;
}, 2000);
});
</script>
```
```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 (
<SlTree>
<SlTreeItem lazy={lazy} onSlLazyLoad={handleLazyLoad}>
Getting Started
{childItems.map(item => (
<SlTreeItem>{item}</SlTreeItem>
))}
</SlTreeItem>
</SlTree>
);
};
```
### 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
<style>
.with-custom-style sl-tree-item::part(item) {
border-left: 2px solid transparent;
}
.with-custom-style sl-tree-item:not([disabled])::part(item):hover,
.with-custom-style sl-tree-item:focus-visible::part(item) {
color: var(--sl-color-primary-1000);
background-color: var(--sl-color-neutral-200);
}
.with-custom-style sl-tree-item::part(item--selected),
.with-custom-style sl-tree-item::part(item--selected):hover,
.with-custom-style sl-tree-item:focus-visible::part(item--selected) {
color: var(--sl-color-neutral-1000);
background-color: var(--sl-color-neutral-100);
border-left-color: var(--sl-color-primary-600);
}
</style>
<sl-tree selection="leaf" class="with-custom-style">
<sl-tree-item expanded>
Getting Started
<sl-tree-item>
Overview
<sl-tree-item>Quick Start</sl-tree-item>
<sl-tree-item>New to Web Components?</sl-tree-item>
<sl-tree-item>What Problem Does This Solve?</sl-tree-item>
<sl-tree-item>Browser Support</sl-tree-item>
<sl-tree-item>License</sl-tree-item>
<sl-tree-item>Attribution</sl-tree-item>
</sl-tree-item>
<sl-tree-item selected> Installation </sl-tree-item>
<sl-tree-item> Usage </sl-tree-item>
</sl-tree-item>
<sl-tree-item>
Frameworks
<sl-tree-item> React</sl-tree-item>
<sl-tree-item> Vue</sl-tree-item>
<sl-tree-item> Angular</sl-tree-item>
</sl-tree-item>
<sl-tree-item disabled> Resources </sl-tree-item>
</sl-tree>
```
### With indentation lines
```html preview
<style>
.with-indentation-lines sl-tree-item[expanded]::part(children) {
position: relative;
}
.with-indentation-lines sl-tree-item[expanded]::part(children)::before {
content: '';
position: absolute;
left: 1em;
top: var(--sl-spacing-2x-small);
bottom: var(--sl-spacing-2x-small);
border-right: 1px solid var(--sl-color-neutral-100);
transition: 0.2s border-right ease-in-out;
}
.with-indentation-lines sl-tree-item[expanded]::part(children):hover::before {
border-right: 1px solid var(--sl-color-neutral-600);
}
</style>
<sl-tree class="with-indentation-lines">
<sl-tree-item expanded>
Getting Started
<sl-tree-item>
Overview
<sl-tree-item>Quick Start</sl-tree-item>
<sl-tree-item>New to Web Components?</sl-tree-item>
<sl-tree-item>What Problem Does This Solve?</sl-tree-item>
<sl-tree-item>Browser Support</sl-tree-item>
<sl-tree-item>License</sl-tree-item>
<sl-tree-item>Attribution</sl-tree-item>
</sl-tree-item>
<sl-tree-item> Installation </sl-tree-item>
<sl-tree-item> Usage </sl-tree-item>
</sl-tree-item>
<sl-tree-item>
Frameworks
<sl-tree-item> React</sl-tree-item>
<sl-tree-item> Vue</sl-tree-item>
<sl-tree-item> Angular</sl-tree-item>
</sl-tree-item>
<sl-tree-item disabled> Resources </sl-tree-item>
</sl-tree>
```
### With icons
```html preview
<style>
sl-icon {
margin-right: var(--sl-spacing-x-small);
}
</style>
<sl-tree>
<sl-tree-item expanded>
<sl-icon name="folder"></sl-icon>Root
<sl-tree-item>
<sl-icon name="folder"> </sl-icon>Folder 1
<sl-tree-item> <sl-icon name="files"></sl-icon>File 1 - 1 </sl-tree-item>
<sl-tree-item disabled> <sl-icon name="files"></sl-icon>File 1 - 2 </sl-tree-item>
<sl-tree-item> <sl-icon name="files"></sl-icon>File 1 - 3 </sl-tree-item>
</sl-tree-item>
<sl-tree-item>
<sl-icon name="files"></sl-icon>
Folder 2
<sl-tree-item selected> <sl-icon name="files"></sl-icon>File 2 - 1 </sl-tree-item>
<sl-tree-item> <sl-icon name="files"></sl-icon>File 2 - 2 </sl-tree-item>
</sl-tree-item>
<sl-tree-item> <sl-icon name="files"></sl-icon>File 1 </sl-tree-item>
</sl-tree-item>
</sl-tree>
```
[component-metadata:sl-tree]

Wyświetl plik

@ -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)));
}
`;

Wyświetl plik

@ -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('<sl-tree-item>', () => {
let leafItem: SlTreeItem;
let parentItem: SlTreeItem;
beforeEach(async () => {
leafItem = await fixture(html` <sl-tree-item>Node 1</sl-tree-item> `);
parentItem = await fixture(html`
<sl-tree-item>
Parent Node
<sl-tree-item>Node 1</sl-tree-item>
<sl-tree-item>Node 1</sl-tree-item>
</sl-tree-item>
`);
});
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;
});
});
});

Wyświetl plik

@ -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<SlTreeItem> | Map<PropertyKey, unknown>): void {
if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) {
this.indeterminate = false;
}
}
render() {
return html`
<div part="base" class="tree-item">
<div
class="${classMap({
'tree-item__item': true,
'tree-item__item--selected': this.selected,
'tree-item__item--disabled': this.disabled
})}"
part="${stringMap({
item: true,
'item--selected': this.selected,
'item--disabled': this.disabled
})}"
>
<div class="tree-item__indentation" part="indentation"></div>
<div class="tree-item__expand-button" aria-hidden="true" @click="${this.handleToggleExpand}">
${when(this.loading, () => html` <sl-spinner></sl-spinner> `)}
${when(
!this.loading && (!this.isLeaf || this.lazy),
() => html`
<sl-icon library="system" name="${this.expanded ? 'chevron-down' : 'chevron-right'}"></sl-icon>
`
)}
</div>
${when(
this.selectable,
() =>
html`
<sl-checkbox
tabindex="-1"
class="tree-item__checkbox"
?disabled="${this.disabled}"
?checked="${live(this.selected)}"
?indeterminate="${this.indeterminate}"
>
<div class="tree-item__label" part="label">
<slot></slot>
</div>
</sl-checkbox>
`,
() => html`
<div class="tree-item__label" part="label">
<slot></slot>
</div>
`
)}
</div>
<div class="tree-item__children" part="children" role="group">
<slot name="children" @slotchange="${this.handleChildrenSlotChange}"></slot>
</div>
</div>
`;
}
}
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;
}
}

Wyświetl plik

@ -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;
}
`;

Wyświetl plik

@ -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('<sl-tree>', () => {
let el: SlTree;
beforeEach(async () => {
el = await fixture(html`
<sl-tree>
<sl-tree-item>Node 1</sl-tree-item>
<sl-tree-item>Node 2</sl-tree-item>
<sl-tree-item>
Parent Node
<sl-tree-item>Child Node 1</sl-tree-item>
<sl-tree-item>Child Node 2</sl-tree-item>
</sl-tree-item>
<sl-tree-item>Node 3</sl-tree-item>
</sl-tree>
`);
});
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;
});
});
});

Wyświetl plik

@ -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<SlTreeItem> = 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`
<div part="base" class="tree" @click="${this.handleClick}" @keydown="${this.handleKeyDown}">
<slot></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-tree': SlTree;
}
}

Wyświetl plik

@ -1,3 +1,11 @@
export function uppercaseFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
export function stringMap(...parts: (string | Record<string, boolean>)[]): string | undefined {
return parts
.map(part => (typeof part === 'object' ? Object.keys(part).filter((key: string) => part[key]) : part))
.flat()
.filter(Boolean)
.join(' ');
}

Wyświetl plik

@ -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