+
${this.branch && !this.open
? html` `
: nothing}
-
;
highlightIndentGuides?: () => void;
emitSelectEvent?: () => void;
+ hoveredItem?: VscodeTreeItem | null;
}
export const treeContext = createContext('vscode-list');
diff --git a/src/vscode-tree/vscode-tree.test.ts b/src/vscode-tree/vscode-tree.test.ts
index 9d71ce07d..57864a1db 100644
--- a/src/vscode-tree/vscode-tree.test.ts
+++ b/src/vscode-tree/vscode-tree.test.ts
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
-import {expect, fixture, html} from '@open-wc/testing';
+import {expect, fixture, html, aTimeout} from '@open-wc/testing';
import {sendKeys} from '@web/test-runner-commands';
import sinon from 'sinon';
import {$$, clickOnElement} from '../includes/test-helpers.js';
@@ -9,6 +9,18 @@ import '../vscode-tree-item/vscode-tree-item.js';
import {VscodeTreeItem} from '../vscode-tree-item/vscode-tree-item.js';
import {VscodeTree} from './index.js';
+const matchesStateSelector = (element: Element, selector: string) => {
+ try {
+ return element.matches(selector);
+ } catch {
+ return false;
+ }
+};
+
+const hasCustomState = (element: Element, state: string) => {
+ return matchesStateSelector(element, `:state(${state})`);
+};
+
describe('vscode-tree', () => {
it('is defined', () => {
const el = document.createElement('vscode-tree');
@@ -81,6 +93,317 @@ describe('vscode-tree', () => {
expect(secondItem.active).to.be.true;
});
+ describe('actions visibility', () => {
+ const getWrapper = (item: VscodeTreeItem) =>
+ item.shadowRoot!.querySelector('.wrapper')!;
+
+ const hasShowActionsState = (item: VscodeTreeItem) =>
+ hasCustomState(item, 'show-actions');
+
+ const renderActionsTree = async (options?: {multiSelect?: boolean}) => {
+ return fixture(html`
+
+
+ Workspace
+ foo
+
+ src
+ bar
+
+ components
+ baz
+
+
+
+
+ `);
+ };
+
+ it('shows actions for hovered item and clears previous hover', async () => {
+ const tree = await renderActionsTree();
+ const workspace = tree.querySelector('#workspace')!;
+ const src = tree.querySelector('#src')!;
+ const components = tree.querySelector('#components')!;
+
+ workspace.dispatchEvent(new PointerEvent('pointerenter'));
+ await aTimeout(0);
+ expect(hasShowActionsState(workspace)).to.be.true;
+
+ src.dispatchEvent(new PointerEvent('pointerenter'));
+ await aTimeout(0);
+ expect(hasShowActionsState(src)).to.be.true;
+ expect(hasShowActionsState(workspace)).to.be.false;
+
+ components.dispatchEvent(new PointerEvent('pointerenter'));
+ await aTimeout(0);
+ expect(hasShowActionsState(components)).to.be.true;
+ expect(hasShowActionsState(src)).to.be.false;
+ });
+
+ it('reclaims hover when moving from child to parent sibling', async () => {
+ const tree = await renderActionsTree();
+ const src = tree.querySelector('#src')!;
+ const components = tree.querySelector('#components')!;
+
+ components.dispatchEvent(new PointerEvent('pointerenter'));
+ await aTimeout(0);
+ expect(hasShowActionsState(components)).to.be.true;
+
+ components.dispatchEvent(
+ new PointerEvent('pointerleave', {
+ bubbles: false,
+ composed: false,
+ relatedTarget: getWrapper(src),
+ })
+ );
+ await aTimeout(0);
+
+ expect(hasShowActionsState(components)).to.be.false;
+ expect(hasShowActionsState(src)).to.be.true;
+ });
+
+ it('keeps actions visible for selected item while hovering siblings', async () => {
+ const tree = await renderActionsTree();
+ const workspace = tree.querySelector('#workspace')!;
+ const src = tree.querySelector('#src')!;
+ const components = tree.querySelector('#components')!;
+
+ getWrapper(workspace).click();
+ await aTimeout(0);
+ expect(workspace.selected).to.be.true;
+ expect(hasShowActionsState(workspace)).to.be.true;
+
+ src.dispatchEvent(new PointerEvent('pointerenter'));
+ await aTimeout(0);
+ expect(hasShowActionsState(src)).to.be.true;
+ expect(hasShowActionsState(workspace)).to.be.true;
+
+ components.dispatchEvent(new PointerEvent('pointerenter'));
+ await aTimeout(0);
+ expect(hasShowActionsState(components)).to.be.true;
+ expect(hasShowActionsState(workspace)).to.be.true;
+ expect(hasShowActionsState(src)).to.be.false;
+ });
+
+ it('clears previous selection actions when a new item is selected', async () => {
+ const tree = await renderActionsTree();
+ const workspace = tree.querySelector('#workspace')!;
+ const src = tree.querySelector('#src')!;
+
+ getWrapper(workspace).click();
+ await aTimeout(0);
+ expect(workspace.selected).to.be.true;
+ expect(hasShowActionsState(workspace)).to.be.true;
+
+ src.dispatchEvent(new PointerEvent('pointerenter'));
+ await aTimeout(0);
+ expect(hasShowActionsState(src)).to.be.true;
+
+ getWrapper(src).click();
+ await aTimeout(0);
+ expect(src.selected).to.be.true;
+ expect(hasShowActionsState(src)).to.be.true;
+ expect(hasShowActionsState(workspace)).to.be.false;
+ });
+
+ it('shows actions for all selected items in multi-select mode', async () => {
+ const tree = await renderActionsTree({multiSelect: true});
+ const workspace = tree.querySelector('#workspace')!;
+ const src = tree.querySelector('#src')!;
+ const workspaceWrapper = getWrapper(workspace);
+ const srcWrapper = getWrapper(src);
+
+ workspaceWrapper.dispatchEvent(
+ new MouseEvent('click', {bubbles: true, composed: true})
+ );
+ await aTimeout(0);
+ expect(workspace.selected).to.be.true;
+ srcWrapper.dispatchEvent(
+ new MouseEvent('click', {bubbles: true, composed: true, ctrlKey: true})
+ );
+ await aTimeout(0);
+ expect(src.selected).to.be.true;
+
+ expect(hasShowActionsState(workspace)).to.be.true;
+ expect(hasShowActionsState(src)).to.be.true;
+
+ workspaceWrapper.dispatchEvent(
+ new MouseEvent('click', {bubbles: true, composed: true, ctrlKey: true})
+ );
+ await aTimeout(0);
+ expect(workspace.selected).to.be.false;
+
+ expect(hasShowActionsState(workspace)).to.be.false;
+ expect(hasShowActionsState(src)).to.be.true;
+ });
+
+ it('shows actions when pointer is already hovering on first render', async () => {
+ const tree = document.createElement('vscode-tree');
+ const item = document.createElement('vscode-tree-item');
+ const action = document.createElement('span');
+ action.slot = 'actions';
+ action.textContent = 'action';
+
+ const originalMatches = item.matches.bind(item);
+ Object.defineProperty(item, 'matches', {
+ value: (selector: string) =>
+ selector === ':hover' ? true : originalMatches(selector),
+ });
+
+ item.append(action);
+ tree.append(item);
+ document.body.append(tree);
+
+ await (tree as VscodeTree).updateComplete;
+ await aTimeout(0);
+
+ expect(hasShowActionsState(item)).to.be.true;
+
+ tree.remove();
+ });
+
+ it('adds a margin between actions and decoration when actions are visible', async () => {
+ const tree = await fixture(html`
+
+
+ Item
+ D
+ A
+
+
+ `);
+
+ const item = tree.querySelector('#item')!;
+ const decoration = item.shadowRoot!.querySelector(
+ '.decoration'
+ ) as HTMLElement;
+
+ item.dispatchEvent(new PointerEvent('pointerenter'));
+ await aTimeout(0);
+
+ const margin = parseFloat(getComputedStyle(decoration).marginLeft);
+ expect(margin).to.be.greaterThan(0);
+ });
+
+ it('moves actions state along with keyboard focus', async () => {
+ const tree = await renderActionsTree();
+ await tree.updateComplete;
+ await aTimeout(0);
+
+ const workspace = tree.querySelector('#workspace')!;
+ const src = tree.querySelector('#src')!;
+
+ const before = document.createElement('button');
+ before.textContent = 'Before tree';
+ tree.parentNode?.insertBefore(before, tree);
+
+ before.focus();
+ await sendKeys({press: 'Tab'});
+ await aTimeout(0);
+
+ if (document.activeElement !== workspace) {
+ workspace.focus();
+ await aTimeout(0);
+ }
+
+ await aTimeout(0);
+
+ expect(document.activeElement).to.equal(workspace);
+ expect(hasCustomState(workspace, 'keyboard-focus')).to.be.true;
+ expect(hasShowActionsState(workspace)).to.be.true;
+
+ await sendKeys({press: 'ArrowDown'});
+ await aTimeout(0);
+
+ expect(document.activeElement).to.equal(src);
+ expect(hasShowActionsState(src)).to.be.true;
+ expect(hasShowActionsState(workspace)).to.be.false;
+
+ before.remove();
+ });
+ });
+
+ describe('decoration visibility', () => {
+ it('shows decoration content when slot has nodes', async () => {
+ const tree = await fixture(html`
+
+
+ Decorated
+ D
+
+ Plain
+
+ `);
+
+ const decorated = tree.querySelector('#decorated')!;
+ await decorated.updateComplete;
+ await aTimeout(0);
+
+ const decorationPart = decorated.shadowRoot!.querySelector(
+ '.decoration'
+ ) as HTMLElement;
+
+ expect(['inline-flex', 'flex']).to.include(
+ getComputedStyle(decorationPart).display
+ );
+ expect(hasCustomState(decorated, 'has-decoration')).to.be.true;
+
+ const plain = tree.querySelector('#plain')!;
+ expect(hasCustomState(plain, 'has-decoration')).to.be.false;
+ });
+
+ it('removes decoration state when slot becomes empty', async () => {
+ const tree = await fixture(html`
+
+
+ Decorated
+ D
+
+
+ `);
+
+ const decorated = tree.querySelector('#decorated')!;
+ const slotted = decorated.querySelector('#dec-span');
+ slotted?.remove();
+
+ await decorated.updateComplete;
+ await aTimeout(0);
+
+ const decorationPart = decorated.shadowRoot!.querySelector(
+ '.decoration'
+ ) as HTMLElement;
+
+ expect(getComputedStyle(decorationPart).display).to.equal('none');
+ expect(hasCustomState(decorated, 'has-decoration')).to.be.false;
+ });
+ });
+
+ describe('description slot', () => {
+ it('keeps description visible when selected', async () => {
+ const tree = await fixture(html`
+
+
+ Item
+ Details
+
+
+ `);
+
+ const item = tree.querySelector('#with-desc')!;
+ const wrapper = item.shadowRoot!.querySelector('.wrapper')!;
+
+ wrapper.click();
+ await aTimeout(0);
+
+ const description =
+ item.shadowRoot!.querySelector('.description')!;
+
+ expect(description.hidden).to.be.false;
+ const display = getComputedStyle(description).display;
+ expect(display).to.not.equal('none');
+ });
+ });
+
describe('default values', () => {
it('expandMode', () => {
const el = document.createElement('vscode-tree');
diff --git a/src/vscode-tree/vscode-tree.ts b/src/vscode-tree/vscode-tree.ts
index 886dc6061..d586f03c6 100644
--- a/src/vscode-tree/vscode-tree.ts
+++ b/src/vscode-tree/vscode-tree.ts
@@ -146,6 +146,7 @@ export class VscodeTree extends VscElement {
isShiftPressed: false,
activeItem: null,
selectedItems: new Set(),
+ hoveredItem: null,
allItems: null,
itemListUpToDate: false,
focusedItem: null,