diff --git a/dev/vscode-tree/oversized-icon.html b/dev/vscode-tree/oversized-icon.html new file mode 100644 index 000000000..43c91f184 --- /dev/null +++ b/dev/vscode-tree/oversized-icon.html @@ -0,0 +1,88 @@ + + + + + + VSCode Elements — Oversized Icon + + + + + + + +

Oversized Icon

+

+ This demo renders three tree items with 16×16, 32×32, and 64×64 icons so + you can quickly verify how different sizes behave. +

+
+ + + + SM + Standard icon (16×16) + + + XL + Oversized icon (32x32) + + + XXL + Bud Spencer tribute (64x64) + + + +
+ + diff --git a/dev/vscode-tree/problems-tree.html b/dev/vscode-tree/problems-tree.html new file mode 100644 index 000000000..9a2b04b12 --- /dev/null +++ b/dev/vscode-tree/problems-tree.html @@ -0,0 +1,382 @@ + + + + + + VSCode Elements — Problems Tree + + + + + + +

Problems (demo)

+

+ Hover and click the error icon to open the quick-fix menu, and notice how + the row doesn't steal focus. The faux (no-console) + link behaves the same way—no selection changes, just logging. +

+
+ + + + + + rollup.config.js + + + + + + + + + + + 'console' is not defined + + +
+ + eslint + (no-undef) + + [Ln 20, Col 7] +
+
+
+ + + + + + + + Unexpected console statement. + + +
+ + eslint + (no-console) + + [Ln 20, Col 7] +
+
+
+ + + + + + + + "onwarn": Unknown word. + + +
+ cSpell + [Ln 18, Col 3] +
+
+
+
+
+ +
+
+ + + diff --git a/dev/vscode-tree/scm-tree.html b/dev/vscode-tree/scm-tree.html new file mode 100644 index 000000000..50fd61614 --- /dev/null +++ b/dev/vscode-tree/scm-tree.html @@ -0,0 +1,384 @@ + + + + + + VSCode Elements + + + + + + + +

SCM (Demo)

+
+
+
+ + + 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..0148f20e8 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; } @@ -93,8 +96,9 @@ const styles: CSSResultGroup = [ .icon-container { align-items: center; display: flex; - margin-bottom: 3px; - margin-top: 3px; + justify-content: center; + margin-right: 3px; + min-height: 22px; overflow: hidden; } @@ -103,9 +107,13 @@ const styles: CSSResultGroup = [ } .icon-container.has-icon { - height: 16px; - margin-right: 6px; - width: 16px; + min-width: 22px; + max-width: 22px; + max-height: 22px; + } + + :host(:is(:--show-actions, :state(show-actions))) .icon-container { + overflow: visible; } .children { @@ -137,12 +145,145 @@ 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-foreground, #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-left: 0.5em; + } + + .content.has-description .label { + flex: 0 1 auto; /* label only grows when description missing */ + } + + .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; + 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( + :is( + :--has-actions:--show-actions, + :--has-actions:state(show-actions), + :state(has-actions):--show-actions, + :state(has-actions):state(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(:is(:--has-decoration, :state(has-decoration))) .decoration { + display: inline-flex; + } + + :host(:is(:--show-actions, :state(show-actions))) .decoration { + margin-left: 6px; + } + + :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]) :is(:state(focus-visible), :--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..f63a9d069 100644 --- a/src/vscode-tree-item/vscode-tree-item.ts +++ b/src/vscode-tree-item/vscode-tree-item.ts @@ -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[]; + @queryAssignedElements({slot: 'description', flatten: true}) + private _descriptionSlotElements!: Element[]; + + @queryAssignedElements({slot: 'actions', flatten: true}) + private _actionsSlotElements!: Element[]; + + @queryAssignedElements({slot: 'decoration', flatten: true}) + private _decorationSlotElements!: Element[]; + + //#endregion + + //#region derived state + + private _isPointerInside = false; + private _hasKeyboardFocus = false; + //#endregion //#region lifecycle methods @@ -135,6 +166,10 @@ export class VscodeTreeItem extends VscElement { this._internals = this.attachInternals(); this.addEventListener('focus', this._handleComponentFocus); + this.addEventListener('pointerenter', this._handlePointerEnter); + this.addEventListener('pointerleave', this._handlePointerLeave); + this.addEventListener('focusin', this._handleFocusIn); + this.addEventListener('focusout', this._handleFocusOut); } override connectedCallback(): void { @@ -144,6 +179,19 @@ export class VscodeTreeItem extends VscElement { this.ariaDisabled = 'false'; } + 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 { if (changedProperties.has('active')) { this._toggleActiveState(); @@ -177,6 +225,141 @@ export class VscodeTreeItem extends VscElement { } } + private _refreshDescriptionSlotState() { + const hasContent = (this._descriptionSlotElements?.length ?? 0) > 0; + + this._hasDescriptionSlotContent = hasContent; + this._setCustomState('has-description', hasContent); + } + + private _refreshActionsSlotState() { + const hasContent = (this._actionsSlotElements?.length ?? 0) > 0; + + this._hasActionsSlotContent = hasContent; + this._setCustomState('has-actions', hasContent); + this._updateActionsVisibility(); + } + + private _refreshDecorationSlotState() { + const hasContent = (this._decorationSlotElements?.length ?? 0) > 0; + + const prevHasDecoration = this._hasDecorationSlotContent; + this._hasDecorationSlotContent = hasContent; + this._setCustomState('has-decoration', hasContent); + if (prevHasDecoration !== hasContent) { + this.requestUpdate(); + } + } + + private _setCustomState(stateName: string, present: boolean) { + if (!this._internals?.states) { + return; + } + + try { + if (present) { + this._internals.states.add(stateName); + } else { + this._internals.states.delete(stateName); + } + } catch { + // https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet#compatibility_with_dashed-ident_syntax + if (present) { + this._internals.states.add(`--${stateName}`); + } else { + this._internals.states.delete(`--${stateName}`); + } + } + } + + 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) { + return false; + } + + return (this._actionsSlotElements ?? []).some( + (element) => element === activeElement || element.contains(activeElement) + ); + } + + private _updateActionsVisibility() { + if (!this._hasActionsSlotContent) { + this._setCustomState('show-actions', false); + return; + } + + const activeElement = this._getActiveElement(); + const isActionsFocused = this._isActiveElementInActions(activeElement); + + const shouldShow = + this.selected || + this._isPointerInside || + this._hasKeyboardFocus || + isActionsFocused; + + this._setCustomState('show-actions', shouldShow); + } + + private _updateFocusState() { + const hostFocusVisible = this.matches(':focus-visible'); + this._setCustomState('focus-visible', hostFocusVisible); + + const activeElement = this._getActiveElement(); + let owner: VscodeTreeItem | null = null; + if (activeElement instanceof Element) { + owner = activeElement.closest('vscode-tree-item'); + + if (!owner) { + const root = activeElement.getRootNode(); + if (root instanceof ShadowRoot && root.host instanceof VscodeTreeItem) { + owner = root.host; + } + } + } + + const hasKeyboardFocus = owner === this; + + this._hasKeyboardFocus = hasKeyboardFocus; + this._setCustomState('keyboard-focus', hasKeyboardFocus); + this._updateActionsVisibility(); + } + + private _clearHoverState() { + this._isPointerInside = false; + this._setCustomState('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._setCustomState('hover', true); + this._updateActionsVisibility(); + } + private _toggleActiveState() { if (this.active) { if (this._treeContextState.activeItem) { @@ -190,7 +373,7 @@ export class VscodeTreeItem extends VscElement { this._treeContextState.activeItem = this; this._setHasActiveItemFlagOnParent(this, true); this.tabIndex = 0; - this._internals.states.add('active'); + this._setCustomState('active', true); } else { if (this._treeContextState.activeItem === this) { this._treeContextState.activeItem = null; @@ -198,28 +381,33 @@ export class VscodeTreeItem extends VscElement { } this.tabIndex = -1; - this._internals.states.delete('active'); + this._setCustomState('active', false); } } 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 +417,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 +441,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 +498,18 @@ export class VscodeTreeItem extends VscElement { } } + private _handleDescriptionSlotChange() { + this._refreshDescriptionSlotState(); + } + + private _handleActionsSlotChange() { + this._refreshActionsSlotState(); + } + + private _handleDecorationSlotChange() { + this._refreshDecorationSlotState(); + } + private _handleMainSlotChange = () => { this._mainSlotChange(); this._treeContextState.itemListUpToDate = false; @@ -320,6 +531,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 +645,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 +662,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..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 + + + 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(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 + + + + `); + + 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,