kopia lustrzana https://github.com/shoelace-style/shoelace
Merge branch 'feat/tree-view' of github.com:alenaksu/shoelace into alenaksu-feat/tree-view
commit
33587f51d3
docs
components
src
components
internal
|
@ -64,6 +64,8 @@
|
|||
- [Tag](/components/tag)
|
||||
- [Textarea](/components/textarea)
|
||||
- [Tooltip](/components/tooltip)
|
||||
- [Tree](/components/tree)
|
||||
- [Tree Item](/components/tree-item)
|
||||
<!--plop:component-->
|
||||
|
||||
- Utilities
|
||||
|
|
|
@ -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]
|
|
@ -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]
|
|
@ -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)));
|
||||
}
|
||||
`;
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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(' ');
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Ładowanie…
Reference in New Issue