feat: add customizable icons

* fix a bug focusing collapsed nodes
This commit is contained in:
Alessandro
2022-08-01 12:41:36 +00:00
committed by GitHub
parent 268aef1711
commit ed2417d974
5 changed files with 186 additions and 29 deletions

View File

@@ -289,6 +289,81 @@ const App = () => {
};
```
### Custom expanded/collapsed icons
Use the `expanded-icon` or `collapsed-icon` slots to change the expanded and collapsed tree element icons respectively.
```html preview
<sl-tree selection="leaf">
<sl-icon name="plus-square" slot="collapsed-icon"></sl-icon>
<sl-icon name="dash-square" slot="expanded-icon"></sl-icon>
<sl-tree-item>
Deciduous
<sl-tree-item>Birch</sl-tree-item>
<sl-tree-item>
Maple
<sl-tree-item>Field maple</sl-tree-item>
<sl-tree-item>Red maple</sl-tree-item>
<sl-tree-item>Sugar maple</sl-tree-item>
</sl-tree-item>
<sl-tree-item>Oak</sl-tree-item>
</sl-tree-item>
<sl-tree-item>
Coniferous
<sl-tree-item>Cedar</sl-tree-item>
<sl-tree-item>Pine</sl-tree-item>
<sl-tree-item>Spruce</sl-tree-item>
</sl-tree-item>
<sl-tree-item>
Non-trees
<sl-tree-item>Bamboo</sl-tree-item>
<sl-tree-item>Cactus</sl-tree-item>
<sl-tree-item>Fern</sl-tree-item>
</sl-tree-item>
</sl-tree>
```
<!-- prettier-ignore -->
```jsx react
import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlTree>
<SlIcon name="plus-square" slot="collapsed-icon"></SlIcon>
<SlIcon name="dash-square" slot="expanded-icon"></SlIcon>
<SlTreeItem>
Deciduous
<SlTreeItem>Birch</SlTreeItem>
<SlTreeItem>
Maple
<SlTreeItem>Field maple</SlTreeItem>
<SlTreeItem>Red maple</SlTreeItem>
<SlTreeItem>Sugar maple</SlTreeItem>
</SlTreeItem>
<SlTreeItem>Oak</SlTreeItem>
</SlTreeItem>
<SlTreeItem>
Coniferous
<SlTreeItem>Cedar</SlTreeItem>
<SlTreeItem>Pine</SlTreeItem>
<SlTreeItem>Spruce</SlTreeItem>
</SlTreeItem>
<SlTreeItem>
Non-trees
<SlTreeItem>Bamboo</SlTreeItem>
<SlTreeItem>Cactus</SlTreeItem>
<SlTreeItem>Fern</SlTreeItem>
</SlTreeItem>
</SlTree>
);
```
### With Icons
Decorative icons can be used before labels to provide hints for each node.
@@ -340,19 +415,6 @@ Decorative icons can be used before labels to provide hints for each node.
import { SlIcon, SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react';
const App = () => {
const [childItems, setChildItems] = useState([]);
const [lazy, setLazy] = useState(true);
const handleLazyLoad = () => {
// Simulate asynchronous loading
setTimeout(() => {
setChildItems(['Overview', 'Installation', 'Usage']);
// Disable lazy mode once the content has been loaded
setLazy(false);
}, 1000);
};
return (
<SlTree class="tree-with-icons">
<SlTreeItem expanded>

View File

@@ -14,7 +14,7 @@ export default css`
outline: 0;
}
::slotted(sl-icon) {
slot:not([name])::slotted(sl-icon) {
margin-inline-end: var(--sl-spacing-x-small);
}

View File

@@ -204,6 +204,7 @@ export default class SlTreeItem extends LitElement {
render() {
const isRtl = this.localize.dir() === 'rtl';
const showExpandButton = !this.loading && (!this.isLeaf || this.lazy);
return html`
<div
@@ -231,19 +232,21 @@ export default class SlTreeItem extends LitElement {
<div
class=${classMap({
'tree-item__expand-button': true,
'tree-item__expand-button--visible': !this.loading && (!this.isLeaf || this.lazy)
'tree-item__expand-button--visible': showExpandButton
})}
aria-hidden="true"
@click="${this.handleToggleExpand}"
>
${when(this.loading, () => html` <sl-spinner></sl-spinner> `)}
${when(
!this.loading && (!this.isLeaf || this.lazy),
showExpandButton,
() => html`
<sl-icon
library="system"
name="${this.expanded ? 'chevron-down' : isRtl ? 'chevron-left' : 'chevron-right'}"
></sl-icon>
<slot name="${this.expanded ? 'expanded-icon' : 'collapsed-icon'}">
<sl-icon
library="system"
name="${this.expanded ? 'chevron-down' : isRtl ? 'chevron-left' : 'chevron-right'}"
></sl-icon>
</slot>
`
)}
</div>

View File

@@ -15,7 +15,11 @@ describe('<sl-tree>', () => {
<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>
Child Node 2
<sl-tree-item>Child Node 2 - 1</sl-tree-item>
<sl-tree-item>Child Node 2 - 2</sl-tree-item>
</sl-tree-item>
</sl-tree-item>
<sl-tree-item>Node 3</sl-tree-item>
</sl-tree>
@@ -32,6 +36,52 @@ describe('<sl-tree>', () => {
await expect(el).to.be.accessible();
});
it('should not focus collapsed nodes', async () => {
// Arrange
const parentNode = el.children[2] as SlTreeItem;
const childNode = parentNode.children[1] as SlTreeItem;
childNode.expanded = true;
parentNode.expanded = false;
await el.updateComplete;
// Act
const focusableItems = el.getFocusableItems();
// Assert
expect(focusableItems).to.have.lengthOf(4);
expect(focusableItems).not.to.include.all.members([childNode, ...childNode.children]);
expect(focusableItems).not.to.include.all.members([...parentNode.children]);
});
describe('when a custom expanded/collapsed icon is provided', () => {
beforeEach(async () => {
el = await fixture(html`
<sl-tree>
<div slot="expanded-icon"></div>
<div slot="collapsed-icon"></div>
<sl-tree-item>Node 1</sl-tree-item>
<sl-tree-item>Node 2</sl-tree-item>
</sl-tree>
`);
});
it('should append a clone of the icon in the proper slot of the tree item', async () => {
// Arrange
await el.updateComplete;
// Act
const treeItems = [...el.querySelectorAll('sl-tree-item')];
// Assert
treeItems.forEach(treeItem => {
expect(treeItem.querySelector('div[slot="expanded-icon"]')).to.be.ok;
expect(treeItem.querySelector('div[slot="collapsed-icon"]')).to.be.ok;
});
});
});
describe('Keyboard navigation', () => {
describe('when ArrowDown is pressed', () => {
it('should move the focus to the next tree item', async () => {
@@ -275,7 +325,7 @@ describe('<sl-tree>', () => {
await sendKeys({ press: 'Enter' });
// Assert
expect(el.selectedItems.length).to.eq(4);
expect(el.selectedItems.length).to.eq(6);
});
});
});

View File

@@ -14,7 +14,7 @@ function syncCheckboxes(changedTreeItem: SlTreeItem) {
if (isTreeItem(parentItem)) {
const children = parentItem.getChildrenItems({ includeDisabled: false });
const allChecked = children.every(item => item.selected);
const allChecked = !!children.length && children.every(item => item.selected);
const allUnchecked = children.every(item => !item.selected && !item.indeterminate);
parentItem.selected = allChecked;
@@ -56,6 +56,8 @@ export default class SlTree extends LitElement {
static styles = styles;
@query('slot') defaultSlot: HTMLSlotElement;
@query('slot[name=expanded-icon]') expandedIconSlot: HTMLSlotElement;
@query('slot[name=collapsed-icon]') collapsedIconSlot: HTMLSlotElement;
/** Specifies the selection behavior of the Tree */
@property() selection: 'single' | 'multiple' | 'leaf' = 'single';
@@ -89,7 +91,48 @@ export default class SlTree extends LitElement {
this.removeEventListener('focusout', this.handleFocusOut);
}
// Generates a clone of the expand icon element to use for each tree item
private getExpandButtonIcon(status: 'expanded' | 'collapsed') {
const slot = status === 'expanded' ? this.expandedIconSlot : this.collapsedIconSlot;
const icon = slot.assignedElements({ flatten: true })[0] as HTMLElement;
// Clone it, remove ids, and slot it
if (icon) {
const clone = icon.cloneNode(true) as HTMLElement;
[clone, ...clone.querySelectorAll('[id]')].forEach(el => el.removeAttribute('id'));
clone.setAttribute('data-default', '');
clone.slot = `${status}-icon`;
return clone;
}
return null;
}
// Initializes new items by setting the `selectable` property and the expanded/collapsed icons if any
private initTreeItem = (item: SlTreeItem) => {
item.selectable = this.selection === 'multiple';
['expanded', 'collapsed']
.filter(status => !!this.querySelector(`[slot="${status}-icon"]`))
.forEach((status: 'expanded' | 'collapsed') => {
const existingIcon = item.querySelector(`[slot="${status}-icon"]`);
if (existingIcon !== null && !existingIcon.hasAttribute('data-default')) {
// The user provided a custom icon, leave it alone
} else if (existingIcon === null) {
// No separator exists, add one
item.append(this.getExpandButtonIcon(status)!);
} else if (existingIcon.hasAttribute('data-default')) {
// A default separator exists, replace it
existingIcon.replaceWith(this.getExpandButtonIcon(status)!);
}
});
};
protected firstUpdated(): void {
[...this.treeItems].forEach(this.initTreeItem);
this.mutationObserver.observe(this, { childList: true, subtree: true });
}
@@ -98,14 +141,11 @@ export default class SlTree extends LitElement {
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);
}
addedNodes.forEach(this.initTreeItem);
// If the focused item has been removed form the DOM, move the focus on the first node
// If the focused item has been removed form the DOM, move the focus to the first focusable item
if (removedNodes.includes(this.lastFocusedItem)) {
this.focusItem(this.treeItems[0]);
this.focusItem(this.getFocusableItems()[0]);
}
}
};
@@ -281,6 +321,8 @@ export default class SlTree extends LitElement {
return html`
<div part="base" class="tree" @click="${this.handleClick}" @keydown="${this.handleKeyDown}">
<slot></slot>
<slot name="expanded-icon" hidden aria-hidden="true"> </slot>
<slot name="collapsed-icon" hidden aria-hidden="true"> </slot>
</div>
`;
}