From 5461f3473bd538b8646ae10847811e60dd962b24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:40:34 +0000 Subject: [PATCH 1/4] Initial plan From 2a3d8a3c3bc898caf821e3c684b2ac776ee26c71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:56:35 +0000 Subject: [PATCH 2/4] Add SelectPanel repro story for live region inside closed dialog Co-authored-by: siddharthkp <1863771+siddharthkp@users.noreply.github.com> --- .../SelectPanel/SelectPanel.dev.stories.tsx | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx index 9faab5de79a..304312d0174 100644 --- a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx @@ -6,12 +6,22 @@ import {useState, useEffect, useRef} from 'react' import {Button} from '../Button' import {SelectPanel} from '.' import type {ItemInput} from '.' +import Checkbox from '../Checkbox' import FormControl from '../FormControl' import Text from '../Text' import Select from '../Select/Select' import type {SelectPanelSecondaryAction} from './SelectPanel' import classes from './SelectPanel.stories.module.css' +declare module 'react' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + 'live-region': React.HTMLAttributes + } + } +} + const meta: Meta = { title: 'Components/SelectPanel/Dev', component: SelectPanel, @@ -445,3 +455,48 @@ export const WithInitialFocusEnabled = ({onCancel, secondaryAction}: ParamProps) /> ) } + +export const WithLiveRegionInsideClosedDialog = ({onCancel, secondaryAction}: ParamProps) => { + const [selected, setSelected] = useState(simpleItems.slice(1, 3)) + const [filter, setFilter] = useState('') + const filteredItems = simpleItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + const [open, setOpen] = useState(false) + const [liveRegionInsideClosedDialog, setLiveRegionInsideClosedDialog] = useState(true) + + return ( + <> +

+ Open the SelectPanel and enter a query with no results. With the checkbox enabled, the empty-state message is + not announced. Disable the checkbox and repeat; the message is announced through a body-level live region. +

+ + Render a live region inside a closed dialog + setLiveRegionInsideClosedDialog(!liveRegionInsideClosedDialog)} + /> + + {liveRegionInsideClosedDialog ? : null} + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} + onCancel={onCancel} + secondaryAction={secondaryAction} + /> + + ) +} From d3a7d0dda38f51f1bf2f0b672cd29ae8b89d3cc8 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 23 Sep 2026 14:50:56 +0200 Subject: [PATCH 3/4] Improve SelectPanel live region reproduction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react/.storybook/main.ts | 6 +- .../SelectPanel/SelectPanel.dev.stories.tsx | 55 ----- ...nel.live-region-bug.dev.stories.module.css | 151 ++++++++++++++ ...electPanel.live-region-bug.dev.stories.tsx | 194 ++++++++++++++++++ 4 files changed, 350 insertions(+), 56 deletions(-) create mode 100644 packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.module.css create mode 100644 packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.tsx 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.dev.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx index 304312d0174..9faab5de79a 100644 --- a/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.dev.stories.tsx @@ -6,22 +6,12 @@ import {useState, useEffect, useRef} from 'react' import {Button} from '../Button' import {SelectPanel} from '.' import type {ItemInput} from '.' -import Checkbox from '../Checkbox' import FormControl from '../FormControl' import Text from '../Text' import Select from '../Select/Select' import type {SelectPanelSecondaryAction} from './SelectPanel' import classes from './SelectPanel.stories.module.css' -declare module 'react' { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - interface IntrinsicElements { - 'live-region': React.HTMLAttributes - } - } -} - const meta: Meta = { title: 'Components/SelectPanel/Dev', component: SelectPanel, @@ -455,48 +445,3 @@ export const WithInitialFocusEnabled = ({onCancel, secondaryAction}: ParamProps) /> ) } - -export const WithLiveRegionInsideClosedDialog = ({onCancel, secondaryAction}: ParamProps) => { - const [selected, setSelected] = useState(simpleItems.slice(1, 3)) - const [filter, setFilter] = useState('') - const filteredItems = simpleItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) - const [open, setOpen] = useState(false) - const [liveRegionInsideClosedDialog, setLiveRegionInsideClosedDialog] = useState(true) - - return ( - <> -

- Open the SelectPanel and enter a query with no results. With the checkbox enabled, the empty-state message is - not announced. Disable the checkbox and repeat; the message is announced through a body-level live region. -

- - Render a live region inside a closed dialog - setLiveRegionInsideClosedDialog(!liveRegionInsideClosedDialog)} - /> - - {liveRegionInsideClosedDialog ? : null} - ( - - )} - open={open} - onOpenChange={setOpen} - items={filteredItems} - selected={selected} - onSelectedChange={setSelected} - onFilterChange={setFilter} - width="medium" - message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} - onCancel={onCancel} - secondaryAction={secondaryAction} - /> - - ) -} 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..7f1ae533d25 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.tsx @@ -0,0 +1,194 @@ +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' + +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 getCurrentRoute = () => getRegionRoute(document.querySelector('live-region') ?? undefined) + +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 [currentRoute, setCurrentRoute] = useState(getCurrentRoute) + + const filteredItems = useMemo( + () => items.filter(item => item.text?.toLowerCase().startsWith(filter.toLowerCase())), + [filter], + ) + + useEffect(() => { + const timeout = window.setTimeout(() => setCurrentRoute(getCurrentRoute()), 700) + return () => window.clearTimeout(timeout) + }, [filter, open, renderConflict]) + + const hasNoResults = filter.length > 0 && filteredItems.length === 0 + const currentRouteIsHidden = currentRoute === 'closed-dialog' + const messageStatus = hasNoResults ? 'Written' : 'Waiting for a no-results query' + const availableToScreenReader = + !hasNoResults || currentRoute === '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 + } + /> +
+ +
+

Current lookup

+
+
+
Selector
+
+ {"document.querySelector('live-region')"} +
+
+
+
Matched node
+
+ {matchedNodeLabels[currentRoute]} +
+
+
+
Inside a closed dialog
+
{currentRoute === 'missing' ? 'Not determined' : currentRouteIsHidden ? 'Yes' : 'No'}
+
+
+
Message
+
{messageStatus}
+
+
+
Available to a screen reader
+
{availableToScreenReader}
+
+
+
+ {!hasNoResults + ? 'Run the steps above to observe the announcement target.' + : currentRouteIsHidden + ? 'Bug reproduced: the message is inside a closed dialog.' + : 'Control case: 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: () => , +} From 2b04c47157e82f8e179e05e95cfa728c9a71cf05 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 25 Sep 2026 15:38:38 +0200 Subject: [PATCH 4/4] Fix live region story diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...electPanel.live-region-bug.dev.stories.tsx | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) 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 index 7f1ae533d25..0a00534d2eb 100644 --- a/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.live-region-bug.dev.stories.tsx @@ -20,6 +20,10 @@ 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'}, @@ -34,7 +38,19 @@ const getRegionRoute = (region: Element | undefined): RegionRoute => { return region.closest('dialog:not([open])') ? 'closed-dialog' : 'available' } -const getCurrentRoute = () => getRegionRoute(document.querySelector('live-region') ?? undefined) +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', @@ -47,23 +63,31 @@ const InteractiveExplainer = () => { const [filter, setFilter] = useState('') const [open, setOpen] = useState(false) const [renderConflict, setRenderConflict] = useState(true) - const [currentRoute, setCurrentRoute] = useState(getCurrentRoute) + 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(() => setCurrentRoute(getCurrentRoute()), 700) + const timeout = window.setTimeout( + () => + setAnnouncementTarget(hasNoResults ? findAnnouncementTarget(expectedMessage) : {message: '', route: 'missing'}), + 1200, + ) return () => window.clearTimeout(timeout) - }, [filter, open, renderConflict]) + }, [expectedMessage, hasNoResults, open, renderConflict]) - const hasNoResults = filter.length > 0 && filteredItems.length === 0 - const currentRouteIsHidden = currentRoute === 'closed-dialog' - const messageStatus = hasNoResults ? 'Written' : 'Waiting for a no-results query' + const currentRouteIsHidden = announcementTarget.route === 'closed-dialog' + const messageStatus = !hasNoResults + ? 'Waiting for a no-results query' + : announcementTarget.message || 'No matching announcement found' const availableToScreenReader = - !hasNoResults || currentRoute === 'missing' ? 'Not determined' : currentRouteIsHidden ? 'No' : 'Yes' + !hasNoResults || announcementTarget.route === 'missing' ? 'Not determined' : currentRouteIsHidden ? 'No' : 'Yes' return (
@@ -123,23 +147,25 @@ const InteractiveExplainer = () => {
-

Current lookup

+

Announcement destination

-
Selector
+
Inspection
- {"document.querySelector('live-region')"} + Matching shadow-root message
Matched node
- {matchedNodeLabels[currentRoute]} + {matchedNodeLabels[announcementTarget.route]}
Inside a closed dialog
-
{currentRoute === 'missing' ? 'Not determined' : currentRouteIsHidden ? 'Yes' : 'No'}
+
+ {announcementTarget.route === 'missing' ? 'Not determined' : currentRouteIsHidden ? 'Yes' : 'No'} +
Message
@@ -153,9 +179,11 @@ const InteractiveExplainer = () => {
{!hasNoResults ? 'Run the steps above to observe the announcement target.' - : currentRouteIsHidden - ? 'Bug reproduced: the message is inside a closed dialog.' - : 'Control case: the message is in an available live region.'} + : 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.'}