diff --git a/packages/react/.storybook/main.ts b/packages/react/.storybook/main.ts index a653e893ed1..dac4025eac3 100644 --- a/packages/react/.storybook/main.ts +++ b/packages/react/.storybook/main.ts @@ -18,7 +18,11 @@ const config: StorybookConfig = { DEPLOY_ENV === 'development' ? ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'] : // Don't include dev stories in production - ['../src/**/*.mdx', '../src/**/!(*.dev).stories.@(js|jsx|ts|tsx)'], + [ + '../src/**/*.mdx', + '../src/**/!(*.dev).stories.@(js|jsx|ts|tsx)', + '../src/SelectPanel/SelectPanel.live-region-bug.dev.stories.tsx', + ], staticDirs: ['../static'], diff --git a/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.module.css b/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.module.css new file mode 100644 index 00000000000..698e63e3898 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.module.css @@ -0,0 +1,151 @@ +.Page { + max-width: 1200px; + padding: var(--base-size-48) var(--base-size-32); + margin: 0 auto; + color: var(--fgColor-default); +} + +.Header { + max-width: 880px; + padding-bottom: var(--base-size-40); +} + +.Title { + margin: 0 0 var(--base-size-16); + font-size: var(--text-title-size-large); + line-height: var(--text-title-lineHeight-large); +} + +.Summary { + margin: 0; + color: var(--fgColor-muted); + font-size: var(--text-body-size-large); + line-height: var(--text-body-lineHeight-medium); +} + +.Section { + padding: var(--base-size-32) 0; + border-top: var(--borderWidth-thin) solid var(--borderColor-default); +} + +.Section:last-child { + padding-bottom: 0; +} + +.Section h2 { + margin: 0 0 var(--base-size-16); + font-size: var(--text-title-size-medium); + line-height: var(--text-title-lineHeight-medium); +} + +.Section h3 { + margin: var(--base-size-20) 0 var(--base-size-8); + font-size: var(--text-body-size-medium); +} + +.Section p { + line-height: var(--text-body-lineHeight-medium); +} + +.Steps { + padding-left: var(--base-size-24); + margin: 0 0 var(--base-size-24); + line-height: var(--text-body-lineHeight-medium); +} + +.Demo { + display: grid; + gap: var(--base-size-20); + align-content: start; + min-width: 0; + min-height: 280px; + padding: var(--base-size-32); + background: var(--bgColor-default); +} + +.Demo h3 { + margin-top: 0; +} + +.Example { + overflow: hidden; + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-large); + box-shadow: var(--shadow-resting-small); +} + +.Diagnostics { + overflow: hidden; + border-top: var(--borderWidth-thin) solid var(--borderColor-default); + background: var(--bgColor-muted); +} + +.Diagnostics h3 { + padding: var(--base-size-16) var(--base-size-20); + margin: 0; + border-bottom: var(--borderWidth-thin) solid var(--borderColor-default); +} + +.Diagnostics dl { + margin: 0; +} + +.DiagnosticRow { + display: grid; + padding: var(--base-size-12) var(--base-size-20); + border-bottom: var(--borderWidth-thin) solid var(--borderColor-muted); + grid-template-columns: minmax(160px, 0.6fr) minmax(0, 1fr); +} + +.DiagnosticRow dt { + color: var(--fgColor-muted); +} + +.DiagnosticRow dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.Result { + padding: var(--base-size-16) var(--base-size-20); + font-weight: var(--base-text-weight-semibold); +} + +.Result[data-state='broken'] { + color: var(--fgColor-danger); +} + +.Code { + padding: var(--base-size-20); + margin: var(--base-size-20) 0; + overflow-x: auto; + border: var(--borderWidth-thin) solid var(--borderColor-default); + border-radius: var(--borderRadius-large); + background: var(--bgColor-neutral-emphasis); + box-shadow: var(--shadow-resting-small); + color: var(--fgColor-onEmphasis); + font-size: var(--text-body-size-small); + line-height: var(--text-codeBlock-lineHeight-default); +} + +.FixList { + padding-left: var(--base-size-24); + margin: 0; + line-height: var(--text-body-lineHeight-medium); +} + +@media (max-width: 899px) { + .Page { + padding: var(--base-size-24) var(--base-size-16); + } + + .DiagnosticRow { + gap: var(--base-size-4); + grid-template-columns: 1fr; + } + + .Demo { + min-height: auto; + } +} diff --git a/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.tsx new file mode 100644 index 00000000000..0a00534d2eb --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.tsx @@ -0,0 +1,222 @@ +import {TriangleDownIcon} from '@primer/octicons-react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {createElement, useEffect, useMemo, useState} from 'react' + +import {Button} from '../Button' +import Checkbox from '../Checkbox' +import FormControl from '../FormControl' +import {SelectPanel, type ItemInput} from '.' +import classes from './SelectPanel.live-region-bug.dev.stories.module.css' + +const meta = { + title: 'Components/SelectPanel/Dev/Live region bug', + component: SelectPanel, + parameters: { + controls: {disable: true}, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj +type RegionRoute = 'closed-dialog' | 'available' | 'missing' +type AnnouncementTarget = { + message: string + route: RegionRoute +} + +const items: ItemInput[] = [ + {id: 1, text: 'JavaScript'}, + {id: 2, text: 'Ruby'}, + {id: 3, text: 'TypeScript'}, +] + +const LiveRegion = () => createElement('live-region') + +const getRegionRoute = (region: Element | undefined): RegionRoute => { + if (!region) return 'missing' + return region.closest('dialog:not([open])') ? 'closed-dialog' : 'available' +} + +const findAnnouncementTarget = (expectedMessage: string): AnnouncementTarget => { + for (const region of document.querySelectorAll('live-region')) { + const message = Array.from(region.shadowRoot?.querySelectorAll('[aria-live]') ?? []) + .map(element => element.textContent.trim()) + .find(text => text.includes(expectedMessage)) + + if (message) { + return {message, route: getRegionRoute(region)} + } + } + + return {message: '', route: 'missing'} +} + +const matchedNodeLabels: Record = { + 'closed-dialog': 'dialog:not([open]) > live-region', + available: 'body > live-region', + missing: 'No live-region found', +} + +const InteractiveExplainer = () => { + const [selected, setSelected] = useState([]) + const [filter, setFilter] = useState('') + const [open, setOpen] = useState(false) + const [renderConflict, setRenderConflict] = useState(true) + const [announcementTarget, setAnnouncementTarget] = useState({message: '', route: 'missing'}) + + const filteredItems = useMemo( + () => items.filter(item => item.text?.toLowerCase().startsWith(filter.toLowerCase())), + [filter], + ) + + const hasNoResults = filter.length > 0 && filteredItems.length === 0 + const expectedMessage = `No language found for “${filter}”. Adjust your search term to find another language.` + + useEffect(() => { + const timeout = window.setTimeout( + () => + setAnnouncementTarget(hasNoResults ? findAnnouncementTarget(expectedMessage) : {message: '', route: 'missing'}), + 1200, + ) + return () => window.clearTimeout(timeout) + }, [expectedMessage, hasNoResults, open, renderConflict]) + + const currentRouteIsHidden = announcementTarget.route === 'closed-dialog' + const messageStatus = !hasNoResults + ? 'Waiting for a no-results query' + : announcementTarget.message || 'No matching announcement found' + const availableToScreenReader = + !hasNoResults || announcementTarget.route === 'missing' ? 'Not determined' : currentRouteIsHidden ? 'No' : 'Yes' + + return ( +
+
+

SelectPanel can announce into a closed dialog

+

+ SelectPanel uses the first live-region in the document. If that element belongs to an unrelated, + closed dialog, the message is written but screen readers cannot reach it. +

+
+ +
+

Reproduce

+
    +
  1. Open the language picker.
  2. +
  3. + Search for zzz. +
  4. +
  5. Clear the checkbox and change the query to compare the working path.
  6. +
+ +
+
+

Rendered demo

+ + Add a live region inside a closed dialog + setRenderConflict(event.target.checked)} /> + + + {renderConflict ? : null} + + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + placeholderText="Filter languages" + message={ + hasNoResults + ? { + variant: 'empty', + title: `No language found for “${filter}”`, + body: 'Adjust your search term to find another language.', + } + : undefined + } + /> +
+ +
+

Announcement destination

+
+
+
Inspection
+
+ Matching shadow-root message +
+
+
+
Matched node
+
+ {matchedNodeLabels[announcementTarget.route]} +
+
+
+
Inside a closed dialog
+
+ {announcementTarget.route === 'missing' ? 'Not determined' : currentRouteIsHidden ? 'Yes' : 'No'} +
+
+
+
Message
+
{messageStatus}
+
+
+
Available to a screen reader
+
{availableToScreenReader}
+
+
+
+ {!hasNoResults + ? 'Run the steps above to observe the announcement target.' + : announcementTarget.route === 'missing' + ? 'No matching announcement was found.' + : currentRouteIsHidden + ? 'Bug reproduced: the message is inside a closed dialog.' + : 'Fix verified: the message is in an available live region.'} +
+
+
+
+ +
+

Potential fix

+

+ Resolve announcements from the active filter input instead of caching an arbitrary document-wide region. Also + make the shared fallback ignore regions inside closed dialogs. +

+
+          {`useAnnouncements
+- const liveRegion = document.querySelector('live-region')
+- announce(message, {delayMs})
++ announce(message, {delayMs, from: inputRef.current ?? undefined})
+
+@primer/live-region-element
+- container.querySelector('live-region')
++ first live-region that is not inside dialog:not([open])`}
+        
+

Why both changes?

+
    +
  • Scoping from the filter input keeps SelectPanel announcements in the active picker.
  • +
  • + Hardening the fallback protects standalone FilteredActionList and other live-region-element consumers. +
  • +
+
+
+ ) +} + +export const BugAndPotentialFix: Story = { + render: () => , +}