Skip to content

Commit 9ff856d

Browse files
[UnderlineNav2]: Introduce disclosure widget pattern (#2466)
* Disclosure pattern implementation * use token name for colours instead of accessing them from theme Co-authored-by: Siddharth Kshetrapal <[email protected]> * code review feedback <3 * [UnderlineNav2]: Always show at least 2 items in the overflow menu (A11y remediations) (#2471) * Display at least two items in the menu * add changeset * [UnderlineNav2]: Add visually hidden heading where `aria-label` is present (#2470) * Add visually hidden heading * add changeset and a test * append 'navigation' to the aria-label string * use prop for 'as' * add changeset Co-authored-by: Siddharth Kshetrapal <[email protected]>
1 parent 914dbf2 commit 9ff856d

File tree

10 files changed

+167
-75
lines changed

10 files changed

+167
-75
lines changed

.changeset/curly-hornets-bathe.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
UnderlineNav2: Introduce disclosure widget pattern

.changeset/moody-garlics-know.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
UnderlineNav2: Always show at least two items in the overflow menu

.changeset/twelve-spoons-walk.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: Render a visually hidden heading for screen readers when aria-label is present

src/UnderlineNav2/UnderlineNav.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,10 @@ describe('UnderlineNav', () => {
112112
expect(loadingCounter?.className).toContain('LoadingCounter')
113113
expect(loadingCounter?.textContent).toBe('')
114114
})
115+
it('renders a visually hidden h2 heading for screen readers when aria-label is present', () => {
116+
const {container} = render(<ResponsiveUnderlineNav />)
117+
const heading = container.getElementsByTagName('h2')[0]
118+
expect(heading.className).toContain('VisuallyHidden')
119+
expect(heading.textContent).toBe('Repository navigation')
120+
})
115121
})

src/UnderlineNav2/UnderlineNav.tsx

Lines changed: 113 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@ import React, {useRef, forwardRef, useCallback, useState, MutableRefObject, RefO
22
import Box from '../Box'
33
import sx, {merge, BetterSystemStyleObject, SxProp} from '../sx'
44
import {UnderlineNavContext} from './UnderlineNavContext'
5-
import {ActionMenu} from '../ActionMenu'
6-
import {ActionList} from '../ActionList'
75
import {useResizeObserver, ResizeObserverEntry} from '../hooks/useResizeObserver'
86
import CounterLabel from '../CounterLabel'
97
import {useTheme} from '../ThemeProvider'
108
import {ChildWidthArray, ResponsiveProps} from './types'
11-
12-
import {moreBtnStyles, getDividerStyle, getNavStyles, ulStyles, menuItemStyles, GAP} from './styles'
9+
import VisuallyHidden from '../_VisuallyHidden'
10+
import {moreBtnStyles, getDividerStyle, getNavStyles, ulStyles, menuStyles, menuItemStyles, GAP} from './styles'
1311
import styled from 'styled-components'
1412
import {LoadingCounter} from './LoadingCounter'
13+
import {Button} from '../Button'
14+
import {useFocusZone} from '../hooks/useFocusZone'
15+
import {FocusKeys} from '@primer/behaviors'
16+
import {TriangleDownIcon} from '@primer/octicons-react'
17+
import {useOnEscapePress} from '../hooks/useOnEscapePress'
18+
import {useOnOutsideClick} from '../hooks/useOnOutsideClick'
19+
import {ActionList} from '../ActionList'
20+
import {useSSRSafeId} from '@react-aria/ssr'
1521

1622
export type UnderlineNavProps = {
1723
'aria-label'?: React.AriaAttributes['aria-label']
@@ -63,23 +69,34 @@ const overflowEffect = (
6369
const items: Array<React.ReactElement> = []
6470
const actions: Array<React.ReactElement> = []
6571

66-
// For fine pointer devices, first we check if we can fit all the items with icons
72+
// First, we check if we can fit all the items with their icons
6773
if (childArray.length <= numberOfItemsPossible) {
6874
items.push(...childArray)
6975
} else if (childArray.length <= numberOfItemsWithoutIconPossible) {
70-
// if we can't fit all the items with icons, we check if we can fit all the items without icons
76+
// if we can't fit all the items with their icons, we check if we can fit all the items without their icons
7177
iconsVisible = false
7278
items.push(...childArray)
7379
} else {
74-
// if we can't fit all the items without icons, we keep the icons hidden and show the rest in the menu
80+
// if we can't fit all the items without their icons, we keep the icons hidden and show the ones that doesn't fit into the list in the overflow menu
7581
iconsVisible = false
82+
83+
/* Below is an accessibiility requirement. Never show only one item in the overflow menu.
84+
* If there is only one item left to display in the overflow menu according to the calculation,
85+
* we need to pull another item from the list into the overflow menu.
86+
*/
87+
const numberOfItemsInMenu = childArray.length - numberOfItemsPossibleWithMoreMenu
88+
const numberOfListItems =
89+
numberOfItemsInMenu === 1 ? numberOfItemsPossibleWithMoreMenu - 1 : numberOfItemsPossibleWithMoreMenu
90+
7691
for (const [index, child] of childArray.entries()) {
77-
if (index < numberOfItemsPossibleWithMoreMenu) {
92+
if (index < numberOfListItems) {
7893
items.push(child)
79-
// keeping selected item always visible.
94+
// We need to make sure to keep the selected item always visible.
8095
} else if (child.props.selected) {
81-
// If selected item's index couldn't make the list, we swap it with the last item in the list.
82-
const propsectiveAction = items.splice(numberOfItemsPossibleWithMoreMenu - 1, 1, child)[0]
96+
// If selected item can't make it to the list, we swap it with the last item in the list.
97+
const indexToReplaceAt = numberOfListItems - 1 // because we are replacing the last item in the list
98+
// splice method modifies the array by removing 1 item here at the given index and replace it with the "child" element then returns the removed item.
99+
const propsectiveAction = items.splice(indexToReplaceAt, 1, child)[0]
83100
actions.push(propsectiveAction)
84101
} else {
85102
actions.push(child)
@@ -129,6 +146,9 @@ export const UnderlineNav = forwardRef(
129146
const navRef = (forwardedRef ?? backupRef) as MutableRefObject<HTMLElement>
130147
const listRef = useRef<HTMLUListElement>(null)
131148
const moreMenuRef = useRef<HTMLLIElement>(null)
149+
const moreMenuBtnRef = useRef<HTMLButtonElement>(null)
150+
const containerRef = React.useRef<HTMLUListElement>(null)
151+
const disclosureWidgetId = useSSRSafeId()
132152

133153
const {theme} = useTheme()
134154

@@ -187,13 +207,12 @@ export const UnderlineNav = forwardRef(
187207
React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement> | null
188208
>(null)
189209

190-
const [asNavItem, setAsNavItem] = useState('a')
191-
192210
const [iconsVisible, setIconsVisible] = useState<boolean>(true)
193211

194212
const afterSelectHandler = (event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => {
195213
if (!event.defaultPrevented) {
196214
if (typeof afterSelect === 'function') afterSelect(event)
215+
closeOverlay()
197216
}
198217
}
199218

@@ -235,6 +254,39 @@ export const UnderlineNav = forwardRef(
235254
// eslint-disable-next-line no-console
236255
console.warn('Use the `aria-label` prop to provide an accessible label for assistive technology')
237256
}
257+
const [isWidgetOpen, setIsWidgetOpen] = useState(false)
258+
259+
const closeOverlay = React.useCallback(() => {
260+
setIsWidgetOpen(false)
261+
}, [setIsWidgetOpen])
262+
263+
const focusOnMoreMenuBtn = React.useCallback(() => {
264+
moreMenuBtnRef.current?.focus()
265+
}, [])
266+
267+
useFocusZone({
268+
containerRef: backupRef,
269+
bindKeys: FocusKeys.ArrowVertical | FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd | FocusKeys.Tab
270+
})
271+
272+
useOnEscapePress(
273+
(event: KeyboardEvent) => {
274+
if (isWidgetOpen) {
275+
event.preventDefault()
276+
closeOverlay()
277+
focusOnMoreMenuBtn()
278+
}
279+
},
280+
[isWidgetOpen]
281+
)
282+
283+
useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]})
284+
const onAnchorClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
285+
if (event.defaultPrevented || event.button !== 0) {
286+
return
287+
}
288+
setIsWidgetOpen(isWidgetOpen => !isWidgetOpen)
289+
}, [])
238290

239291
return (
240292
<UnderlineNavContext.Provider
@@ -246,14 +298,14 @@ export const UnderlineNav = forwardRef(
246298
setSelectedLink,
247299
selectedLinkText,
248300
setSelectedLinkText,
249-
setAsNavItem,
250301
selectEvent,
251302
afterSelect: afterSelectHandler,
252303
variant,
253304
loadingCounters,
254305
iconsVisible
255306
}}
256307
>
308+
{ariaLabel && <VisuallyHidden as="h2">{`${ariaLabel} navigation`}</VisuallyHidden>}
257309
<Box
258310
as={as}
259311
sx={merge<BetterSystemStyleObject>(getNavStyles(theme, {align}), sxProp)}
@@ -265,46 +317,54 @@ export const UnderlineNav = forwardRef(
265317
{actions.length > 0 && (
266318
<MoreMenuListItem ref={moreMenuRef}>
267319
<Box sx={getDividerStyle(theme)}></Box>
268-
<ActionMenu>
269-
<ActionMenu.Button sx={moreBtnStyles}>More</ActionMenu.Button>
270-
<ActionMenu.Overlay align="end">
271-
<ActionList selectionVariant="single">
272-
{actions.map((action, index) => {
273-
const {children: actionElementChildren, ...actionElementProps} = action.props
274-
return (
275-
<Box key={index} as="li">
276-
<ActionList.Item
277-
sx={menuItemStyles}
278-
as={asNavItem}
279-
{...actionElementProps}
280-
onSelect={(
281-
event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>
282-
) => {
283-
swapMenuItemWithListItem(action, index, event, updateListAndMenu)
284-
setSelectEvent(event)
285-
}}
286-
>
287-
<Box
288-
as="span"
289-
sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}
290-
>
291-
{actionElementChildren}
292-
293-
{loadingCounters ? (
294-
<LoadingCounter />
295-
) : (
296-
actionElementProps.counter !== undefined && (
297-
<CounterLabel>{actionElementProps.counter}</CounterLabel>
298-
)
299-
)}
300-
</Box>
301-
</ActionList.Item>
320+
<Button
321+
ref={moreMenuBtnRef}
322+
sx={moreBtnStyles}
323+
aria-controls={disclosureWidgetId}
324+
aria-expanded={isWidgetOpen}
325+
onClick={onAnchorClick}
326+
trailingIcon={TriangleDownIcon}
327+
>
328+
More
329+
</Button>
330+
<ActionList
331+
selectionVariant="single"
332+
ref={containerRef}
333+
id={disclosureWidgetId}
334+
sx={menuStyles}
335+
style={{display: isWidgetOpen ? 'block' : 'none'}}
336+
>
337+
{actions.map((action, index) => {
338+
const {children: actionElementChildren, ...actionElementProps} = action.props
339+
return (
340+
<Box key={index} as="li">
341+
<ActionList.Item
342+
{...actionElementProps}
343+
as={action.props.as || 'a'}
344+
sx={menuItemStyles}
345+
onSelect={(event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => {
346+
swapMenuItemWithListItem(action, index, event, updateListAndMenu)
347+
setSelectEvent(event)
348+
closeOverlay()
349+
focusOnMoreMenuBtn()
350+
}}
351+
>
352+
<Box as="span" sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
353+
{actionElementChildren}
354+
355+
{loadingCounters ? (
356+
<LoadingCounter />
357+
) : (
358+
actionElementProps.counter !== undefined && (
359+
<CounterLabel>{actionElementProps.counter}</CounterLabel>
360+
)
361+
)}
302362
</Box>
303-
)
304-
})}
305-
</ActionList>
306-
</ActionMenu.Overlay>
307-
</ActionMenu>
363+
</ActionList.Item>
364+
</Box>
365+
)
366+
})}
367+
</ActionList>
308368
</MoreMenuListItem>
309369
)}
310370
</NavigationList>

src/UnderlineNav2/UnderlineNavContext.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ export const UnderlineNavContext = createContext<{
1010
selectedLinkText: string
1111
setSelectedLinkText: React.Dispatch<React.SetStateAction<string>>
1212
selectEvent: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement> | null
13-
setAsNavItem: React.Dispatch<React.SetStateAction<string>>
1413
afterSelect?: (event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => void
1514
variant: 'default' | 'small'
1615
loadingCounters: boolean
@@ -24,7 +23,6 @@ export const UnderlineNavContext = createContext<{
2423
selectedLinkText: '',
2524
setSelectedLinkText: () => null,
2625
selectEvent: null,
27-
setAsNavItem: () => null,
2826
variant: 'default',
2927
loadingCounters: false,
3028
iconsVisible: true

src/UnderlineNav2/UnderlineNavItem.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ export const UnderlineNavItem = forwardRef(
7272
selectedLinkText,
7373
setSelectedLinkText,
7474
selectEvent,
75-
setAsNavItem,
7675
afterSelect,
7776
variant,
7877
loadingCounters,
@@ -107,7 +106,6 @@ export const UnderlineNavItem = forwardRef(
107106
if (typeof onSelect === 'function' && selectEvent !== null) onSelect(selectEvent)
108107
setSelectedLinkText('')
109108
}
110-
setAsNavItem(Component)
111109
}, [
112110
ref,
113111
preSelected,
@@ -118,9 +116,7 @@ export const UnderlineNavItem = forwardRef(
118116
setChildrenWidth,
119117
setNoIconChildrenWidth,
120118
onSelect,
121-
selectEvent,
122-
setAsNavItem,
123-
Component
119+
selectEvent
124120
])
125121

126122
const keyPressHandler = React.useCallback(

src/UnderlineNav2/examples.stories.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,16 @@ export const withCounterLabels = () => {
7272
)
7373
}
7474

75-
const items: {navigation: string; icon: React.FC<IconProps>; counter?: number | string}[] = [
76-
{navigation: 'Code', icon: CodeIcon},
77-
{navigation: 'Issues', icon: IssueOpenedIcon, counter: '12K'},
78-
{navigation: 'Pull Requests', icon: GitPullRequestIcon, counter: 13},
79-
{navigation: 'Discussions', icon: CommentDiscussionIcon, counter: 5},
80-
{navigation: 'Actions', icon: PlayIcon, counter: 4},
81-
{navigation: 'Projects', icon: ProjectIcon, counter: 9},
82-
{navigation: 'Insights', icon: GraphIcon, counter: '0'},
83-
{navigation: 'Settings', icon: GearIcon, counter: 10},
84-
{navigation: 'Security', icon: ShieldLockIcon}
75+
const items: {navigation: string; icon: React.FC<IconProps>; counter?: number | string; href?: string}[] = [
76+
{navigation: 'Code', icon: CodeIcon, href: '#code'},
77+
{navigation: 'Issues', icon: IssueOpenedIcon, counter: '12K', href: '#issues'},
78+
{navigation: 'Pull Requests', icon: GitPullRequestIcon, counter: 13, href: '#pull-requests'},
79+
{navigation: 'Discussions', icon: CommentDiscussionIcon, counter: 5, href: '#discussions'},
80+
{navigation: 'Actions', icon: PlayIcon, counter: 4, href: '#actions'},
81+
{navigation: 'Projects', icon: ProjectIcon, counter: 9, href: '#projects'},
82+
{navigation: 'Insights', icon: GraphIcon, counter: '0', href: '#insights'},
83+
{navigation: 'Settings', icon: GearIcon, counter: 10, href: '#settings'},
84+
{navigation: 'Security', icon: ShieldLockIcon, href: '#security'}
8585
]
8686

8787
export const InternalResponsiveNav = () => {
@@ -96,6 +96,7 @@ export const InternalResponsiveNav = () => {
9696
selected={index === selectedIndex}
9797
onSelect={() => setSelectedIndex(index)}
9898
counter={item.counter}
99+
href={item.href}
99100
>
100101
{item.navigation}
101102
</UnderlineNav.Item>

src/UnderlineNav2/styles.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ export const getNavStyles = (theme?: Theme, props?: Partial<Pick<UnderlineNavPro
3939
borderBottom: '1px solid',
4040
borderBottomColor: `${theme?.colors.border.muted}`,
4141
align: 'row',
42-
alignItems: 'center',
43-
position: 'relative'
42+
alignItems: 'center'
4443
})
4544

4645
export const ulStyles = {
@@ -52,7 +51,8 @@ export const ulStyles = {
5251
margin: 0,
5352
marginBottom: '-1px',
5453
alignItems: 'center',
55-
gap: `${GAP}px`
54+
gap: `${GAP}px`,
55+
position: 'relative'
5656
}
5757

5858
export const getDividerStyle = (theme?: Theme) => ({
@@ -71,7 +71,10 @@ export const moreBtnStyles = {
7171
fontWeight: 'normal',
7272
boxShadow: 'none',
7373
paddingY: 1,
74-
paddingX: 2
74+
paddingX: 2,
75+
'& > span[data-component="trailingIcon"]': {
76+
marginLeft: 0
77+
}
7578
}
7679

7780
export const getLinkStyles = (
@@ -142,3 +145,16 @@ export const menuItemStyles = {
142145
// To reset the style when the menu items are rendered as react router links
143146
textDecoration: 'none'
144147
}
148+
149+
export const menuStyles = {
150+
position: 'absolute',
151+
top: '90%',
152+
right: '0',
153+
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24)',
154+
borderRadius: '12px',
155+
backgroundColor: 'canvas.overlay',
156+
listStyle: 'none',
157+
// Values are from ActionMenu
158+
minWidth: '192px',
159+
maxWidth: '640px'
160+
}

0 commit comments

Comments
 (0)