@@ -2,16 +2,22 @@ import React, {useRef, forwardRef, useCallback, useState, MutableRefObject, RefO
22import Box from '../Box'
33import sx , { merge , BetterSystemStyleObject , SxProp } from '../sx'
44import { UnderlineNavContext } from './UnderlineNavContext'
5- import { ActionMenu } from '../ActionMenu'
6- import { ActionList } from '../ActionList'
75import { useResizeObserver , ResizeObserverEntry } from '../hooks/useResizeObserver'
86import CounterLabel from '../CounterLabel'
97import { useTheme } from '../ThemeProvider'
108import { 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'
1311import styled from 'styled-components'
1412import { 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
1622export 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 >
0 commit comments