Skip to content

Commit fd80a60

Browse files
authored
feat(UnderlineNavItem): add support for icons as React.ReactElement (#4718)
* feat(UnderlineNavItem): add support for icons as React.ReactElement * chore: add changeset * fix: update render logic for elements, update stories * chore: update typescript for storybook --------- Co-authored-by: Josh Black <[email protected]>
1 parent 7f55577 commit fd80a60

File tree

6 files changed

+51
-20
lines changed

6 files changed

+51
-20
lines changed

.changeset/bright-islands-kick.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+
Add support for providing icons as an element to UnderlineNavItem

packages/react/src/UnderlineNav/UnderlineNav.features.stories.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import type {Meta} from '@storybook/react'
1616
import {UnderlineNav} from './index'
1717
import {INITIAL_VIEWPORTS} from '@storybook/addon-viewport'
1818

19-
export default {
19+
const meta = {
2020
title: 'Components/UnderlineNav/Features',
21-
} as Meta
21+
} satisfies Meta<typeof UnderlineNav>
22+
23+
export default meta
2224

2325
export const Default = () => {
2426
return (
@@ -33,28 +35,28 @@ export const Default = () => {
3335
export const WithIcons = () => {
3436
return (
3537
<UnderlineNav aria-label="Repository with icons">
36-
<UnderlineNav.Item icon={CodeIcon}>Code</UnderlineNav.Item>
37-
<UnderlineNav.Item icon={EyeIcon} counter={6}>
38+
<UnderlineNav.Item icon={<CodeIcon />}>Code</UnderlineNav.Item>
39+
<UnderlineNav.Item icon={<EyeIcon />} counter={6}>
3840
Issues
3941
</UnderlineNav.Item>
40-
<UnderlineNav.Item aria-current="page" icon={GitPullRequestIcon}>
42+
<UnderlineNav.Item aria-current="page" icon={<GitPullRequestIcon />}>
4143
Pull Requests
4244
</UnderlineNav.Item>
43-
<UnderlineNav.Item icon={CommentDiscussionIcon} counter={7}>
45+
<UnderlineNav.Item icon={<CommentDiscussionIcon />} counter={7}>
4446
Discussions
4547
</UnderlineNav.Item>
46-
<UnderlineNav.Item icon={ProjectIcon}>Projects</UnderlineNav.Item>
48+
<UnderlineNav.Item icon={<ProjectIcon />}>Projects</UnderlineNav.Item>
4749
</UnderlineNav>
4850
)
4951
}
5052

5153
export const WithCounterLabels = () => {
5254
return (
5355
<UnderlineNav aria-label="Repository with counters">
54-
<UnderlineNav.Item aria-current="page" icon={CodeIcon} counter="11K">
56+
<UnderlineNav.Item aria-current="page" icon={<CodeIcon />} counter="11K">
5557
Code
5658
</UnderlineNav.Item>
57-
<UnderlineNav.Item icon={IssueOpenedIcon} counter={12}>
59+
<UnderlineNav.Item icon={<IssueOpenedIcon />} counter={12}>
5860
Issues
5961
</UnderlineNav.Item>
6062
</UnderlineNav>

packages/react/src/UnderlineNav/UnderlineNav.stories.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import {UnderlineNavItem} from './UnderlineNavItem'
55

66
const excludedControlKeys = ['sx', 'as', 'variant', 'align', 'afterSelect']
77

8-
export default {
8+
const meta: Meta<typeof UnderlineNav> = {
99
title: 'Components/UnderlineNav',
1010
component: UnderlineNav,
11-
subcomponents: {UnderlineNavItem},
1211
parameters: {
1312
controls: {
1413
expanded: true,
@@ -33,7 +32,9 @@ export default {
3332
'aria-label': 'Repository',
3433
loadingCounters: false,
3534
},
36-
} as Meta<typeof UnderlineNav>
35+
}
36+
37+
export default meta
3738

3839
export const Default: StoryFn<typeof UnderlineNav> = () => {
3940
const children = ['Code', 'Pull requests', 'Actions', 'Projects', 'Wiki']

packages/react/src/UnderlineNav/UnderlineNav.test.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react'
2-
import {render} from '@testing-library/react'
2+
import {render, screen} from '@testing-library/react'
33
import userEvent from '@testing-library/user-event'
44
import type {IconProps} from '@primer/octicons-react'
55
import {
@@ -77,22 +77,26 @@ describe('UnderlineNav', () => {
7777
default: undefined,
7878
UnderlineNav,
7979
})
80+
8081
it('renders aria-current attribute to be pages when an item is selected', () => {
8182
const {getByRole} = render(<ResponsiveUnderlineNav />)
8283
const selectedNavLink = getByRole('link', {name: 'Code'})
8384
expect(selectedNavLink.getAttribute('aria-current')).toBe('page')
8485
})
86+
8587
it('renders aria-label attribute correctly', () => {
8688
const {container, getByRole} = render(<ResponsiveUnderlineNav />)
8789
expect(container.getElementsByTagName('nav').length).toEqual(1)
8890
const nav = getByRole('navigation')
8991
expect(nav.getAttribute('aria-label')).toBe('Repository')
9092
})
93+
9194
it('renders icons correctly', () => {
9295
const {getByRole} = render(<ResponsiveUnderlineNav />)
9396
const nav = getByRole('navigation')
9497
expect(nav.getElementsByTagName('svg').length).toEqual(7)
9598
})
99+
96100
it('fires onSelect on click', async () => {
97101
const onSelect = jest.fn()
98102
const {getByRole} = render(
@@ -107,6 +111,7 @@ describe('UnderlineNav', () => {
107111
await user.click(item)
108112
expect(onSelect).toHaveBeenCalledTimes(1)
109113
})
114+
110115
it('fires onSelect on keypress', async () => {
111116
const onSelect = jest.fn()
112117
const {getByRole} = render(
@@ -128,27 +133,31 @@ describe('UnderlineNav', () => {
128133
await user.keyboard(' ') // space
129134
expect(onSelect).toHaveBeenCalledTimes(3)
130135
})
136+
131137
it('respects counter prop', () => {
132138
const {getByRole} = render(<ResponsiveUnderlineNav />)
133139
const item = getByRole('link', {name: 'Issues (120)'})
134140
const counter = item.getElementsByTagName('span')[3]
135141
expect(counter.textContent).toBe('120')
136142
expect(counter).toHaveAttribute('aria-hidden', 'true')
137143
})
144+
138145
it('renders the content of visually hidden span properly for screen readers', () => {
139146
const {getByRole} = render(<ResponsiveUnderlineNav />)
140147
const item = getByRole('link', {name: 'Issues (120)'})
141148
const counter = item.getElementsByTagName('span')[4]
142149
// non breaking space unified code
143150
expect(counter.textContent).toBe('\u00A0(120)')
144151
})
152+
145153
it('respects loadingCounters prop', () => {
146154
const {getByRole} = render(<ResponsiveUnderlineNav loadingCounters={true} />)
147155
const item = getByRole('link', {name: 'Actions'})
148156
const loadingCounter = item.getElementsByTagName('span')[2]
149157
expect(loadingCounter.className).toContain('LoadingCounter')
150158
expect(loadingCounter.textContent).toBe('')
151159
})
160+
152161
it('renders a visually hidden h2 heading for screen readers when aria-label is present', () => {
153162
const {getByRole} = render(<ResponsiveUnderlineNav />)
154163
const heading = getByRole('heading', {name: 'Repository navigation'})
@@ -157,6 +166,7 @@ describe('UnderlineNav', () => {
157166
expect(heading.className).toContain('VisuallyHidden')
158167
expect(heading.textContent).toBe('Repository navigation')
159168
})
169+
160170
it('throws an error when there are multiple items that have aria-current', () => {
161171
const spy = jest.spyOn(console, 'error').mockImplementation()
162172
expect(() => {
@@ -186,6 +196,22 @@ describe('UnderlineNav', () => {
186196
// We are expecting a left value back, that way we know the `getAnchoredPosition` ran.
187197
expect(results).toEqual(expect.objectContaining({left: 0}))
188198
})
199+
200+
it('should support icons passed in as an element', () => {
201+
render(
202+
<UnderlineNav aria-label="Repository">
203+
<UnderlineNav.Item aria-current="page" icon={<CodeIcon aria-label="Page one icon" />}>
204+
Page one
205+
</UnderlineNav.Item>
206+
<UnderlineNav.Item icon={<IssueOpenedIcon aria-label="Page two icon" />}>Page two</UnderlineNav.Item>
207+
<UnderlineNav.Item icon={<GitPullRequestIcon aria-label="Page three icon" />}>Page three</UnderlineNav.Item>
208+
</UnderlineNav>,
209+
)
210+
211+
expect(screen.getByLabelText('Page one icon')).toBeInTheDocument()
212+
expect(screen.getByLabelText('Page two icon')).toBeInTheDocument()
213+
expect(screen.getByLabelText('Page three icon')).toBeInTheDocument()
214+
})
189215
})
190216

191217
describe('Keyboard Navigation', () => {

packages/react/src/UnderlineNav/UnderlineNavItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export type UnderlineNavItemProps = {
3838
/**
3939
* Icon before the text
4040
*/
41-
icon?: React.FunctionComponent<IconProps>
41+
icon?: React.FunctionComponent<IconProps> | React.ReactElement
4242
/**
4343
* Renders `UnderlineNav.Item` as given component i.e. react-router's Link
4444
**/

packages/react/src/internal/components/UnderlineTabbedInterface.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Used for UnderlineNav and UnderlinePanels components
22

33
import React, {forwardRef, type FC, type PropsWithChildren} from 'react'
4+
import {isElement} from 'react-is'
45
import type {IconProps} from '@primer/octicons-react'
56
import styled, {keyframes} from 'styled-components'
67
import CounterLabel from '../../CounterLabel'
@@ -193,7 +194,7 @@ export type UnderlineItemProps = {
193194
iconsVisible?: boolean
194195
loadingCounters?: boolean
195196
counter?: number | string
196-
icon?: FC<IconProps>
197+
icon?: FC<IconProps> | React.ReactElement
197198
id?: string
198199
} & SxProp
199200

@@ -213,11 +214,7 @@ export const UnderlineItem = forwardRef(
213214
) => {
214215
return (
215216
<StyledUnderlineItem ref={forwardedRef} as={as} sx={sxProp} {...rest}>
216-
{iconsVisible && Icon && (
217-
<span data-component="icon">
218-
<Icon />
219-
</span>
220-
)}
217+
{iconsVisible && Icon && <span data-component="icon">{isElement(Icon) ? Icon : <Icon />}</span>}
221218
{children && (
222219
<span data-component="text" data-content={children}>
223220
{children}

0 commit comments

Comments
 (0)