Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions apps/www/src/app/examples/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const Page = () => {
backgroundColor: 'var(--rs-color-background-base-primary)'
}}
>
<Sidebar defaultOpen variant='inset'>
<Sidebar defaultOpen variant='plain'>
<Sidebar.Header>
<Flex align='center' gap={3}>
<IconButton
Expand All @@ -111,7 +111,28 @@ const Page = () => {
Analytics
</Sidebar.Item>

<Sidebar.Group label='Resources'>
<Sidebar.Group
label='Resources'
accordion
trailingIcon={
<button
type='button'
onClick={() => alert('Resources trailing icon clicked')}
aria-label='Resources group actions'
style={{
border: 0,
background: 'transparent',
color: 'inherit',
padding: 0,
display: 'inline-flex',
alignItems: 'center',
cursor: 'pointer'
}}
>
<DotsHorizontalIcon width={16} height={16} />
</button>
}
>
<Sidebar.Item href='#' leadingIcon={<FileTextIcon />}>
Reports
</Sidebar.Item>
Expand All @@ -132,7 +153,27 @@ const Page = () => {
</Sidebar.More>
</Sidebar.Group>

<Sidebar.Group label='Account'>
<Sidebar.Group
label='Account'
trailingIcon={
<button
type='button'
onClick={() => alert('Account trailing icon clicked')}
aria-label='Account group actions'
style={{
border: 0,
background: 'transparent',
color: 'inherit',
padding: 0,
display: 'inline-flex',
alignItems: 'center',
cursor: 'pointer'
}}
>
<DotsHorizontalIcon width={16} height={16} />
</button>
}
>
<Sidebar.Item href='#' leadingIcon={<GearIcon />}>
Settings
</Sidebar.Item>
Expand Down
148 changes: 148 additions & 0 deletions packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,154 @@ describe('Sidebar', () => {
const group = screen.getByLabelText(MAIN_GROUP_LABEL);
expect(group).toBeInTheDocument();
});

it('renders accordion trigger when accordion is enabled', () => {
render(
<Sidebar>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
accordion
leadingIcon={<TestIcon />}
>
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
{DASHBOARD_ITEM_TEXT}
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
</Sidebar>
);

const trigger = screen.getByRole('button', { name: /Main/ });
expect(trigger).toBeInTheDocument();
expect(trigger).toHaveAttribute('data-panel-open');
});

it('toggles group items when accordion is enabled', () => {
render(
<Sidebar>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
accordion
leadingIcon={<TestIcon />}
>
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
{DASHBOARD_ITEM_TEXT}
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
</Sidebar>
);

const trigger = screen.getByRole('button', { name: /Main/ });
expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();

fireEvent.click(trigger);
expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();

fireEvent.click(trigger);
expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();
});

it('forces accordion panel open when sidebar is collapsed', () => {
const { rerender } = render(
<Sidebar open>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
accordion
leadingIcon={<TestIcon />}
>
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
{DASHBOARD_ITEM_TEXT}
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
</Sidebar>
);

const trigger = screen.getByRole('button', { name: /Main/ });
fireEvent.click(trigger);
expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();

rerender(
<Sidebar open={false}>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
accordion
leadingIcon={<TestIcon />}
>
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
{DASHBOARD_ITEM_TEXT}
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
</Sidebar>
);

expect(
screen.getByRole('listitem', { name: DASHBOARD_ITEM_TEXT })
).toBeInTheDocument();
});

it('renders right icon when provided in accordion header', () => {
render(
<Sidebar>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
accordion
trailingIcon={<span data-testid='group-trailing-icon'>+</span>}
>
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
{DASHBOARD_ITEM_TEXT}
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
</Sidebar>
);

expect(screen.getByTestId('group-trailing-icon')).toBeInTheDocument();
});

it('does not toggle accordion when trailing icon is clicked', () => {
const onTrailingIconClick = vi.fn();

render(
<Sidebar>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
accordion
trailingIcon={
<button
type='button'
data-testid='group-trailing-action'
onClick={onTrailingIconClick}
>
+
</button>
}
>
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
{DASHBOARD_ITEM_TEXT}
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
</Sidebar>
);

const trigger = screen.getByRole('button', { name: /Main/ });
expect(trigger).toHaveAttribute('data-panel-open');

fireEvent.click(screen.getByTestId('group-trailing-action'));

expect(onTrailingIconClick).toHaveBeenCalledTimes(1);
expect(trigger).toHaveAttribute('data-panel-open');
expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();
});
});

describe('Sidebar More', () => {
Expand Down
137 changes: 116 additions & 21 deletions packages/raystack/components/sidebar/sidebar-misc.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
'use client';

import { Accordion as AccordionPrimitive } from '@base-ui/react';
import { TriangleDownIcon } from '@radix-ui/react-icons';
import { cx } from 'class-variance-authority';
import { ComponentProps, ReactNode } from 'react';
import { ComponentProps, ReactNode, useContext } from 'react';
import { Flex } from '../flex';
import styles from './sidebar.module.css';
import { SidebarContext } from './sidebar-root';

export function SidebarHeader({
className,
Expand Down Expand Up @@ -38,50 +41,142 @@ SidebarFooter.displayName = 'Sidebar.Footer';

export interface SidebarNavigationGroupProps extends ComponentProps<'section'> {
label: string;
value?: string;
accordion?: boolean;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
classNames?: {
header?: string;
items?: string;
label?: string;
icon?: string;
trigger?: string;
chevron?: string;
trailingIcon?: string;
};
}

export function SidebarNavigationGroup({
className,
label,
value,
accordion = false,
leadingIcon,
trailingIcon,
classNames,
children,
...props
}: SidebarNavigationGroupProps) {
const { isCollapsed } = useContext(SidebarContext);
const groupValue = value ?? label;

if (!accordion) {
return (
<section
className={cx(styles['nav-group'], className)}
aria-label={label}
{...props}
>
<Flex
align='center'
gap={3}
className={cx(
styles['nav-group-header'],
trailingIcon && styles['nav-group-header-with-trailing'],
classNames?.header
)}
>
{leadingIcon && (
<span className={cx(styles['nav-leading-icon'], classNames?.icon)}>
{leadingIcon}
</span>
)}
<span className={cx(styles['nav-group-label'], classNames?.label)}>
{label}
</span>
{trailingIcon ? (
<span
className={cx(
styles['nav-group-trailing-icon'],
classNames?.trailingIcon
)}
>
{trailingIcon}
</span>
) : null}
</Flex>
<Flex
direction='column'
className={cx(styles['nav-group-items'], classNames?.items)}
role='list'
>
{children}
</Flex>
</section>
);
}

return (
<section
className={cx(styles['nav-group'], className)}
aria-label={label}
{...props}
>
<Flex
align='center'
gap={3}
className={cx(styles['nav-group-header'], classNames?.header)}
>
{leadingIcon && (
<span className={cx(styles['nav-leading-icon'], classNames?.icon)}>
{leadingIcon}
</span>
)}
<span className={cx(styles['nav-group-label'], classNames?.label)}>
{label}
</span>
</Flex>
<Flex
direction='column'
className={cx(styles['nav-group-items'], classNames?.items)}
role='list'
<AccordionPrimitive.Root
key={isCollapsed ? 'collapsed' : 'expanded'}
className={styles['nav-group-accordion']}
multiple
defaultValue={[groupValue]}
>
{children}
</Flex>
<AccordionPrimitive.Item
value={groupValue}
className={styles['nav-group-accordion-item']}
>
<AccordionPrimitive.Header
className={cx(styles['nav-group-header'], classNames?.header)}
>
<AccordionPrimitive.Trigger
className={cx(styles['nav-group-trigger'], classNames?.trigger)}
>
{leadingIcon && (
<span
className={cx(styles['nav-leading-icon'], classNames?.icon)}
>
{leadingIcon}
</span>
)}
<span
className={cx(styles['nav-group-label'], classNames?.label)}
>
{label}
</span>
<TriangleDownIcon
className={cx(styles['nav-group-chevron'], classNames?.chevron)}
aria-hidden='true'
/>
</AccordionPrimitive.Trigger>
{trailingIcon ? (
<span
className={cx(
styles['nav-group-trailing-icon'],
classNames?.trailingIcon
)}
>
{trailingIcon}
</span>
) : null}
</AccordionPrimitive.Header>
<AccordionPrimitive.Panel className={styles['nav-group-panel']}>
<Flex
direction='column'
className={cx(styles['nav-group-items'], classNames?.items)}
role='list'
>
{children}
</Flex>
</AccordionPrimitive.Panel>
</AccordionPrimitive.Item>
</AccordionPrimitive.Root>
</section>
);
}
Expand Down
Loading
Loading