Skip to content

Correct inactive ActionList.Item behavior in NavList and SelectPanel contexts #5866

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 9, 2025
Merged
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
5 changes: 5 additions & 0 deletions .changeset/floppy-peaches-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Improves how we detect context for inactive item messaging by checking the `role` on `ActionList` instead of relying on parent components, making the logic more robust and consistent. It also fixes incorrect behavior in `NavList` and `SelectPanel`, and improves accessibility by correcting `aria` relationships on tooltip buttons.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion packages/react/src/ActionList/ActionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ describe('ActionList', () => {
expect(document.activeElement).toHaveTextContent('Option 4')

await userEvent.keyboard('{ArrowDown}')
expect(document.activeElement).toHaveAccessibleName('Unavailable due to an outage')
expect(document.activeElement).toHaveAccessibleName('Option 5')
expect(document.activeElement).toHaveAccessibleDescription('Unavailable due to an outage')

await userEvent.keyboard('{ArrowUp}')
expect(document.activeElement).toHaveTextContent('Option 4')
Expand Down
44 changes: 39 additions & 5 deletions packages/react/src/ActionList/Item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ function SimpleActionList(): JSX.Element {
<ActionList.LinkItem href="//github.com" title="anchor" aria-keyshortcuts="d">
Link Item
</ActionList.LinkItem>
<ActionList.Item inactiveText="Unavailable due to an outage">Inactive item</ActionList.Item>
<ActionList.Item inactiveText="Unavailable due to an outage" loading>
Loading and inactive item
</ActionList.Item>
</ActionList>
)
}
Expand Down Expand Up @@ -171,23 +175,53 @@ describe('ActionList.Item', () => {
fireEvent.keyPress(option, {key: 'Enter', charCode: 13})
expect(option).toBeInTheDocument()
})
it('should focus the button around the leading visual when tabbing to an inactive item', async () => {
it('should focus the button around the alert icon when tabbing to an inactive item', async () => {
const component = HTMLRender(<SimpleActionList />)
const inactiveIndicatorButton = await waitFor(() => component.getByRole('button', {name: 'Inactive item'}))
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
await userEvent.tab() // focuses 6th element, which is inactive
expect(inactiveIndicatorButton).toHaveFocus()
expect(document.activeElement).toHaveAccessibleDescription('Unavailable due to an outage')
})
it('should focus the option or menu item when moving focus to an inactive item **in a listbox**', async () => {
const component = HTMLRender(<SingleSelectListStory />)
const inactiveOptionButton = await waitFor(() => component.getByRole('button', {name: projects[3].inactiveText}))
const inactiveOption = await waitFor(() => component.getByRole('option', {name: projects[3].name}))
await userEvent.tab() // get focus on first element
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{ArrowDown}')
expect(inactiveOptionButton).toHaveFocus()
expect(inactiveOption).toHaveFocus()
expect(document.activeElement).toHaveAccessibleDescription(projects[3].inactiveText as string)
})
it('should behave as inactive if both inactiveText and loading props are passed', async () => {
const component = HTMLRender(<SimpleActionList />)
const inactiveIndicatorButton = await waitFor(() =>
component.getByRole('button', {name: 'Loading and inactive item'}),
)
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
await userEvent.tab() // focuses 7th element, which is inactive AND has a loading prop
expect(inactiveIndicatorButton).toHaveFocus()
expect(document.activeElement).toHaveAccessibleDescription('Unavailable due to an outage')
})

it('should behave as inactive if both inactiveText and loading props are passed **in a listbox**', async () => {
const component = HTMLRender(<SingleSelectListStory />)
const inactiveOptionButton = await waitFor(() => component.getByRole('button', {name: projects[5].inactiveText}))
const inactiveOption = await waitFor(() => component.getByRole('option', {name: projects[5].name}))
await userEvent.tab() // get focus on first element
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{ArrowDown}')
expect(inactiveOptionButton).toHaveFocus()
expect(inactiveOption).toHaveFocus()
expect(document.activeElement).toHaveAccessibleDescription(projects[5].inactiveText as string)
})
it('should call onClick for a link item', async () => {
const onClick = jest.fn()
Expand Down
27 changes: 18 additions & 9 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
} = React.useContext(ListContext)
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
const inactive = Boolean(inactiveText)
const showInactiveIndicator = inactive && container === undefined
// TODO change `menuContext` check to ```listRole !== undefined && ['menu', 'listbox'].includes(listRole)```
// once we have a better way to handle existing usage in dotcom that incorrectly use ActionList.TrailingAction
const menuContext = container === 'ActionMenu' || container === 'SelectPanel'
// TODO: when we change `menuContext` to check `listRole` instead of `container`
const showInactiveIndicator = inactive && !(listRole !== undefined && ['menu', 'listbox'].includes(listRole))

const onSelect = React.useCallback(
(
Expand Down Expand Up @@ -142,10 +146,12 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
}

const itemRole = role || inferredItemRole
const menuContext = container === 'ActionMenu' || container === 'SelectPanel'

if (slots.trailingAction) {
invariant(!menuContext, `ActionList.TrailingAction can not be used within a ${container}.`)
invariant(
!menuContext,
`ActionList.TrailingAction can not be used within a list with an ARIA role of "menu" or "listbox".`,
)
}

/** Infer the proper selection attribute based on the item's role */
Expand Down Expand Up @@ -405,7 +411,8 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
<span id={labelId} className={classes.ItemLabel}>
{childrenWithoutSlots}
{/* Loading message needs to be in here so it is read with the label */}
{loading === true && <VisuallyHidden>Loading</VisuallyHidden>}
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
</span>
{slots.description}
</ConditionalWrapper>
Expand All @@ -422,7 +429,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
{
// If the item is inactive, but it's not in an overlay (e.g. ActionMenu, SelectPanel),
// render the inactive warning message directly in the item.
inactive && container ? (
!showInactiveIndicator ? (
<span className={classes.InactiveWarning} id={inactiveWarningId}>
{inactiveText}
</span>
Expand Down Expand Up @@ -477,7 +484,8 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
<span id={labelId} className={classes.ItemLabel}>
{childrenWithoutSlots}
{/* Loading message needs to be in here so it is read with the label */}
{loading === true && <VisuallyHidden>Loading</VisuallyHidden>}
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
</span>
{slots.description}
</ConditionalWrapper>
Expand All @@ -494,7 +502,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
{
// If the item is inactive, but it's not in an overlay (e.g. ActionMenu, SelectPanel),
// render the inactive warning message directly in the item.
inactive && container ? (
!showInactiveIndicator ? (
<span className={classes.InactiveWarning} id={inactiveWarningId}>
{inactiveText}
</span>
Expand Down Expand Up @@ -567,7 +575,8 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
>
{childrenWithoutSlots}
{/* Loading message needs to be in here so it is read with the label */}
{loading === true && <VisuallyHidden>Loading</VisuallyHidden>}
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
</Box>
{slots.inlineDescription}
</ConditionalWrapper>
Expand All @@ -584,7 +593,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
{
// If the item is inactive, but it's not in an overlay (e.g. ActionMenu, SelectPanel),
// render the inactive warning message directly in the item.
inactive && container ? (
!showInactiveIndicator ? (
<Box
as="span"
sx={{
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/ActionList/Visuals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ export const VisualOrIndicator: React.FC<

return inactiveText ? (
<span className={classes.InactiveButtonWrap}>
<Tooltip text={inactiveText} type="label">
<button type="button" className={classes.InactiveButtonReset} aria-describedby={labelId}>
<Tooltip text={inactiveText} type="description">
<button type="button" className={classes.InactiveButtonReset} aria-labelledby={labelId}>
<VisualComponent>
<AlertIcon />
</VisualComponent>
Expand Down
Loading
Loading