Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .agents/skills/live-regions/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
name: live-regions
description: 'Use when adding, modifying, reviewing, or testing screen reader announcements, including ariaNotify, AriaStatus, AriaAlert, and useAnnounce. Do not use for static accessible names or descriptions, or changes unrelated to dynamic announcements.'
---

# Live Regions

## Choose the announcement API

- Default to `ariaNotify` for imperative announcements in response to user actions.
- If code would otherwise use `role="status"`, use the internal `AriaStatus`
component instead.
- If code would otherwise use `role="alert"`, use the internal `AriaAlert`
component instead.
- Use `useAnnounce` to debounce calls to `ariaNotify`. Do not implement another
announcement timer or queue at the call site.
- Do not use `@primer/live-region-element` or query `<live-region>` elements.

The implementations live in `packages/react/src/live-region/`. Within Primer
React, import the components and hook from the relative `live-region` module.

## Announce in response to an action, not rendering

By default, announcements must not happen on initial page render. Call
`ariaNotify` from an event handler or in response to a user action, including
completion of asynchronous work initiated by that action.

Do not announce merely because a component renders, an effect runs, or props
change. An effect observing the completion of a user-initiated operation can be
appropriate, but mounting alone is not a reason to announce.

If a component appears in response to an action, its appearance can justify an
announcement. Either make the imperative announcement as part of that response
or use `announceOnShow` on `AriaStatus` or `AriaAlert`. Do not announce the same
message through both paths.

For content present on initial page render, explicitly use
`announceOnShow={false}`. `AriaStatus` currently defaults to `false`, but
`AriaAlert` currently defaults to `true`; do not rely on that default when the
alert is present on initial render.

## Import the polyfill

Code calling `ariaNotify`, directly or through `useAnnounce`, must import
`packages/react/src/polyfills/ariaNotify`. For a typical sibling component:

```tsx
import '../polyfills/ariaNotify'
```

Adjust the relative path for the file. The shared module imports
`@github/arianotify-polyfill` and provides the TypeScript declarations. Do not
redeclare the API or assume native browser support.

Use `document.ariaNotify(message, {priority: 'normal'})` for an ordinary status
announcement. Use `priority: 'high'` only for an urgent announcement that
corresponds to assertive behavior. `ariaNotify` takes a string, not a React node.

When an announcement belongs to a particular element or modal, use the relevant
element's `ariaNotify` so the announcement has the appropriate scope.

## Debounce with `useAnnounce`

For results of a user-initiated search:

```tsx
import '../polyfills/ariaNotify'
import {useAnnounce} from '../live-region'

function SearchButton({
onSearch,
}: {
onSearch: () => Promise<number>
}) {
const {announce} = useAnnounce()

return (
<button
type="button"
onClick={async () => {
const resultCount = await onSearch()
announce(`${resultCount} results available`, {
delayMs: 250,
})
}}
>
Search
</button>
)
}
```

Each hook instance owns its pending announcement. A subsequent call replaces
that instance's pending message, and unmounting cancels it. High-priority
announcements are immediate rather than debounced.

The hook also returns `cancel()`. Call it when the pending message becomes stale,
such as when loading finishes, matching results return, or a panel closes.
Cancellation only affects work not yet handed to the browser; it cannot retract
an announcement already sent through `ariaNotify`.

Keep ownership local. Each component cancels its own stale announcements using
the state it already receives. Do not recreate a global queue or share a
controller through context just to reproduce the old live-region clearing
behavior.

## Test announcements at the API boundary

Follow the [component-testing skill](../component-testing/SKILL.md).

- Spy on `Element.prototype.ariaNotify` for `Announce`, `AriaStatus`, and
`AriaAlert`; spy on `document.ariaNotify` for `useAnnounce`.
- Give the spy a no-op implementation and restore it after each test. A spy
without an implementation still calls the browser API or polyfill.
- Assert the message and priority, not live-region DOM or polyfill internals.
There is no `getMessage` equivalent to inspect.
- Use polling for eventual calls with real timers. For debounce behavior, use
fake timers advanced inside RTL's `act` and assert before and at the deadline.
- Flush pending work before negative assertions so they do not pass merely
because the announcement has not fired yet.
- Cover relevant cancellation and StrictMode behavior without expecting duplicate
mount announcements.
5 changes: 5 additions & 0 deletions .changeset/select-panel-announcements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

SelectPanel: Prevent stale loading and list announcements after results arrive or the panel closes
Comment on lines +2 to +5

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add a separate changeset for the minor bump that you're describing

23 changes: 7 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
"sideEffects": [
"dist/**/*.css",
"src/**/test-helpers.tsx",
"dist/**/test-helpers.js"
"dist/**/test-helpers.js",
"src/polyfills/*.ts",
"dist/polyfills/*.js"
],
"scripts": {
"build": "./script/build",
Expand Down Expand Up @@ -73,12 +75,12 @@
"npm": ">=7"
},
"dependencies": {
"@github/arianotify-polyfill": "^2.4.0",
"@github/mini-throttle": "^2.1.1",
"@github/relative-time-element": "^5.0.0",
"@lit-labs/react": "1.2.1",
"@oddbird/popover-polyfill": "^0.5.2",
"@primer/behaviors": "^1.10.3",
"@primer/live-region-element": "^0.8.1",
"@primer/octicons-react": "^19.28.1",
"@primer/primitives": "10.x || 11.x",
"@tanstack/react-virtual": "^3.13.18",
Expand All @@ -98,9 +100,9 @@
"@babel/core": "8.0.7",
"@babel/generator": "8.0.6",
"@babel/parser": "8.0.7",
"@babel/plugin-transform-modules-commonjs": "8.0.1",
"@babel/plugin-transform-nullish-coalescing-operator": "8.0.1",
"@babel/plugin-transform-optional-chaining": "8.0.5",
"@babel/plugin-transform-modules-commonjs": "8.0.1",
"@babel/preset-react": "8.0.1",
"@babel/preset-typescript": "8.0.7",
"@babel/types": "8.0.6",
Expand Down
36 changes: 18 additions & 18 deletions packages/react/src/Autocomplete/AutocompleteMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'
import {debounce} from '@github/mini-throttle'
import {announce} from '@primer/live-region-element'
import {scrollIntoView} from '@primer/behaviors'
import type {ScrollIntoViewOptions} from '@primer/behaviors'
import type {IconProps} from '@primer/octicons-react'
import {PlusIcon} from '@primer/octicons-react'
import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'
import {isElement} from 'react-is'
import type {ActionListItemProps} from '../ActionList'
import {ActionList} from '../ActionList'
import {useFocusZone} from '../hooks/useFocusZone'
import type {ComponentProps, MandateProps, AriaRole} from '../utils/types'
import {AutocompleteContext, AutocompleteDeferredInputContext} from './AutocompleteContext'
import Spinner from '../Spinner'
import {useAnnounce} from '../live-region'
import {useId} from '../hooks/useId'
import {AutocompleteContext, AutocompleteDeferredInputContext} from './AutocompleteContext'
import type {IconProps} from '@primer/octicons-react'
import {PlusIcon} from '@primer/octicons-react'
import type {ComponentProps, MandateProps, AriaRole} from '../utils/types'
import VisuallyHidden from '../_VisuallyHidden'
import {isElement} from 'react-is'

import classes from './AutocompleteMenu.module.css'

Expand Down Expand Up @@ -122,14 +121,6 @@ export type AutocompleteMenuInternalProps<T extends AutocompleteItemProps> = {
['aria-labelledby']: string
}

/**
* Announces a message to screen readers at a slowed-down rate. This is useful when you want to announce don't want to
* overwhelm the user with too many announcements in rapid succession.
*/
const debounceAnnouncement = debounce((announcement: string) => {
announce(announcement)
}, 250)

function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMenuInternalProps<T>) {
const autocompleteContext = useContext(AutocompleteContext)
const deferredInputContext = useContext(AutocompleteDeferredInputContext)
Expand Down Expand Up @@ -170,6 +161,7 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe
const [highlightedItem, setHighlightedItem] = useState<T>()
const [sortedItemIds, setSortedItemIds] = useState<Array<string>>(items.map(({id: itemId}) => itemId))
const generatedUniqueId = useId(id)
const {announce, cancel} = useAnnounce()

const selectableItems = useMemo(
() =>
Expand Down Expand Up @@ -280,9 +272,17 @@ function AutocompleteMenu<T extends AutocompleteItemProps>(props: AutocompleteMe

React.useEffect(() => {
if (allItemsToRender.length === 0) {
debounceAnnouncement(emptyStateText as string)
/**
* Announces a message to screen readers at a slowed-down rate. This is useful when you want to announce don't want to
* overwhelm the user with too many announcements in rapid succession.
*/
announce(emptyStateText as string, {
delayMs: 250,
})
} else {
cancel()
}
}, [allItemsToRender, emptyStateText])
}, [allItemsToRender, announce, cancel, emptyStateText])

useFocusZone(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {Tooltip} from '../TooltipV2'
import {SelectPanel} from '../SelectPanel'
import type {ItemInput} from '../FilteredActionList'
import {clsx} from 'clsx'
import {announce} from '@primer/live-region-element'
import '../polyfills/ariaNotify'

import styles from './BranchName.stories.module.css'

Expand Down Expand Up @@ -50,7 +50,9 @@ export const WithTrailingAction = ({
const handleCopy = () => {
setCopied(true)
void navigator.clipboard.writeText(branchName)
announce('Copied!')
document.ariaNotify('Copied!', {
priority: 'normal',
})
setTimeout(() => setCopied(false), 2000)
}

Expand Down
18 changes: 13 additions & 5 deletions packages/react/src/Button/Button.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import {useState} from 'react'
import {Button, IconButton} from '.'
import {Stack} from '../Stack/Stack'
import {announce} from '@primer/live-region-element'
import '../polyfills/ariaNotify'
import {Tooltip} from '../TooltipV2/Tooltip'
import {KeybindingHint} from '../KeybindingHint'
import VisuallyHidden from '../_VisuallyHidden'
Expand Down Expand Up @@ -90,15 +90,19 @@ export const TrailingCounter = () => {
const [count, setCount] = useState(0)
const onClick = () => {
setCount(count + 1)
announce(`Watch ${count + 1}`)
document.ariaNotify(`Watch ${count + 1}`, {
priority: 'normal',
})
}
return (
<>
<Button onClick={onClick} count={count}>
Watch
</Button>
<AccessibilityNote />
<p>In this example, a live region has been implemented to communicate the change.</p>
<p>
In this example, <code>ariaNotify</code> communicates the change to screen reader users.
</p>
</>
)
}
Expand All @@ -113,7 +117,9 @@ export const TrailingCounterAllVariants = () => {
const [count, setCount] = useState(0)
const onClick = () => {
setCount(count + 1)
announce(`Watch ${count + 1}`)
document.ariaNotify(`Watch ${count + 1}`, {
priority: 'normal',
})
}
return (
<>
Expand Down Expand Up @@ -144,7 +150,9 @@ export const TrailingCounterAllVariants = () => {
</Button>
</Stack>
<AccessibilityNote />
<p>In these examples, a live region has been implemented to communicate the change.</p>
<p>
In these examples, <code>ariaNotify</code> communicates the change to screen reader users.
</p>
</>
)
}
Expand Down
Loading
Loading