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( + ` + + + + `, + { + 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'})