Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion apps/www/src/components/docs/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function renderNode(node: Node, pathname: string): ReactNode {

// Handle page items
if (node.type === 'page') {
return <SidebarItem item={node} pathname={pathname} />;
return <SidebarItem item={node} pathname={pathname} key={node.url} />;
}

// Handle separators (if needed)
Expand Down
53 changes: 53 additions & 0 deletions apps/www/src/content/docs/components/sidebar/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,59 @@ export const collapsibleGroupDemo = {
style: styleDemo
};

export const controlledGroupDemo = {
type: 'code',
style: styleDemo,
code: `
function ControlledSidebarGroup() {
const [resourcesOpen, setResourcesOpen] = React.useState(true);

return (
${sidebarLayout(`<Sidebar defaultOpen>
<Sidebar.Header>
<Flex align="center" gap={3}>
<IconButton size={4} aria-label="Logo">
<BellIcon width={24} height={24} />
</IconButton>
<Text size="regular" weight="medium" data-collapse-hidden>Apsara</Text>
</Flex>
</Sidebar.Header>
<Sidebar.Main>
<Sidebar.Item href="#" leadingIcon={<OrganizationIcon width={16} height={16} />}>
Overview
</Sidebar.Item>
<Sidebar.Group
label="Resources"
collapsible
open={resourcesOpen}
onOpenChange={setResourcesOpen}
>
<Sidebar.Item href="#" leadingIcon={<FilterIcon width={16} height={16} />}>
Reports
</Sidebar.Item>
<Sidebar.Item href="#" leadingIcon={<OrganizationIcon width={16} height={16} />}>
Activities
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
<Sidebar.Footer>
<Sidebar.Item
render={
<button
type="button"
onClick={() => setResourcesOpen(open => !open)}
/>
}
leadingIcon={<BellIcon width={16} height={16} />}
>
{resourcesOpen ? 'Collapse Resources' : 'Expand Resources'}
</Sidebar.Item>
</Sidebar.Footer>
</Sidebar>`)}
);
}`
};

export const groupIconDemo = {
type: 'code',
code: sidebarLayout(`<Sidebar defaultOpen>
Expand Down
9 changes: 8 additions & 1 deletion apps/www/src/content/docs/components/sidebar/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
positionDemo,
variantDemo,
collapsibleGroupDemo,
controlledGroupDemo,
groupIconDemo,
stateDemo,
tooltipDemo,
Expand Down Expand Up @@ -132,10 +133,16 @@ Pass `leadingIcon` to `Sidebar.Group` to render an icon next to the section labe

### Collapsible Group

Enable `collapsible` on `Sidebar.Group` to make section items collapsible. You can also pass `trailingIcon` for section-level actions.
Enable `collapsible` on `Sidebar.Group` to make section items collapsible. You can also pass `trailingIcon` for section-level actions. Use `defaultOpen` to set the initial expanded state of an uncontrolled group.

<Demo data={collapsibleGroupDemo} />

### Controlled Group

Use `open` together with `onOpenChange` on `Sidebar.Group` to control the group's expanded state from outside the component.

<Demo data={controlledGroupDemo} />

### More

Use `Sidebar.More` when you want to keep a section compact and move secondary items into a menu.
Expand Down
11 changes: 11 additions & 0 deletions apps/www/src/content/docs/components/sidebar/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ export interface SidebarGroupProps {
*/
collapsible?: boolean;

/** Controls the group's expanded/collapsed state. Only applies when `collapsible` is true. */
open?: boolean;

/** Default expanded/collapsed state when uncontrolled. Only applies when `collapsible` is true.
* @default true
*/
defaultOpen?: boolean;

/** Callback when the group's expanded/collapsed state changes. */
onOpenChange?: (open: boolean) => void;

/** Optional ReactNode for group icon. */
leadingIcon?: ReactNode;

Expand Down
96 changes: 96 additions & 0 deletions packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,102 @@ describe('Sidebar', () => {
expect(screen.getByTestId('group-trailing-icon')).toBeInTheDocument();
});

it('respects defaultOpen=false for uncontrolled collapsible group', () => {
render(
<Sidebar>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
collapsible
defaultOpen={false}
>
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
{DASHBOARD_ITEM_TEXT}
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
</Sidebar>
);

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

it('controls open state via open prop and fires onOpenChange', () => {
const onOpenChange = vi.fn();
const { rerender } = render(
<Sidebar>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
collapsible
open={false}
onOpenChange={onOpenChange}
>
<Sidebar.Item href='#' leadingIcon={<InfoIcon />}>
{DASHBOARD_ITEM_TEXT}
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Main>
</Sidebar>
);

expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();

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

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

expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument();
});

it('fires onOpenChange when uncontrolled group is toggled', () => {
const onOpenChange = vi.fn();
render(
<Sidebar>
<Sidebar.Main>
<Sidebar.Group
label={MAIN_GROUP_LABEL}
collapsible
onOpenChange={onOpenChange}
>
<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(onOpenChange).toHaveBeenLastCalledWith(false);
expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();

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

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

Expand Down
35 changes: 28 additions & 7 deletions packages/raystack/components/sidebar/sidebar-misc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import { Accordion as AccordionPrimitive } from '@base-ui/react';
import { TriangleDownIcon } from '@radix-ui/react-icons';
import { cx } from 'class-variance-authority';
import { ComponentProps, ReactNode, useContext } from 'react';
import {
ComponentProps,
ReactNode,
useCallback,
useContext,
useState
} from 'react';
import { Flex } from '../flex';
import styles from './sidebar.module.css';
import { SidebarLeadingVisual } from './sidebar-leading-visual';
Expand Down Expand Up @@ -43,8 +49,10 @@ SidebarFooter.displayName = 'Sidebar.Footer';

export interface SidebarNavigationGroupProps extends ComponentProps<'section'> {
label: string;
value?: string;
collapsible?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
classNames?: {
Expand All @@ -61,16 +69,29 @@ export interface SidebarNavigationGroupProps extends ComponentProps<'section'> {
export function SidebarNavigationGroup({
className,
label,
value,
collapsible = false,
open: providedOpen,
defaultOpen = true,
onOpenChange,
leadingIcon,
trailingIcon,
classNames,
children,
...props
}: SidebarNavigationGroupProps) {
const { isCollapsed } = useContext(SidebarContext);
const groupValue = value ?? label;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isOpen = isCollapsed || (providedOpen ?? internalOpen);

const handleOpenChange = useCallback(
(value: unknown[]) => {
if (isCollapsed) return;
const nextOpen = value.length > 0;
setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[isCollapsed, onOpenChange]
);

if (!collapsible) {
return (
Expand Down Expand Up @@ -119,13 +140,13 @@ export function SidebarNavigationGroup({
{...props}
>
<AccordionPrimitive.Root
key={isCollapsed ? 'collapsed' : 'expanded'}
className={styles['nav-group-accordion']}
multiple
defaultValue={[groupValue]}
value={isOpen ? [true] : []}
onValueChange={handleOpenChange}
>
<AccordionPrimitive.Item
value={groupValue}
value={true}
className={styles['nav-group-accordion-item']}
>
<AccordionPrimitive.Header
Expand Down
Loading