diff --git a/.changeset/easy-suits-mate.md b/.changeset/easy-suits-mate.md new file mode 100644 index 00000000000..f4e8f19d4c1 --- /dev/null +++ b/.changeset/easy-suits-mate.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Replaces 'aria-live' usage and removes internal LiveRegion component diff --git a/packages/react/src/DataTable/Pagination.tsx b/packages/react/src/DataTable/Pagination.tsx index c58bf6375ec..950838a5638 100644 --- a/packages/react/src/DataTable/Pagination.tsx +++ b/packages/react/src/DataTable/Pagination.tsx @@ -2,7 +2,7 @@ import {ChevronLeftIcon, ChevronRightIcon} from '@primer/octicons-react' import type React from 'react' import {useCallback, useMemo, useState} from 'react' import {Button} from '../internal/components/ButtonReset' -import {LiveRegion, LiveRegionOutlet, Message} from '../internal/components/LiveRegion' +import {AriaStatus} from '../live-region' import {VisuallyHidden} from '../VisuallyHidden' import {warning} from '../utils/warning' import type {ResponsiveValue} from '../hooks/useResponsiveValue' @@ -99,72 +99,69 @@ export function Pagination({ }, [pageCount, pageIndex, showPages]) return ( - - - - + ) } @@ -179,7 +176,11 @@ function Range({pageStart, pageEnd, totalCount}: RangeProps) { const end = pageEnd return ( <> - + + + Showing {start} through {end} of {totalCount} + +

{start}  through  diff --git a/packages/react/src/Skeleton/Skeleton.examples.stories.tsx b/packages/react/src/Skeleton/Skeleton.examples.stories.tsx index 641c863b6b1..acc83622da8 100644 --- a/packages/react/src/Skeleton/Skeleton.examples.stories.tsx +++ b/packages/react/src/Skeleton/Skeleton.examples.stories.tsx @@ -7,6 +7,7 @@ import {SkeletonAvatar} from '../SkeletonAvatar' import {VisuallyHidden} from '../VisuallyHidden' import {KebabHorizontalIcon} from '@primer/octicons-react' import classes from './Skeleton.examples.stories.module.css' +import {AriaStatus} from '../experimental' export default { title: 'Components/Skeleton/Examples', @@ -42,7 +43,9 @@ export const CommentsLoading = () => { {/** read by screen readers in place of the comments in a skeleton loading state */} {loading ? Comments are loading : null} {/** when loading is completed, it should be announced by the screen-reader */} - {loadingFinished ? 'Comments are loaded' : null} + + {loadingFinished ? 'Comments are loaded' : null} +

{Array.from({length: COMMENT_LIST_LENGTH}, (_, index) => ( @@ -101,7 +104,9 @@ export const CommentsLoadingWithSuspense = () => { {/** read by screen readers in place of the comments in a skeleton loading state */} {loadingStatus === 'pending' ? Comments are loading : null} {/** when loading is completed, it should be announced by the screen-reader */} - {loadingStatus === 'fulfilled' ? 'Comments are loaded' : null} + + {loadingStatus === 'fulfilled' ? 'Comments are loaded' : null} + {/* aria-busy is passed so the screenreader doesn't announce the skeleton state */}
diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx index 240d67b078c..f0d2d77f8ad 100644 --- a/packages/react/src/TreeView/TreeView.test.tsx +++ b/packages/react/src/TreeView/TreeView.test.tsx @@ -5,6 +5,7 @@ import React from 'react' import type {SubTreeState} from './TreeView' import {TreeView} from './TreeView' import {GearIcon} from '@primer/octicons-react' +import {getLiveRegion} from '../live-region/__tests__/test-helpers' // TODO: Move this function into a shared location function renderWithTheme( @@ -1391,7 +1392,14 @@ describe('State', () => { }) describe('Asynchronous loading', () => { - it('updates aria live region when loading is done', () => { + afterEach(() => { + const liveRegion = document.querySelector('live-region') + if (liveRegion) { + document.body.removeChild(liveRegion) + } + }) + + it('updates aria live region when loading is done', async () => { function TestTree() { const [state, setState] = React.useState('initial') @@ -1423,29 +1431,33 @@ describe('Asynchronous loading', () => {
) } + const user = userEvent.setup() const {getByRole} = renderWithTheme() const doneButton = getByRole('button', {name: 'Load'}) - const liveRegion = getByRole('status') + const liveRegion = getLiveRegion() // Live region should be empty - expect(liveRegion).toHaveTextContent('') + expect(liveRegion.getMessage('polite')).toBe('') // Click load button to mimic async loading - fireEvent.click(doneButton) + await act(async () => { + await user.click(doneButton) + }) - expect(liveRegion).toHaveTextContent('Parent content loading') + expect(liveRegion.getMessage('polite')).toBe('Parent content loading') // Click done button to mimic the completion of async loading - fireEvent.click(doneButton) + await act(async () => { + await user.click(doneButton) + }) act(() => { vi.runAllTimers() }) // Live region should be updated - expect(liveRegion).not.toHaveTextContent('Child 2 is empty') - expect(liveRegion).toHaveTextContent('Parent content loaded') + expect(liveRegion.getMessage('polite')).toBe('Parent content loaded') }) it('moves focus from loading item to first child', async () => { @@ -1810,7 +1822,8 @@ describe('CSS Module Migration', () => { ) - // Testing on the second child element because the first child element is visually hidden - expect(render().container.children[1]).toHaveClass('test-class-name') + // Find the TreeView ul element (which should have the className) + const treeElement = render().getByRole('tree') + expect(treeElement).toHaveClass('test-class-name') }) }) diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx index 86100c9cbce..c10d740513a 100644 --- a/packages/react/src/TreeView/TreeView.tsx +++ b/packages/react/src/TreeView/TreeView.tsx @@ -29,6 +29,7 @@ import {useIsMacOS} from '../hooks' import {Tooltip} from '../TooltipV2' import {isSlot} from '../utils/is-slot' import type {FCWithSlotMarker} from '../utils/types' +import {AriaStatus} from '../live-region' // ---------------------------------------------------------------------------- // Context @@ -144,8 +145,8 @@ const Root: React.FC = ({ }} > <> - - {ariaLiveMessage} + + {ariaLiveMessage}
    = ({ title, children, }) => { + const MessageWrapper = variant === 'empty' ? 'div' : AriaStatus + if (size === 'full') { return ( -
    + {variant !== 'empty' ? ( = ({ ) : null} {title} {children} -
    + ) } else { return ( -
    +
    {children}
    -
    + ) } } diff --git a/packages/react/src/internal/components/LiveRegion.tsx b/packages/react/src/internal/components/LiveRegion.tsx deleted file mode 100644 index 0bf93a6a8fb..00000000000 --- a/packages/react/src/internal/components/LiveRegion.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react' -import {VisuallyHidden} from '../../VisuallyHidden' - -type LiveRegionContext = { - announce: (message: string) => void - message: string -} - -const LiveRegionContext = React.createContext(null) - -function useLiveRegion() { - const context = React.useContext(LiveRegionContext) - if (!context) { - throw new Error('useLiveRegion() must be used within a ') - } - return context -} - -function LiveRegion({children}: React.PropsWithChildren) { - const [message, setMessage] = React.useState('') - const value = React.useMemo(() => { - return { - announce: setMessage, - message, - } - }, [message]) - - return {children} -} - -function LiveRegionOutlet() { - const liveRegion = useLiveRegion() - return ( - - {liveRegion.message} - - ) -} - -function Message({value}: {value: string}) { - const liveRegion = useLiveRegion() - const savedLiveRegion = React.useRef(liveRegion) - const committedRef = React.useRef(false) - - React.useEffect(() => { - savedLiveRegion.current = liveRegion - }, [liveRegion]) - - React.useEffect(() => { - if (committedRef.current !== true) { - return - } - const timeoutId = setTimeout(() => { - savedLiveRegion.current.announce(value) - }, 750) - return () => { - clearTimeout(timeoutId) - } - }, [value]) - - React.useEffect(() => { - committedRef.current = true - return () => { - committedRef.current = false - } - }, []) - - return null -} - -export {LiveRegion, LiveRegionOutlet, Message, useLiveRegion}