@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,12 @@
1
+ import type { ComponentPropsWithoutRef } from 'react';
2
+
3
+ export interface InputGroupProps extends Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
4
+ /** Label text displayed above the input */
5
+ label: string;
6
+ /** Helper text displayed below the input */
7
+ helperText?: string;
8
+ /** Error message — sets the input to error state */
9
+ error?: string;
10
+ /** Success message — sets the input to success state */
11
+ success?: string;
12
+ }
@@ -0,0 +1,2 @@
1
+ export { InputGroup } from './InputGroup';
2
+ export type { InputGroupProps } from './InputGroup.types';
@@ -0,0 +1,28 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Link } from './Link';
3
+
4
+ const meta: Meta<typeof Link> = {
5
+ title: 'Primitives/Link',
6
+ component: Link,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof Link>;
12
+
13
+ export const Inline: Story = {
14
+ args: { children: 'Learn more about Weave', href: '#', variant: 'inline' },
15
+ };
16
+
17
+ export const WithIcon: Story = {
18
+ args: { children: 'Documentation', href: '#', variant: 'withIcon' },
19
+ };
20
+
21
+ export const InContext: Story = {
22
+ render: () => (
23
+ <p className="text-text-primary" style={{ fontSize: 'var(--text-body)' }}>
24
+ Read our <Link href="#">getting started guide</Link> to learn how to set up Weave
25
+ in your project, or visit the <Link href="#" variant="withIcon">full docs</Link>.
26
+ </p>
27
+ ),
28
+ };
@@ -0,0 +1,23 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { Link } from './Link';
4
+
5
+ describe('Link', () => {
6
+ it('renders as an anchor', () => {
7
+ render(<Link href="/docs">Docs</Link>);
8
+ const link = screen.getByRole('link', { name: 'Docs' });
9
+ expect(link).toHaveAttribute('href', '/docs');
10
+ });
11
+
12
+ it('shows external icon for withIcon variant', () => {
13
+ render(<Link variant="withIcon" href="#">Docs</Link>);
14
+ const link = screen.getByRole('link');
15
+ expect(link.querySelector('svg')).toBeInTheDocument();
16
+ });
17
+
18
+ it('forwards ref', () => {
19
+ const ref = vi.fn();
20
+ render(<Link ref={ref} href="#">Test</Link>);
21
+ expect(ref).toHaveBeenCalledWith(expect.any(HTMLAnchorElement));
22
+ });
23
+ });
@@ -0,0 +1,28 @@
1
+ import { forwardRef } from 'react';
2
+ import { ExternalLink } from 'lucide-react';
3
+ import { cn } from '../../utils/cn';
4
+ import type { LinkProps } from './Link.types';
5
+
6
+ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
7
+ ({ variant = 'inline', className, children, ...props }, ref) => {
8
+ return (
9
+ <a
10
+ ref={ref}
11
+ className={cn(
12
+ 'text-rust transition-colors hover:text-rust-dark',
13
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rust',
14
+ variant === 'inline' && 'underline underline-offset-2',
15
+ variant === 'withIcon' && 'inline-flex items-center gap-1 no-underline',
16
+ className,
17
+ )}
18
+ style={{ transitionDuration: 'var(--duration-micro)' }}
19
+ {...props}
20
+ >
21
+ {children}
22
+ {variant === 'withIcon' && <ExternalLink size={14} />}
23
+ </a>
24
+ );
25
+ },
26
+ );
27
+
28
+ Link.displayName = 'Link';
@@ -0,0 +1,8 @@
1
+ import type { ComponentPropsWithoutRef } from 'react';
2
+
3
+ export type LinkVariant = 'inline' | 'withIcon';
4
+
5
+ export interface LinkProps extends ComponentPropsWithoutRef<'a'> {
6
+ /** Visual variant */
7
+ variant?: LinkVariant;
8
+ }
@@ -0,0 +1,2 @@
1
+ export { Link } from './Link';
2
+ export type { LinkProps, LinkVariant } from './Link.types';
@@ -0,0 +1,29 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Radio } from './Radio';
3
+
4
+ const meta: Meta<typeof Radio> = {
5
+ title: 'Primitives/Radio',
6
+ component: Radio,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof Radio>;
12
+
13
+ export const Default: Story = {
14
+ args: { label: 'Option A', name: 'demo' },
15
+ };
16
+
17
+ export const Group: Story = {
18
+ render: () => (
19
+ <div className="flex flex-col gap-3">
20
+ <Radio label="Small" name="size" value="sm" defaultChecked />
21
+ <Radio label="Medium" name="size" value="md" />
22
+ <Radio label="Large" name="size" value="lg" />
23
+ </div>
24
+ ),
25
+ };
26
+
27
+ export const Disabled: Story = {
28
+ args: { label: 'Disabled', disabled: true },
29
+ };
@@ -0,0 +1,32 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, it, expect } from 'vitest';
4
+ import { Radio } from './Radio';
5
+
6
+ describe('Radio', () => {
7
+ it('renders with label', () => {
8
+ render(<Radio label="Option A" name="test" />);
9
+ expect(screen.getByLabelText('Option A')).toBeInTheDocument();
10
+ });
11
+
12
+ it('selects on click', async () => {
13
+ render(<Radio label="Option A" name="test" />);
14
+ const radio = screen.getByLabelText('Option A');
15
+ await userEvent.click(radio);
16
+ expect(radio).toBeChecked();
17
+ });
18
+
19
+ it('works as a group', async () => {
20
+ render(
21
+ <>
22
+ <Radio label="A" name="group" value="a" />
23
+ <Radio label="B" name="group" value="b" />
24
+ </>,
25
+ );
26
+ await userEvent.click(screen.getByLabelText('A'));
27
+ expect(screen.getByLabelText('A')).toBeChecked();
28
+ await userEvent.click(screen.getByLabelText('B'));
29
+ expect(screen.getByLabelText('B')).toBeChecked();
30
+ expect(screen.getByLabelText('A')).not.toBeChecked();
31
+ });
32
+ });
@@ -0,0 +1,59 @@
1
+ import { forwardRef, useId } from 'react';
2
+ import { cn } from '../../utils/cn';
3
+ import type { RadioProps } from './Radio.types';
4
+
5
+ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
6
+ ({ label, className, disabled, id: idProp, ...props }, ref) => {
7
+ const autoId = useId();
8
+ const id = idProp ?? autoId;
9
+
10
+ return (
11
+ <label
12
+ htmlFor={id}
13
+ className={cn(
14
+ 'inline-flex items-center gap-2 cursor-pointer select-none',
15
+ disabled && 'opacity-40 cursor-not-allowed',
16
+ className,
17
+ )}
18
+ >
19
+ <div className="relative flex items-center justify-center">
20
+ <input
21
+ ref={ref}
22
+ id={id}
23
+ type="radio"
24
+ disabled={disabled}
25
+ className="peer sr-only"
26
+ {...props}
27
+ />
28
+ <div
29
+ className={cn(
30
+ 'h-5 w-5 rounded-full border-2 transition-all flex items-center justify-center',
31
+ 'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-rust',
32
+ 'peer-checked:border-rust border-border',
33
+ )}
34
+ style={{ transitionDuration: 'var(--duration-fast)' }}
35
+ >
36
+ <div
37
+ className={cn(
38
+ 'h-2.5 w-2.5 rounded-full bg-rust transition-all',
39
+ 'peer-checked:scale-100 scale-0',
40
+ )}
41
+ style={{
42
+ transitionDuration: 'var(--duration-fast)',
43
+ transitionTimingFunction: 'var(--ease-spring)',
44
+ }}
45
+ />
46
+ </div>
47
+ </div>
48
+ <span
49
+ className="text-text-primary"
50
+ style={{ fontSize: 'var(--text-body)', lineHeight: 'var(--leading-body)' }}
51
+ >
52
+ {label}
53
+ </span>
54
+ </label>
55
+ );
56
+ },
57
+ );
58
+
59
+ Radio.displayName = 'Radio';
@@ -0,0 +1,6 @@
1
+ import type { ComponentPropsWithoutRef } from 'react';
2
+
3
+ export interface RadioProps extends Omit<ComponentPropsWithoutRef<'input'>, 'type' | 'size'> {
4
+ /** Label text displayed next to the radio button */
5
+ label: string;
6
+ }
@@ -0,0 +1,2 @@
1
+ export { Radio } from './Radio';
2
+ export type { RadioProps } from './Radio.types';
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { SelectGroup } from './SelectGroup';
3
+
4
+ const meta: Meta<typeof SelectGroup> = {
5
+ title: 'Primitives/SelectGroup',
6
+ component: SelectGroup,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof SelectGroup>;
12
+
13
+ export const Default: Story = {
14
+ args: { label: 'Status', placeholder: 'Select a status' },
15
+ render: (args) => (
16
+ <SelectGroup {...args}>
17
+ <option value="active">Active</option>
18
+ <option value="paused">Paused</option>
19
+ <option value="archived">Archived</option>
20
+ </SelectGroup>
21
+ ),
22
+ };
23
+
24
+ export const ErrorState: Story = {
25
+ args: { label: 'Priority', error: 'Please select a priority' },
26
+ render: (args) => (
27
+ <SelectGroup {...args}>
28
+ <option value="high">High</option>
29
+ <option value="medium">Medium</option>
30
+ <option value="low">Low</option>
31
+ </SelectGroup>
32
+ ),
33
+ };
@@ -0,0 +1,34 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { SelectGroup } from './SelectGroup';
4
+
5
+ describe('SelectGroup', () => {
6
+ it('renders label and select', () => {
7
+ render(
8
+ <SelectGroup label="Status">
9
+ <option value="active">Active</option>
10
+ </SelectGroup>,
11
+ );
12
+ expect(screen.getByLabelText('Status')).toBeInTheDocument();
13
+ });
14
+
15
+ it('shows error state', () => {
16
+ render(
17
+ <SelectGroup label="Status" error="Required">
18
+ <option value="active">Active</option>
19
+ </SelectGroup>,
20
+ );
21
+ expect(screen.getByText('Required')).toBeInTheDocument();
22
+ expect(screen.getByLabelText('Status')).toHaveAttribute('aria-invalid', 'true');
23
+ });
24
+
25
+ it('forwards ref', () => {
26
+ const ref = vi.fn();
27
+ render(
28
+ <SelectGroup label="Status" ref={ref}>
29
+ <option value="active">Active</option>
30
+ </SelectGroup>,
31
+ );
32
+ expect(ref).toHaveBeenCalledWith(expect.any(HTMLSelectElement));
33
+ });
34
+ });
@@ -0,0 +1,72 @@
1
+ import { forwardRef, useId } from 'react';
2
+ import { AlertCircle, ChevronDown } from 'lucide-react';
3
+ import { cn } from '../../utils/cn';
4
+ import type { SelectGroupProps } from './SelectGroup.types';
5
+
6
+ export const SelectGroup = forwardRef<HTMLSelectElement, SelectGroupProps>(
7
+ ({ label, helperText, error, placeholder, className, disabled, children, 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
+ <select
23
+ ref={ref}
24
+ id={id}
25
+ disabled={disabled}
26
+ aria-invalid={!!error}
27
+ aria-describedby={error || helperText ? messageId : undefined}
28
+ className={cn(
29
+ 'w-full appearance-none rounded-md bg-white px-3 py-2 pr-10 text-text-primary',
30
+ 'border outline-none transition-all',
31
+ 'focus:border-rust focus:ring-2 focus:ring-rust-light',
32
+ error ? 'border-error' : 'border-border',
33
+ disabled && 'bg-surface opacity-50 cursor-not-allowed',
34
+ )}
35
+ style={{
36
+ fontSize: 'var(--text-body)',
37
+ lineHeight: 'var(--leading-body)',
38
+ transitionDuration: 'var(--duration-fast)',
39
+ }}
40
+ {...props}
41
+ >
42
+ {placeholder && (
43
+ <option value="" disabled>
44
+ {placeholder}
45
+ </option>
46
+ )}
47
+ {children}
48
+ </select>
49
+ <ChevronDown
50
+ size={16}
51
+ className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary"
52
+ />
53
+ </div>
54
+ {(error || helperText) && (
55
+ <p
56
+ id={messageId}
57
+ className={cn(
58
+ 'flex items-center gap-1',
59
+ error ? 'text-error' : 'text-text-secondary',
60
+ )}
61
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
62
+ >
63
+ {error && <AlertCircle size={14} />}
64
+ {error || helperText}
65
+ </p>
66
+ )}
67
+ </div>
68
+ );
69
+ },
70
+ );
71
+
72
+ SelectGroup.displayName = 'SelectGroup';
@@ -0,0 +1,12 @@
1
+ import type { ComponentPropsWithoutRef } from 'react';
2
+
3
+ export interface SelectGroupProps extends ComponentPropsWithoutRef<'select'> {
4
+ /** Label text displayed above the select */
5
+ label: string;
6
+ /** Helper text displayed below the select */
7
+ helperText?: string;
8
+ /** Error message — sets the select to error state */
9
+ error?: string;
10
+ /** Placeholder option text */
11
+ placeholder?: string;
12
+ }
@@ -0,0 +1,2 @@
1
+ export { SelectGroup } from './SelectGroup';
2
+ export type { SelectGroupProps } from './SelectGroup.types';
@@ -0,0 +1,23 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Slider } from './Slider';
3
+
4
+ const meta: Meta<typeof Slider> = {
5
+ title: 'Primitives/Slider',
6
+ component: Slider,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof Slider>;
12
+
13
+ export const Default: Story = {
14
+ args: { label: 'Volume', defaultValue: 60 },
15
+ };
16
+
17
+ export const WithRange: Story = {
18
+ args: { label: 'Price', min: 0, max: 500, step: 10, defaultValue: 200 },
19
+ };
20
+
21
+ export const Disabled: Story = {
22
+ args: { label: 'Brightness', defaultValue: 75, disabled: true },
23
+ };
@@ -0,0 +1,28 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { Slider } from './Slider';
4
+
5
+ describe('Slider', () => {
6
+ it('renders label and value', () => {
7
+ render(<Slider label="Volume" defaultValue={60} />);
8
+ expect(screen.getByText('Volume')).toBeInTheDocument();
9
+ expect(screen.getByText('60')).toBeInTheDocument();
10
+ });
11
+
12
+ it('renders range input', () => {
13
+ render(<Slider label="Volume" />);
14
+ expect(screen.getByRole('slider')).toBeInTheDocument();
15
+ });
16
+
17
+ it('reflects controlled value', () => {
18
+ render(<Slider label="Volume" value={42} />);
19
+ expect(screen.getByText('42')).toBeInTheDocument();
20
+ });
21
+
22
+ it('respects min and max', () => {
23
+ render(<Slider label="Volume" min={10} max={90} />);
24
+ const slider = screen.getByRole('slider');
25
+ expect(slider).toHaveAttribute('min', '10');
26
+ expect(slider).toHaveAttribute('max', '90');
27
+ });
28
+ });
@@ -0,0 +1,80 @@
1
+ import { useId } from 'react';
2
+ import { cn } from '../../utils/cn';
3
+ import { useControllableState } from '../../hooks/use-controllable-state';
4
+ import type { SliderProps } from './Slider.types';
5
+
6
+ export function Slider({
7
+ label,
8
+ value: controlledValue,
9
+ defaultValue = 50,
10
+ min = 0,
11
+ max = 100,
12
+ step = 1,
13
+ onChange,
14
+ disabled = false,
15
+ showValue = true,
16
+ className,
17
+ }: SliderProps) {
18
+ const id = useId();
19
+ const [value, setValue] = useControllableState({
20
+ value: controlledValue,
21
+ defaultValue,
22
+ onChange,
23
+ });
24
+
25
+ const percent = ((value - min) / (max - min)) * 100;
26
+
27
+ return (
28
+ <div className={cn('flex flex-col gap-2', disabled && 'opacity-50', className)}>
29
+ <div className="flex items-center justify-between">
30
+ <label
31
+ htmlFor={id}
32
+ className="text-text-primary font-medium"
33
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
34
+ >
35
+ {label}
36
+ </label>
37
+ {showValue && (
38
+ <span
39
+ className="text-text-secondary font-mono tabular-nums"
40
+ style={{ fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' }}
41
+ >
42
+ {value}
43
+ </span>
44
+ )}
45
+ </div>
46
+ <div className="relative flex items-center h-5">
47
+ <div className="absolute w-full h-1.5 rounded-full bg-border">
48
+ <div
49
+ className="absolute h-full rounded-full bg-rust"
50
+ style={{ width: `${percent}%` }}
51
+ />
52
+ </div>
53
+ <input
54
+ id={id}
55
+ type="range"
56
+ min={min}
57
+ max={max}
58
+ step={step}
59
+ value={value}
60
+ disabled={disabled}
61
+ onChange={(e) => setValue(Number(e.target.value))}
62
+ className={cn(
63
+ 'absolute w-full h-5 appearance-none bg-transparent cursor-pointer',
64
+ 'focus-visible:outline-none',
65
+ '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5',
66
+ '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-rust [&::-webkit-slider-thumb]:shadow-2',
67
+ '[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white',
68
+ '[&::-webkit-slider-thumb]:transition-transform',
69
+ '[&::-webkit-slider-thumb]:hover:scale-110',
70
+ '[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5',
71
+ '[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-rust [&::-moz-range-thumb]:shadow-2',
72
+ '[&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white',
73
+ disabled && 'cursor-not-allowed',
74
+ )}
75
+ aria-label={label}
76
+ />
77
+ </div>
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,22 @@
1
+ export interface SliderProps {
2
+ /** Label text */
3
+ label: string;
4
+ /** Current value (controlled) */
5
+ value?: number;
6
+ /** Default value (uncontrolled) */
7
+ defaultValue?: number;
8
+ /** Minimum value */
9
+ min?: number;
10
+ /** Maximum value */
11
+ max?: number;
12
+ /** Step increment */
13
+ step?: number;
14
+ /** Change handler */
15
+ onChange?: (value: number) => void;
16
+ /** Whether the slider is disabled */
17
+ disabled?: boolean;
18
+ /** Show value label next to the slider */
19
+ showValue?: boolean;
20
+ /** Additional CSS classes */
21
+ className?: string;
22
+ }
@@ -0,0 +1,2 @@
1
+ export { Slider } from './Slider';
2
+ export type { SliderProps } from './Slider.types';
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { TextareaGroup } from './TextareaGroup';
3
+
4
+ const meta: Meta<typeof TextareaGroup> = {
5
+ title: 'Primitives/TextareaGroup',
6
+ component: TextareaGroup,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof TextareaGroup>;
12
+
13
+ export const Default: Story = {
14
+ args: { label: 'Description', placeholder: 'Enter a description...' },
15
+ };
16
+
17
+ export const WithHelperText: Story = {
18
+ args: { label: 'Notes', helperText: 'Markdown supported', placeholder: 'Write your notes...' },
19
+ };
20
+
21
+ export const ErrorState: Story = {
22
+ args: { label: 'Bio', error: 'Bio is required' },
23
+ };
24
+
25
+ export const Disabled: Story = {
26
+ args: { label: 'Description', value: 'Locked content', disabled: true },
27
+ };
@@ -0,0 +1,24 @@
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 { TextareaGroup } from './TextareaGroup';
5
+
6
+ describe('TextareaGroup', () => {
7
+ it('renders label and textarea', () => {
8
+ render(<TextareaGroup label="Description" />);
9
+ expect(screen.getByLabelText('Description')).toBeInTheDocument();
10
+ });
11
+
12
+ it('shows error state', () => {
13
+ render(<TextareaGroup label="Bio" error="Required" />);
14
+ expect(screen.getByText('Required')).toBeInTheDocument();
15
+ expect(screen.getByLabelText('Bio')).toHaveAttribute('aria-invalid', 'true');
16
+ });
17
+
18
+ it('handles user input', async () => {
19
+ const onChange = vi.fn();
20
+ render(<TextareaGroup label="Notes" onChange={onChange} />);
21
+ await userEvent.type(screen.getByLabelText('Notes'), 'hello');
22
+ expect(onChange).toHaveBeenCalled();
23
+ });
24
+ });