-
Notifications
You must be signed in to change notification settings - Fork 536
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
fix(UnderlineNav2): underline nav items' selected state can be managed by the app state & clean up #3361
Changes from 15 commits
9cee89f
000945b
3f25597
b535316
c5a6014
c1daa01
4b6a964
d3b2374
517c4c7
783cd19
01e5817
2a3887a
57e71d8
06a4edc
fa4ff65
662fb28
96794b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 --> |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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' | ||
/** | ||
* 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note for reviewers: |
||
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. | ||
|
@@ -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) | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -138,11 +134,8 @@ export const UnderlineNav = forwardRef( | |
( | ||
{ | ||
as = 'nav', | ||
align, | ||
'aria-label': ariaLabel, | ||
sx: sxProp = defaultSxProp, | ||
afterSelect, | ||
variant = 'default', | ||
loadingCounters = false, | ||
children, | ||
}: UnderlineNavProps, | ||
|
@@ -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>, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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. | ||
|
@@ -203,25 +195,8 @@ export const UnderlineNav = forwardRef( | |
return breakpoint | ||
} | ||
|
||
const [selectedLink, setSelectedLink] = useState<RefObject<HTMLElement> | undefined>(undefined) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't really need |
||
|
||
// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After removing the |
||
if (!event.defaultPrevented) { | ||
if (typeof afterSelect === 'function') afterSelect(event) | ||
closeOverlay() | ||
} | ||
} | ||
|
||
const [responsiveProps, setResponsiveProps] = useState<ResponsiveProps>({ | ||
items: getValidChildren(children), | ||
actions: [], | ||
|
@@ -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>([]) | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I moved the Box wrapper here to make UnderlineNav return |
||
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>} | ||
|
@@ -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') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Refactor the component to use |
||
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> | ||
|
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, | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note for reviewers:
variant
andalign
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.