Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/easy-suits-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Replaces 'aria-live' usage and removes internal LiveRegion component
137 changes: 69 additions & 68 deletions packages/react/src/DataTable/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -99,72 +99,69 @@ export function Pagination({
}, [pageCount, pageIndex, showPages])

return (
<LiveRegion>
<LiveRegionOutlet />
<nav aria-label={label} className={clsx('TablePagination', classes.TablePagination)} id={id}>
<Range pageStart={pageStart} pageEnd={pageEnd} totalCount={totalCount} />
<ol
className={clsx('TablePaginationSteps', classes.TablePaginationSteps)}
data-hidden-viewport-ranges={getViewportRangesToHidePages().join(' ')}
>
<Step>
<Button
className={clsx('TablePaginationAction', classes.TablePaginationAction)}
type="button"
data-has-page={hasPreviousPage ? true : undefined}
aria-disabled={!hasPreviousPage ? true : undefined}
onClick={() => {
if (!hasPreviousPage) {
return
}
selectPreviousPage()
}}
>
{hasPreviousPage ? <ChevronLeftIcon /> : null}
<span>Previous</span>
<VisuallyHidden>&nbsp;page</VisuallyHidden>
</Button>
</Step>
{model.map((page, i) => {
if (page.type === 'BREAK') {
return <TruncationStep key={`truncation-${i}`} />
} else if (page.type === 'NUM') {
return (
<Step key={i}>
<Page
active={!!page.selected}
onClick={() => {
selectPage(page.num - 1)
}}
>
{page.num}
{page.precedesBreak ? <VisuallyHidden>…</VisuallyHidden> : null}
</Page>
</Step>
)
}
})}
<Step>
<Button
className={clsx('TablePaginationAction', classes.TablePaginationAction)}
type="button"
data-has-page={hasNextPage ? true : undefined}
aria-disabled={!hasNextPage ? true : undefined}
onClick={() => {
if (!hasNextPage) {
return
}
selectNextPage()
}}
>
<span>Next</span>
<VisuallyHidden>&nbsp;page</VisuallyHidden>
{hasNextPage ? <ChevronRightIcon /> : null}
</Button>
</Step>
</ol>
</nav>
</LiveRegion>
<nav aria-label={label} className={clsx('TablePagination', classes.TablePagination)} id={id}>
<Range pageStart={pageStart} pageEnd={pageEnd} totalCount={totalCount} />
<ol
className={clsx('TablePaginationSteps', classes.TablePaginationSteps)}
data-hidden-viewport-ranges={getViewportRangesToHidePages().join(' ')}
>
<Step>
<Button
className={clsx('TablePaginationAction', classes.TablePaginationAction)}
type="button"
data-has-page={hasPreviousPage ? true : undefined}
aria-disabled={!hasPreviousPage ? true : undefined}
onClick={() => {
if (!hasPreviousPage) {
return
}
selectPreviousPage()
}}
>
{hasPreviousPage ? <ChevronLeftIcon /> : null}
<span>Previous</span>
<VisuallyHidden>&nbsp;page</VisuallyHidden>
</Button>
</Step>
{model.map((page, i) => {
if (page.type === 'BREAK') {
return <TruncationStep key={`truncation-${i}`} />
} else if (page.type === 'NUM') {
return (
<Step key={i}>
<Page
active={!!page.selected}
onClick={() => {
selectPage(page.num - 1)
}}
>
{page.num}
{page.precedesBreak ? <VisuallyHidden>…</VisuallyHidden> : null}
</Page>
</Step>
)
}
})}
<Step>
<Button
className={clsx('TablePaginationAction', classes.TablePaginationAction)}
type="button"
data-has-page={hasNextPage ? true : undefined}
aria-disabled={!hasNextPage ? true : undefined}
onClick={() => {
if (!hasNextPage) {
return
}
selectNextPage()
}}
>
<span>Next</span>
<VisuallyHidden>&nbsp;page</VisuallyHidden>
{hasNextPage ? <ChevronRightIcon /> : null}
</Button>
</Step>
</ol>
</nav>
)
}

Expand All @@ -179,7 +176,11 @@ function Range({pageStart, pageEnd, totalCount}: RangeProps) {
const end = pageEnd
return (
<>
<Message value={`Showing ${start} through ${end} of ${totalCount}`} />
<VisuallyHidden>
<AriaStatus aria-atomic="true">
Comment thread
mperrotti marked this conversation as resolved.
Outdated
Showing {start} through {end} of {totalCount}
</AriaStatus>
</VisuallyHidden>
<p className={clsx('TablePaginationRange', classes.TablePaginationRange)}>
{start}
<VisuallyHidden>&nbsp;through&nbsp;</VisuallyHidden>
Expand Down
9 changes: 7 additions & 2 deletions packages/react/src/Skeleton/Skeleton.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -42,7 +43,9 @@ export const CommentsLoading = () => {
{/** read by screen readers in place of the comments in a skeleton loading state */}
{loading ? <VisuallyHidden>Comments are loading</VisuallyHidden> : null}
{/** when loading is completed, it should be announced by the screen-reader */}
<VisuallyHidden aria-live="polite">{loadingFinished ? 'Comments are loaded' : null}</VisuallyHidden>
<VisuallyHidden>
<AriaStatus>{loadingFinished ? 'Comments are loaded' : null}</AriaStatus>
</VisuallyHidden>
<div className={classes.CommentsSpacing}>
<Button onClick={toggleLoadingState}>{loading ? 'Stop loading' : 'Start loading'}</Button>
{Array.from({length: COMMENT_LIST_LENGTH}, (_, index) => (
Expand Down Expand Up @@ -101,7 +104,9 @@ export const CommentsLoadingWithSuspense = () => {
{/** read by screen readers in place of the comments in a skeleton loading state */}
{loadingStatus === 'pending' ? <VisuallyHidden>Comments are loading</VisuallyHidden> : null}
{/** when loading is completed, it should be announced by the screen-reader */}
<VisuallyHidden aria-live="polite">{loadingStatus === 'fulfilled' ? 'Comments are loaded' : null}</VisuallyHidden>
<VisuallyHidden>
<AriaStatus>{loadingStatus === 'fulfilled' ? 'Comments are loaded' : null}</AriaStatus>
</VisuallyHidden>

{/* aria-busy is passed so the screenreader doesn't announce the skeleton state */}
<div className={classes.CommentsSpacing} aria-busy={loadingStatus === 'pending'}>
Expand Down
22 changes: 15 additions & 7 deletions packages/react/src/TreeView/TreeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -1392,6 +1393,10 @@ describe('State', () => {

describe('Asynchronous loading', () => {
it('updates aria live region when loading is done', () => {
// Set up live region for this test
const liveRegionEl = document.createElement('live-region')
document.body.appendChild(liveRegionEl)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The live region utilities should be doing this automatically, let me know if not 👀

I think test-wise we just have to have a clean up for them, e.g.

afterEach(() => {
  const liveRegion = document.querySelector('live-region');
  document.body.removeChild(liveRegion);
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's what I thought too, but they weren't.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@mperrotti if I remove these lines from the test it still seems to pass, are you seeing that too or no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Huh, that's odd. The failures may have been caused by something else I changed. Will remove.


function TestTree() {
const [state, setState] = React.useState<SubTreeState>('initial')

Expand Down Expand Up @@ -1426,15 +1431,15 @@ describe('Asynchronous loading', () => {
const {getByRole} = renderWithTheme(<TestTree />)

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)

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)
Expand All @@ -1444,8 +1449,10 @@ describe('Asynchronous loading', () => {
})

// 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')

// Clean up live region
document.body.removeChild(liveRegionEl)
})

it('moves focus from loading item to first child', async () => {
Expand Down Expand Up @@ -1810,7 +1817,8 @@ describe('CSS Module Migration', () => {
</TreeView>
)

// Testing on the second child element because the first child element is visually hidden
expect(render(<TreeViewTestComponent />).container.children[1]).toHaveClass('test-class-name')
// Find the TreeView ul element (which should have the className)
const treeElement = render(<TreeViewTestComponent />).getByRole('tree')
expect(treeElement).toHaveClass('test-class-name')
})
})
8 changes: 6 additions & 2 deletions packages/react/src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -144,8 +145,11 @@ const Root: React.FC<TreeViewProps> = ({
}}
>
<>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{ariaLiveMessage}
<VisuallyHidden>
{/* Message fail without passing a `key` */}

Copilot AI Nov 21, 2025

Copy link

Choose a reason for hiding this comment

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

The comment has a grammar issue. It should be "Messages fail" (plural) or "Message fails" (singular with correct verb form) instead of "Message fail".

Suggested change
{/* Message fail without passing a `key` */}
{/* Message fails without passing a `key` */}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nope, we're getting rid of the key. Thanks though.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ooo what issue were you running into without key? 👀

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It just wasn't picking up the content. Here's the test failure when I don't pass a key:

 FAIL   @primer/react (chromium)  src/TreeView/TreeView.test.tsx > Asynchronous loading > updates aria live region when loading is done
AssertionError: expected '' to be 'Parent content loading' // Object.is equality

- Expected
+ Received

- Parent content loading

 ❯ toBe src/TreeView/TreeView.test.tsx:1442:44
    1440|     fireEvent.click(doneButton)
    1441| 
    1442|     expect(liveRegion.getMessage('polite')).toBe('Parent content loading')
       |                                            ^
    1443| 
    1444|     // Click done button to mimic the completion of async loading

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@mperrotti it seems like the test is complaining about updates not being wrapped in act() when debugging locally, are you seeing that as well? When updating to the following it seemed to work without key:

    // Click load button to mimic async loading
    await act(async () => {
      await user.click(doneButton)
    })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ahh ok I see! Will update. Thank you!

<AriaStatus announceOnShow key={ariaLiveMessage}>
{ariaLiveMessage}
</AriaStatus>
</VisuallyHidden>
<ul
ref={containerRef}
Expand Down
14 changes: 6 additions & 8 deletions packages/react/src/experimental/SelectPanel2/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,9 +595,11 @@ const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({
title,
children,
}) => {
const MessageWrapper = variant === 'empty' ? 'div' : AriaStatus

if (size === 'full') {
return (
<div aria-live={variant === 'empty' ? undefined : 'polite'} className={classes.MessageFull}>
<MessageWrapper className={classes.MessageFull}>
{variant !== 'empty' ? (
<Octicon
icon={AlertIcon}
Expand All @@ -610,18 +612,14 @@ const SelectPanelMessage: React.FC<SelectPanelMessageProps> = ({
) : null}
<span className={classes.MessageTitle}>{title}</span>
<span className={classes.MessageContent}>{children}</span>
</div>
</MessageWrapper>
)
} else {
return (
<div
aria-live={variant === 'empty' ? undefined : 'polite'}
className={classes.MessageInline}
data-variant={variant}
>
<MessageWrapper className={classes.MessageInline} data-variant={variant}>
<AlertIcon size={16} />
<div>{children}</div>
</div>
</MessageWrapper>
)
}
}
Expand Down
71 changes: 0 additions & 71 deletions packages/react/src/internal/components/LiveRegion.tsx

This file was deleted.

Loading