From 89b00b72f63381622f149e57ea8092e03bb38c62 Mon Sep 17 00:00:00 2001 From: 5t3ph Date: Wed, 27 May 2026 10:00:13 -0500 Subject: [PATCH 01/10] feat(tooltip): add tests --- .../core/components/tooltip/Tooltip.base.ts | 9 + 2nd-gen/packages/swc/.storybook/preview.ts | 7 + .../swc/components/tooltip/test/.gitkeep | 0 .../tooltip/test/tooltip.a11y.spec.ts | 158 +++++ .../components/tooltip/test/tooltip.test.ts | 580 ++++++++++++++++++ 5 files changed, 754 insertions(+) delete mode 100644 2nd-gen/packages/swc/components/tooltip/test/.gitkeep create mode 100644 2nd-gen/packages/swc/components/tooltip/test/tooltip.a11y.spec.ts create mode 100644 2nd-gen/packages/swc/components/tooltip/test/tooltip.test.ts diff --git a/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts b/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts index 75c4d9a0165..94d0dc50e11 100644 --- a/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts +++ b/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts @@ -248,6 +248,13 @@ export abstract class TooltipBase extends SpectrumElement { this.dispatchAfterEvent(this.open); }; + // Allows Escape behavior to be testable, does not interfere with native popover dismissal + private readonly handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && this.open) { + this.open = false; + } + }; + protected override updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (changedProperties.has('open')) { @@ -269,6 +276,7 @@ export abstract class TooltipBase extends SpectrumElement { this.addEventListener('beforetoggle', this.handleBeforeToggle); this.addEventListener('toggle', this.handleToggle); this.addEventListener('transitionend', this.handleTransitionEnd); + document.addEventListener('keydown', this.handleKeyDown); } public override disconnectedCallback(): void { @@ -276,5 +284,6 @@ export abstract class TooltipBase extends SpectrumElement { this.removeEventListener('beforetoggle', this.handleBeforeToggle); this.removeEventListener('toggle', this.handleToggle); this.removeEventListener('transitionend', this.handleTransitionEnd); + document.removeEventListener('keydown', this.handleKeyDown); } } diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 49abc731597..a52fb42a930 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -248,6 +248,7 @@ const preview = { 'Tools vs packages', 'Writing migration guides', 'Focus management', + 'Changelog strategy', ], 'Style guide', [ @@ -390,6 +391,11 @@ const preview = { ['Rendering and styling migration analysis'], 'Field label', ['Rendering and styling migration analysis'], + 'Grid', + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Help text', ['Rendering and styling migration analysis'], 'Illustrated message', @@ -436,6 +442,7 @@ const preview = { 'Popover', [ 'Accessibility migration analysis', + 'Migration plan', 'Rendering and styling migration analysis', ], 'Progress bar', diff --git a/2nd-gen/packages/swc/components/tooltip/test/.gitkeep b/2nd-gen/packages/swc/components/tooltip/test/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/2nd-gen/packages/swc/components/tooltip/test/tooltip.a11y.spec.ts b/2nd-gen/packages/swc/components/tooltip/test/tooltip.a11y.spec.ts new file mode 100644 index 00000000000..0eb6157de73 --- /dev/null +++ b/2nd-gen/packages/swc/components/tooltip/test/tooltip.a11y.spec.ts @@ -0,0 +1,158 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, test } from '@playwright/test'; + +import { gotoStory } from '../../../utils/a11y-helpers.js'; + +/** + * Accessibility tests for Tooltip component (2nd Generation) + * + * ARIA snapshot tests validate the accessibility tree structure. + * aXe WCAG compliance and color contrast validation are run via + * test-storybook (see .storybook/test-runner.ts). Both are included + * in the `test:a11y` command. + */ + +test.describe('Tooltip - ARIA Snapshots', () => { + test('closed tooltip is hidden from accessibility tree', async ({ page }) => { + const root = await gotoStory( + page, + 'components-tooltip--overview', + 'swc-button' + ); + // The trigger button is accessible; the closed popover is hidden from the tree. + await expect(root).toMatchAriaSnapshot(` + - button "Open" + `); + }); + + test('open tooltip exposes role="tooltip" in accessibility tree', async ({ + page, + }) => { + await gotoStory(page, 'components-tooltip--overview', 'swc-button'); + + // Open the tooltip programmatically (HoverController not yet wired). + await page.evaluate(() => { + const tooltip = document.querySelector('swc-tooltip') as HTMLElement & { + open: boolean; + }; + if (tooltip) { + tooltip.open = true; + } + }); + + // Wait for the popover to appear in the top layer. + await page.waitForFunction(() => + document.querySelector('swc-tooltip')?.matches(':popover-open') + ); + + const root = page.locator('#storybook-root'); + await expect(root).toMatchAriaSnapshot(` + - button "Open" + - tooltip "Save your changes" + `); + }); + + test('tooltip is removed from accessibility tree when closed', async ({ + page, + }) => { + await gotoStory(page, 'components-tooltip--overview', 'swc-button'); + + // Open then close via the property. + await page.evaluate(() => { + const tooltip = document.querySelector('swc-tooltip') as HTMLElement & { + open: boolean; + }; + if (tooltip) { + tooltip.open = true; + } + }); + await page.waitForFunction(() => + document.querySelector('swc-tooltip')?.matches(':popover-open') + ); + + await page.evaluate(() => { + const tooltip = document.querySelector('swc-tooltip') as HTMLElement & { + open: boolean; + }; + if (tooltip) { + tooltip.open = false; + } + }); + await page.waitForFunction( + () => !document.querySelector('swc-tooltip')?.matches(':popover-open') + ); + + const root = page.locator('#storybook-root'); + await expect(root).toMatchAriaSnapshot(` + - button "Open" + `); + }); + + test('Escape closes an open tooltip', async ({ page }) => { + await gotoStory(page, 'components-tooltip--overview', 'swc-button'); + + await page.evaluate(() => { + const tooltip = document.querySelector('swc-tooltip') as HTMLElement & { + open: boolean; + }; + if (tooltip) { + tooltip.open = true; + } + }); + await page.waitForFunction(() => + document.querySelector('swc-tooltip')?.matches(':popover-open') + ); + + await page.keyboard.press('Escape'); + + await page.waitForFunction( + () => !document.querySelector('swc-tooltip')?.matches(':popover-open') + ); + + const open = await page.evaluate( + () => (document.querySelector('swc-tooltip') as { open?: boolean })?.open + ); + expect(open, 'tooltip.open is false after Escape').toBe(false); + }); + + test('all variant triggers are accessible', async ({ page }) => { + const root = await gotoStory( + page, + 'components-tooltip--variants', + 'swc-button' + ); + await expect(root).toMatchAriaSnapshot(` + - button "Save" + - button "Upload" + - button "Delete" + `); + }); + + test('all placement triggers are accessible', async ({ page }) => { + const root = await gotoStory( + page, + 'components-tooltip--placements', + 'swc-button' + ); + // Each placement renders a separate trigger button. + await expect(root).toMatchAriaSnapshot(` + - button "top" + - button "right" + - button "end" + - button "bottom" + - button "left" + - button "start" + `); + }); +}); diff --git a/2nd-gen/packages/swc/components/tooltip/test/tooltip.test.ts b/2nd-gen/packages/swc/components/tooltip/test/tooltip.test.ts new file mode 100644 index 00000000000..f02c9359760 --- /dev/null +++ b/2nd-gen/packages/swc/components/tooltip/test/tooltip.test.ts @@ -0,0 +1,580 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html } from 'lit'; +import { expect, userEvent } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { Tooltip } from '@adobe/spectrum-wc/tooltip'; + +import '@adobe/spectrum-wc/components/button/swc-button.js'; +import '@adobe/spectrum-wc/components/tooltip/swc-tooltip.js'; + +import { + TOOLTIP_PLACEMENTS, + TOOLTIP_VARIANTS, +} from '../../../../core/components/tooltip/Tooltip.types.js'; +import { + getComponent, + getComponents, + withWarningSpy, +} from '../../../utils/test-utils.js'; +import meta, { + Overview, + Placements, + Variants, +} from '../stories/tooltip.stories.js'; + +// This file defines dev-only test stories that reuse the main story metadata. +export default { + ...meta, + title: 'Tooltip/Tests', + parameters: { + ...meta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +// Type alias for elements with ariaDescribedByElements (available in Chrome 135+, Firefox 136+, Safari 16.4+). +type AriaRelatable = Element & { ariaDescribedByElements: Element[] | null }; + +// Awaits a DOM event dispatched on the given element, resolving with the event object. +const waitForEvent = ( + el: EventTarget, + eventName: string +): Promise => + new Promise((resolve) => { + el.addEventListener(eventName, (event) => resolve(event as T), { + once: true, + }); + }); + +// ────────────────────────────────────────────────────────────── +// TEST: Defaults +// ────────────────────────────────────────────────────────────── + +export const OverviewTest: Story = { + ...Overview, + play: async ({ canvasElement, step }) => { + const tooltip = await getComponent(canvasElement, 'swc-tooltip'); + + await step('renders expected default property values', async () => { + expect(tooltip.variant, 'default variant is neutral').toBe('neutral'); + expect(tooltip.placement, 'default placement is top').toBe('top'); + expect(tooltip.open, 'default open is false').toBe(false); + expect(tooltip.manual, 'default manual is false').toBe(false); + expect(tooltip.delay, 'default delay is 1500').toBe(1500); + }); + + await step('sets role="tooltip" on the host element', async () => { + expect(tooltip.getAttribute('role'), 'host has role="tooltip"').toBe( + 'tooltip' + ); + }); + + await step('sets popover="auto" on the host element', async () => { + expect(tooltip.getAttribute('popover'), 'host has popover="auto"').toBe( + 'auto' + ); + }); + + await step('renders text content in default slot', async () => { + expect( + tooltip.textContent?.trim(), + 'default slot has text content' + ).toBeTruthy(); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Properties / Attributes +// ────────────────────────────────────────────────────────────── + +export const PropertyMutationTest: Story = { + ...Overview, + play: async ({ canvasElement, step }) => { + const tooltip = await getComponent(canvasElement, 'swc-tooltip'); + + await step('variant reflects to attribute after mutation', async () => { + tooltip.variant = 'informative'; + await tooltip.updateComplete; + expect( + tooltip.getAttribute('variant'), + 'variant attribute is informative after mutation' + ).toBe('informative'); + + tooltip.variant = 'negative'; + await tooltip.updateComplete; + expect( + tooltip.getAttribute('variant'), + 'variant attribute is negative after second mutation' + ).toBe('negative'); + + tooltip.variant = 'neutral'; + await tooltip.updateComplete; + expect( + tooltip.getAttribute('variant'), + 'variant attribute is neutral after third mutation' + ).toBe('neutral'); + }); + + await step('placement reflects to attribute after mutation', async () => { + tooltip.placement = 'bottom'; + await tooltip.updateComplete; + expect( + tooltip.getAttribute('placement'), + 'placement attribute is bottom after mutation' + ).toBe('bottom'); + + tooltip.placement = 'start'; + await tooltip.updateComplete; + expect( + tooltip.getAttribute('placement'), + 'placement attribute is start after mutation' + ).toBe('start'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Open / close state +// ────────────────────────────────────────────────────────────── + +export const OpenCloseTest: Story = { + render: () => html` + Toggle + + Tooltip text + + `, + play: async ({ canvasElement, step }) => { + const tooltip = await getComponent(canvasElement, 'swc-tooltip'); + + await step('open=true reflects [open] attribute on host', async () => { + tooltip.open = true; + await tooltip.updateComplete; + expect( + tooltip.hasAttribute('open'), + 'open attribute is present when open=true' + ).toBe(true); + }); + + await step('open=false removes [open] attribute from host', async () => { + tooltip.open = false; + await tooltip.updateComplete; + expect( + tooltip.hasAttribute('open'), + 'open attribute is absent when open=false' + ).toBe(false); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Lifecycle events +// ────────────────────────────────────────────────────────────── + +export const LifecycleEventsTest: Story = { + render: () => html` + Open + + Tooltip content + + `, + play: async ({ canvasElement, step }) => { + const tooltip = await getComponent(canvasElement, 'swc-tooltip'); + + await step('swc-open fires when tooltip is opened', async () => { + const openPromise = waitForEvent(tooltip, 'swc-open'); + tooltip.open = true; + await openPromise; + tooltip.open = false; + await tooltip.updateComplete; + }); + + await step('swc-close fires when tooltip is closed', async () => { + tooltip.open = true; + await tooltip.updateComplete; + + const closePromise = waitForEvent(tooltip, 'swc-close'); + tooltip.open = false; + await closePromise; + }); + + await step('swc-after-open fires after tooltip fully opens', async () => { + const afterOpenPromise = waitForEvent(tooltip, 'swc-after-open'); + tooltip.open = true; + await afterOpenPromise; + tooltip.open = false; + await tooltip.updateComplete; + }); + + await step('swc-after-close fires after tooltip fully closes', async () => { + tooltip.open = true; + await tooltip.updateComplete; + + const afterClosePromise = waitForEvent(tooltip, 'swc-after-close'); + tooltip.open = false; + await afterClosePromise; + }); + + await step('events bubble and are composed', async () => { + let bubbled = false; + const handler = () => { + bubbled = true; + }; + canvasElement.addEventListener('swc-open', handler, { once: true }); + tooltip.open = true; + await waitForEvent(tooltip, 'swc-open'); + expect(bubbled, 'swc-open bubbled to canvas').toBe(true); + canvasElement.removeEventListener('swc-open', handler); + tooltip.open = false; + await tooltip.updateComplete; + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Escape closes tooltip +// ────────────────────────────────────────────────────────────── + +export const EscapeClosesTest: Story = { + render: () => html` + Open + + Press Escape to close + + `, + play: async ({ canvasElement, step }) => { + const tooltip = await getComponent(canvasElement, 'swc-tooltip'); + + await step('Escape closes the open tooltip', async () => { + tooltip.open = true; + await waitForEvent(tooltip, 'swc-open'); + expect(tooltip.open, 'tooltip is open before Escape').toBe(true); + + const closePromise = waitForEvent(tooltip, 'swc-close'); + await userEvent.keyboard('{Escape}'); + await closePromise; + + expect(tooltip.open, 'tooltip is closed after Escape').toBe(false); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: ARIA wiring — native trigger +// ────────────────────────────────────────────────────────────── + +export const AriaWiringNativeTest: Story = { + render: () => html` + + + Changes will be saved + + `, + play: async ({ canvasElement, step }) => { + const tooltip = await getComponent(canvasElement, 'swc-tooltip'); + const trigger = canvasElement.querySelector( + '#tt-native-trigger' + ) as AriaRelatable; + + await step( + 'sets ariaDescribedByElements on native trigger when open=true', + async () => { + tooltip.open = true; + await tooltip.updateComplete; + + const elements = trigger.ariaDescribedByElements ?? []; + expect(elements, 'ariaDescribedByElements contains tooltip').toContain( + tooltip + ); + } + ); + + await step( + 'removes ariaDescribedByElements from native trigger when open=false', + async () => { + tooltip.open = false; + await tooltip.updateComplete; + + const elements = trigger.ariaDescribedByElements ?? []; + expect( + elements, + 'ariaDescribedByElements no longer contains tooltip' + ).not.toContain(tooltip); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: ARIA wiring — SWC component trigger (shadow root with @@ -291,7 +263,7 @@ export const AriaWiringNativeTest: Story = { ) as AriaRelatable; await step( - 'sets ariaDescribedByElements on native trigger when open=true', + 'sets ariaDescribedByElements on a native trigger when open is true', async () => { tooltip.open = true; await tooltip.updateComplete; @@ -304,7 +276,7 @@ export const AriaWiringNativeTest: Story = { ); await step( - 'removes ariaDescribedByElements from native trigger when open=false', + 'removes ariaDescribedByElements from a native trigger when open is false', async () => { tooltip.open = false; await tooltip.updateComplete; @@ -319,10 +291,6 @@ export const AriaWiringNativeTest: Story = { }, }; -// ────────────────────────────────────────────────────────────── -// TEST: ARIA wiring — SWC component trigger (shadow root with + + Favorite + + `, + play: async ({ canvasElement, step }) => { + const tooltip = await getComponent(canvasElement, 'swc-tooltip'); + const trigger = canvasElement.querySelector('#tt-labeling-trigger')!; + + await step( + 'sets ariaLabelledByElements (not ariaDescribedByElements) when labeling is set', + async () => { + tooltip.open = true; + await tooltip.updateComplete; + + expect( + trigger.ariaLabelledByElements ?? [], + 'ariaLabelledByElements contains tooltip when labeling is set' + ).toContain(tooltip); + + expect( + trigger.ariaDescribedByElements ?? [], + 'ariaDescribedByElements does not contain tooltip when labeling is set' + ).not.toContain(tooltip); + } + ); + + await step( + 'clears ariaLabelledByElements from trigger when closed', + async () => { + tooltip.open = false; + await tooltip.updateComplete; + + expect( + trigger.ariaLabelledByElements ?? [], + 'ariaLabelledByElements no longer contains tooltip after close' + ).not.toContain(tooltip); + } + ); + + await step( + 'switches to ariaDescribedByElements when labeling is removed while open', + async () => { + tooltip.open = true; + await tooltip.updateComplete; + + tooltip.labeling = false; + await tooltip.updateComplete; + + expect( + trigger.ariaDescribedByElements ?? [], + 'ariaDescribedByElements contains tooltip after labeling removed' + ).toContain(tooltip); + + expect( + trigger.ariaLabelledByElements ?? [], + 'ariaLabelledByElements does not contain tooltip after labeling removed' + ).not.toContain(tooltip); + + tooltip.open = false; + await tooltip.updateComplete; + } + ); + }, +}; + // ────────────────────────────────────────────────────────────── // TEST: Variants / States // ────────────────────────────────────────────────────────────── diff --git a/2nd-gen/packages/swc/stylesheets/global/global-button.css b/2nd-gen/packages/swc/stylesheets/global/global-button.css index a586afbb294..95c9215c062 100644 --- a/2nd-gen/packages/swc/stylesheets/global/global-button.css +++ b/2nd-gen/packages/swc/stylesheets/global/global-button.css @@ -16,64 +16,80 @@ @layer swc-global-elements {.swc-Button, .swc-Button * { box-sizing: border-box; -}.swc-Button { +} + +.swc-Button { -webkit-tap-highlight-color: transparent; display: inline-flex; position: relative; + gap: var(--swc-button-gap, token("text-to-visual-100")); align-items: center; justify-content: center; - margin: 0; - text-transform: none; - text-decoration: none; - border-style: solid; - overflow: visible; - user-select: none; - --_swc-button-border-width: token("border-width-200"); - --_swc-button-min-block-size: var(--swc-button-min-block-size, token("component-height-100")); - - gap: var(--swc-button-gap, token("text-to-visual-100")); max-inline-size: var(--swc-button-max-inline-size, inherit); min-block-size: var(--_swc-button-min-block-size); padding-block: calc(var(--swc-button-padding-vertical, token("component-padding-vertical-100")) - var(--_swc-button-border-width)); padding-inline: calc(var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100")) - var(--_swc-button-border-width)); + margin: 0; font-family: token("sans-serif-font"); font-size: var(--swc-button-font-size, token("font-size-100")); font-weight: token("bold-font-weight"); line-height: round(down, calc(token("line-height-100") * 1em), 1px); color: var(--swc-button-content-color-default, token("gray-25")); + text-transform: none; + text-decoration: none; background-color: var(--swc-button-background-color-default, token("neutral-background-color-default")); border-color: var(--swc-button-border-color-default, transparent); + border-style: solid; border-width: var(--_swc-button-border-width); border-radius: var(--swc-button-border-radius, calc(var(--_swc-button-min-block-size) / 2)); + overflow: visible; + user-select: none; transition-timing-function: token("animation-ease-in-out"); transition-duration: token("animation-duration-100"); transition-property: outline, border-color, color, background-color, transform; -}.swc-Button:focus-visible { - outline: token("focus-indicator-thickness") solid var(--swc-button-focus-indicator-color, token("focus-indicator-color")); - outline-offset: token("focus-indicator-gap"); + + --_swc-button-border-width: token("border-width-200"); + --_swc-button-min-block-size: var(--swc-button-min-block-size, token("component-height-100")); +} + +.swc-Button:focus-visible { color: var(--swc-button-content-color-focus, token("gray-25")); background-color: var(--swc-button-background-color-focus, token("neutral-background-color-key-focus")); border-color: var(--swc-button-border-color-focus, transparent); -}.swc-Button:disabled:is(*, :hover) { + outline: token("focus-indicator-thickness") solid var(--swc-button-focus-indicator-color, token("focus-indicator-color")); + outline-offset: token("focus-indicator-gap"); +} + +.swc-Button:disabled:is(*, :hover) { color: var(--swc-button-content-color-disabled, token("disabled-content-color")); background-color: var(--swc-button-background-color-disabled, token("disabled-background-color")); border-color: var(--swc-button-border-color-disabled, transparent); transform: none; -}.swc-Button:hover { +} + +.swc-Button:hover { color: var(--swc-button-content-color-hover, token("gray-25")); background-color: var(--swc-button-background-color-hover, token("neutral-background-color-hover")); border-color: var(--swc-button-border-color-hover, transparent); -}.swc-Button:active { +} + +.swc-Button:active { color: var(--swc-button-content-color-down, token("gray-25")); background-color: var(--swc-button-background-color-down, token("neutral-background-color-down")); border-color: var(--swc-button-border-color-down, transparent); transform: var(--swc-button-down-state-transform, perspective(var(--_swc-button-min-block-size)) translate3d(0, 0, token("component-size-difference-down"))); will-change: transform; -}.swc-Button-label { +} + +.swc-Button-label { text-align: center; -}.swc-Button--hasIcon .swc-Button-label { +} + +.swc-Button--hasIcon .swc-Button-label { text-align: start; -}.swc-Button-icon, +} + +.swc-Button-icon, .swc-Button-pendingSpinner { --_swc-button-icon-size: var(--swc-button-icon-size, token("workflow-icon-size-100")); --_swc-button-icon-block-size: var(--swc-button-icon-block-size, var(--_swc-button-icon-size)); @@ -84,10 +100,14 @@ block-size: var(--_swc-button-icon-block-size); margin-block: calc((var(--_swc-button-icon-block-size) - 1lh) / 2 * -1); margin-inline-start: calc(var(--swc-button-edge-to-visual, token("component-pill-edge-to-visual-100")) - var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100"))); -}.swc-Button-icon { +} + +.swc-Button-icon { color: inherit; fill: currentcolor; -}.swc-Button--sizeS { +} + +.swc-Button--sizeS { --swc-button-min-block-size: token("component-height-75"); --swc-button-font-size: token("font-size-75"); --swc-button-gap: token("text-to-visual-75"); @@ -96,7 +116,9 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-75"); --swc-button-padding-vertical: token("component-padding-vertical-75"); --swc-button-icon-size: token("workflow-icon-size-75"); -}.swc-Button--sizeL { +} + +.swc-Button--sizeL { --swc-button-min-block-size: token("component-height-200"); --swc-button-font-size: token("font-size-200"); --swc-button-gap: token("text-to-visual-200"); @@ -105,7 +127,9 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-200"); --swc-button-padding-vertical: token("component-padding-vertical-200"); --swc-button-icon-size: token("workflow-icon-size-200"); -}.swc-Button--sizeXl { +} + +.swc-Button--sizeXl { --swc-button-min-block-size: token("component-height-300"); --swc-button-font-size: token("font-size-300"); --swc-button-gap: token("text-to-visual-300"); @@ -114,7 +138,9 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-300"); --swc-button-padding-vertical: token("component-padding-vertical-300"); --swc-button-icon-size: token("workflow-icon-size-300"); -}.swc-Button--accent { +} + +.swc-Button--accent { --swc-button-content-color-default: token("white"); --swc-button-content-color-hover: token("white"); --swc-button-content-color-down: token("white"); @@ -123,7 +149,9 @@ --swc-button-background-color-hover: token("accent-background-color-hover"); --swc-button-background-color-down: token("accent-background-color-down"); --swc-button-background-color-focus: token("accent-background-color-key-focus"); -}.swc-Button--negative { +} + +.swc-Button--negative { --swc-button-content-color-default: token("white"); --swc-button-content-color-hover: token("white"); --swc-button-content-color-down: token("white"); @@ -132,7 +160,9 @@ --swc-button-background-color-hover: token("negative-background-color-hover"); --swc-button-background-color-down: token("negative-background-color-down"); --swc-button-background-color-focus: token("negative-background-color-key-focus"); -}.swc-Button--secondary { +} + +.swc-Button--secondary { --swc-button-content-color-default: token("neutral-content-color-default"); --swc-button-content-color-hover: token("neutral-content-color-hover"); --swc-button-content-color-down: token("neutral-content-color-down"); @@ -141,7 +171,9 @@ --swc-button-background-color-hover: token("gray-200"); --swc-button-background-color-down: token("gray-200"); --swc-button-background-color-focus: token("gray-200"); -}.swc-Button--primary.swc-Button--outline { +} + +.swc-Button--primary.swc-Button--outline { --swc-button-background-color-default: transparent; --swc-button-background-color-hover: token("gray-100"); --swc-button-background-color-down: token("gray-100"); @@ -156,7 +188,9 @@ --swc-button-content-color-focus: token("neutral-content-color-key-focus"); --swc-button-background-color-disabled: transparent; --swc-button-border-color-disabled: token("disabled-border-color"); -}.swc-Button--secondary.swc-Button--outline { +} + +.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: transparent; --swc-button-background-color-hover: token("gray-100"); --swc-button-background-color-down: token("gray-100"); @@ -167,7 +201,9 @@ --swc-button-border-color-focus: token("gray-400"); --swc-button-background-color-disabled: transparent; --swc-button-border-color-disabled: token("disabled-border-color"); -}.swc-Button--staticWhite { +} + +.swc-Button--staticWhite { --swc-button-focus-indicator-color: token("static-white-focus-indicator-color"); --swc-button-background-color-default: token("transparent-white-800"); --swc-button-background-color-hover: token("transparent-white-900"); @@ -180,7 +216,9 @@ --swc-button-background-color-disabled: token("disabled-static-white-background-color"); --swc-button-border-color-disabled: transparent; --swc-button-content-color-disabled: token("disabled-static-white-content-color"); -}.swc-Button--staticWhite.swc-Button--secondary { +} + +.swc-Button--staticWhite.swc-Button--secondary { --swc-button-background-color-default: token("transparent-white-100"); --swc-button-background-color-hover: token("transparent-white-200"); --swc-button-background-color-down: token("transparent-white-200"); @@ -189,7 +227,9 @@ --swc-button-content-color-hover: token("transparent-white-900"); --swc-button-content-color-down: token("transparent-white-900"); --swc-button-content-color-focus: token("transparent-white-900"); -}.swc-Button--staticWhite.swc-Button--outline { +} + +.swc-Button--staticWhite.swc-Button--outline { --swc-button-background-color-default: token("transparent-white-25"); --swc-button-background-color-hover: token("transparent-white-100"); --swc-button-background-color-down: token("transparent-white-100"); @@ -205,7 +245,9 @@ --swc-button-background-color-disabled: token("transparent-white-25"); --swc-button-border-color-disabled: token("disabled-static-white-border-color"); --swc-button-content-color-disabled: token("disabled-static-white-content-color"); -}.swc-Button--staticWhite.swc-Button--secondary.swc-Button--outline { +} + +.swc-Button--staticWhite.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: token("transparent-white-25"); --swc-button-background-color-hover: token("transparent-white-100"); --swc-button-background-color-down: token("transparent-white-100"); @@ -214,7 +256,9 @@ --swc-button-border-color-hover: token("transparent-white-400"); --swc-button-border-color-down: token("transparent-white-400"); --swc-button-border-color-focus: token("transparent-white-400"); -}.swc-Button--staticBlack { +} + +.swc-Button--staticBlack { --swc-button-focus-indicator-color: token("static-black-focus-indicator-color"); --swc-button-background-color-default: token("transparent-black-800"); --swc-button-background-color-hover: token("transparent-black-900"); @@ -227,7 +271,9 @@ --swc-button-background-color-disabled: token("disabled-static-black-background-color"); --swc-button-border-color-disabled: transparent; --swc-button-content-color-disabled: token("disabled-static-black-content-color"); -}.swc-Button--staticBlack.swc-Button--secondary { +} + +.swc-Button--staticBlack.swc-Button--secondary { --swc-button-background-color-default: token("transparent-black-100"); --swc-button-background-color-hover: token("transparent-black-200"); --swc-button-background-color-down: token("transparent-black-200"); @@ -236,7 +282,9 @@ --swc-button-content-color-hover: token("transparent-black-900"); --swc-button-content-color-down: token("transparent-black-900"); --swc-button-content-color-focus: token("transparent-black-900"); -}.swc-Button--staticBlack.swc-Button--outline { +} + +.swc-Button--staticBlack.swc-Button--outline { --swc-button-background-color-default: token("transparent-black-25"); --swc-button-background-color-hover: token("transparent-black-100"); --swc-button-background-color-down: token("transparent-black-100"); @@ -252,7 +300,9 @@ --swc-button-background-color-disabled: token("transparent-black-25"); --swc-button-border-color-disabled: token("disabled-static-black-border-color"); --swc-button-content-color-disabled: token("disabled-static-black-content-color"); -}.swc-Button--staticBlack.swc-Button--secondary.swc-Button--outline { +} + +.swc-Button--staticBlack.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: token("transparent-black-25"); --swc-button-background-color-hover: token("transparent-black-100"); --swc-button-background-color-down: token("transparent-black-100"); @@ -261,28 +311,40 @@ --swc-button-border-color-hover: token("transparent-black-400"); --swc-button-border-color-down: token("transparent-black-400"); --swc-button-border-color-focus: token("transparent-black-400"); -}.swc-Button--iconOnly { +} + +.swc-Button--iconOnly { --swc-button-border-radius: token("corner-radius-full"); --swc-button-gap: 0; padding-inline: calc(var(--swc-button-edge-to-visual-only, token("component-pill-edge-to-visual-only-100")) - var(--_swc-button-border-width)); -}.swc-Button--iconOnly .swc-Button-icon { +} + +.swc-Button--iconOnly .swc-Button-icon { --swc-button-edge-to-visual: 0; align-self: center; -}.swc-Button--truncate .swc-Button-label { +} + +.swc-Button--truncate .swc-Button-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -}.swc-Button--justified { +} + +.swc-Button--justified { flex-grow: 1; justify-self: stretch; inline-size: 100%; -}@media (prefers-reduced-motion: reduce) { +} + +@media (prefers-reduced-motion: reduce) { .swc-Button { transition-duration: 0ms; } } -}.swc-Button { +} + +.swc-Button { all: revert-layer !important; } From 92cdaa96b33b3af80f73f7e78383181269da7205 Mon Sep 17 00:00:00 2001 From: 5t3ph Date: Tue, 2 Jun 2026 12:23:28 -0500 Subject: [PATCH 07/10] feat(tooltip): update migration plan for `labeling` --- .../03_components/tooltip/migration-plan.md | 12 ++++++------ CONTRIBUTOR-DOCS/03_project-planning/README.md | 2 -- CONTRIBUTOR-DOCS/README.md | 1 - 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/tooltip/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/tooltip/migration-plan.md index 6e115fc220a..81267477481 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/tooltip/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/tooltip/migration-plan.md @@ -244,7 +244,7 @@ Neither controller is available yet. The automatic trigger integration additive | A8 | `cross-offset` | Offset along the cross axis (perpendicular to `offset`). React default: 0. Blocked on `PlacementController` API. | | A9 | `should-flip` | Whether to reposition to the opposite side when space runs out. Floating UI `flip` middleware. React default: `true`. Default should also be `true` in `PlacementController`; expose as a consumer attribute if override need is confirmed. | | A10 | `--swc-*` CSS custom properties | No `--swc-*` custom properties initially. A small reviewed set may be added if consumer override needs emerge. | -| A11 | `labeling` attribute — `aria-labelledby` wiring | The base ARIA wiring (A4 in must-ship) always uses `ariaDescribedByElements`. When `labeling` is set, the SWC layer uses `ariaLabelledByElements` instead — for icon-only triggers where the tooltip text is the sole accessible name and adding `accessible-label` directly to the trigger is not possible. `role="tooltip"` is retained. Works in automatic and manual modes. | +| ~~A11~~ | ~~`labeling` attribute — `aria-labelledby` wiring~~ | **Shipped.** Implemented in the initial release alongside the base ARIA wiring. `syncAriaRelationship()` branches on `this.labeling`: when set, uses `ariaLabelledByElements` instead of `ariaDescribedByElements` on the trigger's inner interactive element. Stale references in the opposite property are cleaned up on each sync. Re-syncs when `labeling` changes while the tooltip is open. | | A12 | Inner interactive element selector expansion | Initial implementation uses `querySelector('button')` as the convention for resolving the inner interactive element within a trigger's shadow root. Expand to support additional interactive elements (``, ``, ``), association is established on the host element directly - [ ] `disabled` attribute prevents automatic mode response to user input **(additive phase)** @@ -705,7 +705,7 @@ The impact is most acute in the additive phase, when `HoverController` will call - [ ] Variant colors are supplementary: pair each variant with readable text; meaning must not rely on color alone (WCAG 1.4.1) - [ ] Touch guidance: tooltip is hover/focus only; direct consumers to `swc-popover` or contextual help for explicit disclosure on touch devices - [ ] No auto-dismiss timer: tooltip must remain visible until the user dismisses it or the triggering state becomes invalid (WCAG 1.4.13) -- [ ] Icon-only trigger pattern: document in Accessibility story that (1) adding an accessible name directly to the trigger host (`aria-label` on native elements; `accessible-label` attribute on 2nd-gen SWC components) is preferred and works from the initial release; (2) the `labeling` attribute switches the SWC layer to wire `aria-labelledby` for cases where the trigger host cannot be modified (additive phase); (3) explain the semantic difference between labeling (accessible name) and describing (supplementary hint) +- [ ] Icon-only trigger pattern: document in Accessibility story that (1) adding an accessible name directly to the trigger host (`aria-label` on native elements; `accessible-label` attribute on 2nd-gen SWC components) is preferred and works from the initial release; (2) the `labeling` attribute switches the SWC layer to wire `aria-labelledby` for cases where the trigger host cannot be modified — active from the initial release; (3) explain the semantic difference between labeling (accessible name) and describing (supplementary hint) - [ ] Verify 200% zoom: tooltip does not obscure critical UI ### Review diff --git a/CONTRIBUTOR-DOCS/03_project-planning/README.md b/CONTRIBUTOR-DOCS/03_project-planning/README.md index c9d9fc827a7..4bbf025e26c 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/README.md @@ -77,8 +77,6 @@ - [Milestones](04_milestones/README.md) - Strategies - [Focus Management Strategy: 2nd-Gen Proposal](05_strategies/focus-management-strategy-rfc.md) -- Initiatives - - Decisions diff --git a/CONTRIBUTOR-DOCS/README.md b/CONTRIBUTOR-DOCS/README.md index 11f57d94f86..ab34fd58c88 100644 --- a/CONTRIBUTOR-DOCS/README.md +++ b/CONTRIBUTOR-DOCS/README.md @@ -43,7 +43,6 @@ - [Components](03_project-planning/03_components/README.md) - [Milestones](03_project-planning/04_milestones/README.md) - Strategies - - Initiatives From 81e099d1b4d1fd0fb3240aa76d8dce7c58b9fcb8 Mon Sep 17 00:00:00 2001 From: 5t3ph Date: Tue, 2 Jun 2026 12:31:17 -0500 Subject: [PATCH 08/10] fix(tooltip): update Button type cast --- .../components/tooltip/test/tooltip.test.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/2nd-gen/packages/swc/components/tooltip/test/tooltip.test.ts b/2nd-gen/packages/swc/components/tooltip/test/tooltip.test.ts index 566db489658..21ba597568d 100644 --- a/2nd-gen/packages/swc/components/tooltip/test/tooltip.test.ts +++ b/2nd-gen/packages/swc/components/tooltip/test/tooltip.test.ts @@ -14,6 +14,7 @@ import { html } from 'lit'; import { expect, userEvent } from '@storybook/test'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; +import type { Button } from '@adobe/spectrum-wc/button'; import { Tooltip } from '@adobe/spectrum-wc/tooltip'; import '@adobe/spectrum-wc/components/button/swc-button.js'; @@ -309,11 +310,8 @@ export const AriaWiringSwcTriggerTest: Story = { `, play: async ({ canvasElement, step }) => { - const swcButton = canvasElement.querySelector( - '#tt-swc-trigger' - ) as HTMLElement; - await (swcButton as HTMLElement & { updateComplete: Promise }) - .updateComplete; + const swcButton = canvasElement.querySelector('#tt-swc-trigger') as Button; + await swcButton.updateComplete; const tooltip = await getComponent(canvasElement, 'swc-tooltip'); const innerButton = swcButton.shadowRoot?.querySelector('button') ?? null; @@ -365,10 +363,10 @@ export const AriaWiringTriggerElementOverrideTest: Story = { const tooltip = await getComponent(canvasElement, 'swc-tooltip'); const wrongTarget = canvasElement.querySelector( '#tt-wrong-target' - ) as HTMLElement & { updateComplete: Promise }; + ) as Button; const correctTarget = canvasElement.querySelector( '#tt-correct-target' - ) as HTMLElement & { updateComplete: Promise }; + ) as Button; await wrongTarget.updateComplete; await correctTarget.updateComplete; const wrongInner = wrongTarget.shadowRoot?.querySelector('button') ?? null; @@ -432,11 +430,7 @@ export const AriaWiringManualModeTest: Story = { `, play: async ({ canvasElement, step }) => { const tooltip = await getComponent(canvasElement, 'swc-tooltip'); - const trigger = canvasElement.querySelector( - '#tt-manual-trigger' - ) as HTMLElement & { - updateComplete: Promise; - }; + const trigger = canvasElement.querySelector('#tt-manual-trigger') as Button; await trigger.updateComplete; const innerButton = trigger.shadowRoot?.querySelector('button') ?? null; From 4d28886bd49b8108ed0b5ba312afb2aa06bd183f Mon Sep 17 00:00:00 2001 From: 5t3ph Date: Tue, 2 Jun 2026 12:48:03 -0500 Subject: [PATCH 09/10] feat(tooltip): harden `transitionDuration` check --- 2nd-gen/packages/core/components/tooltip/Tooltip.base.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts b/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts index c72e7299c26..3cfd9434819 100644 --- a/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts +++ b/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts @@ -251,7 +251,10 @@ export abstract class TooltipBase extends SpectrumElement { this.open = isOpen; } // When no CSS transition is active, dispatch after-* immediately since transitionend will not fire. - if (getComputedStyle(this).transitionDuration === '0s') { + // transitionDuration is comma-separated when multiple properties transition ("0s, 0s, …"), + // so check that every value in the list is zero rather than comparing the full string. + const durations = getComputedStyle(this).transitionDuration.split(','); + if (durations.every((d) => d.trim() === '0s')) { this.afterEventPending = false; this.dispatchAfterEvent(isOpen); } From 891601d70f870800dd172edaeebb9785c78c5977 Mon Sep 17 00:00:00 2001 From: 5t3ph Date: Tue, 2 Jun 2026 13:46:35 -0500 Subject: [PATCH 10/10] fix(tooltip): add aria-label due to axe-core limitation --- .../tooltip/stories/tooltip.stories.ts | 6 +++- .../components/tooltip/test/tooltip.test.ts | 36 +++++++++++-------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/2nd-gen/packages/swc/components/tooltip/stories/tooltip.stories.ts b/2nd-gen/packages/swc/components/tooltip/stories/tooltip.stories.ts index 8ca81cc9930..ef4da40b866 100644 --- a/2nd-gen/packages/swc/components/tooltip/stories/tooltip.stories.ts +++ b/2nd-gen/packages/swc/components/tooltip/stories/tooltip.stories.ts @@ -157,7 +157,11 @@ const triggered = ( `; } else { return html` - + html` - + + ★ + Favorite `, play: async ({ canvasElement, step }) => { const tooltip = await getComponent(canvasElement, 'swc-tooltip'); - const trigger = canvasElement.querySelector('#tt-labeling-trigger')!; + const trigger = canvasElement.querySelector( + '#tt-labeling-trigger' + ) as Button; + await trigger.updateComplete; + const innerButton = trigger.shadowRoot?.querySelector('button') ?? null; await step( - 'sets ariaLabelledByElements (not ariaDescribedByElements) when labeling is set', + 'sets ariaLabelledByElements (not ariaDescribedByElements) on inner button when labeling is set', async () => { tooltip.open = true; await tooltip.updateComplete; expect( - trigger.ariaLabelledByElements ?? [], - 'ariaLabelledByElements contains tooltip when labeling is set' + innerButton?.ariaLabelledByElements ?? [], + 'inner button ariaLabelledByElements contains tooltip when labeling is set' ).toContain(tooltip); expect( - trigger.ariaDescribedByElements ?? [], - 'ariaDescribedByElements does not contain tooltip when labeling is set' + innerButton?.ariaDescribedByElements ?? [], + 'inner button ariaDescribedByElements does not contain tooltip when labeling is set' ).not.toContain(tooltip); } ); await step( - 'clears ariaLabelledByElements from trigger when closed', + 'clears ariaLabelledByElements from inner button when closed', async () => { tooltip.open = false; await tooltip.updateComplete; expect( - trigger.ariaLabelledByElements ?? [], - 'ariaLabelledByElements no longer contains tooltip after close' + innerButton?.ariaLabelledByElements ?? [], + 'inner button ariaLabelledByElements no longer contains tooltip after close' ).not.toContain(tooltip); } ); await step( - 'switches to ariaDescribedByElements when labeling is removed while open', + 'switches to ariaDescribedByElements on inner button when labeling is removed while open', async () => { tooltip.open = true; await tooltip.updateComplete; @@ -506,13 +512,13 @@ export const AriaWiringLabelingTest: Story = { await tooltip.updateComplete; expect( - trigger.ariaDescribedByElements ?? [], - 'ariaDescribedByElements contains tooltip after labeling removed' + innerButton?.ariaDescribedByElements ?? [], + 'inner button ariaDescribedByElements contains tooltip after labeling removed' ).toContain(tooltip); expect( - trigger.ariaLabelledByElements ?? [], - 'ariaLabelledByElements does not contain tooltip after labeling removed' + innerButton?.ariaLabelledByElements ?? [], + 'inner button ariaLabelledByElements does not contain tooltip after labeling removed' ).not.toContain(tooltip); tooltip.open = false;