@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,201 @@
1
+ import { useState, useId, useRef, useCallback } from 'react';
2
+ import {
3
+ useFloating,
4
+ useClick,
5
+ useDismiss,
6
+ useRole,
7
+ useInteractions,
8
+ useListNavigation,
9
+ offset,
10
+ flip,
11
+ size as floatingSize,
12
+ FloatingPortal,
13
+ FloatingFocusManager,
14
+ } from '@floating-ui/react';
15
+ import { Check, ChevronDown, AlertCircle } from 'lucide-react';
16
+ import { cn } from '../../utils/cn';
17
+ import { useControllableState } from '../../hooks/use-controllable-state';
18
+ import type { ComboboxProps } from './Combobox.types';
19
+
20
+ export function Combobox({
21
+ label,
22
+ options,
23
+ value: controlledValue,
24
+ defaultValue = '',
25
+ onChange,
26
+ placeholder = 'Select...',
27
+ error,
28
+ disabled = false,
29
+ className,
30
+ }: ComboboxProps) {
31
+ const id = useId();
32
+ const messageId = `${id}-message`;
33
+ const [value, setValue] = useControllableState({
34
+ value: controlledValue,
35
+ defaultValue,
36
+ onChange,
37
+ });
38
+ const [isOpen, setIsOpen] = useState(false);
39
+ const [query, setQuery] = useState('');
40
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
41
+ const listRef = useRef<(HTMLElement | null)[]>([]);
42
+
43
+ const filtered = query
44
+ ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
45
+ : options;
46
+
47
+ const selectedLabel = options.find((o) => o.value === value)?.label ?? '';
48
+
49
+ const { refs, floatingStyles, context } = useFloating({
50
+ open: isOpen,
51
+ onOpenChange: setIsOpen,
52
+ placement: 'bottom-start',
53
+ middleware: [
54
+ offset(4),
55
+ flip(),
56
+ floatingSize({
57
+ apply({ rects, elements }) {
58
+ Object.assign(elements.floating.style, { width: `${rects.reference.width}px` });
59
+ },
60
+ }),
61
+ ],
62
+ });
63
+
64
+ const click = useClick(context);
65
+ const dismiss = useDismiss(context);
66
+ const role = useRole(context, { role: 'listbox' });
67
+ const listNav = useListNavigation(context, {
68
+ listRef,
69
+ activeIndex,
70
+ onNavigate: setActiveIndex,
71
+ virtual: true,
72
+ loop: true,
73
+ });
74
+
75
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
76
+ click,
77
+ dismiss,
78
+ role,
79
+ listNav,
80
+ ]);
81
+
82
+ const handleSelect = useCallback(
83
+ (optionValue: string) => {
84
+ setValue(optionValue);
85
+ setIsOpen(false);
86
+ setQuery('');
87
+ },
88
+ [setValue],
89
+ );
90
+
91
+ return (
92
+ <div className={cn('flex flex-col gap-1.5', className)}>
93
+ <label
94
+ htmlFor={id}
95
+ className="text-text-primary font-medium"
96
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
97
+ >
98
+ {label}
99
+ </label>
100
+ <div className="relative" ref={refs.setReference}>
101
+ <input
102
+ id={id}
103
+ type="text"
104
+ role="combobox"
105
+ aria-expanded={isOpen}
106
+ aria-autocomplete="list"
107
+ aria-invalid={!!error}
108
+ aria-describedby={error ? messageId : undefined}
109
+ disabled={disabled}
110
+ placeholder={isOpen ? 'Type to filter...' : (selectedLabel || placeholder)}
111
+ value={isOpen ? query : selectedLabel}
112
+ onChange={(e) => {
113
+ setQuery(e.target.value);
114
+ if (!isOpen) setIsOpen(true);
115
+ }}
116
+ onFocus={() => {
117
+ if (!isOpen) setIsOpen(true);
118
+ setQuery('');
119
+ }}
120
+ className={cn(
121
+ 'w-full rounded-md bg-white px-3 py-2 pr-10 text-text-primary placeholder:text-text-secondary',
122
+ 'border outline-none transition-all',
123
+ 'focus:border-rust focus:ring-2 focus:ring-rust-light',
124
+ error ? 'border-error' : 'border-border',
125
+ disabled && 'bg-surface opacity-50 cursor-not-allowed',
126
+ )}
127
+ style={{
128
+ fontSize: 'var(--text-body)',
129
+ lineHeight: 'var(--leading-body)',
130
+ transitionDuration: 'var(--duration-fast)',
131
+ }}
132
+ {...getReferenceProps()}
133
+ />
134
+ <ChevronDown
135
+ size={16}
136
+ className={cn(
137
+ 'pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary transition-transform',
138
+ isOpen && 'rotate-180',
139
+ )}
140
+ style={{ transitionDuration: 'var(--duration-fast)' }}
141
+ />
142
+ </div>
143
+ {isOpen && (
144
+ <FloatingPortal>
145
+ <FloatingFocusManager context={context} initialFocus={-1} visuallyHiddenDismiss>
146
+ <div
147
+ ref={refs.setFloating}
148
+ style={floatingStyles}
149
+ className="z-50 rounded-md border border-border bg-white shadow-2 py-1 max-h-60 overflow-y-auto"
150
+ {...getFloatingProps()}
151
+ >
152
+ {filtered.length === 0 ? (
153
+ <div
154
+ className="px-3 py-2 text-text-secondary"
155
+ style={{ fontSize: 'var(--text-body-sm)' }}
156
+ >
157
+ No results found
158
+ </div>
159
+ ) : (
160
+ filtered.map((option, i) => (
161
+ <div
162
+ key={option.value}
163
+ ref={(node) => { listRef.current[i] = node; }}
164
+ role="option"
165
+ aria-selected={value === option.value}
166
+ tabIndex={activeIndex === i ? 0 : -1}
167
+ className={cn(
168
+ 'flex items-center justify-between px-3 py-2 cursor-pointer transition-colors',
169
+ activeIndex === i && 'bg-surface',
170
+ value === option.value && 'text-rust font-medium',
171
+ )}
172
+ style={{
173
+ fontSize: 'var(--text-body)',
174
+ transitionDuration: 'var(--duration-micro)',
175
+ }}
176
+ {...getItemProps({
177
+ onClick: () => handleSelect(option.value),
178
+ })}
179
+ >
180
+ {option.label}
181
+ {value === option.value && <Check size={16} className="text-rust" />}
182
+ </div>
183
+ ))
184
+ )}
185
+ </div>
186
+ </FloatingFocusManager>
187
+ </FloatingPortal>
188
+ )}
189
+ {error && (
190
+ <p
191
+ id={messageId}
192
+ className="flex items-center gap-1 text-error"
193
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
194
+ >
195
+ <AlertCircle size={14} />
196
+ {error}
197
+ </p>
198
+ )}
199
+ </div>
200
+ );
201
+ }
@@ -0,0 +1,25 @@
1
+ export interface ComboboxOption {
2
+ value: string;
3
+ label: string;
4
+ }
5
+
6
+ export interface ComboboxProps {
7
+ /** Label text */
8
+ label: string;
9
+ /** Available options */
10
+ options: ComboboxOption[];
11
+ /** Controlled selected value */
12
+ value?: string;
13
+ /** Default selected value */
14
+ defaultValue?: string;
15
+ /** Change handler */
16
+ onChange?: (value: string) => void;
17
+ /** Placeholder text */
18
+ placeholder?: string;
19
+ /** Error message */
20
+ error?: string;
21
+ /** Whether the combobox is disabled */
22
+ disabled?: boolean;
23
+ /** Additional CSS classes */
24
+ className?: string;
25
+ }
@@ -0,0 +1,2 @@
1
+ export { Combobox } from './Combobox';
2
+ export type { ComboboxProps, ComboboxOption } from './Combobox.types';
@@ -0,0 +1,23 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { DateInput } from './DateInput';
3
+
4
+ const meta: Meta<typeof DateInput> = {
5
+ title: 'Primitives/DateInput',
6
+ component: DateInput,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof DateInput>;
12
+
13
+ export const Default: Story = {
14
+ args: { label: 'Due Date' },
15
+ };
16
+
17
+ export const WithError: Story = {
18
+ args: { label: 'Start Date', error: 'Start date must be in the future' },
19
+ };
20
+
21
+ export const Disabled: Story = {
22
+ args: { label: 'Created', value: '2026-03-01', disabled: true },
23
+ };
@@ -0,0 +1,22 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { DateInput } from './DateInput';
4
+
5
+ describe('DateInput', () => {
6
+ it('renders label and date input', () => {
7
+ render(<DateInput label="Due Date" />);
8
+ expect(screen.getByLabelText('Due Date')).toHaveAttribute('type', 'date');
9
+ });
10
+
11
+ it('shows error state', () => {
12
+ render(<DateInput label="Due Date" error="Required" />);
13
+ expect(screen.getByText('Required')).toBeInTheDocument();
14
+ expect(screen.getByLabelText('Due Date')).toHaveAttribute('aria-invalid', 'true');
15
+ });
16
+
17
+ it('forwards ref', () => {
18
+ const ref = vi.fn();
19
+ render(<DateInput label="Due Date" ref={ref} />);
20
+ expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement));
21
+ });
22
+ });
@@ -0,0 +1,66 @@
1
+ import { forwardRef, useId } from 'react';
2
+ import { Calendar, AlertCircle } from 'lucide-react';
3
+ import { cn } from '../../utils/cn';
4
+ import type { DateInputProps } from './DateInput.types';
5
+
6
+ export const DateInput = forwardRef<HTMLInputElement, DateInputProps>(
7
+ ({ label, helperText, error, className, disabled, id: idProp, ...props }, ref) => {
8
+ const autoId = useId();
9
+ const id = idProp ?? autoId;
10
+ const messageId = `${id}-message`;
11
+
12
+ return (
13
+ <div className={cn('flex flex-col gap-1.5', className)}>
14
+ <label
15
+ htmlFor={id}
16
+ className="text-text-primary font-medium"
17
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
18
+ >
19
+ {label}
20
+ </label>
21
+ <div className="relative">
22
+ <input
23
+ ref={ref}
24
+ id={id}
25
+ type="date"
26
+ disabled={disabled}
27
+ aria-invalid={!!error}
28
+ aria-describedby={error || helperText ? messageId : undefined}
29
+ className={cn(
30
+ 'w-full rounded-md bg-white px-3 py-2 pr-10 text-text-primary',
31
+ 'border outline-none transition-all',
32
+ 'focus:border-rust focus:ring-2 focus:ring-rust-light',
33
+ error ? 'border-error' : 'border-border',
34
+ disabled && 'bg-surface opacity-50 cursor-not-allowed',
35
+ )}
36
+ style={{
37
+ fontSize: 'var(--text-body)',
38
+ lineHeight: 'var(--leading-body)',
39
+ transitionDuration: 'var(--duration-fast)',
40
+ }}
41
+ {...props}
42
+ />
43
+ <Calendar
44
+ size={16}
45
+ className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary"
46
+ />
47
+ </div>
48
+ {(error || helperText) && (
49
+ <p
50
+ id={messageId}
51
+ className={cn(
52
+ 'flex items-center gap-1',
53
+ error ? 'text-error' : 'text-text-secondary',
54
+ )}
55
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
56
+ >
57
+ {error && <AlertCircle size={14} />}
58
+ {error || helperText}
59
+ </p>
60
+ )}
61
+ </div>
62
+ );
63
+ },
64
+ );
65
+
66
+ DateInput.displayName = 'DateInput';
@@ -0,0 +1,10 @@
1
+ import type { ComponentPropsWithoutRef } from 'react';
2
+
3
+ export interface DateInputProps extends Omit<ComponentPropsWithoutRef<'input'>, 'type' | 'size'> {
4
+ /** Label text displayed above the input */
5
+ label: string;
6
+ /** Helper text displayed below the input */
7
+ helperText?: string;
8
+ /** Error message */
9
+ error?: string;
10
+ }
@@ -0,0 +1,2 @@
1
+ export { DateInput } from './DateInput';
2
+ export type { DateInputProps } from './DateInput.types';
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { FileUploadDropzone } from './FileUploadDropzone';
3
+
4
+ const meta: Meta<typeof FileUploadDropzone> = {
5
+ title: 'Primitives/FileUploadDropzone',
6
+ component: FileUploadDropzone,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof FileUploadDropzone>;
12
+
13
+ export const Default: Story = {
14
+ args: {},
15
+ };
16
+
17
+ export const ImagesOnly: Story = {
18
+ args: {
19
+ accept: 'image/*',
20
+ description: 'Drag & drop images here, or click to browse',
21
+ multiple: true,
22
+ },
23
+ };
24
+
25
+ export const Disabled: Story = {
26
+ args: { disabled: true },
27
+ };
@@ -0,0 +1,26 @@
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 { FileUploadDropzone } from './FileUploadDropzone';
5
+
6
+ describe('FileUploadDropzone', () => {
7
+ it('renders upload area', () => {
8
+ render(<FileUploadDropzone />);
9
+ expect(screen.getByRole('button', { name: 'Upload files' })).toBeInTheDocument();
10
+ expect(screen.getByText('Browse files')).toBeInTheDocument();
11
+ });
12
+
13
+ it('renders custom description', () => {
14
+ render(<FileUploadDropzone description="Upload images" />);
15
+ expect(screen.getByText('Upload images')).toBeInTheDocument();
16
+ });
17
+
18
+ it('calls onFiles when files selected via input', async () => {
19
+ const onFiles = vi.fn();
20
+ render(<FileUploadDropzone onFiles={onFiles} />);
21
+ const file = new File(['hello'], 'test.txt', { type: 'text/plain' });
22
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
23
+ await userEvent.upload(input, file);
24
+ expect(onFiles).toHaveBeenCalledWith([file]);
25
+ });
26
+ });
@@ -0,0 +1,99 @@
1
+ import { useState, useRef, useCallback } from 'react';
2
+ import { UploadCloud } from 'lucide-react';
3
+ import { cn } from '../../utils/cn';
4
+ import type { FileUploadDropzoneProps } from './FileUploadDropzone.types';
5
+
6
+ export function FileUploadDropzone({
7
+ onFiles,
8
+ accept,
9
+ multiple = false,
10
+ disabled = false,
11
+ description = 'Drag & drop files here, or click to browse',
12
+ className,
13
+ }: FileUploadDropzoneProps) {
14
+ const [isDragOver, setIsDragOver] = useState(false);
15
+ const inputRef = useRef<HTMLInputElement>(null);
16
+
17
+ const handleFiles = useCallback(
18
+ (fileList: FileList | null) => {
19
+ if (!fileList || disabled) return;
20
+ onFiles?.(Array.from(fileList));
21
+ },
22
+ [onFiles, disabled],
23
+ );
24
+
25
+ const handleDrop = useCallback(
26
+ (e: React.DragEvent) => {
27
+ e.preventDefault();
28
+ setIsDragOver(false);
29
+ handleFiles(e.dataTransfer.files);
30
+ },
31
+ [handleFiles],
32
+ );
33
+
34
+ return (
35
+ <div
36
+ role="button"
37
+ tabIndex={disabled ? -1 : 0}
38
+ aria-label="Upload files"
39
+ onClick={() => !disabled && inputRef.current?.click()}
40
+ onKeyDown={(e) => {
41
+ if (e.key === 'Enter' || e.key === ' ') {
42
+ e.preventDefault();
43
+ if (!disabled) inputRef.current?.click();
44
+ }
45
+ }}
46
+ onDragOver={(e) => {
47
+ e.preventDefault();
48
+ if (!disabled) setIsDragOver(true);
49
+ }}
50
+ onDragLeave={() => setIsDragOver(false)}
51
+ onDrop={handleDrop}
52
+ className={cn(
53
+ 'flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer',
54
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rust',
55
+ isDragOver ? 'border-rust bg-rust-light/30' : 'border-border bg-surface/50',
56
+ disabled && 'opacity-50 cursor-not-allowed',
57
+ className,
58
+ )}
59
+ style={{ transitionDuration: 'var(--duration-fast)' }}
60
+ >
61
+ <UploadCloud size={32} className={cn('text-text-secondary', isDragOver && 'text-rust')} />
62
+ <p
63
+ className="text-text-secondary text-center"
64
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
65
+ >
66
+ {description}
67
+ </p>
68
+ <button
69
+ type="button"
70
+ tabIndex={-1}
71
+ disabled={disabled}
72
+ className={cn(
73
+ 'rounded-md bg-white border border-border px-3 py-1.5 text-text-primary font-medium transition-colors',
74
+ 'hover:bg-surface',
75
+ )}
76
+ style={{
77
+ fontSize: 'var(--text-body-sm)',
78
+ transitionDuration: 'var(--duration-fast)',
79
+ }}
80
+ onClick={(e) => {
81
+ e.stopPropagation();
82
+ inputRef.current?.click();
83
+ }}
84
+ >
85
+ Browse files
86
+ </button>
87
+ <input
88
+ ref={inputRef}
89
+ type="file"
90
+ accept={accept}
91
+ multiple={multiple}
92
+ disabled={disabled}
93
+ className="sr-only"
94
+ onChange={(e) => handleFiles(e.target.files)}
95
+ tabIndex={-1}
96
+ />
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,14 @@
1
+ export interface FileUploadDropzoneProps {
2
+ /** Called when files are dropped or selected */
3
+ onFiles?: (files: File[]) => void;
4
+ /** Accepted file types (e.g., "image/*,.pdf") */
5
+ accept?: string;
6
+ /** Allow multiple files */
7
+ multiple?: boolean;
8
+ /** Whether the dropzone is disabled */
9
+ disabled?: boolean;
10
+ /** Description text */
11
+ description?: string;
12
+ /** Additional CSS classes */
13
+ className?: string;
14
+ }
@@ -0,0 +1,2 @@
1
+ export { FileUploadDropzone } from './FileUploadDropzone';
2
+ export type { FileUploadDropzoneProps } from './FileUploadDropzone.types';
@@ -0,0 +1,31 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { InputGroup } from './InputGroup';
3
+
4
+ const meta: Meta<typeof InputGroup> = {
5
+ title: 'Primitives/InputGroup',
6
+ component: InputGroup,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof InputGroup>;
12
+
13
+ export const Default: Story = {
14
+ args: { label: 'Email', placeholder: 'you@example.com' },
15
+ };
16
+
17
+ export const WithHelperText: Story = {
18
+ args: { label: 'Password', type: 'password', helperText: 'Must be at least 8 characters' },
19
+ };
20
+
21
+ export const ErrorState: Story = {
22
+ args: { label: 'Email', value: 'invalid', error: 'Please enter a valid email address' },
23
+ };
24
+
25
+ export const SuccessState: Story = {
26
+ args: { label: 'Username', value: 'weaveuser', success: 'Username is available' },
27
+ };
28
+
29
+ export const Disabled: Story = {
30
+ args: { label: 'Email', value: 'locked@example.com', disabled: true },
31
+ };
@@ -0,0 +1,40 @@
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 { InputGroup } from './InputGroup';
5
+
6
+ describe('InputGroup', () => {
7
+ it('renders label and input', () => {
8
+ render(<InputGroup label="Email" />);
9
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
10
+ });
11
+
12
+ it('shows helper text', () => {
13
+ render(<InputGroup label="Name" helperText="Enter your full name" />);
14
+ expect(screen.getByText('Enter your full name')).toBeInTheDocument();
15
+ });
16
+
17
+ it('shows error state', () => {
18
+ render(<InputGroup label="Email" error="Invalid email" />);
19
+ expect(screen.getByText('Invalid email')).toBeInTheDocument();
20
+ expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true');
21
+ });
22
+
23
+ it('shows success state', () => {
24
+ render(<InputGroup label="Username" success="Available" />);
25
+ expect(screen.getByText('Available')).toBeInTheDocument();
26
+ });
27
+
28
+ it('handles user input', async () => {
29
+ const onChange = vi.fn();
30
+ render(<InputGroup label="Email" onChange={onChange} />);
31
+ await userEvent.type(screen.getByLabelText('Email'), 'test');
32
+ expect(onChange).toHaveBeenCalled();
33
+ });
34
+
35
+ it('forwards ref', () => {
36
+ const ref = vi.fn();
37
+ render(<InputGroup label="Email" ref={ref} />);
38
+ expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement));
39
+ });
40
+ });
@@ -0,0 +1,65 @@
1
+ import { forwardRef, useId } from 'react';
2
+ import { AlertCircle, CheckCircle2 } from 'lucide-react';
3
+ import { cn } from '../../utils/cn';
4
+ import type { InputGroupProps } from './InputGroup.types';
5
+
6
+ export const InputGroup = forwardRef<HTMLInputElement, InputGroupProps>(
7
+ ({ label, helperText, error, success, className, disabled, id: idProp, ...props }, ref) => {
8
+ const autoId = useId();
9
+ const id = idProp ?? autoId;
10
+ const messageId = `${id}-message`;
11
+ const state = error ? 'error' : success ? 'success' : 'default';
12
+
13
+ return (
14
+ <div className={cn('flex flex-col gap-1.5', className)}>
15
+ <label
16
+ htmlFor={id}
17
+ className="text-text-primary font-medium"
18
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
19
+ >
20
+ {label}
21
+ </label>
22
+ <input
23
+ ref={ref}
24
+ id={id}
25
+ disabled={disabled}
26
+ aria-invalid={state === 'error'}
27
+ aria-describedby={error || success || helperText ? messageId : undefined}
28
+ className={cn(
29
+ 'w-full rounded-md bg-white px-3 py-2 text-text-primary placeholder:text-text-secondary',
30
+ 'border outline-none transition-all',
31
+ 'focus:border-rust focus:ring-2 focus:ring-rust-light',
32
+ state === 'default' && 'border-border',
33
+ state === 'error' && 'border-error',
34
+ state === 'success' && 'border-success',
35
+ disabled && 'bg-surface opacity-50 cursor-not-allowed',
36
+ )}
37
+ style={{
38
+ fontSize: 'var(--text-body)',
39
+ lineHeight: 'var(--leading-body)',
40
+ transitionDuration: 'var(--duration-fast)',
41
+ }}
42
+ {...props}
43
+ />
44
+ {(error || success || helperText) && (
45
+ <p
46
+ id={messageId}
47
+ className={cn(
48
+ 'flex items-center gap-1',
49
+ state === 'error' && 'text-error',
50
+ state === 'success' && 'text-success',
51
+ state === 'default' && 'text-text-secondary',
52
+ )}
53
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
54
+ >
55
+ {state === 'error' && <AlertCircle size={14} />}
56
+ {state === 'success' && <CheckCircle2 size={14} />}
57
+ {error || success || helperText}
58
+ </p>
59
+ )}
60
+ </div>
61
+ );
62
+ },
63
+ );
64
+
65
+ InputGroup.displayName = 'InputGroup';