diff --git a/1st-gen/storybook/main.js b/1st-gen/storybook/main.js index 188ff25b55f..cf3d269d6c5 100644 --- a/1st-gen/storybook/main.js +++ b/1st-gen/storybook/main.js @@ -30,6 +30,8 @@ export default { : []), // https://geometricpanda.github.io/storybook-addon-badges/ '@geometricpanda/storybook-addon-badges', + // Screen reader addon (shared from 2nd-gen) + '../../2nd-gen/packages/swc/.storybook/addons/screen-reader-addon', ], framework: { name: '@storybook/web-components-webpack5', diff --git a/2nd-gen/packages/core/shared/base/version.ts b/2nd-gen/packages/core/shared/base/version.ts index cb9e28ff658..2177cdd7401 100644 --- a/2nd-gen/packages/core/shared/base/version.ts +++ b/2nd-gen/packages/core/shared/base/version.ts @@ -9,6 +9,5 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - // Generated by genversion. export const version = '1.10.0'; diff --git a/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/manager.js b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/manager.js new file mode 100644 index 00000000000..509a2f28210 --- /dev/null +++ b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/manager.js @@ -0,0 +1,15 @@ +/** + * Copyright 2025 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. + */ + +// Storybook manager addon entry point +// Imports TypeScript source directly - Storybook's esbuild compiles on the fly +import './src/register.tsx'; diff --git a/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/package.json b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/package.json new file mode 100644 index 00000000000..de15b06e808 --- /dev/null +++ b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/package.json @@ -0,0 +1,44 @@ +{ + "author": "Adobe", + "bugs": { + "url": "https://github.com/adobe/spectrum-web-components/issues" + }, + "dependencies": { + "aria-query": "^5.3.0", + "dom-accessibility-api": "^0.7.0", + "query-selector-shadow-dom": "^1.0.0" + }, + "description": "A Screen Reader Storybook addon for accessibility testing", + "homepage": "https://opensource.adobe.com/spectrum-web-components/", + "keywords": [ + "storybook", + "storybook-addons", + "addons", + "accessibility", + "a11y", + "screen-reader", + "wcag" + ], + "license": "Apache-2.0", + "name": "@spectrum-web-components/screen-reader-addon", + "peerDependencies": { + "@lit/react": ">=1.0.0", + "@spectrum-web-components/field-label": ">=1.0.0", + "@spectrum-web-components/help-text": ">=1.0.0", + "@spectrum-web-components/switch": ">=1.0.0", + "@spectrum-web-components/textfield": ">=1.0.0", + "@spectrum-web-components/theme": ">=1.0.0", + "@storybook/components": ">=8.0.0", + "@storybook/core-events": ">=8.0.0", + "@storybook/manager-api": ">=8.0.0", + "lit": "^2.5.0 || ^3.1.3", + "react": ">=18.0.0" + }, + "private": true, + "repository": { + "directory": "2nd-gen/packages/swc/.storybook/addons/screen-reader-addon", + "type": "git", + "url": "https://github.com/adobe/spectrum-web-components.git" + }, + "version": "1.0.0" +} diff --git a/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/components/screen-reader-panel.ts b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/components/screen-reader-panel.ts new file mode 100644 index 00000000000..78527bb9395 --- /dev/null +++ b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/components/screen-reader-panel.ts @@ -0,0 +1,303 @@ +/** + * Copyright 2025 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 { css, html, LitElement } from 'lit'; +import { addons } from '@storybook/manager-api'; +import { STORY_CHANGED } from '@storybook/core-events'; + +// Import Spectrum Web Components +import '@spectrum-web-components/switch/sp-switch.js'; +import '@spectrum-web-components/theme/sp-theme.js'; +import '@spectrum-web-components/theme/src/spectrum-two/themes-core-tokens.js'; +import '@spectrum-web-components/textfield/sp-textfield.js'; +import '@spectrum-web-components/help-text/sp-help-text.js'; +import '@spectrum-web-components/field-label/sp-field-label.js'; + +import ScreenReader from '../screen-reader/screenReader.js'; + +interface ScreenReaderTextEvent extends CustomEvent { + detail: { + text: string; + }; +} + +export class ScreenReaderPanel extends LitElement { + // Using static properties instead of decorators for compatibility + // with Storybook's internal esbuild (no decorator compilation needed) + static override properties = { + voice: { type: Boolean }, + text: { type: Boolean }, + isActive: { type: Boolean }, + screenReaderText: { type: String }, + themeColor: { type: String }, + }; + + // Use 'declare' to avoid class field definition overriding Lit's reactive properties + declare voice: boolean; + declare text: boolean; + declare isActive: boolean; + declare screenReaderText: string; + declare themeColor: 'light' | 'dark'; + + private screenReader: ScreenReader | null = null; + private channel: ReturnType | null = null; + private themeMediaQuery: MediaQueryList | null = null; + + static override styles = css` + :host { + display: block; + padding: 16px; + } + + .toggle-row { + display: flex; + align-items: center; + margin-bottom: 12px; + } + + .output-section { + margin-top: 16px; + } + + sp-textfield { + width: 100%; + } + + sp-help-text { + margin-top: 12px; + } + `; + + constructor() { + super(); + // Initialize reactive properties + this.voice = false; + this.text = false; + this.isActive = false; + this.screenReaderText = ''; + this.themeColor = this.detectTheme(); + // Bind event handlers + this.handleTextChange = this.handleTextChange.bind(this); + this.handleStoryChange = this.handleStoryChange.bind(this); + this.handleThemeChange = this.handleThemeChange.bind(this); + } + + private detectTheme(): 'light' | 'dark' { + // Detect theme by checking Storybook's actual background color + // This works for both explicit themes (1st-gen) and auto themes (2nd-gen) + const body = document.body; + const computedStyle = getComputedStyle(body); + const bgColor = computedStyle.backgroundColor; + + // Parse RGB values + const rgbMatch = bgColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (rgbMatch) { + const [, r, g, b] = rgbMatch.map(Number); + // Calculate relative luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + // If background is dark (luminance < 0.5), use dark theme + return luminance < 0.5 ? 'dark' : 'light'; + } + + // Fallback to system preference + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + + return 'light'; + } + + private handleThemeChange(): void { + this.themeColor = this.detectTheme(); + } + + override connectedCallback(): void { + super.connectedCallback(); + + // Listen for text changes from the screen reader + window.addEventListener( + 'screen-reader-text-changed', + this.handleTextChange as EventListener + ); + + // Listen for story changes via Storybook API + this.channel = addons.getChannel(); + this.channel.on(STORY_CHANGED, this.handleStoryChange); + + // Listen for system theme changes (for auto-theme Storybooks like 2nd-gen) + this.themeMediaQuery = window.matchMedia( + '(prefers-color-scheme: dark)' + ); + this.themeMediaQuery.addEventListener('change', this.handleThemeChange); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + + window.removeEventListener( + 'screen-reader-text-changed', + this.handleTextChange as EventListener + ); + + if (this.channel) { + this.channel.off(STORY_CHANGED, this.handleStoryChange); + } + + if (this.themeMediaQuery) { + this.themeMediaQuery.removeEventListener( + 'change', + this.handleThemeChange + ); + } + + this.stopScreenReader(); + } + + private handleTextChange(event: ScreenReaderTextEvent): void { + this.screenReaderText = event.detail.text; + } + + private handleStoryChange(): void { + if (this.isActive && this.screenReader) { + this.screenReader.stop(); + this.screenReader = null; + + // Wait for new story to load, then restart + setTimeout(() => { + if (this.voice || this.text) { + this.startScreenReader(); + } + }, 500); + } + } + + private handleVoiceToggle(event: Event): void { + this.voice = (event.target as HTMLInputElement).checked; + this.updateScreenReader(); + } + + private handleTextToggle(event: Event): void { + this.text = (event.target as HTMLInputElement).checked; + this.updateScreenReader(); + } + + private findStorybookIframe(): HTMLIFrameElement | null { + return ( + (document.getElementById( + 'storybook-preview-iframe' + ) as HTMLIFrameElement) || + (document.querySelector( + 'iframe[data-is-storybook="true"]' + ) as HTMLIFrameElement) || + (document.querySelector( + 'iframe[title*="storybook"]' + ) as HTMLIFrameElement) || + (document.querySelector('iframe') as HTMLIFrameElement) + ); + } + + private startScreenReader(): void { + const iframe = this.findStorybookIframe(); + + if (!iframe) { + // eslint-disable-next-line no-console + console.error('[Screen Reader Addon] Cannot find preview iframe'); + return; + } + + this.screenReader = new ScreenReader(); + this.screenReader.voiceEnabled = this.voice; + this.screenReader.textEnabled = this.text; + this.screenReader.start(iframe); + + this.isActive = true; + } + + private stopScreenReader(): void { + if (this.screenReader) { + this.screenReader.stop(); + this.screenReader = null; + } + this.isActive = false; + this.screenReaderText = ''; + } + + private updateScreenReader(): void { + const shouldBeActive = this.voice || this.text; + + if (shouldBeActive && !this.isActive) { + this.startScreenReader(); + } else if (!shouldBeActive && this.isActive) { + this.stopScreenReader(); + } else if (shouldBeActive && this.screenReader) { + this.screenReader.voiceEnabled = this.voice; + this.screenReader.textEnabled = this.text; + } + } + + override render() { + return html` + +
+ + Voice Reader + +
+ +
+ + Text Reader + +
+ + ${this.text + ? html` +
+ + Screen reader output + + +
+ ` + : ''} + ${this.isActive + ? html` + + Use Tab or arrow keys to navigate. Focus changes + will be announced. + + ` + : ''} +
+ `; + } +} + +customElements.define('screen-reader-panel', ScreenReaderPanel); diff --git a/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/register.tsx b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/register.tsx new file mode 100644 index 00000000000..5bfc9bf8e61 --- /dev/null +++ b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/register.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2025 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 React from 'react'; +import { createComponent } from '@lit/react'; +import { addons, types } from '@storybook/manager-api'; +import { AddonPanel } from '@storybook/components'; +import { ScreenReaderPanel } from './components/screen-reader-panel.js'; + +// Create React wrapper for our Lit component +const ScreenReaderPanelReact = createComponent({ + tagName: 'screen-reader-panel', + elementClass: ScreenReaderPanel, + react: React, +}); + +const ADDON_ID = 'screenreader'; +const PANEL_ID = `${ADDON_ID}/panel`; + +addons.register(ADDON_ID, () => { + addons.add(PANEL_ID, { + type: types.PANEL, + title: 'Screen Reader', + render: ({ active }) => ( + + + + ), + }); +}); diff --git a/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/screen-reader/screenReader.ts b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/screen-reader/screenReader.ts new file mode 100644 index 00000000000..809ce1b9062 --- /dev/null +++ b/2nd-gen/packages/swc/.storybook/addons/screen-reader-addon/src/screen-reader/screenReader.ts @@ -0,0 +1,970 @@ +/** + * Copyright 2025 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 { querySelectorDeep } from 'query-selector-shadow-dom'; +import { + computeAccessibleDescription, + computeAccessibleName, +} from 'dom-accessibility-api'; +import { elementRoles } from 'aria-query'; + +type RoleAnnouncementFn = () => string; + +interface ElementRoleKey { + name: string; + attributes?: Array<{ name: string; value?: string }>; +} + +/** + * Dispatch text change event to the addon panel + */ +function dispatchTextChanged(text: string): void { + const customEvent = new CustomEvent('screen-reader-text-changed', { + detail: { text }, + }); + window.dispatchEvent(customEvent); +} + +/** + * Get the implicit ARIA role for an element using aria-query + */ +function getImplicitRole(element: Element): string | null { + if (!element || !element.tagName) { + return null; + } + + const tagName = element.tagName.toLowerCase(); + + // Check aria-query's elementRoles map + // elementRoles is a Map where keys are objects like { name: 'button' } + for (const [key, roleSet] of elementRoles) { + const roleKey = key as ElementRoleKey; + if (roleKey.name === tagName) { + // Check if attributes match (for elements like input with type) + if (roleKey.attributes) { + const matches = roleKey.attributes.every((attr) => { + if (attr.name === 'type') { + return element.getAttribute('type') === attr.value; + } + return ( + element.hasAttribute(attr.name) === + (attr.value !== undefined) + ); + }); + if (matches && roleSet.size > 0) { + return Array.from(roleSet)[0] as string; + } + } else if (roleSet.size > 0) { + return Array.from(roleSet)[0] as string; + } + } + } + + return null; +} + +/** + * Screen Reader class that simulates screen reader behavior + * Uses dom-accessibility-api for W3C-compliant accessible name computation + */ +export default class ScreenReader { + isRunning = false; + voiceEnabled = false; + textEnabled = false; + private storyDocument: Document | null = null; + private lastAnnouncedElement: Element | null = null; + + // Bound handlers for proper cleanup + private handleFocusIn: (event: FocusEvent) => void; + private handleKeyDown: (event: KeyboardEvent) => void; + private handleMutation: (mutations: MutationRecord[]) => void; + + private mutationObserver: MutationObserver | null = null; + private liveRegionObserver: MutationObserver | null = null; + + constructor() { + this.handleFocusIn = this.onFocusIn.bind(this); + this.handleKeyDown = this.onKeyDown.bind(this); + this.handleMutation = this.onMutation.bind(this); + } + + /** + * Compute the accessible role for an element + * Priority: explicit role > implicit role from aria-query > fallback mapping + */ + computeRole(element: Element | null): string { + if (!element) { + return 'element'; + } + + // 1. Check explicit ARIA role + const explicitRole = element.getAttribute('role'); + if (explicitRole) { + return explicitRole; + } + + // 2. Try to get implicit role from aria-query + const implicitRole = getImplicitRole(element); + if (implicitRole) { + return implicitRole; + } + + // 3. Fallback mapping for common elements + const tagName = element.tagName ? element.tagName.toLowerCase() : ''; + const fallbackMappings: Record = { + a: element.hasAttribute('href') ? 'link' : 'generic', + button: 'button', + input: this.getInputRole(element as HTMLInputElement), + select: 'combobox', + textarea: 'textbox', + h1: 'heading', + h2: 'heading', + h3: 'heading', + h4: 'heading', + h5: 'heading', + h6: 'heading', + p: 'paragraph', + img: 'img', + nav: 'navigation', + main: 'main', + aside: 'complementary', + header: 'banner', + footer: 'contentinfo', + li: 'listitem', + ul: 'list', + ol: 'list', + table: 'table', + details: 'group', + summary: 'button', + label: 'generic', + section: + element.hasAttribute('aria-label') || + element.hasAttribute('aria-labelledby') + ? 'region' + : 'generic', + article: 'article', + form: 'form', + dialog: 'dialog', + }; + + return fallbackMappings[tagName] || 'generic'; + } + + /** + * Get role for input elements based on type + */ + getInputRole(element: HTMLInputElement): string { + const type = (element.getAttribute('type') || 'text').toLowerCase(); + const inputRoles: Record = { + checkbox: 'checkbox', + radio: 'radio', + button: 'button', + submit: 'button', + reset: 'button', + image: 'button', + range: 'slider', + search: 'searchbox', + email: 'textbox', + tel: 'textbox', + url: 'textbox', + number: 'spinbutton', + password: 'textbox', + text: 'textbox', + }; + return inputRoles[type] || 'textbox'; + } + + /** + * Get accessible name using dom-accessibility-api (W3C AccName spec) + */ + getAccessibleName(element: Element | null): string { + if (!element) { + return ''; + } + + try { + return computeAccessibleName(element, { + // Compute name even for elements that normally wouldn't have one + computedStyleSupportsPseudoElements: true, + }); + } catch { + // Fallback for Shadow DOM elements that might not work with the library + if (element.getAttribute('aria-label')) { + return element.getAttribute('aria-label') || ''; + } + if ((element as Element & { shadowRoot: ShadowRoot }).shadowRoot) { + const slot = ( + element as Element & { shadowRoot: ShadowRoot } + ).shadowRoot.querySelector('slot'); + if (slot) { + const nodes = (slot as HTMLSlotElement).assignedNodes({ + flatten: true, + }); + return nodes + .map((n) => n.textContent || '') + .join('') + .trim(); + } + } + return (element.textContent || '').trim(); + } + } + + /** + * Get accessible description using dom-accessibility-api + */ + getAccessibleDescription(element: Element | null): string { + if (!element) { + return ''; + } + + try { + return computeAccessibleDescription(element); + } catch { + return ''; + } + } + + /** + * Generate announcement for an element based on its role and state + */ + announceElement(element: Element | null): void { + if (!element || element === this.lastAnnouncedElement) { + return; + } + + this.lastAnnouncedElement = element; + + const role = this.computeRole(element); + const name = this.getAccessibleName(element); + const description = this.getAccessibleDescription(element); + + // Build the announcement based on role + let announcement = this.buildRoleAnnouncement(element, role, name); + + // Add description if available + if (description) { + announcement += ` ${description}`; + } + + if (announcement) { + this.say(announcement); + } + } + + /** + * Build announcement string based on role + */ + buildRoleAnnouncement( + element: Element, + role: string, + name: string + ): string { + const announcements: Record = { + link: () => { + const visited = element.matches(':visited') ? 'visited ' : ''; + return `${visited}Link, ${name || 'unlabeled'}. Press Enter to follow.`; + }, + button: () => { + const pressed = element.getAttribute('aria-pressed'); + const expanded = element.getAttribute('aria-expanded'); + let state = ''; + if (pressed === 'true') { + state = ', pressed'; + } else if (pressed === 'false') { + state = ', not pressed'; + } + if (expanded === 'true') { + state += ', expanded'; + } else if (expanded === 'false') { + state += ', collapsed'; + } + return `Button, ${name || 'unlabeled'}${state}. Press Space or Enter to activate.`; + }, + checkbox: () => { + const inputElement = element as HTMLInputElement; + const checked = + inputElement.checked || + element.getAttribute('aria-checked') === 'true'; + const mixed = element.getAttribute('aria-checked') === 'mixed'; + const state = mixed + ? 'partially checked' + : checked + ? 'checked' + : 'not checked'; + return `Checkbox, ${name || 'unlabeled'}, ${state}. Press Space to toggle.`; + }, + radio: () => { + const inputElement = element as HTMLInputElement; + const checked = + inputElement.checked || + element.getAttribute('aria-checked') === 'true'; + return `Radio button, ${name || 'unlabeled'}, ${checked ? 'selected' : 'not selected'}.`; + }, + switch: () => { + const inputElement = element as HTMLInputElement; + const checked = + element.getAttribute('aria-checked') === 'true' || + inputElement.checked; + return `Switch, ${name || 'unlabeled'}, ${checked ? 'on' : 'off'}. Press Space to toggle.`; + }, + textbox: () => { + const inputElement = element as HTMLInputElement; + const value = inputElement.value || ''; + const required = + element.hasAttribute('required') || + element.getAttribute('aria-required') === 'true'; + const invalid = element.getAttribute('aria-invalid') === 'true'; + const readonly = + element.hasAttribute('readonly') || + element.getAttribute('aria-readonly') === 'true'; + let state = ''; + if (required) { + state += ', required'; + } + if (invalid) { + state += ', invalid entry'; + } + if (readonly) { + state += ', read only'; + } + return `Text field, ${name || 'unlabeled'}${state}. ${value ? `Contains: ${value}` : 'Empty.'}`; + }, + searchbox: () => { + const inputElement = element as HTMLInputElement; + const value = inputElement.value || ''; + return `Search field, ${name || 'unlabeled'}. ${value ? `Contains: ${value}` : 'Empty.'}`; + }, + combobox: () => { + const expanded = + element.getAttribute('aria-expanded') === 'true'; + const selectElement = element as HTMLSelectElement; + const value = + (element as HTMLInputElement).value || + selectElement.options?.[selectElement.selectedIndex] + ?.text || + ''; + return `Combo box, ${name || 'unlabeled'}, ${expanded ? 'expanded' : 'collapsed'}. ${value ? `Selected: ${value}` : ''} Press Space to open.`; + }, + listbox: () => { + const expanded = element.getAttribute('aria-expanded'); + let state = ''; + if (expanded === 'true') { + state = ', expanded'; + } else if (expanded === 'false') { + state = ', collapsed'; + } + return `List box, ${name || 'unlabeled'}${state}.`; + }, + slider: () => { + const inputElement = element as HTMLInputElement; + const value = + inputElement.value || + element.getAttribute('aria-valuenow') || + ''; + const min = + inputElement.min || + element.getAttribute('aria-valuemin') || + '0'; + const max = + inputElement.max || + element.getAttribute('aria-valuemax') || + '100'; + const valueText = + element.getAttribute('aria-valuetext') || value; + return `Slider, ${name || 'unlabeled'}. Value: ${valueText}. Range: ${min} to ${max}.`; + }, + spinbutton: () => { + const inputElement = element as HTMLInputElement; + const value = + inputElement.value || + element.getAttribute('aria-valuenow') || + ''; + return `Spin button, ${name || 'unlabeled'}. Value: ${value}. Use arrow keys to adjust.`; + }, + heading: () => { + const level = + element.getAttribute('aria-level') || + (element.tagName + ? element.tagName.match(/h(\d)/i)?.[1] + : null) || + '2'; + return `Heading level ${level}, ${name}`; + }, + img: () => { + if (!name && element.getAttribute('alt') === '') { + return ''; // Decorative image, don't announce + } + return `Image, ${name || 'no description'}`; + }, + figure: () => `Figure, ${name || ''}`, + listitem: () => { + const list = element.closest('ol, ul, [role="list"]'); + const items = list + ? list.querySelectorAll('li, [role="listitem"]') + : []; + const index = Array.from(items).indexOf(element) + 1; + const total = items.length; + return `${name}. List item ${index} of ${total}.`; + }, + option: () => { + const optionElement = element as HTMLOptionElement; + const selected = + element.getAttribute('aria-selected') === 'true' || + optionElement.selected; + const list = element.closest('[role="listbox"], select'); + const options = list + ? list.querySelectorAll('[role="option"], option') + : []; + const index = Array.from(options).indexOf(element) + 1; + return `${name}${selected ? ', selected' : ''}. Option ${index} of ${options.length}.`; + }, + menuitem: () => `Menu item, ${name}`, + menuitemcheckbox: () => { + const checked = element.getAttribute('aria-checked') === 'true'; + return `Menu item checkbox, ${name}, ${checked ? 'checked' : 'not checked'}`; + }, + menuitemradio: () => { + const checked = element.getAttribute('aria-checked') === 'true'; + return `Menu item radio, ${name}, ${checked ? 'selected' : 'not selected'}`; + }, + tab: () => { + const selected = + element.getAttribute('aria-selected') === 'true'; + const tablist = element.closest('[role="tablist"]'); + const tabs = tablist + ? tablist.querySelectorAll('[role="tab"]') + : []; + const index = Array.from(tabs).indexOf(element) + 1; + return `Tab, ${name}${selected ? ', selected' : ''}. ${index} of ${tabs.length}.`; + }, + tabpanel: () => `Tab panel, ${name}`, + navigation: () => `Navigation${name ? `, ${name}` : ''}`, + main: () => `Main content${name ? `, ${name}` : ''}`, + banner: () => `Banner${name ? `, ${name}` : ''}`, + contentinfo: () => `Content info${name ? `, ${name}` : ''}`, + complementary: () => `Complementary${name ? `, ${name}` : ''}`, + region: () => `Region, ${name}`, + article: () => `Article${name ? `, ${name}` : ''}`, + form: () => `Form${name ? `, ${name}` : ''}`, + search: () => `Search${name ? `, ${name}` : ''}`, + dialog: () => { + const modal = element.getAttribute('aria-modal') === 'true'; + return `${modal ? 'Modal ' : ''}Dialog, ${name || 'unlabeled'}`; + }, + alertdialog: () => `Alert dialog, ${name || 'unlabeled'}`, + alert: () => `Alert: ${name}`, + status: () => `Status: ${name}`, + log: () => `Log: ${name}`, + marquee: () => `Marquee: ${name}`, + timer: () => `Timer: ${name}`, + progressbar: () => { + const value = element.getAttribute('aria-valuenow'); + const valueText = element.getAttribute('aria-valuetext'); + if (valueText) { + return `Progress bar, ${name}. ${valueText}`; + } + if (value) { + return `Progress bar, ${name}. ${value} percent`; + } + return `Progress bar, ${name}. Loading...`; + }, + meter: () => { + const meterElement = element as HTMLMeterElement; + const value = + element.getAttribute('aria-valuenow') || meterElement.value; + return `Meter, ${name}. Value: ${value}`; + }, + tooltip: () => `Tooltip: ${name}`, + tree: () => `Tree, ${name}`, + treeitem: () => { + const expanded = element.getAttribute('aria-expanded'); + const selected = + element.getAttribute('aria-selected') === 'true'; + let state = selected ? ', selected' : ''; + if (expanded === 'true') { + state += ', expanded'; + } else if (expanded === 'false') { + state += ', collapsed'; + } + return `Tree item, ${name}${state}`; + }, + grid: () => `Grid, ${name}`, + gridcell: () => name, + row: () => { + const index = element.getAttribute('aria-rowindex'); + return index ? `Row ${index}, ${name}` : name; + }, + rowheader: () => `Row header, ${name}`, + columnheader: () => `Column header, ${name}`, + cell: () => name, + table: () => { + const rows = + element.querySelectorAll('tr, [role="row"]').length; + const cols = + element.querySelector('tr, [role="row"]')?.children + .length || 0; + return `Table, ${name || 'unlabeled'}. ${rows} rows, ${cols} columns.`; + }, + list: () => { + const items = element.querySelectorAll( + 'li, [role="listitem"]' + ).length; + return `List${name ? `, ${name}` : ''}. ${items} items.`; + }, + menu: () => `Menu, ${name}`, + menubar: () => `Menu bar, ${name}`, + toolbar: () => `Toolbar, ${name}`, + group: () => (name ? `Group, ${name}` : ''), + separator: () => 'Separator', + generic: () => name || '', + }; + + const announceFn = announcements[role]; + return announceFn ? announceFn() : name || `${role}`; + } + + /** + * Add visual focus indicator styles to the document + */ + addStyles(): void { + if (!this.storyDocument || !this.storyDocument.head) { + return; + } + + this.removeStyles(); + + const styleElement = this.storyDocument.createElement('style'); + styleElement.id = 'screen-reader-addon-styles'; + styleElement.textContent = ` + [data-sr-current] { + outline: 3px solid #005fcc !important; + outline-offset: 2px !important; + } + `; + this.storyDocument.head.appendChild(styleElement); + } + + /** + * Remove visual focus indicator styles + */ + removeStyles(): void { + if (!this.storyDocument) { + return; + } + const styles = this.storyDocument.getElementById( + 'screen-reader-addon-styles' + ); + if (styles) { + styles.remove(); + } + } + + /** + * Speak/display the announcement + */ + say(speech: string): void { + if (!speech) { + return; + } + + if (this.voiceEnabled) { + const utterance = new SpeechSynthesisUtterance(speech); + speechSynthesis.cancel(); + speechSynthesis.speak(utterance); + } + + if (this.textEnabled) { + dispatchTextChanged(speech); + } + } + + /** + * Update visual focus indicator + */ + updateFocusIndicator(element: Element | null): void { + if (!element) { + return; + } + + // Remove previous focus indicator + const prev = querySelectorDeep( + '[data-sr-current]', + this.storyDocument as Document + ); + if (prev) { + prev.removeAttribute('data-sr-current'); + } + + // Add focus indicator to current element + if (element.setAttribute) { + element.setAttribute('data-sr-current', 'true'); + } + } + + /** + * Find the deeply focused element, traversing into shadow DOMs + */ + getDeepActiveElement( + root: Document | ShadowRoot = this.storyDocument as Document + ): Element | null { + let active = root.activeElement; + + // Keep traversing into shadow roots to find the actual focused element + while (active && active.shadowRoot && active.shadowRoot.activeElement) { + active = active.shadowRoot.activeElement; + } + + return active; + } + + /** + * Handle focus changes - main way we track navigation + * + * Real screen readers announce the actual focused element (often inside shadow DOM). + * We traverse into shadow DOM to find the real focused element, just like browsers do. + */ + private onFocusIn(event: FocusEvent): void { + if (!this.isRunning) { + return; + } + + const target = event.target as Element | null; + if (!target) { + return; + } + + // Find the actual focused element (may be deep inside shadow DOM) + const deepActive = this.getDeepActiveElement(); + + // Use the deep active element if it has a name, otherwise use the event target + const deepName = deepActive ? this.getAccessibleName(deepActive) : ''; + const deepRole = deepActive ? this.computeRole(deepActive) : 'generic'; + + // Prefer the deep element if it has a meaningful name and role + const elementToAnnounce = + deepName && deepRole !== 'generic' ? deepActive : target; + + this.updateFocusIndicator(elementToAnnounce); + this.announceElement(elementToAnnounce); + } + + /** + * Handle keyboard events for additional navigation feedback + */ + private onKeyDown(event: KeyboardEvent): void { + if (!this.isRunning) { + return; + } + + // After arrow key navigation, check if aria-activedescendant changed + if ( + ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].indexOf( + event.key + ) !== -1 + ) { + setTimeout(() => { + this.checkActiveDescendant(event.target as Element); + }, 10); + } + } + + /** + * Check for aria-activedescendant changes (used by menus, listboxes, etc.) + */ + checkActiveDescendant(element: Element | null): void { + if (!element) { + return; + } + + const activeId = element.getAttribute('aria-activedescendant'); + if (activeId && this.storyDocument) { + const activeElement = this.storyDocument.getElementById(activeId); + if (activeElement) { + this.updateFocusIndicator(activeElement); + this.announceElement(activeElement); + return; + } + } + + // Also check shadow DOM for the active element + const elementWithShadow = element as Element & { + shadowRoot?: ShadowRoot; + }; + if (elementWithShadow.shadowRoot) { + const activeInShadow = elementWithShadow.shadowRoot.querySelector( + '[aria-selected="true"], [aria-current="true"], :focus' + ); + if (activeInShadow) { + this.updateFocusIndicator(activeInShadow); + this.announceElement(activeInShadow); + } + } + } + + /** + * Watch for DOM mutations to catch dynamic changes + */ + private onMutation(mutations: MutationRecord[]): void { + if (!this.isRunning) { + return; + } + + const watchedAttrs = [ + 'aria-selected', + 'aria-checked', + 'aria-expanded', + 'aria-activedescendant', + 'aria-pressed', + 'aria-invalid', + ]; + + mutations.forEach((mutation) => { + if (mutation.type === 'attributes') { + const target = mutation.target as Element; + + if ( + mutation.attributeName && + watchedAttrs.indexOf(mutation.attributeName) !== -1 + ) { + if (mutation.attributeName === 'aria-activedescendant') { + this.checkActiveDescendant(target); + } else if ( + target.getAttribute(mutation.attributeName) === + 'true' && + mutation.attributeName === 'aria-selected' + ) { + this.updateFocusIndicator(target); + this.announceElement(target); + } + } + } + }); + } + + /** + * Watch for live region updates (aria-live) + */ + private onLiveRegionMutation(mutations: MutationRecord[]): void { + if (!this.isRunning) { + return; + } + + mutations.forEach((mutation) => { + const target = mutation.target as Element; + + // Check if this is inside a live region + const liveRegion = ( + target.closest ? target : (target.parentElement as Element) + )?.closest( + '[aria-live], [role="alert"], [role="status"], [role="log"]' + ); + if (!liveRegion) { + return; + } + + const politeness = + liveRegion.getAttribute('aria-live') || + (liveRegion.getAttribute('role') === 'alert' + ? 'assertive' + : 'polite'); + + // Get the new content + let announcement = ''; + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node.textContent) { + announcement += node.textContent.trim() + ' '; + } + }); + } else if (mutation.type === 'characterData') { + announcement = target.textContent || ''; + } + + if (announcement.trim()) { + // For assertive, interrupt current speech + if (politeness === 'assertive') { + speechSynthesis.cancel(); + } + this.say(announcement.trim()); + } + }); + } + + /** + * Set up mutation observer for aria attribute changes + */ + setupMutationObserver(): void { + if (!this.storyDocument || !this.storyDocument.body) { + return; + } + + this.mutationObserver = new MutationObserver(this.handleMutation); + this.mutationObserver.observe(this.storyDocument.body, { + attributes: true, + subtree: true, + attributeFilter: [ + 'aria-selected', + 'aria-checked', + 'aria-expanded', + 'aria-activedescendant', + 'aria-pressed', + 'aria-invalid', + ], + }); + } + + /** + * Set up observer for live regions + */ + setupLiveRegionObserver(): void { + if (!this.storyDocument || !this.storyDocument.body) { + return; + } + + this.liveRegionObserver = new MutationObserver( + this.onLiveRegionMutation.bind(this) + ); + this.liveRegionObserver.observe(this.storyDocument.body, { + childList: true, + subtree: true, + characterData: true, + }); + } + + /** + * Start the screen reader + */ + start(iframe?: HTMLIFrameElement | null): void { + let targetIframe = iframe; + if (!targetIframe) { + targetIframe = + (document.getElementById( + 'storybook-preview-iframe' + ) as HTMLIFrameElement) || + (document.querySelector( + 'iframe[data-is-storybook="true"]' + ) as HTMLIFrameElement) || + (document.querySelector('iframe') as HTMLIFrameElement); + } + + if ( + !targetIframe || + !targetIframe.contentWindow || + !targetIframe.contentWindow.document || + !targetIframe.contentWindow.document.body + ) { + // eslint-disable-next-line no-console + console.warn('[Screen Reader] Waiting for iframe...'); + setTimeout(() => { + this.start(targetIframe); + }, 200); + return; + } + + // Stop any existing instance first + this.stop(); + + this.storyDocument = targetIframe.contentWindow.document; + + // Wait for document to be ready + if (this.storyDocument.readyState === 'loading') { + this.storyDocument.addEventListener('DOMContentLoaded', () => { + this.start(targetIframe); + }); + return; + } + + this.addStyles(); + + // Listen for focus changes + this.storyDocument.addEventListener( + 'focusin', + this.handleFocusIn as EventListener, + true + ); + this.storyDocument.addEventListener( + 'keydown', + this.handleKeyDown as EventListener, + true + ); + + // Set up mutation observers + this.setupMutationObserver(); + this.setupLiveRegionObserver(); + + this.isRunning = true; + this.lastAnnouncedElement = null; + + this.say('Screen reader enabled. Use Tab or arrow keys to navigate.'); + + // Announce current focus if any + const currentFocus = this.storyDocument.activeElement; + if (currentFocus && currentFocus !== this.storyDocument.body) { + this.updateFocusIndicator(currentFocus); + this.announceElement(currentFocus); + } + } + + /** + * Stop the screen reader + */ + stop(): void { + if (!this.isRunning && !this.storyDocument) { + return; + } + + // Clean up event listeners + if (this.storyDocument) { + this.storyDocument.removeEventListener( + 'focusin', + this.handleFocusIn as EventListener, + true + ); + this.storyDocument.removeEventListener( + 'keydown', + this.handleKeyDown as EventListener, + true + ); + } + + // Clean up mutation observers + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + this.mutationObserver = null; + } + + if (this.liveRegionObserver) { + this.liveRegionObserver.disconnect(); + this.liveRegionObserver = null; + } + + // Remove focus indicator + if (this.storyDocument) { + const current = querySelectorDeep( + '[data-sr-current]', + this.storyDocument + ); + if (current) { + current.removeAttribute('data-sr-current'); + } + this.removeStyles(); + } + + this.isRunning = false; + this.lastAnnouncedElement = null; + + if (this.voiceEnabled || this.textEnabled) { + this.say('Screen reader disabled'); + } + } +} diff --git a/2nd-gen/packages/swc/.storybook/main.ts b/2nd-gen/packages/swc/.storybook/main.ts index 3f29c12473a..83e11926401 100644 --- a/2nd-gen/packages/swc/.storybook/main.ts +++ b/2nd-gen/packages/swc/.storybook/main.ts @@ -27,6 +27,8 @@ const config = { '@storybook/addon-a11y', '@storybook/addon-designs', '@storybook/addon-vitest', + // Screen reader addon (local) + resolve(__dirname, './addons/screen-reader-addon'), ], viteFinal: async (config) => { return mergeConfig(config, { diff --git a/2nd-gen/packages/swc/package.json b/2nd-gen/packages/swc/package.json index 40c49984cb6..4f3b70dec82 100644 --- a/2nd-gen/packages/swc/package.json +++ b/2nd-gen/packages/swc/package.json @@ -68,11 +68,14 @@ "@vitest/coverage-v8": "3.2.4", "@vitest/ui": "3.2.4", "@wc-toolkit/storybook-helpers": "10.0.0", + "aria-query": "^5.3.0", "autoprefixer": "10.4.21", + "dom-accessibility-api": "^0.7.0", "glob": "11.0.3", "playwright": "1.53.1", "postcss": "8.5.6", "postcss-preset-env": "10.4.0", + "query-selector-shadow-dom": "^1.0.0", "react": "19.1.1", "react-dom": "19.1.1", "rimraf": "6.0.1", diff --git a/package.json b/package.json index f5cb45400c1..5acd99fdafa 100644 --- a/package.json +++ b/package.json @@ -27,13 +27,13 @@ "start:1st-gen": "yarn workspace @spectrum-web-components/1st-gen start", "start:2nd-gen": "yarn workspace @spectrum-web-components/2nd-gen start", "test": "run-p test:2nd-gen test:1st-gen", + "test:1st-gen": "yarn workspace @spectrum-web-components/1st-gen test", + "test:2nd-gen": "yarn workspace @spectrum-web-components/2nd-gen test", "test:a11y": "playwright test --config=playwright.a11y.config.ts", - "test:a11y:ui": "playwright test --config=playwright.a11y.config.ts --ui", "test:a11y:1st": "playwright test --config=playwright.a11y.config.ts --project=1st-gen", "test:a11y:2nd": "playwright test --config=playwright.a11y.config.ts --project=2nd-gen", "test:a11y:report": "playwright show-report 2nd-gen/test/playwright-a11y/report", - "test:1st-gen": "yarn workspace @spectrum-web-components/1st-gen test", - "test:2nd-gen": "yarn workspace @spectrum-web-components/2nd-gen test" + "test:a11y:ui": "playwright test --config=playwright.a11y.config.ts --ui" }, "workspaces": [ "1st-gen", diff --git a/yarn.lock b/yarn.lock index 1013d66e8b6..c704b96e2b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -189,12 +189,15 @@ __metadata: "@vitest/coverage-v8": "npm:3.2.4" "@vitest/ui": "npm:3.2.4" "@wc-toolkit/storybook-helpers": "npm:10.0.0" + aria-query: "npm:^5.3.0" autoprefixer: "npm:10.4.21" + dom-accessibility-api: "npm:^0.7.0" glob: "npm:11.0.3" lit: "npm:^2.5.0 || ^3.1.3" playwright: "npm:1.53.1" postcss: "npm:8.5.6" postcss-preset-env: "npm:10.4.0" + query-selector-shadow-dom: "npm:^1.0.0" react: "npm:19.1.1" react-dom: "npm:19.1.1" rimraf: "npm:6.0.1" @@ -10417,7 +10420,7 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:^5.0.0, aria-query@npm:^5.1.3": +"aria-query@npm:^5.0.0, aria-query@npm:^5.1.3, aria-query@npm:^5.3.0": version: 5.3.2 resolution: "aria-query@npm:5.3.2" checksum: 10c0/003c7e3e2cff5540bf7a7893775fc614de82b0c5dde8ae823d47b7a28a9d4da1f7ed85f340bdb93d5649caa927755f0e31ecc7ab63edfdfc00c8ef07e505e03e @@ -13656,6 +13659,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.7.0": + version: 0.7.1 + resolution: "dom-accessibility-api@npm:0.7.1" + checksum: 10c0/1667710482e373913d610828c3eba062165bd747f7d7b5309d70a8ef02ebe14d3f26ec1b2a8edb6d9c9c045bd755426fdc87941e88d70c0e9907fd1ba762b277 + languageName: node + linkType: hard + "dom-converter@npm:^0.2.0": version: 0.2.0 resolution: "dom-converter@npm:0.2.0" @@ -25902,6 +25912,13 @@ __metadata: languageName: node linkType: hard +"query-selector-shadow-dom@npm:^1.0.0": + version: 1.0.1 + resolution: "query-selector-shadow-dom@npm:1.0.1" + checksum: 10c0/f36de03f170ff1da69c3eecfa7f8b01e450a46dd266c921e17f36076ec59862eee00179489f30cb17c118bb56e868436578c01ea66f671fb358750d6ae474125 + languageName: node + linkType: hard + "query-string@npm:^7.1.0": version: 7.1.3 resolution: "query-string@npm:7.1.3"