From ed2417d97450bb997c9a12ed6b6b4d579f1599aa Mon Sep 17 00:00:00 2001 From: Alessandro Date: Mon, 1 Aug 2022 12:41:36 +0000 Subject: [PATCH] feat: add customizable icons * fix a bug focusing collapsed nodes --- docs/components/tree.md | 88 +++++++++++++++++--- src/components/tree-item/tree-item.styles.ts | 2 +- src/components/tree-item/tree-item.ts | 15 ++-- src/components/tree/tree.test.ts | 54 +++++++++++- src/components/tree/tree.ts | 56 +++++++++++-- 5 files changed, 186 insertions(+), 29 deletions(-) diff --git a/docs/components/tree.md b/docs/components/tree.md index 7024c3eb3..974ff0e84 100644 --- a/docs/components/tree.md +++ b/docs/components/tree.md @@ -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 + + + + + + Deciduous + Birch + + Maple + Field maple + Red maple + Sugar maple + + Oak + + + + Coniferous + Cedar + Pine + Spruce + + + + Non-trees + Bamboo + Cactus + Fern + + +``` + + +```jsx react +import { SlTree, SlTreeItem } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + + + + + Deciduous + Birch + + Maple + Field maple + Red maple + Sugar maple + + Oak + + + + Coniferous + Cedar + Pine + Spruce + + + + Non-trees + Bamboo + Cactus + Fern + + +); +``` + ### 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 ( diff --git a/src/components/tree-item/tree-item.styles.ts b/src/components/tree-item/tree-item.styles.ts index fe3d6963f..3bed2b3a2 100644 --- a/src/components/tree-item/tree-item.styles.ts +++ b/src/components/tree-item/tree-item.styles.ts @@ -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); } diff --git a/src/components/tree-item/tree-item.ts b/src/components/tree-item/tree-item.ts index 6fab18b90..c74cc6c53 100644 --- a/src/components/tree-item/tree-item.ts +++ b/src/components/tree-item/tree-item.ts @@ -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` diff --git a/src/components/tree/tree.test.ts b/src/components/tree/tree.test.ts index 0494e3272..0c377e688 100644 --- a/src/components/tree/tree.test.ts +++ b/src/components/tree/tree.test.ts @@ -15,7 +15,11 @@ describe('', () => { Parent Node Child Node 1 - Child Node 2 + + Child Node 2 + Child Node 2 - 1 + Child Node 2 - 2 + Node 3 @@ -32,6 +36,52 @@ describe('', () => { 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` + +
+
+ + Node 1 + Node 2 +
+ `); + }); + + 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('', () => { await sendKeys({ press: 'Enter' }); // Assert - expect(el.selectedItems.length).to.eq(4); + expect(el.selectedItems.length).to.eq(6); }); }); }); diff --git a/src/components/tree/tree.ts b/src/components/tree/tree.ts index d29d67107..c7e552bd8 100644 --- a/src/components/tree/tree.ts +++ b/src/components/tree/tree.ts @@ -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`
+ +
`; }