From 480d06ac24f797d87c4764a8f35ac273eb466afb Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 14 May 2026 12:33:42 +0530 Subject: [PATCH] feat: controlled support for sidebar.group --- apps/www/src/components/docs/sidebar.tsx | 2 +- .../content/docs/components/sidebar/demo.ts | 53 ++++++++++ .../content/docs/components/sidebar/index.mdx | 9 +- .../content/docs/components/sidebar/props.ts | 11 +++ .../sidebar/__tests__/sidebar.test.tsx | 96 +++++++++++++++++++ .../components/sidebar/sidebar-misc.tsx | 35 +++++-- 6 files changed, 197 insertions(+), 9 deletions(-) diff --git a/apps/www/src/components/docs/sidebar.tsx b/apps/www/src/components/docs/sidebar.tsx index ef90e734e..4ba98cc9d 100644 --- a/apps/www/src/components/docs/sidebar.tsx +++ b/apps/www/src/components/docs/sidebar.tsx @@ -71,7 +71,7 @@ function renderNode(node: Node, pathname: string): ReactNode { // Handle page items if (node.type === 'page') { - return ; + return ; } // Handle separators (if needed) diff --git a/apps/www/src/content/docs/components/sidebar/demo.ts b/apps/www/src/content/docs/components/sidebar/demo.ts index a53317305..227d6525e 100644 --- a/apps/www/src/content/docs/components/sidebar/demo.ts +++ b/apps/www/src/content/docs/components/sidebar/demo.ts @@ -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(` + + + + + + Apsara + + + + }> + Overview + + + }> + Reports + + }> + Activities + + + + + setResourcesOpen(open => !open)} + /> + } + leadingIcon={} + > + {resourcesOpen ? 'Collapse Resources' : 'Expand Resources'} + + + `)} + ); + }` +}; + export const groupIconDemo = { type: 'code', code: sidebarLayout(` diff --git a/apps/www/src/content/docs/components/sidebar/index.mdx b/apps/www/src/content/docs/components/sidebar/index.mdx index 5ed4da6e2..983a6a963 100644 --- a/apps/www/src/content/docs/components/sidebar/index.mdx +++ b/apps/www/src/content/docs/components/sidebar/index.mdx @@ -9,6 +9,7 @@ import { positionDemo, variantDemo, collapsibleGroupDemo, + controlledGroupDemo, groupIconDemo, stateDemo, tooltipDemo, @@ -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. +### Controlled Group + +Use `open` together with `onOpenChange` on `Sidebar.Group` to control the group's expanded state from outside the component. + + + ### More Use `Sidebar.More` when you want to keep a section compact and move secondary items into a menu. diff --git a/apps/www/src/content/docs/components/sidebar/props.ts b/apps/www/src/content/docs/components/sidebar/props.ts index b9fedd4fc..084e59908 100644 --- a/apps/www/src/content/docs/components/sidebar/props.ts +++ b/apps/www/src/content/docs/components/sidebar/props.ts @@ -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; diff --git a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx index 39550827a..80361d48b 100644 --- a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx +++ b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx @@ -412,6 +412,102 @@ describe('Sidebar', () => { expect(screen.getByTestId('group-trailing-icon')).toBeInTheDocument(); }); + it('respects defaultOpen=false for uncontrolled collapsible group', () => { + render( + + + + }> + {DASHBOARD_ITEM_TEXT} + + + + + ); + + 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( + + + + }> + {DASHBOARD_ITEM_TEXT} + + + + + ); + + 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( + + + + }> + {DASHBOARD_ITEM_TEXT} + + + + + ); + + expect(screen.getByText(DASHBOARD_ITEM_TEXT)).toBeInTheDocument(); + }); + + it('fires onOpenChange when uncontrolled group is toggled', () => { + const onOpenChange = vi.fn(); + render( + + + + }> + {DASHBOARD_ITEM_TEXT} + + + + + ); + + 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(); diff --git a/packages/raystack/components/sidebar/sidebar-misc.tsx b/packages/raystack/components/sidebar/sidebar-misc.tsx index 444ececc0..f3a32036f 100644 --- a/packages/raystack/components/sidebar/sidebar-misc.tsx +++ b/packages/raystack/components/sidebar/sidebar-misc.tsx @@ -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'; @@ -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?: { @@ -61,8 +69,10 @@ export interface SidebarNavigationGroupProps extends ComponentProps<'section'> { export function SidebarNavigationGroup({ className, label, - value, collapsible = false, + open: providedOpen, + defaultOpen = true, + onOpenChange, leadingIcon, trailingIcon, classNames, @@ -70,7 +80,18 @@ export function SidebarNavigationGroup({ ...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 ( @@ -119,13 +140,13 @@ export function SidebarNavigationGroup({ {...props} >