diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index 2f9f94b16..347dc0db5 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,62 @@ const Page = () => { - }> + }> Dashboard - }> + }> Analytics - }> - Reports - - Activities + + }> + Reports + + + } + > + Activities + + console.log('Notifications clicked')} + leadingIcon={} + > + Notifications + + - Settings - - Notifications + }> + Settings + + }> + }> + Notifications + + } disabled> + Billing + + - Help & Support - - Preferences + }> + Help & Support + + + }> + Preferences + + }> + Documentation + + @@ -285,7 +323,7 @@ const Page = () => { color: 'var(--rs-color-foreground-base-secondary)' }} > - + 25% @@ -300,7 +338,7 @@ const Page = () => { color: 'var(--rs-color-foreground-base-secondary)' }} > - + 25% @@ -315,7 +353,7 @@ const Page = () => { color: 'var(--rs-color-foreground-base-secondary)' }} > - + 25% @@ -352,7 +390,9 @@ const Page = () => { color: 'var(--rs-color-foreground-base-secondary)' }} > - + Sun @@ -371,7 +411,9 @@ const Page = () => { color: 'var(--rs-color-foreground-base-secondary)' }} > - + 15th @@ -390,7 +432,9 @@ const Page = () => { color: 'var(--rs-color-foreground-base-secondary)' }} > - + Today @@ -1365,7 +1409,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 +1606,7 @@ const Page = () => { @@ -1584,7 +1628,7 @@ const Page = () => { } + leadingIcon={} width='100%' /> @@ -1722,7 +1766,7 @@ const Page = () => { @@ -1755,7 +1799,7 @@ const Page = () => { } + leadingIcon={} width='100%' /> @@ -2197,7 +2241,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 +2249,7 @@ const Page = () => { } emptyState={ } + icon={} heading='Empty state' variant='empty1' subHeading="We couldn't find any matches for that keyword or filter." @@ -2277,7 +2321,7 @@ const Page = () => { } + icon={} heading='zero state' variant='empty2' subHeading='Get started by creating your first user.' @@ -2285,7 +2329,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 +2396,7 @@ const Page = () => { } + icon={} heading='zero state' variant='empty2' subHeading='Get started by creating your first user.' @@ -2360,7 +2404,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 +2457,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 +2527,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 +2535,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 +2601,7 @@ const Page = () => { } + icon={} heading='zero state' variant='empty2' subHeading='Get started by creating your first user.' @@ -2565,7 +2609,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..0ad382909 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,59 @@ export const hideTooltipDemo = { + }>Overview } active>Dashboard }>Settings + }> + Help + + + `) +}; + +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 7b58eb0d5..3bc09f882 100644 --- a/apps/www/src/content/docs/components/sidebar/index.mdx +++ b/apps/www/src/content/docs/components/sidebar/index.mdx @@ -7,10 +7,12 @@ source: packages/raystack/components/sidebar import { preview, positionDemo, + variantDemo, stateDemo, tooltipDemo, collapsibleDemo, - hideTooltipDemo + hideTooltipDemo, + moreDemo } from "./demo.ts"; @@ -27,6 +29,9 @@ import { Sidebar } from "@raystack/apsara"; Item + + Hidden item + @@ -63,6 +68,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 +84,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 +122,12 @@ Set `hideCollapsedItemTooltip` to disable tooltips on navigation items when the +### 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..6003ea2a9 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 */ @@ -83,3 +88,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..88bacde54 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', () => { @@ -272,4 +286,38 @@ describe('Sidebar', () => { expect(group).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..6849fb3c7 100644 --- a/packages/raystack/components/sidebar/sidebar-item.tsx +++ b/packages/raystack/components/sidebar/sidebar-item.tsx @@ -8,10 +8,9 @@ import { ReactNode, useContext } from 'react'; -import { Avatar } from '../avatar'; -import { Flex } from '../flex'; import { Tooltip } from '../tooltip'; import styles from './sidebar.module.css'; +import { SidebarLeadingVisual } from './sidebar-leading-visual'; import { SidebarContext } from './sidebar-root'; export interface SidebarItemProps extends ComponentProps<'a'> { @@ -43,13 +42,6 @@ export function SidebarItem({ 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 content = cloneElement( as, { @@ -65,20 +57,11 @@ export function SidebarItem({ ...props }, <> - {shouldShowFallback ? ( - - - - ) : null} - {!shouldShowFallback && leadingIcon ? ( - {leadingIcon} - ) : null} + {!isCollapsed && ( {children} 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..d55054385 --- /dev/null +++ b/packages/raystack/components/sidebar/sidebar-leading-visual.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { 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; +} + +export function SidebarLeadingVisual({ + leadingIcon, + fallbackText, + className +}: SidebarLeadingVisualProps) { + if (leadingIcon) { + return ( + + ); + } + + if (fallbackText && fallbackText.length > 0) { + return ( + + ); + } + + return null; +} diff --git a/packages/raystack/components/sidebar/sidebar-more.tsx b/packages/raystack/components/sidebar/sidebar-more.tsx new file mode 100644 index 000000000..23b2f7b5b --- /dev/null +++ b/packages/raystack/components/sidebar/sidebar-more.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { DotsHorizontalIcon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { + Children, + cloneElement, + isValidElement, + ReactElement, + ReactNode, + useContext +} from 'react'; +import { Menu } from '../menu'; +import { Tooltip } from '../tooltip'; +import styles from './sidebar.module.css'; +import { SidebarItemProps } from './sidebar-item'; +import { SidebarLeadingVisual } from './sidebar-leading-visual'; +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); + + const items = Children.toArray(children).filter( + isValidElement + ) as ReactElement[]; + + if (items.length === 0) return null; + const triggerIcon = leadingIcon ?? ( + + ); + + const triggerContent = ( + + ); + + return ( + + {isCollapsed && !hideCollapsedItemTooltip ? ( + + } /> + + {label} + + + ) : ( + + )} + + {items.map((item, index) => { + const { + leadingIcon: itemLeadingIcon, + active, + disabled, + as = , + classNames: itemClassNames, + children: itemLabel, + ...itemProps + } = item.props; + + const render = cloneElement( + as, + { + className: cx( + styles['nav-item'], + styles['more-menu-item'], + itemClassNames?.root, + classNames?.menuItem + ), + 'data-active': active, + 'data-disabled': disabled, + 'aria-current': active ? 'page' : undefined, + 'aria-disabled': disabled, + ...itemProps + }, + <> + + + {itemLabel} + + + ); + + 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 ( - +