Skip to content
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

fix(UnderlineNav2): underline nav items' selected state can be managed by the app state & clean up #3361

Merged
merged 17 commits into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from 15 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
7 changes: 7 additions & 0 deletions .changeset/new-students-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/react': patch
---

UnderlineNav2: underline nav items' selected state can be managed by the app state

<!-- Changed components: UnderlineNav -->
179 changes: 89 additions & 90 deletions src/UnderlineNav2/UnderlineNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,31 @@ import Box from '../Box'
import sx, {merge, BetterSystemStyleObject, SxProp} from '../sx'
import {UnderlineNavContext} from './UnderlineNavContext'
import {useResizeObserver, ResizeObserverEntry} from '../hooks/useResizeObserver'
import CounterLabel from '../CounterLabel'
import {useTheme} from '../ThemeProvider'
import {ChildWidthArray, ResponsiveProps, ChildSize} from './types'
import VisuallyHidden from '../_VisuallyHidden'
import {moreBtnStyles, getDividerStyle, getNavStyles, ulStyles, menuStyles, menuItemStyles, GAP} from './styles'
import styled from 'styled-components'
import {LoadingCounter} from './LoadingCounter'
import {Button} from '../Button'
import {TriangleDownIcon} from '@primer/octicons-react'
import {useOnEscapePress} from '../hooks/useOnEscapePress'
import {useOnOutsideClick} from '../hooks/useOnOutsideClick'
import {useId} from '../hooks/useId'
import {ActionList} from '../ActionList'
import {defaultSxProp} from '../utils/defaultSxProp'
import CounterLabel from '../CounterLabel'
import {LoadingCounter} from './LoadingCounter'
import {invariant} from '../utils/invariant'

export type UnderlineNavProps = {
children: React.ReactNode
'aria-label'?: React.AriaAttributes['aria-label']
as?: React.ElementType
sx?: SxProp['sx']
// cariant and align are currently not in used. Keeping here until some design explorations are finalized.
variant?: 'default' | 'small'
align?: 'right'
Comment on lines -25 to -27
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewers: variant and align were considered in the initial version of the component but it is being decided not to include them at the end. Removing them to reduce the clutter and complexity on the component and styles.

/**
* loading state for all counters. It displays loading animation for individual counters (UnderlineNav.Item) until all are resolved. It is needed to prevent multiple layout shift.
*/
loadingCounters?: boolean
afterSelect?: (event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => void
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewers: afterSelect was considered in the initial version of the component but it is being decided not to include it at the end. Removing it to reduce the clutter and complexity on the component

children: React.ReactNode
}
// When page is loaded, we don't have ref for the more button as it is not on the DOM yet.
// However, we need to calculate number of possible items when the more button present as well. So using the width of the more button as a constant.
Expand Down Expand Up @@ -90,7 +87,6 @@ const overflowEffect = (
const numberOfItemsInMenu = childArray.length - numberOfItemsPossibleWithMoreMenu
const numberOfListItems =
numberOfItemsInMenu === 1 ? numberOfItemsPossibleWithMoreMenu - 1 : numberOfItemsPossibleWithMoreMenu

for (const [index, child] of childArray.entries()) {
if (index < numberOfListItems) {
items.push(child)
Expand Down Expand Up @@ -120,15 +116,15 @@ const getValidChildren = (children: React.ReactNode) => {

const calculatePossibleItems = (childWidthArray: ChildWidthArray, navWidth: number, moreMenuWidth = 0) => {
const widthToFit = navWidth - moreMenuWidth
let breakpoint = childWidthArray.length - 1
let breakpoint = childWidthArray.length // assume all items will fit
let sumsOfChildWidth = 0
for (const [index, childWidth] of childWidthArray.entries()) {
sumsOfChildWidth = sumsOfChildWidth + childWidth.width + GAP
if (sumsOfChildWidth > widthToFit) {
breakpoint = index - 1
breakpoint = index
break
} else {
// The the gap between items into account when calculating the number of items possible
sumsOfChildWidth = sumsOfChildWidth + childWidth.width + GAP
continue
Comment on lines +122 to +127
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think calculating the sum first and then to see if that will fit into the available width is a better approach. It fixes this specific overflow issue

}
}
return breakpoint
Expand All @@ -138,11 +134,8 @@ export const UnderlineNav = forwardRef(
(
{
as = 'nav',
align,
'aria-label': ariaLabel,
sx: sxProp = defaultSxProp,
afterSelect,
variant = 'default',
loadingCounters = false,
children,
}: UnderlineNavProps,
Expand All @@ -165,7 +158,7 @@ export const UnderlineNav = forwardRef(
const swapMenuItemWithListItem = (
prospectiveListItem: React.ReactElement,
indexOfProspectiveListItem: number,
event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
event: React.MouseEvent<HTMLAnchorElement> | React.KeyboardEvent<HTMLAnchorElement>,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callback: (props: ResponsiveProps, displayIcons: boolean) => void,
) => {
// get the selected menu item's width
Expand All @@ -186,7 +179,6 @@ export const UnderlineNav = forwardRef(
const updatedMenuItems = [...actions]
// Add itemsToAddToMenu array's items to the menu at the index of the prospectiveListItem and remove 1 count of items (prospectiveListItem)
updatedMenuItems.splice(indexOfProspectiveListItem, 1, ...itemsToAddToMenu)
setSelectedLinkText(prospectiveListItem.props.children)
callback({items: updatedItemList, actions: updatedMenuItems}, false)
}
// How many items do we need to pull in to the menu to make room for the selected menu item.
Expand All @@ -203,25 +195,8 @@ export const UnderlineNav = forwardRef(
return breakpoint
}

const [selectedLink, setSelectedLink] = useState<RefObject<HTMLElement> | undefined>(undefined)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't really need selectedLink anymore as we manage the selected state with aria-current


// selectedLinkText is needed to be able set the selected menu item as selectedLink.
// 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
const [selectedLinkText, setSelectedLinkText] = useState<string>('')
// Capture the mouse/keyboard event when a menu item is selected so that we can use it to fire the onSelect callback after the menu item is swapped with the list item
const [selectEvent, setSelectEvent] = useState<
React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement> | null
>(null)

Comment on lines -208 to -215
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bad solution 🙈 I implemented this a way back and now looking at it now, triggering onSelect function on the selected menu item is as straightforward as this

const [iconsVisible, setIconsVisible] = useState<boolean>(true)

const afterSelectHandler = (event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After removing the afterSelect, this is not needed.

if (!event.defaultPrevented) {
if (typeof afterSelect === 'function') afterSelect(event)
closeOverlay()
}
}

const [responsiveProps, setResponsiveProps] = useState<ResponsiveProps>({
items: getValidChildren(children),
actions: [],
Expand All @@ -232,30 +207,30 @@ export const UnderlineNav = forwardRef(
* Particually when an item is selected. It adds 'aria-current="page"' attribute to the child and we need to make sure
* responsiveProps.items and ResponsiveProps.actions are updated with that attribute
*/
useEffect(() => {
const childArray = getValidChildren(children)

const updatedItems = responsiveProps.items.map(item => {
return childArray.find(child => child.key === item.key) || item
})

const updatedActions = responsiveProps.actions.map(action => {
return childArray.find(child => child.key === action.key) || action
})

setResponsiveProps({
items: updatedItems,
actions: updatedActions,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [children])
const listLinks = responsiveProps.items.map(item => {
return getValidChildren(children).find(child => child.key === item.key) || item
})
// TODO: Rename this to menuItems
const actions = responsiveProps.actions.map(action => {
return getValidChildren(children).find(child => child.key === action.key) || action
})

const updateListAndMenu = useCallback((props: ResponsiveProps, displayIcons: boolean) => {
setResponsiveProps(props)
setIconsVisible(displayIcons)
}, [])

const actions = responsiveProps.actions
// Address illegal state where there are multiple items that have `aria-current=''page'` attribute
if (__DEV__) {
// Practically, this is not a conditional hook, it is just making sure this hook runs only on DEV not PROD.
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const activeElements = getValidChildren(children).filter(child => {
return child.props['aria-current'] !== undefined
})
invariant(activeElements.length <= 1, 'Only one current element is allowed')
})
}
// This is the case where the viewport is too narrow to show any list item with the more menu. In this case, we only show the dropdown
const onlyMenuVisible = responsiveProps.items.length === 0
const [childWidthArray, setChildWidthArray] = useState<ChildWidthArray>([])
Expand Down Expand Up @@ -321,26 +296,30 @@ export const UnderlineNav = forwardRef(
theme,
setChildrenWidth,
setNoIconChildrenWidth,
selectedLink,
setSelectedLink,
selectedLinkText,
setSelectedLinkText,
selectEvent,
afterSelect: afterSelectHandler,
variant,
loadingCounters,
iconsVisible,
}}
>
{ariaLabel && <VisuallyHidden as="h2">{`${ariaLabel} navigation`}</VisuallyHidden>}
<Box
as={as}
sx={merge<BetterSystemStyleObject>(getNavStyles(theme, {align}), sxProp)}
sx={merge<BetterSystemStyleObject>(getNavStyles(theme), sxProp)}
aria-label={ariaLabel}
ref={navRef}
>
<NavigationList sx={ulStyles} ref={listRef} role="list">
{responsiveProps.items}
{listLinks.map(listLink => {
return (
<Box
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the Box wrapper here to make UnderlineNav return <Link></Link> instead of <Box><Link></Link></Box to align with as PolymorphicForwardRefComponent<'a', UnderlineNavItemProps> but let me know if you have any concern. I am not 100% sure about that.

key={listLink.props.children}
as="li"
sx={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}
>
{listLink}
</Box>
)
})}

{actions.length > 0 && (
<MoreMenuListItem ref={moreMenuRef}>
{!onlyMenuVisible && <Box sx={getDividerStyle(theme)}></Box>}
Expand Down Expand Up @@ -372,36 +351,56 @@ export const UnderlineNav = forwardRef(
style={{display: isWidgetOpen ? 'block' : 'none'}}
>
{actions.map((action, index) => {
const {children: actionElementChildren, ...actionElementProps} = action.props
return (
<Box key={actionElementChildren} as="li">
<ActionList.Item
{...actionElementProps}
as={action.props.as || 'a'}
sx={menuItemStyles}
onSelect={(event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => {
// When there are no items in the list, do not run the swap function as we want to keep everything in the menu.
!onlyMenuVisible && swapMenuItemWithListItem(action, index, event, updateListAndMenu)
setSelectEvent(event)
closeOverlay()
focusOnMoreMenuBtn()
}}
>
<Box as="span" sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
{actionElementChildren}
const {
children: actionElementChildren,
counter,
'aria-current': ariaCurrent,
onSelect,
...actionElementProps
} = action.props

// This logic is used to pop the selected item out of the menu and into the list when the navigation is control externally
if (Boolean(ariaCurrent) && ariaCurrent !== 'false') {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please refer to the gif in the description to see this in action.

const event = new MouseEvent('click')
!onlyMenuVisible &&
swapMenuItemWithListItem(
action,
index,
// @ts-ignore - not a big deal because it is internally creating an event but ask help
event as React.MouseEvent<HTMLAnchorElement>,
updateListAndMenu,
)
}

{loadingCounters ? (
<LoadingCounter />
) : (
actionElementProps.counter !== undefined && (
<Box as="span" data-component="counter">
<CounterLabel>{actionElementProps.counter}</CounterLabel>
</Box>
)
)}
</Box>
</ActionList.Item>
</Box>
return (
<ActionList.LinkItem
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor the component to use ActionList.LinkItem instead of ActionList.Item because it is a navigation component and we want consumers to use this for this purpose and with items being as anchor links not as buttons or any other elements.

key={actionElementChildren}
sx={menuItemStyles}
onClick={(
event: React.MouseEvent<HTMLAnchorElement> | React.KeyboardEvent<HTMLAnchorElement>,
) => {
// When there are no items in the list, do not run the swap function as we want to keep everything in the menu.
!onlyMenuVisible && swapMenuItemWithListItem(action, index, event, updateListAndMenu)
closeOverlay()
focusOnMoreMenuBtn()
// fire onSelect event that comes from the UnderlineNav.Item (if it is defined)
typeof onSelect === 'function' && onSelect(event)
}}
{...actionElementProps}
>
<Box as="span" sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
{actionElementChildren}
{loadingCounters ? (
<LoadingCounter />
) : (
counter !== undefined && (
<Box as="span" data-component="counter">
<CounterLabel>{counter}</CounterLabel>
</Box>
)
)}
</Box>
</ActionList.LinkItem>
)
})}
</ActionList>
Expand Down
9 changes: 9 additions & 0 deletions src/UnderlineNav2/UnderlineNav2.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export const Default = () => {
)
}

export const IllegalState = () => {
return (
<UnderlineNav aria-label="Repository">
<UnderlineNav.Item aria-current="page">Code</UnderlineNav.Item>
<UnderlineNav.Item aria-current="page">Issues</UnderlineNav.Item>
<UnderlineNav.Item aria-current="page">Pull Requests</UnderlineNav.Item>
</UnderlineNav>
)
}
export const WithIcons = () => {
return (
<UnderlineNav aria-label="Repository with icons">
Expand Down
15 changes: 1 addition & 14 deletions src/UnderlineNav2/UnderlineNavContext.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
import React, {createContext, RefObject} from 'react'
import React, {createContext} from 'react'
import {Theme} from '../ThemeProvider'

export const UnderlineNavContext = createContext<{
theme: Theme | undefined
setChildrenWidth: React.Dispatch<{text: string; width: number}>
setNoIconChildrenWidth: React.Dispatch<{text: string; width: number}>
selectedLink: RefObject<HTMLElement> | undefined
setSelectedLink: (ref: RefObject<HTMLElement>) => void
selectedLinkText: string
setSelectedLinkText: React.Dispatch<React.SetStateAction<string>>
selectEvent: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement> | null
afterSelect?: (event: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>) => void
variant: 'default' | 'small'
loadingCounters: boolean
iconsVisible: boolean
}>({
theme: {},
setChildrenWidth: () => null,
setNoIconChildrenWidth: () => null,
selectedLink: undefined,
setSelectedLink: () => null,
selectedLinkText: '',
setSelectedLinkText: () => null,
selectEvent: null,
variant: 'default',
loadingCounters: false,
iconsVisible: true,
})
Loading