@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,43 @@
|
|
|
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 { CommandPalette } from './CommandPalette';
|
|
5
|
+
|
|
6
|
+
const items = [
|
|
7
|
+
{ id: '1', label: 'Dashboard', section: 'Nav', onSelect: vi.fn() },
|
|
8
|
+
{ id: '2', label: 'Settings', section: 'Nav', onSelect: vi.fn() },
|
|
9
|
+
{ id: '3', label: 'Create task', section: 'Actions', onSelect: vi.fn() },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe('CommandPalette', () => {
|
|
13
|
+
it('renders when open', () => {
|
|
14
|
+
render(<CommandPalette open items={items} onOpenChange={() => {}} />);
|
|
15
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('does not render when closed', () => {
|
|
20
|
+
render(<CommandPalette open={false} items={items} onOpenChange={() => {}} />);
|
|
21
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('filters items by query', async () => {
|
|
25
|
+
render(<CommandPalette open items={items} onOpenChange={() => {}} />);
|
|
26
|
+
await userEvent.type(screen.getByPlaceholderText('Type a command or search...'), 'dash');
|
|
27
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
28
|
+
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('selects item on click', async () => {
|
|
32
|
+
render(<CommandPalette open items={items} onOpenChange={() => {}} />);
|
|
33
|
+
await userEvent.click(screen.getByText('Create task'));
|
|
34
|
+
expect(items[2].onSelect).toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('closes on Escape', async () => {
|
|
38
|
+
const onOpenChange = vi.fn();
|
|
39
|
+
render(<CommandPalette open items={items} onOpenChange={onOpenChange} />);
|
|
40
|
+
await userEvent.keyboard('{Escape}');
|
|
41
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { Search } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../utils/cn';
|
|
4
|
+
import { useFocusTrap } from '../../hooks/use-focus-trap';
|
|
5
|
+
import type { CommandPaletteProps, CommandItem } from './CommandPalette.types';
|
|
6
|
+
|
|
7
|
+
export function CommandPalette({
|
|
8
|
+
open,
|
|
9
|
+
onOpenChange,
|
|
10
|
+
items,
|
|
11
|
+
placeholder = 'Type a command or search...',
|
|
12
|
+
}: CommandPaletteProps) {
|
|
13
|
+
const [query, setQuery] = useState('');
|
|
14
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
15
|
+
const containerRef = useFocusTrap<HTMLDivElement>(open);
|
|
16
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
|
|
18
|
+
const filtered = query
|
|
19
|
+
? items.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()))
|
|
20
|
+
: items;
|
|
21
|
+
|
|
22
|
+
// Group by section
|
|
23
|
+
const sections = filtered.reduce<Record<string, CommandItem[]>>((acc, item) => {
|
|
24
|
+
const section = item.section ?? 'Commands';
|
|
25
|
+
if (!acc[section]) acc[section] = [];
|
|
26
|
+
acc[section].push(item);
|
|
27
|
+
return acc;
|
|
28
|
+
}, {});
|
|
29
|
+
|
|
30
|
+
const flatItems = filtered;
|
|
31
|
+
|
|
32
|
+
const handleSelect = useCallback(
|
|
33
|
+
(item: CommandItem) => {
|
|
34
|
+
item.onSelect();
|
|
35
|
+
onOpenChange(false);
|
|
36
|
+
setQuery('');
|
|
37
|
+
},
|
|
38
|
+
[onOpenChange],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Keyboard handling
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) return;
|
|
44
|
+
const handler = (e: KeyboardEvent) => {
|
|
45
|
+
if (e.key === 'Escape') {
|
|
46
|
+
onOpenChange(false);
|
|
47
|
+
setQuery('');
|
|
48
|
+
} else if (e.key === 'ArrowDown') {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
setActiveIndex((i) => Math.min(i + 1, flatItems.length - 1));
|
|
51
|
+
} else if (e.key === 'ArrowUp') {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
54
|
+
} else if (e.key === 'Enter' && flatItems[activeIndex]) {
|
|
55
|
+
handleSelect(flatItems[activeIndex]);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener('keydown', handler);
|
|
59
|
+
return () => document.removeEventListener('keydown', handler);
|
|
60
|
+
}, [open, activeIndex, flatItems, handleSelect, onOpenChange]);
|
|
61
|
+
|
|
62
|
+
// Reset on open
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (open) {
|
|
65
|
+
setQuery('');
|
|
66
|
+
setActiveIndex(0);
|
|
67
|
+
}
|
|
68
|
+
}, [open]);
|
|
69
|
+
|
|
70
|
+
// Scroll active item into view
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const el = listRef.current?.querySelector('[data-active="true"]');
|
|
73
|
+
if (el && typeof el.scrollIntoView === 'function') {
|
|
74
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
75
|
+
}
|
|
76
|
+
}, [activeIndex]);
|
|
77
|
+
|
|
78
|
+
// Global Cmd+K listener
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const handler = (e: KeyboardEvent) => {
|
|
81
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
onOpenChange(!open);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
document.addEventListener('keydown', handler);
|
|
87
|
+
return () => document.removeEventListener('keydown', handler);
|
|
88
|
+
}, [open, onOpenChange]);
|
|
89
|
+
|
|
90
|
+
if (!open) return null;
|
|
91
|
+
|
|
92
|
+
let itemIndex = 0;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
|
96
|
+
{/* Backdrop */}
|
|
97
|
+
<div
|
|
98
|
+
className="absolute inset-0 bg-text-primary/30 animate-fade-in"
|
|
99
|
+
onClick={() => onOpenChange(false)}
|
|
100
|
+
/>
|
|
101
|
+
{/* Palette */}
|
|
102
|
+
<div
|
|
103
|
+
ref={containerRef}
|
|
104
|
+
className="relative w-full max-w-lg bg-white rounded-xl shadow-3 border border-border overflow-hidden animate-scale-in"
|
|
105
|
+
role="dialog"
|
|
106
|
+
aria-label="Command palette"
|
|
107
|
+
>
|
|
108
|
+
{/* Search */}
|
|
109
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
|
110
|
+
<Search size={18} className="text-text-secondary shrink-0" />
|
|
111
|
+
<input
|
|
112
|
+
type="text"
|
|
113
|
+
value={query}
|
|
114
|
+
onChange={(e) => {
|
|
115
|
+
setQuery(e.target.value);
|
|
116
|
+
setActiveIndex(0);
|
|
117
|
+
}}
|
|
118
|
+
placeholder={placeholder}
|
|
119
|
+
className="flex-1 bg-transparent outline-none text-text-primary placeholder:text-text-secondary"
|
|
120
|
+
style={{ fontSize: 'var(--text-body)' }}
|
|
121
|
+
autoFocus
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Results */}
|
|
126
|
+
<div ref={listRef} className="max-h-72 overflow-y-auto py-2" role="listbox">
|
|
127
|
+
{Object.entries(sections).map(([section, sectionItems]) => (
|
|
128
|
+
<div key={section}>
|
|
129
|
+
<div
|
|
130
|
+
className="px-4 py-1.5 text-text-secondary font-medium uppercase tracking-wider"
|
|
131
|
+
style={{ fontSize: 'var(--text-caption)' }}
|
|
132
|
+
>
|
|
133
|
+
{section}
|
|
134
|
+
</div>
|
|
135
|
+
{sectionItems.map((item) => {
|
|
136
|
+
const idx = itemIndex++;
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
key={item.id}
|
|
140
|
+
role="option"
|
|
141
|
+
aria-selected={idx === activeIndex}
|
|
142
|
+
data-active={idx === activeIndex}
|
|
143
|
+
className={cn(
|
|
144
|
+
'flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors',
|
|
145
|
+
idx === activeIndex ? 'bg-surface' : 'hover:bg-surface/50',
|
|
146
|
+
)}
|
|
147
|
+
style={{ transitionDuration: 'var(--duration-micro)' }}
|
|
148
|
+
onClick={() => handleSelect(item)}
|
|
149
|
+
onMouseEnter={() => setActiveIndex(idx)}
|
|
150
|
+
>
|
|
151
|
+
{item.icon && <span className="text-text-secondary shrink-0">{item.icon}</span>}
|
|
152
|
+
<span
|
|
153
|
+
className="text-text-primary"
|
|
154
|
+
style={{ fontSize: 'var(--text-body-sm)' }}
|
|
155
|
+
>
|
|
156
|
+
{item.label}
|
|
157
|
+
</span>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
))}
|
|
163
|
+
{flatItems.length === 0 && (
|
|
164
|
+
<div
|
|
165
|
+
className="px-4 py-6 text-center text-text-secondary"
|
|
166
|
+
style={{ fontSize: 'var(--text-body-sm)' }}
|
|
167
|
+
>
|
|
168
|
+
No results found
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{/* Footer hints */}
|
|
174
|
+
<div className="flex items-center gap-4 px-4 py-2 border-t border-border">
|
|
175
|
+
<span className="text-text-secondary flex items-center gap-1" style={{ fontSize: 'var(--text-caption)' }}>
|
|
176
|
+
<kbd className="px-1 py-0.5 bg-surface rounded" style={{ fontSize: 'var(--text-caption)' }}>↑↓</kbd> Navigate
|
|
177
|
+
</span>
|
|
178
|
+
<span className="text-text-secondary flex items-center gap-1" style={{ fontSize: 'var(--text-caption)' }}>
|
|
179
|
+
<kbd className="px-1 py-0.5 bg-surface rounded" style={{ fontSize: 'var(--text-caption)' }}>↵</kbd> Select
|
|
180
|
+
</span>
|
|
181
|
+
<span className="text-text-secondary flex items-center gap-1" style={{ fontSize: 'var(--text-caption)' }}>
|
|
182
|
+
<kbd className="px-1 py-0.5 bg-surface rounded" style={{ fontSize: 'var(--text-caption)' }}>Esc</kbd> Close
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface CommandItem {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
icon?: React.ReactNode;
|
|
5
|
+
section?: string;
|
|
6
|
+
onSelect: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CommandPaletteProps {
|
|
10
|
+
/** Whether the palette is open */
|
|
11
|
+
open: boolean;
|
|
12
|
+
/** Called when open state changes */
|
|
13
|
+
onOpenChange: (open: boolean) => void;
|
|
14
|
+
/** Available command items */
|
|
15
|
+
items: CommandItem[];
|
|
16
|
+
/** Placeholder text for the search input */
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { Home, Settings, Users, Mail, Calendar } from 'lucide-react';
|
|
4
|
+
import { Sidebar } from './Sidebar';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Sidebar> = {
|
|
7
|
+
title: 'Layout/Sidebar',
|
|
8
|
+
component: Sidebar,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="h-[500px] flex">
|
|
13
|
+
<Story />
|
|
14
|
+
<div className="flex-1 p-6 bg-bg">
|
|
15
|
+
<p className="text-text-secondary" style={{ fontSize: 'var(--text-body)' }}>
|
|
16
|
+
Main content area
|
|
17
|
+
</p>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
),
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default meta;
|
|
25
|
+
type Story = StoryObj<typeof Sidebar>;
|
|
26
|
+
|
|
27
|
+
function NavItem({ icon: Icon, label, active }: { icon: React.ElementType; label: string; active?: boolean }) {
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
className={`flex items-center gap-3 w-full px-4 py-2 text-left transition-colors rounded-md mx-auto ${
|
|
31
|
+
active ? 'bg-rust-light text-rust-dark font-medium' : 'text-text-secondary hover:bg-border/30'
|
|
32
|
+
}`}
|
|
33
|
+
style={{ fontSize: 'var(--text-body-sm)', transitionDuration: 'var(--duration-micro)' }}
|
|
34
|
+
>
|
|
35
|
+
<Icon size={20} className="shrink-0" />
|
|
36
|
+
<span>{label}</span>
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {
|
|
42
|
+
render: () => {
|
|
43
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
44
|
+
return (
|
|
45
|
+
<Sidebar
|
|
46
|
+
collapsed={collapsed}
|
|
47
|
+
onCollapsedChange={setCollapsed}
|
|
48
|
+
header={<span className="font-bold text-text-primary" style={{ fontSize: 'var(--text-body-lg)' }}>Weave</span>}
|
|
49
|
+
>
|
|
50
|
+
<div className="flex flex-col gap-0.5 px-2">
|
|
51
|
+
<NavItem icon={Home} label="Dashboard" active />
|
|
52
|
+
<NavItem icon={Mail} label="Messages" />
|
|
53
|
+
<NavItem icon={Users} label="Team" />
|
|
54
|
+
<NavItem icon={Calendar} label="Calendar" />
|
|
55
|
+
<NavItem icon={Settings} label="Settings" />
|
|
56
|
+
</div>
|
|
57
|
+
</Sidebar>
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
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 { Sidebar } from './Sidebar';
|
|
5
|
+
|
|
6
|
+
describe('Sidebar', () => {
|
|
7
|
+
it('renders children', () => {
|
|
8
|
+
render(<Sidebar>Nav items</Sidebar>);
|
|
9
|
+
expect(screen.getByText('Nav items')).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('renders header', () => {
|
|
13
|
+
render(<Sidebar header={<span>Logo</span>}>Nav</Sidebar>);
|
|
14
|
+
expect(screen.getByText('Logo')).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('toggles collapse on button click', async () => {
|
|
18
|
+
const onCollapsedChange = vi.fn();
|
|
19
|
+
render(
|
|
20
|
+
<Sidebar collapsed={false} onCollapsedChange={onCollapsedChange}>
|
|
21
|
+
Nav
|
|
22
|
+
</Sidebar>,
|
|
23
|
+
);
|
|
24
|
+
await userEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
|
|
25
|
+
expect(onCollapsedChange).toHaveBeenCalledWith(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../utils/cn';
|
|
4
|
+
import type { SidebarProps } from './Sidebar.types';
|
|
5
|
+
|
|
6
|
+
export const SidebarContext = createContext<{ collapsed: boolean }>({ collapsed: false });
|
|
7
|
+
export const useSidebar = () => useContext(SidebarContext);
|
|
8
|
+
|
|
9
|
+
export function Sidebar({
|
|
10
|
+
collapsed = false,
|
|
11
|
+
onCollapsedChange,
|
|
12
|
+
header,
|
|
13
|
+
children,
|
|
14
|
+
footer,
|
|
15
|
+
className,
|
|
16
|
+
}: SidebarProps) {
|
|
17
|
+
return (
|
|
18
|
+
<SidebarContext.Provider value={{ collapsed }}>
|
|
19
|
+
<aside
|
|
20
|
+
className={cn(
|
|
21
|
+
'flex flex-col h-full bg-surface border-r border-border transition-all overflow-hidden',
|
|
22
|
+
collapsed ? 'w-14' : 'w-60',
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
style={{ transitionDuration: 'var(--duration-normal)' }}
|
|
26
|
+
>
|
|
27
|
+
{/* Header */}
|
|
28
|
+
<div className={cn('flex items-center border-b border-border', collapsed ? 'px-2 py-3 justify-center' : 'px-4 py-3')}>
|
|
29
|
+
{header && (
|
|
30
|
+
<div className={cn('flex-1 overflow-hidden', collapsed && 'sr-only')}>{header}</div>
|
|
31
|
+
)}
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => onCollapsedChange?.(!collapsed)}
|
|
35
|
+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
36
|
+
className="p-1.5 rounded-md text-text-secondary hover:bg-border/50 transition-colors shrink-0"
|
|
37
|
+
style={{ transitionDuration: 'var(--duration-micro)' }}
|
|
38
|
+
>
|
|
39
|
+
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
{/* Navigation */}
|
|
44
|
+
<nav className="flex-1 overflow-y-auto py-2">
|
|
45
|
+
{children}
|
|
46
|
+
</nav>
|
|
47
|
+
|
|
48
|
+
{/* Footer */}
|
|
49
|
+
{footer && (
|
|
50
|
+
<div className={cn('border-t border-border', collapsed ? 'px-2 py-3' : 'px-4 py-3')}>
|
|
51
|
+
{footer}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</aside>
|
|
55
|
+
</SidebarContext.Provider>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SidebarProps {
|
|
2
|
+
/** Whether the sidebar is collapsed to rail mode (56px) */
|
|
3
|
+
collapsed?: boolean;
|
|
4
|
+
/** Called when collapse state changes */
|
|
5
|
+
onCollapsedChange?: (collapsed: boolean) => void;
|
|
6
|
+
/** Header slot (logo area) */
|
|
7
|
+
header?: React.ReactNode;
|
|
8
|
+
/** Navigation slot */
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
/** Footer slot */
|
|
11
|
+
footer?: React.ReactNode;
|
|
12
|
+
/** Additional CSS classes */
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Bell, Search } from 'lucide-react';
|
|
3
|
+
import { TopBar } from './TopBar';
|
|
4
|
+
import { Avatar } from '../../primitives/avatar';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof TopBar> = {
|
|
7
|
+
title: 'Layout/TopBar',
|
|
8
|
+
component: TopBar,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof TopBar>;
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<TopBar
|
|
18
|
+
logo={
|
|
19
|
+
<span className="font-bold text-text-primary" style={{ fontSize: 'var(--text-body-lg)' }}>
|
|
20
|
+
Weave
|
|
21
|
+
</span>
|
|
22
|
+
}
|
|
23
|
+
navigation={
|
|
24
|
+
<>
|
|
25
|
+
<button className="px-3 py-1.5 rounded-full bg-rust-light text-rust-dark font-medium" style={{ fontSize: 'var(--text-body-sm)' }}>
|
|
26
|
+
Dashboard
|
|
27
|
+
</button>
|
|
28
|
+
<button className="px-3 py-1.5 rounded-full text-text-secondary hover:bg-border/30 transition-colors" style={{ fontSize: 'var(--text-body-sm)' }}>
|
|
29
|
+
Projects
|
|
30
|
+
</button>
|
|
31
|
+
<button className="px-3 py-1.5 rounded-full text-text-secondary hover:bg-border/30 transition-colors" style={{ fontSize: 'var(--text-body-sm)' }}>
|
|
32
|
+
Team
|
|
33
|
+
</button>
|
|
34
|
+
</>
|
|
35
|
+
}
|
|
36
|
+
actions={
|
|
37
|
+
<>
|
|
38
|
+
<button className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-md border border-border text-text-secondary" style={{ fontSize: 'var(--text-body-sm)' }}>
|
|
39
|
+
<Search size={14} />
|
|
40
|
+
Search...
|
|
41
|
+
<kbd className="ml-4 px-1.5 py-0.5 bg-surface rounded text-text-secondary" style={{ fontSize: 'var(--text-caption)' }}>⌘K</kbd>
|
|
42
|
+
</button>
|
|
43
|
+
<button className="relative p-2 rounded-md text-text-secondary hover:bg-border/30 transition-colors">
|
|
44
|
+
<Bell size={18} />
|
|
45
|
+
</button>
|
|
46
|
+
<Avatar name="Hank Morrison" size="sm" />
|
|
47
|
+
</>
|
|
48
|
+
}
|
|
49
|
+
/>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { TopBar } from './TopBar';
|
|
4
|
+
|
|
5
|
+
describe('TopBar', () => {
|
|
6
|
+
it('renders logo, navigation, and actions', () => {
|
|
7
|
+
render(
|
|
8
|
+
<TopBar
|
|
9
|
+
logo={<span>Logo</span>}
|
|
10
|
+
navigation={<button>Dashboard</button>}
|
|
11
|
+
actions={<button>Search</button>}
|
|
12
|
+
/>,
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByText('Logo')).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('Search')).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cn } from '../../utils/cn';
|
|
2
|
+
import type { TopBarProps } from './TopBar.types';
|
|
3
|
+
|
|
4
|
+
export function TopBar({ logo, navigation, actions, className }: TopBarProps) {
|
|
5
|
+
return (
|
|
6
|
+
<header
|
|
7
|
+
className={cn(
|
|
8
|
+
'flex items-center justify-between h-14 px-4 bg-surface border-b border-border',
|
|
9
|
+
className,
|
|
10
|
+
)}
|
|
11
|
+
>
|
|
12
|
+
<div className="flex items-center gap-6">
|
|
13
|
+
{logo && <div className="shrink-0">{logo}</div>}
|
|
14
|
+
{navigation && <nav className="flex items-center gap-1">{navigation}</nav>}
|
|
15
|
+
</div>
|
|
16
|
+
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
|
17
|
+
</header>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface TopBarProps {
|
|
2
|
+
/** Logo or brand element */
|
|
3
|
+
logo?: React.ReactNode;
|
|
4
|
+
/** Navigation tabs or links */
|
|
5
|
+
navigation?: React.ReactNode;
|
|
6
|
+
/** Right-side actions (search, notifications, avatar) */
|
|
7
|
+
actions?: React.ReactNode;
|
|
8
|
+
/** Additional CSS classes */
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Breadcrumbs } from './Breadcrumbs';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Breadcrumbs> = {
|
|
5
|
+
title: 'Navigation/Breadcrumbs',
|
|
6
|
+
component: Breadcrumbs,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof Breadcrumbs>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
items: [
|
|
16
|
+
{ label: 'Home', href: '#' },
|
|
17
|
+
{ label: 'Projects', href: '#' },
|
|
18
|
+
{ label: 'Weave Design System' },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const TwoLevels: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
items: [
|
|
26
|
+
{ label: 'Dashboard', href: '#' },
|
|
27
|
+
{ label: 'Settings' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { Breadcrumbs } from './Breadcrumbs';
|
|
4
|
+
|
|
5
|
+
describe('Breadcrumbs', () => {
|
|
6
|
+
it('renders all items', () => {
|
|
7
|
+
render(
|
|
8
|
+
<Breadcrumbs items={[
|
|
9
|
+
{ label: 'Home', href: '#' },
|
|
10
|
+
{ label: 'Projects', href: '#' },
|
|
11
|
+
{ label: 'Current' },
|
|
12
|
+
]} />,
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByText('Home')).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText('Projects')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('Current')).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('marks last item as current page', () => {
|
|
20
|
+
render(
|
|
21
|
+
<Breadcrumbs items={[
|
|
22
|
+
{ label: 'Home', href: '#' },
|
|
23
|
+
{ label: 'Current' },
|
|
24
|
+
]} />,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByText('Current')).toHaveAttribute('aria-current', 'page');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders links for non-last items', () => {
|
|
30
|
+
render(
|
|
31
|
+
<Breadcrumbs items={[
|
|
32
|
+
{ label: 'Home', href: '/home' },
|
|
33
|
+
{ label: 'Current' },
|
|
34
|
+
]} />,
|
|
35
|
+
);
|
|
36
|
+
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/home');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('has breadcrumb navigation landmark', () => {
|
|
40
|
+
render(<Breadcrumbs items={[{ label: 'Home' }]} />);
|
|
41
|
+
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ChevronRight } from 'lucide-react';
|
|
2
|
+
import { cn } from '../../utils/cn';
|
|
3
|
+
import type { BreadcrumbsProps } from './Breadcrumbs.types';
|
|
4
|
+
|
|
5
|
+
export function Breadcrumbs({ items, className }: BreadcrumbsProps) {
|
|
6
|
+
return (
|
|
7
|
+
<nav aria-label="Breadcrumb" className={className}>
|
|
8
|
+
<ol className="flex items-center gap-1.5">
|
|
9
|
+
{items.map((item, i) => {
|
|
10
|
+
const isLast = i === items.length - 1;
|
|
11
|
+
return (
|
|
12
|
+
<li key={i} className="flex items-center gap-1.5">
|
|
13
|
+
{i > 0 && <ChevronRight size={14} className="text-text-secondary" />}
|
|
14
|
+
{isLast ? (
|
|
15
|
+
<span
|
|
16
|
+
className="text-text-primary font-medium"
|
|
17
|
+
style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
|
|
18
|
+
aria-current="page"
|
|
19
|
+
>
|
|
20
|
+
{item.label}
|
|
21
|
+
</span>
|
|
22
|
+
) : (
|
|
23
|
+
<a
|
|
24
|
+
href={item.href}
|
|
25
|
+
onClick={item.onClick}
|
|
26
|
+
className={cn(
|
|
27
|
+
'text-text-secondary hover:text-text-primary transition-colors',
|
|
28
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rust rounded-sm',
|
|
29
|
+
)}
|
|
30
|
+
style={{
|
|
31
|
+
fontSize: 'var(--text-body-sm)',
|
|
32
|
+
lineHeight: 'var(--leading-body-sm)',
|
|
33
|
+
transitionDuration: 'var(--duration-micro)',
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
{item.label}
|
|
37
|
+
</a>
|
|
38
|
+
)}
|
|
39
|
+
</li>
|
|
40
|
+
);
|
|
41
|
+
})}
|
|
42
|
+
</ol>
|
|
43
|
+
</nav>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface BreadcrumbItem {
|
|
2
|
+
label: string;
|
|
3
|
+
href?: string;
|
|
4
|
+
onClick?: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface BreadcrumbsProps {
|
|
8
|
+
/** Breadcrumb items — last item is treated as active/current */
|
|
9
|
+
items: BreadcrumbItem[];
|
|
10
|
+
/** Additional CSS classes */
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|