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