diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index 2725361f4..008b3a39f 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -98,7 +98,7 @@ const Page = () => { Dashboard - + }> Analytics diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index 83d2b8d66..58ec4b009 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -8,6 +8,12 @@ import { UploadIcon } from '@radix-ui/react-icons'; import * as Apsara from '@raystack/apsara'; +import { + BellIcon, + FilterIcon, + OrganizationIcon, + SidebarIcon +} from '@raystack/apsara/icons'; import dayjs from 'dayjs'; import { Home, Info, Laugh, X } from 'lucide-react'; import NextLink from 'next/link'; @@ -24,6 +30,10 @@ export default function Demo(props: DemoProps) { data, scope = { ...Apsara, + BellIcon, + FilterIcon, + OrganizationIcon, + SidebarIcon, DataTableDemo, LinearMenuDemo, PopoverColorPicker, diff --git a/apps/www/src/components/preview/preview.module.css b/apps/www/src/components/preview/preview.module.css index 1121a7bca..33c3884a4 100644 --- a/apps/www/src/components/preview/preview.module.css +++ b/apps/www/src/components/preview/preview.module.css @@ -1,5 +1,5 @@ .preview { - padding: 40px 20px; + padding: 0; width: 100%; height: 100%; display: flex; diff --git a/apps/www/src/content/docs/components/sidebar/demo.ts b/apps/www/src/content/docs/components/sidebar/demo.ts index 8278e96f6..cfb8dc34e 100644 --- a/apps/www/src/content/docs/components/sidebar/demo.ts +++ b/apps/www/src/content/docs/components/sidebar/demo.ts @@ -1,38 +1,63 @@ 'use client'; +const mainAreaStyle = `{{ flex: 1, border: '2px dashed var(--rs-color-border-base-secondary)', margin: 'var(--rs-space-4)', boxSizing: 'border-box' }}`; + +const sidebarLayout = (sidebar: string) => + ` + ${sidebar.trim()} + +`; + +const sidebarLayoutRight = (sidebar: string) => + ` + + ${sidebar.trim()} +`; + export const preview = { type: 'code', - code: ` + code: sidebarLayout(` - + Apsara - }> - } active> + + } active> Dashboard - } disabled> + }> + Analytics + + }> Settings + + }> + Reports + + }> + Activities + + - }> + }> Help - }> - Help + }> + Help & Support - ` + `) }; export const positionDemo = { @@ -40,39 +65,51 @@ export const positionDemo = { tabs: [ { name: 'Left', - code: ` + code: sidebarLayout(` - + Apsara - } active>Dashboard - } disabled>Settings + + } active>Dashboard + }>Analytics + }>Settings + + + }>Help + - ` + `) }, { name: 'Right', - code: ` + code: sidebarLayoutRight(` - + Apsara - } active>Dashboard - } disabled>Settings + + } active>Dashboard + }>Analytics + }>Settings + + + }>Help + - ` + `) } ] }; @@ -82,92 +119,162 @@ export const stateDemo = { tabs: [ { name: 'Expanded', - code: ` + code: sidebarLayout(` - + Apsara - } active>Dashboard - } disabled>Settings + + } active>Dashboard + }>Analytics + }>Settings + + + }>Help + - ` + `) }, { name: 'Collapsed', - code: ` + code: sidebarLayout(` - + Apsara - } active>Dashboard - } disabled>Settings + + } active>Dashboard + }>Analytics + }>Settings + + + }>Help + - ` + `) }, { name: 'Uncontrolled', - code: ` + code: sidebarLayout(` - + Apsara - } active>Dashboard - } disabled>Settings + + } active>Dashboard + }>Analytics + }>Settings + + + }>Help + - ` + `) }, { name: 'Uncontrolled (default open)', - code: ` + code: sidebarLayout(` - + Apsara - } active>Dashboard - } disabled>Settings + + } active>Dashboard + }>Analytics + }>Settings + + + }>Help + - ` + `) } ] }; export const tooltipDemo = { type: 'code', - code: ` - + + + Apsara + + + + + } active>Dashboard + }>Analytics + }>Settings + + + }>Help + + + `) +}; + +export const collapsibleDemo = { + type: 'code', + code: sidebarLayout(` + + + + + + Apsara + + + + + } active>Dashboard + }>Analytics + + + `) +}; + +export const hideTooltipDemo = { + type: 'code', + code: sidebarLayout(` + + + + Apsara - } active>Dashboard - } disabled>Settings + + } active>Dashboard + }>Settings + - ` + `) }; diff --git a/apps/www/src/content/docs/components/sidebar/index.mdx b/apps/www/src/content/docs/components/sidebar/index.mdx index 02a9522fb..7b58eb0d5 100644 --- a/apps/www/src/content/docs/components/sidebar/index.mdx +++ b/apps/www/src/content/docs/components/sidebar/index.mdx @@ -4,7 +4,14 @@ description: A collapsible side navigation panel component. source: packages/raystack/components/sidebar --- -import { preview, positionDemo, stateDemo, tooltipDemo } from "./demo.ts"; +import { + preview, + positionDemo, + stateDemo, + tooltipDemo, + collapsibleDemo, + hideTooltipDemo +} from "./demo.ts"; @@ -17,9 +24,12 @@ import { Sidebar } from "@raystack/apsara"; - - - + + + Item + + + ``` @@ -27,7 +37,7 @@ import { Sidebar } from "@raystack/apsara"; ### Root -Groups all parts of the sidebar navigation. +Groups all parts of the sidebar navigation. Use `collapsible={false}` to hide the resize handle and prevent toggling. Use `hideCollapsedItemTooltip` to disable tooltips on items when the sidebar is collapsed. @@ -35,16 +45,24 @@ Groups all parts of the sidebar navigation. The header section is a container component that accepts all `div` props. It's commonly used to create a header with an icon and title. +### Main + +The main section wraps navigation groups and items. It accepts all `div` props and scrolls when content overflows. + ### Group ### Item -*Note: `leadingIcon` is optional and will show a fallback avatar only in collapsed state. You can pass `<>` to render truly nothing.* +*Note: `leadingIcon` is optional and will show a fallback avatar only in collapsed state. You can pass `<>` to render truly nothing. Use the `as` prop to render as a custom element (e.g. a router `Link`).* +### Footer + +The footer section is a container that accepts all `div` props. It's commonly used for secondary links (e.g. Help, Preferences) and stays at the bottom of the sidebar. + ## Examples ### Position @@ -69,15 +87,29 @@ You can use Sidebar as a controlled component to conditionally render different +### Non-collapsible + +Set `collapsible={false}` to hide the resize handle and prevent the sidebar from being collapsed or expanded. + + + +### Hide item tooltips when collapsed + +Set `hideCollapsedItemTooltip` to disable tooltips on navigation items when the sidebar is collapsed. Useful when item labels are redundant with the collapse tooltip or you want a cleaner collapsed state. + + + ## Accessibility The Sidebar implements the following accessibility features: +- **Reduced motion** — Respects the user's motion preferences. When the system "Reduce motion" setting is enabled, collapse/expand and hover transitions are disabled so the sidebar updates without animation. + - Proper ARIA roles and attributes - `role="navigation"` for the main sidebar - `role="banner"` for the header - - `role="menuitem"` for navigation items + - `role="list"` for item containers (Main groups, Footer) and `role="listitem"` for navigation items - `aria-expanded` to indicate sidebar state - `aria-current="page"` for active items - `aria-disabled="true"` for disabled items diff --git a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx index bbbc6e3e7..a56486e53 100644 --- a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx +++ b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx @@ -199,7 +199,7 @@ describe('Sidebar', () => { it('has proper ARIA attributes', () => { render(); - const footer = screen.getByRole('group', { name: 'Footer navigation' }); + const footer = screen.getByRole('list', { name: 'Footer navigation' }); expect(footer).toBeInTheDocument(); }); }); @@ -246,10 +246,14 @@ describe('Sidebar', () => { expect(item).toHaveAttribute('aria-disabled', 'true'); }); - it('hides text when collapsed', () => { + it('hides text when collapsed and sets aria-label for screen readers', () => { render(); expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument(); + const dashboardLink = screen.getByRole('listitem', { + name: DASHBOARD_ITEM_TEXT + }); + expect(dashboardLink).toHaveAttribute('aria-label', DASHBOARD_ITEM_TEXT); }); }); diff --git a/packages/raystack/components/sidebar/sidebar-item.tsx b/packages/raystack/components/sidebar/sidebar-item.tsx index 85d70bd6a..0dac1fab0 100644 --- a/packages/raystack/components/sidebar/sidebar-item.tsx +++ b/packages/raystack/components/sidebar/sidebar-item.tsx @@ -63,9 +63,12 @@ export const SidebarItem = forwardRef( className: cx(styles['nav-item'], classNames?.root), 'data-active': active, 'data-disabled': disabled, - role: 'menuitem', + role: 'listitem', 'aria-current': active ? 'page' : undefined, 'aria-disabled': disabled, + ...(isCollapsed && typeof children === 'string' + ? { 'aria-label': children } + : {}), ...props }, <> @@ -76,7 +79,7 @@ export const SidebarItem = forwardRef( variant='soft' color='neutral' fallback={children[0].toUpperCase()} - style={{ cursor: 'pointer' }} + className={styles['nav-fallback-avatar']} /> ) : null} @@ -93,8 +96,9 @@ export const SidebarItem = forwardRef( if (isCollapsed && !hideCollapsedItemTooltip) { return ( - - {content} + + + {children} ); } diff --git a/packages/raystack/components/sidebar/sidebar-misc.tsx b/packages/raystack/components/sidebar/sidebar-misc.tsx index 8de58bca9..981860802 100644 --- a/packages/raystack/components/sidebar/sidebar-misc.tsx +++ b/packages/raystack/components/sidebar/sidebar-misc.tsx @@ -29,7 +29,7 @@ export const SidebarFooter = forwardRef< ref={ref} className={cx(styles.footer, className)} direction='column' - role='group' + role='list' aria-label='Footer navigation' {...props} > diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css index ed084adf8..42e4ac51b 100644 --- a/packages/raystack/components/sidebar/sidebar.module.css +++ b/packages/raystack/components/sidebar/sidebar.module.css @@ -25,7 +25,7 @@ } .root[data-closed] { - width: 57px; + width: var(--rs-space-12); } .header { @@ -47,7 +47,7 @@ } .root[data-closed] .main { - align-items: center; + align-items: flex-start; } .root[data-closed] [data-collapse-hidden] { @@ -74,7 +74,7 @@ } .root[data-closed] .nav-item { - justify-content: center; + justify-content: flex-start; } .nav-item:hover { @@ -106,6 +106,10 @@ flex-shrink: 0; } +.nav-fallback-avatar { + cursor: pointer; +} + .nav-text { color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-small); @@ -129,7 +133,7 @@ position: absolute; top: 0; right: 0; - width: 4px; + width: var(--rs-space-2); height: 100%; cursor: ew-resize; transition: background-color 0.2s ease; @@ -159,7 +163,7 @@ } .root[data-closed] .nav-group { - align-items: center; + align-items: flex-start; } .nav-group-header { @@ -186,8 +190,25 @@ width: 100%; } +/* Hide group header visually when collapsed but reserve vertical space to prevent shift */ +.root[data-closed] .nav-group-header { + visibility: hidden; +} + .root[data-closed] .nav-group-label { width: 0; + min-width: 0; + overflow: hidden; opacity: 0; - display: none; + visibility: hidden; + /* Keep in flow (no display: none) so header row height is preserved */ +} + +@media (prefers-reduced-motion: reduce) { + .root, + .nav-item, + .nav-text, + .resizeHandle { + transition: none; + } }