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`
${when(this.loading, () => html` `)}
${when(
- !this.loading && (!this.isLeaf || this.lazy),
+ showExpandButton,
() => 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`
+
+
`;
}