Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/www/src/content/docs/components/sidebar/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,24 @@ export const stateDemo = {
}
]
};

export const tooltipDemo = {
type: 'code',
code: `<Sidebar
defaultOpen
tooltipMessage="Toggle navigation"
>
<Sidebar.Header>
<Flex align="center" gap={3}>
<IconButton size={4} aria-label="Logo">
<Home width={24} height={24}/>
</IconButton>
<Text size={4} weight="medium" data-collapse-hidden>Apsara</Text>
</Flex>
</Sidebar.Header>
<Sidebar.Main>
<Sidebar.Item href="#" leadingIcon={<Info />} active>Dashboard</Sidebar.Item>
<Sidebar.Item href="#" leadingIcon={<Info />} disabled>Settings</Sidebar.Item>
</Sidebar.Main>
</Sidebar>`
};
10 changes: 9 additions & 1 deletion apps/www/src/content/docs/components/sidebar/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Sidebar
description: A collapsible side navigation panel component.
---

import { preview, positionDemo, stateDemo } from "./demo.ts";
import { preview, positionDemo, stateDemo, tooltipDemo } from "./demo.ts";

<Demo data={preview} />

Expand Down Expand Up @@ -47,6 +47,14 @@ The `data-collapse-hidden` attribute can be used to conditionally hide elements

<Demo data={stateDemo} />

### Custom Tooltip Message

You can customize the tooltip message that appears when hovering over the collapse/expand handle using the `tooltipMessage` prop.

You can use Sidebar as a controlled component to conditionally render different tooltip messages.

<Demo data={tooltipDemo} />

## Accessibility

The Sidebar implements the following accessibility features:
Expand Down
5 changes: 5 additions & 0 deletions apps/www/src/content/docs/components/sidebar/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface SidebarRootProps {
* @default false
*/
hideCollapsedItemTooltip?: boolean;

/** Custom message for the collapsible tooltip.
* By default, it shows "Click to collapse" when expanded, "Click to expand" when collapsed
*/
tooltipMessage?: ReactNode;
}

export interface SidebarGroupProps {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Sidebar, SidebarProps } from '../sidebar';
import { Sidebar } from '../sidebar';
import { SidebarRootProps } from '../sidebar-root';
import styles from '../sidebar.module.css';

const HEADER_TEXT = 'Apsara';
Expand All @@ -24,7 +25,7 @@ const BasicSidebar = ({
position = 'left',
children,
...props
}: SidebarProps) => (
}: SidebarRootProps) => (
<Sidebar
defaultOpen={defaultOpen}
open={open}
Expand Down
99 changes: 99 additions & 0 deletions packages/raystack/components/sidebar/sidebar-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client';

import { cx } from 'class-variance-authority';
import {
ComponentPropsWithoutRef,
ReactElement,
ReactNode,
cloneElement,
forwardRef,
useContext
} from 'react';
import { Avatar } from '../avatar';
import { Flex } from '../flex';
import { Tooltip } from '../tooltip';
import { SidebarContext } from './sidebar-root';
import styles from './sidebar.module.css';

export interface SidebarItemProps extends ComponentPropsWithoutRef<'a'> {
leadingIcon?: ReactNode;
active?: boolean;
disabled?: boolean;
as?: ReactElement;
classNames?: {
root?: string;
leadingIcon?: string;
text?: string;
};
}

export const SidebarItem = forwardRef<HTMLAnchorElement, SidebarItemProps>(
(
{
classNames,
leadingIcon,
children,
active,
disabled,
as = <a />,
...props
},
ref
) => {
const { isCollapsed, hideCollapsedItemTooltip } =
useContext(SidebarContext);

const shouldShowFallback =
leadingIcon == undefined &&
isCollapsed &&
typeof children === 'string' &&
children.length > 0;

const content = cloneElement(
as,
{
ref,
className: cx(styles['nav-item'], classNames?.root),
'data-active': active,
'data-disabled': disabled,
role: 'menuitem',
'aria-current': active ? 'page' : undefined,
'aria-disabled': disabled,
...props
},
<>
<Flex
align='center'
gap={3}
className={cx(styles['nav-leading-icon'], classNames?.leadingIcon)}
aria-hidden='true'
>
{shouldShowFallback ? (
<Avatar
size={1}
variant='soft'
color='neutral'
fallback={children[0].toUpperCase()}
style={{ cursor: 'pointer' }}
/>
) : (
leadingIcon
)}
</Flex>
{!isCollapsed && <span className={styles['nav-text']}>{children}</span>}
</>
);

if (isCollapsed && !hideCollapsedItemTooltip) {
return (
<Tooltip message={children} side='right'>
{content}
</Tooltip>
);
}

return content;
}
);

SidebarItem.displayName = 'Sidebar.Item';
23 changes: 23 additions & 0 deletions packages/raystack/components/sidebar/sidebar-main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import { ComponentPropsWithoutRef, forwardRef } from 'react';
import { Flex } from '../flex';
import styles from './sidebar.module.css';

export const SidebarMain = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<'div'>
>(({ className, children, ...props }, ref) => (
<Flex
ref={ref}
className={styles.main}
direction='column'
role='group'
aria-label='Main navigation'
{...props}
>
{children}
</Flex>
));

SidebarMain.displayName = 'Sidebar.Main';
69 changes: 69 additions & 0 deletions packages/raystack/components/sidebar/sidebar-misc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client';

import { cx } from 'class-variance-authority';
import { ComponentPropsWithoutRef, ReactNode, forwardRef } from 'react';
import { Flex } from '../flex';
import styles from './sidebar.module.css';

export const SidebarHeader = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<'div'>
>(({ className, children, ...props }, ref) => (
<Flex
align='center'
ref={ref}
className={styles.header}
role='banner'
{...props}
>
{children}
</Flex>
));
SidebarHeader.displayName = 'Sidebar.Header';

export const SidebarFooter = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<'div'>
>(({ className, children, ...props }, ref) => (
<Flex
ref={ref}
className={styles.footer}
direction='column'
role='group'
aria-label='Footer navigation'
{...props}
>
{children}
</Flex>
));
SidebarFooter.displayName = 'Sidebar.Footer';

export interface SidebarNavigationGroupProps
extends ComponentPropsWithoutRef<'div'> {
label: string;
leadingIcon?: ReactNode;
}

export const SidebarNavigationGroup = forwardRef<
HTMLElement,
SidebarNavigationGroupProps
>(({ className, label, leadingIcon, children, ...props }, ref) => (
<section
ref={ref}
className={cx(styles['nav-group'], className)}
aria-label={label}
{...props}
>
<Flex align='center' gap={3} className={styles['nav-group-header']}>
{leadingIcon && (
<span className={styles['nav-leading-icon']}>{leadingIcon}</span>
)}
<span className={styles['nav-group-label']}>{label}</span>
</Flex>
<Flex direction='column' className={styles['nav-group-items']} role='list'>
{children}
</Flex>
</section>
));

SidebarNavigationGroup.displayName = 'Sidebar.Group';
122 changes: 122 additions & 0 deletions packages/raystack/components/sidebar/sidebar-root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client';

import { cva } from 'class-variance-authority';
import { Collapsible } from 'radix-ui';
import {
ComponentPropsWithoutRef,
ComponentRef,
ReactNode,
createContext,
forwardRef,
useCallback,
useState
} from 'react';
import { Tooltip } from '../tooltip';
import styles from './sidebar.module.css';

export interface SidebarContextValue {
isCollapsed: boolean;
hideCollapsedItemTooltip?: boolean;
}

export const SidebarContext = createContext<SidebarContextValue>({
isCollapsed: false
});

const root = cva(styles.root);

export interface SidebarRootProps
extends ComponentPropsWithoutRef<typeof Collapsible.Root> {
position?: 'left' | 'right';
hideCollapsedItemTooltip?: boolean;
collapsible?: boolean;
tooltipMessage?: ReactNode;
}

export const SidebarRoot = forwardRef<
ComponentRef<typeof Collapsible.Root>,
SidebarRootProps
>(
(
{
className,
position = 'left',
open: providedOpen,
onOpenChange,
hideCollapsedItemTooltip,
collapsible = true,
tooltipMessage,
defaultOpen,
children,
...props
},
ref
) => {
const [internalOpen, setInternalOpen] = useState(defaultOpen);

const open = providedOpen ?? internalOpen;

const handleOpenChange = useCallback(
(value: boolean) => {
setInternalOpen(value);
onOpenChange?.(value);
},
[onOpenChange]
);

return (
<SidebarContext.Provider
value={{ isCollapsed: !open, hideCollapsedItemTooltip }}
>
<Tooltip.Provider>
<Collapsible.Root
ref={ref}
className={root({ className })}
data-position={position}
data-state={open ? 'expanded' : 'collapsed'}
data-collapse-disabled={!collapsible}
open={open}
onOpenChange={collapsible ? handleOpenChange : undefined}
aria-label='Navigation Sidebar'
aria-expanded={open}
role='navigation'
{...props}
asChild
>
<aside>
{collapsible && (
<Tooltip
message={
tooltipMessage ??
(open ? 'Click to collapse' : 'Click to expand')
}
side={position === 'left' ? 'right' : 'left'}
asChild
followCursor
sideOffset={10}
>
<div
className={styles.resizeHandle}
onClick={() => handleOpenChange(!open)}
role='button'
tabIndex={0}
aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenChange(!open);
}
}}
/>
</Tooltip>
)}
{children}
</aside>
</Collapsible.Root>
</Tooltip.Provider>
</SidebarContext.Provider>
);
}
);

SidebarRoot.displayName = 'Sidebar';
Loading