From 6582cc6991e0a8d3c1d8f9964c4fbbeb128216ea Mon Sep 17 00:00:00 2001 From: dankeboy36 Date: Tue, 14 Oct 2025 16:05:33 +0200 Subject: [PATCH] fix: expose tree item decoration lane Closes: vscode-elements/elements#548 Signed-off-by: dankeboy36 --- dev/vscode-tree/decoration-lane.html | 363 ++++++++++++++++++ .../vscode-tree-item.styles.ts | 132 ++++++- src/vscode-tree-item/vscode-tree-item.ts | 330 +++++++++++++++- src/vscode-tree/tree-context.ts | 1 + src/vscode-tree/vscode-tree.test.ts | 195 +++++++++- src/vscode-tree/vscode-tree.ts | 1 + 6 files changed, 1005 insertions(+), 17 deletions(-) create mode 100644 dev/vscode-tree/decoration-lane.html diff --git a/dev/vscode-tree/decoration-lane.html b/dev/vscode-tree/decoration-lane.html new file mode 100644 index 000000000..01d02348f --- /dev/null +++ b/dev/vscode-tree/decoration-lane.html @@ -0,0 +1,363 @@ + + + + + + VSCode Elements + + + + + + + +

Decoration lane

+
+
+
+ + + 500px +
+
+ + + + + +
+ + + +
+ + + + useClient.js + + src/hooks + +
+ + +
+
+
+ + + +
+ + + + +
+ + + + strings.js + + src/utils + +
+ + + +
+
+ + + + longLongLongLongModule.js + + src/nested/deep/inside/somewhere + +
+ + + +
+
+
+
+
+
+ + + diff --git a/src/vscode-tree-item/vscode-tree-item.styles.ts b/src/vscode-tree-item/vscode-tree-item.styles.ts index b05d93cd9..0f19d6cb4 100644 --- a/src/vscode-tree-item/vscode-tree-item.styles.ts +++ b/src/vscode-tree-item/vscode-tree-item.styles.ts @@ -30,9 +30,12 @@ const styles: CSSResultGroup = [ align-items: flex-start; color: var(--vscode-foreground, #cccccc); display: flex; + flex-wrap: nowrap; font-family: var(--vscode-font-family, sans-serif); font-size: var(--vscode-font-size, 13px); font-weight: var(--vscode-font-weight, normal); + height: 22px; + line-height: 22px; outline-offset: -1px; padding-right: 12px; } @@ -105,7 +108,7 @@ const styles: CSSResultGroup = [ .icon-container.has-icon { height: 16px; margin-right: 6px; - width: 16px; + min-width: 16px; } .children { @@ -137,12 +140,139 @@ const styles: CSSResultGroup = [ } .content { + display: flex; + align-items: center; + flex-wrap: nowrap; /* prevent wrapping; allow ellipses via min-width: 0 */ + min-width: 0; + width: 100%; line-height: 22px; + } + + .label { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .description { + color: var(--vscode-descriptionForeground, #cccccc); + opacity: 0.7; + display: none; + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .content.has-description .description { + display: flex; + align-items: center; + justify-content: flex-start; + flex: 1 1 0%; /* description takes remaining space, yields first when shrinking */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: calc(var(--vscode-sash-size, 4px) * 1); + } + + .content.has-description .label { + flex: 0 1 auto; /* label only grows when description missing */ + margin-right: calc(var(--vscode-sash-size, 4px) * 1.5); + } + + .content:not(.has-description) .label { + flex: 1 1 auto; + } + + .label ::slotted(*) { + display: inline-block; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .description ::slotted(*) { + display: inline-block; + max-width: 100%; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .actions { + align-items: center; + align-self: center; + display: none; + flex: 0 0 auto; + gap: 2px; + margin-left: auto; + padding-left: calc(var(--vscode-sash-size, 4px) * 1.5); + min-height: 22px; + color: inherit; + } + + .actions ::slotted(*) { + align-items: center; + display: inline-flex; + height: 22px; + } + + .actions ::slotted(button) { + cursor: pointer; + } + + .actions ::slotted([hidden]) { + display: none !important; + } + + :host([has-actions][show-actions]) .actions { + display: inline-flex; + } + + .decoration { + align-items: center; + align-self: center; + color: inherit; + display: none; + flex: 0 0 auto; + gap: 4px; + margin-left: auto; + min-height: 22px; + } + + :host([has-decoration]) .decoration { + display: inline-flex; + } + + :host([show-actions]) .decoration { + margin-left: calc(var(--vscode-sash-size, 4px) * 1.5); + } + + :host([selected]) ::slotted([slot='decoration']), + :host([selected]) ::slotted([slot='decoration']) * { + color: inherit !important; + } + + :host([selected]) .description { + color: var(--internal-selectionForeground, #ffffff); + opacity: 0.8; + } + + :host([selected][focus-visible]) .description, + :host([selected]:focus-within) .description { + opacity: 0.95; + } + :host([branch]) ::slotted(vscode-tree-item) { display: none; } diff --git a/src/vscode-tree-item/vscode-tree-item.ts b/src/vscode-tree-item/vscode-tree-item.ts index e13892f76..30e2d4218 100644 --- a/src/vscode-tree-item/vscode-tree-item.ts +++ b/src/vscode-tree-item/vscode-tree-item.ts @@ -1,6 +1,6 @@ import {PropertyValues, TemplateResult, html, nothing} from 'lit'; import {consume} from '@lit/context'; -import {property, queryAssignedElements, state} from 'lit/decorators.js'; +import {property, query, queryAssignedElements, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {customElement, VscElement} from '../includes/VscElement.js'; import {stylePropertyMap} from '../includes/style-property-map.js'; @@ -73,8 +73,13 @@ export class VscodeTreeItem extends VscElement { @property({type: Boolean, reflect: true}) set selected(selected: boolean) { this._selected = selected; - this._treeContextState.selectedItems.add(this); + if (selected) { + this._treeContextState.selectedItems.add(this); + } else { + this._treeContextState.selectedItems.delete(this); + } this.ariaSelected = selected ? 'true' : 'false'; + this._updateActionsVisibility(); } get selected(): boolean { return this._selected; @@ -104,10 +109,20 @@ export class VscodeTreeItem extends VscElement { @state() private _hasLeafIcon = false; + @state() + private _hasDescriptionSlotContent = false; + + @state() + private _hasActionsSlotContent = false; + + @state() + private _hasDecorationSlotContent = false; + @consume({context: treeContext, subscribe: true}) private _treeContextState: TreeContext = { isShiftPressed: false, selectedItems: new Set(), + hoveredItem: null, allItems: null, itemListUpToDate: false, focusedItem: null, @@ -126,6 +141,22 @@ export class VscodeTreeItem extends VscElement { @queryAssignedElements({selector: 'vscode-tree-item', slot: 'children'}) private _childrenTreeItems!: VscodeTreeItem[]; + @query('slot[name="description"]') + private _descriptionSlotElement!: HTMLSlotElement; + + @query('slot[name="actions"]') + private _actionsSlotElement!: HTMLSlotElement; + + @query('slot[name="decoration"]') + private _decorationSlotElement!: HTMLSlotElement; + + //#endregion + + //#region derived state + + private _isPointerInside = false; + private _hasKeyboardFocus = false; + //#endregion //#region lifecycle methods @@ -142,6 +173,31 @@ export class VscodeTreeItem extends VscElement { this._mainSlotChange(); this.role = 'treeitem'; this.ariaDisabled = 'false'; + this.addEventListener('pointerenter', this._handlePointerEnter); + this.addEventListener('pointerleave', this._handlePointerLeave); + this.addEventListener('focusin', this._handleFocusIn); + this.addEventListener('focusout', this._handleFocusOut); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('pointerenter', this._handlePointerEnter); + this.removeEventListener('pointerleave', this._handlePointerLeave); + this.removeEventListener('focusin', this._handleFocusIn); + this.removeEventListener('focusout', this._handleFocusOut); + } + + protected override firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this._refreshDescriptionSlotState(); + this._refreshActionsSlotState(); + this._refreshDecorationSlotState(); + if (this.matches(':hover')) { + this._isPointerInside = true; + this._claimHover(); + } else { + this._updateActionsVisibility(); + } } protected override willUpdate(changedProperties: PropertyValues): void { @@ -177,6 +233,155 @@ export class VscodeTreeItem extends VscElement { } } + private _refreshDescriptionSlotState(slot?: HTMLSlotElement) { + const descriptionSlot = slot ?? this._descriptionSlotElement; + + if (!descriptionSlot) { + return; + } + + const assignedNodes = descriptionSlot + .assignedNodes({flatten: true}) + .filter((node) => { + if (node.nodeType !== Node.TEXT_NODE) { + return true; + } + + return node.textContent?.trim(); + }); + + const hasContent = assignedNodes.length > 0; + + this._hasDescriptionSlotContent = hasContent; + this.toggleAttribute('has-description', hasContent); + } + + private _refreshActionsSlotState(slot?: HTMLSlotElement) { + const actionsSlot = slot ?? this._actionsSlotElement; + + if (!actionsSlot) { + return; + } + + const assignedNodes = actionsSlot.assignedNodes({flatten: true}); + const hasContent = assignedNodes.length > 0; + + this._hasActionsSlotContent = hasContent; + this.toggleAttribute('has-actions', hasContent); + this._updateActionsVisibility(); + } + + private _refreshDecorationSlotState(slot?: HTMLSlotElement) { + const decorationSlot = slot ?? this._decorationSlotElement; + + if (!decorationSlot) { + return; + } + + const assignedNodes = decorationSlot + .assignedNodes({flatten: true}) + .filter((node) => { + if (node.nodeType !== Node.TEXT_NODE) { + return true; + } + + return node.textContent?.trim(); + }); + + const hasContent = assignedNodes.length > 0; + + const prevHasDecoration = this._hasDecorationSlotContent; + this._hasDecorationSlotContent = hasContent; + this.toggleAttribute('has-decoration', hasContent); + if (prevHasDecoration !== hasContent) { + this.requestUpdate(); + } + } + + private _getActiveElement(): Element | null { + const root = this.getRootNode({composed: true}); + + if (root instanceof Document) { + return root.activeElement instanceof Element ? root.activeElement : null; + } + + if (root instanceof ShadowRoot) { + return root.activeElement instanceof Element ? root.activeElement : null; + } + + return null; + } + + private _isActiveElementInActions(activeElement: Element | null): boolean { + if (!activeElement || !this._actionsSlotElement) { + return false; + } + + const assigned = this._actionsSlotElement.assignedElements({flatten: true}); + + return assigned.some( + (element) => element === activeElement || element.contains(activeElement) + ); + } + + private _updateActionsVisibility() { + if (!this._hasActionsSlotContent) { + this.toggleAttribute('show-actions', false); + return; + } + + const activeElement = this._getActiveElement(); + const isActionsFocused = this._isActiveElementInActions(activeElement); + + const shouldShow = + this.selected || + this._isPointerInside || + this._hasKeyboardFocus || + isActionsFocused; + + this.toggleAttribute('show-actions', shouldShow); + } + + private _updateFocusState() { + requestAnimationFrame(() => { + const hostFocusVisible = this.matches(':focus-visible'); + this.toggleAttribute('focus-visible', hostFocusVisible); + + const activeElement = this._getActiveElement(); + const hasKeyboardFocus = + !!activeElement && + this.contains(activeElement) && + typeof activeElement.matches === 'function' && + activeElement.matches(':focus-visible'); + + this._hasKeyboardFocus = hasKeyboardFocus; + this.toggleAttribute('keyboard-focus', hasKeyboardFocus); + this._updateActionsVisibility(); + }); + } + + private _clearHoverState() { + this._isPointerInside = false; + this.toggleAttribute('hover', false); + this._updateActionsVisibility(); + } + + private _adoptHoverFromSibling() { + this._isPointerInside = true; + this._claimHover(); + } + + private _claimHover() { + const treeState = this._treeContextState; + if (treeState.hoveredItem && treeState.hoveredItem !== this) { + treeState.hoveredItem._clearHoverState(); + } + + treeState.hoveredItem = this; + this.toggleAttribute('hover', true); + this._updateActionsVisibility(); + } + private _toggleActiveState() { if (this.active) { if (this._treeContextState.activeItem) { @@ -205,21 +410,28 @@ export class VscodeTreeItem extends VscElement { private _selectItem(isCtrlDown: boolean) { const {selectedItems} = this._treeContextState; const {multiSelect} = this._configContext; + const prevSelected = new Set(selectedItems); if (multiSelect && isCtrlDown) { - if (this.selected) { - this.selected = false; - selectedItems.delete(this); - } else { - this.selected = true; - selectedItems.add(this); - } + this.selected = !this.selected; } else { - selectedItems.forEach((li) => (li.selected = false)); + Array.from(selectedItems).forEach((li) => { + if (li !== this) { + li.selected = false; + } + }); selectedItems.clear(); this.selected = true; - selectedItems.add(this); } + + const affected = new Set([ + ...prevSelected, + ...selectedItems, + ]); + + affected.add(this); + + affected.forEach((li) => li._updateActionsVisibility()); } private _selectRange() { @@ -229,6 +441,8 @@ export class VscodeTreeItem extends VscElement { return; } + const prevSelected = new Set(this._treeContextState.selectedItems); + if (!this._treeContextState.itemListUpToDate) { this._treeContextState.allItems = this._treeContextState.rootElement!.querySelectorAll( @@ -251,10 +465,19 @@ export class VscodeTreeItem extends VscElement { [from, to] = [to, from]; } - this._treeContextState.selectedItems.forEach((li) => (li.selected = false)); + Array.from(this._treeContextState.selectedItems).forEach( + (li) => (li.selected = false) + ); this._treeContextState.selectedItems.clear(); this._selectItemsAndAllVisibleDescendants(from, to); + + const affected = new Set([ + ...prevSelected, + ...this._treeContextState.selectedItems, + ]); + affected.add(this); + affected.forEach((li) => li._updateActionsVisibility()); } private _selectItemsAndAllVisibleDescendants(from: number, to: number) { @@ -299,6 +522,18 @@ export class VscodeTreeItem extends VscElement { } } + private _handleDescriptionSlotChange(ev: Event) { + this._refreshDescriptionSlotState(ev.target as HTMLSlotElement); + } + + private _handleActionsSlotChange(ev: Event) { + this._refreshActionsSlotState(ev.target as HTMLSlotElement); + } + + private _handleDecorationSlotChange(ev: Event) { + this._refreshDecorationSlotState(ev.target as HTMLSlotElement); + } + private _handleMainSlotChange = () => { this._mainSlotChange(); this._treeContextState.itemListUpToDate = false; @@ -320,6 +555,36 @@ export class VscodeTreeItem extends VscElement { this._treeContextState.focusedItem = this; }; + private _handlePointerEnter = () => { + this._isPointerInside = true; + this._claimHover(); + }; + + private _handlePointerLeave = (ev: PointerEvent) => { + this._isPointerInside = false; + if (this._treeContextState.hoveredItem === this) { + this._treeContextState.hoveredItem = null; + } + this._clearHoverState(); + + const relatedTarget = ev.relatedTarget; + if (relatedTarget instanceof Element) { + const nextItem = + relatedTarget.closest('vscode-tree-item'); + if (nextItem && nextItem !== this && nextItem.isConnected) { + nextItem._adoptHoverFromSibling(); + } + } + }; + + private _handleFocusIn = () => { + this._updateFocusState(); + }; + + private _handleFocusOut = () => { + this._updateFocusState(); + }; + private _handleContentClick(ev: MouseEvent) { ev.stopPropagation(); @@ -404,6 +669,9 @@ export class VscodeTreeItem extends VscElement { const wrapperClasses = { wrapper: true, active: this.active, + 'has-description': this._hasDescriptionSlotContent, + 'has-actions': this._hasActionsSlotContent, + 'has-decoration': this._hasDecorationSlotContent, }; const childrenClasses = { @@ -418,9 +686,16 @@ export class VscodeTreeItem extends VscElement { 'has-icon': hasVisibleIcon, }; + const contentClasses = { + content: true, + 'has-description': this._hasDescriptionSlotContent, + 'has-decoration': this._hasDecorationSlotContent, + }; + return html`
${arrowIcon}
` : nothing} -
+
${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..0d9df0268 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'; @@ -81,6 +81,199 @@ describe('vscode-tree', () => { expect(secondItem.active).to.be.true; }); + describe('actions visibility', () => { + const getWrapper = (item: VscodeTreeItem) => + item.shadowRoot!.querySelector('.wrapper')!; + + const renderActionsTree = async (options?: {multiSelect?: boolean}) => { + return fixture(html` + + + Workspace + + + src + + + components + + + + + + `); + }; + + 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(workspace.hasAttribute('show-actions')).to.be.true; + + src.dispatchEvent(new PointerEvent('pointerenter')); + await aTimeout(0); + expect(src.hasAttribute('show-actions')).to.be.true; + expect(workspace.hasAttribute('show-actions')).to.be.false; + + components.dispatchEvent(new PointerEvent('pointerenter')); + await aTimeout(0); + expect(components.hasAttribute('show-actions')).to.be.true; + expect(src.hasAttribute('show-actions')).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(components.hasAttribute('show-actions')).to.be.true; + + components.dispatchEvent( + new PointerEvent('pointerleave', { + bubbles: false, + composed: false, + relatedTarget: getWrapper(src), + }) + ); + await aTimeout(0); + + expect(components.hasAttribute('show-actions')).to.be.false; + expect(src.hasAttribute('show-actions')).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(workspace.hasAttribute('show-actions')).to.be.true; + + src.dispatchEvent(new PointerEvent('pointerenter')); + await aTimeout(0); + expect(src.hasAttribute('show-actions')).to.be.true; + expect(workspace.hasAttribute('show-actions')).to.be.true; + + components.dispatchEvent(new PointerEvent('pointerenter')); + await aTimeout(0); + expect(components.hasAttribute('show-actions')).to.be.true; + expect(workspace.hasAttribute('show-actions')).to.be.true; + expect(src.hasAttribute('show-actions')).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(workspace.hasAttribute('show-actions')).to.be.true; + + src.dispatchEvent(new PointerEvent('pointerenter')); + await aTimeout(0); + expect(src.hasAttribute('show-actions')).to.be.true; + + getWrapper(src).click(); + await aTimeout(0); + expect(src.selected).to.be.true; + expect(src.hasAttribute('show-actions')).to.be.true; + expect(workspace.hasAttribute('show-actions')).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(workspace.hasAttribute('show-actions')).to.be.true; + expect(src.hasAttribute('show-actions')).to.be.true; + + workspaceWrapper.dispatchEvent( + new MouseEvent('click', {bubbles: true, composed: true, ctrlKey: true}) + ); + await aTimeout(0); + expect(workspace.selected).to.be.false; + + expect(workspace.hasAttribute('show-actions')).to.be.false; + expect(src.hasAttribute('show-actions')).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(item.hasAttribute('show-actions')).to.be.true; + + tree.remove(); + }); + }); + + 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,