@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.
Files changed (230) hide show
  1. package/dist/index.cjs +7729 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.cts +677 -0
  4. package/dist/index.d.ts +677 -0
  5. package/dist/index.js +7654 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/theme.css +2 -0
  8. package/dist/tokens.cjs +78 -0
  9. package/dist/tokens.cjs.map +1 -0
  10. package/dist/tokens.d.cts +67 -0
  11. package/dist/tokens.d.ts +67 -0
  12. package/dist/tokens.js +70 -0
  13. package/dist/tokens.js.map +1 -0
  14. package/package.json +103 -0
  15. package/src/data-display/activity-feed/ActivityFeed.stories.tsx +16 -0
  16. package/src/data-display/activity-feed/ActivityFeed.test.tsx +11 -0
  17. package/src/data-display/activity-feed/ActivityFeed.tsx +34 -0
  18. package/src/data-display/activity-feed/index.ts +2 -0
  19. package/src/data-display/circular-progress/CircularProgress.stories.tsx +10 -0
  20. package/src/data-display/circular-progress/CircularProgress.test.tsx +11 -0
  21. package/src/data-display/circular-progress/CircularProgress.tsx +35 -0
  22. package/src/data-display/circular-progress/index.ts +2 -0
  23. package/src/data-display/empty-state/EmptyState.stories.tsx +15 -0
  24. package/src/data-display/empty-state/EmptyState.test.tsx +18 -0
  25. package/src/data-display/empty-state/EmptyState.tsx +34 -0
  26. package/src/data-display/empty-state/index.ts +2 -0
  27. package/src/data-display/progress-bar/ProgressBar.stories.tsx +10 -0
  28. package/src/data-display/progress-bar/ProgressBar.test.tsx +15 -0
  29. package/src/data-display/progress-bar/ProgressBar.tsx +38 -0
  30. package/src/data-display/progress-bar/index.ts +2 -0
  31. package/src/data-display/stat-card/StatCard.stories.tsx +10 -0
  32. package/src/data-display/stat-card/StatCard.test.tsx +15 -0
  33. package/src/data-display/stat-card/StatCard.tsx +40 -0
  34. package/src/data-display/stat-card/index.ts +2 -0
  35. package/src/data-display/table/Table.stories.tsx +44 -0
  36. package/src/data-display/table/Table.test.tsx +16 -0
  37. package/src/data-display/table/Table.tsx +71 -0
  38. package/src/data-display/table/index.ts +1 -0
  39. package/src/data-display/timeline/Timeline.stories.tsx +16 -0
  40. package/src/data-display/timeline/Timeline.test.tsx +11 -0
  41. package/src/data-display/timeline/Timeline.tsx +44 -0
  42. package/src/data-display/timeline/index.ts +2 -0
  43. package/src/docs/ComponentOverview.mdx +192 -0
  44. package/src/docs/DesignTokens.mdx +235 -0
  45. package/src/docs/GettingStarted.mdx +145 -0
  46. package/src/feedback/alert-banner/AlertBanner.stories.tsx +10 -0
  47. package/src/feedback/alert-banner/AlertBanner.test.tsx +16 -0
  48. package/src/feedback/alert-banner/AlertBanner.tsx +47 -0
  49. package/src/feedback/alert-banner/index.ts +2 -0
  50. package/src/feedback/modal/Modal.stories.tsx +31 -0
  51. package/src/feedback/modal/Modal.test.tsx +33 -0
  52. package/src/feedback/modal/Modal.tsx +88 -0
  53. package/src/feedback/modal/index.ts +2 -0
  54. package/src/feedback/skeleton-loader/SkeletonLoader.stories.tsx +23 -0
  55. package/src/feedback/skeleton-loader/SkeletonLoader.test.tsx +14 -0
  56. package/src/feedback/skeleton-loader/SkeletonLoader.tsx +61 -0
  57. package/src/feedback/skeleton-loader/index.ts +2 -0
  58. package/src/feedback/toast/Toast.stories.tsx +27 -0
  59. package/src/feedback/toast/Toast.test.tsx +32 -0
  60. package/src/feedback/toast/Toast.tsx +106 -0
  61. package/src/feedback/toast/index.ts +2 -0
  62. package/src/hooks/index.ts +3 -0
  63. package/src/hooks/use-controllable-state.ts +34 -0
  64. package/src/hooks/use-focus-trap.ts +56 -0
  65. package/src/hooks/use-reduced-motion.ts +17 -0
  66. package/src/index.ts +148 -0
  67. package/src/layout/card/Card.stories.tsx +45 -0
  68. package/src/layout/card/Card.test.tsx +23 -0
  69. package/src/layout/card/Card.tsx +42 -0
  70. package/src/layout/card/Card.types.ts +6 -0
  71. package/src/layout/card/index.ts +2 -0
  72. package/src/layout/command-palette/CommandPalette.stories.tsx +34 -0
  73. package/src/layout/command-palette/CommandPalette.test.tsx +43 -0
  74. package/src/layout/command-palette/CommandPalette.tsx +188 -0
  75. package/src/layout/command-palette/CommandPalette.types.ts +18 -0
  76. package/src/layout/command-palette/index.ts +2 -0
  77. package/src/layout/sidebar/Sidebar.stories.tsx +60 -0
  78. package/src/layout/sidebar/Sidebar.test.tsx +27 -0
  79. package/src/layout/sidebar/Sidebar.tsx +57 -0
  80. package/src/layout/sidebar/Sidebar.types.ts +14 -0
  81. package/src/layout/sidebar/index.ts +2 -0
  82. package/src/layout/top-bar/TopBar.stories.tsx +51 -0
  83. package/src/layout/top-bar/TopBar.test.tsx +18 -0
  84. package/src/layout/top-bar/TopBar.tsx +19 -0
  85. package/src/layout/top-bar/TopBar.types.ts +10 -0
  86. package/src/layout/top-bar/index.ts +2 -0
  87. package/src/navigation/breadcrumbs/Breadcrumbs.stories.tsx +30 -0
  88. package/src/navigation/breadcrumbs/Breadcrumbs.test.tsx +43 -0
  89. package/src/navigation/breadcrumbs/Breadcrumbs.tsx +45 -0
  90. package/src/navigation/breadcrumbs/Breadcrumbs.types.ts +12 -0
  91. package/src/navigation/breadcrumbs/index.ts +2 -0
  92. package/src/navigation/sidebar-nav-item/SidebarNavItem.stories.tsx +41 -0
  93. package/src/navigation/sidebar-nav-item/SidebarNavItem.test.tsx +46 -0
  94. package/src/navigation/sidebar-nav-item/SidebarNavItem.tsx +38 -0
  95. package/src/navigation/sidebar-nav-item/SidebarNavItem.types.ts +12 -0
  96. package/src/navigation/sidebar-nav-item/index.ts +2 -0
  97. package/src/navigation/tabs/Tabs.stories.tsx +47 -0
  98. package/src/navigation/tabs/Tabs.test.tsx +67 -0
  99. package/src/navigation/tabs/Tabs.tsx +111 -0
  100. package/src/navigation/tabs/Tabs.types.ts +36 -0
  101. package/src/navigation/tabs/index.ts +2 -0
  102. package/src/patterns/accordion/Accordion.stories.tsx +25 -0
  103. package/src/patterns/accordion/Accordion.test.tsx +44 -0
  104. package/src/patterns/accordion/Accordion.tsx +92 -0
  105. package/src/patterns/accordion/index.ts +2 -0
  106. package/src/patterns/action-menu/ActionMenu.stories.tsx +18 -0
  107. package/src/patterns/action-menu/ActionMenu.test.tsx +18 -0
  108. package/src/patterns/action-menu/ActionMenu.tsx +41 -0
  109. package/src/patterns/action-menu/index.ts +2 -0
  110. package/src/patterns/carousel/Carousel.stories.tsx +16 -0
  111. package/src/patterns/carousel/Carousel.test.tsx +16 -0
  112. package/src/patterns/carousel/Carousel.tsx +69 -0
  113. package/src/patterns/carousel/index.ts +2 -0
  114. package/src/patterns/image-placeholder/ImagePlaceholder.stories.tsx +9 -0
  115. package/src/patterns/image-placeholder/ImagePlaceholder.test.tsx +10 -0
  116. package/src/patterns/image-placeholder/ImagePlaceholder.tsx +21 -0
  117. package/src/patterns/image-placeholder/index.ts +2 -0
  118. package/src/patterns/notification-dot/NotificationDot.stories.tsx +17 -0
  119. package/src/patterns/notification-dot/NotificationDot.test.tsx +14 -0
  120. package/src/patterns/notification-dot/NotificationDot.tsx +18 -0
  121. package/src/patterns/notification-dot/index.ts +2 -0
  122. package/src/patterns/pagination/Pagination.stories.tsx +14 -0
  123. package/src/patterns/pagination/Pagination.test.tsx +22 -0
  124. package/src/patterns/pagination/Pagination.tsx +67 -0
  125. package/src/patterns/pagination/index.ts +2 -0
  126. package/src/primitives/avatar/Avatar.stories.tsx +46 -0
  127. package/src/primitives/avatar/Avatar.test.tsx +35 -0
  128. package/src/primitives/avatar/Avatar.tsx +49 -0
  129. package/src/primitives/avatar/Avatar.types.ts +21 -0
  130. package/src/primitives/avatar/AvatarGroup.tsx +27 -0
  131. package/src/primitives/avatar/index.ts +3 -0
  132. package/src/primitives/badge/Badge.stories.tsx +28 -0
  133. package/src/primitives/badge/Badge.test.tsx +23 -0
  134. package/src/primitives/badge/Badge.tsx +44 -0
  135. package/src/primitives/badge/Badge.types.ts +14 -0
  136. package/src/primitives/badge/index.ts +2 -0
  137. package/src/primitives/button/Button.stories.tsx +81 -0
  138. package/src/primitives/button/Button.test.tsx +64 -0
  139. package/src/primitives/button/Button.tsx +85 -0
  140. package/src/primitives/button/Button.types.ts +17 -0
  141. package/src/primitives/button/index.ts +2 -0
  142. package/src/primitives/checkbox/Checkbox.stories.tsx +27 -0
  143. package/src/primitives/checkbox/Checkbox.test.tsx +30 -0
  144. package/src/primitives/checkbox/Checkbox.tsx +79 -0
  145. package/src/primitives/checkbox/Checkbox.types.ts +12 -0
  146. package/src/primitives/checkbox/index.ts +2 -0
  147. package/src/primitives/combobox/Combobox.stories.tsx +44 -0
  148. package/src/primitives/combobox/Combobox.test.tsx +44 -0
  149. package/src/primitives/combobox/Combobox.tsx +201 -0
  150. package/src/primitives/combobox/Combobox.types.ts +25 -0
  151. package/src/primitives/combobox/index.ts +2 -0
  152. package/src/primitives/date-input/DateInput.stories.tsx +23 -0
  153. package/src/primitives/date-input/DateInput.test.tsx +22 -0
  154. package/src/primitives/date-input/DateInput.tsx +66 -0
  155. package/src/primitives/date-input/DateInput.types.ts +10 -0
  156. package/src/primitives/date-input/index.ts +2 -0
  157. package/src/primitives/file-upload/FileUploadDropzone.stories.tsx +27 -0
  158. package/src/primitives/file-upload/FileUploadDropzone.test.tsx +26 -0
  159. package/src/primitives/file-upload/FileUploadDropzone.tsx +99 -0
  160. package/src/primitives/file-upload/FileUploadDropzone.types.ts +14 -0
  161. package/src/primitives/file-upload/index.ts +2 -0
  162. package/src/primitives/input/InputGroup.stories.tsx +31 -0
  163. package/src/primitives/input/InputGroup.test.tsx +40 -0
  164. package/src/primitives/input/InputGroup.tsx +65 -0
  165. package/src/primitives/input/InputGroup.types.ts +12 -0
  166. package/src/primitives/input/index.ts +2 -0
  167. package/src/primitives/link/Link.stories.tsx +28 -0
  168. package/src/primitives/link/Link.test.tsx +23 -0
  169. package/src/primitives/link/Link.tsx +28 -0
  170. package/src/primitives/link/Link.types.ts +8 -0
  171. package/src/primitives/link/index.ts +2 -0
  172. package/src/primitives/radio/Radio.stories.tsx +29 -0
  173. package/src/primitives/radio/Radio.test.tsx +32 -0
  174. package/src/primitives/radio/Radio.tsx +59 -0
  175. package/src/primitives/radio/Radio.types.ts +6 -0
  176. package/src/primitives/radio/index.ts +2 -0
  177. package/src/primitives/select/SelectGroup.stories.tsx +33 -0
  178. package/src/primitives/select/SelectGroup.test.tsx +34 -0
  179. package/src/primitives/select/SelectGroup.tsx +72 -0
  180. package/src/primitives/select/SelectGroup.types.ts +12 -0
  181. package/src/primitives/select/index.ts +2 -0
  182. package/src/primitives/slider/Slider.stories.tsx +23 -0
  183. package/src/primitives/slider/Slider.test.tsx +28 -0
  184. package/src/primitives/slider/Slider.tsx +80 -0
  185. package/src/primitives/slider/Slider.types.ts +22 -0
  186. package/src/primitives/slider/index.ts +2 -0
  187. package/src/primitives/textarea/TextareaGroup.stories.tsx +27 -0
  188. package/src/primitives/textarea/TextareaGroup.test.tsx +24 -0
  189. package/src/primitives/textarea/TextareaGroup.tsx +59 -0
  190. package/src/primitives/textarea/TextareaGroup.types.ts +10 -0
  191. package/src/primitives/textarea/index.ts +2 -0
  192. package/src/primitives/toggle/Toggle.stories.tsx +27 -0
  193. package/src/primitives/toggle/Toggle.test.tsx +31 -0
  194. package/src/primitives/toggle/Toggle.tsx +65 -0
  195. package/src/primitives/toggle/Toggle.types.ts +16 -0
  196. package/src/primitives/toggle/index.ts +2 -0
  197. package/src/primitives/tooltip/Tooltip.stories.tsx +45 -0
  198. package/src/primitives/tooltip/Tooltip.test.tsx +28 -0
  199. package/src/primitives/tooltip/Tooltip.tsx +94 -0
  200. package/src/primitives/tooltip/Tooltip.types.ts +16 -0
  201. package/src/primitives/tooltip/index.ts +2 -0
  202. package/src/productivity/comment-thread/CommentThread.stories.tsx +20 -0
  203. package/src/productivity/comment-thread/CommentThread.test.tsx +21 -0
  204. package/src/productivity/comment-thread/CommentThread.tsx +47 -0
  205. package/src/productivity/comment-thread/index.ts +2 -0
  206. package/src/productivity/kanban-board/KanbanBoard.tsx +41 -0
  207. package/src/productivity/kanban-board/index.ts +2 -0
  208. package/src/productivity/kanban-column/KanbanColumn.stories.tsx +131 -0
  209. package/src/productivity/kanban-column/KanbanColumn.test.tsx +18 -0
  210. package/src/productivity/kanban-column/KanbanColumn.tsx +58 -0
  211. package/src/productivity/kanban-column/SortableTaskCard.tsx +46 -0
  212. package/src/productivity/kanban-column/index.ts +4 -0
  213. package/src/productivity/priority-selector/PrioritySelector.stories.tsx +14 -0
  214. package/src/productivity/priority-selector/PrioritySelector.test.tsx +18 -0
  215. package/src/productivity/priority-selector/PrioritySelector.tsx +40 -0
  216. package/src/productivity/priority-selector/index.ts +2 -0
  217. package/src/productivity/rich-text-toolbar/RichTextToolbar.stories.tsx +19 -0
  218. package/src/productivity/rich-text-toolbar/RichTextToolbar.test.tsx +21 -0
  219. package/src/productivity/rich-text-toolbar/RichTextToolbar.tsx +50 -0
  220. package/src/productivity/rich-text-toolbar/index.ts +2 -0
  221. package/src/productivity/task-card/TaskCard.stories.tsx +20 -0
  222. package/src/productivity/task-card/TaskCard.test.tsx +21 -0
  223. package/src/productivity/task-card/TaskCard.tsx +76 -0
  224. package/src/productivity/task-card/index.ts +2 -0
  225. package/src/test-setup.ts +1 -0
  226. package/src/tokens/index.ts +1 -0
  227. package/src/tokens/tokens.ts +71 -0
  228. package/src/tokens/weave-theme.css +168 -0
  229. package/src/utils/cn.ts +6 -0
  230. 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,2 @@
1
+ export { CommandPalette } from './CommandPalette';
2
+ export type { CommandPaletteProps, CommandItem } from './CommandPalette.types';
@@ -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,2 @@
1
+ export { Sidebar, SidebarContext, useSidebar } from './Sidebar';
2
+ export type { SidebarProps } from './Sidebar.types';
@@ -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,2 @@
1
+ export { TopBar } from './TopBar';
2
+ export type { TopBarProps } from './TopBar.types';
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { Breadcrumbs } from './Breadcrumbs';
2
+ export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs.types';