@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,131 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { arrayMove } from '@dnd-kit/sortable';
4
+ import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core';
5
+ import { KanbanColumn } from './KanbanColumn';
6
+ import { SortableTaskCard } from './SortableTaskCard';
7
+ import { TaskCard } from '../task-card/TaskCard';
8
+ import { KanbanBoard } from '../kanban-board/KanbanBoard';
9
+
10
+ const meta: Meta<typeof KanbanColumn> = { title: 'Productivity/KanbanColumn', component: KanbanColumn, tags: ['autodocs'] };
11
+ export default meta;
12
+ type Story = StoryObj<typeof KanbanColumn>;
13
+
14
+ interface Task {
15
+ id: string;
16
+ title: string;
17
+ priority: 'urgent' | 'high' | 'medium' | 'low';
18
+ assignee?: { name: string };
19
+ dueDate?: string;
20
+ }
21
+
22
+ const initialColumns: Record<string, Task[]> = {
23
+ todo: [
24
+ { id: 't1', title: 'Design landing page', priority: 'high', dueDate: 'Mar 22' },
25
+ { id: 't2', title: 'Write copy for about page', priority: 'medium' },
26
+ { id: 't3', title: 'Create icon set', priority: 'low' },
27
+ ],
28
+ progress: [
29
+ { id: 't4', title: 'Implement auth flow', priority: 'urgent', assignee: { name: 'Bob' } },
30
+ ],
31
+ done: [
32
+ { id: 't5', title: 'Set up CI/CD', priority: 'low', assignee: { name: 'Alice' } },
33
+ ],
34
+ };
35
+
36
+ function DragDropDemo() {
37
+ const [columns, setColumns] = useState(initialColumns);
38
+
39
+ const findColumn = (id: string) => {
40
+ for (const [col, tasks] of Object.entries(columns)) {
41
+ if (col === id) return col;
42
+ if (tasks.find((t) => t.id === id)) return col;
43
+ }
44
+ return null;
45
+ };
46
+
47
+ const handleDragOver = (event: DragOverEvent) => {
48
+ const { active, over } = event;
49
+ if (!over) return;
50
+
51
+ const activeCol = findColumn(String(active.id));
52
+ const overCol = findColumn(String(over.id));
53
+ if (!activeCol || !overCol || activeCol === overCol) return;
54
+
55
+ setColumns((prev) => {
56
+ const activeItems = [...prev[activeCol]];
57
+ const overItems = [...prev[overCol]];
58
+ const activeIdx = activeItems.findIndex((t) => t.id === active.id);
59
+ const [moved] = activeItems.splice(activeIdx, 1);
60
+ overItems.push(moved);
61
+ return { ...prev, [activeCol]: activeItems, [overCol]: overItems };
62
+ });
63
+ };
64
+
65
+ const handleDragEnd = (event: DragEndEvent) => {
66
+ const { active, over } = event;
67
+ if (!over) return;
68
+
69
+ const activeCol = findColumn(String(active.id));
70
+ const overCol = findColumn(String(over.id));
71
+ if (!activeCol || !overCol || activeCol !== overCol) return;
72
+
73
+ const items = columns[activeCol];
74
+ const oldIndex = items.findIndex((t) => t.id === active.id);
75
+ const newIndex = items.findIndex((t) => t.id === over.id);
76
+ if (oldIndex !== newIndex) {
77
+ setColumns((prev) => ({
78
+ ...prev,
79
+ [activeCol]: arrayMove(prev[activeCol], oldIndex, newIndex),
80
+ }));
81
+ }
82
+ };
83
+
84
+ return (
85
+ <KanbanBoard
86
+ onDragOver={handleDragOver}
87
+ onDragEnd={handleDragEnd}
88
+ >
89
+ {Object.entries(columns).map(([colId, tasks]) => (
90
+ <KanbanColumn
91
+ key={colId}
92
+ id={colId}
93
+ title={colId === 'todo' ? 'To Do' : colId === 'progress' ? 'In Progress' : 'Done'}
94
+ count={tasks.length}
95
+ itemIds={tasks.map((t) => t.id)}
96
+ onAdd={() => {}}
97
+ >
98
+ {tasks.map((task) => (
99
+ <SortableTaskCard
100
+ key={task.id}
101
+ id={task.id}
102
+ title={task.title}
103
+ priority={task.priority}
104
+ assignee={task.assignee}
105
+ dueDate={task.dueDate}
106
+ />
107
+ ))}
108
+ </KanbanColumn>
109
+ ))}
110
+ </KanbanBoard>
111
+ );
112
+ }
113
+
114
+ export const WithDragDrop: Story = {
115
+ render: () => <DragDropDemo />,
116
+ };
117
+
118
+ export const Static: Story = {
119
+ name: 'Static (no DnD)',
120
+ render: () => (
121
+ <div className="flex gap-4 p-4 bg-bg">
122
+ <KanbanColumn id="todo" title="To Do" count={2} onAdd={() => {}}>
123
+ <TaskCard title="Design landing page" priority="high" />
124
+ <TaskCard title="Write copy" priority="medium" />
125
+ </KanbanColumn>
126
+ <KanbanColumn id="progress" title="In Progress" count={1}>
127
+ <TaskCard title="Implement auth" priority="urgent" assignee={{ name: 'Bob' }} />
128
+ </KanbanColumn>
129
+ </div>
130
+ ),
131
+ };
@@ -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 { KanbanColumn } from './KanbanColumn';
5
+
6
+ describe('KanbanColumn', () => {
7
+ it('renders title and count', () => {
8
+ render(<KanbanColumn title="To Do" count={3}>Cards</KanbanColumn>);
9
+ expect(screen.getByText('To Do')).toBeInTheDocument();
10
+ expect(screen.getByText('3')).toBeInTheDocument();
11
+ });
12
+ it('calls onAdd', async () => {
13
+ const onAdd = vi.fn();
14
+ render(<KanbanColumn title="To Do" onAdd={onAdd}>Cards</KanbanColumn>);
15
+ await userEvent.click(screen.getByRole('button', { name: 'Add to To Do' }));
16
+ expect(onAdd).toHaveBeenCalledOnce();
17
+ });
18
+ });
@@ -0,0 +1,58 @@
1
+ import { useDroppable } from '@dnd-kit/core';
2
+ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
3
+ import { Plus } from 'lucide-react';
4
+ import { cn } from '../../utils/cn';
5
+
6
+ export interface KanbanColumnProps {
7
+ /** Unique column identifier for drop targeting */
8
+ id: string;
9
+ title: string;
10
+ count?: number;
11
+ onAdd?: () => void;
12
+ /** Item IDs for sortable context */
13
+ itemIds?: string[];
14
+ children: React.ReactNode;
15
+ className?: string;
16
+ }
17
+
18
+ export function KanbanColumn({ id, title, count, onAdd, itemIds = [], children, className }: KanbanColumnProps) {
19
+ const { setNodeRef, isOver } = useDroppable({ id });
20
+
21
+ return (
22
+ <div className={cn('flex flex-col w-72 shrink-0', className)}>
23
+ <div className="flex items-center justify-between px-2 py-3">
24
+ <div className="flex items-center gap-2">
25
+ <h3 className="text-text-primary font-semibold" style={{ fontSize: 'var(--text-body-sm)' }}>{title}</h3>
26
+ {count !== undefined && (
27
+ <span className="px-1.5 py-0.5 rounded-full bg-surface text-text-secondary font-medium" style={{ fontSize: 'var(--text-caption)' }}>
28
+ {count}
29
+ </span>
30
+ )}
31
+ </div>
32
+ {onAdd && (
33
+ <button
34
+ type="button"
35
+ onClick={onAdd}
36
+ className="p-1 rounded-md text-text-secondary hover:bg-surface transition-colors"
37
+ aria-label={`Add to ${title}`}
38
+ style={{ transitionDuration: 'var(--duration-micro)' }}
39
+ >
40
+ <Plus size={16} />
41
+ </button>
42
+ )}
43
+ </div>
44
+ <SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
45
+ <div
46
+ ref={setNodeRef}
47
+ className={cn(
48
+ 'flex flex-col gap-2 flex-1 rounded-lg p-2 min-h-25 transition-colors',
49
+ isOver ? 'bg-rust-light/30 border-2 border-dashed border-rust' : 'bg-bg border-2 border-transparent',
50
+ )}
51
+ style={{ transitionDuration: 'var(--duration-fast)' }}
52
+ >
53
+ {children}
54
+ </div>
55
+ </SortableContext>
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,46 @@
1
+ import { useSortable } from '@dnd-kit/sortable';
2
+ import { CSS } from '@dnd-kit/utilities';
3
+ import { cn } from '../../utils/cn';
4
+ import { TaskCard, type TaskCardProps } from '../task-card/TaskCard';
5
+
6
+ export interface SortableTaskCardProps extends TaskCardProps {
7
+ /** Unique sortable ID — required */
8
+ id: string;
9
+ }
10
+
11
+ export function SortableTaskCard({ id, className, ...props }: SortableTaskCardProps) {
12
+ const {
13
+ attributes,
14
+ listeners,
15
+ setNodeRef,
16
+ transform,
17
+ transition,
18
+ isDragging,
19
+ } = useSortable({ id });
20
+
21
+ const style = {
22
+ transform: CSS.Translate.toString(transform),
23
+ transition,
24
+ zIndex: isDragging ? 50 : undefined,
25
+ position: (isDragging ? 'relative' : undefined) as React.CSSProperties['position'],
26
+ };
27
+
28
+ return (
29
+ <div
30
+ ref={setNodeRef}
31
+ style={style}
32
+ {...attributes}
33
+ >
34
+ <TaskCard
35
+ {...props}
36
+ id={id}
37
+ draggable
38
+ dragHandleProps={listeners}
39
+ className={cn(
40
+ isDragging && 'shadow-4 scale-[1.02] opacity-90',
41
+ className,
42
+ )}
43
+ />
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,4 @@
1
+ export { KanbanColumn } from './KanbanColumn';
2
+ export type { KanbanColumnProps } from './KanbanColumn';
3
+ export { SortableTaskCard } from './SortableTaskCard';
4
+ export type { SortableTaskCardProps } from './SortableTaskCard';
@@ -0,0 +1,14 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { PrioritySelector, type Priority } from './PrioritySelector';
4
+
5
+ const meta: Meta<typeof PrioritySelector> = { title: 'Productivity/PrioritySelector', component: PrioritySelector, tags: ['autodocs'] };
6
+ export default meta;
7
+ type Story = StoryObj<typeof PrioritySelector>;
8
+
9
+ export const Default: Story = {
10
+ render: () => {
11
+ const [value, setValue] = useState<Priority>('medium');
12
+ return <PrioritySelector value={value} onChange={setValue} />;
13
+ },
14
+ };
@@ -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 { PrioritySelector } from './PrioritySelector';
5
+
6
+ describe('PrioritySelector', () => {
7
+ it('renders all priorities', () => {
8
+ render(<PrioritySelector />);
9
+ expect(screen.getByText('Urgent')).toBeInTheDocument();
10
+ expect(screen.getByText('Low')).toBeInTheDocument();
11
+ });
12
+ it('calls onChange', async () => {
13
+ const onChange = vi.fn();
14
+ render(<PrioritySelector onChange={onChange} />);
15
+ await userEvent.click(screen.getByText('High'));
16
+ expect(onChange).toHaveBeenCalledWith('high');
17
+ });
18
+ });
@@ -0,0 +1,40 @@
1
+ import { cn } from '../../utils/cn';
2
+
3
+ export type Priority = 'urgent' | 'high' | 'medium' | 'low';
4
+
5
+ export interface PrioritySelectorProps {
6
+ value?: Priority;
7
+ onChange?: (priority: Priority) => void;
8
+ className?: string;
9
+ }
10
+
11
+ const priorities: { value: Priority; label: string; color: string }[] = [
12
+ { value: 'urgent', label: 'Urgent', color: 'bg-error' },
13
+ { value: 'high', label: 'High', color: 'bg-warning' },
14
+ { value: 'medium', label: 'Medium', color: 'bg-rust' },
15
+ { value: 'low', label: 'Low', color: 'bg-border' },
16
+ ];
17
+
18
+ export function PrioritySelector({ value, onChange, className }: PrioritySelectorProps) {
19
+ return (
20
+ <div className={cn('flex flex-col gap-1', className)} role="radiogroup" aria-label="Priority">
21
+ {priorities.map((p) => (
22
+ <button
23
+ key={p.value}
24
+ type="button"
25
+ role="radio"
26
+ aria-checked={value === p.value}
27
+ onClick={() => onChange?.(p.value)}
28
+ className={cn(
29
+ 'flex items-center gap-2.5 px-3 py-2 rounded-md transition-colors text-left w-full',
30
+ value === p.value ? 'bg-surface font-medium' : 'hover:bg-surface/50',
31
+ )}
32
+ style={{ fontSize: 'var(--text-body-sm)', transitionDuration: 'var(--duration-micro)' }}
33
+ >
34
+ <div className={cn('w-2.5 h-2.5 rounded-full', p.color)} />
35
+ <span className="text-text-primary">{p.label}</span>
36
+ </button>
37
+ ))}
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,2 @@
1
+ export { PrioritySelector } from './PrioritySelector';
2
+ export type { PrioritySelectorProps, Priority } from './PrioritySelector';
@@ -0,0 +1,19 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { RichTextToolbar } from './RichTextToolbar';
4
+
5
+ const meta: Meta<typeof RichTextToolbar> = { title: 'Productivity/RichTextToolbar', component: RichTextToolbar, tags: ['autodocs'] };
6
+ export default meta;
7
+ type Story = StoryObj<typeof RichTextToolbar>;
8
+
9
+ export const Default: Story = {
10
+ render: () => {
11
+ const [active, setActive] = useState<string[]>(['bold']);
12
+ return (
13
+ <RichTextToolbar
14
+ activeFormats={active}
15
+ onFormatToggle={(f) => setActive((prev) => prev.includes(f) ? prev.filter((x) => x !== f) : [...prev, f])}
16
+ />
17
+ );
18
+ },
19
+ };
@@ -0,0 +1,21 @@
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 { RichTextToolbar } from './RichTextToolbar';
5
+
6
+ describe('RichTextToolbar', () => {
7
+ it('renders toolbar', () => {
8
+ render(<RichTextToolbar />);
9
+ expect(screen.getByRole('toolbar')).toBeInTheDocument();
10
+ });
11
+ it('marks active formats', () => {
12
+ render(<RichTextToolbar activeFormats={['bold']} />);
13
+ expect(screen.getByRole('button', { name: 'bold' })).toHaveAttribute('aria-pressed', 'true');
14
+ });
15
+ it('calls onFormatToggle', async () => {
16
+ const onToggle = vi.fn();
17
+ render(<RichTextToolbar onFormatToggle={onToggle} />);
18
+ await userEvent.click(screen.getByRole('button', { name: 'italic' }));
19
+ expect(onToggle).toHaveBeenCalledWith('italic');
20
+ });
21
+ });
@@ -0,0 +1,50 @@
1
+ import { Bold, Italic, Underline, List, ListOrdered, Link, Code, AtSign } from 'lucide-react';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export interface RichTextToolbarProps {
5
+ activeFormats?: string[];
6
+ onFormatToggle?: (format: string) => void;
7
+ className?: string;
8
+ }
9
+
10
+ const tools = [
11
+ { format: 'bold', icon: Bold },
12
+ { format: 'italic', icon: Italic },
13
+ { format: 'underline', icon: Underline },
14
+ { format: 'separator1', icon: null },
15
+ { format: 'list', icon: List },
16
+ { format: 'orderedList', icon: ListOrdered },
17
+ { format: 'separator2', icon: null },
18
+ { format: 'link', icon: Link },
19
+ { format: 'code', icon: Code },
20
+ { format: 'mention', icon: AtSign },
21
+ ];
22
+
23
+ export function RichTextToolbar({ activeFormats = [], onFormatToggle, className }: RichTextToolbarProps) {
24
+ return (
25
+ <div className={cn('flex items-center gap-0.5 p-1 rounded-lg border border-border bg-white', className)} role="toolbar" aria-label="Text formatting">
26
+ {tools.map((tool, i) =>
27
+ tool.icon === null ? (
28
+ <div key={tool.format} className="w-px h-5 bg-border mx-1" role="separator" />
29
+ ) : (
30
+ <button
31
+ key={tool.format}
32
+ type="button"
33
+ onClick={() => onFormatToggle?.(tool.format)}
34
+ aria-pressed={activeFormats.includes(tool.format)}
35
+ className={cn(
36
+ 'p-1.5 rounded-md transition-colors',
37
+ activeFormats.includes(tool.format)
38
+ ? 'bg-rust-light text-rust-dark'
39
+ : 'text-text-secondary hover:bg-surface',
40
+ )}
41
+ aria-label={tool.format}
42
+ style={{ transitionDuration: 'var(--duration-micro)' }}
43
+ >
44
+ <tool.icon size={16} />
45
+ </button>
46
+ ),
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,2 @@
1
+ export { RichTextToolbar } from './RichTextToolbar';
2
+ export type { RichTextToolbarProps } from './RichTextToolbar';
@@ -0,0 +1,20 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { TaskCard } from './TaskCard';
3
+
4
+ const meta: Meta<typeof TaskCard> = { title: 'Productivity/TaskCard', component: TaskCard, tags: ['autodocs'] };
5
+ export default meta;
6
+ type Story = StoryObj<typeof TaskCard>;
7
+
8
+ export const Default: Story = {
9
+ args: {
10
+ title: 'Design landing page hero section',
11
+ priority: 'high',
12
+ labels: [{ text: 'Design', variant: 'primary' }, { text: 'Frontend', variant: 'secondary' }],
13
+ assignee: { name: 'Alice Smith' },
14
+ dueDate: 'Mar 22',
15
+ },
16
+ };
17
+
18
+ export const Minimal: Story = {
19
+ args: { title: 'Fix navigation bug', priority: 'urgent' },
20
+ };
@@ -0,0 +1,21 @@
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 { TaskCard } from './TaskCard';
5
+
6
+ describe('TaskCard', () => {
7
+ it('renders title', () => {
8
+ render(<TaskCard title="Fix bug" />);
9
+ expect(screen.getByText('Fix bug')).toBeInTheDocument();
10
+ });
11
+ it('renders labels', () => {
12
+ render(<TaskCard title="Task" labels={[{ text: 'Design' }]} />);
13
+ expect(screen.getByText('Design')).toBeInTheDocument();
14
+ });
15
+ it('calls onClick', async () => {
16
+ const onClick = vi.fn();
17
+ render(<TaskCard title="Task" onClick={onClick} />);
18
+ await userEvent.click(screen.getByRole('button'));
19
+ expect(onClick).toHaveBeenCalledOnce();
20
+ });
21
+ });
@@ -0,0 +1,76 @@
1
+ import { Calendar, GripVertical } from 'lucide-react';
2
+ import { cn } from '../../utils/cn';
3
+ import { Avatar } from '../../primitives/avatar';
4
+ import { Badge } from '../../primitives/badge';
5
+
6
+ export interface TaskCardProps {
7
+ /** Unique identifier for drag-and-drop */
8
+ id?: string;
9
+ title: string;
10
+ priority?: 'urgent' | 'high' | 'medium' | 'low';
11
+ labels?: { text: string; variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' }[];
12
+ assignee?: { name: string; src?: string };
13
+ dueDate?: string;
14
+ className?: string;
15
+ onClick?: () => void;
16
+ /** Show drag grip handle */
17
+ draggable?: boolean;
18
+ /** Props spread from useSortable — used internally by KanbanBoard */
19
+ dragHandleProps?: Record<string, unknown>;
20
+ }
21
+
22
+ const priorityColors: Record<string, string> = {
23
+ urgent: 'bg-error',
24
+ high: 'bg-warning',
25
+ medium: 'bg-rust',
26
+ low: 'bg-border',
27
+ };
28
+
29
+ export function TaskCard({ title, priority, labels, assignee, dueDate, className, onClick, draggable, dragHandleProps }: TaskCardProps) {
30
+ return (
31
+ <div
32
+ className={cn(
33
+ 'rounded-lg bg-white p-4 shadow-1 transition-all',
34
+ !draggable && 'cursor-pointer hover:shadow-2 hover:-translate-y-0.5',
35
+ draggable && 'cursor-default',
36
+ className,
37
+ )}
38
+ style={{ transitionDuration: 'var(--duration-fast)' }}
39
+ onClick={onClick}
40
+ role="button"
41
+ tabIndex={0}
42
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick?.(); } }}
43
+ >
44
+ <div className="flex items-start gap-2">
45
+ {draggable && (
46
+ <button
47
+ type="button"
48
+ className="mt-0.5 shrink-0 cursor-grab text-text-secondary hover:text-text-primary active:cursor-grabbing transition-colors rounded p-0.5 -ml-1"
49
+ style={{ transitionDuration: 'var(--duration-micro)' }}
50
+ aria-label="Drag to reorder"
51
+ {...dragHandleProps}
52
+ >
53
+ <GripVertical size={14} />
54
+ </button>
55
+ )}
56
+ {priority && <div className={cn('w-2 h-2 rounded-full mt-2 shrink-0', priorityColors[priority])} />}
57
+ <p className="text-text-primary font-medium flex-1" style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}>
58
+ {title}
59
+ </p>
60
+ </div>
61
+ {labels && labels.length > 0 && (
62
+ <div className="flex flex-wrap gap-1.5 mt-3">
63
+ {labels.map((l, i) => <Badge key={i} variant={l.variant}>{l.text}</Badge>)}
64
+ </div>
65
+ )}
66
+ <div className="flex items-center justify-between mt-3">
67
+ {dueDate && (
68
+ <span className="flex items-center gap-1 text-text-secondary" style={{ fontSize: 'var(--text-caption)' }}>
69
+ <Calendar size={12} />{dueDate}
70
+ </span>
71
+ )}
72
+ {assignee && <Avatar name={assignee.name} src={assignee.src} size="sm" />}
73
+ </div>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,2 @@
1
+ export { TaskCard } from './TaskCard';
2
+ export type { TaskCardProps } from './TaskCard';
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';
@@ -0,0 +1 @@
1
+ export { colors, spacing, radii, shadows, durations, easings, typography } from './tokens';
@@ -0,0 +1,71 @@
1
+ export const colors = {
2
+ bg: '#FAF7F2',
3
+ surface: '#F0EBE3',
4
+ border: '#D9D2C7',
5
+ textPrimary: '#2C2825',
6
+ textSecondary: '#6B635A',
7
+ white: '#FFFFFF',
8
+ rust: '#B85C38',
9
+ rustLight: '#F0D5C5',
10
+ rustDark: '#8E4429',
11
+ forest: '#3D6B5E',
12
+ forestLight: '#D4E5DF',
13
+ success: '#5E8A6B',
14
+ successLight: '#DDE9E0',
15
+ warning: '#C49A3C',
16
+ warningLight: '#F5EDD4',
17
+ error: '#B84A3C',
18
+ errorLight: '#F3D9D5',
19
+ info: '#7A8A9A',
20
+ infoLight: '#E0E5EA',
21
+ saffron: '#D4923A',
22
+ saffronLight: '#F5E3C8',
23
+ } as const;
24
+
25
+ export const spacing = {
26
+ 1: '4px',
27
+ 2: '8px',
28
+ 3: '12px',
29
+ 4: '16px',
30
+ 5: '20px',
31
+ 6: '24px',
32
+ 8: '32px',
33
+ 10: '40px',
34
+ 12: '48px',
35
+ 16: '64px',
36
+ 20: '80px',
37
+ 24: '96px',
38
+ } as const;
39
+
40
+ export const radii = {
41
+ sm: '6px',
42
+ md: '10px',
43
+ lg: '16px',
44
+ xl: '24px',
45
+ full: '9999px',
46
+ } as const;
47
+
48
+ export const shadows = {
49
+ 1: '0 1px 3px rgba(44, 40, 37, 0.06)',
50
+ 2: '0 4px 12px rgba(44, 40, 37, 0.08)',
51
+ 3: '0 8px 24px rgba(44, 40, 37, 0.12)',
52
+ 4: '0 16px 48px rgba(44, 40, 37, 0.16)',
53
+ } as const;
54
+
55
+ export const durations = {
56
+ micro: '100ms',
57
+ fast: '200ms',
58
+ normal: '350ms',
59
+ slow: '500ms',
60
+ } as const;
61
+
62
+ export const easings = {
63
+ out: 'cubic-bezier(0.22, 1, 0.36, 1)',
64
+ in: 'cubic-bezier(0.55, 0, 1, 0.45)',
65
+ spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
66
+ } as const;
67
+
68
+ export const typography = {
69
+ fontSans: "'Satoshi', system-ui, -apple-system, sans-serif",
70
+ fontMono: "'JetBrains Mono', 'Fira Code', monospace",
71
+ } as const;