diff --git a/.changeset/short-pans-know.md b/.changeset/short-pans-know.md new file mode 100644 index 00000000000..0cd4f204195 --- /dev/null +++ b/.changeset/short-pans-know.md @@ -0,0 +1,5 @@ +--- +'@spectrum-web-components/status-light': patch +--- + +**Fixed**: Added missing `accent` and `cyan` variant to status light. diff --git a/.circleci/config.yml b/.circleci/config.yml index 302c12e98b5..3516b189088 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,7 +22,7 @@ parameters: # 3. Commit this change to the PR branch where the changes exist. current_golden_images_hash: type: string - default: eb24194a08a84696ddd7eb582d08118302b95ed7 + default: 71be19cab16d3ff600820e801341f14629ad06d9 wireit_cache_name: type: string default: wireit diff --git a/.cursor/rules/github-description.mdc b/.cursor/rules/github-description.mdc index ff3778b1425..9abe4bd4804 100644 --- a/.cursor/rules/github-description.mdc +++ b/.cursor/rules/github-description.mdc @@ -93,6 +93,7 @@ Use the following labels to categorize pull requests. Only use labels that exist - `Browser: Edge (Legacy)`: Issue with pre-chromium Edge - `Browser: FireFox`: Firefox browser issues - `Browser: Safari`: Safari browser issues +- `iOS`: iOS-specific issues and bugs ### Development and process labels @@ -107,11 +108,9 @@ Common additional labels include: - `chore`: Routine tasks, maintenance, or non-feature changes - `dependencies`: Updates or changes to project dependencies -- `docs`: Documentation updates or improvements -- `enhancement`: Improvements to existing features -- `feature`: New feature implementations +- `Documentation`: Documentation updates or improvements +- `feature`: New feature implementations or improvements to existing features - `i18n`: Internationalization and localization work -- `iOS`: iOS-specific issues and bugs - `mobile`: Mobile platform issues and responsive design - `performance`: Performance-related improvements or regressions - `refactor`: Code restructuring and refactoring work diff --git a/.cursor/rules/jira-ticket.mdc b/.cursor/rules/jira-ticket.mdc index 7d618120b49..ff313b62326 100644 --- a/.cursor/rules/jira-ticket.mdc +++ b/.cursor/rules/jira-ticket.mdc @@ -185,7 +185,7 @@ Use the following labels to categorize tickets appropriately: - `s2foundations`: Spectrum 2 Foundations related work - `spectrum2`: Spectrum 2 platform specific tasks - `team-processes`: Internal team workflow improvements -- `testing`: Test implementation or testing infrastructure work +- `test`: Test implementation or testing infrastructure work - `triage`: New tickets requiring team assessment and prioritization - `VoiceOver`: VoiceOver screen reader specific issues diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 55c61bcb1da..be4d9f61bed 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,7 +1,7 @@ name: Feature request description: Describe the feature you would like added title: '[Feat]: ' -labels: [enhancement, triage, needs jira ticket] +labels: [feature, triage, needs jira ticket] # assignees: body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/new_component.yaml b/.github/ISSUE_TEMPLATE/new_component.yaml index 6398d37f0f2..c76d1132bc7 100644 --- a/.github/ISSUE_TEMPLATE/new_component.yaml +++ b/.github/ISSUE_TEMPLATE/new_component.yaml @@ -1,7 +1,7 @@ name: New component description: Outline the requirements for a new component title: '[NEW]: ' -labels: [missing components, triage, needs jira ticket] +labels: [new component, triage, needs jira ticket] # assignees: body: - type: markdown diff --git a/first-gen/packages/action-menu/stories/action-menu.stories.ts b/first-gen/packages/action-menu/stories/action-menu.stories.ts index ce44d5adb85..da6144a1386 100644 --- a/first-gen/packages/action-menu/stories/action-menu.stories.ts +++ b/first-gen/packages/action-menu/stories/action-menu.stories.ts @@ -281,6 +281,9 @@ selects.args = { open: true, }; selects.decorators = [isOverlayOpen]; +selects.swc_vrt = { + skip: true, +}; export const iconOnly = (args: StoryArgs = {}): TemplateResult => Template(args); diff --git a/first-gen/packages/overlay/src/HoverController.ts b/first-gen/packages/overlay/src/HoverController.ts index bd77ae8fdc9..490f22bbc45 100644 --- a/first-gen/packages/overlay/src/HoverController.ts +++ b/first-gen/packages/overlay/src/HoverController.ts @@ -20,22 +20,32 @@ import { lastInteractionType, } from './InteractionController.js'; -const HOVER_DELAY = 300; - export class HoverController extends InteractionController { override type = InteractionTypes.hover; private elementIds: string[] = []; - focusedin = false; + private targetFocused = false; private hoverTimeout?: ReturnType; - pointerentered = false; + private hovering = false; + + private overlayFocused = false; handleKeyup(event: KeyboardEvent): void { - if (event.code === 'Tab' || event.code === 'Escape') { + if (event.code === 'Tab') { this.open = true; + } else if (event.code === 'Escape') { + if (this.open) { + event.preventDefault(); + event.stopPropagation(); + this.open = false; + // Return focus to trigger element + if (this.target) { + this.target.focus(); + } + } } } @@ -52,23 +62,29 @@ export class HoverController extends InteractionController { } this.open = true; - this.focusedin = true; + this.targetFocused = true; } handleTargetFocusout(): void { - this.focusedin = false; - if (this.pointerentered) return; - this.open = false; + this.targetFocused = false; + // Don't close immediately if pointer is over the content + if (this.hovering) return; + // Use delay to allow focus to move into overlay content + this.doFocusleave(); } - handleTargetPointerenter(): void { + private clearCloseTimeout(): void { if (this.hoverTimeout) { clearTimeout(this.hoverTimeout); this.hoverTimeout = undefined; } + } + + handleTargetPointerenter(): void { + this.clearCloseTimeout(); if (this.overlay?.disabled) return; this.open = true; - this.pointerentered = true; + this.hovering = true; } handleTargetPointerleave(): void { @@ -78,16 +94,28 @@ export class HoverController extends InteractionController { // set a timeout once the pointer enters and the overlay is shown // give the user time to enter the overlay handleHostPointerenter(): void { - if (this.hoverTimeout) { - clearTimeout(this.hoverTimeout); - this.hoverTimeout = undefined; - } + this.clearCloseTimeout(); } handleHostPointerleave(): void { this.doPointerleave(); } + handleOverlayFocusin(): void { + this.overlayFocused = true; + // Clear any pending close timeout when focus enters overlay + this.clearCloseTimeout(); + } + + handleOverlayFocusout(): void { + this.overlayFocused = false; + // Don't close immediately if pointer is over the content or trigger has focus + if (this.hovering) return; + if (this.targetFocused && this.target.matches(':focus-visible')) return; + // Use delay before closing + this.doFocusleave(); + } + override prepareDescription(): void { // require "content" to apply relationship if (!this.overlay.elements.length) return; @@ -138,14 +166,31 @@ export class HoverController extends InteractionController { }; } - protected doPointerleave(): void { - this.pointerentered = false; - const triggerElement = this.target as HTMLElement; - if (this.focusedin && triggerElement.matches(':focus-visible')) return; - + private scheduleClose(): void { this.hoverTimeout = setTimeout(() => { this.open = false; - }, HOVER_DELAY); + }, 300); + } + + private doPointerleave(): void { + this.hovering = false; + const triggerElement = this.target as HTMLElement; + if (this.targetFocused && triggerElement.matches(':focus-visible')) + return; + // Don't close if focus is within overlay content + if (this.overlayFocused) return; + + this.scheduleClose(); + } + + private doFocusleave(): void { + // Clear any existing timeout + this.clearCloseTimeout(); + + // Use same delay as pointer interactions for consistency + if (!this.targetFocused && !this.overlayFocused && !this.hovering) { + this.scheduleClose(); + } } override init(): void { @@ -198,5 +243,20 @@ export class HoverController extends InteractionController { () => this.handleHostPointerleave(), { signal } ); + this.overlay.addEventListener( + 'focusin', + () => this.handleOverlayFocusin(), + { signal } + ); + this.overlay.addEventListener( + 'focusout', + () => this.handleOverlayFocusout(), + { signal } + ); + this.overlay.addEventListener( + 'keyup', + (event) => this.handleKeyup(event), + { signal } + ); } } diff --git a/first-gen/packages/overlay/stories/overlay.stories.ts b/first-gen/packages/overlay/stories/overlay.stories.ts index 06074def5b9..2477841dfd6 100644 --- a/first-gen/packages/overlay/stories/overlay.stories.ts +++ b/first-gen/packages/overlay/stories/overlay.stories.ts @@ -20,6 +20,7 @@ import '@spectrum-web-components/dialog/sp-dialog.js'; import '@spectrum-web-components/field-label/sp-field-label.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-magnify.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-open-in.js'; +import '@spectrum-web-components/link/sp-link.js'; import { openOverlay, Overlay, @@ -1654,6 +1655,92 @@ export const triggeredByOptimization = (): TemplateResult => { `; }; +export const hoverWithInteractiveContent = (): TemplateResult => { + return html` +
+ + + + Hover for interactive buttons + + + +

Interactive content

+

Tab into these buttons:

+
+ Action 1 + Action 2 + Action 3 +
+
+
+
+ + + + + Hover for interactive links + + + +

Quick links

+
    +
  • + + Example link 1 + +
  • +
  • + + Example link 2 + +
  • +
  • + + Example link 3 + +
  • +
+
+
+
+ + + + Hover for action group + + + + + Send to Front + + + + Send to Back + + + + Align Center + + + + +
+ `; +}; + +hoverWithInteractiveContent.swc_vrt = { + skip: true, +}; + export const pickerInDialog = (): TemplateResult => { return html` Button popover diff --git a/first-gen/packages/overlay/test/overlay-trigger-hover.test.ts b/first-gen/packages/overlay/test/overlay-trigger-hover.test.ts index b7c3668a1af..02c6d4e2961 100644 --- a/first-gen/packages/overlay/test/overlay-trigger-hover.test.ts +++ b/first-gen/packages/overlay/test/overlay-trigger-hover.test.ts @@ -13,7 +13,6 @@ import { elementUpdated, expect, html, - nextFrame, oneEvent, waitUntil, } from '@open-wc/testing'; @@ -38,6 +37,7 @@ import { ignoreResizeObserverLoopError, mouseMoveAway, mouseMoveOver, + sendShiftTabKey, sendTabKey, } from '../../../test/testing-helpers.js'; @@ -125,10 +125,11 @@ describe('Overlay Trigger - Hover', () => { composed: true, }) ); - await nextFrame(); - await nextFrame(); - await nextFrame(); - await nextFrame(); + await waitUntil( + () => tooltip.open === true, + 'tooltip should open', + { timeout: 500 } + ); expect(tooltip.open).to.be.true; button.dispatchEvent( @@ -137,7 +138,7 @@ describe('Overlay Trigger - Hover', () => { composed: true, }) ); - await nextFrame(); + await elementUpdated(tooltip); button.dispatchEvent( new MouseEvent('pointerenter', { @@ -145,7 +146,7 @@ describe('Overlay Trigger - Hover', () => { composed: true, }) ); - await nextFrame(); + await elementUpdated(tooltip); tooltip.dispatchEvent( new MouseEvent('pointerleave', { @@ -153,7 +154,7 @@ describe('Overlay Trigger - Hover', () => { composed: true, }) ); - await nextFrame(); + await elementUpdated(tooltip); button.dispatchEvent( new MouseEvent('pointerenter', { @@ -185,7 +186,7 @@ describe('Overlay Trigger - Hover', () => { composed: true, }) ); - await nextFrame(); + await elementUpdated(tooltip); button.dispatchEvent( new MouseEvent('pointerleave', { relatedTarget: tooltip, @@ -372,4 +373,592 @@ describe('Overlay Trigger - Hover', () => { expect(button3 === document.activeElement).to.be.true; }); + describe('nested overlays focus management', () => { + it('closes nested hover overlay without closing parent modal when focus leaves nested overlay', async () => { + const el = await styledFixture(html` + + Toggle Dialog + + + + Button with Tooltip 1 + + + Tooltip content 1 + + + + + Button with Tooltip 2 + + + Tooltip content 2 + + + + + `); + await elementUpdated(el); + + const button = el.querySelector('sp-button') as Button; + const dialog = el.querySelector('sp-dialog-wrapper') as HTMLElement; + const button1 = dialog.querySelector('#button-1') as Button; + const button2 = dialog.querySelector('#button-2') as Button; + const tooltipTrigger1 = dialog.querySelector( + '#tooltip-trigger-1' + ) as OverlayTrigger; + const tooltipTrigger2 = dialog.querySelector( + '#tooltip-trigger-2' + ) as OverlayTrigger; + + // Open the modal dialog + const opened = oneEvent(button, 'sp-opened'); + button.dispatchEvent(new Event('click', { bubbles: true })); + await opened; + + expect(el.open).to.equal('click'); + + // Focus button1 to open its tooltip + button1.focus(); + await elementUpdated(tooltipTrigger1); + await waitUntil( + () => tooltipTrigger1.open === 'hover', + 'tooltip 1 opens on focus', + { timeout: 500 } + ); + + expect(tooltipTrigger1.open).to.equal('hover'); + expect(el.open).to.equal('click'); // Modal should still be open + + // Tab to button2, which should close tooltip1 and open tooltip2 + await sendTabKey(); + await elementUpdated(tooltipTrigger1); + await elementUpdated(tooltipTrigger2); + + // Wait for tooltip 2 to open + await waitUntil( + () => tooltipTrigger2.open === 'hover', + 'tooltip 2 opens on focus', + { timeout: 500 } + ); + + // Verify button2 has focus + expect( + button2 === document.activeElement || + button2.contains(document.activeElement) + ).to.be.true; + + // Wait for tooltip 1 to close (with delay) + await new Promise((resolve) => setTimeout(resolve, 400)); + await elementUpdated(tooltipTrigger1); + + expect(tooltipTrigger2.open).to.equal('hover'); + expect(tooltipTrigger1.open).to.be.undefined; // Tooltip 1 should be closed + expect(el.open).to.equal('click'); // Modal should still be open + + // Shift+Tab back to button1 + await sendShiftTabKey(); + await elementUpdated(tooltipTrigger1); + await elementUpdated(tooltipTrigger2); + + // Wait for tooltip 1 to open + await waitUntil( + () => tooltipTrigger1.open === 'hover', + 'tooltip 1 opens on reverse focus', + { timeout: 500 } + ); + + // Verify button1 has focus again + expect( + button1 === document.activeElement || + button1.contains(document.activeElement) + ).to.be.true; + + // Wait for tooltip 2 to close (with delay) + await new Promise((resolve) => setTimeout(resolve, 400)); + await elementUpdated(tooltipTrigger2); + + expect(tooltipTrigger1.open).to.equal('hover'); + expect(tooltipTrigger2.open).to.be.undefined; // Tooltip 2 should be closed + expect(el.open).to.equal('click'); // Modal should still be open + }); + + it('maintains parent modal open when nested hover overlay closes on pointer leave', async () => { + const el = await styledFixture(html` + + Toggle Dialog + + + + Button with Popover + + + Tooltip content + + + + + `); + await elementUpdated(el); + + const button = el.querySelector('sp-button') as Button; + const dialog = el.querySelector('sp-dialog-wrapper') as HTMLElement; + const buttonWithPopover = dialog.querySelector( + '#button-with-popover' + ) as Button; + const popoverTrigger = dialog.querySelector( + '#popover-trigger' + ) as OverlayTrigger; + + // Open the modal dialog + const opened = oneEvent(button, 'sp-opened'); + button.dispatchEvent(new Event('click', { bubbles: true })); + await opened; + await elementUpdated(dialog); + await elementUpdated(buttonWithPopover); + + expect(el.open).to.equal('click'); + + // Hover over the button to open tooltip + buttonWithPopover.dispatchEvent( + new MouseEvent('pointerenter', { + bubbles: true, + composed: true, + }) + ); + await elementUpdated(popoverTrigger); + await waitUntil( + () => popoverTrigger.open === 'hover', + 'tooltip opens on hover', + { timeout: 1000 } + ); + + expect(popoverTrigger.open).to.equal('hover'); + expect(el.open).to.equal('click'); // Modal should still be open + + // Find the rendered tooltip + const nestedTooltip = document.querySelector( + '#nested-tooltip' + ) as HTMLElement; + + // Blur the button to ensure it doesn't keep tooltip open via focus + buttonWithPopover.blur(); + await elementUpdated(popoverTrigger); + + // Dispatch pointerleave on button with tooltip as relatedTarget + const closed = oneEvent(buttonWithPopover, 'sp-closed'); + buttonWithPopover.dispatchEvent( + new MouseEvent('pointerleave', { + relatedTarget: nestedTooltip, + bubbles: true, + composed: true, + }) + ); + await elementUpdated(popoverTrigger); + + // Then dispatch pointerleave on the tooltip itself + if (nestedTooltip) { + nestedTooltip.dispatchEvent( + new MouseEvent('pointerleave', { + relatedTarget: null, + bubbles: true, + composed: true, + }) + ); + } + await closed; + + expect(popoverTrigger.open).to.be.undefined; // Tooltip should be closed + expect(el.open).to.equal('click'); // Modal should still be open + }); + }); + describe('keyboard navigation into hover content', () => { + it('keeps hover content open when tabbing into interactive overlay content', async () => { + const el = await styledFixture(html` + + + Hover trigger with interactive content + + + + Interactive button + + + + `); + await elementUpdated(el); + + const trigger = el.querySelector('[slot="trigger"]') as Button; + const buttonInPopover = el.querySelector( + '#button-in-popover' + ) as Button; + + // Focus the trigger element + trigger.focus(); + await elementUpdated(el); + + // Wait for hover content to open + await waitUntil( + () => el.open === 'hover', + 'hover content opens on focus', + { timeout: 500 } + ); + + // Hover content should open when trigger receives focus + expect(el.open).to.equal('hover'); + + // Tab into the popover content + await sendTabKey(); + await elementUpdated(el); + + // Wait for focus to move to button in popover + await waitUntil( + () => + buttonInPopover === document.activeElement || + buttonInPopover.contains(document.activeElement), + 'focus moved to button in popover', + { timeout: 500 } + ); + + // Verify focus moved to button in popover + expect( + buttonInPopover === document.activeElement || + buttonInPopover.contains(document.activeElement) + ).to.be.true; + + // Wait beyond the hover delay to ensure content stays open + await new Promise((resolve) => setTimeout(resolve, 400)); + await elementUpdated(el); + + // Hover content should still be open + expect(el.open).to.equal('hover'); + }); + + it('closes hover content after delay when tabbing out of both trigger and content', async () => { + const theme = await fixture(html` + + + + Hover trigger + + + Interactive button + + + + + + `); + await elementUpdated(theme); + + const el = theme.querySelector('overlay-trigger') as OverlayTrigger; + const trigger = el.querySelector('[slot="trigger"]') as Button; + const afterInput = theme.querySelector( + '#after-trigger' + ) as HTMLInputElement; + + // Focus the trigger element + trigger.focus(); + await elementUpdated(el); + await waitUntil( + () => el.open === 'hover', + 'overlay should open on focus', + { timeout: 500 } + ); + + expect(el.open).to.equal('hover'); + + // Tab into the popover content + await sendTabKey(); + await elementUpdated(el); + + expect(el.open).to.equal('hover'); + + // Tab out of the popover content completely + await sendTabKey(); + await elementUpdated(el); + + // Wait for focus to move out completely + await waitUntil( + () => afterInput === document.activeElement, + 'focus moved to input after overlay', + { timeout: 500 } + ); + + // Verify focus moved out + expect(afterInput === document.activeElement).to.be.true; + + // Should still be open initially + expect(el.open).to.equal('hover'); + + // Wait for the hover delay + await new Promise((resolve) => setTimeout(resolve, 400)); + await elementUpdated(el); + + // Now it should be closed + expect(el.open).to.be.undefined; + }); + + it('closes hover content after delay when using Shift+Tab to exit overlay content backwards', async () => { + const theme = await fixture(html` + + + + Hover trigger + + + Interactive button + + + + + + `); + await elementUpdated(theme); + + const el = theme.querySelector('overlay-trigger') as OverlayTrigger; + const trigger = el.querySelector('[slot="trigger"]') as Button; + const beforeInput = theme.querySelector( + '#before-trigger' + ) as HTMLInputElement; + + // Focus the trigger element + trigger.focus(); + await elementUpdated(el); + await waitUntil( + () => el.open === 'hover', + 'overlay should open on focus', + { timeout: 500 } + ); + + expect(el.open).to.equal('hover'); + + // Tab into the popover content + await sendTabKey(); + await elementUpdated(el); + + expect(el.open).to.equal('hover'); + + // Shift+Tab back out of the popover content + await sendShiftTabKey(); + await elementUpdated(el); + + // Wait for focus to return to trigger + await waitUntil( + () => + trigger === document.activeElement || + trigger.contains(document.activeElement), + 'focus returned to trigger', + { timeout: 500 } + ); + + // Should still be open while trigger has focus + expect(el.open).to.equal('hover'); + + // Shift+Tab again to move focus before the trigger + await sendShiftTabKey(); + await elementUpdated(el); + + // Wait for focus to move to input before overlay + await waitUntil( + () => beforeInput === document.activeElement, + 'focus moved to input before overlay', + { timeout: 500 } + ); + + // Verify focus moved out + expect(beforeInput === document.activeElement).to.be.true; + + // Should still be open initially + expect(el.open).to.equal('hover'); + + // Wait for the hover delay + await new Promise((resolve) => setTimeout(resolve, 400)); + await elementUpdated(el); + + // Now it should be closed + expect(el.open).to.be.undefined; + }); + + it('closes hover content on Escape and returns focus to trigger', async () => { + const el = await styledFixture(html` + + Hover trigger + + + Interactive button + + + + `); + await elementUpdated(el); + + const trigger = el.querySelector('[slot="trigger"]') as Button; + const buttonInPopover = el.querySelector( + '#button-in-popover' + ) as Button; + + // Focus the trigger element + trigger.focus(); + await elementUpdated(el); + await waitUntil( + () => el.open === 'hover', + 'overlay should open on focus', + { timeout: 500 } + ); + + expect(el.open).to.equal('hover'); + + // Tab into the popover content + await sendTabKey(); + await elementUpdated(el); + + expect(el.open).to.equal('hover'); + + // Press Escape + const escapeEvent = new KeyboardEvent('keyup', { + code: 'Escape', + bubbles: true, + composed: true, + cancelable: true, + }); + buttonInPopover.dispatchEvent(escapeEvent); + await elementUpdated(el); + + // Wait for hover content to close + await waitUntil( + () => el.open === undefined, + 'hover content closes on Escape', + { timeout: 500 } + ); + + // Hover content should be closed + expect(el.open).to.be.undefined; + + // Focus should return to trigger + expect( + trigger === document.activeElement || + trigger.shadowRoot?.activeElement + ).to.exist; + }); + + it('allows keyboard navigation through multiple interactive elements in hover content', async () => { + const el = await styledFixture(html` + + Hover trigger + + Button 1 + Button 2 + Button 3 + + + `); + await elementUpdated(el); + + const trigger = el.querySelector('[slot="trigger"]') as Button; + + // Focus the trigger element + trigger.focus(); + await elementUpdated(el); + + // Wait for hover content to open + await waitUntil( + () => el.open === 'hover', + 'hover content opens on focus', + { timeout: 500 } + ); + + expect(el.open).to.equal('hover'); + + // Tab through all buttons in popover + await sendTabKey(); + await elementUpdated(el); + expect(el.open).to.equal('hover'); + + await sendTabKey(); + await elementUpdated(el); + expect(el.open).to.equal('hover'); + + await sendTabKey(); + await elementUpdated(el); + expect(el.open).to.equal('hover'); + + // Content should remain open while navigating through interactive elements + expect(el.open).to.equal('hover'); + }); + + it('keeps hover content open when mouse enters after keyboard focus', async () => { + const el = await styledFixture(html` + + Hover trigger + + + Interactive button + + + + `); + await elementUpdated(el); + + const trigger = el.querySelector('[slot="trigger"]') as Button; + + // Focus the trigger element with keyboard + trigger.focus(); + await elementUpdated(el); + + // Wait for hover content to open + await waitUntil( + () => el.open === 'hover', + 'hover content opens on focus', + { timeout: 500 } + ); + + expect(el.open).to.equal('hover'); + + // Tab into the popover content + await sendTabKey(); + await elementUpdated(el); + + expect(el.open).to.equal('hover'); + + // Mouse enters the trigger + trigger.dispatchEvent( + new MouseEvent('pointerenter', { + bubbles: true, + composed: true, + }) + ); + await elementUpdated(el); + + // Content should still be open + expect(el.open).to.equal('hover'); + + // Tab out of popover + await sendTabKey(); + await elementUpdated(el); + + // Should still be open due to pointer interaction + expect(el.open).to.equal('hover'); + }); + }); }); diff --git a/first-gen/packages/picker/stories/picker.stories.ts b/first-gen/packages/picker/stories/picker.stories.ts index db0bd854763..61a5a0f7632 100644 --- a/first-gen/packages/picker/stories/picker.stories.ts +++ b/first-gen/packages/picker/stories/picker.stories.ts @@ -418,6 +418,9 @@ iconsNone.args = { open: true, }; iconsNone.decorators = [isOverlayOpen]; +iconsNone.swc_vrt = { + skip: true, +}; export const iconValue = (args: StoryArgs): TemplateResult => { return html` @@ -478,6 +481,9 @@ iconsOnly.args = { open: true, }; iconsOnly.decorators = [isOverlayOpen]; +iconsOnly.swc_vrt = { + skip: true, +}; export const dynamicIcons = (args: StoryArgs): TemplateResult => { return html` diff --git a/first-gen/packages/picker/test/index.ts b/first-gen/packages/picker/test/index.ts index 3242018f159..61bc05a9fe0 100644 --- a/first-gen/packages/picker/test/index.ts +++ b/first-gen/packages/picker/test/index.ts @@ -29,6 +29,8 @@ import '@spectrum-web-components/dialog/sp-dialog.js'; import '@spectrum-web-components/field-label/sp-field-label.js'; import { FieldLabel } from '@spectrum-web-components/field-label/src/FieldLabel.js'; import type { Icon } from '@spectrum-web-components/icon'; +import '@spectrum-web-components/icons-workflow/icons/sp-icon-copy.js'; +import '@spectrum-web-components/icons-workflow/icons/sp-icon-edit.js'; import type { Menu, MenuItem } from '@spectrum-web-components/menu'; import '@spectrum-web-components/menu/sp-menu-group.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; @@ -2362,4 +2364,175 @@ export function runPickerTests(): void { ).to.be.greaterThan(-1); }); }); + describe('icons attribute', () => { + it('hides icon in button when icons="none"', async () => { + const el = await fixture(html` + + + + Edit + + + + Copy + + + `); + await elementUpdated(el); + + const iconSpan = el.shadowRoot.querySelector( + '#icon' + ) as HTMLElement; + expect(iconSpan).to.not.be.null; + expect(iconSpan.hidden, 'icon span should be hidden').to.be.true; + + // Verify the label is still visible + const labelSpan = el.shadowRoot.querySelector( + '.label' + ) as HTMLElement; + expect(labelSpan).to.not.be.null; + expect( + labelSpan.classList.contains('visually-hidden'), + 'label should be visible' + ).to.be.false; + }); + + it('preserves icon elements in menu items when icons="none"', async () => { + const el = await fixture(html` + + + + Edit + + + + Copy + + + `); + await elementUpdated(el); + + const opened = oneEvent(el, 'sp-opened'); + el.open = true; + await opened; + await elementUpdated(el); + + // Verify icons are present in menu items (icons="none" only affects button display) + const menuItems = el.querySelectorAll('sp-menu-item'); + expect(menuItems.length).to.equal(2); + menuItems.forEach((item, index) => { + const icon = item.querySelector('[slot="icon"]'); + expect(icon, `menu item ${item.value} should have icon`).to.not + .be.null; + const expectedTag = + index === 0 ? 'SP-ICON-EDIT' : 'SP-ICON-COPY'; + expect(icon!.tagName).to.equal(expectedTag); + }); + }); + + it('hides label text when icons="only" and has value', async () => { + const el = await fixture(html` + + + + Edit + + + + Copy + + + `); + await elementUpdated(el); + + const labelSpan = el.shadowRoot.querySelector( + '.label' + ) as HTMLElement; + expect(labelSpan).to.not.be.null; + expect( + labelSpan.classList.contains('visually-hidden'), + 'label should be visually hidden when icons="only" and has value' + ).to.be.true; + + // Verify icon is still visible + const iconSpan = el.shadowRoot.querySelector( + '#icon' + ) as HTMLElement; + expect(iconSpan).to.not.be.null; + expect(iconSpan.hidden, 'icon should be visible').to.be.false; + }); + + it('shows label text when icons="only" but no value selected', async () => { + const el = await fixture(html` + + + + Edit + + + + Copy + + + `); + await elementUpdated(el); + + const labelSpan = el.shadowRoot.querySelector( + '.label' + ) as HTMLElement; + expect(labelSpan).to.not.be.null; + expect( + labelSpan.classList.contains('visually-hidden'), + 'label should be visible when no value selected' + ).to.be.false; + expect( + labelSpan.classList.contains('placeholder'), + 'label should have placeholder class' + ).to.be.true; + }); + + it('updates icon visibility when icons attribute changes', async () => { + const el = await fixture(html` + + + + Edit + + + `); + await elementUpdated(el); + + let iconSpan = el.shadowRoot.querySelector('#icon') as HTMLElement; + expect(iconSpan.hidden, 'icon should be visible initially').to.be + .false; + + // Change to icons="none" + el.icons = 'none'; + await elementUpdated(el); + + iconSpan = el.shadowRoot.querySelector('#icon') as HTMLElement; + expect( + iconSpan.hidden, + 'icon should be hidden after setting icons="none"' + ).to.be.true; + + // Change to icons="only" + el.icons = 'only'; + await elementUpdated(el); + + iconSpan = el.shadowRoot.querySelector('#icon') as HTMLElement; + expect( + iconSpan.hidden, + 'icon should be visible after setting icons="only"' + ).to.be.false; + + const labelSpan = el.shadowRoot.querySelector( + '.label' + ) as HTMLElement; + expect( + labelSpan.classList.contains('visually-hidden'), + 'label should be hidden with icons="only"' + ).to.be.true; + }); + }); } diff --git a/first-gen/packages/status-light/README.md b/first-gen/packages/status-light/README.md index 908364552a7..49a75fe728b 100644 --- a/first-gen/packages/status-light/README.md +++ b/first-gen/packages/status-light/README.md @@ -80,15 +80,11 @@ Status lights come in various semantic and non-semantic variants to convey diffe ```html - - use for default state - +use for default state use for success or approval - - use for error or rejection - +use for error or rejection use for warning or attention needed @@ -110,6 +106,7 @@ Status lights come in various semantic and non-semantic variants to convey diffe magenta status celery status purple status +cyan status ``` diff --git a/first-gen/packages/status-light/src/spectrum-status-light.css b/first-gen/packages/status-light/src/spectrum-status-light.css index 7a37b8242b5..f8555b31897 100644 --- a/first-gen/packages/status-light/src/spectrum-status-light.css +++ b/first-gen/packages/status-light/src/spectrum-status-light.css @@ -79,6 +79,7 @@ --spectrum-statuslight-nonsemantic-purple-color: var(--spectrum-purple-visual-color); --spectrum-statuslight-nonsemantic-fuchsia-color: var(--spectrum-fuchsia-visual-color); --spectrum-statuslight-nonsemantic-magenta-color: var(--spectrum-magenta-visual-color); + min-block-size: var(--mod-statuslight-height, var(--spectrum-statuslight-height)); box-sizing: border-box; font-size: var(--mod-statuslight-font-size, var(--spectrum-statuslight-font-size)); @@ -102,6 +103,7 @@ :host:before { --spectrum-statuslight-spacing-computed-top-to-dot: calc(var(--mod-statuslight-spacing-top-to-dot, var(--spectrum-statuslight-spacing-top-to-dot)) - var(--mod-statuslight-spacing-top-to-label, var(--spectrum-statuslight-spacing-top-to-label))); + content: ""; inline-size: var(--mod-statuslight-dot-size, var(--spectrum-statuslight-dot-size)); block-size: var(--mod-statuslight-dot-size, var(--spectrum-statuslight-dot-size)); @@ -122,7 +124,7 @@ background-color: var(--mod-statuslight-semantic-neutral-color, var(--spectrum-statuslight-semantic-neutral-color)); } -.spectrum-StatusLight--accent:before { +:host([variant="accent"]):before { background-color: var(--mod-statuslight-semantic-accent-color, var(--spectrum-statuslight-semantic-accent-color)); } @@ -174,7 +176,7 @@ background-color: var(--mod-statuslight-nonsemantic-seafoam-color, var(--spectrum-statuslight-nonsemantic-seafoam-color)); } -.spectrum-StatusLight--cyan:before { +:host([variant="cyan"]):before { background-color: var(--mod-statuslight-nonsemantic-cyan-color, var(--spectrum-statuslight-nonsemantic-cyan-color)); } @@ -202,6 +204,7 @@ :host([dir]) { --highcontrast-statuslight-content-color-default: CanvasText; --highcontrast-statuslight-subdued-content-color-default: CanvasText; + forced-color-adjust: none; } diff --git a/first-gen/packages/status-light/stories/status-light.stories.ts b/first-gen/packages/status-light/stories/status-light.stories.ts index e41c437cad6..3120012fdc0 100644 --- a/first-gen/packages/status-light/stories/status-light.stories.ts +++ b/first-gen/packages/status-light/stories/status-light.stories.ts @@ -19,6 +19,7 @@ export default { }; export const s = (): TemplateResult => html` + accent positive negative notice @@ -32,9 +33,11 @@ export const s = (): TemplateResult => html` magenta celery purple + cyan `; export const m = (): TemplateResult => html` + accent positive negative notice @@ -48,9 +51,11 @@ export const m = (): TemplateResult => html` magenta celery purple + cyan `; export const l = (): TemplateResult => html` + accent positive negative notice @@ -64,9 +69,11 @@ export const l = (): TemplateResult => html` magenta celery purple + cyan `; export const XL = (): TemplateResult => html` + accent positive negative notice @@ -80,6 +87,7 @@ export const XL = (): TemplateResult => html` magenta celery purple + cyan `; export const disabledTrue = (): TemplateResult => html` diff --git a/first-gen/tools/grid/README.md b/first-gen/tools/grid/README.md index 77de55b0707..69c082794e4 100644 --- a/first-gen/tools/grid/README.md +++ b/first-gen/tools/grid/README.md @@ -1,32 +1,183 @@ -## Description +## Overview -An `` element displays a virtualized grid of elements built from its `items`, a normalized array of javascript objects, applied to a supplied `renderItem`, a `TemplateResult` returning method. `sp-grid` is a class extension of [`lit-virtualizer`](https://www.npmjs.com/package/@lit-labs/virtualizer/v/0.7.0-pre.2) and as such surfaces all of its underlying methods and events. - -Elements displayed in the grid can be focused via the [roving tabindex](https://www.w3.org/TR/wai-aria-practices-1.2/#kbd_roving_tabindex) that allows the grid to be entered via the `Tab` key and then subsequent elements to be focused via the arrow keys. To inform the `` element what part of the DOM created by the `renderItem` method can be focused, supply a value to `focusableSelector`. Focus will always enter the element list at index 0 of ALL available elements, not just those currently realized to the page. - -Elements rendered via `renderItem` can have their width and height customized by supplying a value for `itemSize` that accepts an object: `{ width: number, height: number }`. You can customize the space between these elements via the `gap` property that accepts a value of `0` or `${number}px`. +An `` element displays a virtualized grid of elements built from its `items`, a normalized array of JavaScript objects, applied to a supplied `renderItem`, a `TemplateResult` returning method. The `` is a class extension of [`lit-virtualizer`](https://www.npmjs.com/package/@lit-labs/virtualizer/v/0.7.0-pre.2) and as such surfaces all of its underlying methods and events. ### Usage [![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/grid?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/grid) [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/grid?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/grid) -``` +```bash yarn add @spectrum-web-components/grid ``` Import the side effectful registration of `` via: -``` +```javascript import '@spectrum-web-components/grid/sp-grid.js'; ``` When looking to leverage the `Grid` base class as a type and/or for extension purposes, do so via: -``` +```javascript import { Grid } from '@spectrum-web-components/grid'; ``` +### Anatomy + +The grid consists of several key parts: + +- A virtualized container that efficiently renders only visible items +- Individual grid items rendered via the `renderItem` method +- A roving tabindex system for keyboard navigation +- Configurable layout properties for item sizing and spacing + +```html + + +``` + +### Options + +#### Properties + +The grid supports several properties for configuration: + +##### Items + +The `items` property accepts a normalized array of JavaScript objects that will be rendered in the grid: + +```javascript +const grid = document.querySelector('sp-grid'); +grid.items = [ + { name: 'Card 1', date: '10/15/18' }, + { name: 'Card 2', date: '10/16/18' }, + { name: 'Card 3', date: '10/17/18' }, +]; +``` + +##### Render Item + +The `renderItem` property is a function that receives an item, index, and selected state, and returns a DOM element to be rendered: + +```javascript +grid.renderItem = (item, index, selected) => { + const card = document.createElement('sp-card'); + card.heading = item.name; + card.selected = selected; + return card; +}; +``` + +##### Item Size + +Control the dimensions of each grid item using the `itemSize` property, which accepts an object with `width` and `height` properties: + +```javascript +grid.itemSize = { + width: 200, + height: 300, +}; +``` + +##### Gap + +Customize the space between grid items via the `gap` property, which accepts a value of `0` or `${number}px`: + +```javascript +grid.gap = '10px'; +``` + +##### Focusable Selector + +Specify which element within the rendered item can receive focus by providing a CSS selector to the `focusableSelector` property: + +```javascript +grid.focusableSelector = 'sp-card'; +``` + +This informs the `` element what part of the DOM created by the `renderItem` method can be focused via keyboard navigation. + +### Behaviors + +#### Virtualization + +The `` uses virtualization to efficiently render large lists of items. Only the items visible in the viewport (plus a small buffer) are rendered to the DOM, which significantly improves performance for large datasets. As you scroll, the grid dynamically updates which items are rendered. + +#### Focus Management + +Elements displayed in the grid can be focused via the [roving tabindex](https://www.w3.org/TR/wai-aria-practices-1.2/#kbd_roving_tabindex) pattern. This allows the grid to be entered via the Tab key and then subsequent elements to be focused via the arrow keys. + +Focus will always enter the element list at index 0 of all available elements, not just those currently realized to the page. + +#### Selection Management + +The grid supports selection of items. You can maintain a `selectedItems` array and update it based on user interactions: + +```javascript +grid.selectedItems = []; + +grid.renderItem = (item, index, selected) => { + const card = document.createElement('sp-card'); + card.selected = grid.selectedItems.includes(card.value); + card.addEventListener('change', () => { + if (grid.selectedItems.includes(card.value)) { + grid.selectedItems = grid.selectedItems.filter( + (item) => item !== card.value + ); + } else { + grid.selectedItems.push(card.value); + } + }); + return card; +}; +``` + +### Accessibility + +The `` is designed with accessibility in mind and follows ARIA best practices for grid patterns. + +#### Keyboard Navigation + +The grid supports keyboard navigation through the roving tabindex pattern: + +- Tab: Enter the grid (focus moves to first item) +- Arrow Keys: Navigate between grid items +- Focus always starts at index 0 of all available elements + +#### ARIA Attributes + +When implementing a grid, ensure you provide appropriate ARIA attributes for screen reader support: + +```javascript +grid.role = 'grid'; +grid.ariaLabel = 'Select images'; +grid.ariaMultiSelectable = 'true'; +grid.ariaRowCount = `${grid.items.length}`; +grid.ariaColCount = 1; +``` + +Additionally, each rendered item should have appropriate ARIA attributes: + +```javascript +card.role = 'row'; +card.label = `Card Heading ${index}`; +card.ariaSelected = grid.selectedItems.includes(card.value); +card.ariaRowIndex = `${index + 1}`; +``` + +#### Focusable Elements + +Use the `focusableSelector` property to specify which elements within each grid item should receive focus. This ensures that keyboard users can navigate to interactive elements within the grid. + ## Example To interact with a fully accessible grid example, reference our [Grid Storybook](https://opensource.adobe.com/spectrum-web-components/storybook/index.html?path=/story/grid/) documentation. diff --git a/first-gen/tools/shared/README.md b/first-gen/tools/shared/README.md index a1910738925..d8a7fcecf9a 100644 --- a/first-gen/tools/shared/README.md +++ b/first-gen/tools/shared/README.md @@ -1,6 +1,6 @@ -## Description +## Overview -Shared mixins, tools, etc. that support developing Spectrum Web Components. +The `@spectrum-web-components/shared` package provides essential base classes, mixins, and utilities that support developing Spectrum Web Components. This package contains foundational tools for focus management, slot observation, accessibility enhancements, and other common functionality used across the component library. ### Usage @@ -8,121 +8,222 @@ Shared mixins, tools, etc. that support developing Spectrum Web Components. [![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/shared?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/shared) ```bash -npm install @spectrum-web-components/shared +yarn add @spectrum-web-components/shared ``` -Individual base classes and mixins can be imported as follows: +Individual base classes, mixins, and utilities can be imported as follows: ```javascript import { Focusable, FocusVisiblePolyfillMixin, getActiveElement, + getDeepElementFromPoint, LikeAnchor, + ObserveSlotPresence, ObserveSlotText, } from '@spectrum-web-components/shared'; ``` -### getDeepElementFromPoint +#### Exported Classes, Mixins, and Utilities + +
+ + + Export + Type + Description + + + + getActiveElement() + Utility + Find the active element, including shadow DOM + + + getDeepElementFromPoint() + Utility + Deepest element at coordinates + + + Focusable + Base class + Focus management for custom elements + + + LikeAnchor + Mixin + Anchor-like properties and rendering + + + FocusVisiblePolyfillMixin + Mixin + Polyfill for :focus-visible support + + + ObserveSlotPresence + Mixin + Observe presence of slotted content + + + ObserveSlotText + Mixin + Observe text changes in slots + + + +
+ +### Utilities + +#### getDeepElementFromPoint + +The `getDeepElementFromPoint` method allows you to obtain the deepest possible element at given coordinates on the current page. The method will step into any available `shadowRoot`s until it reaches the first element with no `shadowRoot` or no children available at the given coordinates. + +**When to use:** Use this when you need to find the actual target element at specific coordinates, especially when working with shadow DOM where `document.elementFromPoint()` might not give you the deepest element. -The `getDeepElementFromPoint` method allows you to obtain the deepest possible element at a given coordinates on the current page. The method will step into any available `shadowRoot`s until it reaches the first element with no `shadowRoot` or no children available at the given coordinates. +```javascript +import { getDeepElementFromPoint } from '@spectrum-web-components/shared'; + +const element = getDeepElementFromPoint(x, y); +``` + +#### getActiveElement + +Use this helper to find an `activeElement` in your component. + +**When to use:** Use this when you need to determine which element currently has focus, especially in components with shadow DOM where `document.activeElement` might not give you the correct element. + +```javascript +import { getActiveElement } from '@spectrum-web-components/shared'; + +const activeEl = getActiveElement(this); +``` + +### Base classes + +#### Focusable -### Focusable +The `Focusable` subclass of `SpectrumElement` adds helper methods and lifecycle coverage to support passing focus to a container element inside of a custom element. The `Focusable` base class handles `tabindex` setting into shadowed elements automatically and is based heavily on the [aybolit delegate-focus-mixin](https://github.com/web-padawan/aybolit/blob/master/packages/core/src/mixins/delegate-focus-mixin.js). -The `Focusable` subclass of `LitElement` adds some helpers method and lifecycle coverage in order to support passing focus to a container element inside of a custom element. The Focusable base class handles tabindex setting into shadowed elements automatically and is based heavily on the [aybolit delegate-focus-mixin](https://github.com/web-padawan/aybolit/blob/master/packages/core/src/mixins/delegate-focus-mixin.js). +**When to use:** Use this base class when creating custom elements that need to delegate focus to an internal element (like a button or input) while maintaining proper tabindex management and accessibility. ```javascript import { Focusable } from '@spectrum-web-components/shared'; -import { html } from 'lit-element'; +import { html, TemplateResult } from '@spectrum-web-components/base'; class FocusableButton extends Focusable { - public static override get styles(): CSSResultArray { - return [...super.styles]; - } public get focusElement(): HTMLElement { return this.shadowRoot.querySelector('#button') as HTMLElement; } protected override render(): TemplateResult { return html` - `; } } ``` -### FocusVisiblePolyfillMixin +### Mixins + +#### LikeAnchor -Use this mixin if you would like to leverage `:focus-visible` based selectors in your CSS. [Learn more about the polyfill that powers this.](https://www.npmjs.com/package/focus-visible) +Mix `download`, `label`, `href`, `target`, `rel`, and `referrerpolicy` properties into your element to allow it to act more like an `HTMLAnchorElement`. It also provides a `renderAnchor` method for rendering anchor elements. -### getActiveElement +**When to use:** Use this mixin when creating custom elements that need to behave like links or buttons with link-like functionality, such as action buttons that can navigate to URLs. -Use this helper to find an `activeElement` in your component. [Learn more about tracking active elements over shadow DOM boundaries.](https://dev.to/open-wc/mind-the-document-activeelement-2o9a) +```javascript +import { LikeAnchor } from '@spectrum-web-components/shared'; +import { ReactiveElement, html, TemplateResult } from '@spectrum-web-components/base'; + +class MyLinkElement extends LikeAnchor(ReactiveElement) { + protected render(): TemplateResult { + return this.renderAnchor({ + id: 'my-anchor', + className: 'my-link', + anchorContent: html``, + }); + } +} +``` -### LikeAnchor +#### FocusVisiblePolyfillMixin -Mix `download`, `label`, `href`, and `target` properties into your element to allow it to act more like an `HTMLAnchorElement`. +This mixin coordinates with the focus-visible polyfill to ensure proper behavior across browsers. [Learn more about the polyfill that powers this](https://www.npmjs.com/package/focus-visible). -### ObserveSlotPresence +**When to use:** Use this mixin when you need to leverage `:focus-visible`-based selectors in your CSS. + +```javascript +import { FocusVisiblePolyfillMixin } from '@spectrum-web-components/shared'; + +class MyElement extends FocusVisiblePolyfillMixin(HTMLElement) { + // Your element now supports `:focus-visible` selectors and coordinates with the polyfill +} +``` + +#### ObserveSlotPresence When working with styles that are driven by the conditional presence of ``s in a component's shadow DOM, you will need to track whether light DOM that is meant for that slot exists. Use the `ObserveSlotPresence` mixin to target specific light DOM to observe the presence of and trigger `this.requestUpdate()` calls when content fulfilling that selector comes in and out of availability. +**When to use:** Use this mixin when you need to conditionally render UI or apply styles based on whether specific slotted content is present. Common use cases include showing/hiding labels, icons, or wrapper elements. + ```javascript import { ObserveSlotPresence } from '@spectrum-web-components/shared'; -import { LitElement, html } from 'lit-element'; -class ObserveSlotPresenceElement extends ObserveSlotPresence(LitElement, '[slot="conditional-slot"]') { - // translate the mixin properties into locally understandable language +import { ReactiveElement, html, TemplateResult } from '@spectrum-web-components/base'; + +class ObserveSlotPresenceElement extends ObserveSlotPresence( + ReactiveElement, + '[slot="conditional-slot"]' +) { + // Translate the mixin properties into locally understandable language protected get hasConditionalSlotContent() { return this.slotContentIsPresent; } + protected override render(): TemplateResult { return html` - `; } + protected updated(): void { console.log(this.slotContentIsPresent); // => true when
} } + customElements.define('observing-slot-presence-element', ObserveSlotPresenceElement); ``` -### ObserveSlotText +#### ObserveSlotText -When working with ``s and their `slotchange` event, you will have the opportunity to capture when the nodes and/or elements in your element are added or removed. However, if the `textContent` of a text node changes, you will not receive the `slotchange` event because the slot hasn't actually received new nodes and/or elements in the exchange. When working with a lit-html binding `${text}` that means you will not receive a `slotchange` event when the value of `text` goes from `text = ''` to `text = 'something'` or the other way. In these cases the `ObserveSlotText` can be leverages to apply a mutation observe onto your element that tracks `characterData` mutations so that you can resspond as desired. +When working with ``s and their `slotchange` event, you will have the opportunity to capture when the nodes and/or elements in your element are added or removed. However, if the `textContent` of a text node changes, you will not receive the `slotchange` event because the slot hasn't actually received new nodes and/or elements in the exchange. When working with a lit-html binding `${text}` that means you will not receive a `slotchange` event when the value of `text` goes from `text = ''` to `text = 'something'` or the other way. In these cases the `ObserveSlotText` can be leveraged to apply a mutation observer onto your element that tracks `characterData` mutations so that you can respond as desired. + +**When to use:** Use this mixin when you need to detect changes in text content within slots, especially for dynamic text that changes after the initial render. Useful for components that need to react to text content changes for layout or styling purposes. ```javascript import { ObserveSlotText } from '@spectrum-web-components/shared'; -import { LitElement, html } from 'lit-element'; +import { ReactiveElement, html, TemplateResult } from '@spectrum-web-components/base'; -class ObserveSlotTextElement extends ObserveSlotText(LitElement, '#observing-slot') { +class ObserveSlotTextElement extends ObserveSlotText(ReactiveElement) { protected override render(): TemplateResult { return html` - `; } + protected updated(): void { console.log(this.slotHasContent); // => true when Text } @@ -130,3 +231,21 @@ class ObserveSlotTextElement extends ObserveSlotText(LitElement, '#observing-slo customElements.define('observing-slot-text-element', ObserveSlotTextElement); ``` + +For named slots, you can supply the name of the slot as the second argument: + +```javascript +class ObserveSlotTextElement extends ObserveSlotText(ReactiveElement, 'button-label') { + protected override render(): TemplateResult { + return html` + + `; + } +} +```