Skip to content

Commit 96b004b

Browse files
[UnderlineNav] Accessibility Remediations (#2406)
* UnderlineNav accessibility remediations * update tests * lint and interaction story update * ariaLabel optional and add a comment * remove conditional tabindex and find a better way to manage focus * restyle arrow btns and fix focus issue * add aria-label type * aria-label improvements * add changeset * arrow button slide in and out behaviour * bump octicons_react version up * update snapshots * update docs * add docs name
1 parent 45afa3d commit 96b004b

30 files changed

+1954
-1632
lines changed

.changeset/wet-glasses-kneel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
UnderlineNav2: Address accessibility feedback and re-style arrow buttons for scroll behaviour

docs/content/drafts/UnderlineNav2.mdx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {UnderlineNav} from '@primer/react/drafts'
1616
### Simple
1717

1818
```jsx live drafts
19-
<UnderlineNav>
19+
<UnderlineNav aria-label="Repository">
2020
<UnderlineNav.Item selected>Item 1</UnderlineNav.Item>
2121
<UnderlineNav.Item>Item 2</UnderlineNav.Item>
2222
<UnderlineNav.Item>Item 3</UnderlineNav.Item>
@@ -26,7 +26,7 @@ import {UnderlineNav} from '@primer/react/drafts'
2626
### With Icons
2727

2828
```jsx live drafts
29-
<UnderlineNav>
29+
<UnderlineNav aria-label="Repository">
3030
<UnderlineNav.Item selected icon={CodeIcon}>
3131
Code
3232
</UnderlineNav.Item>
@@ -53,7 +53,7 @@ When overflow occurs, the component first hides icons if present to optimize for
5353
#### Items Without Icons
5454

5555
```jsx live drafts
56-
<UnderlineNav>
56+
<UnderlineNav aria-label="Repository">
5757
<UnderlineNav.Item selected icon={CodeIcon}>
5858
Code
5959
</UnderlineNav.Item>
@@ -83,7 +83,7 @@ When overflow occurs, the component first hides icons if present to optimize for
8383
If there is still overflow, the component will behave depending on the pointer.
8484

8585
```jsx live drafts
86-
<UnderlineNav>
86+
<UnderlineNav aria-label="Repository">
8787
<UnderlineNav.Item selected icon={CodeIcon}>
8888
Code
8989
</UnderlineNav.Item>
@@ -114,7 +114,7 @@ If there is still overflow, the component will behave depending on the pointer.
114114
### Loading state for counters
115115

116116
```jsx live drafts
117-
<UnderlineNav loadingCounters={true}>
117+
<UnderlineNav aria-label="Repository" loadingCounters={true}>
118118
<UnderlineNav.Item counter={4} selected>
119119
Item 1
120120
</UnderlineNav.Item>
@@ -128,9 +128,11 @@ If there is still overflow, the component will behave depending on the pointer.
128128
### UnderlineNav
129129

130130
<PropsTable>
131-
<PropsTableRow name="aria-label" type="string" />
132-
<PropsTableRow name="aria-labelledby" type="string" />
133-
<PropsTableRow name="aria-describedby" type="string" />
131+
<PropsTableRow
132+
name="aria-label"
133+
type="string"
134+
description="A unique name for the rendered 'nav' landmark. It will also be used to label the arrow buttons that control the scroll behaviour on coarse pointer devices. (I.e. 'Scroll ${aria-label} left/right')"
135+
/>
134136
<PropsTableRow
135137
name="loadingCounters"
136138
type="boolean"

docs/package-lock.json

Lines changed: 8 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
},
1515
"dependencies": {
1616
"@primer/gatsby-theme-doctocat": "^4.2.0",
17-
"@primer/octicons-react": "^17.5.0",
17+
"@primer/octicons-react": "^17.7.0",
1818
"@primer/primitives": "4.1.0",
1919
"@styled-system/prop-types": "^5.1.0",
2020
"@styled-system/theme-get": "^5.1.0",

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
"@github/markdown-toolbar-element": "^2.1.0",
8383
"@github/paste-markdown": "^1.4.0",
8484
"@primer/behaviors": "^1.1.1",
85-
"@primer/octicons-react": "^17.3.0",
85+
"@primer/octicons-react": "^17.7.0",
8686
"@primer/primitives": "7.9.0",
8787
"@react-aria/ssr": "^3.1.0",
8888
"@styled-system/css": "^5.1.5",

src/NavList/__snapshots__/NavList.test.tsx.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,7 @@ exports[`NavList.Item with NavList.SubNav does not have active styles if SubNav
12201220
aria-hidden="true"
12211221
class="c8"
12221222
fill="currentColor"
1223+
focusable="false"
12231224
height="16"
12241225
role="img"
12251226
style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible;"
@@ -1702,6 +1703,7 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t
17021703
aria-hidden="true"
17031704
class="c8"
17041705
fill="currentColor"
1706+
focusable="false"
17051707
height="16"
17061708
role="img"
17071709
style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible;"

src/UnderlineNav2/UnderlineNav.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const ResponsiveUnderlineNav = ({
5454
{navigation: 'Security', icon: ShieldLockIcon}
5555
]
5656
return (
57-
<UnderlineNav label="Repository" loadingCounters={loadingCounters}>
57+
<UnderlineNav aria-label="Repository" loadingCounters={loadingCounters}>
5858
{items.map(item => (
5959
<UnderlineNav.Item
6060
key={item.navigation}
@@ -91,7 +91,7 @@ describe('UnderlineNav', () => {
9191
it('fires onSelect on click and keypress', async () => {
9292
const onSelect = jest.fn()
9393
const {getByText} = render(
94-
<UnderlineNav label="Test nav">
94+
<UnderlineNav aria-label="Test Navigation">
9595
<UnderlineNav.Item onSelect={onSelect}>Item 1</UnderlineNav.Item>
9696
<UnderlineNav.Item onSelect={onSelect}>Item 2</UnderlineNav.Item>
9797
<UnderlineNav.Item onSelect={onSelect}>Item 3</UnderlineNav.Item>

src/UnderlineNav2/UnderlineNav.tsx

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ import {
2121
moreMenuStyles,
2222
menuItemStyles
2323
} from './styles'
24-
import {LeftArrowButton, RightArrowButton} from './UnderlineNavArrowButton'
24+
import {ArrowButton} from './UnderlineNavArrowButton'
2525
import styled from 'styled-components'
2626
import {LoadingCounter} from './LoadingCounter'
2727

2828
export type UnderlineNavProps = {
29-
label: string
29+
'aria-label'?: React.AriaAttributes['aria-label']
3030
as?: React.ElementType
3131
align?: 'right'
3232
sx?: SxProp
@@ -68,6 +68,7 @@ const handleArrowBtnsVisibility = (
6868
const scrollOffsets = {scrollLeft, scrollRight}
6969
callback(scrollOffsets)
7070
}
71+
7172
const overflowEffect = (
7273
navWidth: number,
7374
moreMenuWidth: number,
@@ -157,7 +158,7 @@ export const UnderlineNav = forwardRef(
157158
{
158159
as = 'nav',
159160
align,
160-
label,
161+
'aria-label': ariaLabel,
161162
sx: sxProp = {},
162163
afterSelect,
163164
variant = 'default',
@@ -225,6 +226,8 @@ export const UnderlineNav = forwardRef(
225226

226227
const [selectedLink, setSelectedLink] = useState<RefObject<HTMLElement> | undefined>(undefined)
227228

229+
const [focusedLink, setFocusedLink] = useState<RefObject<HTMLElement> | null>(null)
230+
228231
// selectedLinkText is needed to be able set the selected menu item as selectedLink.
229232
// This is needed because setSelectedLink only accepts ref but at the time of setting selected menu item as selectedLink, its ref as a list item is not available
230233
const [selectedLinkText, setSelectedLinkText] = useState<string>('')
@@ -311,14 +314,28 @@ export const UnderlineNav = forwardRef(
311314
return () => listEl?.removeEventListener('scroll', scrollOnList)
312315
}, [scrollOnList])
313316

314-
useEffect(() => {
315-
// scroll the selected link into the view (coarse pointer behaviour)
316-
if (isCoarsePointer && selectedLink?.current && listRef.current) {
317-
scrollIntoView(selectedLink.current, listRef.current, underlineNavScrollMargins)
317+
function scrollLinkIntoView(link: RefObject<HTMLElement>) {
318+
if (link.current && listRef.current) {
319+
scrollIntoView(link.current, listRef.current, underlineNavScrollMargins)
318320
return
319321
}
322+
}
323+
324+
useEffect(() => {
325+
// scroll the selected link into the view (coarse pointer behaviour)
326+
selectedLink?.current && isCoarsePointer && scrollLinkIntoView(selectedLink)
320327
}, [selectedLink, isCoarsePointer])
321328

329+
useEffect(() => {
330+
// scroll the focused link into the view (coarse pointer behaviour)
331+
focusedLink?.current && isCoarsePointer && scrollLinkIntoView(focusedLink)
332+
}, [focusedLink, isCoarsePointer])
333+
334+
if (!ariaLabel) {
335+
// eslint-disable-next-line no-console
336+
console.warn('Use the `aria-label` prop to provide an accessible label for assistive technology')
337+
}
338+
322339
return (
323340
<UnderlineNavContext.Provider
324341
value={{
@@ -329,6 +346,7 @@ export const UnderlineNav = forwardRef(
329346
setSelectedLink,
330347
selectedLinkText,
331348
setSelectedLinkText,
349+
setFocusedLink,
332350
selectEvent,
333351
afterSelect: afterSelectHandler,
334352
variant,
@@ -339,11 +357,17 @@ export const UnderlineNav = forwardRef(
339357
<Box
340358
as={as}
341359
sx={merge<BetterSystemStyleObject>(getNavStyles(theme, {align}), sxProp)}
342-
aria-label={label}
360+
aria-label={ariaLabel}
343361
ref={navRef}
344362
>
345363
{isCoarsePointer && (
346-
<LeftArrowButton show={scrollValues.scrollLeft > 0} onScrollWithButton={onScrollWithButton} />
364+
<ArrowButton
365+
scrollValue={scrollValues.scrollLeft}
366+
type="left"
367+
show={scrollValues.scrollLeft > 0}
368+
onScrollWithButton={onScrollWithButton}
369+
aria-label={ariaLabel}
370+
/>
347371
)}
348372

349373
<NavigationList sx={merge<BetterSystemStyleObject>(responsiveProps.overflowStyles, ulStyles)} ref={listRef}>
@@ -390,7 +414,13 @@ export const UnderlineNav = forwardRef(
390414
</NavigationList>
391415

392416
{isCoarsePointer && (
393-
<RightArrowButton show={scrollValues.scrollRight > 0} onScrollWithButton={onScrollWithButton} />
417+
<ArrowButton
418+
scrollValue={scrollValues.scrollRight}
419+
type="right"
420+
show={scrollValues.scrollRight > 0}
421+
onScrollWithButton={onScrollWithButton}
422+
aria-label={ariaLabel}
423+
/>
394424
)}
395425
</Box>
396426
</UnderlineNavContext.Provider>
Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,66 @@
11
import React, {useContext} from 'react'
22
import {IconButton} from '../Button/IconButton'
33
import {ChevronLeftIcon, ChevronRightIcon} from '@primer/octicons-react'
4-
import {getLeftArrowHiddenBtn, getRightArrowHiddenBtn, getLeftArrowVisibleBtn, getRightArrowVisibleBtn} from './styles'
4+
import {btnWrapperStyles, getArrowBtnStyles} from './styles'
55
import {OnScrollWithButtonEventType} from './types'
66
import {UnderlineNavContext} from './UnderlineNavContext'
7+
import Box from '../Box'
78

8-
const LeftArrowButton = ({
9+
const ArrowButton = ({
10+
scrollValue,
11+
type,
912
show,
10-
onScrollWithButton
13+
onScrollWithButton,
14+
'aria-label': ariaLabel
1115
}: {
16+
scrollValue: number
17+
type: 'left' | 'right'
1218
show: boolean
1319
onScrollWithButton: OnScrollWithButtonEventType
20+
'aria-label'?: React.AriaAttributes['aria-label']
1421
}) => {
22+
const leftBtnRef = React.useRef<HTMLButtonElement>(null)
23+
const rightBtnRef = React.useRef<HTMLButtonElement>(null)
1524
const {theme} = useContext(UnderlineNavContext)
16-
return (
17-
<IconButton
18-
aria-label="Scroll Left"
19-
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => onScrollWithButton(e, -1)}
20-
icon={ChevronLeftIcon}
21-
sx={show ? getLeftArrowVisibleBtn(theme) : getLeftArrowHiddenBtn(theme)}
22-
/>
23-
)
24-
}
25+
const direction = type === 'left' ? -1 : 1
26+
const ARROW_BTN_WIDTH = 44 // Min touch target size is 44px
27+
28+
// re-trigger focus on the button with aria-disabled=true when it becomes hidden to communicate to screen readers that the button is no longer available
29+
React.useEffect(() => {
30+
const currentBtn = type === 'left' ? leftBtnRef.current : rightBtnRef.current
31+
if (currentBtn?.getAttribute('aria-disabled') === 'true') {
32+
currentBtn.focus()
33+
} else {
34+
// eslint-disable-next-line github/no-blur
35+
currentBtn?.blur()
36+
}
37+
}, [show, type])
38+
39+
let translateX = 0
40+
let display = 'flex'
41+
42+
// Determine the translateX value to transform for the slide in/out effect
43+
if (scrollValue === 0) {
44+
// If the scrollValue is 0, the buttons should be hidden
45+
translateX = ARROW_BTN_WIDTH * direction
46+
// This is mainly needed for the right arrow button. Because hiding translateX value for it is positive (44px) and this positive value was causing button to be visibly overflowed rathan than hiding.
47+
display = 'none'
48+
} else if (scrollValue <= ARROW_BTN_WIDTH) translateX = (ARROW_BTN_WIDTH - scrollValue) * direction
49+
else translateX = 0
2550

26-
const RightArrowButton = ({
27-
show,
28-
onScrollWithButton
29-
}: {
30-
show: boolean
31-
onScrollWithButton: OnScrollWithButtonEventType
32-
}) => {
33-
const {theme} = useContext(UnderlineNavContext)
3451
return (
35-
<IconButton
36-
aria-label="Scroll Right"
37-
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => onScrollWithButton(e, 1)}
38-
icon={ChevronRightIcon}
39-
sx={show ? getRightArrowVisibleBtn(theme) : getRightArrowHiddenBtn(theme)}
40-
/>
52+
<Box sx={btnWrapperStyles(theme, type, show, translateX, display)}>
53+
<IconButton
54+
tabIndex={show ? 0 : -1}
55+
ref={type === 'left' ? leftBtnRef : rightBtnRef}
56+
aria-label={`Scroll ${ariaLabel} navigation ${type}`}
57+
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => onScrollWithButton(e, direction)}
58+
icon={type === 'left' ? ChevronLeftIcon : ChevronRightIcon}
59+
sx={getArrowBtnStyles(theme, type)}
60+
aria-disabled={!show}
61+
/>
62+
</Box>
4163
)
4264
}
4365

44-
export {LeftArrowButton, RightArrowButton}
66+
export {ArrowButton}

0 commit comments

Comments
 (0)