Skip to content

Commit c68a182

Browse files
feat: add accordion and trailingIcon
1 parent d52afdd commit c68a182

4 files changed

Lines changed: 393 additions & 26 deletions

File tree

apps/www/src/app/examples/page.tsx

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const Page = () => {
8686
backgroundColor: 'var(--rs-color-background-base-primary)'
8787
}}
8888
>
89-
<Sidebar defaultOpen variant='inset'>
89+
<Sidebar defaultOpen variant='plain'>
9090
<Sidebar.Header>
9191
<Flex align='center' gap={3}>
9292
<IconButton
@@ -111,7 +111,28 @@ const Page = () => {
111111
Analytics
112112
</Sidebar.Item>
113113

114-
<Sidebar.Group label='Resources'>
114+
<Sidebar.Group
115+
label='Resources'
116+
accordion
117+
trailingIcon={
118+
<button
119+
type='button'
120+
onClick={() => alert('Resources trailing icon clicked')}
121+
aria-label='Resources group actions'
122+
style={{
123+
border: 0,
124+
background: 'transparent',
125+
color: 'inherit',
126+
padding: 0,
127+
display: 'inline-flex',
128+
alignItems: 'center',
129+
cursor: 'pointer'
130+
}}
131+
>
132+
<DotsHorizontalIcon width={16} height={16} />
133+
</button>
134+
}
135+
>
115136
<Sidebar.Item href='#' leadingIcon={<FileTextIcon />}>
116137
Reports
117138
</Sidebar.Item>
@@ -132,7 +153,27 @@ const Page = () => {
132153
</Sidebar.More>
133154
</Sidebar.Group>
134155

135-
<Sidebar.Group label='Account'>
156+
<Sidebar.Group
157+
label='Account'
158+
trailingIcon={
159+
<button
160+
type='button'
161+
onClick={() => alert('Account trailing icon clicked')}
162+
aria-label='Account group actions'
163+
style={{
164+
border: 0,
165+
background: 'transparent',
166+
color: 'inherit',
167+
padding: 0,
168+
display: 'inline-flex',
169+
alignItems: 'center',
170+
cursor: 'pointer'
171+
}}
172+
>
173+
<DotsHorizontalIcon width={16} height={16} />
174+
</button>
175+
}
176+
>
136177
<Sidebar.Item href='#' leadingIcon={<GearIcon />}>
137178
Settings
138179
</Sidebar.Item>

packages/raystack/components/sidebar/__tests__/sidebar.test.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,154 @@ describe('Sidebar', () => {
285285
const group = screen.getByLabelText(MAIN_GROUP_LABEL);
286286
expect(group).toBeInTheDocument();
287287
});
288+
289+
it('renders accordion trigger when accordion is enabled', () => {
290+
render(
291+
<Sidebar>
292+
<Sidebar.Main>
293+
<Sidebar.Group
294+
label={MAIN_GROUP_LABEL}
295+
accordion
296+
leadingIcon={<TestIcon />}
297+
>
298+
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
299+
{DASHBOARD_ITEM_TEXT}
300+
</Sidebar.Item>
301+
</Sidebar.Group>
302+
</Sidebar.Main>
303+
</Sidebar>
304+
);
305+
306+
const trigger = screen.getByRole('button', { name: /Main/ });
307+
expect(trigger).toBeInTheDocument();
308+
expect(trigger).toHaveAttribute('data-panel-open');
309+
});
310+
311+
it('toggles group items when accordion is enabled', () => {
312+
render(
313+
<Sidebar>
314+
<Sidebar.Main>
315+
<Sidebar.Group
316+
label={MAIN_GROUP_LABEL}
317+
accordion
318+
leadingIcon={<TestIcon />}
319+
>
320+
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
321+
{DASHBOARD_ITEM_TEXT}
322+
</Sidebar.Item>
323+
</Sidebar.Group>
324+
</Sidebar.Main>
325+
</Sidebar>
326+
);
327+
328+
const trigger = screen.getByRole('button', { name: /Main/ });
329+
expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();
330+
331+
fireEvent.click(trigger);
332+
expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();
333+
334+
fireEvent.click(trigger);
335+
expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();
336+
});
337+
338+
it('forces accordion panel open when sidebar is collapsed', () => {
339+
const { rerender } = render(
340+
<Sidebar open>
341+
<Sidebar.Main>
342+
<Sidebar.Group
343+
label={MAIN_GROUP_LABEL}
344+
accordion
345+
leadingIcon={<TestIcon />}
346+
>
347+
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
348+
{DASHBOARD_ITEM_TEXT}
349+
</Sidebar.Item>
350+
</Sidebar.Group>
351+
</Sidebar.Main>
352+
</Sidebar>
353+
);
354+
355+
const trigger = screen.getByRole('button', { name: /Main/ });
356+
fireEvent.click(trigger);
357+
expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();
358+
359+
rerender(
360+
<Sidebar open={false}>
361+
<Sidebar.Main>
362+
<Sidebar.Group
363+
label={MAIN_GROUP_LABEL}
364+
accordion
365+
leadingIcon={<TestIcon />}
366+
>
367+
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
368+
{DASHBOARD_ITEM_TEXT}
369+
</Sidebar.Item>
370+
</Sidebar.Group>
371+
</Sidebar.Main>
372+
</Sidebar>
373+
);
374+
375+
expect(
376+
screen.getByRole('listitem', { name: DASHBOARD_ITEM_TEXT })
377+
).toBeInTheDocument();
378+
});
379+
380+
it('renders right icon when provided in accordion header', () => {
381+
render(
382+
<Sidebar>
383+
<Sidebar.Main>
384+
<Sidebar.Group
385+
label={MAIN_GROUP_LABEL}
386+
accordion
387+
trailingIcon={<span data-testid='group-trailing-icon'>+</span>}
388+
>
389+
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
390+
{DASHBOARD_ITEM_TEXT}
391+
</Sidebar.Item>
392+
</Sidebar.Group>
393+
</Sidebar.Main>
394+
</Sidebar>
395+
);
396+
397+
expect(screen.getByTestId('group-trailing-icon')).toBeInTheDocument();
398+
});
399+
400+
it('does not toggle accordion when trailing icon is clicked', () => {
401+
const onTrailingIconClick = vi.fn();
402+
403+
render(
404+
<Sidebar>
405+
<Sidebar.Main>
406+
<Sidebar.Group
407+
label={MAIN_GROUP_LABEL}
408+
accordion
409+
trailingIcon={
410+
<button
411+
type='button'
412+
data-testid='group-trailing-action'
413+
onClick={onTrailingIconClick}
414+
>
415+
+
416+
</button>
417+
}
418+
>
419+
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
420+
{DASHBOARD_ITEM_TEXT}
421+
</Sidebar.Item>
422+
</Sidebar.Group>
423+
</Sidebar.Main>
424+
</Sidebar>
425+
);
426+
427+
const trigger = screen.getByRole('button', { name: /Main/ });
428+
expect(trigger).toHaveAttribute('data-panel-open');
429+
430+
fireEvent.click(screen.getByTestId('group-trailing-action'));
431+
432+
expect(onTrailingIconClick).toHaveBeenCalledTimes(1);
433+
expect(trigger).toHaveAttribute('data-panel-open');
434+
expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();
435+
});
288436
});
289437

290438
describe('Sidebar More', () => {

packages/raystack/components/sidebar/sidebar-misc.tsx

Lines changed: 116 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
'use client';
22

3+
import { Accordion as AccordionPrimitive } from '@base-ui/react';
4+
import { TriangleDownIcon } from '@radix-ui/react-icons';
35
import { cx } from 'class-variance-authority';
4-
import { ComponentProps, ReactNode } from 'react';
6+
import { ComponentProps, ReactNode, useContext } from 'react';
57
import { Flex } from '../flex';
68
import styles from './sidebar.module.css';
9+
import { SidebarContext } from './sidebar-root';
710

811
export function SidebarHeader({
912
className,
@@ -38,50 +41,142 @@ SidebarFooter.displayName = 'Sidebar.Footer';
3841

3942
export interface SidebarNavigationGroupProps extends ComponentProps<'section'> {
4043
label: string;
44+
value?: string;
45+
accordion?: boolean;
4146
leadingIcon?: ReactNode;
47+
trailingIcon?: ReactNode;
4248
classNames?: {
4349
header?: string;
4450
items?: string;
4551
label?: string;
4652
icon?: string;
53+
trigger?: string;
54+
chevron?: string;
55+
trailingIcon?: string;
4756
};
4857
}
4958

5059
export function SidebarNavigationGroup({
5160
className,
5261
label,
62+
value,
63+
accordion = false,
5364
leadingIcon,
65+
trailingIcon,
5466
classNames,
5567
children,
5668
...props
5769
}: SidebarNavigationGroupProps) {
70+
const { isCollapsed } = useContext(SidebarContext);
71+
const groupValue = value ?? label;
72+
73+
if (!accordion) {
74+
return (
75+
<section
76+
className={cx(styles['nav-group'], className)}
77+
aria-label={label}
78+
{...props}
79+
>
80+
<Flex
81+
align='center'
82+
gap={3}
83+
className={cx(
84+
styles['nav-group-header'],
85+
trailingIcon && styles['nav-group-header-with-trailing'],
86+
classNames?.header
87+
)}
88+
>
89+
{leadingIcon && (
90+
<span className={cx(styles['nav-leading-icon'], classNames?.icon)}>
91+
{leadingIcon}
92+
</span>
93+
)}
94+
<span className={cx(styles['nav-group-label'], classNames?.label)}>
95+
{label}
96+
</span>
97+
{trailingIcon ? (
98+
<span
99+
className={cx(
100+
styles['nav-group-trailing-icon'],
101+
classNames?.trailingIcon
102+
)}
103+
>
104+
{trailingIcon}
105+
</span>
106+
) : null}
107+
</Flex>
108+
<Flex
109+
direction='column'
110+
className={cx(styles['nav-group-items'], classNames?.items)}
111+
role='list'
112+
>
113+
{children}
114+
</Flex>
115+
</section>
116+
);
117+
}
118+
58119
return (
59120
<section
60121
className={cx(styles['nav-group'], className)}
61122
aria-label={label}
62123
{...props}
63124
>
64-
<Flex
65-
align='center'
66-
gap={3}
67-
className={cx(styles['nav-group-header'], classNames?.header)}
68-
>
69-
{leadingIcon && (
70-
<span className={cx(styles['nav-leading-icon'], classNames?.icon)}>
71-
{leadingIcon}
72-
</span>
73-
)}
74-
<span className={cx(styles['nav-group-label'], classNames?.label)}>
75-
{label}
76-
</span>
77-
</Flex>
78-
<Flex
79-
direction='column'
80-
className={cx(styles['nav-group-items'], classNames?.items)}
81-
role='list'
125+
<AccordionPrimitive.Root
126+
key={isCollapsed ? 'collapsed' : 'expanded'}
127+
className={styles['nav-group-accordion']}
128+
multiple
129+
defaultValue={[groupValue]}
82130
>
83-
{children}
84-
</Flex>
131+
<AccordionPrimitive.Item
132+
value={groupValue}
133+
className={styles['nav-group-accordion-item']}
134+
>
135+
<AccordionPrimitive.Header
136+
className={cx(styles['nav-group-header'], classNames?.header)}
137+
>
138+
<AccordionPrimitive.Trigger
139+
className={cx(styles['nav-group-trigger'], classNames?.trigger)}
140+
>
141+
{leadingIcon && (
142+
<span
143+
className={cx(styles['nav-leading-icon'], classNames?.icon)}
144+
>
145+
{leadingIcon}
146+
</span>
147+
)}
148+
<span
149+
className={cx(styles['nav-group-label'], classNames?.label)}
150+
>
151+
{label}
152+
</span>
153+
<TriangleDownIcon
154+
className={cx(styles['nav-group-chevron'], classNames?.chevron)}
155+
aria-hidden='true'
156+
/>
157+
</AccordionPrimitive.Trigger>
158+
{trailingIcon ? (
159+
<span
160+
className={cx(
161+
styles['nav-group-trailing-icon'],
162+
classNames?.trailingIcon
163+
)}
164+
>
165+
{trailingIcon}
166+
</span>
167+
) : null}
168+
</AccordionPrimitive.Header>
169+
<AccordionPrimitive.Panel className={styles['nav-group-panel']}>
170+
<Flex
171+
direction='column'
172+
className={cx(styles['nav-group-items'], classNames?.items)}
173+
role='list'
174+
>
175+
{children}
176+
</Flex>
177+
</AccordionPrimitive.Panel>
178+
</AccordionPrimitive.Item>
179+
</AccordionPrimitive.Root>
85180
</section>
86181
);
87182
}

0 commit comments

Comments
 (0)