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 ( + + ); +} + +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 ( + + {isCollapsed && !hideCollapsedItemTooltip ? ( + + } /> + + {label} + + + ) : ( + + )} + + + {children} + + + + ); +} + +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 ( - +