Skip to content

Commit 3f6bfce

Browse files
authored
Merge branch 'main' into mp/rm-dialog-btm-border
2 parents 2fcd653 + f978534 commit 3f6bfce

16 files changed

+481
-198
lines changed

.changeset/floppy-peaches-obey.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
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.

packages/react/src/ActionList/ActionList.test.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ describe('ActionList', () => {
100100
expect(document.activeElement).toHaveTextContent('Option 4')
101101

102102
await userEvent.keyboard('{ArrowDown}')
103-
expect(document.activeElement).toHaveAccessibleName('Unavailable due to an outage')
103+
expect(document.activeElement).toHaveAccessibleName('Option 5')
104+
expect(document.activeElement).toHaveAccessibleDescription('Unavailable due to an outage')
104105

105106
await userEvent.keyboard('{ArrowUp}')
106107
expect(document.activeElement).toHaveTextContent('Option 4')

packages/react/src/ActionList/Item.test.tsx

+39-5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ function SimpleActionList(): JSX.Element {
1717
<ActionList.LinkItem href="//github.com" title="anchor" aria-keyshortcuts="d">
1818
Link Item
1919
</ActionList.LinkItem>
20+
<ActionList.Item inactiveText="Unavailable due to an outage">Inactive item</ActionList.Item>
21+
<ActionList.Item inactiveText="Unavailable due to an outage" loading>
22+
Loading and inactive item
23+
</ActionList.Item>
2024
</ActionList>
2125
)
2226
}
@@ -171,23 +175,53 @@ describe('ActionList.Item', () => {
171175
fireEvent.keyPress(option, {key: 'Enter', charCode: 13})
172176
expect(option).toBeInTheDocument()
173177
})
174-
it('should focus the button around the leading visual when tabbing to an inactive item', async () => {
178+
it('should focus the button around the alert icon when tabbing to an inactive item', async () => {
179+
const component = HTMLRender(<SimpleActionList />)
180+
const inactiveIndicatorButton = await waitFor(() => component.getByRole('button', {name: 'Inactive item'}))
181+
await userEvent.tab()
182+
await userEvent.tab()
183+
await userEvent.tab()
184+
await userEvent.tab()
185+
await userEvent.tab()
186+
await userEvent.tab() // focuses 6th element, which is inactive
187+
expect(inactiveIndicatorButton).toHaveFocus()
188+
expect(document.activeElement).toHaveAccessibleDescription('Unavailable due to an outage')
189+
})
190+
it('should focus the option or menu item when moving focus to an inactive item **in a listbox**', async () => {
175191
const component = HTMLRender(<SingleSelectListStory />)
176-
const inactiveOptionButton = await waitFor(() => component.getByRole('button', {name: projects[3].inactiveText}))
192+
const inactiveOption = await waitFor(() => component.getByRole('option', {name: projects[3].name}))
177193
await userEvent.tab() // get focus on first element
178194
await userEvent.keyboard('{ArrowDown}')
179195
await userEvent.keyboard('{ArrowDown}')
180-
expect(inactiveOptionButton).toHaveFocus()
196+
expect(inactiveOption).toHaveFocus()
197+
expect(document.activeElement).toHaveAccessibleDescription(projects[3].inactiveText as string)
181198
})
182199
it('should behave as inactive if both inactiveText and loading props are passed', async () => {
200+
const component = HTMLRender(<SimpleActionList />)
201+
const inactiveIndicatorButton = await waitFor(() =>
202+
component.getByRole('button', {name: 'Loading and inactive item'}),
203+
)
204+
await userEvent.tab()
205+
await userEvent.tab()
206+
await userEvent.tab()
207+
await userEvent.tab()
208+
await userEvent.tab()
209+
await userEvent.tab()
210+
await userEvent.tab() // focuses 7th element, which is inactive AND has a loading prop
211+
expect(inactiveIndicatorButton).toHaveFocus()
212+
expect(document.activeElement).toHaveAccessibleDescription('Unavailable due to an outage')
213+
})
214+
215+
it('should behave as inactive if both inactiveText and loading props are passed **in a listbox**', async () => {
183216
const component = HTMLRender(<SingleSelectListStory />)
184-
const inactiveOptionButton = await waitFor(() => component.getByRole('button', {name: projects[5].inactiveText}))
217+
const inactiveOption = await waitFor(() => component.getByRole('option', {name: projects[5].name}))
185218
await userEvent.tab() // get focus on first element
186219
await userEvent.keyboard('{ArrowDown}')
187220
await userEvent.keyboard('{ArrowDown}')
188221
await userEvent.keyboard('{ArrowDown}')
189222
await userEvent.keyboard('{ArrowDown}')
190-
expect(inactiveOptionButton).toHaveFocus()
223+
expect(inactiveOption).toHaveFocus()
224+
expect(document.activeElement).toHaveAccessibleDescription(projects[5].inactiveText as string)
191225
})
192226
it('should call onClick for a link item', async () => {
193227
const onClick = jest.fn()

packages/react/src/ActionList/Item.tsx

+18-9
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,11 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
112112
} = React.useContext(ListContext)
113113
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
114114
const inactive = Boolean(inactiveText)
115-
const showInactiveIndicator = inactive && container === undefined
115+
// TODO change `menuContext` check to ```listRole !== undefined && ['menu', 'listbox'].includes(listRole)```
116+
// once we have a better way to handle existing usage in dotcom that incorrectly use ActionList.TrailingAction
117+
const menuContext = container === 'ActionMenu' || container === 'SelectPanel'
118+
// TODO: when we change `menuContext` to check `listRole` instead of `container`
119+
const showInactiveIndicator = inactive && !(listRole !== undefined && ['menu', 'listbox'].includes(listRole))
116120

117121
const onSelect = React.useCallback(
118122
(
@@ -142,10 +146,12 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
142146
}
143147

144148
const itemRole = role || inferredItemRole
145-
const menuContext = container === 'ActionMenu' || container === 'SelectPanel'
146149

147150
if (slots.trailingAction) {
148-
invariant(!menuContext, `ActionList.TrailingAction can not be used within a ${container}.`)
151+
invariant(
152+
!menuContext,
153+
`ActionList.TrailingAction can not be used within a list with an ARIA role of "menu" or "listbox".`,
154+
)
149155
}
150156

151157
/** Infer the proper selection attribute based on the item's role */
@@ -405,7 +411,8 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
405411
<span id={labelId} className={classes.ItemLabel}>
406412
{childrenWithoutSlots}
407413
{/* Loading message needs to be in here so it is read with the label */}
408-
{loading === true && <VisuallyHidden>Loading</VisuallyHidden>}
414+
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
415+
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
409416
</span>
410417
{slots.description}
411418
</ConditionalWrapper>
@@ -422,7 +429,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
422429
{
423430
// If the item is inactive, but it's not in an overlay (e.g. ActionMenu, SelectPanel),
424431
// render the inactive warning message directly in the item.
425-
inactive && container ? (
432+
!showInactiveIndicator ? (
426433
<span className={classes.InactiveWarning} id={inactiveWarningId}>
427434
{inactiveText}
428435
</span>
@@ -477,7 +484,8 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
477484
<span id={labelId} className={classes.ItemLabel}>
478485
{childrenWithoutSlots}
479486
{/* Loading message needs to be in here so it is read with the label */}
480-
{loading === true && <VisuallyHidden>Loading</VisuallyHidden>}
487+
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
488+
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
481489
</span>
482490
{slots.description}
483491
</ConditionalWrapper>
@@ -494,7 +502,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
494502
{
495503
// If the item is inactive, but it's not in an overlay (e.g. ActionMenu, SelectPanel),
496504
// render the inactive warning message directly in the item.
497-
inactive && container ? (
505+
!showInactiveIndicator ? (
498506
<span className={classes.InactiveWarning} id={inactiveWarningId}>
499507
{inactiveText}
500508
</span>
@@ -567,7 +575,8 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
567575
>
568576
{childrenWithoutSlots}
569577
{/* Loading message needs to be in here so it is read with the label */}
570-
{loading === true && <VisuallyHidden>Loading</VisuallyHidden>}
578+
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
579+
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
571580
</Box>
572581
{slots.inlineDescription}
573582
</ConditionalWrapper>
@@ -584,7 +593,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
584593
{
585594
// If the item is inactive, but it's not in an overlay (e.g. ActionMenu, SelectPanel),
586595
// render the inactive warning message directly in the item.
587-
inactive && container ? (
596+
!showInactiveIndicator ? (
588597
<Box
589598
as="span"
590599
sx={{

packages/react/src/ActionList/Visuals.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ export const VisualOrIndicator: React.FC<
168168

169169
return inactiveText ? (
170170
<span className={classes.InactiveButtonWrap}>
171-
<Tooltip text={inactiveText} type="label">
172-
<button type="button" className={classes.InactiveButtonReset} aria-describedby={labelId}>
171+
<Tooltip text={inactiveText} type="description">
172+
<button type="button" className={classes.InactiveButtonReset} aria-labelledby={labelId}>
173173
<VisualComponent>
174174
<AlertIcon />
175175
</VisualComponent>

0 commit comments

Comments
 (0)