From 33e4ab2db240f02e546b16491e80e198f33b33e4 Mon Sep 17 00:00:00 2001 From: aramos-adobe Date: Tue, 29 Sep 2026 14:27:14 -0400 Subject: [PATCH 1/6] test(text-field): add gen2 a11y spec and dedicated VRT stories Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../text-field/migration-plan.md | 16 +- .../text-field/test/text-field.a11y.spec.ts | 249 +++++++++++ .../text-field/test/text-field.test.ts | 26 +- .../vrt/text-field-custom-properties.vrt.ts | 122 ++++++ .../text-field/test/vrt/text-field.vrt.ts | 401 ++++++++++++++++++ 5 files changed, 805 insertions(+), 9 deletions(-) create mode 100644 gen2/packages/swc/components/text-field/test/text-field.a11y.spec.ts create mode 100644 gen2/packages/swc/components/text-field/test/vrt/text-field-custom-properties.vrt.ts create mode 100644 gen2/packages/swc/components/text-field/test/vrt/text-field.vrt.ts diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/text-field/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/text-field/migration-plan.md index 9a92f071f8a..5877550f036 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/text-field/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/text-field/migration-plan.md @@ -472,20 +472,20 @@ Planned rendering shape: ### Testing -- [ ] Port applicable coverage from `1st-gen/packages/textfield/test/` (single-line only) -- [ ] Add Playwright `text-field.a11y.spec.ts` with `toMatchAriaSnapshot` (name/value/description across states and label positions) -- [ ] Unit: same-root unique ids; `aria-describedby` only when description/error present; form participation (`FormData`, reset, `fieldset[disabled]`) +- [x] Port applicable coverage from `1st-gen/packages/textfield/test/` (single-line only) +- [x] Add Playwright `text-field.a11y.spec.ts` with `toMatchAriaSnapshot` (name/value/description across states and label positions) +- [x] Unit: same-root unique ids; `aria-describedby` only when description/error present; form participation (`FormData`, reset, `fieldset[disabled]`) #### Behavior -- [ ] `input`/`change` events; `checkValidity()`; `setSelectionRange()`/`select()` -- [ ] Dev-warning story for a field with no accessible name at all +- [x] `input`/`change` events; `checkValidity()`; `setSelectionRange()`/`select()` +- [x] Dev-warning story for a field with no accessible name at all #### Visual regression -- [ ] Add VRT coverage for size × state combinations (default/hover/focus/keyboard-focus/disabled/readonly/invalid/valid) across both `label-position` modes (`top`/`side`) -- [ ] Add focus-visible regression coverage for the keyboard ring (B12) -- [ ] Add forced-colors (high-contrast) coverage for invalid/valid and focus states +- [x] Add VRT coverage for size × state combinations (default/hover/focus/keyboard-focus/disabled/readonly/invalid/valid) across both `label-position` modes (`top`/`side`) +- [x] Add focus-visible regression coverage for the keyboard ring (B12) +- [x] Add forced-colors (high-contrast) coverage for invalid/valid and focus states ### Documentation diff --git a/gen2/packages/swc/components/text-field/test/text-field.a11y.spec.ts b/gen2/packages/swc/components/text-field/test/text-field.a11y.spec.ts new file mode 100644 index 00000000000..a7c0e5c908e --- /dev/null +++ b/gen2/packages/swc/components/text-field/test/text-field.a11y.spec.ts @@ -0,0 +1,249 @@ +/** + * 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 the gen2 Text Field component. + * + * ARIA snapshot tests validate the accessibility tree (the `textbox` role, + * its accessible name, and its description) across every labelling source, + * size, label position, necessity indicator, prefix, and state the stories + * cover. aXe WCAG compliance and color-contrast checks run via + * test-storybook (see .storybook/test-runner.ts). + * + * The component sets `delegatesFocus: true`, so tab focus lands on the + * internal ``. Keyboard-interaction tests exercise that contract + * with real trusted input. + */ + +test.describe('Text field - ARIA Snapshots', () => { + test('exposes a textbox with the slotted label as its name', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-text-field--overview', + 'swc-text-field' + ); + await expect(root).toMatchAriaSnapshot(` + - textbox "Favorite food" + `); + }); + + test('names each anatomy variation (description and error-text)', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-text-field--anatomy', + 'swc-text-field' + ); + await expect(root).toMatchAriaSnapshot(` + - textbox "Email address" + - textbox "Email address" + `); + }); + + test('resolves each accessible name source (slot, accessible-label, accessible-labelledby)', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-text-field--labelling', + 'swc-text-field' + ); + const fields = root.locator('swc-text-field'); + await expect(fields.nth(0)).toMatchAriaSnapshot(` + - textbox "Email address" + `); + await expect(fields.nth(1)).toMatchAriaSnapshot(` + - textbox "Email address" + `); + // Chromium's `ariaLabelledByElements` getter enforces a same-tree + // check, so a cross-root labelling reference does not compute a name + // for `toHaveAccessibleName` / `toMatchAriaSnapshot`. Real browsers + // and AT still announce it. Assert the mixin's actual contract: the + // referenced elements are set on the input. + const labelledbyRefText = await fields.nth(2).evaluate((field) => { + const input = (field as HTMLElement).shadowRoot?.querySelector('input'); + const refs = + (input as HTMLInputElement & { ariaLabelledByElements?: Element[] }) + ?.ariaLabelledByElements ?? null; + return refs?.map((element) => element.textContent?.trim() ?? '') ?? null; + }); + expect(labelledbyRefText).toEqual(['Billing', 'Street address']); + }); + + test('names each textbox across sizes', async ({ page }) => { + const root = await gotoStory( + page, + 'components-text-field--sizes', + 'swc-text-field' + ); + await expect(root).toMatchAriaSnapshot(` + - textbox "Small" + - textbox "Medium" + - textbox "Large" + - textbox "Extra-large" + `); + }); + + test('names each textbox across label positions (identical semantics)', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-text-field--label-positions', + 'swc-text-field' + ); + await expect(root).toMatchAriaSnapshot(` + - textbox "Label on top" + - textbox "Label on side" + `); + }); + + test('exposes an identical accessible name across necessity-indicator modes', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-text-field--necessity-indicator', + 'swc-text-field' + ); + // The necessity-indicator span is `aria-hidden="true"` (see + // `renderNecessityIndicator` in `render-label.ts`), so the "(required)" + // and "(optional)" text is excluded from the accessible name. All three + // fields expose the same name; the mode only affects visible presentation. + await expect(root).toMatchAriaSnapshot(` + - textbox "Email address" + - textbox "Email address" + - textbox "Email address" + `); + }); + + test('names each textbox with a leading prefix affix', async ({ page }) => { + const root = await gotoStory( + page, + 'components-text-field--prefix', + 'swc-text-field' + ); + // Prefix content is non-interactive and not part of the accessible name. + await expect(root).toMatchAriaSnapshot(` + - textbox "URL" + - textbox "Mention" + - textbox "User Email" + `); + }); + + test('reflects native form states (required / readonly / disabled / invalid)', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-text-field--states', + 'swc-text-field' + ); + await expect(root).toMatchAriaSnapshot(` + - text: Default + - textbox "Default" + - text: Full name + - textbox "Full name" + - textbox "Read-only": Read-only value + - text: Disabled + - textbox "Disabled" [disabled]: Disabled value + - textbox "Disabled placeholder" [disabled] + - textbox "Email address" + - text: Enter a valid email address. Email address + - textbox "Email address" [disabled] + - text: We'll never share your email. + `); + }); + + test('associates description text via aria-describedby', async ({ page }) => { + const root = await gotoStory( + page, + 'components-text-field--accessibility', + 'swc-text-field' + ); + await expect(root).toMatchAriaSnapshot(` + - textbox "Comments" + - paragraph: Describe the issue in as much detail as possible. + - textbox "Issue details" + `); + }); +}); + +test.describe('Text field - Keyboard interactions', () => { + test('tab focus lands on the internal input via delegatesFocus', async ({ + page, + }) => { + await gotoStory(page, 'components-text-field--overview', 'swc-text-field'); + + await page.keyboard.press('Tab'); + + // The host reports as active; shadow-active is the delegated ``. + const focus = await page.evaluate(() => { + const host = document.querySelector('swc-text-field'); + const shadowActive = host?.shadowRoot?.activeElement; + return { + activeTag: document.activeElement?.tagName.toLowerCase() ?? '', + shadowActiveTag: shadowActive?.tagName.toLowerCase() ?? '', + }; + }); + expect(focus.activeTag).toBe('swc-text-field'); + expect(focus.shadowActiveTag).toBe('input'); + }); + + test('typing into the focused field updates the host value', async ({ + page, + }) => { + await gotoStory(page, 'components-text-field--overview', 'swc-text-field'); + + // Trusted keyboard input exercises the platform's own input dispatch, + // catching regressions in the `@input` bridge that play functions + // (synthetic events) can miss. + await page.keyboard.press('Tab'); + await page.keyboard.type('hello'); + + const value = await page.evaluate(() => { + const host = document.querySelector('swc-text-field') as HTMLElement & { + value: string; + }; + return host.value; + }); + expect(value).toBe('hello'); + }); + + test('a disabled field is not reachable via Tab', async ({ page }) => { + await gotoStory(page, 'components-text-field--states', 'swc-text-field'); + + for (let i = 0; i < 10; i++) { + await page.keyboard.press('Tab'); + const active = await page.evaluate(() => { + const host = document.activeElement as HTMLElement | null; + return { + tag: host?.tagName.toLowerCase() ?? '', + disabled: host?.hasAttribute('disabled') ?? false, + }; + }); + if (active.tag === 'swc-text-field') { + expect( + active.disabled, + 'Tab must never land on a disabled swc-text-field' + ).toBe(false); + } + } + }); +}); diff --git a/gen2/packages/swc/components/text-field/test/text-field.test.ts b/gen2/packages/swc/components/text-field/test/text-field.test.ts index e5ec7c81dea..4d64702797f 100644 --- a/gen2/packages/swc/components/text-field/test/text-field.test.ts +++ b/gen2/packages/swc/components/text-field/test/text-field.test.ts @@ -223,6 +223,16 @@ export const StatesTest: Story = { expect(input?.getAttribute('aria-invalid')).toBe('true'); }); + await step( + 'invalid input never carries aria-errormessage (decision B9, describedby only)', + () => { + // Decision B9 in accessibility-migration-analysis.md: gen2 associates + // the error via aria-describedby only, because AT support for + // aria-errormessage is inconsistent while aria-describedby is universal. + expect(input?.getAttribute('aria-errormessage')).toBeNull(); + } + ); + await step( 'disabled invalid fields suppress invalid presentation and association', () => { @@ -484,7 +494,10 @@ export const AccessibilityTest: Story = { export const BindingsTest: Story = { render: () => html` - + `, play: async ({ canvasElement, step }) => { const field = await getComponent( @@ -508,6 +521,17 @@ export const BindingsTest: Story = { expect(input().getAttribute('aria-invalid')).toBe('true'); }); + await step( + 'placeholder passes through natively, not as aria-placeholder', + () => { + // accessibility-migration-analysis.md (placeholder section): the + // browser exposes native `placeholder` to the a11y tree, so gen2 does + // not duplicate via `aria-placeholder`. + expect(input().placeholder).toBe('Enter your username'); + expect(input().getAttribute('aria-placeholder')).toBeNull(); + } + ); + await step('typing round-trips the native value back to the host', () => { // The @input handler is the only path that syncs user edits onto // the host `value`; assert it so `.value` in render can't silently diff --git a/gen2/packages/swc/components/text-field/test/vrt/text-field-custom-properties.vrt.ts b/gen2/packages/swc/components/text-field/test/vrt/text-field-custom-properties.vrt.ts new file mode 100644 index 00000000000..3348a02cf03 --- /dev/null +++ b/gen2/packages/swc/components/text-field/test/vrt/text-field-custom-properties.vrt.ts @@ -0,0 +1,122 @@ +/** + * 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, nothing } from 'lit'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import '@adobe/spectrum-wc/components/text-field/swc-text-field.js'; + +import type { CustomPropertyCase } from '../../../../.storybook/helpers/index.js'; +import { + coveredCustomProperties, + customPropertyRows, + theme, + verifyCustomPropertyCoverage, + vrtParameters, +} from '../../../../.storybook/helpers/index.js'; +import customElementsManifest from '../../../../dist/custom-elements.json'; + +// ──────────────── +// METADATA +// ──────────────── + +const meta: Meta = { + title: 'Text field/Text field VRT', + component: 'swc-text-field', + tags: ['dev'], +}; + +export default meta; + +// ──────────────── +// HELPERS +// ──────────────── + +type TextFieldPropertyCase = + CustomPropertyCase<`--swc-${'field' | 'text-field' | 'form-field'}-${string}`>; + +const MOD_PROPERTY_CASES: readonly TextFieldPropertyCase[] = [ + { property: '--swc-field-label-max-inline-size', value: '80px' }, + { property: '--swc-field-input-min-inline-size', value: '260px' }, + { property: '--swc-field-input-max-inline-size', value: '120px' }, + { property: '--swc-text-field-padding-block', value: '24px' }, + { property: '--swc-text-field-padding-inline', value: '24px' }, + { property: '--swc-text-field-affix-gap', value: '24px' }, + { property: '--swc-form-field-row-gap', value: '24px' }, + { property: '--swc-text-field-font-size', value: '24px' }, + { property: '--swc-text-field-line-height', value: '2.5' }, + { property: '--swc-text-field-border-radius', value: '20px' }, + { property: '--swc-text-field-validation-icon-size', value: '32px' }, + { property: '--swc-form-field-label-font-size', value: '24px' }, + { property: '--swc-form-field-description-font-size', value: '20px' }, +]; + +// Two properties need extra context to be visible: the affix-gap needs a +// slotted prefix, and the validation-icon-size needs the invalid state. +// A long label makes the label-max-inline-size wrap visibly. +const renderModPropertyCase = ( + { property }: TextFieldPropertyCase, + overrideStyle?: string +) => { + const style = overrideStyle + ? `inline-size: 240px; ${overrideStyle}` + : 'inline-size: 240px;'; + const showsPrefix = property === '--swc-text-field-affix-gap'; + const showsError = property === '--swc-text-field-validation-icon-size'; + const label = + property === '--swc-field-label-max-inline-size' + ? 'A long label wraps sooner when the label column is tightly capped' + : 'Field label'; + return html` +
+ + ${label} + ${showsPrefix + ? html` + https:// + ` + : nothing} + Description text + ${showsError + ? html` + Fix this field + ` + : nothing} + +
+ `; +}; + +const modPropertiesContent = () => + customPropertyRows(MOD_PROPERTY_CASES, renderModPropertyCase); + +const coveredTextFieldCustomProperties = + coveredCustomProperties(MOD_PROPERTY_CASES); + +const verifyCoverage = async () => { + await verifyCustomPropertyCoverage({ + customElementsManifest, + modulePath: 'components/text-field/TextField.ts', + declarationName: 'TextField', + coveredProperties: coveredTextFieldCustomProperties, + }); +}; + +// ──────────────── +// VRT STORIES +// ──────────────── + +export const CustomProperties: Story = { + render: () => theme(modPropertiesContent(), 'light', 'ltr'), + parameters: vrtParameters, + play: verifyCoverage, +}; diff --git a/gen2/packages/swc/components/text-field/test/vrt/text-field.vrt.ts b/gen2/packages/swc/components/text-field/test/vrt/text-field.vrt.ts new file mode 100644 index 00000000000..0522360e592 --- /dev/null +++ b/gen2/packages/swc/components/text-field/test/vrt/text-field.vrt.ts @@ -0,0 +1,401 @@ +/** + * 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, nothing, type TemplateResult } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { + TEXT_FIELD_LABEL_POSITIONS, + TEXT_FIELD_VALID_SIZES, + type TextFieldLabelPosition, + type TextFieldNecessityIndicator, + type TextFieldSize, +} from '@adobe/spectrum-wc-core/components/text-field'; + +import '@adobe/spectrum-wc/components/avatar/swc-avatar.js'; +import '@adobe/spectrum-wc/components/text-field/swc-text-field.js'; +import '@adobe/spectrum-wc-icons/swc-icon-mention.js'; + +import { + forcedColorsVrtParameters, + forcePseudoStates, + row, + SIZE_LABELS, + theme, + vrtParameters, +} from '../../../../.storybook/helpers/index.js'; + +// ──────────────── +// METADATA +// ──────────────── + +const meta: Meta = { + title: 'Text field/Text field VRT', + component: 'swc-text-field', + tags: ['dev'], +}; + +export default meta; + +// ──────────────── +// HELPERS +// ──────────────── + +// Text field draws its keyboard focus ring off a custom `:state(keyboard-focused)` +// element-internals state, not `:host(:focus-visible)`, because text inputs match +// `:focus-visible` on pointer click. The shared `forcePseudoStates` helper only +// mirrors `:hover`/`:focus-visible`/`:active`, so mirror the keyboard-focused +// rule separately: rewrite `:host(:state(keyboard-focused))` as +// `:host([data-forced-focus-visible])` in the shadow root's adopted sheets. +const KEYBOARD_FOCUSED_SELECTOR = ':host(:state(keyboard-focused))'; +const KEYBOARD_FOCUSED_MIRROR = ':host([data-forced-focus-visible])'; + +const mirrorCache = new WeakMap(); +const augmentedRoots = new WeakSet(); + +const mirrorKeyboardFocusedRules = ( + sheet: CSSStyleSheet +): CSSStyleSheet | null => { + const rules: string[] = []; + for (const rule of sheet.cssRules) { + if ( + rule instanceof CSSStyleRule && + rule.cssText.includes(KEYBOARD_FOCUSED_SELECTOR) + ) { + rules.push( + rule.cssText + .split(KEYBOARD_FOCUSED_SELECTOR) + .join(KEYBOARD_FOCUSED_MIRROR) + ); + } + } + if (!rules.length) { + return null; + } + const mirror = new CSSStyleSheet(); + rules.forEach((cssText, index) => mirror.insertRule(cssText, index)); + return mirror; +}; + +const augmentKeyboardFocused = (root: ShadowRoot): void => { + if (augmentedRoots.has(root)) { + return; + } + augmentedRoots.add(root); + const mirrors = root.adoptedStyleSheets + .map((sheet) => { + if (!mirrorCache.has(sheet)) { + mirrorCache.set(sheet, mirrorKeyboardFocusedRules(sheet)); + } + return mirrorCache.get(sheet) ?? null; + }) + .filter((sheet): sheet is CSSStyleSheet => sheet !== null); + if (mirrors.length) { + root.adoptedStyleSheets = [...root.adoptedStyleSheets, ...mirrors]; + } +}; + +const forceTextFieldStates = async ({ + canvasElement, +}: { + canvasElement: HTMLElement; +}) => { + // Shared helper covers `:hover` on the internal control wrapper. + await forcePseudoStates( + 'swc-text-field[data-force-state]', + '.swc-TextField-control' + )({ canvasElement }); + + // Custom mirror for the keyboard-focused ring on hosts tagged focus-visible. + canvasElement + .querySelectorAll( + 'swc-text-field[data-force-state="focus-visible"]' + ) + .forEach((host) => { + if (!host.shadowRoot) { + return; + } + augmentKeyboardFocused(host.shadowRoot); + host.setAttribute('data-forced-focus-visible', ''); + }); +}; + +type TextFieldCase = { + size?: TextFieldSize; + labelPosition?: TextFieldLabelPosition; + necessityIndicator?: TextFieldNecessityIndicator; + label?: string; + description?: string; + errorText?: string; + accessibleLabel?: string; + placeholder?: string; + value?: string; + prefix?: TemplateResult; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + invalid?: boolean; + extraStyle?: string; + lang?: string; + forceState?: 'hover' | 'focus-visible'; +}; + +const renderCase = ({ + size, + labelPosition, + necessityIndicator, + label, + description, + errorText, + accessibleLabel, + placeholder, + value = '', + prefix, + required = false, + readonly = false, + disabled = false, + invalid = false, + extraStyle, + lang, + forceState, +}: TextFieldCase) => { + const wrapperStyle = `inline-size: 220px;${extraStyle ? ` ${extraStyle}` : ''}`; + return html` +
+ + ${label + ? html` + ${label} + ` + : nothing} + ${prefix ?? nothing} + ${description + ? html` + ${description} + ` + : nothing} + ${errorText + ? html` + ${errorText} + ` + : nothing} + +
+ `; +}; + +const labelPositionLabels = { + top: 'Top label', + side: 'Side label', +} as const satisfies Record; + +const permutationContent = () => html` + ${row( + TEXT_FIELD_VALID_SIZES.map((size) => + renderCase({ + size, + label: SIZE_LABELS[size], + description: 'Field help text', + }) + ), + 'Sizes' + )} + ${row( + TEXT_FIELD_LABEL_POSITIONS.map((labelPosition) => + renderCase({ + labelPosition, + label: labelPositionLabels[labelPosition], + }) + ), + 'Label position' + )} + ${row( + [ + renderCase({ + necessityIndicator: 'icon', + required: true, + label: 'Icon · required', + }), + renderCase({ + necessityIndicator: 'label', + required: true, + label: 'Label · required', + }), + renderCase({ + necessityIndicator: 'label', + label: 'Label · optional', + }), + ], + 'Necessity indicator' + )} + ${row( + [ + renderCase({ label: 'Default' }), + renderCase({ + label: 'Read-only', + readonly: true, + value: 'Read-only value', + }), + renderCase({ + label: 'Disabled', + disabled: true, + value: 'Disabled value', + }), + renderCase({ + label: 'Disabled placeholder', + disabled: true, + placeholder: 'Placeholder', + }), + renderCase({ + label: 'Invalid', + invalid: true, + description: 'Field help text', + errorText: 'Enter a valid value', + }), + renderCase({ + label: 'Invalid + disabled', + invalid: true, + disabled: true, + description: 'Field help text', + errorText: 'Enter a valid value', + }), + ], + 'States' + )} + ${row( + [ + renderCase({ label: 'Label only' }), + renderCase({ + label: 'Label + description', + description: 'Extra help text below the field.', + }), + renderCase({ + accessibleLabel: 'Screen-reader-only label', + placeholder: 'No visible label', + }), + ], + 'Anatomy' + )} + ${row( + [ + renderCase({ + label: 'URL', + placeholder: 'example.com', + prefix: html` + https:// + `, + }), + renderCase({ + label: 'Mention', + placeholder: 'username', + prefix: html` + + `, + }), + renderCase({ + label: 'User email', + placeholder: 'contact@example.com', + prefix: html` + + `, + }), + ], + 'Prefix' + )} + ${row( + [ + renderCase({ label: 'Hover', forceState: 'hover' }), + renderCase({ label: 'Focus-visible', forceState: 'focus-visible' }), + renderCase({ + label: 'Invalid hover', + invalid: true, + errorText: 'Enter a valid value', + forceState: 'hover', + }), + renderCase({ + label: 'Invalid focus-visible', + invalid: true, + errorText: 'Enter a valid value', + forceState: 'focus-visible', + }), + ], + 'Forced pseudo-states' + )} + ${row( + [ + renderCase({ + label: + 'This top label wraps across multiple lines because it is quite long', + placeholder: 'Placeholder', + }), + renderCase({ + labelPosition: 'side', + label: 'This side label wraps and the input shrinks', + placeholder: 'Placeholder', + extraStyle: 'inline-size: 360px;', + }), + renderCase({ + labelPosition: 'side', + label: 'Tightly capped label wraps early', + placeholder: 'Placeholder', + extraStyle: + 'inline-size: 360px; --swc-field-label-max-inline-size: 120px;', + }), + ], + 'Wrapping' + )} + ${row( + [ + renderCase({ lang: 'ja', label: '承認ワークフローの入力値' }), + renderCase({ lang: 'ko', label: '승인 워크플로 입력값' }), + renderCase({ lang: 'zh', label: '审批工作流输入值' }), + ], + 'CJK language' + )} +`; + +// ──────────────── +// VRT STORIES +// ──────────────── + +export const Permutations: Story = { + render: () => html` + ${theme(permutationContent(), 'light', 'ltr')} + ${theme(permutationContent(), 'dark', 'rtl')} + `, + parameters: vrtParameters, + play: forceTextFieldStates, +}; + +export const ForcedColors: Story = { + render: () => theme(permutationContent(), 'light', 'ltr'), + parameters: forcedColorsVrtParameters, + play: forceTextFieldStates, +}; From f98a5e8bf6e653d17e3931d7440250fd4bcf5d9f Mon Sep 17 00:00:00 2001 From: aramos-adobe Date: Tue, 29 Sep 2026 15:21:11 -0400 Subject: [PATCH 2/6] test(text-field): update vrt and a11y specs --- .../text-field/test/text-field.a11y.spec.ts | 29 ++++- .../text-field/test/vrt/text-field.vrt.ts | 104 +++++------------- 2 files changed, 57 insertions(+), 76 deletions(-) diff --git a/gen2/packages/swc/components/text-field/test/text-field.a11y.spec.ts b/gen2/packages/swc/components/text-field/test/text-field.a11y.spec.ts index a7c0e5c908e..62ae28cae9e 100644 --- a/gen2/packages/swc/components/text-field/test/text-field.a11y.spec.ts +++ b/gen2/packages/swc/components/text-field/test/text-field.a11y.spec.ts @@ -131,6 +131,15 @@ test.describe('Text field - ARIA Snapshots', () => { - textbox "Email address" - textbox "Email address" `); + const fields = root.locator('swc-text-field'); + await expect(fields.nth(0).locator('input')).toHaveAttribute( + 'required', + '' + ); + await expect(fields.nth(1).locator('input')).toHaveAttribute( + 'required', + '' + ); }); test('names each textbox with a leading prefix affix', async ({ page }) => { @@ -159,7 +168,7 @@ test.describe('Text field - ARIA Snapshots', () => { - text: Default - textbox "Default" - text: Full name - - textbox "Full name" + - textbox "Full name" [required] - textbox "Read-only": Read-only value - text: Disabled - textbox "Disabled" [disabled]: Disabled value @@ -169,6 +178,9 @@ test.describe('Text field - ARIA Snapshots', () => { - textbox "Email address" [disabled] - text: We'll never share your email. `); + await expect( + root.locator('swc-text-field').nth(1).locator('input') + ).toHaveAttribute('required', ''); }); test('associates description text via aria-describedby', async ({ page }) => { @@ -178,10 +190,25 @@ test.describe('Text field - ARIA Snapshots', () => { 'swc-text-field' ); await expect(root).toMatchAriaSnapshot(` + - text: Slotted + - code: description - textbox "Comments" + - text: Optional; visible to your team only. + - code: accessible-describedby + - text: ": described by another element" - paragraph: Describe the issue in as much detail as possible. - textbox "Issue details" `); + const describedByIds = await root + .locator('swc-text-field') + .nth(1) + .locator('input') + .evaluate((input) => + (input as HTMLInputElement).ariaDescribedByElements?.map( + (element) => element.id + ) + ); + expect(describedByIds).toEqual(['accessibility-external-description']); }); }); diff --git a/gen2/packages/swc/components/text-field/test/vrt/text-field.vrt.ts b/gen2/packages/swc/components/text-field/test/vrt/text-field.vrt.ts index 0522360e592..777b96be547 100644 --- a/gen2/packages/swc/components/text-field/test/vrt/text-field.vrt.ts +++ b/gen2/packages/swc/components/text-field/test/vrt/text-field.vrt.ts @@ -51,83 +51,20 @@ export default meta; // HELPERS // ──────────────── -// Text field draws its keyboard focus ring off a custom `:state(keyboard-focused)` -// element-internals state, not `:host(:focus-visible)`, because text inputs match -// `:focus-visible` on pointer click. The shared `forcePseudoStates` helper only -// mirrors `:hover`/`:focus-visible`/`:active`, so mirror the keyboard-focused -// rule separately: rewrite `:host(:state(keyboard-focused))` as -// `:host([data-forced-focus-visible])` in the shadow root's adopted sheets. -const KEYBOARD_FOCUSED_SELECTOR = ':host(:state(keyboard-focused))'; -const KEYBOARD_FOCUSED_MIRROR = ':host([data-forced-focus-visible])'; - -const mirrorCache = new WeakMap(); -const augmentedRoots = new WeakSet(); - -const mirrorKeyboardFocusedRules = ( - sheet: CSSStyleSheet -): CSSStyleSheet | null => { - const rules: string[] = []; - for (const rule of sheet.cssRules) { - if ( - rule instanceof CSSStyleRule && - rule.cssText.includes(KEYBOARD_FOCUSED_SELECTOR) - ) { - rules.push( - rule.cssText - .split(KEYBOARD_FOCUSED_SELECTOR) - .join(KEYBOARD_FOCUSED_MIRROR) - ); - } - } - if (!rules.length) { - return null; - } - const mirror = new CSSStyleSheet(); - rules.forEach((cssText, index) => mirror.insertRule(cssText, index)); - return mirror; -}; - -const augmentKeyboardFocused = (root: ShadowRoot): void => { - if (augmentedRoots.has(root)) { - return; - } - augmentedRoots.add(root); - const mirrors = root.adoptedStyleSheets - .map((sheet) => { - if (!mirrorCache.has(sheet)) { - mirrorCache.set(sheet, mirrorKeyboardFocusedRules(sheet)); - } - return mirrorCache.get(sheet) ?? null; - }) - .filter((sheet): sheet is CSSStyleSheet => sheet !== null); - if (mirrors.length) { - root.adoptedStyleSheets = [...root.adoptedStyleSheets, ...mirrors]; - } -}; - const forceTextFieldStates = async ({ canvasElement, }: { canvasElement: HTMLElement; }) => { - // Shared helper covers `:hover` on the internal control wrapper. + // Hover and focus-within target the internal control; keyboard focus targets + // the host's custom state. await forcePseudoStates( - 'swc-text-field[data-force-state]', + 'swc-text-field[data-force-state="hover"], swc-text-field[data-force-state="focus-within"]', '.swc-TextField-control' )({ canvasElement }); - - // Custom mirror for the keyboard-focused ring on hosts tagged focus-visible. - canvasElement - .querySelectorAll( - 'swc-text-field[data-force-state="focus-visible"]' - ) - .forEach((host) => { - if (!host.shadowRoot) { - return; - } - augmentKeyboardFocused(host.shadowRoot); - host.setAttribute('data-forced-focus-visible', ''); - }); + await forcePseudoStates('swc-text-field[data-force-state="focus-visible"]')({ + canvasElement, + }); }; type TextFieldCase = { @@ -147,7 +84,7 @@ type TextFieldCase = { invalid?: boolean; extraStyle?: string; lang?: string; - forceState?: 'hover' | 'focus-visible'; + forceState?: 'hover' | 'focus-visible' | 'focus-within'; }; const renderCase = ({ @@ -331,22 +268,39 @@ const permutationContent = () => html` )} ${row( [ - renderCase({ label: 'Hover', forceState: 'hover' }), - renderCase({ label: 'Focus-visible', forceState: 'focus-visible' }), + renderCase({ label: 'Default', forceState: 'hover' }), renderCase({ - label: 'Invalid hover', + label: 'Invalid', invalid: true, errorText: 'Enter a valid value', forceState: 'hover', }), + ], + 'Hover' + )} + ${row( + [ + renderCase({ label: 'Default', forceState: 'focus-within' }), + renderCase({ + label: 'Invalid', + invalid: true, + errorText: 'Enter a valid value', + forceState: 'focus-within', + }), + ], + 'Focus within' + )} + ${row( + [ + renderCase({ label: 'Default', forceState: 'focus-visible' }), renderCase({ - label: 'Invalid focus-visible', + label: 'Invalid', invalid: true, errorText: 'Enter a valid value', forceState: 'focus-visible', }), ], - 'Forced pseudo-states' + 'Keyboard focus' )} ${row( [ From 7a91e4d4d1f5125579c50f6c76e1bd9bc4b2f87d Mon Sep 17 00:00:00 2001 From: aramos-adobe Date: Tue, 29 Sep 2026 15:37:02 -0400 Subject: [PATCH 3/6] test(text-field): add descriptive assertion messages Improve failure output for Storybook play-function assertions. --- .../text-field/test/text-field.test.ts | 435 +++++++++++++----- 1 file changed, 320 insertions(+), 115 deletions(-) diff --git a/gen2/packages/swc/components/text-field/test/text-field.test.ts b/gen2/packages/swc/components/text-field/test/text-field.test.ts index 4d64702797f..08e129e8e3e 100644 --- a/gen2/packages/swc/components/text-field/test/text-field.test.ts +++ b/gen2/packages/swc/components/text-field/test/text-field.test.ts @@ -61,13 +61,18 @@ export const LabellingTest: Story = { await step('slotted label renders as a real