@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,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,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,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,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,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,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
|
+
});
|