diff --git a/dev/vscode-tree/multi-select.html b/dev/vscode-tree/multi-select.html
index a9883dd7d..6e458d707 100644
--- a/dev/vscode-tree/multi-select.html
+++ b/dev/vscode-tree/multi-select.html
@@ -237,6 +237,8 @@
Basic example
li.focus();
});
});
+
+ logEvents('vscode-tree', 'vsc-tree-select');
diff --git a/src/includes/test-helpers.ts b/src/includes/test-helpers.ts
index 4de7beb26..f1072bb55 100644
--- a/src/includes/test-helpers.ts
+++ b/src/includes/test-helpers.ts
@@ -108,3 +108,82 @@ export async function dragElement(
await sendMouse({type: 'up'});
}
+
+type AllTagNames = keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap;
+
+type TagNameToElement =
+ K extends keyof HTMLElementTagNameMap
+ ? HTMLElementTagNameMap[K]
+ : K extends keyof SVGElementTagNameMap
+ ? SVGElementTagNameMap[K]
+ : Element;
+
+export function $(selector: K): TagNameToElement;
+export function $(
+ root: Element,
+ selector: K
+): TagNameToElement;
+export function $(selector: string): T;
+export function $(
+ root: Element,
+ selector: string
+): T;
+export function $(
+ arg1: string | Element,
+ arg2?: string
+): T {
+ let result: Element | null;
+
+ if (typeof arg1 === 'string') {
+ result = document.querySelector(arg1);
+ } else if (arg1 instanceof Element && typeof arg2 === 'string') {
+ result = arg1.querySelector(arg2);
+ } else {
+ throw new Error('Invalid arguments passed to $()');
+ }
+
+ if (!result) {
+ const selector = typeof arg1 === 'string' ? arg1 : arg2!;
+ const context = typeof arg1 === 'string' ? 'document' : 'root element';
+ throw new Error(`No match for selector: ${selector} in ${context}`);
+ }
+
+ return result as T;
+}
+
+export function $$(
+ selector: K
+): NodeListOf>;
+export function $$(
+ root: Element,
+ selector: K
+): NodeListOf>;
+export function $$(
+ selector: string
+): NodeListOf;
+export function $$(
+ root: Element,
+ selector: string
+): NodeListOf;
+export function $$(
+ arg1: string | Element,
+ arg2?: string
+): NodeListOf {
+ let result: NodeListOf;
+
+ if (typeof arg1 === 'string') {
+ result = document.querySelectorAll(arg1);
+ } else if (arg1 instanceof Element && typeof arg2 === 'string') {
+ result = arg1.querySelectorAll(arg2);
+ } else {
+ throw new Error('Invalid arguments passed to $$()');
+ }
+
+ if (result.length === 0) {
+ const selector = typeof arg1 === 'string' ? arg1 : arg2!;
+ const context = typeof arg1 === 'string' ? 'document' : 'root element';
+ throw new Error(`No matches for selector: ${selector} in ${context}`);
+ }
+
+ return result as NodeListOf;
+}
diff --git a/src/vscode-tree-item/vscode-tree-item.styles.ts b/src/vscode-tree-item/vscode-tree-item.styles.ts
index e6330a67b..d315185bc 100644
--- a/src/vscode-tree-item/vscode-tree-item.styles.ts
+++ b/src/vscode-tree-item/vscode-tree-item.styles.ts
@@ -18,6 +18,10 @@ const styles: CSSResultGroup = [
user-select: none;
}
+ ::slotted(vscode-icon) {
+ display: block;
+ }
+
.root {
display: block;
}
@@ -85,7 +89,9 @@ const styles: CSSResultGroup = [
.icon-container {
align-items: center;
display: flex;
- height: 22px;
+ margin-bottom: 3px;
+ margin-top: 3px;
+ overflow: hidden;
}
.icon-container slot {
@@ -93,7 +99,9 @@ const styles: CSSResultGroup = [
}
.icon-container.has-icon {
+ height: 16px;
margin-right: 6px;
+ width: 16px;
}
.children {
diff --git a/src/vscode-tree/vscode-tree.test.ts b/src/vscode-tree/vscode-tree.test.ts
index f635504a0..16fe0eba2 100644
--- a/src/vscode-tree/vscode-tree.test.ts
+++ b/src/vscode-tree/vscode-tree.test.ts
@@ -1,5 +1,9 @@
import {expect, fixture, html} from '@open-wc/testing';
import {sendKeys} from '@web/test-runner-commands';
+import sinon from 'sinon';
+import {$$, clickOnElement} from '../includes/test-helpers.js';
+import {$} from '../includes/test-helpers.js';
+
import '../vscode-tree-item/vscode-tree-item.js';
import {VscodeTreeItem} from '../vscode-tree-item/vscode-tree-item.js';
import {VscodeTree} from './index.js';
@@ -10,6 +14,36 @@ describe('vscode-tree', () => {
expect(el).to.instanceOf(VscodeTree);
});
+ it('is accessible', async () => {
+ const el = await fixture(html`
+
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+
+ Item 2
+
+ Item 2.1
+ Item 2.1.1
+
+
+
+ Item 3
+
+ Item 3.1
+ Item 3.1.1
+
+
+
+ `);
+
+ expect(el).to.be.accessible;
+ });
+
it('focuses first item by default', async () => {
const el = await fixture(html`
@@ -46,6 +80,298 @@ describe('vscode-tree', () => {
expect(secondItem.active).to.be.true;
});
+ describe('default values', () => {
+ it('expandMode', () => {
+ const el = document.createElement('vscode-tree');
+ expect(el.expandMode).to.eq('singleClick');
+ });
+
+ it('hideArrows', () => {
+ const el = document.createElement('vscode-tree');
+ expect(el.hideArrows).to.be.false;
+ });
+
+ it('indent', () => {
+ const el = document.createElement('vscode-tree');
+ expect(el.indent).to.eq(8);
+ });
+
+ it('indentGuides', () => {
+ const el = document.createElement('vscode-tree');
+ expect(el.indentGuides).to.eq('onHover');
+ });
+ });
+
+ describe('public methods', () => {
+ it('expands all', async () => {
+ const el = await fixture(html`
+
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+
+ Item 2
+
+ Item 2.1
+ Item 2.1.1
+
+
+
+ Item 3
+
+ Item 3.1
+ Item 3.1.1
+
+
+
+ `);
+
+ el.expandAll();
+ await el.updateComplete;
+
+ expect(el).lightDom.to.eq(
+ `
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+
+ Item 2
+
+ Item 2.1
+ Item 2.1.1
+
+
+
+ Item 3
+
+ Item 3.1
+ Item 3.1.1
+
+
+ `,
+ {
+ ignoreAttributes: [
+ 'aria-disabled',
+ 'data-path',
+ 'level',
+ 'role',
+ 'slot',
+ 'tabindex',
+ ],
+ }
+ );
+ });
+
+ it('closes all', async () => {
+ const el = await fixture(html`
+
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+
+ Item 2
+
+ Item 2.1
+ Item 2.1.1
+
+
+
+ Item 3
+
+ Item 3.1
+ Item 3.1.1
+
+
+
+ `);
+
+ el.collapseAll();
+ await el.updateComplete;
+
+ expect(el).lightDom.to.eq(
+ `
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+
+ Item 2
+
+ Item 2.1
+ Item 2.1.1
+
+
+
+ Item 3
+
+ Item 3.1
+ Item 3.1.1
+
+
+ `,
+ {
+ ignoreAttributes: [
+ 'aria-disabled',
+ 'data-path',
+ 'level',
+ 'role',
+ 'slot',
+ 'tabindex',
+ ],
+ }
+ );
+ });
+ });
+
+ describe('expand mode', () => {
+ it('singleClick', async () => {
+ const el = await fixture(html`
+
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+
+ `);
+ const branch = el.querySelector('vscode-tree-item')!;
+
+ branch.shadowRoot
+ ?.querySelector('.wrapper')
+ ?.dispatchEvent(new PointerEvent('click'));
+
+ expect(branch.open).to.be.true;
+ });
+
+ it('doubleClick', async () => {
+ const el = await fixture(html`
+
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+
+ `);
+ const branch = el.querySelector('vscode-tree-item')!;
+
+ branch.shadowRoot
+ ?.querySelector('.wrapper')
+ ?.dispatchEvent(new PointerEvent('dblclick'));
+
+ expect(branch.open).to.be.true;
+ });
+ });
+
+ describe('indent guides', () => {
+ it('indentGuides onHover', async () => {
+ const el = await fixture(html`
+
+
+ Item 1
+ Item 1.1
+ Item 1.2
+
+
+ `);
+ const branch = el.querySelector('vscode-tree-item')!;
+ const childrenWrapper =
+ branch.shadowRoot!.querySelector('.children')!;
+
+ expect(childrenWrapper.classList.contains('guide')).to.be.true;
+ expect(childrenWrapper.classList.contains('default-guide')).to.be.true;
+ });
+
+ it('indentGuides always', async () => {
+ const el = await fixture(html`
+
+
+ Item 1
+ Item 1.1
+ Item 1.2
+
+
+ `);
+ const branch = el.querySelector('vscode-tree-item')!;
+ const childrenWrapper =
+ branch.shadowRoot!.querySelector('.children')!;
+
+ expect(childrenWrapper.classList.contains('guide')).to.be.true;
+ expect(childrenWrapper.classList.contains('default-guide')).to.be.true;
+ });
+
+ it('indentGuides none', async () => {
+ const el = await fixture(html`
+
+
+ Item 1
+ Item 1.1
+ Item 1.2
+
+
+ `);
+ const branch = el.querySelector('vscode-tree-item')!;
+ const childrenWrapper =
+ branch.shadowRoot!.querySelector('.children')!;
+
+ expect(childrenWrapper.classList.contains('guide')).to.be.false;
+ expect(childrenWrapper.classList.contains('default-guide')).to.be.false;
+ });
+ });
+
+ describe('hide arrows', () => {
+ it('show arrows', async () => {
+ const el = await fixture(
+ html`
+
+ Item 1
+ Item 1.1
+ Item 1.2
+
+ `
+ );
+
+ const branch = el.querySelector('vscode-tree-item[open]')!;
+
+ expect(branch.shadowRoot?.querySelector('.arrow-container')).to.be.ok;
+ });
+
+ it('hide arrows', async () => {
+ const el = await fixture(
+ html`
+
+ Item 1
+ Item 1.1
+ Item 1.2
+
+ `
+ );
+
+ const branch = el.querySelector('vscode-tree-item[open]')!;
+
+ expect(branch.shadowRoot?.querySelector('.arrow-container')).to.be.null;
+ });
+ });
+
it('focuses next item when arrow down key is pressed', async () => {
const el = await fixture(html`
@@ -66,16 +392,356 @@ describe('vscode-tree', () => {
expect(items[1].active).to.be.true;
});
- it('selects item with Enter key press');
- it('selects item with click on it');
- it('opens and selects branch item with Enter key press');
- it('opens and selects branch item with click on it');
- it('selecting multiple items upwards with the mouse and the Shift key');
- it(
- 'expands selection of multiple items upwards with the mouse and the Shift key'
- );
- it('selecting multiple items downwards with the mouse and the Shift key');
- it(
- 'expands selection of multiple items downwards with the mouse and the Shift key'
- );
+ it('focuses previous item when arrow up key is pressed', async () => {
+ const el = await fixture(html`
+
+ Item 1
+ Item 2
+
+ `);
+
+ const items = $$('vscode-tree-item');
+
+ items[1].focus();
+ await sendKeys({press: 'ArrowUp'});
+ await el.updateComplete;
+
+ expect(items[0].tabIndex).to.eq(0);
+ expect(items[0].active).to.be.true;
+ expect(items[1].tabIndex).to.eq(-1);
+ expect(items[1].active).to.be.false;
+ });
+
+ it('selects item with Enter key press', async () => {
+ const el = await fixture(
+ html`
+ Item 1
+ Item 2
+ `
+ );
+ const spy = sinon.spy();
+
+ el.addEventListener('vsc-tree-select', spy);
+
+ const firstChild = $(el, 'vscode-tree-item')!;
+ firstChild.active = true;
+ firstChild.focus();
+ await sendKeys({down: 'Enter'});
+
+ expect(firstChild.selected).to.be.true;
+ expect(spy.getCalls()[0].args[0].type).to.eq('vsc-tree-select');
+ });
+
+ it('selects item with click on it', async () => {
+ const el = await fixture(
+ html`
+ Item 1
+ Item 2
+ `
+ );
+ const spy = sinon.spy();
+
+ el.addEventListener('vsc-tree-select', spy);
+
+ const firstChild = el.querySelector('vscode-tree-item')!;
+ await clickOnElement(firstChild);
+
+ expect(firstChild.selected).to.be.true;
+ expect(spy.getCalls()[0].args[0].type).to.eq('vsc-tree-select');
+ });
+
+ it('opens and selects branch item with Enter key press', async () => {
+ await fixture(
+ html`
+ Item 1
+
+ Item 2
+ Item 2.1
+
+ `
+ );
+
+ $('vscode-tree-item').focus();
+ await sendKeys({down: 'ArrowDown'});
+ await sendKeys({down: 'Enter'});
+
+ expect($('#item2').open).to.be.true;
+ expect($('#item2').selected).to.be.true;
+ });
+
+ it('opens and selects branch item with click on it', async () => {
+ await fixture(
+ html`
+ Item 1
+
+ Item 2
+ Item 2.1
+
+ `
+ );
+
+ await clickOnElement($('#item2'));
+
+ expect($('#item2').open).to.be.true;
+ expect($('#item2').selected).to.be.true;
+ });
+
+ it('selecting multiple items upwards with the mouse and the Shift key', async () => {
+ await fixture(
+ html`
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+ Item 5
+ `
+ );
+
+ await clickOnElement($('#item5'));
+
+ await sendKeys({down: 'Shift'});
+ await clickOnElement($('#item1'));
+ await sendKeys({up: 'Shift'});
+
+ for (let i = 1; i <= 5; i++) {
+ expect($(`#item${i}`).selected).to.be.true;
+ }
+ });
+
+ it('expands selection of multiple items upwards with the mouse and the Shift key', async () => {
+ await fixture(
+ html`
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+ Item 5
+ `
+ );
+
+ await clickOnElement($('#item5'));
+
+ await sendKeys({down: 'Shift'});
+ await clickOnElement($('#item3'));
+ await clickOnElement($('#item1'));
+ await sendKeys({up: 'Shift'});
+
+ for (let i = 1; i <= 5; i++) {
+ expect($(`#item${i}`).selected).to.be.true;
+ }
+ });
+
+ it('selecting multiple items downwards with the mouse and the Shift key', async () => {
+ await fixture(
+ html`
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+ Item 5
+ `
+ );
+
+ await clickOnElement($('#item1'));
+
+ await sendKeys({down: 'Shift'});
+ await clickOnElement($('#item5'));
+ await sendKeys({up: 'Shift'});
+
+ for (let i = 1; i <= 5; i++) {
+ expect($(`#item${i}`).selected).to.be.true;
+ }
+ });
+
+ it('expands selection of multiple items downwards with the mouse and the Shift key', async () => {
+ await fixture(
+ html`
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+ Item 5
+ `
+ );
+
+ await clickOnElement($('#item1'));
+
+ await sendKeys({down: 'Shift'});
+ await clickOnElement($('#item3'));
+ await clickOnElement($('#item5'));
+ await sendKeys({up: 'Shift'});
+
+ for (let i = 1; i <= 5; i++) {
+ expect($(`#item${i}`).selected).to.be.true;
+ }
+ });
+
+ it('toggle selection upwards with ArrowUp + Shift', async () => {
+ await fixture(
+ html`
+ Item 1
+ Item 2
+ `
+ );
+
+ const items = $$('vscode-tree-item');
+ items[1].active;
+ items[1].focus();
+ await sendKeys({down: 'Shift'});
+ await sendKeys({down: 'ArrowUp'});
+ await sendKeys({up: 'Shift'});
+
+ expect($('vscode-tree-item').selected).to.be.false;
+ });
+
+ it('toggle selection downwards with ArrowDown + Shift', async () => {
+ await fixture(
+ html`
+ Item 1
+ Item 2
+ `
+ );
+
+ const items = $$('vscode-tree-item');
+ items[0].active;
+ items[0].focus();
+ await sendKeys({down: 'Shift'});
+ await sendKeys({down: 'ArrowDown'});
+ await sendKeys({up: 'Shift'});
+
+ expect($$('vscode-tree-item')[1].selected).to.be.false;
+ });
+
+ it('selecting multiple items with the mouse and the Ctrl key', async () => {
+ await fixture(
+ html`
+ Item 1
+ Item 2
+ Item 3
+ Item 4
+ Item 5
+ `
+ );
+
+ await clickOnElement($('#item1'));
+ await sendKeys({down: 'Control'});
+ await clickOnElement($('#item3'));
+ await clickOnElement($('#item5'));
+ await sendKeys({up: 'Control'});
+
+ expect($('#item1').selected).to.be.true;
+ expect($('#item2').selected).to.be.false;
+ expect($('#item3').selected).to.be.true;
+ expect($('#item4').selected).to.be.false;
+ expect($('#item5').selected).to.be.true;
+ });
+
+ it('opens closed branch if the ArrowRight key is pressed', async () => {
+ await fixture(
+ html`
+
+ Item 1
+ Item 1.1
+
+ `
+ );
+
+ $('vscode-tree-item').focus();
+ await sendKeys({down: 'ArrowRight'});
+
+ expect($('vscode-tree-item').open).to.be.true;
+ });
+
+ it('select the first child when the ArrowRight key is pressed on an opened branch', async () => {
+ await fixture(
+ html`
+
+ Item 1
+ Item 1.1
+
+ `
+ );
+
+ $('vscode-tree-item').active = true;
+ $('vscode-tree-item').focus();
+ await sendKeys({down: 'ArrowRight'});
+
+ expect($('#item1_1').active).to.be.true;
+ });
+
+ it('closes the opened branch if the ArrowLeft key is pressed', async () => {
+ await fixture(
+ html`
+
+ Item 1
+ Item 1.1
+
+ `
+ );
+
+ $('vscode-tree-item').focus();
+ await sendKeys({down: 'ArrowLeft'});
+
+ expect($('vscode-tree-item').open).to.be.false;
+ });
+
+ it('pressing ArrowLeft on a leaf selects its parent branch', async () => {
+ await fixture(
+ html`
+
+ Item 1
+ Item 1.1
+
+ `
+ );
+
+ $('#item1_1').active = true;
+ $('#item1_1').focus();
+ await sendKeys({down: 'ArrowLeft'});
+
+ expect($('vscode-tree-item').active).to.be.true;
+ });
+
+ it('pressing ArrowLeft on a collapsed branch selects its parent', async () => {
+ await fixture(
+ html`
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+ `
+ );
+
+ $('#item1_1').active = true;
+ $('#item1_1').focus();
+ await sendKeys({down: 'ArrowLeft'});
+
+ expect($('vscode-tree-item').active).to.be.true;
+ });
+
+ it('Ctrl + ArrowLeft collapses all branches', async () => {
+ await fixture(
+ html`
+
+ Item 1
+
+ Item 1.1
+ Item 1.1.1
+
+
+ `
+ );
+
+ $('vscode-tree-item').active = true;
+ $('vscode-tree-item').focus();
+ await sendKeys({down: 'Control'});
+ await sendKeys({down: 'ArrowLeft'});
+
+ $$('vscode-tree-item').forEach((i) => {
+ expect(i.open).to.be.false;
+ });
+ });
});
diff --git a/src/vscode-tree/vscode-tree.ts b/src/vscode-tree/vscode-tree.ts
index 733650640..717b90ef0 100644
--- a/src/vscode-tree/vscode-tree.ts
+++ b/src/vscode-tree/vscode-tree.ts
@@ -59,11 +59,6 @@ const listenedKeys: ListenedKey[] = [
'Escape',
'Shift',
];
-const DEFAULT_HIDE_ARROWS = false;
-const DEFAULT_INDENT = 8;
-const DEFAULT_MULTI_SELECT = false;
-const DEFAULT_EXPAND_MODE = ExpandMode.singleClick;
-const DEFAULT_INDENT_GUIDE_DISPLAY = IndentGuides.onHover;
/**
* @tag vscode-tree
@@ -74,17 +69,6 @@ export class VscodeTree extends VscElement {
//#region properties
- /**
- * Although arrows are always visible in the Tree component by default in VSCode, some icon sets
- * (e.g., Material Icon Theme) allow disabling them in the file explorer view. This flag makes it
- * possible to mimic that behavior.
- *
- * @type {boolean}
- * @default false
- */
- @property({type: Boolean, reflect: true, attribute: 'hide-arrows'})
- hideArrows: boolean = DEFAULT_HIDE_ARROWS;
-
/**
* Controls how tree folders are expanded when clicked. This property is designed to use
* the `workbench.tree.expandMode` setting.
@@ -98,20 +82,24 @@ export class VscodeTree extends VscElement {
* ```
*
* @type {'singleClick' | 'doubleClick'}
- * @default 'singleClick'
*/
@property({type: String, attribute: 'expand-mode'})
- expandMode: ExpandMode = DEFAULT_EXPAND_MODE;
+ expandMode: ExpandMode = 'singleClick';
+
+ /**
+ * Although arrows are always visible in the Tree component by default in VSCode, some icon sets
+ * (e.g., Material Icon Theme) allow disabling them in the file explorer view. This flag makes it
+ * possible to mimic that behavior.
+ */
+ @property({type: Boolean, reflect: true, attribute: 'hide-arrows'})
+ hideArrows: boolean = false;
/**
* Controls the indentation in pixels. This property is designed to use the
* `workbench.tree.indent` setting.
- *
- * @type {number}
- * @default 8
*/
@property({type: Number, reflect: true})
- indent: number = DEFAULT_INDENT;
+ indent: number = 8;
/**
* Controls whether the tree should render indent guides. This property is
@@ -126,7 +114,6 @@ export class VscodeTree extends VscElement {
* ```
*
* @type {'none' | 'onHover' | 'always'}
- * @default 'onHover'
*/
@property({
type: String,
@@ -134,16 +121,13 @@ export class VscodeTree extends VscElement {
useDefault: true,
reflect: true,
})
- indentGuides: IndentGuideDisplay = DEFAULT_INDENT_GUIDE_DISPLAY;
+ indentGuides: IndentGuideDisplay = 'onHover';
/**
* Allows selecting multiple items.
- *
- * @type {boolean}
- * @default false
*/
@property({type: Boolean, reflect: true, attribute: 'multi-select'})
- multiSelect: boolean = DEFAULT_MULTI_SELECT;
+ multiSelect: boolean = false;
//#endregion
@@ -171,11 +155,11 @@ export class VscodeTree extends VscElement {
@provide({context: configContext})
private _configContext: ConfigContext = {
- hideArrows: DEFAULT_HIDE_ARROWS,
- expandMode: DEFAULT_EXPAND_MODE,
- indent: DEFAULT_INDENT,
- indentGuides: DEFAULT_INDENT_GUIDE_DISPLAY,
- multiSelect: DEFAULT_MULTI_SELECT,
+ hideArrows: this.hideArrows,
+ expandMode: this.expandMode,
+ indent: this.indent,
+ indentGuides: this.indentGuides,
+ multiSelect: this.multiSelect,
};
@queryAssignedElements({selector: 'vscode-tree-item'})