mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 04:09:12 +00:00
feat: add customizable icons
* fix a bug focusing collapsed nodes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user