From e8f8b8637ab43c2c85b05beda522fdcdfe583c1a Mon Sep 17 00:00:00 2001 From: dankeboy36 Date: Tue, 14 Oct 2025 16:05:33 +0200 Subject: [PATCH 1/2] 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, From 71c7970351cc4bded56637214e7a627b612c481f Mon Sep 17 00:00:00 2001 From: dankeboy36 Date: Tue, 11 Nov 2025 14:47:39 +0100 Subject: [PATCH 2/2] fix: review feedback + add problems tree demo Signed-off-by: dankeboy36 --- dev/vscode-tree/oversized-icon.html | 88 ++++ dev/vscode-tree/problems-tree.html | 382 ++++++++++++++++++ .../{decoration-lane.html => scm-tree.html} | 31 +- .../vscode-tree-item.styles.ts | 39 +- src/vscode-tree-item/vscode-tree-item.ts | 176 ++++---- src/vscode-tree/vscode-tree.test.ts | 176 ++++++-- 6 files changed, 750 insertions(+), 142 deletions(-) create mode 100644 dev/vscode-tree/oversized-icon.html create mode 100644 dev/vscode-tree/problems-tree.html rename dev/vscode-tree/{decoration-lane.html => scm-tree.html} (94%) 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/decoration-lane.html b/dev/vscode-tree/scm-tree.html similarity index 94% rename from dev/vscode-tree/decoration-lane.html rename to dev/vscode-tree/scm-tree.html index 01d02348f..50fd61614 100644 --- a/dev/vscode-tree/decoration-lane.html +++ b/dev/vscode-tree/scm-tree.html @@ -121,7 +121,7 @@ -

Decoration lane

+

SCM (Demo)

@@ -293,6 +293,17 @@

Decoration lane

const widthInput = document.getElementById('tree-width'); const widthValue = document.getElementById('tree-width-value'); + const selectTreeItem = (treeItem) => { + const wrapper = treeItem.shadowRoot?.querySelector('.wrapper'); + if (wrapper) { + wrapper.dispatchEvent( + new MouseEvent('click', {bubbles: true, composed: true}) + ); + } else { + treeItem.selected = true; + } + }; + const setupActionGuards = () => { if (!demoFrame) { return; @@ -312,9 +323,6 @@

Decoration lane

group.addEventListener( 'click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const target = ev.target instanceof HTMLElement ? ev.target : null; const button = target?.closest('vscode-toolbar-button'); @@ -323,11 +331,24 @@

Decoration lane

return; } + const treeItem = button.closest('vscode-tree-item'); + if (!treeItem) { + return; + } + + if (!treeItem.hasAttribute('selected')) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + selectTreeItem(treeItem); + return; + } + + ev.stopPropagation(); + const action = button.dataset.action ?? button.getAttribute('icon') ?? 'action'; - const treeItem = button.closest('vscode-tree-item'); const label = treeItem?.dataset.node ?? 'tree-item'; console.log(`Action "${action}" on ${label}`); diff --git a/src/vscode-tree-item/vscode-tree-item.styles.ts b/src/vscode-tree-item/vscode-tree-item.styles.ts index 0f19d6cb4..0148f20e8 100644 --- a/src/vscode-tree-item/vscode-tree-item.styles.ts +++ b/src/vscode-tree-item/vscode-tree-item.styles.ts @@ -96,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; } @@ -106,9 +107,13 @@ const styles: CSSResultGroup = [ } .icon-container.has-icon { - height: 16px; - margin-right: 6px; - min-width: 16px; + min-width: 22px; + max-width: 22px; + max-height: 22px; + } + + :host(:is(:--show-actions, :state(show-actions))) .icon-container { + overflow: visible; } .children { @@ -160,7 +165,7 @@ const styles: CSSResultGroup = [ } .description { - color: var(--vscode-descriptionForeground, #cccccc); + color: var(--vscode-foreground, #cccccc); opacity: 0.7; display: none; flex: 0 1 auto; @@ -179,12 +184,11 @@ const styles: CSSResultGroup = [ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - margin-right: calc(var(--vscode-sash-size, 4px) * 1); + margin-left: 0.5em; } .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 { @@ -216,7 +220,6 @@ const styles: CSSResultGroup = [ flex: 0 0 auto; gap: 2px; margin-left: auto; - padding-left: calc(var(--vscode-sash-size, 4px) * 1.5); min-height: 22px; color: inherit; } @@ -235,7 +238,15 @@ const styles: CSSResultGroup = [ display: none !important; } - :host([has-actions][show-actions]) .actions { + :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; } @@ -250,12 +261,12 @@ const styles: CSSResultGroup = [ min-height: 22px; } - :host([has-decoration]) .decoration { + :host(:is(:--has-decoration, :state(has-decoration))) .decoration { display: inline-flex; } - :host([show-actions]) .decoration { - margin-left: calc(var(--vscode-sash-size, 4px) * 1.5); + :host(:is(:--show-actions, :state(show-actions))) .decoration { + margin-left: 6px; } :host([selected]) ::slotted([slot='decoration']), @@ -268,7 +279,7 @@ const styles: CSSResultGroup = [ opacity: 0.8; } - :host([selected][focus-visible]) .description, + :host([selected]) :is(:state(focus-visible), :--focus-visible) .description, :host([selected]:focus-within) .description { opacity: 0.95; } diff --git a/src/vscode-tree-item/vscode-tree-item.ts b/src/vscode-tree-item/vscode-tree-item.ts index 30e2d4218..f63a9d069 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, query, queryAssignedElements, state} from 'lit/decorators.js'; +import {property, 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'; @@ -141,14 +141,14 @@ export class VscodeTreeItem extends VscElement { @queryAssignedElements({selector: 'vscode-tree-item', slot: 'children'}) private _childrenTreeItems!: VscodeTreeItem[]; - @query('slot[name="description"]') - private _descriptionSlotElement!: HTMLSlotElement; + @queryAssignedElements({slot: 'description', flatten: true}) + private _descriptionSlotElements!: Element[]; - @query('slot[name="actions"]') - private _actionsSlotElement!: HTMLSlotElement; + @queryAssignedElements({slot: 'actions', flatten: true}) + private _actionsSlotElements!: Element[]; - @query('slot[name="decoration"]') - private _decorationSlotElement!: HTMLSlotElement; + @queryAssignedElements({slot: 'decoration', flatten: true}) + private _decorationSlotElements!: Element[]; //#endregion @@ -166,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 { @@ -173,18 +177,6 @@ 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 { @@ -233,71 +225,53 @@ 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; + private _refreshDescriptionSlotState() { + const hasContent = (this._descriptionSlotElements?.length ?? 0) > 0; this._hasDescriptionSlotContent = hasContent; - this.toggleAttribute('has-description', hasContent); + this._setCustomState('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; + private _refreshActionsSlotState() { + const hasContent = (this._actionsSlotElements?.length ?? 0) > 0; this._hasActionsSlotContent = hasContent; - this.toggleAttribute('has-actions', hasContent); + this._setCustomState('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; + private _refreshDecorationSlotState() { + const hasContent = (this._decorationSlotElements?.length ?? 0) > 0; const prevHasDecoration = this._hasDecorationSlotContent; this._hasDecorationSlotContent = hasContent; - this.toggleAttribute('has-decoration', 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}); @@ -313,20 +287,18 @@ export class VscodeTreeItem extends VscElement { } private _isActiveElementInActions(activeElement: Element | null): boolean { - if (!activeElement || !this._actionsSlotElement) { + if (!activeElement) { return false; } - const assigned = this._actionsSlotElement.assignedElements({flatten: true}); - - return assigned.some( + return (this._actionsSlotElements ?? []).some( (element) => element === activeElement || element.contains(activeElement) ); } private _updateActionsVisibility() { if (!this._hasActionsSlotContent) { - this.toggleAttribute('show-actions', false); + this._setCustomState('show-actions', false); return; } @@ -339,30 +311,36 @@ export class VscodeTreeItem extends VscElement { this._hasKeyboardFocus || isActionsFocused; - this.toggleAttribute('show-actions', shouldShow); + this._setCustomState('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(); - }); + 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.toggleAttribute('hover', false); + this._setCustomState('hover', false); this._updateActionsVisibility(); } @@ -378,7 +356,7 @@ export class VscodeTreeItem extends VscElement { } treeState.hoveredItem = this; - this.toggleAttribute('hover', true); + this._setCustomState('hover', true); this._updateActionsVisibility(); } @@ -395,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; @@ -403,7 +381,7 @@ export class VscodeTreeItem extends VscElement { } this.tabIndex = -1; - this._internals.states.delete('active'); + this._setCustomState('active', false); } } @@ -428,9 +406,7 @@ export class VscodeTreeItem extends VscElement { ...prevSelected, ...selectedItems, ]); - affected.add(this); - affected.forEach((li) => li._updateActionsVisibility()); } @@ -522,16 +498,16 @@ export class VscodeTreeItem extends VscElement { } } - private _handleDescriptionSlotChange(ev: Event) { - this._refreshDescriptionSlotState(ev.target as HTMLSlotElement); + private _handleDescriptionSlotChange() { + this._refreshDescriptionSlotState(); } - private _handleActionsSlotChange(ev: Event) { - this._refreshActionsSlotState(ev.target as HTMLSlotElement); + private _handleActionsSlotChange() { + this._refreshActionsSlotState(); } - private _handleDecorationSlotChange(ev: Event) { - this._refreshDecorationSlotState(ev.target as HTMLSlotElement); + private _handleDecorationSlotChange() { + this._refreshDecorationSlotState(); } private _handleMainSlotChange = () => { diff --git a/src/vscode-tree/vscode-tree.test.ts b/src/vscode-tree/vscode-tree.test.ts index 0d9df0268..57864a1db 100644 --- a/src/vscode-tree/vscode-tree.test.ts +++ b/src/vscode-tree/vscode-tree.test.ts @@ -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'); @@ -85,6 +97,9 @@ describe('vscode-tree', () => { 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` @@ -112,17 +127,17 @@ describe('vscode-tree', () => { workspace.dispatchEvent(new PointerEvent('pointerenter')); await aTimeout(0); - expect(workspace.hasAttribute('show-actions')).to.be.true; + expect(hasShowActionsState(workspace)).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; + expect(hasShowActionsState(src)).to.be.true; + expect(hasShowActionsState(workspace)).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; + expect(hasShowActionsState(components)).to.be.true; + expect(hasShowActionsState(src)).to.be.false; }); it('reclaims hover when moving from child to parent sibling', async () => { @@ -132,7 +147,7 @@ describe('vscode-tree', () => { components.dispatchEvent(new PointerEvent('pointerenter')); await aTimeout(0); - expect(components.hasAttribute('show-actions')).to.be.true; + expect(hasShowActionsState(components)).to.be.true; components.dispatchEvent( new PointerEvent('pointerleave', { @@ -143,8 +158,8 @@ describe('vscode-tree', () => { ); await aTimeout(0); - expect(components.hasAttribute('show-actions')).to.be.false; - expect(src.hasAttribute('show-actions')).to.be.true; + expect(hasShowActionsState(components)).to.be.false; + expect(hasShowActionsState(src)).to.be.true; }); it('keeps actions visible for selected item while hovering siblings', async () => { @@ -156,18 +171,18 @@ describe('vscode-tree', () => { getWrapper(workspace).click(); await aTimeout(0); expect(workspace.selected).to.be.true; - expect(workspace.hasAttribute('show-actions')).to.be.true; + expect(hasShowActionsState(workspace)).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; + expect(hasShowActionsState(src)).to.be.true; + expect(hasShowActionsState(workspace)).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; + 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 () => { @@ -178,17 +193,17 @@ describe('vscode-tree', () => { getWrapper(workspace).click(); await aTimeout(0); expect(workspace.selected).to.be.true; - expect(workspace.hasAttribute('show-actions')).to.be.true; + expect(hasShowActionsState(workspace)).to.be.true; src.dispatchEvent(new PointerEvent('pointerenter')); await aTimeout(0); - expect(src.hasAttribute('show-actions')).to.be.true; + expect(hasShowActionsState(src)).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; + expect(hasShowActionsState(src)).to.be.true; + expect(hasShowActionsState(workspace)).to.be.false; }); it('shows actions for all selected items in multi-select mode', async () => { @@ -209,8 +224,8 @@ describe('vscode-tree', () => { 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; + expect(hasShowActionsState(workspace)).to.be.true; + expect(hasShowActionsState(src)).to.be.true; workspaceWrapper.dispatchEvent( new MouseEvent('click', {bubbles: true, composed: true, ctrlKey: true}) @@ -218,8 +233,8 @@ describe('vscode-tree', () => { 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; + expect(hasShowActionsState(workspace)).to.be.false; + expect(hasShowActionsState(src)).to.be.true; }); it('shows actions when pointer is already hovering on first render', async () => { @@ -242,10 +257,125 @@ describe('vscode-tree', () => { await (tree as VscodeTree).updateComplete; await aTimeout(0); - expect(item.hasAttribute('show-actions')).to.be.true; + 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', () => {