@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.
- package/dist/index.cjs +7729 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +677 -0
- package/dist/index.d.ts +677 -0
- package/dist/index.js +7654 -0
- package/dist/index.js.map +1 -0
- package/dist/theme.css +2 -0
- package/dist/tokens.cjs +78 -0
- package/dist/tokens.cjs.map +1 -0
- package/dist/tokens.d.cts +67 -0
- package/dist/tokens.d.ts +67 -0
- package/dist/tokens.js +70 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +103 -0
- package/src/data-display/activity-feed/ActivityFeed.stories.tsx +16 -0
- package/src/data-display/activity-feed/ActivityFeed.test.tsx +11 -0
- package/src/data-display/activity-feed/ActivityFeed.tsx +34 -0
- package/src/data-display/activity-feed/index.ts +2 -0
- package/src/data-display/circular-progress/CircularProgress.stories.tsx +10 -0
- package/src/data-display/circular-progress/CircularProgress.test.tsx +11 -0
- package/src/data-display/circular-progress/CircularProgress.tsx +35 -0
- package/src/data-display/circular-progress/index.ts +2 -0
- package/src/data-display/empty-state/EmptyState.stories.tsx +15 -0
- package/src/data-display/empty-state/EmptyState.test.tsx +18 -0
- package/src/data-display/empty-state/EmptyState.tsx +34 -0
- package/src/data-display/empty-state/index.ts +2 -0
- package/src/data-display/progress-bar/ProgressBar.stories.tsx +10 -0
- package/src/data-display/progress-bar/ProgressBar.test.tsx +15 -0
- package/src/data-display/progress-bar/ProgressBar.tsx +38 -0
- package/src/data-display/progress-bar/index.ts +2 -0
- package/src/data-display/stat-card/StatCard.stories.tsx +10 -0
- package/src/data-display/stat-card/StatCard.test.tsx +15 -0
- package/src/data-display/stat-card/StatCard.tsx +40 -0
- package/src/data-display/stat-card/index.ts +2 -0
- package/src/data-display/table/Table.stories.tsx +44 -0
- package/src/data-display/table/Table.test.tsx +16 -0
- package/src/data-display/table/Table.tsx +71 -0
- package/src/data-display/table/index.ts +1 -0
- package/src/data-display/timeline/Timeline.stories.tsx +16 -0
- package/src/data-display/timeline/Timeline.test.tsx +11 -0
- package/src/data-display/timeline/Timeline.tsx +44 -0
- package/src/data-display/timeline/index.ts +2 -0
- package/src/docs/ComponentOverview.mdx +192 -0
- package/src/docs/DesignTokens.mdx +235 -0
- package/src/docs/GettingStarted.mdx +145 -0
- package/src/feedback/alert-banner/AlertBanner.stories.tsx +10 -0
- package/src/feedback/alert-banner/AlertBanner.test.tsx +16 -0
- package/src/feedback/alert-banner/AlertBanner.tsx +47 -0
- package/src/feedback/alert-banner/index.ts +2 -0
- package/src/feedback/modal/Modal.stories.tsx +31 -0
- package/src/feedback/modal/Modal.test.tsx +33 -0
- package/src/feedback/modal/Modal.tsx +88 -0
- package/src/feedback/modal/index.ts +2 -0
- package/src/feedback/skeleton-loader/SkeletonLoader.stories.tsx +23 -0
- package/src/feedback/skeleton-loader/SkeletonLoader.test.tsx +14 -0
- package/src/feedback/skeleton-loader/SkeletonLoader.tsx +61 -0
- package/src/feedback/skeleton-loader/index.ts +2 -0
- package/src/feedback/toast/Toast.stories.tsx +27 -0
- package/src/feedback/toast/Toast.test.tsx +32 -0
- package/src/feedback/toast/Toast.tsx +106 -0
- package/src/feedback/toast/index.ts +2 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-controllable-state.ts +34 -0
- package/src/hooks/use-focus-trap.ts +56 -0
- package/src/hooks/use-reduced-motion.ts +17 -0
- package/src/index.ts +148 -0
- package/src/layout/card/Card.stories.tsx +45 -0
- package/src/layout/card/Card.test.tsx +23 -0
- package/src/layout/card/Card.tsx +42 -0
- package/src/layout/card/Card.types.ts +6 -0
- package/src/layout/card/index.ts +2 -0
- package/src/layout/command-palette/CommandPalette.stories.tsx +34 -0
- package/src/layout/command-palette/CommandPalette.test.tsx +43 -0
- package/src/layout/command-palette/CommandPalette.tsx +188 -0
- package/src/layout/command-palette/CommandPalette.types.ts +18 -0
- package/src/layout/command-palette/index.ts +2 -0
- package/src/layout/sidebar/Sidebar.stories.tsx +60 -0
- package/src/layout/sidebar/Sidebar.test.tsx +27 -0
- package/src/layout/sidebar/Sidebar.tsx +57 -0
- package/src/layout/sidebar/Sidebar.types.ts +14 -0
- package/src/layout/sidebar/index.ts +2 -0
- package/src/layout/top-bar/TopBar.stories.tsx +51 -0
- package/src/layout/top-bar/TopBar.test.tsx +18 -0
- package/src/layout/top-bar/TopBar.tsx +19 -0
- package/src/layout/top-bar/TopBar.types.ts +10 -0
- package/src/layout/top-bar/index.ts +2 -0
- package/src/navigation/breadcrumbs/Breadcrumbs.stories.tsx +30 -0
- package/src/navigation/breadcrumbs/Breadcrumbs.test.tsx +43 -0
- package/src/navigation/breadcrumbs/Breadcrumbs.tsx +45 -0
- package/src/navigation/breadcrumbs/Breadcrumbs.types.ts +12 -0
- package/src/navigation/breadcrumbs/index.ts +2 -0
- package/src/navigation/sidebar-nav-item/SidebarNavItem.stories.tsx +41 -0
- package/src/navigation/sidebar-nav-item/SidebarNavItem.test.tsx +46 -0
- package/src/navigation/sidebar-nav-item/SidebarNavItem.tsx +38 -0
- package/src/navigation/sidebar-nav-item/SidebarNavItem.types.ts +12 -0
- package/src/navigation/sidebar-nav-item/index.ts +2 -0
- package/src/navigation/tabs/Tabs.stories.tsx +47 -0
- package/src/navigation/tabs/Tabs.test.tsx +67 -0
- package/src/navigation/tabs/Tabs.tsx +111 -0
- package/src/navigation/tabs/Tabs.types.ts +36 -0
- package/src/navigation/tabs/index.ts +2 -0
- package/src/patterns/accordion/Accordion.stories.tsx +25 -0
- package/src/patterns/accordion/Accordion.test.tsx +44 -0
- package/src/patterns/accordion/Accordion.tsx +92 -0
- package/src/patterns/accordion/index.ts +2 -0
- package/src/patterns/action-menu/ActionMenu.stories.tsx +18 -0
- package/src/patterns/action-menu/ActionMenu.test.tsx +18 -0
- package/src/patterns/action-menu/ActionMenu.tsx +41 -0
- package/src/patterns/action-menu/index.ts +2 -0
- package/src/patterns/carousel/Carousel.stories.tsx +16 -0
- package/src/patterns/carousel/Carousel.test.tsx +16 -0
- package/src/patterns/carousel/Carousel.tsx +69 -0
- package/src/patterns/carousel/index.ts +2 -0
- package/src/patterns/image-placeholder/ImagePlaceholder.stories.tsx +9 -0
- package/src/patterns/image-placeholder/ImagePlaceholder.test.tsx +10 -0
- package/src/patterns/image-placeholder/ImagePlaceholder.tsx +21 -0
- package/src/patterns/image-placeholder/index.ts +2 -0
- package/src/patterns/notification-dot/NotificationDot.stories.tsx +17 -0
- package/src/patterns/notification-dot/NotificationDot.test.tsx +14 -0
- package/src/patterns/notification-dot/NotificationDot.tsx +18 -0
- package/src/patterns/notification-dot/index.ts +2 -0
- package/src/patterns/pagination/Pagination.stories.tsx +14 -0
- package/src/patterns/pagination/Pagination.test.tsx +22 -0
- package/src/patterns/pagination/Pagination.tsx +67 -0
- package/src/patterns/pagination/index.ts +2 -0
- package/src/primitives/avatar/Avatar.stories.tsx +46 -0
- package/src/primitives/avatar/Avatar.test.tsx +35 -0
- package/src/primitives/avatar/Avatar.tsx +49 -0
- package/src/primitives/avatar/Avatar.types.ts +21 -0
- package/src/primitives/avatar/AvatarGroup.tsx +27 -0
- package/src/primitives/avatar/index.ts +3 -0
- package/src/primitives/badge/Badge.stories.tsx +28 -0
- package/src/primitives/badge/Badge.test.tsx +23 -0
- package/src/primitives/badge/Badge.tsx +44 -0
- package/src/primitives/badge/Badge.types.ts +14 -0
- package/src/primitives/badge/index.ts +2 -0
- package/src/primitives/button/Button.stories.tsx +81 -0
- package/src/primitives/button/Button.test.tsx +64 -0
- package/src/primitives/button/Button.tsx +85 -0
- package/src/primitives/button/Button.types.ts +17 -0
- package/src/primitives/button/index.ts +2 -0
- package/src/primitives/checkbox/Checkbox.stories.tsx +27 -0
- package/src/primitives/checkbox/Checkbox.test.tsx +30 -0
- package/src/primitives/checkbox/Checkbox.tsx +79 -0
- package/src/primitives/checkbox/Checkbox.types.ts +12 -0
- package/src/primitives/checkbox/index.ts +2 -0
- package/src/primitives/combobox/Combobox.stories.tsx +44 -0
- package/src/primitives/combobox/Combobox.test.tsx +44 -0
- package/src/primitives/combobox/Combobox.tsx +201 -0
- package/src/primitives/combobox/Combobox.types.ts +25 -0
- package/src/primitives/combobox/index.ts +2 -0
- package/src/primitives/date-input/DateInput.stories.tsx +23 -0
- package/src/primitives/date-input/DateInput.test.tsx +22 -0
- package/src/primitives/date-input/DateInput.tsx +66 -0
- package/src/primitives/date-input/DateInput.types.ts +10 -0
- package/src/primitives/date-input/index.ts +2 -0
- package/src/primitives/file-upload/FileUploadDropzone.stories.tsx +27 -0
- package/src/primitives/file-upload/FileUploadDropzone.test.tsx +26 -0
- package/src/primitives/file-upload/FileUploadDropzone.tsx +99 -0
- package/src/primitives/file-upload/FileUploadDropzone.types.ts +14 -0
- package/src/primitives/file-upload/index.ts +2 -0
- package/src/primitives/input/InputGroup.stories.tsx +31 -0
- package/src/primitives/input/InputGroup.test.tsx +40 -0
- package/src/primitives/input/InputGroup.tsx +65 -0
- package/src/primitives/input/InputGroup.types.ts +12 -0
- package/src/primitives/input/index.ts +2 -0
- package/src/primitives/link/Link.stories.tsx +28 -0
- package/src/primitives/link/Link.test.tsx +23 -0
- package/src/primitives/link/Link.tsx +28 -0
- package/src/primitives/link/Link.types.ts +8 -0
- package/src/primitives/link/index.ts +2 -0
- package/src/primitives/radio/Radio.stories.tsx +29 -0
- package/src/primitives/radio/Radio.test.tsx +32 -0
- package/src/primitives/radio/Radio.tsx +59 -0
- package/src/primitives/radio/Radio.types.ts +6 -0
- package/src/primitives/radio/index.ts +2 -0
- package/src/primitives/select/SelectGroup.stories.tsx +33 -0
- package/src/primitives/select/SelectGroup.test.tsx +34 -0
- package/src/primitives/select/SelectGroup.tsx +72 -0
- package/src/primitives/select/SelectGroup.types.ts +12 -0
- package/src/primitives/select/index.ts +2 -0
- package/src/primitives/slider/Slider.stories.tsx +23 -0
- package/src/primitives/slider/Slider.test.tsx +28 -0
- package/src/primitives/slider/Slider.tsx +80 -0
- package/src/primitives/slider/Slider.types.ts +22 -0
- package/src/primitives/slider/index.ts +2 -0
- package/src/primitives/textarea/TextareaGroup.stories.tsx +27 -0
- package/src/primitives/textarea/TextareaGroup.test.tsx +24 -0
- package/src/primitives/textarea/TextareaGroup.tsx +59 -0
- package/src/primitives/textarea/TextareaGroup.types.ts +10 -0
- package/src/primitives/textarea/index.ts +2 -0
- package/src/primitives/toggle/Toggle.stories.tsx +27 -0
- package/src/primitives/toggle/Toggle.test.tsx +31 -0
- package/src/primitives/toggle/Toggle.tsx +65 -0
- package/src/primitives/toggle/Toggle.types.ts +16 -0
- package/src/primitives/toggle/index.ts +2 -0
- package/src/primitives/tooltip/Tooltip.stories.tsx +45 -0
- package/src/primitives/tooltip/Tooltip.test.tsx +28 -0
- package/src/primitives/tooltip/Tooltip.tsx +94 -0
- package/src/primitives/tooltip/Tooltip.types.ts +16 -0
- package/src/primitives/tooltip/index.ts +2 -0
- package/src/productivity/comment-thread/CommentThread.stories.tsx +20 -0
- package/src/productivity/comment-thread/CommentThread.test.tsx +21 -0
- package/src/productivity/comment-thread/CommentThread.tsx +47 -0
- package/src/productivity/comment-thread/index.ts +2 -0
- package/src/productivity/kanban-board/KanbanBoard.tsx +41 -0
- package/src/productivity/kanban-board/index.ts +2 -0
- package/src/productivity/kanban-column/KanbanColumn.stories.tsx +131 -0
- package/src/productivity/kanban-column/KanbanColumn.test.tsx +18 -0
- package/src/productivity/kanban-column/KanbanColumn.tsx +58 -0
- package/src/productivity/kanban-column/SortableTaskCard.tsx +46 -0
- package/src/productivity/kanban-column/index.ts +4 -0
- package/src/productivity/priority-selector/PrioritySelector.stories.tsx +14 -0
- package/src/productivity/priority-selector/PrioritySelector.test.tsx +18 -0
- package/src/productivity/priority-selector/PrioritySelector.tsx +40 -0
- package/src/productivity/priority-selector/index.ts +2 -0
- package/src/productivity/rich-text-toolbar/RichTextToolbar.stories.tsx +19 -0
- package/src/productivity/rich-text-toolbar/RichTextToolbar.test.tsx +21 -0
- package/src/productivity/rich-text-toolbar/RichTextToolbar.tsx +50 -0
- package/src/productivity/rich-text-toolbar/index.ts +2 -0
- package/src/productivity/task-card/TaskCard.stories.tsx +20 -0
- package/src/productivity/task-card/TaskCard.test.tsx +21 -0
- package/src/productivity/task-card/TaskCard.tsx +76 -0
- package/src/productivity/task-card/index.ts +2 -0
- package/src/test-setup.ts +1 -0
- package/src/tokens/index.ts +1 -0
- package/src/tokens/tokens.ts +71 -0
- package/src/tokens/weave-theme.css +168 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { ArrowRight, Plus, Trash2 } from 'lucide-react';
|
|
3
|
+
import { Button } from './Button';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Button> = {
|
|
6
|
+
title: 'Primitives/Button',
|
|
7
|
+
component: Button,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
variant: {
|
|
11
|
+
control: 'select',
|
|
12
|
+
options: ['primary', 'secondary', 'ghost', 'destructive', 'outline'],
|
|
13
|
+
},
|
|
14
|
+
size: {
|
|
15
|
+
control: 'select',
|
|
16
|
+
options: ['sm', 'md', 'lg'],
|
|
17
|
+
},
|
|
18
|
+
loading: { control: 'boolean' },
|
|
19
|
+
disabled: { control: 'boolean' },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof Button>;
|
|
25
|
+
|
|
26
|
+
export const Primary: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
children: 'Create Task',
|
|
29
|
+
variant: 'primary',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const AllVariants: Story = {
|
|
34
|
+
name: 'All Variants',
|
|
35
|
+
render: () => (
|
|
36
|
+
<div className="flex flex-wrap gap-4 items-center">
|
|
37
|
+
<Button variant="primary">Primary</Button>
|
|
38
|
+
<Button variant="secondary">Secondary</Button>
|
|
39
|
+
<Button variant="ghost">Ghost</Button>
|
|
40
|
+
<Button variant="destructive">Destructive</Button>
|
|
41
|
+
<Button variant="outline">Outline</Button>
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const Sizes: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<div className="flex gap-4 items-center">
|
|
49
|
+
<Button size="sm">Small</Button>
|
|
50
|
+
<Button size="md">Medium</Button>
|
|
51
|
+
<Button size="lg">Large</Button>
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const WithIcons: Story = {
|
|
57
|
+
name: 'With Icons',
|
|
58
|
+
render: () => (
|
|
59
|
+
<div className="flex gap-4 items-center">
|
|
60
|
+
<Button leftIcon={<Plus size={16} />}>Add Item</Button>
|
|
61
|
+
<Button rightIcon={<ArrowRight size={16} />}>Continue</Button>
|
|
62
|
+
<Button variant="destructive" leftIcon={<Trash2 size={16} />}>
|
|
63
|
+
Delete
|
|
64
|
+
</Button>
|
|
65
|
+
</div>
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const Loading: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
children: 'Saving...',
|
|
72
|
+
loading: true,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const Disabled: Story = {
|
|
77
|
+
args: {
|
|
78
|
+
children: 'Disabled',
|
|
79
|
+
disabled: true,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
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 { Button } from './Button';
|
|
5
|
+
|
|
6
|
+
describe('Button', () => {
|
|
7
|
+
it('renders with children', () => {
|
|
8
|
+
render(<Button>Click me</Button>);
|
|
9
|
+
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('calls onClick handler', async () => {
|
|
13
|
+
const onClick = vi.fn();
|
|
14
|
+
render(<Button onClick={onClick}>Click</Button>);
|
|
15
|
+
await userEvent.click(screen.getByRole('button'));
|
|
16
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('does not fire onClick when disabled', async () => {
|
|
20
|
+
const onClick = vi.fn();
|
|
21
|
+
render(
|
|
22
|
+
<Button disabled onClick={onClick}>
|
|
23
|
+
Click
|
|
24
|
+
</Button>,
|
|
25
|
+
);
|
|
26
|
+
await userEvent.click(screen.getByRole('button'));
|
|
27
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does not fire onClick when loading', async () => {
|
|
31
|
+
const onClick = vi.fn();
|
|
32
|
+
render(
|
|
33
|
+
<Button loading onClick={onClick}>
|
|
34
|
+
Click
|
|
35
|
+
</Button>,
|
|
36
|
+
);
|
|
37
|
+
await userEvent.click(screen.getByRole('button'));
|
|
38
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('shows loading spinner when loading', () => {
|
|
42
|
+
render(<Button loading>Save</Button>);
|
|
43
|
+
const button = screen.getByRole('button');
|
|
44
|
+
expect(button).toBeDisabled();
|
|
45
|
+
expect(button.querySelector('svg')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('applies variant classes', () => {
|
|
49
|
+
const { rerender } = render(<Button variant="primary">Test</Button>);
|
|
50
|
+
expect(screen.getByRole('button')).toHaveClass('bg-rust');
|
|
51
|
+
|
|
52
|
+
rerender(<Button variant="secondary">Test</Button>);
|
|
53
|
+
expect(screen.getByRole('button')).toHaveClass('bg-forest');
|
|
54
|
+
|
|
55
|
+
rerender(<Button variant="destructive">Test</Button>);
|
|
56
|
+
expect(screen.getByRole('button')).toHaveClass('bg-error');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('forwards ref', () => {
|
|
60
|
+
const ref = vi.fn();
|
|
61
|
+
render(<Button ref={ref}>Test</Button>);
|
|
62
|
+
expect(ref).toHaveBeenCalledWith(expect.any(HTMLButtonElement));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { forwardRef } from 'react';
|
|
2
|
+
import { Loader2 } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../utils/cn';
|
|
4
|
+
import type { ButtonProps } from './Button.types';
|
|
5
|
+
|
|
6
|
+
const variantStyles: Record<string, string> = {
|
|
7
|
+
primary:
|
|
8
|
+
'bg-rust text-white hover:bg-rust-dark active:bg-rust-dark/90 disabled:bg-border disabled:text-text-secondary',
|
|
9
|
+
secondary:
|
|
10
|
+
'bg-forest text-white hover:bg-forest/90 active:bg-forest/80 disabled:bg-border disabled:text-text-secondary',
|
|
11
|
+
ghost:
|
|
12
|
+
'bg-transparent text-text-primary hover:bg-surface active:bg-border disabled:text-text-secondary',
|
|
13
|
+
destructive:
|
|
14
|
+
'bg-error text-white hover:bg-error/90 active:bg-error/80 disabled:bg-border disabled:text-text-secondary',
|
|
15
|
+
outline:
|
|
16
|
+
'bg-transparent text-text-primary border border-border hover:bg-surface active:bg-border disabled:text-text-secondary',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const sizeStyles: Record<string, string> = {
|
|
20
|
+
sm: 'px-3 py-1.5 rounded-md gap-1.5',
|
|
21
|
+
md: 'px-4 py-2 rounded-md gap-2',
|
|
22
|
+
lg: 'px-6 py-3 rounded-md gap-2.5',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const sizeFontStyles: Record<string, React.CSSProperties> = {
|
|
26
|
+
sm: { fontSize: 'var(--text-body-sm)', lineHeight: 'var(--leading-body-sm)' },
|
|
27
|
+
md: { fontSize: 'var(--text-body)', lineHeight: 'var(--leading-body)' },
|
|
28
|
+
lg: { fontSize: 'var(--text-body-lg)', lineHeight: 'var(--leading-body-lg)' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const iconSize: Record<string, number> = {
|
|
32
|
+
sm: 14,
|
|
33
|
+
md: 16,
|
|
34
|
+
lg: 18,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
38
|
+
(
|
|
39
|
+
{
|
|
40
|
+
variant = 'primary',
|
|
41
|
+
size = 'md',
|
|
42
|
+
loading = false,
|
|
43
|
+
disabled,
|
|
44
|
+
leftIcon,
|
|
45
|
+
rightIcon,
|
|
46
|
+
className,
|
|
47
|
+
children,
|
|
48
|
+
...props
|
|
49
|
+
},
|
|
50
|
+
ref,
|
|
51
|
+
) => {
|
|
52
|
+
return (
|
|
53
|
+
<button
|
|
54
|
+
ref={ref}
|
|
55
|
+
disabled={disabled || loading}
|
|
56
|
+
className={cn(
|
|
57
|
+
'inline-flex items-center justify-center font-sans font-semibold',
|
|
58
|
+
'transition-all ease-out',
|
|
59
|
+
'active:scale-[0.97]',
|
|
60
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rust',
|
|
61
|
+
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
|
|
62
|
+
variantStyles[variant],
|
|
63
|
+
sizeStyles[size],
|
|
64
|
+
loading && 'opacity-80',
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
style={{
|
|
68
|
+
transitionDuration: 'var(--duration-fast)',
|
|
69
|
+
...sizeFontStyles[size],
|
|
70
|
+
}}
|
|
71
|
+
{...props}
|
|
72
|
+
>
|
|
73
|
+
{loading ? (
|
|
74
|
+
<Loader2 className="animate-spin" size={iconSize[size]} />
|
|
75
|
+
) : (
|
|
76
|
+
leftIcon
|
|
77
|
+
)}
|
|
78
|
+
{children}
|
|
79
|
+
{!loading && rightIcon}
|
|
80
|
+
</button>
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
Button.displayName = 'Button';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
|
|
4
|
+
export type ButtonSize = 'sm' | 'md' | 'lg';
|
|
5
|
+
|
|
6
|
+
export interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
|
|
7
|
+
/** Visual style variant */
|
|
8
|
+
variant?: ButtonVariant;
|
|
9
|
+
/** Size of the button */
|
|
10
|
+
size?: ButtonSize;
|
|
11
|
+
/** Show loading spinner and disable interaction */
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
/** Icon element to show before children */
|
|
14
|
+
leftIcon?: React.ReactNode;
|
|
15
|
+
/** Icon element to show after children */
|
|
16
|
+
rightIcon?: React.ReactNode;
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Checkbox } from './Checkbox';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Checkbox> = {
|
|
5
|
+
title: 'Primitives/Checkbox',
|
|
6
|
+
component: Checkbox,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof Checkbox>;
|
|
12
|
+
|
|
13
|
+
export const Unchecked: Story = {
|
|
14
|
+
args: { label: 'Accept terms and conditions' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const Checked: Story = {
|
|
18
|
+
args: { label: 'Accept terms and conditions', defaultChecked: true },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const Disabled: Story = {
|
|
22
|
+
args: { label: 'Disabled option', disabled: true },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const DisabledChecked: Story = {
|
|
26
|
+
args: { label: 'Locked option', disabled: true, checked: true },
|
|
27
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
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 { Checkbox } from './Checkbox';
|
|
5
|
+
|
|
6
|
+
describe('Checkbox', () => {
|
|
7
|
+
it('renders with label', () => {
|
|
8
|
+
render(<Checkbox label="Accept terms" />);
|
|
9
|
+
expect(screen.getByLabelText('Accept terms')).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('toggles on click', async () => {
|
|
13
|
+
const onCheckedChange = vi.fn();
|
|
14
|
+
render(<Checkbox label="Accept" onCheckedChange={onCheckedChange} />);
|
|
15
|
+
await userEvent.click(screen.getByLabelText('Accept'));
|
|
16
|
+
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('does not toggle when disabled', async () => {
|
|
20
|
+
const onCheckedChange = vi.fn();
|
|
21
|
+
render(<Checkbox label="Accept" disabled onCheckedChange={onCheckedChange} />);
|
|
22
|
+
await userEvent.click(screen.getByLabelText('Accept'));
|
|
23
|
+
expect(onCheckedChange).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('supports controlled state', () => {
|
|
27
|
+
render(<Checkbox label="Accept" checked={true} />);
|
|
28
|
+
expect(screen.getByLabelText('Accept')).toBeChecked();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { forwardRef, useId } from 'react';
|
|
2
|
+
import { Check } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../utils/cn';
|
|
4
|
+
import { useControllableState } from '../../hooks/use-controllable-state';
|
|
5
|
+
import type { CheckboxProps } from './Checkbox.types';
|
|
6
|
+
|
|
7
|
+
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
|
8
|
+
({ label, checked: controlledChecked, defaultChecked = false, onCheckedChange, className, disabled, id: idProp, onChange, ...props }, ref) => {
|
|
9
|
+
const autoId = useId();
|
|
10
|
+
const id = idProp ?? autoId;
|
|
11
|
+
const [checked, setChecked] = useControllableState({
|
|
12
|
+
value: controlledChecked,
|
|
13
|
+
defaultValue: defaultChecked,
|
|
14
|
+
onChange: onCheckedChange,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<label
|
|
19
|
+
htmlFor={id}
|
|
20
|
+
className={cn(
|
|
21
|
+
'inline-flex items-center gap-2 cursor-pointer select-none',
|
|
22
|
+
disabled && 'opacity-40 cursor-not-allowed',
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
<div className="relative flex items-center justify-center">
|
|
27
|
+
<input
|
|
28
|
+
ref={ref}
|
|
29
|
+
id={id}
|
|
30
|
+
type="checkbox"
|
|
31
|
+
checked={checked}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
className="peer sr-only"
|
|
34
|
+
onChange={(e) => {
|
|
35
|
+
setChecked(e.target.checked);
|
|
36
|
+
onChange?.(e);
|
|
37
|
+
}}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
'h-5 w-5 rounded-sm border-2 flex items-center justify-center',
|
|
43
|
+
'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-rust',
|
|
44
|
+
checked
|
|
45
|
+
? 'bg-rust border-rust'
|
|
46
|
+
: 'bg-white border-border',
|
|
47
|
+
)}
|
|
48
|
+
style={{
|
|
49
|
+
transitionProperty: 'background-color, border-color, transform',
|
|
50
|
+
transitionDuration: 'var(--duration-fast)',
|
|
51
|
+
transitionTimingFunction: 'var(--ease-spring)',
|
|
52
|
+
transform: 'scale(1)',
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<Check
|
|
56
|
+
size={14}
|
|
57
|
+
className="text-white"
|
|
58
|
+
strokeWidth={3}
|
|
59
|
+
style={{
|
|
60
|
+
transform: checked ? 'scale(1)' : 'scale(0)',
|
|
61
|
+
transitionProperty: 'transform',
|
|
62
|
+
transitionDuration: 'var(--duration-fast)',
|
|
63
|
+
transitionTimingFunction: 'var(--ease-spring)',
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<span
|
|
69
|
+
className="text-text-primary"
|
|
70
|
+
style={{ fontSize: 'var(--text-body)', lineHeight: 'var(--leading-body)' }}
|
|
71
|
+
>
|
|
72
|
+
{label}
|
|
73
|
+
</span>
|
|
74
|
+
</label>
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
Checkbox.displayName = 'Checkbox';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface CheckboxProps extends Omit<ComponentPropsWithoutRef<'input'>, 'type' | 'size'> {
|
|
4
|
+
/** Label text displayed next to the checkbox */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Controlled checked state */
|
|
7
|
+
checked?: boolean;
|
|
8
|
+
/** Default checked state for uncontrolled usage */
|
|
9
|
+
defaultChecked?: boolean;
|
|
10
|
+
/** Change handler */
|
|
11
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Combobox } from './Combobox';
|
|
3
|
+
|
|
4
|
+
const fruits = [
|
|
5
|
+
{ value: 'apple', label: 'Apple' },
|
|
6
|
+
{ value: 'banana', label: 'Banana' },
|
|
7
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
8
|
+
{ value: 'date', label: 'Date' },
|
|
9
|
+
{ value: 'elderberry', label: 'Elderberry' },
|
|
10
|
+
{ value: 'fig', label: 'Fig' },
|
|
11
|
+
{ value: 'grape', label: 'Grape' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof Combobox> = {
|
|
15
|
+
title: 'Primitives/Combobox',
|
|
16
|
+
component: Combobox,
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
decorators: [
|
|
19
|
+
(Story) => (
|
|
20
|
+
<div style={{ minHeight: 300 }}>
|
|
21
|
+
<Story />
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof Combobox>;
|
|
29
|
+
|
|
30
|
+
export const Default: Story = {
|
|
31
|
+
args: { label: 'Fruit', options: fruits, placeholder: 'Choose a fruit...' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const WithSelection: Story = {
|
|
35
|
+
args: { label: 'Fruit', options: fruits, defaultValue: 'cherry' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const WithError: Story = {
|
|
39
|
+
args: { label: 'Fruit', options: fruits, error: 'Please select a fruit' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const Disabled: Story = {
|
|
43
|
+
args: { label: 'Fruit', options: fruits, defaultValue: 'apple', disabled: true },
|
|
44
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
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 { Combobox } from './Combobox';
|
|
5
|
+
|
|
6
|
+
const options = [
|
|
7
|
+
{ value: 'apple', label: 'Apple' },
|
|
8
|
+
{ value: 'banana', label: 'Banana' },
|
|
9
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe('Combobox', () => {
|
|
13
|
+
it('renders label and combobox input', () => {
|
|
14
|
+
render(<Combobox label="Fruit" options={options} />);
|
|
15
|
+
expect(screen.getByLabelText('Fruit')).toHaveAttribute('role', 'combobox');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('opens dropdown on click', async () => {
|
|
19
|
+
render(<Combobox label="Fruit" options={options} />);
|
|
20
|
+
await userEvent.click(screen.getByLabelText('Fruit'));
|
|
21
|
+
expect(screen.getByRole('option', { name: 'Apple' })).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('selects an option', async () => {
|
|
25
|
+
const onChange = vi.fn();
|
|
26
|
+
render(<Combobox label="Fruit" options={options} onChange={onChange} />);
|
|
27
|
+
await userEvent.click(screen.getByLabelText('Fruit'));
|
|
28
|
+
await userEvent.click(screen.getByRole('option', { name: 'Banana' }));
|
|
29
|
+
expect(onChange).toHaveBeenCalledWith('banana');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('filters options by query', async () => {
|
|
33
|
+
render(<Combobox label="Fruit" options={options} />);
|
|
34
|
+
await userEvent.click(screen.getByLabelText('Fruit'));
|
|
35
|
+
await userEvent.type(screen.getByLabelText('Fruit'), 'ch');
|
|
36
|
+
expect(screen.getByRole('option', { name: 'Cherry' })).toBeInTheDocument();
|
|
37
|
+
expect(screen.queryByRole('option', { name: 'Apple' })).not.toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('shows error', () => {
|
|
41
|
+
render(<Combobox label="Fruit" options={options} error="Required" />);
|
|
42
|
+
expect(screen.getByText('Required')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
});
|