Skip to content

Commit 6b137a4

Browse files
TylerJDevkhiga8joshblack
authoredFeb 24, 2025··
Add expand to NavList (#4686)
* Add expand to `NavList` * Improved semantics * Remove styles * Add extra story * Add unit test coverage * Add Group unit test coverage * Add e2e test coverage * Add expanded to groups * Fix import * Change structure * Update stories, add ref * Update tests, add focus target * Add changeset * Remove `GroupContent` * Update to use context * Add useRef usage * Change name to `NavList.ShowMoreItem` * Update docs * test(vrt): update snapshots * Update .changeset/many-rivers-deny.md Co-authored-by: Josh Black <joshblack@github.com> * Address some feedback * Memoize id value * Update w/ new prop `Pages` based on suggestion from Primer patterns * test(vrt): update snapshots * Remove story * Remove story test * Remove the correct story * Add condition for css modules feature flag * Update tests, docs, add ternary * Only allow for `Item` in `ShowMoreItem` * Fix lint issue * Add dependencies * Remove comment * Add data API * Some clean up for `NavList` * More clean up * Use `useMemo` instead * Update docs * Fix tests * Remove auto-import * Add `key` to story * PR review feedback * Type check * Update packages/react/src/NavList/NavList.tsx Co-authored-by: Josh Black <joshblack@github.com> --------- Co-authored-by: Kate Higa <16447748+khiga8@users.noreply.github.com> Co-authored-by: TylerJDev <TylerJDev@users.noreply.github.com> Co-authored-by: Josh Black <joshblack@github.com>
1 parent a93a3fc commit 6b137a4

25 files changed

+685
-4
lines changed
 

‎.changeset/many-rivers-deny.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Add `NavList.ShowMoreItem` component to support showing more content within a `NavList`.
Loading

‎e2e/components/NavList.test.ts

+56
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,60 @@ test.describe('NavList', () => {
9292
})
9393
}
9494
})
95+
96+
test.describe('With expand', () => {
97+
for (const theme of themes) {
98+
test.describe(theme, () => {
99+
test('default @vrt', async ({page}) => {
100+
await visit(page, {
101+
id: 'components-navlist--with-expand',
102+
globals: {
103+
colorScheme: theme,
104+
},
105+
})
106+
107+
// Default state
108+
expect(await page.screenshot()).toMatchSnapshot(`NavList.With expand.${theme}.png`)
109+
})
110+
111+
test('axe @aat', async ({page}) => {
112+
await visit(page, {
113+
id: 'components-navlist--with-expand',
114+
globals: {
115+
colorScheme: theme,
116+
},
117+
})
118+
await expect(page).toHaveNoViolations()
119+
})
120+
})
121+
}
122+
})
123+
124+
test.describe('With group expand', () => {
125+
for (const theme of themes) {
126+
test.describe(theme, () => {
127+
test('default @vrt', async ({page}) => {
128+
await visit(page, {
129+
id: 'components-navlist--with-group-expand',
130+
globals: {
131+
colorScheme: theme,
132+
},
133+
})
134+
135+
// Default state
136+
expect(await page.screenshot()).toMatchSnapshot(`NavList.With group expand.${theme}.png`)
137+
})
138+
139+
test('axe @aat', async ({page}) => {
140+
await visit(page, {
141+
id: 'components-navlist--with-group-expand',
142+
globals: {
143+
colorScheme: theme,
144+
},
145+
})
146+
await expect(page).toHaveNoViolations()
147+
})
148+
})
149+
}
150+
})
95151
})

‎packages/react/src/NavList/NavList.docs.json

+35
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,41 @@
219219
"description": "href when the TrailingAction is rendered as a link."
220220
}
221221
]
222+
},
223+
{
224+
"name": "NavList.GroupExpand",
225+
"props": [
226+
{
227+
"name": "label",
228+
"type": "string",
229+
"defaultValue": "",
230+
"required": true,
231+
"description": "Acccessible name for the control."
232+
},
233+
{
234+
"name": "pages",
235+
"type": "number",
236+
"defaultValue": "0",
237+
"required": false,
238+
"description": "The total number of pages, used to calculate the number of items to show at a given time."
239+
},
240+
{
241+
"name": "items",
242+
"type": "Array<GroupItems>",
243+
"required": true,
244+
"description": "The NavList.Items to render in the group."
245+
},
246+
{
247+
"name": "renderItem",
248+
"type": "(item: {text: string}) => React.ReactNode",
249+
"required": false,
250+
"description": "A function that returns a ReactNode to render in the group. If this is not provided, the group will only render the items in the array."
251+
},
252+
{
253+
"name": "ref",
254+
"type": "React.RefObject<HTMLButtonElement>"
255+
}
256+
]
222257
}
223258
]
224259
}

‎packages/react/src/NavList/NavList.stories.tsx

+271-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,25 @@ import type {Meta, StoryFn} from '@storybook/react'
22
import React from 'react'
33
import {PageLayout} from '../PageLayout'
44
import {NavList} from './NavList'
5-
import {ArrowRightIcon, ArrowLeftIcon, BookIcon, FileDirectoryIcon} from '@primer/octicons-react'
5+
import {
6+
type Icon,
7+
ArrowRightIcon,
8+
ArrowLeftIcon,
9+
BookIcon,
10+
FileDirectoryIcon,
11+
CodeIcon,
12+
RepoIcon,
13+
IssueOpenedIcon,
14+
GitPullRequestIcon,
15+
CommentDiscussionIcon,
16+
PeopleIcon,
17+
GitCommitIcon,
18+
PackageIcon,
19+
MilestoneIcon,
20+
TelescopeIcon,
21+
} from '@primer/octicons-react'
22+
import Octicon from '../Octicon'
23+
import VisuallyHidden from '../_VisuallyHidden'
624

725
const meta: Meta = {
826
title: 'Components/NavList',
@@ -328,4 +346,256 @@ export const WithTrailingActionInSubItem = () => {
328346
)
329347
}
330348

349+
export const WithExpand: StoryFn = () => {
350+
const items = [
351+
{href: '#', text: 'Item 4'},
352+
{href: '#', text: 'Item 5'},
353+
{href: '#', text: 'Item 6'},
354+
{href: '#', text: 'Item 7'},
355+
{href: '#', text: 'Item 8'},
356+
{href: '#', text: 'Item 9'},
357+
]
358+
359+
return (
360+
<PageLayout>
361+
<PageLayout.Pane position="start">
362+
<NavList>
363+
<NavList.Item href="#" aria-current="page">
364+
Item 1
365+
</NavList.Item>
366+
<NavList.Item href="#">Item 2</NavList.Item>
367+
<NavList.Item href="#">Item 3</NavList.Item>
368+
<NavList.GroupExpand label="Show more" items={items} />
369+
</NavList>
370+
</PageLayout.Pane>
371+
<PageLayout.Content></PageLayout.Content>
372+
</PageLayout>
373+
)
374+
}
375+
376+
export const WithExpandAndIcons: StoryFn = () => {
377+
const items = [
378+
{href: '#', text: 'Item 4'},
379+
{href: '#', text: 'Item 5'},
380+
{href: '#', text: 'Item 6'},
381+
{href: '#', text: 'Item 7'},
382+
{href: '#', text: 'Item 8'},
383+
{href: '#', text: 'Item 9'},
384+
]
385+
386+
return (
387+
<PageLayout>
388+
<PageLayout.Pane position="start">
389+
<NavList>
390+
<NavList.Item href="#" aria-current="page">
391+
Item 1
392+
</NavList.Item>
393+
<NavList.Item href="#">Item 2</NavList.Item>
394+
<NavList.Item href="#">Item 3</NavList.Item>
395+
<NavList.GroupExpand label="Show more" items={items} />
396+
</NavList>
397+
</PageLayout.Pane>
398+
<PageLayout.Content></PageLayout.Content>
399+
</PageLayout>
400+
)
401+
}
402+
403+
type CustomItemProps = {
404+
text: string
405+
leadingVisual?: Icon
406+
trailingVisual?: Icon | string
407+
}
408+
409+
export const ExpandWithCustomItems: StoryFn = () => {
410+
const items: {href: string; text: string; 'aria-current'?: 'page'}[] = [
411+
{href: '#', text: 'Item 4', 'aria-current': 'page'},
412+
{href: '#', text: 'Item 5'},
413+
{href: '#', text: 'Item 6'},
414+
{href: '#', text: 'Item 7'},
415+
{href: '#', text: 'Item 8'},
416+
{href: '#', text: 'Item 9'},
417+
]
418+
419+
const Item = ({leadingVisual, text, trailingVisual, ...rest}: CustomItemProps) => {
420+
return (
421+
<NavList.Item key={text} onClick={() => {}} href="#" {...rest}>
422+
{leadingVisual ? (
423+
<NavList.LeadingVisual>
424+
<Octicon icon={leadingVisual} />
425+
</NavList.LeadingVisual>
426+
) : null}
427+
{text}
428+
429+
{trailingVisual ? (
430+
<NavList.TrailingVisual>
431+
{typeof trailingVisual === 'string' ? (
432+
trailingVisual
433+
) : (
434+
<Octicon icon={trailingVisual as React.ElementType} />
435+
)}
436+
<VisuallyHidden>results</VisuallyHidden>
437+
</NavList.TrailingVisual>
438+
) : null}
439+
</NavList.Item>
440+
)
441+
}
442+
443+
return (
444+
<PageLayout>
445+
<PageLayout.Pane position="start">
446+
<NavList>
447+
<NavList.Item href="#">Item 1</NavList.Item>
448+
<NavList.Item href="#">Item 2</NavList.Item>
449+
<NavList.Item href="#">Item 3</NavList.Item>
450+
<NavList.GroupExpand label="Show more" items={items} renderItem={Item} />
451+
</NavList>
452+
</PageLayout.Pane>
453+
<PageLayout.Content></PageLayout.Content>
454+
</PageLayout>
455+
)
456+
}
457+
458+
export const ExpandWithPages: StoryFn = () => {
459+
const items = [
460+
{href: '#', text: 'Item 4'},
461+
{href: '#', text: 'Item 5'},
462+
{href: '#', text: 'Item 6'},
463+
{href: '#', text: 'Item 7'},
464+
{href: '#', text: 'Item 8'},
465+
{href: '#', text: 'Item 9'},
466+
]
467+
468+
return (
469+
<PageLayout>
470+
<PageLayout.Pane position="start">
471+
<NavList>
472+
<NavList.Item href="#" aria-current="page">
473+
Item 1
474+
</NavList.Item>
475+
<NavList.Item href="#">Item 2</NavList.Item>
476+
<NavList.Item href="#">Item 3</NavList.Item>
477+
<NavList.GroupExpand pages={2} label="Show more" items={items} />
478+
</NavList>
479+
</PageLayout.Pane>
480+
<PageLayout.Content></PageLayout.Content>
481+
</PageLayout>
482+
)
483+
}
484+
485+
export const WithGroupExpand = () => {
486+
const items1 = [
487+
{href: '#', text: 'Item 1D'},
488+
{href: '#', text: 'Item 1E', trailingAction: {label: 'Some action', icon: ArrowRightIcon}},
489+
]
490+
491+
const items2 = [
492+
{href: '#', text: 'Item 2D', trailingVisual: BookIcon},
493+
{href: '#', text: 'Item 2E', trailingVisual: FileDirectoryIcon},
494+
]
495+
496+
return (
497+
<PageLayout>
498+
<PageLayout.Pane position="start">
499+
<NavList>
500+
<NavList.Group title="Group 1">
501+
<NavList.Item aria-current="true" href="#">
502+
Item 1A
503+
</NavList.Item>
504+
<NavList.Item href="#">Item 1B</NavList.Item>
505+
<NavList.Item href="#">Item 1C</NavList.Item>
506+
<NavList.GroupExpand label="More" items={items1} />
507+
</NavList.Group>
508+
<NavList.Group title="Group 2">
509+
<NavList.Item href="#">Item 2A</NavList.Item>
510+
<NavList.Item href="#">Item 2B</NavList.Item>
511+
<NavList.Item href="#">Item 2C</NavList.Item>
512+
<NavList.GroupExpand label="Show" items={items2} />
513+
</NavList.Group>
514+
</NavList>
515+
</PageLayout.Pane>
516+
<PageLayout.Content></PageLayout.Content>
517+
</PageLayout>
518+
)
519+
}
520+
521+
export const GroupWithExpandAndCustomItems = () => {
522+
const Item = ({leadingVisual: LeadingVisual, text, trailingVisual: TrailingVisual, ...rest}: CustomItemProps) => {
523+
return (
524+
<NavList.Item onClick={() => {}} href="#" {...rest} key={text}>
525+
{LeadingVisual ? (
526+
<NavList.LeadingVisual>
527+
<LeadingVisual />
528+
</NavList.LeadingVisual>
529+
) : null}
530+
{text}
531+
532+
{TrailingVisual ? (
533+
<NavList.TrailingVisual>
534+
{typeof TrailingVisual === 'string' ? TrailingVisual : <TrailingVisual />}
535+
<VisuallyHidden>results</VisuallyHidden>
536+
</NavList.TrailingVisual>
537+
) : null}
538+
</NavList.Item>
539+
)
540+
}
541+
542+
const items = [
543+
{href: '#', text: 'Commits', leadingVisual: GitCommitIcon, trailingVisual: '32k'},
544+
{href: '#', text: 'Packages', leadingVisual: PackageIcon, trailingVisual: '1k'},
545+
{href: '#', text: 'Wikis', leadingVisual: BookIcon, trailingVisual: '121'},
546+
{href: '#', text: 'Topics', leadingVisual: MilestoneIcon, trailingVisual: '12k'},
547+
{href: '#', text: 'Marketplace', leadingVisual: TelescopeIcon, trailingVisual: ArrowRightIcon},
548+
]
549+
550+
return (
551+
<NavList>
552+
<NavList.Group>
553+
<NavList.Item aria-current="page">
554+
<NavList.LeadingVisual>
555+
<CodeIcon />
556+
</NavList.LeadingVisual>
557+
Code
558+
<NavList.TrailingVisual>3k</NavList.TrailingVisual>
559+
</NavList.Item>
560+
<NavList.Item>
561+
<NavList.LeadingVisual>
562+
<RepoIcon />
563+
</NavList.LeadingVisual>
564+
Repositories
565+
<NavList.TrailingVisual>713</NavList.TrailingVisual>
566+
</NavList.Item>
567+
<NavList.Item>
568+
<NavList.LeadingVisual>
569+
<IssueOpenedIcon />
570+
</NavList.LeadingVisual>
571+
Issues
572+
<NavList.TrailingVisual>6k</NavList.TrailingVisual>
573+
</NavList.Item>
574+
<NavList.Item>
575+
<NavList.LeadingVisual>
576+
<GitPullRequestIcon />
577+
</NavList.LeadingVisual>
578+
Pull requests
579+
<NavList.TrailingVisual>4k</NavList.TrailingVisual>
580+
</NavList.Item>
581+
<NavList.Item>
582+
<NavList.LeadingVisual>
583+
<CommentDiscussionIcon />
584+
</NavList.LeadingVisual>
585+
Discussions
586+
<NavList.TrailingVisual>236</NavList.TrailingVisual>
587+
</NavList.Item>
588+
<NavList.Item>
589+
<NavList.LeadingVisual>
590+
<PeopleIcon />
591+
</NavList.LeadingVisual>
592+
Users
593+
<NavList.TrailingVisual>10k</NavList.TrailingVisual>
594+
</NavList.Item>
595+
<NavList.GroupExpand items={items} renderItem={Item} />
596+
</NavList.Group>
597+
</NavList>
598+
)
599+
}
600+
331601
export default meta

‎packages/react/src/NavList/NavList.test.tsx

+172-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {render, fireEvent} from '@testing-library/react'
1+
import {render, fireEvent, act} from '@testing-library/react'
22
import React from 'react'
33
import {ThemeProvider} from '..'
44
import {NavList} from './NavList'
@@ -381,3 +381,174 @@ describe('NavList.Item with NavList.SubNav', () => {
381381
})
382382
})
383383
})
384+
385+
describe('NavList.GroupExpand', () => {
386+
function NavListWithExpand() {
387+
const items = [
388+
{text: 'Item 3', href: '#'},
389+
{text: 'Item 4', href: '#'},
390+
]
391+
392+
return (
393+
<NavList>
394+
<NavList.Item href="#">Item 1</NavList.Item>
395+
<NavList.Item href="#">Item 2</NavList.Item>
396+
<NavList.GroupExpand label="More" items={items} />
397+
</NavList>
398+
)
399+
}
400+
401+
it('renders with button', () => {
402+
const {queryByRole} = render(<NavListWithExpand />)
403+
const button = queryByRole('button', {name: 'More'})
404+
expect(button).toBeInTheDocument()
405+
})
406+
407+
it('renders button as child of <ul>', () => {
408+
const {queryByRole} = render(<NavListWithExpand />)
409+
const button = queryByRole('button', {name: 'More'})
410+
const buttonParent = button!.parentElement as HTMLButtonElement
411+
412+
expect(buttonParent).toBeInTheDocument()
413+
expect(buttonParent.tagName).toEqual('LI')
414+
expect(buttonParent.parentElement?.tagName).toEqual('UL')
415+
})
416+
417+
it('hides items inside of NavList.ShowMoreItem by default', () => {
418+
const {queryByRole} = render(<NavListWithExpand />)
419+
420+
expect(queryByRole('link', {name: 'Item 1'})).toBeInTheDocument()
421+
expect(queryByRole('link', {name: 'Item 2'})).toBeInTheDocument()
422+
expect(queryByRole('link', {name: 'Item 3'})).not.toBeInTheDocument()
423+
expect(queryByRole('link', {name: 'Item 4'})).not.toBeInTheDocument()
424+
})
425+
426+
it('shows items inside of NavList.ShowMoreItem when expand button is activated', () => {
427+
const {queryByRole} = render(<NavListWithExpand />)
428+
429+
act(() => {
430+
queryByRole('button', {name: 'More'})?.click()
431+
})
432+
433+
expect(queryByRole('link', {name: 'Item 1'})).toBeInTheDocument()
434+
expect(queryByRole('link', {name: 'Item 2'})).toBeInTheDocument()
435+
expect(queryByRole('link', {name: 'Item 3'})).toBeInTheDocument()
436+
expect(queryByRole('link', {name: 'Item 4'})).toBeInTheDocument()
437+
438+
expect(queryByRole('button', {name: 'More'})).not.toBeInTheDocument()
439+
})
440+
441+
it('removes expand button after it is activated', () => {
442+
const {queryByRole} = render(<NavListWithExpand />)
443+
444+
act(() => {
445+
queryByRole('button', {name: 'More'})?.click()
446+
})
447+
448+
expect(queryByRole('button', {name: 'More'})).not.toBeInTheDocument()
449+
})
450+
451+
it('places focus on the first of the newly shown list item', () => {
452+
const {queryByRole} = render(<NavListWithExpand />)
453+
454+
act(() => {
455+
queryByRole('button', {name: 'More'})?.click()
456+
})
457+
458+
expect(queryByRole('link', {name: 'Item 3'})).toHaveFocus()
459+
})
460+
})
461+
462+
describe('NavList.GroupExpand with Group', () => {
463+
function NavListWithExpand() {
464+
const items1 = [
465+
{text: 'Item 1D', href: '#'},
466+
{text: 'Item 1E', href: '#'},
467+
{text: 'Item 1F', href: '#'},
468+
]
469+
470+
const items2 = [
471+
{text: 'Item 2D', href: '#'},
472+
{text: 'Item 2E', href: '#'},
473+
{text: 'Item 2F', href: '#'},
474+
]
475+
476+
return (
477+
<NavList>
478+
<NavList.Group title="Group 1">
479+
<NavList.Item aria-current="true" href="#">
480+
Item 1A
481+
</NavList.Item>
482+
<NavList.Item href="#">Item 1B</NavList.Item>
483+
<NavList.Item href="#">Item 1C</NavList.Item>
484+
<NavList.GroupExpand label="More" items={items1} />
485+
</NavList.Group>
486+
<NavList.Group title="Group 2">
487+
<NavList.Item href="#">Item 2A</NavList.Item>
488+
<NavList.Item href="#">Item 2B</NavList.Item>
489+
<NavList.Item href="#">Item 2C</NavList.Item>
490+
<NavList.GroupExpand label="Show" items={items2} />
491+
</NavList.Group>
492+
</NavList>
493+
)
494+
}
495+
496+
it('renders expand buttons for each group', () => {
497+
const {queryByRole} = render(<NavListWithExpand />)
498+
499+
expect(queryByRole('button', {name: 'More'})).toBeInTheDocument()
500+
expect(queryByRole('button', {name: 'Show'})).toBeInTheDocument()
501+
})
502+
503+
it('renders expand buttons as within <ul>', () => {
504+
const {queryByRole} = render(<NavListWithExpand />)
505+
506+
const group1Button = queryByRole('button', {name: 'More'})
507+
const buttonParent = group1Button?.parentElement as HTMLUListElement
508+
509+
expect(buttonParent).toBeInTheDocument()
510+
expect(buttonParent.tagName).toEqual('LI')
511+
expect(buttonParent.parentElement!.tagName).toEqual('UL')
512+
})
513+
})
514+
515+
describe('NavList.ShowMoreItem with pages', () => {
516+
function NavListExpandWithPages() {
517+
const items = [
518+
{text: 'Item 3', href: '#'},
519+
{text: 'Item 4', href: '#'},
520+
{text: 'Item 5', href: '#'},
521+
{text: 'Item 6', href: '#'},
522+
{text: 'Item 7', href: '#'},
523+
]
524+
525+
return (
526+
<NavList>
527+
<NavList.Item href="#">Item 1</NavList.Item>
528+
<NavList.Item href="#">Item 2</NavList.Item>
529+
<NavList.GroupExpand pages={2} label="More" items={items} />
530+
</NavList>
531+
)
532+
}
533+
534+
it('renders an expand button', () => {
535+
const {queryByRole} = render(<NavListExpandWithPages />)
536+
537+
expect(queryByRole('button', {name: 'More'})).toBeInTheDocument()
538+
})
539+
540+
it('expands the list when the expand button is clicked', () => {
541+
const {queryByRole} = render(<NavListExpandWithPages />)
542+
const button = queryByRole('button', {name: 'More'})
543+
544+
act(() => {
545+
button?.click()
546+
})
547+
548+
expect(queryByRole('link', {name: 'Item 3'})).toBeInTheDocument()
549+
expect(queryByRole('link', {name: 'Item 4'})).toBeInTheDocument()
550+
expect(queryByRole('link', {name: 'Item 5'})).toBeInTheDocument()
551+
expect(queryByRole('link', {name: 'Item 6'})).not.toBeInTheDocument()
552+
expect(queryByRole('link', {name: 'Item 7'})).not.toBeInTheDocument()
553+
})
554+
})

‎packages/react/src/NavList/NavList.tsx

+134-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {ChevronDownIcon} from '@primer/octicons-react'
1+
import {ChevronDownIcon, PlusIcon, type Icon} from '@primer/octicons-react'
22
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
33
import React, {isValidElement} from 'react'
44
import styled from 'styled-components'
@@ -22,6 +22,7 @@ import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
2222
import {useFeatureFlag} from '../FeatureFlags'
2323
import classes from '../ActionList/ActionList.module.css'
2424
import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent'
25+
import {flushSync} from 'react-dom'
2526

2627
const getSubnavStyles = (depth: number) => {
2728
return {
@@ -413,7 +414,137 @@ const Group: React.FC<NavListGroupProps> = ({title, children, sx: sxProp = defau
413414
)
414415
}
415416

416-
Group.displayName = 'NavList.Group'
417+
// ----------------------------------------------------------------------------
418+
// NavList.GroupExpand
419+
420+
type GroupItem = {
421+
text: string
422+
trailingVisual?: Icon | string
423+
leadingVisual?: Icon
424+
trailingAction?: ActionListTrailingActionProps
425+
'data-expand-focus-target'?: string
426+
} & Omit<NavListItemProps, 'children'>
427+
428+
export type NavListGroupExpandProps = {
429+
label?: string
430+
pages?: number
431+
items: GroupItem[]
432+
renderItem?: (item: GroupItem) => React.ReactNode
433+
}
434+
435+
export const GroupExpand = React.forwardRef<HTMLButtonElement, NavListGroupExpandProps>(
436+
({label = 'Show more', pages = 0, items, renderItem, ...props}, forwardedRef) => {
437+
const [currentPage, setCurrentPage] = React.useState(0)
438+
const groupId = useId()
439+
440+
const teamEnabled = useFeatureFlag('primer_react_css_modules_team')
441+
const staffEnabled = useFeatureFlag('primer_react_css_modules_staff')
442+
443+
const itemsPerPage = items.length / pages
444+
const amountToShow = pages === 0 ? items.length : Math.ceil(itemsPerPage * currentPage)
445+
const focusTargetIndex = currentPage === 1 ? 0 : amountToShow - Math.floor(itemsPerPage)
446+
447+
return (
448+
<>
449+
{currentPage > 0 ? (
450+
<>
451+
{items.map((itemArr, index) => {
452+
const {
453+
text,
454+
trailingVisual: TrailingVisualIcon,
455+
leadingVisual: LeadingVisualIcon,
456+
trailingAction,
457+
...rest
458+
} = itemArr
459+
const {icon, label: actionLabel, ...actionProps} = trailingAction || {}
460+
const focusTarget = index === focusTargetIndex ? groupId : undefined
461+
462+
if (index < amountToShow) {
463+
if (renderItem) {
464+
return renderItem({
465+
...itemArr,
466+
['data-expand-focus-target']: focusTarget,
467+
})
468+
}
469+
return (
470+
<Item {...rest} key={index} data-expand-focus-target={focusTarget}>
471+
{LeadingVisualIcon ? (
472+
<LeadingVisual>
473+
<LeadingVisualIcon />
474+
</LeadingVisual>
475+
) : null}
476+
{text}
477+
{TrailingVisualIcon ? (
478+
<TrailingVisual>
479+
<TrailingVisualIcon />
480+
</TrailingVisual>
481+
) : null}
482+
{trailingAction ? <TrailingAction {...actionProps} icon={icon} label={actionLabel || ''} /> : null}
483+
</Item>
484+
)
485+
}
486+
})}
487+
</>
488+
) : null}
489+
{(currentPage < pages || currentPage === 0) && !teamEnabled && !staffEnabled ? (
490+
<Box as="li" sx={{listStyle: 'none'}}>
491+
<ActionList.Item
492+
as="button"
493+
aria-expanded="false"
494+
ref={forwardedRef}
495+
onClick={() => {
496+
flushSync(() => {
497+
setCurrentPage(currentPage + 1)
498+
})
499+
const focusTarget: HTMLElement[] | null = Array.from(
500+
document.querySelectorAll(`[data-expand-focus-target="${groupId}"]`),
501+
)
502+
503+
if (focusTarget.length > 0) {
504+
focusTarget[focusTarget.length - 1].focus()
505+
}
506+
}}
507+
{...props}
508+
>
509+
{label}
510+
<TrailingVisual>
511+
<PlusIcon />
512+
</TrailingVisual>
513+
</ActionList.Item>
514+
</Box>
515+
) : null}
516+
{(currentPage < pages || currentPage === 0) && (teamEnabled || staffEnabled) ? (
517+
<ActionList.Item
518+
as="button"
519+
aria-expanded="false"
520+
ref={forwardedRef}
521+
onClick={() => {
522+
flushSync(() => {
523+
setCurrentPage(currentPage + 1)
524+
})
525+
const focusTarget: HTMLElement[] | null = Array.from(
526+
document.querySelectorAll(`[data-expand-focus-target="${groupId}"]`),
527+
)
528+
529+
if (focusTarget.length > 0) {
530+
focusTarget[focusTarget.length - 1].focus()
531+
}
532+
}}
533+
{...props}
534+
>
535+
{label}
536+
<TrailingVisual>
537+
<PlusIcon />
538+
</TrailingVisual>
539+
</ActionList.Item>
540+
) : null}
541+
</>
542+
)
543+
},
544+
)
545+
546+
// ----------------------------------------------------------------------------
547+
// NavList.GroupHeading
417548

418549
export type NavListGroupHeadingProps = ActionListGroupHeadingProps
419550

@@ -453,5 +584,6 @@ export const NavList = Object.assign(Root, {
453584
TrailingAction,
454585
Divider,
455586
Group,
587+
GroupExpand,
456588
GroupHeading,
457589
})

‎script/generate-e2e-tests.js

100644100755
+12
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,18 @@ const components = new Map([
789789
'NavList',
790790
{
791791
stories: [
792+
{
793+
id: 'components-navlist--simple',
794+
name: 'Simple',
795+
},
796+
{
797+
id: 'components-navlist--with-group',
798+
name: 'With group',
799+
},
800+
{
801+
id: 'components-navlist--with-group-expand',
802+
name: 'With group expand',
803+
},
792804
{
793805
id: 'components-navlist--with-trailing-action',
794806
name: 'With TrailingAction',

0 commit comments

Comments
 (0)
Please sign in to comment.