@weave-design-system/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +7729 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +677 -0
- package/dist/index.d.ts +677 -0
- package/dist/index.js +7654 -0
- package/dist/index.js.map +1 -0
- package/dist/theme.css +2 -0
- package/dist/tokens.cjs +78 -0
- package/dist/tokens.cjs.map +1 -0
- package/dist/tokens.d.cts +67 -0
- package/dist/tokens.d.ts +67 -0
- package/dist/tokens.js +70 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +103 -0
- package/src/data-display/activity-feed/ActivityFeed.stories.tsx +16 -0
- package/src/data-display/activity-feed/ActivityFeed.test.tsx +11 -0
- package/src/data-display/activity-feed/ActivityFeed.tsx +34 -0
- package/src/data-display/activity-feed/index.ts +2 -0
- package/src/data-display/circular-progress/CircularProgress.stories.tsx +10 -0
- package/src/data-display/circular-progress/CircularProgress.test.tsx +11 -0
- package/src/data-display/circular-progress/CircularProgress.tsx +35 -0
- package/src/data-display/circular-progress/index.ts +2 -0
- package/src/data-display/empty-state/EmptyState.stories.tsx +15 -0
- package/src/data-display/empty-state/EmptyState.test.tsx +18 -0
- package/src/data-display/empty-state/EmptyState.tsx +34 -0
- package/src/data-display/empty-state/index.ts +2 -0
- package/src/data-display/progress-bar/ProgressBar.stories.tsx +10 -0
- package/src/data-display/progress-bar/ProgressBar.test.tsx +15 -0
- package/src/data-display/progress-bar/ProgressBar.tsx +38 -0
- package/src/data-display/progress-bar/index.ts +2 -0
- package/src/data-display/stat-card/StatCard.stories.tsx +10 -0
- package/src/data-display/stat-card/StatCard.test.tsx +15 -0
- package/src/data-display/stat-card/StatCard.tsx +40 -0
- package/src/data-display/stat-card/index.ts +2 -0
- package/src/data-display/table/Table.stories.tsx +44 -0
- package/src/data-display/table/Table.test.tsx +16 -0
- package/src/data-display/table/Table.tsx +71 -0
- package/src/data-display/table/index.ts +1 -0
- package/src/data-display/timeline/Timeline.stories.tsx +16 -0
- package/src/data-display/timeline/Timeline.test.tsx +11 -0
- package/src/data-display/timeline/Timeline.tsx +44 -0
- package/src/data-display/timeline/index.ts +2 -0
- package/src/docs/ComponentOverview.mdx +192 -0
- package/src/docs/DesignTokens.mdx +235 -0
- package/src/docs/GettingStarted.mdx +145 -0
- package/src/feedback/alert-banner/AlertBanner.stories.tsx +10 -0
- package/src/feedback/alert-banner/AlertBanner.test.tsx +16 -0
- package/src/feedback/alert-banner/AlertBanner.tsx +47 -0
- package/src/feedback/alert-banner/index.ts +2 -0
- package/src/feedback/modal/Modal.stories.tsx +31 -0
- package/src/feedback/modal/Modal.test.tsx +33 -0
- package/src/feedback/modal/Modal.tsx +88 -0
- package/src/feedback/modal/index.ts +2 -0
- package/src/feedback/skeleton-loader/SkeletonLoader.stories.tsx +23 -0
- package/src/feedback/skeleton-loader/SkeletonLoader.test.tsx +14 -0
- package/src/feedback/skeleton-loader/SkeletonLoader.tsx +61 -0
- package/src/feedback/skeleton-loader/index.ts +2 -0
- package/src/feedback/toast/Toast.stories.tsx +27 -0
- package/src/feedback/toast/Toast.test.tsx +32 -0
- package/src/feedback/toast/Toast.tsx +106 -0
- package/src/feedback/toast/index.ts +2 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-controllable-state.ts +34 -0
- package/src/hooks/use-focus-trap.ts +56 -0
- package/src/hooks/use-reduced-motion.ts +17 -0
- package/src/index.ts +148 -0
- package/src/layout/card/Card.stories.tsx +45 -0
- package/src/layout/card/Card.test.tsx +23 -0
- package/src/layout/card/Card.tsx +42 -0
- package/src/layout/card/Card.types.ts +6 -0
- package/src/layout/card/index.ts +2 -0
- package/src/layout/command-palette/CommandPalette.stories.tsx +34 -0
- package/src/layout/command-palette/CommandPalette.test.tsx +43 -0
- package/src/layout/command-palette/CommandPalette.tsx +188 -0
- package/src/layout/command-palette/CommandPalette.types.ts +18 -0
- package/src/layout/command-palette/index.ts +2 -0
- package/src/layout/sidebar/Sidebar.stories.tsx +60 -0
- package/src/layout/sidebar/Sidebar.test.tsx +27 -0
- package/src/layout/sidebar/Sidebar.tsx +57 -0
- package/src/layout/sidebar/Sidebar.types.ts +14 -0
- package/src/layout/sidebar/index.ts +2 -0
- package/src/layout/top-bar/TopBar.stories.tsx +51 -0
- package/src/layout/top-bar/TopBar.test.tsx +18 -0
- package/src/layout/top-bar/TopBar.tsx +19 -0
- package/src/layout/top-bar/TopBar.types.ts +10 -0
- package/src/layout/top-bar/index.ts +2 -0
- package/src/navigation/breadcrumbs/Breadcrumbs.stories.tsx +30 -0
- package/src/navigation/breadcrumbs/Breadcrumbs.test.tsx +43 -0
- package/src/navigation/breadcrumbs/Breadcrumbs.tsx +45 -0
- package/src/navigation/breadcrumbs/Breadcrumbs.types.ts +12 -0
- package/src/navigation/breadcrumbs/index.ts +2 -0
- package/src/navigation/sidebar-nav-item/SidebarNavItem.stories.tsx +41 -0
- package/src/navigation/sidebar-nav-item/SidebarNavItem.test.tsx +46 -0
- package/src/navigation/sidebar-nav-item/SidebarNavItem.tsx +38 -0
- package/src/navigation/sidebar-nav-item/SidebarNavItem.types.ts +12 -0
- package/src/navigation/sidebar-nav-item/index.ts +2 -0
- package/src/navigation/tabs/Tabs.stories.tsx +47 -0
- package/src/navigation/tabs/Tabs.test.tsx +67 -0
- package/src/navigation/tabs/Tabs.tsx +111 -0
- package/src/navigation/tabs/Tabs.types.ts +36 -0
- package/src/navigation/tabs/index.ts +2 -0
- package/src/patterns/accordion/Accordion.stories.tsx +25 -0
- package/src/patterns/accordion/Accordion.test.tsx +44 -0
- package/src/patterns/accordion/Accordion.tsx +92 -0
- package/src/patterns/accordion/index.ts +2 -0
- package/src/patterns/action-menu/ActionMenu.stories.tsx +18 -0
- package/src/patterns/action-menu/ActionMenu.test.tsx +18 -0
- package/src/patterns/action-menu/ActionMenu.tsx +41 -0
- package/src/patterns/action-menu/index.ts +2 -0
- package/src/patterns/carousel/Carousel.stories.tsx +16 -0
- package/src/patterns/carousel/Carousel.test.tsx +16 -0
- package/src/patterns/carousel/Carousel.tsx +69 -0
- package/src/patterns/carousel/index.ts +2 -0
- package/src/patterns/image-placeholder/ImagePlaceholder.stories.tsx +9 -0
- package/src/patterns/image-placeholder/ImagePlaceholder.test.tsx +10 -0
- package/src/patterns/image-placeholder/ImagePlaceholder.tsx +21 -0
- package/src/patterns/image-placeholder/index.ts +2 -0
- package/src/patterns/notification-dot/NotificationDot.stories.tsx +17 -0
- package/src/patterns/notification-dot/NotificationDot.test.tsx +14 -0
- package/src/patterns/notification-dot/NotificationDot.tsx +18 -0
- package/src/patterns/notification-dot/index.ts +2 -0
- package/src/patterns/pagination/Pagination.stories.tsx +14 -0
- package/src/patterns/pagination/Pagination.test.tsx +22 -0
- package/src/patterns/pagination/Pagination.tsx +67 -0
- package/src/patterns/pagination/index.ts +2 -0
- package/src/primitives/avatar/Avatar.stories.tsx +46 -0
- package/src/primitives/avatar/Avatar.test.tsx +35 -0
- package/src/primitives/avatar/Avatar.tsx +49 -0
- package/src/primitives/avatar/Avatar.types.ts +21 -0
- package/src/primitives/avatar/AvatarGroup.tsx +27 -0
- package/src/primitives/avatar/index.ts +3 -0
- package/src/primitives/badge/Badge.stories.tsx +28 -0
- package/src/primitives/badge/Badge.test.tsx +23 -0
- package/src/primitives/badge/Badge.tsx +44 -0
- package/src/primitives/badge/Badge.types.ts +14 -0
- package/src/primitives/badge/index.ts +2 -0
- package/src/primitives/button/Button.stories.tsx +81 -0
- package/src/primitives/button/Button.test.tsx +64 -0
- package/src/primitives/button/Button.tsx +85 -0
- package/src/primitives/button/Button.types.ts +17 -0
- package/src/primitives/button/index.ts +2 -0
- package/src/primitives/checkbox/Checkbox.stories.tsx +27 -0
- package/src/primitives/checkbox/Checkbox.test.tsx +30 -0
- package/src/primitives/checkbox/Checkbox.tsx +79 -0
- package/src/primitives/checkbox/Checkbox.types.ts +12 -0
- package/src/primitives/checkbox/index.ts +2 -0
- package/src/primitives/combobox/Combobox.stories.tsx +44 -0
- package/src/primitives/combobox/Combobox.test.tsx +44 -0
- package/src/primitives/combobox/Combobox.tsx +201 -0
- package/src/primitives/combobox/Combobox.types.ts +25 -0
- package/src/primitives/combobox/index.ts +2 -0
- package/src/primitives/date-input/DateInput.stories.tsx +23 -0
- package/src/primitives/date-input/DateInput.test.tsx +22 -0
- package/src/primitives/date-input/DateInput.tsx +66 -0
- package/src/primitives/date-input/DateInput.types.ts +10 -0
- package/src/primitives/date-input/index.ts +2 -0
- package/src/primitives/file-upload/FileUploadDropzone.stories.tsx +27 -0
- package/src/primitives/file-upload/FileUploadDropzone.test.tsx +26 -0
- package/src/primitives/file-upload/FileUploadDropzone.tsx +99 -0
- package/src/primitives/file-upload/FileUploadDropzone.types.ts +14 -0
- package/src/primitives/file-upload/index.ts +2 -0
- package/src/primitives/input/InputGroup.stories.tsx +31 -0
- package/src/primitives/input/InputGroup.test.tsx +40 -0
- package/src/primitives/input/InputGroup.tsx +65 -0
- package/src/primitives/input/InputGroup.types.ts +12 -0
- package/src/primitives/input/index.ts +2 -0
- package/src/primitives/link/Link.stories.tsx +28 -0
- package/src/primitives/link/Link.test.tsx +23 -0
- package/src/primitives/link/Link.tsx +28 -0
- package/src/primitives/link/Link.types.ts +8 -0
- package/src/primitives/link/index.ts +2 -0
- package/src/primitives/radio/Radio.stories.tsx +29 -0
- package/src/primitives/radio/Radio.test.tsx +32 -0
- package/src/primitives/radio/Radio.tsx +59 -0
- package/src/primitives/radio/Radio.types.ts +6 -0
- package/src/primitives/radio/index.ts +2 -0
- package/src/primitives/select/SelectGroup.stories.tsx +33 -0
- package/src/primitives/select/SelectGroup.test.tsx +34 -0
- package/src/primitives/select/SelectGroup.tsx +72 -0
- package/src/primitives/select/SelectGroup.types.ts +12 -0
- package/src/primitives/select/index.ts +2 -0
- package/src/primitives/slider/Slider.stories.tsx +23 -0
- package/src/primitives/slider/Slider.test.tsx +28 -0
- package/src/primitives/slider/Slider.tsx +80 -0
- package/src/primitives/slider/Slider.types.ts +22 -0
- package/src/primitives/slider/index.ts +2 -0
- package/src/primitives/textarea/TextareaGroup.stories.tsx +27 -0
- package/src/primitives/textarea/TextareaGroup.test.tsx +24 -0
- package/src/primitives/textarea/TextareaGroup.tsx +59 -0
- package/src/primitives/textarea/TextareaGroup.types.ts +10 -0
- package/src/primitives/textarea/index.ts +2 -0
- package/src/primitives/toggle/Toggle.stories.tsx +27 -0
- package/src/primitives/toggle/Toggle.test.tsx +31 -0
- package/src/primitives/toggle/Toggle.tsx +65 -0
- package/src/primitives/toggle/Toggle.types.ts +16 -0
- package/src/primitives/toggle/index.ts +2 -0
- package/src/primitives/tooltip/Tooltip.stories.tsx +45 -0
- package/src/primitives/tooltip/Tooltip.test.tsx +28 -0
- package/src/primitives/tooltip/Tooltip.tsx +94 -0
- package/src/primitives/tooltip/Tooltip.types.ts +16 -0
- package/src/primitives/tooltip/index.ts +2 -0
- package/src/productivity/comment-thread/CommentThread.stories.tsx +20 -0
- package/src/productivity/comment-thread/CommentThread.test.tsx +21 -0
- package/src/productivity/comment-thread/CommentThread.tsx +47 -0
- package/src/productivity/comment-thread/index.ts +2 -0
- package/src/productivity/kanban-board/KanbanBoard.tsx +41 -0
- package/src/productivity/kanban-board/index.ts +2 -0
- package/src/productivity/kanban-column/KanbanColumn.stories.tsx +131 -0
- package/src/productivity/kanban-column/KanbanColumn.test.tsx +18 -0
- package/src/productivity/kanban-column/KanbanColumn.tsx +58 -0
- package/src/productivity/kanban-column/SortableTaskCard.tsx +46 -0
- package/src/productivity/kanban-column/index.ts +4 -0
- package/src/productivity/priority-selector/PrioritySelector.stories.tsx +14 -0
- package/src/productivity/priority-selector/PrioritySelector.test.tsx +18 -0
- package/src/productivity/priority-selector/PrioritySelector.tsx +40 -0
- package/src/productivity/priority-selector/index.ts +2 -0
- package/src/productivity/rich-text-toolbar/RichTextToolbar.stories.tsx +19 -0
- package/src/productivity/rich-text-toolbar/RichTextToolbar.test.tsx +21 -0
- package/src/productivity/rich-text-toolbar/RichTextToolbar.tsx +50 -0
- package/src/productivity/rich-text-toolbar/index.ts +2 -0
- package/src/productivity/task-card/TaskCard.stories.tsx +20 -0
- package/src/productivity/task-card/TaskCard.test.tsx +21 -0
- package/src/productivity/task-card/TaskCard.tsx +76 -0
- package/src/productivity/task-card/index.ts +2 -0
- package/src/test-setup.ts +1 -0
- package/src/tokens/index.ts +1 -0
- package/src/tokens/tokens.ts +71 -0
- package/src/tokens/weave-theme.css +168 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Home, Settings, Users, Mail } from 'lucide-react';
|
|
3
|
+
import { SidebarNavItem } from './SidebarNavItem';
|
|
4
|
+
import { SidebarContext } from '../../layout/sidebar/Sidebar';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof SidebarNavItem> = {
|
|
7
|
+
title: 'Navigation/SidebarNavItem',
|
|
8
|
+
component: SidebarNavItem,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<SidebarContext.Provider value={{ collapsed: false }}>
|
|
13
|
+
<div className="w-60 bg-surface p-2">
|
|
14
|
+
<Story />
|
|
15
|
+
</div>
|
|
16
|
+
</SidebarContext.Provider>
|
|
17
|
+
),
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof SidebarNavItem>;
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
args: { icon: <Home size={20} />, label: 'Dashboard' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Active: Story = {
|
|
29
|
+
args: { icon: <Home size={20} />, label: 'Dashboard', active: true },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const Group: Story = {
|
|
33
|
+
render: () => (
|
|
34
|
+
<div className="flex flex-col gap-0.5">
|
|
35
|
+
<SidebarNavItem icon={<Home size={20} />} label="Dashboard" active />
|
|
36
|
+
<SidebarNavItem icon={<Mail size={20} />} label="Messages" />
|
|
37
|
+
<SidebarNavItem icon={<Users size={20} />} label="Team" />
|
|
38
|
+
<SidebarNavItem icon={<Settings size={20} />} label="Settings" />
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { SidebarNavItem } from './SidebarNavItem';
|
|
5
|
+
import { SidebarContext } from '../../layout/sidebar/Sidebar';
|
|
6
|
+
|
|
7
|
+
describe('SidebarNavItem', () => {
|
|
8
|
+
it('renders icon and label', () => {
|
|
9
|
+
render(
|
|
10
|
+
<SidebarContext.Provider value={{ collapsed: false }}>
|
|
11
|
+
<SidebarNavItem icon={<span>IC</span>} label="Dashboard" />
|
|
12
|
+
</SidebarContext.Provider>,
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText('IC')).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('marks active item with aria-current', () => {
|
|
19
|
+
render(
|
|
20
|
+
<SidebarContext.Provider value={{ collapsed: false }}>
|
|
21
|
+
<SidebarNavItem icon={<span>IC</span>} label="Dashboard" active />
|
|
22
|
+
</SidebarContext.Provider>,
|
|
23
|
+
);
|
|
24
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-current', 'page');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('calls onClick', async () => {
|
|
28
|
+
const onClick = vi.fn();
|
|
29
|
+
render(
|
|
30
|
+
<SidebarContext.Provider value={{ collapsed: false }}>
|
|
31
|
+
<SidebarNavItem icon={<span>IC</span>} label="Dashboard" onClick={onClick} />
|
|
32
|
+
</SidebarContext.Provider>,
|
|
33
|
+
);
|
|
34
|
+
await userEvent.click(screen.getByRole('button'));
|
|
35
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('hides label when collapsed', () => {
|
|
39
|
+
render(
|
|
40
|
+
<SidebarContext.Provider value={{ collapsed: true }}>
|
|
41
|
+
<SidebarNavItem icon={<span>IC</span>} label="Dashboard" />
|
|
42
|
+
</SidebarContext.Provider>,
|
|
43
|
+
);
|
|
44
|
+
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cn } from '../../utils/cn';
|
|
2
|
+
import { useSidebar } from '../../layout/sidebar/Sidebar';
|
|
3
|
+
import type { SidebarNavItemProps } from './SidebarNavItem.types';
|
|
4
|
+
|
|
5
|
+
export function SidebarNavItem({
|
|
6
|
+
icon,
|
|
7
|
+
label,
|
|
8
|
+
active = false,
|
|
9
|
+
onClick,
|
|
10
|
+
className,
|
|
11
|
+
}: SidebarNavItemProps) {
|
|
12
|
+
const { collapsed } = useSidebar();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
onClick={onClick}
|
|
18
|
+
aria-current={active ? 'page' : undefined}
|
|
19
|
+
title={collapsed ? label : undefined}
|
|
20
|
+
className={cn(
|
|
21
|
+
'flex items-center w-full rounded-md transition-colors',
|
|
22
|
+
collapsed ? 'justify-center px-2 py-2' : 'gap-3 px-3 py-2',
|
|
23
|
+
active
|
|
24
|
+
? 'bg-rust-light text-rust-dark font-medium'
|
|
25
|
+
: 'text-text-secondary hover:bg-border/30',
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
style={{
|
|
29
|
+
fontSize: 'var(--text-body-sm)',
|
|
30
|
+
lineHeight: 'var(--leading-body-sm)',
|
|
31
|
+
transitionDuration: 'var(--duration-micro)',
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<span className="shrink-0">{icon}</span>
|
|
35
|
+
{!collapsed && <span>{label}</span>}
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface SidebarNavItemProps {
|
|
2
|
+
/** Icon element */
|
|
3
|
+
icon: React.ReactNode;
|
|
4
|
+
/** Label text */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Whether this item is currently active */
|
|
7
|
+
active?: boolean;
|
|
8
|
+
/** Click handler */
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
/** Additional CSS classes */
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Tabs } from './Tabs';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Tabs> = {
|
|
5
|
+
title: 'Navigation/Tabs',
|
|
6
|
+
component: Tabs,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof Tabs>;
|
|
12
|
+
|
|
13
|
+
export const Underline: Story = {
|
|
14
|
+
render: () => (
|
|
15
|
+
<Tabs defaultValue="overview" variant="underline">
|
|
16
|
+
<Tabs.List>
|
|
17
|
+
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
|
18
|
+
<Tabs.Trigger value="tasks">Tasks</Tabs.Trigger>
|
|
19
|
+
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
|
20
|
+
</Tabs.List>
|
|
21
|
+
<Tabs.Panel value="overview">
|
|
22
|
+
<p className="text-text-primary" style={{ fontSize: 'var(--text-body)' }}>Overview content goes here.</p>
|
|
23
|
+
</Tabs.Panel>
|
|
24
|
+
<Tabs.Panel value="tasks">
|
|
25
|
+
<p className="text-text-primary" style={{ fontSize: 'var(--text-body)' }}>Tasks content goes here.</p>
|
|
26
|
+
</Tabs.Panel>
|
|
27
|
+
<Tabs.Panel value="activity">
|
|
28
|
+
<p className="text-text-primary" style={{ fontSize: 'var(--text-body)' }}>Activity content goes here.</p>
|
|
29
|
+
</Tabs.Panel>
|
|
30
|
+
</Tabs>
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const Pill: Story = {
|
|
35
|
+
render: () => (
|
|
36
|
+
<Tabs defaultValue="all" variant="pill">
|
|
37
|
+
<Tabs.List>
|
|
38
|
+
<Tabs.Trigger value="all">All</Tabs.Trigger>
|
|
39
|
+
<Tabs.Trigger value="active">Active</Tabs.Trigger>
|
|
40
|
+
<Tabs.Trigger value="completed">Completed</Tabs.Trigger>
|
|
41
|
+
</Tabs.List>
|
|
42
|
+
<Tabs.Panel value="all">All items</Tabs.Panel>
|
|
43
|
+
<Tabs.Panel value="active">Active items</Tabs.Panel>
|
|
44
|
+
<Tabs.Panel value="completed">Completed items</Tabs.Panel>
|
|
45
|
+
</Tabs>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { Tabs } from './Tabs';
|
|
5
|
+
|
|
6
|
+
describe('Tabs', () => {
|
|
7
|
+
it('renders tabs and shows active panel', () => {
|
|
8
|
+
render(
|
|
9
|
+
<Tabs defaultValue="a">
|
|
10
|
+
<Tabs.List>
|
|
11
|
+
<Tabs.Trigger value="a">Tab A</Tabs.Trigger>
|
|
12
|
+
<Tabs.Trigger value="b">Tab B</Tabs.Trigger>
|
|
13
|
+
</Tabs.List>
|
|
14
|
+
<Tabs.Panel value="a">Content A</Tabs.Panel>
|
|
15
|
+
<Tabs.Panel value="b">Content B</Tabs.Panel>
|
|
16
|
+
</Tabs>,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText('Content A')).toBeInTheDocument();
|
|
19
|
+
expect(screen.queryByText('Content B')).not.toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('switches panels on click', async () => {
|
|
23
|
+
render(
|
|
24
|
+
<Tabs defaultValue="a">
|
|
25
|
+
<Tabs.List>
|
|
26
|
+
<Tabs.Trigger value="a">Tab A</Tabs.Trigger>
|
|
27
|
+
<Tabs.Trigger value="b">Tab B</Tabs.Trigger>
|
|
28
|
+
</Tabs.List>
|
|
29
|
+
<Tabs.Panel value="a">Content A</Tabs.Panel>
|
|
30
|
+
<Tabs.Panel value="b">Content B</Tabs.Panel>
|
|
31
|
+
</Tabs>,
|
|
32
|
+
);
|
|
33
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Tab B' }));
|
|
34
|
+
expect(screen.getByText('Content B')).toBeInTheDocument();
|
|
35
|
+
expect(screen.queryByText('Content A')).not.toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('calls onValueChange', async () => {
|
|
39
|
+
const onChange = vi.fn();
|
|
40
|
+
render(
|
|
41
|
+
<Tabs defaultValue="a" onValueChange={onChange}>
|
|
42
|
+
<Tabs.List>
|
|
43
|
+
<Tabs.Trigger value="a">Tab A</Tabs.Trigger>
|
|
44
|
+
<Tabs.Trigger value="b">Tab B</Tabs.Trigger>
|
|
45
|
+
</Tabs.List>
|
|
46
|
+
<Tabs.Panel value="a">A</Tabs.Panel>
|
|
47
|
+
<Tabs.Panel value="b">B</Tabs.Panel>
|
|
48
|
+
</Tabs>,
|
|
49
|
+
);
|
|
50
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Tab B' }));
|
|
51
|
+
expect(onChange).toHaveBeenCalledWith('b');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sets aria-selected on active trigger', () => {
|
|
55
|
+
render(
|
|
56
|
+
<Tabs defaultValue="a">
|
|
57
|
+
<Tabs.List>
|
|
58
|
+
<Tabs.Trigger value="a">Tab A</Tabs.Trigger>
|
|
59
|
+
<Tabs.Trigger value="b">Tab B</Tabs.Trigger>
|
|
60
|
+
</Tabs.List>
|
|
61
|
+
<Tabs.Panel value="a">A</Tabs.Panel>
|
|
62
|
+
</Tabs>,
|
|
63
|
+
);
|
|
64
|
+
expect(screen.getByRole('tab', { name: 'Tab A' })).toHaveAttribute('aria-selected', 'true');
|
|
65
|
+
expect(screen.getByRole('tab', { name: 'Tab B' })).toHaveAttribute('aria-selected', 'false');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { cn } from '../../utils/cn';
|
|
3
|
+
import { useControllableState } from '../../hooks/use-controllable-state';
|
|
4
|
+
import type { TabsProps, TabsListProps, TabsTriggerProps, TabsPanelProps, TabsVariant } from './Tabs.types';
|
|
5
|
+
|
|
6
|
+
interface TabsContextValue {
|
|
7
|
+
value: string;
|
|
8
|
+
onValueChange: (value: string) => void;
|
|
9
|
+
variant: TabsVariant;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TabsContext = createContext<TabsContextValue>({
|
|
13
|
+
value: '',
|
|
14
|
+
onValueChange: () => {},
|
|
15
|
+
variant: 'underline',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function TabsRoot({
|
|
19
|
+
value: controlledValue,
|
|
20
|
+
defaultValue = '',
|
|
21
|
+
onValueChange,
|
|
22
|
+
variant = 'underline',
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
}: TabsProps) {
|
|
26
|
+
const [value, setValue] = useControllableState({
|
|
27
|
+
value: controlledValue,
|
|
28
|
+
defaultValue,
|
|
29
|
+
onChange: onValueChange,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<TabsContext.Provider value={{ value, onValueChange: setValue, variant }}>
|
|
34
|
+
<div className={className}>{children}</div>
|
|
35
|
+
</TabsContext.Provider>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TabsList({ children, className }: TabsListProps) {
|
|
40
|
+
const { variant } = useContext(TabsContext);
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
role="tablist"
|
|
44
|
+
className={cn(
|
|
45
|
+
'flex gap-1',
|
|
46
|
+
variant === 'underline' && 'border-b border-border',
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function TabsTrigger({ value, children, className, disabled }: TabsTriggerProps) {
|
|
56
|
+
const { value: activeValue, onValueChange, variant } = useContext(TabsContext);
|
|
57
|
+
const isActive = activeValue === value;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
role="tab"
|
|
63
|
+
aria-selected={isActive}
|
|
64
|
+
aria-controls={`panel-${value}`}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
onClick={() => onValueChange(value)}
|
|
67
|
+
className={cn(
|
|
68
|
+
'transition-colors font-medium',
|
|
69
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rust',
|
|
70
|
+
variant === 'underline' && [
|
|
71
|
+
'px-3 py-2 -mb-px border-b-2',
|
|
72
|
+
isActive ? 'border-rust text-rust' : 'border-transparent text-text-secondary hover:text-text-primary',
|
|
73
|
+
],
|
|
74
|
+
variant === 'pill' && [
|
|
75
|
+
'px-3 py-1.5 rounded-full',
|
|
76
|
+
isActive ? 'bg-rust-light text-rust-dark' : 'text-text-secondary hover:bg-border/30',
|
|
77
|
+
],
|
|
78
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
style={{
|
|
82
|
+
fontSize: 'var(--text-body-sm)',
|
|
83
|
+
lineHeight: 'var(--leading-body-sm)',
|
|
84
|
+
transitionDuration: 'var(--duration-micro)',
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{children}
|
|
88
|
+
</button>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function TabsPanel({ value, children, className }: TabsPanelProps) {
|
|
93
|
+
const { value: activeValue } = useContext(TabsContext);
|
|
94
|
+
if (activeValue !== value) return null;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
role="tabpanel"
|
|
99
|
+
id={`panel-${value}`}
|
|
100
|
+
className={cn('py-4', className)}
|
|
101
|
+
>
|
|
102
|
+
{children}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const Tabs = Object.assign(TabsRoot, {
|
|
108
|
+
List: TabsList,
|
|
109
|
+
Trigger: TabsTrigger,
|
|
110
|
+
Panel: TabsPanel,
|
|
111
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type TabsVariant = 'underline' | 'pill';
|
|
2
|
+
|
|
3
|
+
export interface TabsProps {
|
|
4
|
+
/** Controlled active tab value */
|
|
5
|
+
value?: string;
|
|
6
|
+
/** Default active tab value */
|
|
7
|
+
defaultValue?: string;
|
|
8
|
+
/** Called when active tab changes */
|
|
9
|
+
onValueChange?: (value: string) => void;
|
|
10
|
+
/** Visual variant */
|
|
11
|
+
variant?: TabsVariant;
|
|
12
|
+
/** Tab content */
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
/** Additional CSS classes */
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TabsListProps {
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TabsTriggerProps {
|
|
24
|
+
/** Unique value identifying this tab */
|
|
25
|
+
value: string;
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
className?: string;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TabsPanelProps {
|
|
32
|
+
/** Value matching the corresponding trigger */
|
|
33
|
+
value: string;
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Accordion } from './Accordion';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Accordion> = { title: 'Patterns/Accordion', component: Accordion, tags: ['autodocs'] };
|
|
5
|
+
export default meta;
|
|
6
|
+
type Story = StoryObj<typeof Accordion>;
|
|
7
|
+
|
|
8
|
+
export const Default: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<Accordion className="max-w-md">
|
|
11
|
+
<Accordion.Item value="1">
|
|
12
|
+
<Accordion.Trigger>What is Weave?</Accordion.Trigger>
|
|
13
|
+
<Accordion.Content>Weave is a human-centered design system for productivity software.</Accordion.Content>
|
|
14
|
+
</Accordion.Item>
|
|
15
|
+
<Accordion.Item value="2">
|
|
16
|
+
<Accordion.Trigger>How do I get started?</Accordion.Trigger>
|
|
17
|
+
<Accordion.Content>Install the package and import the components you need.</Accordion.Content>
|
|
18
|
+
</Accordion.Item>
|
|
19
|
+
<Accordion.Item value="3">
|
|
20
|
+
<Accordion.Trigger>Is it accessible?</Accordion.Trigger>
|
|
21
|
+
<Accordion.Content>Yes, all components target WCAG 2.1 AA compliance.</Accordion.Content>
|
|
22
|
+
</Accordion.Item>
|
|
23
|
+
</Accordion>
|
|
24
|
+
),
|
|
25
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { Accordion } from './Accordion';
|
|
5
|
+
|
|
6
|
+
describe('Accordion', () => {
|
|
7
|
+
it('starts collapsed', () => {
|
|
8
|
+
render(
|
|
9
|
+
<Accordion>
|
|
10
|
+
<Accordion.Item value="1">
|
|
11
|
+
<Accordion.Trigger>Question</Accordion.Trigger>
|
|
12
|
+
<Accordion.Content>Answer</Accordion.Content>
|
|
13
|
+
</Accordion.Item>
|
|
14
|
+
</Accordion>,
|
|
15
|
+
);
|
|
16
|
+
expect(screen.getByText('Question')).toHaveAttribute('aria-expanded', 'false');
|
|
17
|
+
});
|
|
18
|
+
it('expands on click', async () => {
|
|
19
|
+
render(
|
|
20
|
+
<Accordion>
|
|
21
|
+
<Accordion.Item value="1">
|
|
22
|
+
<Accordion.Trigger>Question</Accordion.Trigger>
|
|
23
|
+
<Accordion.Content>Answer</Accordion.Content>
|
|
24
|
+
</Accordion.Item>
|
|
25
|
+
</Accordion>,
|
|
26
|
+
);
|
|
27
|
+
await userEvent.click(screen.getByText('Question'));
|
|
28
|
+
expect(screen.getByText('Question')).toHaveAttribute('aria-expanded', 'true');
|
|
29
|
+
expect(screen.getByText('Answer')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
it('collapses on second click', async () => {
|
|
32
|
+
render(
|
|
33
|
+
<Accordion>
|
|
34
|
+
<Accordion.Item value="1">
|
|
35
|
+
<Accordion.Trigger>Question</Accordion.Trigger>
|
|
36
|
+
<Accordion.Content>Answer</Accordion.Content>
|
|
37
|
+
</Accordion.Item>
|
|
38
|
+
</Accordion>,
|
|
39
|
+
);
|
|
40
|
+
await userEvent.click(screen.getByText('Question'));
|
|
41
|
+
await userEvent.click(screen.getByText('Question'));
|
|
42
|
+
expect(screen.getByText('Question')).toHaveAttribute('aria-expanded', 'false');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { ChevronDown } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../utils/cn';
|
|
4
|
+
|
|
5
|
+
export interface AccordionProps { children: React.ReactNode; className?: string; }
|
|
6
|
+
export interface AccordionItemProps { value: string; children: React.ReactNode; className?: string; }
|
|
7
|
+
export interface AccordionTriggerProps { children: React.ReactNode; className?: string; }
|
|
8
|
+
export interface AccordionContentProps { children: React.ReactNode; className?: string; }
|
|
9
|
+
|
|
10
|
+
const AccordionContext = createContext<{ openItems: Set<string>; toggle: (v: string) => void }>({ openItems: new Set(), toggle: () => {} });
|
|
11
|
+
const ItemContext = createContext<{ value: string; isOpen: boolean }>({ value: '', isOpen: false });
|
|
12
|
+
|
|
13
|
+
function AccordionRoot({ children, className }: AccordionProps) {
|
|
14
|
+
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
|
|
15
|
+
const toggle = (value: string) => {
|
|
16
|
+
setOpenItems((prev) => {
|
|
17
|
+
const next = new Set(prev);
|
|
18
|
+
next.has(value) ? next.delete(value) : next.add(value);
|
|
19
|
+
return next;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
return (
|
|
23
|
+
<AccordionContext.Provider value={{ openItems, toggle }}>
|
|
24
|
+
<div className={cn('divide-y divide-border', className)}>{children}</div>
|
|
25
|
+
</AccordionContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function AccordionItem({ value, children, className }: AccordionItemProps) {
|
|
30
|
+
const { openItems } = useContext(AccordionContext);
|
|
31
|
+
return (
|
|
32
|
+
<ItemContext.Provider value={{ value, isOpen: openItems.has(value) }}>
|
|
33
|
+
<div className={className}>{children}</div>
|
|
34
|
+
</ItemContext.Provider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function AccordionTrigger({ children, className }: AccordionTriggerProps) {
|
|
39
|
+
const { toggle } = useContext(AccordionContext);
|
|
40
|
+
const { value, isOpen } = useContext(ItemContext);
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={() => toggle(value)}
|
|
45
|
+
aria-expanded={isOpen}
|
|
46
|
+
className={cn('flex w-full items-center justify-between py-4 text-text-primary font-medium transition-colors hover:text-rust', className)}
|
|
47
|
+
style={{ fontSize: 'var(--text-body)', transitionDuration: 'var(--duration-micro)' }}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
<ChevronDown
|
|
51
|
+
size={16}
|
|
52
|
+
className={cn('text-text-secondary transition-transform', isOpen && 'rotate-180')}
|
|
53
|
+
style={{ transitionDuration: 'var(--duration-fast)' }}
|
|
54
|
+
/>
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function AccordionContent({ children, className }: AccordionContentProps) {
|
|
60
|
+
const { isOpen } = useContext(ItemContext);
|
|
61
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const [height, setHeight] = useState(0);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (contentRef.current) {
|
|
66
|
+
setHeight(contentRef.current.scrollHeight);
|
|
67
|
+
}
|
|
68
|
+
}, [children, isOpen]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className="overflow-hidden transition-all"
|
|
73
|
+
style={{
|
|
74
|
+
height: isOpen ? height : 0,
|
|
75
|
+
opacity: isOpen ? 1 : 0,
|
|
76
|
+
transitionDuration: 'var(--duration-normal)',
|
|
77
|
+
transitionTimingFunction: isOpen ? 'var(--ease-out)' : 'var(--ease-in)',
|
|
78
|
+
}}
|
|
79
|
+
aria-hidden={!isOpen}
|
|
80
|
+
>
|
|
81
|
+
<div ref={contentRef} className={cn('pb-4 text-text-secondary', className)} style={{ fontSize: 'var(--text-body)' }}>
|
|
82
|
+
{children}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const Accordion = Object.assign(AccordionRoot, {
|
|
89
|
+
Item: AccordionItem,
|
|
90
|
+
Trigger: AccordionTrigger,
|
|
91
|
+
Content: AccordionContent,
|
|
92
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Edit, Copy, Trash2 } from 'lucide-react';
|
|
3
|
+
import { ActionMenu } from './ActionMenu';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ActionMenu> = { title: 'Patterns/ActionMenu', component: ActionMenu, tags: ['autodocs'] };
|
|
6
|
+
export default meta;
|
|
7
|
+
type Story = StoryObj<typeof ActionMenu>;
|
|
8
|
+
|
|
9
|
+
export const Default: Story = {
|
|
10
|
+
args: {
|
|
11
|
+
items: [
|
|
12
|
+
{ label: 'Edit', icon: <Edit size={16} /> },
|
|
13
|
+
{ label: 'Duplicate', icon: <Copy size={16} /> },
|
|
14
|
+
{ divider: true, label: '' },
|
|
15
|
+
{ label: 'Delete', icon: <Trash2 size={16} />, destructive: true },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { ActionMenu } from './ActionMenu';
|
|
5
|
+
|
|
6
|
+
describe('ActionMenu', () => {
|
|
7
|
+
it('renders items', () => {
|
|
8
|
+
render(<ActionMenu items={[{ label: 'Edit' }, { label: 'Delete', destructive: true }]} />);
|
|
9
|
+
expect(screen.getByText('Edit')).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByText('Delete')).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
it('calls onClick', async () => {
|
|
13
|
+
const onClick = vi.fn();
|
|
14
|
+
render(<ActionMenu items={[{ label: 'Edit', onClick }]} />);
|
|
15
|
+
await userEvent.click(screen.getByText('Edit'));
|
|
16
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cn } from '../../utils/cn';
|
|
2
|
+
|
|
3
|
+
export interface ActionMenuItem {
|
|
4
|
+
label: string;
|
|
5
|
+
icon?: React.ReactNode;
|
|
6
|
+
onClick?: () => void;
|
|
7
|
+
destructive?: boolean;
|
|
8
|
+
divider?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ActionMenuProps {
|
|
12
|
+
items: ActionMenuItem[];
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ActionMenu({ items, className }: ActionMenuProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div className={cn('w-56 rounded-lg bg-white border border-border shadow-2 py-1 overflow-hidden', className)} role="menu">
|
|
19
|
+
{items.map((item, i) =>
|
|
20
|
+
item.divider ? (
|
|
21
|
+
<div key={i} className="h-px bg-border my-1" role="separator" />
|
|
22
|
+
) : (
|
|
23
|
+
<button
|
|
24
|
+
key={i}
|
|
25
|
+
type="button"
|
|
26
|
+
role="menuitem"
|
|
27
|
+
onClick={item.onClick}
|
|
28
|
+
className={cn(
|
|
29
|
+
'flex items-center gap-3 w-full px-3 py-2 transition-colors text-left',
|
|
30
|
+
item.destructive ? 'text-error hover:bg-error-light' : 'text-text-primary hover:bg-surface',
|
|
31
|
+
)}
|
|
32
|
+
style={{ fontSize: 'var(--text-body-sm)', transitionDuration: 'var(--duration-micro)' }}
|
|
33
|
+
>
|
|
34
|
+
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
|
35
|
+
{item.label}
|
|
36
|
+
</button>
|
|
37
|
+
),
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|