@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,16 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Carousel } from './Carousel';
3
+
4
+ const meta: Meta<typeof Carousel> = { title: 'Patterns/Carousel', component: Carousel, tags: ['autodocs'] };
5
+ export default meta;
6
+ type Story = StoryObj<typeof Carousel>;
7
+
8
+ export const Default: Story = {
9
+ render: () => (
10
+ <Carousel className="max-w-md">
11
+ <div className="h-48 bg-rust-light rounded-lg flex items-center justify-center text-rust-dark font-semibold" style={{ fontSize: 'var(--text-h2)' }}>Slide 1</div>
12
+ <div className="h-48 bg-forest-light rounded-lg flex items-center justify-center text-forest font-semibold" style={{ fontSize: 'var(--text-h2)' }}>Slide 2</div>
13
+ <div className="h-48 bg-saffron-light rounded-lg flex items-center justify-center text-saffron font-semibold" style={{ fontSize: 'var(--text-h2)' }}>Slide 3</div>
14
+ </Carousel>
15
+ ),
16
+ };
@@ -0,0 +1,16 @@
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 { Carousel } from './Carousel';
5
+
6
+ describe('Carousel', () => {
7
+ it('renders first slide', () => {
8
+ render(<Carousel><div>Slide 1</div><div>Slide 2</div></Carousel>);
9
+ expect(screen.getByText('Slide 1')).toBeInTheDocument();
10
+ });
11
+ it('navigates to next slide', async () => {
12
+ render(<Carousel><div>Slide 1</div><div>Slide 2</div></Carousel>);
13
+ await userEvent.click(screen.getByRole('button', { name: 'Next slide' }));
14
+ expect(screen.getByText('Slide 2')).toBeInTheDocument();
15
+ });
16
+ });
@@ -0,0 +1,69 @@
1
+ import { useState, Children } from 'react';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import { cn } from '../../utils/cn';
4
+
5
+ export interface CarouselProps {
6
+ children: React.ReactNode;
7
+ className?: string;
8
+ }
9
+
10
+ export function Carousel({ children, className }: CarouselProps) {
11
+ const slides = Children.toArray(children);
12
+ const [index, setIndex] = useState(0);
13
+
14
+ const prev = () => setIndex((i) => (i > 0 ? i - 1 : slides.length - 1));
15
+ const next = () => setIndex((i) => (i < slides.length - 1 ? i + 1 : 0));
16
+
17
+ return (
18
+ <div className={cn('relative overflow-hidden rounded-lg', className)}>
19
+ {/* Slide track — translateX to the active slide */}
20
+ <div
21
+ className="flex transition-transform"
22
+ style={{
23
+ transform: `translateX(-${index * 100}%)`,
24
+ transitionDuration: 'var(--duration-normal)',
25
+ transitionTimingFunction: 'var(--ease-out)',
26
+ }}
27
+ >
28
+ {slides.map((slide, i) => (
29
+ <div key={i} className="w-full shrink-0">
30
+ {slide}
31
+ </div>
32
+ ))}
33
+ </div>
34
+ <button
35
+ type="button"
36
+ onClick={prev}
37
+ className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white/80 shadow-1 flex items-center justify-center text-text-primary hover:bg-white transition-colors"
38
+ aria-label="Previous slide"
39
+ style={{ transitionDuration: 'var(--duration-micro)' }}
40
+ >
41
+ <ChevronLeft size={16} />
42
+ </button>
43
+ <button
44
+ type="button"
45
+ onClick={next}
46
+ className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white/80 shadow-1 flex items-center justify-center text-text-primary hover:bg-white transition-colors"
47
+ aria-label="Next slide"
48
+ style={{ transitionDuration: 'var(--duration-micro)' }}
49
+ >
50
+ <ChevronRight size={16} />
51
+ </button>
52
+ <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-1.5">
53
+ {slides.map((_, i) => (
54
+ <button
55
+ key={i}
56
+ type="button"
57
+ onClick={() => setIndex(i)}
58
+ className={cn(
59
+ 'rounded-full transition-all',
60
+ i === index ? 'bg-rust w-4 h-2' : 'bg-white/60 w-2 h-2',
61
+ )}
62
+ aria-label={`Go to slide ${i + 1}`}
63
+ style={{ transitionDuration: 'var(--duration-fast)' }}
64
+ />
65
+ ))}
66
+ </div>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,2 @@
1
+ export { Carousel } from './Carousel';
2
+ export type { CarouselProps } from './Carousel';
@@ -0,0 +1,9 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { ImagePlaceholder } from './ImagePlaceholder';
3
+
4
+ const meta: Meta<typeof ImagePlaceholder> = { title: 'Patterns/ImagePlaceholder', component: ImagePlaceholder, tags: ['autodocs'] };
5
+ export default meta;
6
+ type Story = StoryObj<typeof ImagePlaceholder>;
7
+
8
+ export const Default: Story = { args: {} };
9
+ export const Custom: Story = { args: { width: 300, height: 150, label: 'Upload an image' } };
@@ -0,0 +1,10 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { ImagePlaceholder } from './ImagePlaceholder';
4
+
5
+ describe('ImagePlaceholder', () => {
6
+ it('renders label', () => {
7
+ render(<ImagePlaceholder />);
8
+ expect(screen.getByText('No image')).toBeInTheDocument();
9
+ });
10
+ });
@@ -0,0 +1,21 @@
1
+ import { ImageIcon } from 'lucide-react';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export interface ImagePlaceholderProps {
5
+ width?: string | number;
6
+ height?: string | number;
7
+ label?: string;
8
+ className?: string;
9
+ }
10
+
11
+ export function ImagePlaceholder({ width = '100%', height = 200, label = 'No image', className }: ImagePlaceholderProps) {
12
+ return (
13
+ <div
14
+ className={cn('flex flex-col items-center justify-center gap-2 rounded-lg bg-surface text-text-secondary', className)}
15
+ style={{ width, height }}
16
+ >
17
+ <ImageIcon size={24} />
18
+ <span style={{ fontSize: 'var(--text-body-sm)' }}>{label}</span>
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,2 @@
1
+ export { ImagePlaceholder } from './ImagePlaceholder';
2
+ export type { ImagePlaceholderProps } from './ImagePlaceholder';
@@ -0,0 +1,17 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Bell } from 'lucide-react';
3
+ import { NotificationDot } from './NotificationDot';
4
+
5
+ const meta: Meta<typeof NotificationDot> = { title: 'Patterns/NotificationDot', component: NotificationDot, tags: ['autodocs'] };
6
+ export default meta;
7
+ type Story = StoryObj<typeof NotificationDot>;
8
+
9
+ export const Default: Story = {
10
+ render: () => (
11
+ <NotificationDot>
12
+ <button className="p-2 rounded-md text-text-secondary hover:bg-surface">
13
+ <Bell size={20} />
14
+ </button>
15
+ </NotificationDot>
16
+ ),
17
+ };
@@ -0,0 +1,14 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { NotificationDot } from './NotificationDot';
4
+
5
+ describe('NotificationDot', () => {
6
+ it('renders dot when show is true', () => {
7
+ const { container } = render(<NotificationDot><span>Bell</span></NotificationDot>);
8
+ expect(container.querySelector('.bg-error')).toBeInTheDocument();
9
+ });
10
+ it('hides dot when show is false', () => {
11
+ const { container } = render(<NotificationDot show={false}><span>Bell</span></NotificationDot>);
12
+ expect(container.querySelector('.bg-error')).not.toBeInTheDocument();
13
+ });
14
+ });
@@ -0,0 +1,18 @@
1
+ import { cn } from '../../utils/cn';
2
+
3
+ export interface NotificationDotProps {
4
+ children: React.ReactNode;
5
+ show?: boolean;
6
+ className?: string;
7
+ }
8
+
9
+ export function NotificationDot({ children, show = true, className }: NotificationDotProps) {
10
+ return (
11
+ <div className={cn('relative inline-flex', className)}>
12
+ {children}
13
+ {show && (
14
+ <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-error ring-2 ring-white" />
15
+ )}
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,2 @@
1
+ export { NotificationDot } from './NotificationDot';
2
+ export type { NotificationDotProps } from './NotificationDot';
@@ -0,0 +1,14 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { Pagination } from './Pagination';
4
+
5
+ const meta: Meta<typeof Pagination> = { title: 'Patterns/Pagination', component: Pagination, tags: ['autodocs'] };
6
+ export default meta;
7
+ type Story = StoryObj<typeof Pagination>;
8
+
9
+ export const Default: Story = {
10
+ render: () => {
11
+ const [page, setPage] = useState(1);
12
+ return <Pagination currentPage={page} totalPages={10} onPageChange={setPage} />;
13
+ },
14
+ };
@@ -0,0 +1,22 @@
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 { Pagination } from './Pagination';
5
+
6
+ describe('Pagination', () => {
7
+ it('renders page numbers', () => {
8
+ render(<Pagination currentPage={1} totalPages={5} onPageChange={() => {}} />);
9
+ expect(screen.getByText('1')).toBeInTheDocument();
10
+ expect(screen.getByText('5')).toBeInTheDocument();
11
+ });
12
+ it('calls onPageChange', async () => {
13
+ const onChange = vi.fn();
14
+ render(<Pagination currentPage={1} totalPages={5} onPageChange={onChange} />);
15
+ await userEvent.click(screen.getByText('3'));
16
+ expect(onChange).toHaveBeenCalledWith(3);
17
+ });
18
+ it('marks current page', () => {
19
+ render(<Pagination currentPage={2} totalPages={5} onPageChange={() => {}} />);
20
+ expect(screen.getByText('2')).toHaveAttribute('aria-current', 'page');
21
+ });
22
+ });
@@ -0,0 +1,67 @@
1
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ export interface PaginationProps {
5
+ currentPage: number;
6
+ totalPages: number;
7
+ onPageChange: (page: number) => void;
8
+ className?: string;
9
+ }
10
+
11
+ function getPageNumbers(current: number, total: number): (number | '...')[] {
12
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
13
+ const pages: (number | '...')[] = [1];
14
+ if (current > 3) pages.push('...');
15
+ for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) pages.push(i);
16
+ if (current < total - 2) pages.push('...');
17
+ pages.push(total);
18
+ return pages;
19
+ }
20
+
21
+ export function Pagination({ currentPage, totalPages, onPageChange, className }: PaginationProps) {
22
+ const pages = getPageNumbers(currentPage, totalPages);
23
+
24
+ return (
25
+ <nav aria-label="Pagination" className={cn('flex items-center gap-1', className)}>
26
+ <button
27
+ type="button"
28
+ onClick={() => onPageChange(currentPage - 1)}
29
+ disabled={currentPage <= 1}
30
+ className="p-2 rounded-md text-text-secondary hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
31
+ aria-label="Previous page"
32
+ style={{ transitionDuration: 'var(--duration-micro)' }}
33
+ >
34
+ <ChevronLeft size={16} />
35
+ </button>
36
+ {pages.map((page, i) =>
37
+ page === '...' ? (
38
+ <span key={`ellipsis-${i}`} className="px-2 text-text-secondary" style={{ fontSize: 'var(--text-body-sm)' }}>...</span>
39
+ ) : (
40
+ <button
41
+ key={page}
42
+ type="button"
43
+ onClick={() => onPageChange(page)}
44
+ aria-current={page === currentPage ? 'page' : undefined}
45
+ className={cn(
46
+ 'min-w-[36px] h-9 rounded-md font-medium transition-colors',
47
+ page === currentPage ? 'bg-rust text-white' : 'text-text-primary hover:bg-surface',
48
+ )}
49
+ style={{ fontSize: 'var(--text-body-sm)', transitionDuration: 'var(--duration-micro)' }}
50
+ >
51
+ {page}
52
+ </button>
53
+ ),
54
+ )}
55
+ <button
56
+ type="button"
57
+ onClick={() => onPageChange(currentPage + 1)}
58
+ disabled={currentPage >= totalPages}
59
+ className="p-2 rounded-md text-text-secondary hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
60
+ aria-label="Next page"
61
+ style={{ transitionDuration: 'var(--duration-micro)' }}
62
+ >
63
+ <ChevronRight size={16} />
64
+ </button>
65
+ </nav>
66
+ );
67
+ }
@@ -0,0 +1,2 @@
1
+ export { Pagination } from './Pagination';
2
+ export type { PaginationProps } from './Pagination';
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Avatar } from './Avatar';
3
+ import { AvatarGroup } from './AvatarGroup';
4
+
5
+ const meta: Meta<typeof Avatar> = {
6
+ title: 'Primitives/Avatar',
7
+ component: Avatar,
8
+ tags: ['autodocs'],
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof Avatar>;
13
+
14
+ export const Sizes: Story = {
15
+ render: () => (
16
+ <div className="flex items-center gap-4">
17
+ <Avatar name="Jane Doe" size="sm" />
18
+ <Avatar name="Jane Doe" size="md" />
19
+ <Avatar name="Jane Doe" size="lg" />
20
+ <Avatar name="Jane Doe" size="xl" />
21
+ </div>
22
+ ),
23
+ };
24
+
25
+ export const WithImage: Story = {
26
+ args: {
27
+ name: 'Jane Doe',
28
+ src: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&fit=crop',
29
+ },
30
+ };
31
+
32
+ export const InitialsFallback: Story = {
33
+ args: { name: 'Hank Morrison' },
34
+ };
35
+
36
+ export const Group: Story = {
37
+ render: () => (
38
+ <AvatarGroup max={3}>
39
+ <Avatar name="Alice" />
40
+ <Avatar name="Bob" />
41
+ <Avatar name="Charlie" />
42
+ <Avatar name="Diana" />
43
+ <Avatar name="Eve" />
44
+ </AvatarGroup>
45
+ ),
46
+ };
@@ -0,0 +1,35 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { Avatar } from './Avatar';
4
+ import { AvatarGroup } from './AvatarGroup';
5
+
6
+ describe('Avatar', () => {
7
+ it('renders initials from name', () => {
8
+ render(<Avatar name="Jane Doe" />);
9
+ expect(screen.getByText('JD')).toBeInTheDocument();
10
+ });
11
+
12
+ it('has accessible label', () => {
13
+ render(<Avatar name="Jane Doe" />);
14
+ expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument();
15
+ });
16
+
17
+ it('renders image when src provided', () => {
18
+ render(<Avatar name="Jane" src="https://example.com/avatar.jpg" />);
19
+ expect(screen.getByAltText('Jane')).toBeInTheDocument();
20
+ });
21
+ });
22
+
23
+ describe('AvatarGroup', () => {
24
+ it('shows overflow count', () => {
25
+ render(
26
+ <AvatarGroup max={2}>
27
+ <Avatar name="A" />
28
+ <Avatar name="B" />
29
+ <Avatar name="C" />
30
+ <Avatar name="D" />
31
+ </AvatarGroup>,
32
+ );
33
+ expect(screen.getByText('+2')).toBeInTheDocument();
34
+ });
35
+ });
@@ -0,0 +1,49 @@
1
+ import { useState } from 'react';
2
+ import { cn } from '../../utils/cn';
3
+ import type { AvatarProps } from './Avatar.types';
4
+
5
+ const sizeMap: Record<string, { px: number; textStyle: React.CSSProperties }> = {
6
+ sm: { px: 28, textStyle: { fontSize: 'var(--text-caption)', lineHeight: 'var(--leading-caption)' } },
7
+ md: { px: 40, textStyle: { fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' } },
8
+ lg: { px: 56, textStyle: { fontSize: 'var(--text-body-lg)', lineHeight: 'var(--leading-body-lg)' } },
9
+ xl: { px: 80, textStyle: { fontSize: 'var(--text-h2)', lineHeight: 'var(--leading-h2)' } },
10
+ };
11
+
12
+ function getInitials(name: string): string {
13
+ return name
14
+ .split(' ')
15
+ .map((part) => part[0])
16
+ .filter(Boolean)
17
+ .slice(0, 2)
18
+ .join('')
19
+ .toUpperCase();
20
+ }
21
+
22
+ export function Avatar({ name, src, size = 'md', className }: AvatarProps) {
23
+ const [imgError, setImgError] = useState(false);
24
+ const { px, textStyle } = sizeMap[size];
25
+ const showImage = src && !imgError;
26
+
27
+ return (
28
+ <div
29
+ className={cn(
30
+ 'relative inline-flex shrink-0 items-center justify-center rounded-full bg-rust-light text-rust-dark font-semibold overflow-hidden',
31
+ className,
32
+ )}
33
+ style={{ width: px, height: px }}
34
+ role="img"
35
+ aria-label={name}
36
+ >
37
+ {showImage ? (
38
+ <img
39
+ src={src}
40
+ alt={name}
41
+ className="h-full w-full object-cover"
42
+ onError={() => setImgError(true)}
43
+ />
44
+ ) : (
45
+ <span style={textStyle}>{getInitials(name)}</span>
46
+ )}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,21 @@
1
+ export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl';
2
+
3
+ export interface AvatarProps {
4
+ /** Full name — used for initials fallback and alt text */
5
+ name: string;
6
+ /** Image URL */
7
+ src?: string;
8
+ /** Size variant */
9
+ size?: AvatarSize;
10
+ /** Additional CSS classes */
11
+ className?: string;
12
+ }
13
+
14
+ export interface AvatarGroupProps {
15
+ /** Avatar elements */
16
+ children: React.ReactNode;
17
+ /** Maximum number to display before +N overflow */
18
+ max?: number;
19
+ /** Additional CSS classes */
20
+ className?: string;
21
+ }
@@ -0,0 +1,27 @@
1
+ import { Children } from 'react';
2
+ import { cn } from '../../utils/cn';
3
+ import type { AvatarGroupProps } from './Avatar.types';
4
+
5
+ export function AvatarGroup({ children, max, className }: AvatarGroupProps) {
6
+ const childArray = Children.toArray(children);
7
+ const visible = max ? childArray.slice(0, max) : childArray;
8
+ const overflow = max ? childArray.length - max : 0;
9
+
10
+ return (
11
+ <div className={cn('flex -space-x-2', className)}>
12
+ {visible.map((child, i) => (
13
+ <div key={i} className="relative ring-2 ring-bg rounded-full">
14
+ {child}
15
+ </div>
16
+ ))}
17
+ {overflow > 0 && (
18
+ <div
19
+ className="relative inline-flex shrink-0 items-center justify-center rounded-full bg-surface text-text-secondary font-semibold ring-2 ring-bg"
20
+ style={{ width: 40, height: 40, fontSize: 'var(--text-body-sm)' }}
21
+ >
22
+ +{overflow}
23
+ </div>
24
+ )}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,3 @@
1
+ export { Avatar } from './Avatar';
2
+ export { AvatarGroup } from './AvatarGroup';
3
+ export type { AvatarProps, AvatarGroupProps, AvatarSize } from './Avatar.types';
@@ -0,0 +1,28 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Badge } from './Badge';
3
+
4
+ const meta: Meta<typeof Badge> = {
5
+ title: 'Primitives/Badge',
6
+ component: Badge,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof Badge>;
12
+
13
+ export const AllVariants: Story = {
14
+ render: () => (
15
+ <div className="flex flex-wrap gap-2">
16
+ <Badge variant="primary">Design</Badge>
17
+ <Badge variant="secondary">Engineering</Badge>
18
+ <Badge variant="success">Completed</Badge>
19
+ <Badge variant="warning">In Review</Badge>
20
+ <Badge variant="error">Urgent</Badge>
21
+ <Badge variant="info">Info</Badge>
22
+ </div>
23
+ ),
24
+ };
25
+
26
+ export const Removable: Story = {
27
+ args: { children: 'Removable Tag', removable: true, onRemove: () => {} },
28
+ };
@@ -0,0 +1,23 @@
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 { Badge } from './Badge';
5
+
6
+ describe('Badge', () => {
7
+ it('renders children', () => {
8
+ render(<Badge>Design</Badge>);
9
+ expect(screen.getByText('Design')).toBeInTheDocument();
10
+ });
11
+
12
+ it('renders remove button when removable', () => {
13
+ render(<Badge removable>Tag</Badge>);
14
+ expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
15
+ });
16
+
17
+ it('calls onRemove', async () => {
18
+ const onRemove = vi.fn();
19
+ render(<Badge removable onRemove={onRemove}>Tag</Badge>);
20
+ await userEvent.click(screen.getByRole('button', { name: 'Remove' }));
21
+ expect(onRemove).toHaveBeenCalledOnce();
22
+ });
23
+ });
@@ -0,0 +1,44 @@
1
+ import { X } from 'lucide-react';
2
+ import { cn } from '../../utils/cn';
3
+ import type { BadgeProps } from './Badge.types';
4
+
5
+ const variantStyles: Record<string, string> = {
6
+ primary: 'bg-rust-light text-rust-dark',
7
+ secondary: 'bg-forest-light text-forest',
8
+ success: 'bg-success-light text-success',
9
+ warning: 'bg-warning-light text-warning',
10
+ error: 'bg-error-light text-error',
11
+ info: 'bg-info-light text-info',
12
+ };
13
+
14
+ export function Badge({
15
+ children,
16
+ variant = 'primary',
17
+ removable = false,
18
+ onRemove,
19
+ className,
20
+ }: BadgeProps) {
21
+ return (
22
+ <span
23
+ className={cn(
24
+ 'inline-flex items-center gap-1 rounded-sm px-2 py-0.5 font-medium',
25
+ variantStyles[variant],
26
+ className,
27
+ )}
28
+ style={{ fontSize: 'var(--text-caption)', lineHeight: 'var(--leading-caption)' }}
29
+ >
30
+ {children}
31
+ {removable && (
32
+ <button
33
+ type="button"
34
+ onClick={onRemove}
35
+ aria-label="Remove"
36
+ className="ml-0.5 rounded-sm hover:bg-black/10 transition-colors p-0.5 -mr-0.5"
37
+ style={{ transitionDuration: 'var(--duration-micro)' }}
38
+ >
39
+ <X size={12} />
40
+ </button>
41
+ )}
42
+ </span>
43
+ );
44
+ }
@@ -0,0 +1,14 @@
1
+ export type BadgeVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info';
2
+
3
+ export interface BadgeProps {
4
+ /** Badge content */
5
+ children: React.ReactNode;
6
+ /** Visual variant */
7
+ variant?: BadgeVariant;
8
+ /** Show a remove button */
9
+ removable?: boolean;
10
+ /** Called when the remove button is clicked */
11
+ onRemove?: () => void;
12
+ /** Additional CSS classes */
13
+ className?: string;
14
+ }
@@ -0,0 +1,2 @@
1
+ export { Badge } from './Badge';
2
+ export type { BadgeProps, BadgeVariant } from './Badge.types';