Skip to content

Commit cc88235

Browse files
UnderlineNav with updated design for states and counter (#2277)
* UnderlineNav states with counters * update li and a hover and focus states * add a wrapper to style hover * Fix rounded bordem bottom when rounding the outline * fix padding and color name * move radius to wrapper initial style * use px rather than primitive * remove margin and change the width of select state line * add the underline back * focus and selected border re-implement * add comments * focus trap and fixes * theme aware underlineNav and fix safari extra margin issue * add changeset * update docs and tests * fix export issues * add a comment and fix the test import * update changeset to minor
1 parent 4536b87 commit cc88235

File tree

7 files changed

+162
-76
lines changed

7 files changed

+162
-76
lines changed
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+
UnderlineNav.Link renamed to UnderlineNav.Item along with updated styles

docs/content/drafts/UnderlineNav2.mdx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,29 @@ description: Use an underlined nav to allow tab like navigation with overflow be
1010

1111
```jsx live drafts
1212
<UnderlineNav label="simple nav">
13-
<UnderlineNav.Link selected>Item 1</UnderlineNav.Link>
14-
<UnderlineNav.Link>Item 2</UnderlineNav.Link>
15-
<UnderlineNav.Link>Item 3</UnderlineNav.Link>
13+
<UnderlineNav.Item selected>Item 1</UnderlineNav.Item>
14+
<UnderlineNav.Item>Item 2</UnderlineNav.Item>
15+
<UnderlineNav.Item>Item 3</UnderlineNav.Item>
1616
</UnderlineNav>
1717
```
1818

1919
### With icons
2020

2121
```jsx live drafts
2222
<UnderlineNav label="simple nav with icons">
23-
<UnderlineNav.Link selected leadingIcon={EyeIcon}>
23+
<UnderlineNav.Item selected leadingIcon={EyeIcon}>
2424
Item 1
25-
</UnderlineNav.Link>
26-
<UnderlineNav.Link>Item 2</UnderlineNav.Link>
25+
</UnderlineNav.Item>
26+
<UnderlineNav.Item>Item 2</UnderlineNav.Item>
2727
</UnderlineNav>
2828
```
2929

3030
### Small variant
3131

3232
```jsx live drafts
3333
<UnderlineNav label="small variant" variant="small">
34-
<UnderlineNav.Link selected>Item 1</UnderlineNav.Link>
35-
<UnderlineNav.Link>Item 2</UnderlineNav.Link>
34+
<UnderlineNav.Item selected>Item 1</UnderlineNav.Item>
35+
<UnderlineNav.Item>Item 2</UnderlineNav.Item>
3636
</UnderlineNav>
3737
```
3838

@@ -65,7 +65,7 @@ description: Use an underlined nav to allow tab like navigation with overflow be
6565
<PropsTableSxRow />
6666
</PropsTable>
6767

68-
### UnderlineNav.Link
68+
### UnderlineNav.Item
6969

7070
<PropsTable>
7171
<PropsTableRow name="leadingIcon" type="Component" description="The leading icon comes before item label" />

src/UnderlineNav2/UnderlineNav.test.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import React from 'react'
22
import '@testing-library/jest-dom/extend-expect'
33
import {render} from '@testing-library/react'
44

5-
import UnderlineNav from '.'
5+
import {UnderlineNav} from '.'
66

77
describe('UnderlineNav', () => {
88
test('selected nav', () => {
99
const {getByText} = render(
1010
<UnderlineNav label="Test nav">
11-
<UnderlineNav.Link selected>Item 1</UnderlineNav.Link>
12-
<UnderlineNav.Link>Item 2</UnderlineNav.Link>
13-
<UnderlineNav.Link>Item 3</UnderlineNav.Link>
11+
<UnderlineNav.Item selected>Item 1</UnderlineNav.Item>
12+
<UnderlineNav.Item>Item 2</UnderlineNav.Item>
13+
<UnderlineNav.Item>Item 3</UnderlineNav.Item>
1414
</UnderlineNav>
1515
)
1616
const selectedNavLink = getByText('Item 1').closest('a')
@@ -20,9 +20,9 @@ describe('UnderlineNav', () => {
2020
test('basic nav functionality', () => {
2121
const {container} = render(
2222
<UnderlineNav label="Test nav">
23-
<UnderlineNav.Link selected>Item 1</UnderlineNav.Link>
24-
<UnderlineNav.Link>Item 2</UnderlineNav.Link>
25-
<UnderlineNav.Link>Item 3</UnderlineNav.Link>
23+
<UnderlineNav.Item selected>Item 1</UnderlineNav.Item>
24+
<UnderlineNav.Item>Item 2</UnderlineNav.Item>
25+
<UnderlineNav.Item>Item 3</UnderlineNav.Item>
2626
</UnderlineNav>
2727
)
2828
expect(container.getElementsByTagName('nav').length).toEqual(1)
@@ -33,9 +33,9 @@ describe('UnderlineNav', () => {
3333
test('respect align prop', () => {
3434
const {container} = render(
3535
<UnderlineNav label="Test nav" align="right">
36-
<UnderlineNav.Link selected>Item 1</UnderlineNav.Link>
37-
<UnderlineNav.Link>Item 2</UnderlineNav.Link>
38-
<UnderlineNav.Link>Item 3</UnderlineNav.Link>
36+
<UnderlineNav.Item selected>Item 1</UnderlineNav.Item>
37+
<UnderlineNav.Item>Item 2</UnderlineNav.Item>
38+
<UnderlineNav.Item>Item 3</UnderlineNav.Item>
3939
</UnderlineNav>
4040
)
4141
const nav = container.getElementsByTagName('nav')[0]

src/UnderlineNav2/UnderlineNav.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {UnderlineNavContext} from './UnderlineNavContext'
55
import {ActionMenu} from '../ActionMenu'
66
import {ActionList} from '../ActionList'
77
import {useResizeObserver, ResizeObserverEntry} from '../hooks/useResizeObserver'
8+
import {useFocusZone} from '../hooks/useFocusZone'
9+
import {FocusKeys} from '@primer/behaviors'
810

911
type Overflow = 'auto' | 'menu' | 'scroll'
1012
type ChildWidthArray = Array<{width: number}>
@@ -84,6 +86,16 @@ export const UnderlineNav = forwardRef(
8486
) => {
8587
const backupRef = useRef<HTMLElement>(null)
8688
const newRef = (forwardedRef ?? backupRef) as MutableRefObject<HTMLElement>
89+
90+
// This might change if we decide tab through the navigation items rather than navigationg with the arrow keys.
91+
// TBD. In the meantime keeping it as a menu with the focus trap.
92+
// ref: https://www.w3.org/WAI/ARIA/apg/example-index/menubar/menubar-navigation.html (Keyboard Support)
93+
useFocusZone({
94+
containerRef: backupRef,
95+
bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd,
96+
focusOutBehavior: 'wrap'
97+
})
98+
8799
const styles = {
88100
display: 'flex',
89101
justifyContent: align === 'right' ? 'flex-end' : 'space-between',
@@ -98,7 +110,8 @@ export const UnderlineNav = forwardRef(
98110
listStyle: 'none',
99111
padding: '0',
100112
margin: '0',
101-
marginBottom: '-1px'
113+
marginBottom: '-1px',
114+
alignItems: 'center'
102115
}
103116

104117
const [selectedLink, setSelectedLink] = useState<RefObject<HTMLElement> | undefined>(undefined)
@@ -143,14 +156,15 @@ export const UnderlineNav = forwardRef(
143156
<UnderlineNavContext.Provider
144157
value={{setChildrenWidth, selectedLink, setSelectedLink, afterSelect: afterSelectHandler, variant}}
145158
>
146-
<Box as={as} sx={merge(styles, sxProp)} aria-label={label} ref={newRef}>
159+
<Box tabIndex={0} as={as} sx={merge(styles, sxProp)} aria-label={label} ref={newRef}>
147160
<Box as="ul" sx={merge<BetterSystemStyleObject>(overflowStyles, ulStyles)}>
148161
{responsiveProps.items}
149162
</Box>
150163

151164
{actions.length > 0 && (
152165
<ActionMenu>
153-
<ActionMenu.Button>More</ActionMenu.Button>
166+
{/* set margin 0 here because safari puts extra margin around the button */}
167+
<ActionMenu.Button sx={{m: 0}}>More</ActionMenu.Button>
154168
<ActionMenu.Overlay>
155169
<ActionList>
156170
{actions.map((action, index) => {

src/UnderlineNav2/UnderlineNavLink.tsx renamed to src/UnderlineNav2/UnderlineNavItem.tsx

Lines changed: 89 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {merge, SxProp, BetterSystemStyleObject} from '../sx'
44
import {IconProps} from '@primer/octicons-react'
55
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
66
import {UnderlineNavContext} from './UnderlineNavContext'
7+
import CounterLabel from '../CounterLabel'
8+
import {Theme, useTheme} from '../ThemeProvider'
79

810
// adopted from React.AnchorHTMLAttributes
911
type LinkProps = {
@@ -18,7 +20,7 @@ type LinkProps = {
1820
referrerPolicy?: React.AnchorHTMLAttributes<HTMLAnchorElement>['referrerPolicy']
1921
}
2022

21-
export type UnderlineNavLinkProps = {
23+
export type UnderlineNavItemProps = {
2224
/**
2325
* Primary content for an NavLink
2426
*/
@@ -36,16 +38,21 @@ export type UnderlineNavLinkProps = {
3638
*/
3739
leadingIcon?: React.FunctionComponent<IconProps>
3840
as?: React.ElementType
41+
/**
42+
* Counter
43+
*/
44+
counter?: number
3945
} & SxProp &
4046
LinkProps
4147

42-
export const UnderlineNavLink = forwardRef(
48+
export const UnderlineNavItem = forwardRef(
4349
(
4450
{
4551
sx: sxProp = {},
4652
as: Component = 'a',
4753
href = '#',
4854
children,
55+
counter,
4956
onSelect,
5057
selected: preSelected = false,
5158
leadingIcon: LeadingIcon,
@@ -56,11 +63,13 @@ export const UnderlineNavLink = forwardRef(
5663
const backupRef = useRef<HTMLElement>(null)
5764
const ref = forwardedRef ?? backupRef
5865
const {setChildrenWidth, selectedLink, setSelectedLink, afterSelect, variant} = useContext(UnderlineNavContext)
66+
const {theme} = useTheme()
5967
useLayoutEffect(() => {
6068
const domRect = (ref as MutableRefObject<HTMLElement>).current.getBoundingClientRect()
6169
setChildrenWidth({width: domRect.width})
6270
preSelected && selectedLink === undefined && setSelectedLink(ref as RefObject<HTMLElement>)
6371
}, [ref, preSelected, selectedLink, setSelectedLink, setChildrenWidth])
72+
6473
const iconWrapStyles = {
6574
alignItems: 'center',
6675
display: 'inline-flex',
@@ -70,29 +79,72 @@ export const UnderlineNavLink = forwardRef(
7079
const textStyles: BetterSystemStyleObject = {
7180
whiteSpace: 'nowrap'
7281
}
82+
83+
const wrapperStyles = {
84+
display: 'inline-flex',
85+
paddingY: 1,
86+
paddingX: 2,
87+
borderRadius: 2
88+
}
7389
const smallVariantLinkStyles = {
74-
paddingY: 2,
90+
paddingY: 1,
7591
fontSize: 0
7692
}
7793
const defaultVariantLinkStyles = {
78-
paddingY: 3,
94+
paddingY: 2,
7995
fontSize: 1
8096
}
8197

82-
const linkStyles = {
98+
// eslint-disable-next-line no-shadow
99+
const linkStyles = (theme?: Theme) => ({
100+
position: 'relative',
83101
display: 'inline-flex',
84102
color: 'fg.default',
85103
textAlign: 'center',
86-
borderBottom: '2px solid transparent',
87-
borderColor: selectedLink === ref ? 'primer.border.active' : 'transparent',
88104
textDecoration: 'none',
89-
paddingX: 2,
90-
marginRight: 3,
105+
paddingX: 1,
91106
...(variant === 'small' ? smallVariantLinkStyles : defaultVariantLinkStyles),
92-
'&:hover, &:focus': {
93-
borderColor: selectedLink === ref ? 'primer.border.active' : 'neutral.muted',
94-
transition: '0.2s ease'
107+
'&:hover > div[data-component="wrapper"] ': {
108+
backgroundColor: theme?.colors.neutral.muted,
109+
transition: 'background .12s ease-out'
110+
},
111+
'&:focus': {
112+
outline: 0,
113+
'& > div[data-component="wrapper"]': {
114+
boxShadow: `inset 0 0 0 2px ${theme?.colors.accent.fg}`
115+
},
116+
// where focus-visible is supported, remove the focus box-shadow
117+
'&:not(:focus-visible) > div[data-component="wrapper"]': {
118+
boxShadow: 'none'
119+
}
120+
},
121+
'&:focus-visible > div[data-component="wrapper"]': {
122+
boxShadow: `inset 0 0 0 2px ${theme?.colors.accent.fg}`
123+
},
124+
// renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected
125+
'& span[data-content]::before': {
126+
content: 'attr(data-content)',
127+
display: 'block',
128+
height: 0,
129+
fontWeight: '600',
130+
visibility: 'hidden'
131+
},
132+
// selected state styles
133+
'&::after': {
134+
position: 'absolute',
135+
right: '50%',
136+
bottom: 0,
137+
width: `calc(100% - 8px)`,
138+
height: 2,
139+
content: '""',
140+
bg: selectedLink === ref ? theme?.colors.primer.border.active : 'transparent',
141+
borderRadius: 0,
142+
transform: 'translate(50%, -50%)'
95143
}
144+
})
145+
146+
const counterStyles = {
147+
marginLeft: 2
96148
}
97149
const keyPressHandler = React.useCallback(
98150
event => {
@@ -118,29 +170,41 @@ export const UnderlineNavLink = forwardRef(
118170
[onSelect, afterSelect, ref, setSelectedLink]
119171
)
120172
return (
121-
<Box as="li">
173+
<Box as="li" sx={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
122174
<Box
123175
as={Component}
124176
href={href}
125177
onKeyPress={keyPressHandler}
126178
onClick={clickHandler}
127179
{...(selectedLink === ref ? {'aria-current': 'page'} : {})}
128-
sx={merge(linkStyles, sxProp as SxProp)}
180+
sx={merge(linkStyles(theme), sxProp as SxProp)}
129181
{...props}
130182
ref={ref}
131183
>
132-
{LeadingIcon && (
133-
<Box as="span" data-component="leadingIcon" sx={iconWrapStyles}>
134-
<LeadingIcon />
135-
</Box>
136-
)}
137-
{children && (
138-
<Box as="span" data-component="text" sx={textStyles}>
139-
{children}
140-
</Box>
141-
)}
184+
<Box as="div" data-component="wrapper" sx={wrapperStyles}>
185+
{LeadingIcon && (
186+
<Box as="span" data-component="leadingIcon" sx={iconWrapStyles}>
187+
<LeadingIcon />
188+
</Box>
189+
)}
190+
{children && (
191+
<Box
192+
as="span"
193+
data-component="text"
194+
data-content={children}
195+
sx={selectedLink === ref ? {fontWeight: 600, ...{textStyles}} : {textStyles}}
196+
>
197+
{children}
198+
</Box>
199+
)}
200+
{counter && (
201+
<Box as="span" data-component="counter" sx={counterStyles}>
202+
<CounterLabel>{counter}</CounterLabel>
203+
</Box>
204+
)}
205+
</Box>
142206
</Box>
143207
</Box>
144208
)
145209
}
146-
) as PolymorphicForwardRefComponent<'a', UnderlineNavLinkProps>
210+
) as PolymorphicForwardRefComponent<'a', UnderlineNavItemProps>

0 commit comments

Comments
 (0)