diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx
index 2f9f94b16..9ca8b791e 100644
--- a/apps/www/src/app/examples/page.tsx
+++ b/apps/www/src/app/examples/page.tsx
@@ -1,4 +1,17 @@
'use client';
+import {
+ ActivityLogIcon,
+ BarChartIcon,
+ DashboardIcon,
+ DotsHorizontalIcon,
+ FileTextIcon,
+ GearIcon,
+ HomeIcon,
+ MixerHorizontalIcon,
+ PersonIcon,
+ QuestionMarkCircledIcon,
+ BellIcon as RadixBellIcon
+} from '@radix-ui/react-icons';
import {
Amount,
Avatar,
@@ -30,12 +43,6 @@ import {
TextArea,
Tooltip
} from '@raystack/apsara';
-import {
- BellIcon,
- FilterIcon,
- OrganizationIcon,
- SidebarIcon
-} from '@raystack/apsara/icons';
import dayjs from 'dayjs';
import React, { useState } from 'react';
@@ -59,16 +66,16 @@ const Page = () => {
// Sample options data with icons
const selectOptions = [
- { value: 'dashboard', label: 'Dashboard', icon: },
- { value: 'analytics', label: 'Analytics', icon: },
- { value: 'settings', label: 'Settings', icon: },
- { value: 'profile', label: 'Profile', icon: }
+ { value: 'dashboard', label: 'Dashboard', icon: },
+ { value: 'analytics', label: 'Analytics', icon: },
+ { value: 'settings', label: 'Settings', icon: },
+ { value: 'profile', label: 'Profile', icon: }
];
const filterOptions = [
- { value: 'Option 1', label: 'Option 1', icon: },
- { value: 'Option 2', label: 'Option 2', icon: },
- { value: 'Option 3', label: 'Option 3', icon: }
+ { value: 'Option 1', label: 'Option 1', icon: },
+ { value: 'Option 2', label: 'Option 2', icon: },
+ { value: 'Option 3', label: 'Option 3', icon: }
];
return (
@@ -79,7 +86,7 @@ const Page = () => {
backgroundColor: 'var(--rs-color-background-base-primary)'
}}
>
-
+
{
onClick={() => console.log('Logo clicked')}
aria-label='Logo'
>
-
+
Raystack
@@ -96,31 +103,103 @@ const Page = () => {
- }>
+ }>
Dashboard
- }>
+ }>
Analytics
- }>
- Reports
-
- Activities
+ 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'
+ }}
+ >
+
+
+ }
+ >
+ }>
+ Reports
+
+
+ }
+ >
+ Activities
+
+ console.log('Notifications clicked')}
+ leadingIcon={}
+ >
+ Notifications
+
+
-
- Settings
-
- Notifications
+ 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'
+ }}
+ >
+
+
+ }
+ >
+ }>
+ Settings
+
+ }>
+ }>
+ Notifications
+
+ } disabled>
+ Billing
+
+
- Help & Support
-
- Preferences
+ }>
+ Help & Support
+
+
+ }>
+ Preferences
+
+ }>
+ Documentation
+
+
@@ -285,7 +364,7 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
25%
@@ -300,7 +379,7 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
25%
@@ -315,7 +394,7 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
25%
@@ -352,7 +431,9 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
Sun
@@ -371,7 +452,9 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
15th
@@ -390,7 +473,9 @@ const Page = () => {
color: 'var(--rs-color-foreground-base-secondary)'
}}
>
-
+
Today
@@ -1365,7 +1450,7 @@ const Page = () => {
}
+ icon={}
heading='KYC required for image orders'
subHeading='Please contact your organization owner to complete the KYC process for the image orders. You can also contact support@raystack.io for assistance.'
primaryAction={
@@ -1562,7 +1647,7 @@ const Page = () => {
@@ -1584,7 +1669,7 @@ const Page = () => {
}
+ leadingIcon={}
width='100%'
/>
@@ -1722,7 +1807,7 @@ const Page = () => {
@@ -1755,7 +1840,7 @@ const Page = () => {
}
+ leadingIcon={}
width='100%'
/>
@@ -2197,7 +2282,7 @@ const Page = () => {
}
+ icon={}
heading='Zero state'
variant='empty2'
subHeading='Get started by creating your first user. Filter bar and search are hidden in zero state.'
@@ -2205,7 +2290,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='Empty state'
variant='empty1'
subHeading="We couldn't find any matches for that keyword or filter."
@@ -2277,7 +2362,7 @@ const Page = () => {
}
+ icon={}
heading='zero state'
variant='empty2'
subHeading='Get started by creating your first user.'
@@ -2285,7 +2370,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading="We couldn't find any matches for that filter. Try adjusting your filters or search query. Filter bar remains visible so you can modify filters."
@@ -2352,7 +2437,7 @@ const Page = () => {
}
+ icon={}
heading='zero state'
variant='empty2'
subHeading='Get started by creating your first user.'
@@ -2360,7 +2445,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading="We couldn't find any matches for that search. Try a different search term. Filter bar stays hidden when only search is applied."
@@ -2413,7 +2498,7 @@ const Page = () => {
align='center'
style={{ padding: '40px' }}
>
- {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading='Try adjusting your filters or search query.'
@@ -2483,7 +2568,7 @@ const Page = () => {
}
+ icon={}
heading='zero state'
variant='empty2'
subHeading='Search is enabled even in zero state. Start typing to see empty state. Filter bar will only appear when filters are applied.'
@@ -2491,7 +2576,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading='Search applied but no results. Filter bar stays hidden when only search is used.'
@@ -2557,7 +2642,7 @@ const Page = () => {
}
+ icon={}
heading='zero state'
variant='empty2'
subHeading='Get started by creating your first user.'
@@ -2565,7 +2650,7 @@ const Page = () => {
}
emptyState={
}
+ icon={}
heading='empty state'
variant='empty1'
subHeading="We couldn't find any matches for that keyword or filter."
diff --git a/apps/www/src/content/docs/components/sidebar/demo.ts b/apps/www/src/content/docs/components/sidebar/demo.ts
index cfb8dc34e..8473ab3b1 100644
--- a/apps/www/src/content/docs/components/sidebar/demo.ts
+++ b/apps/www/src/content/docs/components/sidebar/demo.ts
@@ -27,6 +27,9 @@ export const preview = {
+ }>
+ Overview
+
} active>
Dashboard
@@ -46,11 +49,6 @@ export const preview = {
Activities
-
- }>
- Help
-
-
}>
@@ -76,6 +74,7 @@ export const positionDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -100,6 +99,7 @@ export const positionDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -114,6 +114,75 @@ export const positionDemo = {
]
};
+export const variantDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Plain',
+ code: sidebarLayout(`
+
+
+
+
+
+
+ Apsara
+
+
+
+ }>Overview
+
+ } active>Dashboard
+ }>Analytics
+
+
+ `)
+ },
+ {
+ name: 'Floating',
+ code: sidebarLayout(`
+
+
+
+
+
+
+ Apsara
+
+
+
+ }>Overview
+
+ } active>Dashboard
+ }>Analytics
+
+
+ `)
+ },
+ {
+ name: 'Inset',
+ code: sidebarLayout(`
+
+
+
+
+
+
+ Apsara
+
+
+
+ }>Overview
+
+ } active>Dashboard
+ }>Analytics
+
+
+ `)
+ }
+ ]
+};
+
export const stateDemo = {
type: 'code',
tabs: [
@@ -129,6 +198,7 @@ export const stateDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -152,6 +222,7 @@ export const stateDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -175,6 +246,7 @@ export const stateDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -198,6 +270,7 @@ export const stateDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -227,6 +300,7 @@ export const tooltipDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -251,6 +325,7 @@ export const collapsibleDemo = {
+ }>Overview
} active>Dashboard
}>Analytics
@@ -271,10 +346,91 @@ export const hideTooltipDemo = {
+ }>Overview
} active>Dashboard
}>Settings
+ }>
+ Help
+
+
+ `)
+};
+
+export const collapsibleGroupDemo = {
+ type: 'code',
+ code: sidebarLayout(`
+
+
+
+
+
+ Apsara
+
+
+
+ }>
+ Overview
+
+
+ }>
+ Reports
+
+ }>
+ Activities
+
+
+ }>
+ }>
+ Settings
+
+
+
+ `)
+};
+
+export const moreDemo = {
+ type: 'code',
+ code: sidebarLayout(`
+
+
+
+
+
+ Apsara
+
+
+
+ } active>
+ Dashboard
+
+ }>
+ Analytics
+
+
+ }>
+ Reports
+
+
+ }>
+ Activities
+
+ } disabled>
+ Notifications
+
+
+
+
+
+ }>
+ Preferences
+
+ }>
+ Documentation
+
+
+
`)
};
diff --git a/apps/www/src/content/docs/components/sidebar/index.mdx b/apps/www/src/content/docs/components/sidebar/index.mdx
index 636e4802d..833508f9e 100644
--- a/apps/www/src/content/docs/components/sidebar/index.mdx
+++ b/apps/www/src/content/docs/components/sidebar/index.mdx
@@ -7,10 +7,13 @@ source: packages/raystack/components/sidebar
import {
preview,
positionDemo,
+ variantDemo,
+ collapsibleGroupDemo,
stateDemo,
tooltipDemo,
collapsibleDemo,
- hideTooltipDemo
+ hideTooltipDemo,
+ moreDemo
} from "./demo.ts";
@@ -27,6 +30,9 @@ import { Sidebar } from "@raystack/apsara";
Item
+
+ Hidden item
+
@@ -63,6 +69,14 @@ The main section wraps navigation groups and items. It accepts all `div` props a
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.
+### More
+
+Renders a sidebar row that opens an Apsara menu with additional `Sidebar.Item` entries. It can be used inside `Sidebar.Group` or directly under `Sidebar.Main` / `Sidebar.Footer`.
+
+*Note: if `leadingIcon` is not provided, the trigger uses a default dots icon. In collapsed state, it follows the same item tooltip behavior and respects `hideCollapsedItemTooltip`.*
+
+
+
## Examples
### Position
@@ -71,6 +85,16 @@ The Sidebar can be positioned on either the left or right side of the screen.
+### Variants
+
+Use `variant` to switch the Sidebar surface style:
+
+- `plain` (default): regular surface with side border
+- `floating`: lifted surface with shadow
+- `inset`: transparent surface without border or shadow
+
+
+
### State
The Sidebar supports expanded and collapsed states with smooth transitions.
@@ -99,6 +123,18 @@ Set `hideCollapsedItemTooltip` to disable tooltips on navigation items when the
+### Collapsible Group
+
+Enable `collapsible` on `Sidebar.Group` to make section items collapsible. You can also pass `trailingIcon` for section-level actions.
+
+
+
+### More
+
+Use `Sidebar.More` when you want to keep a section compact and move secondary items into a menu.
+
+
+
## Accessibility
The Sidebar implements the following accessibility features:
diff --git a/apps/www/src/content/docs/components/sidebar/props.ts b/apps/www/src/content/docs/components/sidebar/props.ts
index ccf03e15a..967bc3973 100644
--- a/apps/www/src/content/docs/components/sidebar/props.ts
+++ b/apps/www/src/content/docs/components/sidebar/props.ts
@@ -22,6 +22,11 @@ export interface SidebarRootProps {
*/
position?: 'left' | 'right';
+ /** Visual style variant of the Sidebar.
+ * @default "plain"
+ */
+ variant?: 'plain' | 'floating' | 'inset';
+
/** Hide tooltips on sidebar items when sidebar is collapsed.
* @default false
*/
@@ -37,6 +42,11 @@ export interface SidebarGroupProps {
/** String for the group title. */
label: string;
+ /** Makes group items collapsible.
+ * @default false
+ */
+ collapsible?: boolean;
+
/** Optional ReactNode for group icon. */
leadingIcon?: ReactNode;
@@ -83,3 +93,28 @@ export interface SidebarItemProps {
text?: string;
};
}
+
+export interface SidebarMoreProps {
+ /** String for the more trigger label. */
+ label?: string;
+
+ /** Optional ReactNode for the trigger icon. */
+ leadingIcon?: ReactNode;
+
+ /** Sidebar items rendered inside the menu content. */
+ children?: ReactNode;
+
+ /** Optional class names for customizing parts of the more trigger/menu. */
+ classNames?: {
+ /** Class name for the trigger root element. */
+ root?: string;
+ /** Class name for the leading icon container. */
+ leadingIcon?: string;
+ /** Class name for the text element. */
+ text?: string;
+ /** Class name for menu item root elements. */
+ menuItem?: string;
+ /** Class name for menu content container. */
+ menuContent?: string;
+ };
+}
diff --git a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
index a56486e53..23ea5c4cf 100644
--- a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
+++ b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx
@@ -157,6 +157,20 @@ describe('Sidebar', () => {
const sidebar = container.querySelector('[data-position="right"]');
expect(sidebar).toBeInTheDocument();
});
+
+ it('applies floating variant when specified', () => {
+ const { container } = render();
+
+ const sidebar = container.querySelector('[data-variant="floating"]');
+ expect(sidebar).toBeInTheDocument();
+ });
+
+ it('applies inset variant when specified', () => {
+ const { container } = render();
+
+ const sidebar = container.querySelector('[data-variant="inset"]');
+ expect(sidebar).toBeInTheDocument();
+ });
});
describe('Sidebar Header', () => {
@@ -271,5 +285,187 @@ describe('Sidebar', () => {
const group = screen.getByLabelText(MAIN_GROUP_LABEL);
expect(group).toBeInTheDocument();
});
+
+ it('renders collapsible trigger when collapsible is enabled', () => {
+ render(
+
+
+ }
+ >
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ const trigger = screen.getByRole('button', { name: /Main/ });
+ expect(trigger).toBeInTheDocument();
+ expect(trigger).toHaveAttribute('data-panel-open');
+ });
+
+ it('toggles group items when collapsible is enabled', () => {
+ render(
+
+
+ }
+ >
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ 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 collapsible panel open when sidebar is collapsed', () => {
+ const { rerender } = render(
+
+
+ }
+ >
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ const trigger = screen.getByRole('button', { name: /Main/ });
+ fireEvent.click(trigger);
+ expect(screen.queryByText(DASHBOARD_ITEM_TEXT)).not.toBeInTheDocument();
+
+ rerender(
+
+
+ }
+ >
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ expect(
+ screen.getByRole('listitem', { name: DASHBOARD_ITEM_TEXT })
+ ).toBeInTheDocument();
+ });
+
+ it('renders right icon when provided in collapsible header', () => {
+ render(
+
+
+ +}
+ >
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ expect(screen.getByTestId('group-trailing-icon')).toBeInTheDocument();
+ });
+
+ it('does not toggle collapsible when trailing icon is clicked', () => {
+ const onTrailingIconClick = vi.fn();
+
+ render(
+
+
+
+ +
+
+ }
+ >
+ }>
+ {DASHBOARD_ITEM_TEXT}
+
+
+
+
+ );
+
+ 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', () => {
+ it('renders More trigger and opens menu items', () => {
+ render(
+
+
+ Logs
+ Audit
+
+
+ );
+
+ const trigger = screen.getByText('More items').closest('button');
+ expect(trigger).toBeInTheDocument();
+ if (!trigger) return;
+ fireEvent.click(trigger);
+
+ expect(screen.getByText('Logs')).toBeInTheDocument();
+ expect(screen.getByText('Audit')).toBeInTheDocument();
+ });
+
+ it('sets aria-label for collapsed More trigger', () => {
+ render(
+
+
+ Logs
+
+
+ );
+
+ const trigger = screen.getByRole('listitem', { name: 'Overflow' });
+ expect(trigger).toHaveAttribute('aria-label', 'Overflow');
+ });
});
});
diff --git a/packages/raystack/components/sidebar/sidebar-item.tsx b/packages/raystack/components/sidebar/sidebar-item.tsx
index 65c571908..707de9dc7 100644
--- a/packages/raystack/components/sidebar/sidebar-item.tsx
+++ b/packages/raystack/components/sidebar/sidebar-item.tsx
@@ -1,17 +1,13 @@
'use client';
+import { mergeProps, useRender } from '@base-ui/react';
import { cx } from 'class-variance-authority';
-import {
- ComponentProps,
- cloneElement,
- ReactElement,
- ReactNode,
- useContext
-} from 'react';
-import { Avatar } from '../avatar';
-import { Flex } from '../flex';
+import { ComponentProps, ReactElement, ReactNode, useContext } from 'react';
+import { Menu } from '../menu';
import { Tooltip } from '../tooltip';
import styles from './sidebar.module.css';
+import { SidebarLeadingVisual } from './sidebar-leading-visual';
+import { useSidebarMoreContext } from './sidebar-more-context';
import { SidebarContext } from './sidebar-root';
export interface SidebarItemProps extends ComponentProps<'a'> {
@@ -36,49 +32,36 @@ export function SidebarItem({
...props
}: SidebarItemProps) {
const { isCollapsed, hideCollapsedItemTooltip } = useContext(SidebarContext);
+ const sidebarMoreContext = useSidebarMoreContext();
+ const insideSidebarMore = !!sidebarMoreContext?.isInsideSidebarMore;
const shouldShowFallback =
leadingIcon == undefined &&
- isCollapsed &&
+ (isCollapsed || insideSidebarMore) &&
typeof children === 'string' &&
children.length > 0;
- const iconProps = {
- align: 'center',
- gap: 3,
- className: cx(styles['nav-leading-icon'], classNames?.leadingIcon),
- 'aria-hidden': true
- } as const;
+ const menuChildren = (
+ <>
+ }
+ />
+
+ {children}
+
+ >
+ );
- const content = cloneElement(
- as,
- {
- className: cx(styles['nav-item'], classNames?.root),
- 'data-active': active,
- 'data-disabled': disabled,
- role: 'listitem',
- 'aria-current': active ? 'page' : undefined,
- 'aria-disabled': disabled,
- ...(isCollapsed && typeof children === 'string'
- ? { 'aria-label': children }
- : {}),
- ...props
- },
+ const sidebarChildren = (
<>
- {shouldShowFallback ? (
-
-
-
- ) : null}
- {!shouldShowFallback && leadingIcon ? (
- {leadingIcon}
- ) : null}
+
{!isCollapsed && (
{children}
@@ -87,6 +70,50 @@ export function SidebarItem({
>
);
+ const menuContent = useRender({
+ defaultTagName: 'a',
+ render: as,
+ props: mergeProps<'a'>(
+ {
+ className: cx(
+ styles['more-menu-item'],
+ classNames?.root,
+ sidebarMoreContext?.menuItemClassName
+ ),
+ 'data-active': active,
+ 'data-disabled': disabled,
+ 'aria-current': active ? 'page' : undefined,
+ 'aria-disabled': disabled,
+ children: menuChildren
+ } as useRender.ComponentProps<'a'>,
+ props
+ )
+ });
+
+ const content = useRender({
+ defaultTagName: 'a',
+ render: as,
+ props: mergeProps<'a'>(
+ {
+ className: cx(styles['nav-item'], classNames?.root),
+ 'data-active': active,
+ 'data-disabled': disabled,
+ role: 'listitem',
+ 'aria-current': active ? 'page' : undefined,
+ 'aria-disabled': disabled,
+ ...(isCollapsed && typeof children === 'string'
+ ? { 'aria-label': children }
+ : {}),
+ children: sidebarChildren
+ } as useRender.ComponentProps<'a'>,
+ props
+ )
+ });
+
+ if (insideSidebarMore) {
+ return ;
+ }
+
if (isCollapsed && !hideCollapsedItemTooltip) {
return (
diff --git a/packages/raystack/components/sidebar/sidebar-leading-visual.tsx b/packages/raystack/components/sidebar/sidebar-leading-visual.tsx
new file mode 100644
index 000000000..a2b9e6d44
--- /dev/null
+++ b/packages/raystack/components/sidebar/sidebar-leading-visual.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { cx } from 'class-variance-authority';
+import { ComponentProps, ReactNode } from 'react';
+import { Avatar } from '../avatar';
+import { Flex } from '../flex';
+import styles from './sidebar.module.css';
+
+interface SidebarLeadingVisualProps {
+ leadingIcon?: ReactNode;
+ fallbackText?: string;
+ className?: string;
+ render?: ComponentProps['render'];
+}
+
+function AvatarContainer({
+ children,
+ className,
+ render
+}: {
+ children: ReactNode;
+ className?: string;
+ render?: ComponentProps['render'];
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SidebarLeadingVisual({
+ leadingIcon,
+ fallbackText,
+ className,
+ render
+}: SidebarLeadingVisualProps) {
+ if (leadingIcon) {
+ return (
+
+ {leadingIcon}
+
+ );
+ }
+
+ if (fallbackText && fallbackText.length > 0) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/packages/raystack/components/sidebar/sidebar-misc.tsx b/packages/raystack/components/sidebar/sidebar-misc.tsx
index 9c4b182e5..444ececc0 100644
--- a/packages/raystack/components/sidebar/sidebar-misc.tsx
+++ b/packages/raystack/components/sidebar/sidebar-misc.tsx
@@ -1,9 +1,14 @@
'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 { SidebarLeadingVisual } from './sidebar-leading-visual';
+import { SidebarContext } from './sidebar-root';
+import { SidebarTrailingVisual } from './sidebar-trailing-visual';
export function SidebarHeader({
className,
@@ -38,50 +43,128 @@ SidebarFooter.displayName = 'Sidebar.Footer';
export interface SidebarNavigationGroupProps extends ComponentProps<'section'> {
label: string;
+ value?: string;
+ collapsible?: 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,
+ collapsible = false,
leadingIcon,
+ trailingIcon,
classNames,
children,
...props
}: SidebarNavigationGroupProps) {
+ const { isCollapsed } = useContext(SidebarContext);
+ const groupValue = value ?? label;
+
+ if (!collapsible) {
+ return (
+
+
+ }
+ />
+
+ {label}
+
+
+
+
+ {children}
+
+
+ );
+ }
+
return (
-
- {leadingIcon && (
-
- {leadingIcon}
-
- )}
-
- {label}
-
-
-
- {children}
-
+
+
+
+ }
+ />
+
+ {label}
+
+
+
+
+
+
+
+ {children}
+
+
+
+
);
}
diff --git a/packages/raystack/components/sidebar/sidebar-more-context.tsx b/packages/raystack/components/sidebar/sidebar-more-context.tsx
new file mode 100644
index 000000000..567cabe92
--- /dev/null
+++ b/packages/raystack/components/sidebar/sidebar-more-context.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { createContext, ReactNode, useContext } from 'react';
+
+interface SidebarMoreContextValue {
+ isInsideSidebarMore: boolean;
+ menuItemClassName?: string;
+}
+
+const SidebarMoreContext = createContext(
+ undefined
+);
+
+export function SidebarMoreProvider({
+ value,
+ children
+}: {
+ value: SidebarMoreContextValue;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSidebarMoreContext() {
+ return useContext(SidebarMoreContext);
+}
diff --git a/packages/raystack/components/sidebar/sidebar-more.tsx b/packages/raystack/components/sidebar/sidebar-more.tsx
new file mode 100644
index 000000000..093acaab1
--- /dev/null
+++ b/packages/raystack/components/sidebar/sidebar-more.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { DotsHorizontalIcon } from '@radix-ui/react-icons';
+import { cx } from 'class-variance-authority';
+import { ReactNode, useContext } from 'react';
+import { Menu } from '../menu';
+import { Tooltip } from '../tooltip';
+import styles from './sidebar.module.css';
+import { SidebarLeadingVisual } from './sidebar-leading-visual';
+import { SidebarMoreProvider } from './sidebar-more-context';
+import { SidebarContext } from './sidebar-root';
+
+export interface SidebarMoreProps {
+ children?: ReactNode;
+ label?: string;
+ leadingIcon?: ReactNode;
+ classNames?: {
+ root?: string;
+ leadingIcon?: string;
+ text?: string;
+ menuItem?: string;
+ menuContent?: string;
+ };
+}
+
+export function SidebarMore({
+ children,
+ label = 'More',
+ leadingIcon,
+ classNames
+}: SidebarMoreProps) {
+ const { isCollapsed, position, hideCollapsedItemTooltip } =
+ useContext(SidebarContext);
+ if (!children) return null;
+ const triggerIcon = leadingIcon ?? (
+
+ );
+
+ const triggerContent = (
+
+ );
+
+ return (
+
+ );
+}
+
+SidebarMore.displayName = 'Sidebar.More';
diff --git a/packages/raystack/components/sidebar/sidebar-root.tsx b/packages/raystack/components/sidebar/sidebar-root.tsx
index 1438656a0..910ea2d63 100644
--- a/packages/raystack/components/sidebar/sidebar-root.tsx
+++ b/packages/raystack/components/sidebar/sidebar-root.tsx
@@ -13,15 +13,18 @@ import styles from './sidebar.module.css';
export interface SidebarContextValue {
isCollapsed: boolean;
+ position: 'left' | 'right';
hideCollapsedItemTooltip?: boolean;
}
export const SidebarContext = createContext({
- isCollapsed: false
+ isCollapsed: false,
+ position: 'left'
});
export interface SidebarRootProps extends ComponentProps<'aside'> {
position?: 'left' | 'right';
+ variant?: 'plain' | 'floating' | 'inset';
hideCollapsedItemTooltip?: boolean;
collapsible?: boolean;
tooltipMessage?: ReactNode;
@@ -33,6 +36,7 @@ export interface SidebarRootProps extends ComponentProps<'aside'> {
export function SidebarRoot({
className,
position = 'left',
+ variant = 'plain',
open: providedOpen,
onOpenChange,
hideCollapsedItemTooltip,
@@ -54,10 +58,13 @@ export function SidebarRoot({
);
return (
-
+
+ );
+}
diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css
index 63aa7b4bd..8782b3974 100644
--- a/packages/raystack/components/sidebar/sidebar.module.css
+++ b/packages/raystack/components/sidebar/sidebar.module.css
@@ -19,6 +19,19 @@
border-left: 1px solid var(--rs-color-border-base-primary);
}
+.root[data-variant="floating"] {
+ box-shadow: var(--rs-shadow-lifted);
+ border-radius: var(--rs-radius-3);
+ margin: var(--rs-space-4);
+ border: 0.5px solid var(--rs-color-border-base-primary);
+}
+
+.root[data-variant="inset"] {
+ background: transparent;
+ box-shadow: none;
+ border: none;
+}
+
.root[data-open] {
width: 240px;
}
@@ -63,15 +76,28 @@
padding: var(--rs-space-3);
align-items: center;
gap: var(--rs-space-3);
+ min-height: var(--rs-space-9);
align-self: stretch;
border-radius: var(--rs-radius-2);
color: var(--rs-color-foreground-base-secondary);
cursor: pointer;
text-decoration: none;
- transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease,
+ opacity 0.2s ease;
box-sizing: border-box;
}
+.more-trigger {
+ width: 100%;
+ border: 0;
+ background: transparent;
+ text-align: left;
+}
+
.root[data-closed] .nav-item {
justify-content: flex-start;
}
@@ -81,6 +107,12 @@
color: var(--rs-color-foreground-base-primary);
}
+/* keep item highlighted when menu is open */
+.more-trigger[aria-expanded="true"] {
+ background-color: var(--rs-color-background-base-primary-hover);
+ color: var(--rs-color-foreground-base-primary);
+}
+
.nav-item[data-active="true"] {
display: flex;
padding: var(--rs-space-3);
@@ -97,6 +129,51 @@
pointer-events: none;
}
+.more-menu-item {
+ display: flex;
+ padding: var(--rs-space-3);
+ align-items: center;
+ gap: var(--rs-space-3);
+ min-height: var(--rs-space-9);
+ align-self: stretch;
+ border-radius: var(--rs-radius-2);
+ color: var(--rs-color-foreground-base-secondary);
+ cursor: pointer;
+ text-decoration: none;
+ box-sizing: border-box;
+ width: 100%;
+ border: 0;
+ background: transparent;
+ text-align: left;
+}
+
+.more-menu-item:hover {
+ background-color: var(--rs-color-background-base-primary-hover);
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.more-menu-item[data-active="true"] {
+ background: var(--rs-color-background-neutral-secondary);
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.more-menu-item[data-disabled="true"] {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.more-menu-item-text {
+ color: var(--rs-color-foreground-base-primary);
+ font-size: var(--rs-font-size-small);
+ font-style: normal;
+ font-weight: var(--rs-font-weight-medium);
+ line-height: var(--rs-line-height-small);
+ letter-spacing: var(--rs-letter-spacing-small);
+ width: auto;
+ opacity: 1;
+ display: inline;
+}
+
.nav-leading-icon {
align-self: stretch;
width: fit-content;
@@ -158,6 +235,7 @@
flex-direction: column;
align-items: flex-start;
width: 100%;
+ margin-top: var(--rs-space-3);
}
.root[data-closed] .nav-group {
@@ -165,9 +243,28 @@
}
.nav-group-header {
- padding: var(--rs-space-3) var(--rs-space-3);
+ display: flex;
+ height: var(--rs-space-7);
+ padding: var(--rs-space-2) var(--rs-space-3);
+ margin-bottom: var(--rs-space-1);
+ justify-content: space-between;
+ align-items: center;
+ align-self: stretch;
color: var(--rs-color-foreground-base-secondary);
margin-top: var(--rs-space-4);
+ border-radius: var(--rs-radius-2);
+}
+
+.nav-group-accordion-item .nav-group-header:hover {
+ background-color: var(--rs-color-background-base-primary-hover);
+}
+
+.nav-group-header-with-trailing:hover {
+ background-color: var(--rs-color-background-base-primary-hover);
+}
+
+.nav-group-accordion-item .nav-group-header {
+ padding: 0;
}
.nav-group-header:first-child {
@@ -183,14 +280,88 @@
letter-spacing: var(--rs-letter-spacing-small);
}
+.nav-group-trigger {
+ display: flex;
+ width: auto;
+ flex: 1;
+ height: 100%;
+ border: 0;
+ padding: var(--rs-space-2) var(--rs-space-2) var(--rs-space-2)
+ var(--rs-space-3);
+ background: transparent;
+ align-items: center;
+ gap: var(--rs-space-2);
+ cursor: pointer;
+ color: inherit;
+ text-align: left;
+}
+
+.nav-group-chevron {
+ width: var(--rs-space-4);
+ height: var(--rs-space-4);
+ color: var(--rs-color-foreground-base-secondary);
+ transform: rotate(-90deg);
+ transition: transform 0.2s ease;
+ flex-shrink: 0;
+}
+
+.nav-group-trigger[data-panel-open] .nav-group-chevron {
+ transform: rotate(0deg);
+}
+
+.nav-group-trailing-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--rs-space-2) var(--rs-space-3);
+ color: var(--rs-color-foreground-base-secondary);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.nav-group-header:hover .nav-group-trailing-icon {
+ opacity: 1;
+}
+
.nav-group-items {
gap: var(--rs-space-2);
width: 100%;
}
-/* Hide group header visually when collapsed but reserve vertical space to prevent shift */
+.nav-group-accordion,
+.nav-group-accordion-item,
+.nav-group-panel {
+ width: 100%;
+}
+
+.nav-group-panel {
+ height: var(--accordion-panel-height);
+ overflow: hidden;
+ transition: height 0.2s ease;
+}
+
+.nav-group-panel[data-starting-style],
+.nav-group-panel[data-ending-style] {
+ height: 0;
+}
+
+/* Hide group header text when collapsed but show a separator line in its place */
.root[data-closed] .nav-group-header {
visibility: hidden;
+ position: relative;
+ align-self: stretch;
+}
+
+.root[data-closed] .nav-group-header::after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: var(--rs-space-5);
+ height: 1px;
+ background: var(--rs-color-border-base-primary);
+ visibility: visible;
}
.root[data-closed] .nav-group-label {
@@ -202,8 +373,16 @@
/* Keep in flow (no display: none) so header row height is preserved */
}
+.root[data-closed] .nav-group-chevron {
+ display: none;
+}
+
@media (prefers-reduced-motion: no-preference) {
- .root {
+ .root,
+ .nav-item,
+ .nav-text,
+ .resizeHandle,
+ .nav-group-panel {
transition: width 0.2s ease;
}
-}
\ No newline at end of file
+}
diff --git a/packages/raystack/components/sidebar/sidebar.tsx b/packages/raystack/components/sidebar/sidebar.tsx
index 5ab0eddec..85eb6fca3 100644
--- a/packages/raystack/components/sidebar/sidebar.tsx
+++ b/packages/raystack/components/sidebar/sidebar.tsx
@@ -5,6 +5,7 @@ import {
SidebarHeader,
SidebarNavigationGroup
} from './sidebar-misc';
+import { SidebarMore } from './sidebar-more';
import { SidebarRoot } from './sidebar-root';
export const Sidebar = Object.assign(SidebarRoot, {
@@ -12,5 +13,6 @@ export const Sidebar = Object.assign(SidebarRoot, {
Main: SidebarMain,
Footer: SidebarFooter,
Item: SidebarItem,
- Group: SidebarNavigationGroup
+ Group: SidebarNavigationGroup,
+ More: SidebarMore
});