vinta-schedule-design-system 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/components.json +21 -0
- package/dist/styles.css +3452 -0
- package/package.json +103 -0
- package/src/layout/app-shell.tsx +115 -0
- package/src/layout/app-topbar.stories.tsx +74 -0
- package/src/layout/app-topbar.tsx +145 -0
- package/src/layout/auth-layout.tsx +45 -0
- package/src/layout/box.stories.tsx +86 -0
- package/src/layout/box.tsx +34 -0
- package/src/layout/center.tsx +24 -0
- package/src/layout/container.stories.tsx +44 -0
- package/src/layout/container.tsx +39 -0
- package/src/layout/divider.tsx +64 -0
- package/src/layout/flex.stories.tsx +109 -0
- package/src/layout/flex.test.tsx +71 -0
- package/src/layout/flex.tsx +112 -0
- package/src/layout/grid.stories.tsx +91 -0
- package/src/layout/grid.tsx +132 -0
- package/src/layout/heading.tsx +53 -0
- package/src/layout/index.ts +47 -0
- package/src/layout/layout-style.ts +291 -0
- package/src/layout/link.stories.tsx +54 -0
- package/src/layout/link.test.tsx +75 -0
- package/src/layout/link.tsx +100 -0
- package/src/layout/navbar.stories.tsx +70 -0
- package/src/layout/navbar.tsx +128 -0
- package/src/layout/page-header.stories.tsx +80 -0
- package/src/layout/page-header.tsx +59 -0
- package/src/layout/section.tsx +22 -0
- package/src/layout/spacer.tsx +21 -0
- package/src/layout/stack.stories.tsx +58 -0
- package/src/layout/stack.tsx +19 -0
- package/src/layout/text.tsx +139 -0
- package/src/layout/typography.stories.tsx +112 -0
- package/src/lib/utils.ts +6 -0
- package/src/story-types.ts +80 -0
- package/src/styles/build.css +33 -0
- package/src/styles/globals.css +9 -0
- package/src/styles/tokens.css +281 -0
- package/src/ui/accordion.stories.tsx +84 -0
- package/src/ui/accordion.tsx +57 -0
- package/src/ui/alert-dialog.stories.tsx +57 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.stories.tsx +46 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/avatar.stories.tsx +56 -0
- package/src/ui/avatar.tsx +50 -0
- package/src/ui/badge.stories.tsx +105 -0
- package/src/ui/badge.tsx +57 -0
- package/src/ui/breadcrumb.stories.tsx +69 -0
- package/src/ui/breadcrumb.tsx +140 -0
- package/src/ui/button.stories.tsx +85 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/card.stories.tsx +62 -0
- package/src/ui/card.tsx +96 -0
- package/src/ui/carousel.stories.tsx +55 -0
- package/src/ui/carousel.tsx +262 -0
- package/src/ui/checkbox.stories.tsx +52 -0
- package/src/ui/checkbox.tsx +30 -0
- package/src/ui/combobox.stories.tsx +138 -0
- package/src/ui/combobox.tsx +217 -0
- package/src/ui/command.tsx +184 -0
- package/src/ui/dialog.stories.tsx +66 -0
- package/src/ui/dialog.tsx +163 -0
- package/src/ui/drawer.stories.tsx +80 -0
- package/src/ui/drawer.tsx +118 -0
- package/src/ui/dropdown-menu.stories.tsx +57 -0
- package/src/ui/dropdown-menu.tsx +201 -0
- package/src/ui/form.stories.tsx +70 -0
- package/src/ui/form.tsx +181 -0
- package/src/ui/input-otp.stories.tsx +52 -0
- package/src/ui/input-otp.tsx +71 -0
- package/src/ui/input.stories.tsx +68 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.stories.tsx +35 -0
- package/src/ui/label.tsx +26 -0
- package/src/ui/navigation-menu.stories.tsx +79 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.stories.tsx +62 -0
- package/src/ui/pagination.tsx +137 -0
- package/src/ui/popover.tsx +89 -0
- package/src/ui/progress.stories.tsx +33 -0
- package/src/ui/progress.tsx +28 -0
- package/src/ui/radio-group.stories.tsx +52 -0
- package/src/ui/radio-group.tsx +44 -0
- package/src/ui/scheduling-chip.stories.tsx +117 -0
- package/src/ui/scheduling-chip.tsx +62 -0
- package/src/ui/scroll-area.stories.tsx +52 -0
- package/src/ui/scroll-area.tsx +48 -0
- package/src/ui/select.stories.tsx +56 -0
- package/src/ui/select.tsx +159 -0
- package/src/ui/sheet.stories.tsx +63 -0
- package/src/ui/sheet.tsx +141 -0
- package/src/ui/skeleton.stories.tsx +40 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/sonner.stories.tsx +62 -0
- package/src/ui/sonner.tsx +34 -0
- package/src/ui/switch.stories.tsx +41 -0
- package/src/ui/switch.tsx +29 -0
- package/src/ui/table.stories.tsx +88 -0
- package/src/ui/table.tsx +132 -0
- package/src/ui/tabs.stories.tsx +63 -0
- package/src/ui/tabs.tsx +55 -0
- package/src/ui/textarea.stories.tsx +45 -0
- package/src/ui/textarea.tsx +22 -0
- package/src/ui/tooltip.stories.tsx +48 -0
- package/src/ui/tooltip.tsx +32 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import useEmblaCarousel, {
|
|
5
|
+
type UseEmblaCarouselType,
|
|
6
|
+
} from 'embla-carousel-react';
|
|
7
|
+
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/utils';
|
|
10
|
+
import { Button } from './button';
|
|
11
|
+
|
|
12
|
+
type CarouselApi = UseEmblaCarouselType[1];
|
|
13
|
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
|
14
|
+
type CarouselOptions = UseCarouselParameters[0];
|
|
15
|
+
type CarouselPlugin = UseCarouselParameters[1];
|
|
16
|
+
|
|
17
|
+
type CarouselProps = {
|
|
18
|
+
opts?: CarouselOptions;
|
|
19
|
+
plugins?: CarouselPlugin;
|
|
20
|
+
orientation?: 'horizontal' | 'vertical';
|
|
21
|
+
setApi?: (api: CarouselApi) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type CarouselContextProps = {
|
|
25
|
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
|
26
|
+
api: ReturnType<typeof useEmblaCarousel>[1];
|
|
27
|
+
scrollPrev: () => void;
|
|
28
|
+
scrollNext: () => void;
|
|
29
|
+
canScrollPrev: boolean;
|
|
30
|
+
canScrollNext: boolean;
|
|
31
|
+
} & CarouselProps;
|
|
32
|
+
|
|
33
|
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
|
34
|
+
|
|
35
|
+
function useCarousel() {
|
|
36
|
+
const context = React.useContext(CarouselContext);
|
|
37
|
+
|
|
38
|
+
if (!context) {
|
|
39
|
+
throw new Error('useCarousel must be used within a <Carousel />');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return context;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const Carousel = React.forwardRef<
|
|
46
|
+
HTMLDivElement,
|
|
47
|
+
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
|
48
|
+
>(
|
|
49
|
+
(
|
|
50
|
+
{
|
|
51
|
+
orientation = 'horizontal',
|
|
52
|
+
opts,
|
|
53
|
+
setApi,
|
|
54
|
+
plugins,
|
|
55
|
+
className,
|
|
56
|
+
children,
|
|
57
|
+
...props
|
|
58
|
+
},
|
|
59
|
+
ref
|
|
60
|
+
) => {
|
|
61
|
+
const [carouselRef, api] = useEmblaCarousel(
|
|
62
|
+
{
|
|
63
|
+
...opts,
|
|
64
|
+
axis: orientation === 'horizontal' ? 'x' : 'y',
|
|
65
|
+
},
|
|
66
|
+
plugins
|
|
67
|
+
);
|
|
68
|
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
|
69
|
+
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
|
70
|
+
|
|
71
|
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
|
72
|
+
if (!api) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setCanScrollPrev(api.canScrollPrev());
|
|
77
|
+
setCanScrollNext(api.canScrollNext());
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const scrollPrev = React.useCallback(() => {
|
|
81
|
+
api?.scrollPrev();
|
|
82
|
+
}, [api]);
|
|
83
|
+
|
|
84
|
+
const scrollNext = React.useCallback(() => {
|
|
85
|
+
api?.scrollNext();
|
|
86
|
+
}, [api]);
|
|
87
|
+
|
|
88
|
+
const handleKeyDown = React.useCallback(
|
|
89
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
90
|
+
if (event.key === 'ArrowLeft') {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
scrollPrev();
|
|
93
|
+
} else if (event.key === 'ArrowRight') {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
scrollNext();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[scrollPrev, scrollNext]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
if (!api || !setApi) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setApi(api);
|
|
107
|
+
}, [api, setApi]);
|
|
108
|
+
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
if (!api) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onSelect(api);
|
|
115
|
+
api.on('reInit', onSelect);
|
|
116
|
+
api.on('select', onSelect);
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
api?.off('select', onSelect);
|
|
120
|
+
};
|
|
121
|
+
}, [api, onSelect]);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<CarouselContext.Provider
|
|
125
|
+
value={{
|
|
126
|
+
carouselRef,
|
|
127
|
+
api: api,
|
|
128
|
+
opts,
|
|
129
|
+
orientation:
|
|
130
|
+
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
|
131
|
+
scrollPrev,
|
|
132
|
+
scrollNext,
|
|
133
|
+
canScrollPrev,
|
|
134
|
+
canScrollNext,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<div
|
|
138
|
+
ref={ref}
|
|
139
|
+
onKeyDownCapture={handleKeyDown}
|
|
140
|
+
className={cn('relative', className)}
|
|
141
|
+
role='region'
|
|
142
|
+
aria-roledescription='carousel'
|
|
143
|
+
{...props}
|
|
144
|
+
>
|
|
145
|
+
{children}
|
|
146
|
+
</div>
|
|
147
|
+
</CarouselContext.Provider>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
Carousel.displayName = 'Carousel';
|
|
152
|
+
|
|
153
|
+
const CarouselContent = React.forwardRef<
|
|
154
|
+
HTMLDivElement,
|
|
155
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
156
|
+
>(({ className, ...props }, ref) => {
|
|
157
|
+
const { carouselRef, orientation } = useCarousel();
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div ref={carouselRef} className='overflow-hidden'>
|
|
161
|
+
<div
|
|
162
|
+
ref={ref}
|
|
163
|
+
className={cn(
|
|
164
|
+
'flex',
|
|
165
|
+
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
|
166
|
+
className
|
|
167
|
+
)}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
CarouselContent.displayName = 'CarouselContent';
|
|
174
|
+
|
|
175
|
+
const CarouselItem = React.forwardRef<
|
|
176
|
+
HTMLDivElement,
|
|
177
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
178
|
+
>(({ className, ...props }, ref) => {
|
|
179
|
+
const { orientation } = useCarousel();
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
ref={ref}
|
|
184
|
+
role='group'
|
|
185
|
+
aria-roledescription='slide'
|
|
186
|
+
className={cn(
|
|
187
|
+
'min-w-0 shrink-0 grow-0 basis-full',
|
|
188
|
+
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
|
189
|
+
className
|
|
190
|
+
)}
|
|
191
|
+
{...props}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
CarouselItem.displayName = 'CarouselItem';
|
|
196
|
+
|
|
197
|
+
const CarouselPrevious = React.forwardRef<
|
|
198
|
+
HTMLButtonElement,
|
|
199
|
+
React.ComponentProps<typeof Button>
|
|
200
|
+
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
|
201
|
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<Button
|
|
205
|
+
ref={ref}
|
|
206
|
+
variant={variant}
|
|
207
|
+
size={size}
|
|
208
|
+
className={cn(
|
|
209
|
+
'absolute h-8 w-8 rounded-full',
|
|
210
|
+
orientation === 'horizontal'
|
|
211
|
+
? 'top-1/2 -left-12 -translate-y-1/2'
|
|
212
|
+
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
|
213
|
+
className
|
|
214
|
+
)}
|
|
215
|
+
disabled={!canScrollPrev}
|
|
216
|
+
onClick={scrollPrev}
|
|
217
|
+
{...props}
|
|
218
|
+
>
|
|
219
|
+
<ArrowLeft className='h-4 w-4' />
|
|
220
|
+
<span className='sr-only'>Previous slide</span>
|
|
221
|
+
</Button>
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
CarouselPrevious.displayName = 'CarouselPrevious';
|
|
225
|
+
|
|
226
|
+
const CarouselNext = React.forwardRef<
|
|
227
|
+
HTMLButtonElement,
|
|
228
|
+
React.ComponentProps<typeof Button>
|
|
229
|
+
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
|
230
|
+
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<Button
|
|
234
|
+
ref={ref}
|
|
235
|
+
variant={variant}
|
|
236
|
+
size={size}
|
|
237
|
+
className={cn(
|
|
238
|
+
'absolute h-8 w-8 rounded-full',
|
|
239
|
+
orientation === 'horizontal'
|
|
240
|
+
? 'top-1/2 -right-12 -translate-y-1/2'
|
|
241
|
+
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
|
242
|
+
className
|
|
243
|
+
)}
|
|
244
|
+
disabled={!canScrollNext}
|
|
245
|
+
onClick={scrollNext}
|
|
246
|
+
{...props}
|
|
247
|
+
>
|
|
248
|
+
<ArrowRight className='h-4 w-4' />
|
|
249
|
+
<span className='sr-only'>Next slide</span>
|
|
250
|
+
</Button>
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
CarouselNext.displayName = 'CarouselNext';
|
|
254
|
+
|
|
255
|
+
export {
|
|
256
|
+
type CarouselApi,
|
|
257
|
+
Carousel,
|
|
258
|
+
CarouselContent,
|
|
259
|
+
CarouselItem,
|
|
260
|
+
CarouselPrevious,
|
|
261
|
+
CarouselNext,
|
|
262
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
|
|
3
|
+
import { Checkbox } from './checkbox';
|
|
4
|
+
import { Label } from './label';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/Checkbox',
|
|
8
|
+
component: Checkbox,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
// Leaf control: the Radix `Checkbox.Root` renders its own Indicator, so it
|
|
11
|
+
// takes no composed content — NO slot. These are real Radix Root props
|
|
12
|
+
// (§6: className/style stay unexposed).
|
|
13
|
+
argTypes: {
|
|
14
|
+
defaultChecked: { control: 'boolean' },
|
|
15
|
+
disabled: { control: 'boolean' },
|
|
16
|
+
required: { control: 'boolean' },
|
|
17
|
+
name: { control: 'text', description: 'Name submitted with the form' },
|
|
18
|
+
value: { control: 'text', description: 'Value submitted when checked' },
|
|
19
|
+
},
|
|
20
|
+
args: { defaultChecked: true },
|
|
21
|
+
} satisfies Meta<typeof Checkbox>;
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof meta>;
|
|
25
|
+
|
|
26
|
+
export const Default: Story = {
|
|
27
|
+
render: (args) => (
|
|
28
|
+
<div className='flex items-center gap-2'>
|
|
29
|
+
<Checkbox id='sync' {...args} />
|
|
30
|
+
<Label htmlFor='sync'>Sync to Google Calendar</Label>
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const States: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div className='flex flex-col gap-3'>
|
|
38
|
+
<div className='flex items-center gap-2'>
|
|
39
|
+
<Checkbox id='a' />
|
|
40
|
+
<Label htmlFor='a'>Unchecked</Label>
|
|
41
|
+
</div>
|
|
42
|
+
<div className='flex items-center gap-2'>
|
|
43
|
+
<Checkbox id='b' defaultChecked />
|
|
44
|
+
<Label htmlFor='b'>Checked</Label>
|
|
45
|
+
</div>
|
|
46
|
+
<div className='flex items-center gap-2'>
|
|
47
|
+
<Checkbox id='c' disabled />
|
|
48
|
+
<Label htmlFor='c'>Disabled</Label>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
|
5
|
+
import { Check } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/utils';
|
|
8
|
+
|
|
9
|
+
const Checkbox = React.forwardRef<
|
|
10
|
+
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
11
|
+
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
|
12
|
+
>(({ className, ...props }, ref) => (
|
|
13
|
+
<CheckboxPrimitive.Root
|
|
14
|
+
ref={ref}
|
|
15
|
+
className={cn(
|
|
16
|
+
'peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground grid h-4 w-4 shrink-0 place-content-center rounded-sm border shadow focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
|
17
|
+
className
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
<CheckboxPrimitive.Indicator
|
|
22
|
+
className={cn('grid place-content-center text-current')}
|
|
23
|
+
>
|
|
24
|
+
<Check className='h-4 w-4' />
|
|
25
|
+
</CheckboxPrimitive.Indicator>
|
|
26
|
+
</CheckboxPrimitive.Root>
|
|
27
|
+
));
|
|
28
|
+
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
|
29
|
+
|
|
30
|
+
export { Checkbox };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { Combobox } from './combobox';
|
|
4
|
+
|
|
5
|
+
const FRAMEWORKS: Array<{
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}> = [
|
|
10
|
+
{ value: 'next', label: 'Next.js', description: 'React framework' },
|
|
11
|
+
{ value: 'sveltekit', label: 'SvelteKit', description: 'Svelte framework' },
|
|
12
|
+
{ value: 'nuxt', label: 'Nuxt.js', description: 'Vue framework' },
|
|
13
|
+
{ value: 'remix', label: 'Remix', description: 'React framework' },
|
|
14
|
+
{ value: 'astro', label: 'Astro', description: 'Static site builder' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
title: 'Components/Combobox',
|
|
19
|
+
component: Combobox,
|
|
20
|
+
tags: ['autodocs'],
|
|
21
|
+
// Leaf: Combobox renders its own trigger/popover/list — it takes no composed
|
|
22
|
+
// ReactNode children, so NO slot. `options` is data (an array of
|
|
23
|
+
// {value,label,description}), edited with the `object` control. `className`
|
|
24
|
+
// is deliberately not exposed (§6).
|
|
25
|
+
argTypes: {
|
|
26
|
+
options: {
|
|
27
|
+
control: 'object',
|
|
28
|
+
description: 'Selectable options: { value, label, description? }[]',
|
|
29
|
+
},
|
|
30
|
+
placeholder: { control: 'text' },
|
|
31
|
+
searchPlaceholder: { control: 'text' },
|
|
32
|
+
emptyText: { control: 'text', description: 'Shown when nothing matches' },
|
|
33
|
+
multiple: {
|
|
34
|
+
control: 'boolean',
|
|
35
|
+
description: 'Allow selecting more than one option',
|
|
36
|
+
},
|
|
37
|
+
isLoading: { control: 'boolean' },
|
|
38
|
+
disabled: { control: 'boolean' },
|
|
39
|
+
},
|
|
40
|
+
args: {
|
|
41
|
+
options: FRAMEWORKS,
|
|
42
|
+
placeholder: 'Select a framework…',
|
|
43
|
+
searchPlaceholder: 'Search frameworks…',
|
|
44
|
+
},
|
|
45
|
+
parameters: { layout: 'centered' },
|
|
46
|
+
} satisfies Meta<typeof Combobox>;
|
|
47
|
+
|
|
48
|
+
export default meta;
|
|
49
|
+
type Story = StoryObj<typeof meta>;
|
|
50
|
+
|
|
51
|
+
// Hooks must live in components, not in story `render` functions
|
|
52
|
+
// (react-hooks/rules-of-hooks) — hence the named wrappers.
|
|
53
|
+
function SingleSelectStory() {
|
|
54
|
+
const [value, setValue] = React.useState('');
|
|
55
|
+
return (
|
|
56
|
+
<div className='w-64'>
|
|
57
|
+
<Combobox
|
|
58
|
+
options={FRAMEWORKS}
|
|
59
|
+
value={value}
|
|
60
|
+
onValueChange={setValue}
|
|
61
|
+
placeholder='Select a framework…'
|
|
62
|
+
searchPlaceholder='Search frameworks…'
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function MultiSelectStory() {
|
|
69
|
+
const [values, setValues] = React.useState<string[]>([]);
|
|
70
|
+
return (
|
|
71
|
+
<div className='w-64'>
|
|
72
|
+
<Combobox
|
|
73
|
+
multiple
|
|
74
|
+
options={FRAMEWORKS}
|
|
75
|
+
value={values}
|
|
76
|
+
onValueChange={setValues}
|
|
77
|
+
placeholder='Select frameworks…'
|
|
78
|
+
searchPlaceholder='Search frameworks…'
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function WithDescriptionsStory() {
|
|
85
|
+
const [value, setValue] = React.useState('');
|
|
86
|
+
return (
|
|
87
|
+
<div className='w-64'>
|
|
88
|
+
<Combobox
|
|
89
|
+
options={FRAMEWORKS}
|
|
90
|
+
value={value}
|
|
91
|
+
onValueChange={setValue}
|
|
92
|
+
placeholder='Select a framework…'
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const SingleSelect: Story = {
|
|
99
|
+
render: () => <SingleSelectStory />,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const MultiSelect: Story = {
|
|
103
|
+
render: () => <MultiSelectStory />,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const WithDescriptions: Story = {
|
|
107
|
+
render: () => <WithDescriptionsStory />,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const Loading: Story = {
|
|
111
|
+
render: () => (
|
|
112
|
+
<div className='w-64'>
|
|
113
|
+
<Combobox
|
|
114
|
+
options={[]}
|
|
115
|
+
value={[]}
|
|
116
|
+
onValueChange={() => {}}
|
|
117
|
+
multiple
|
|
118
|
+
isLoading
|
|
119
|
+
placeholder='Select members…'
|
|
120
|
+
searchPlaceholder='Search members…'
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const Disabled: Story = {
|
|
127
|
+
render: () => (
|
|
128
|
+
<div className='w-64'>
|
|
129
|
+
<Combobox
|
|
130
|
+
options={FRAMEWORKS}
|
|
131
|
+
value='next'
|
|
132
|
+
onValueChange={() => {}}
|
|
133
|
+
disabled
|
|
134
|
+
placeholder='Select a framework…'
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
),
|
|
138
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Check, ChevronsUpDown, X } from 'lucide-react';
|
|
5
|
+
import { cn } from '../lib/utils';
|
|
6
|
+
import { Button } from './button';
|
|
7
|
+
import { Badge } from './badge';
|
|
8
|
+
import {
|
|
9
|
+
Command,
|
|
10
|
+
CommandEmpty,
|
|
11
|
+
CommandGroup,
|
|
12
|
+
CommandInput,
|
|
13
|
+
CommandItem,
|
|
14
|
+
CommandList,
|
|
15
|
+
} from './command';
|
|
16
|
+
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface ComboboxOption {
|
|
23
|
+
value: string;
|
|
24
|
+
label: string;
|
|
25
|
+
/** Optional secondary line shown below the label */
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ComboboxBaseProps {
|
|
30
|
+
options: ComboboxOption[];
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
searchPlaceholder?: string;
|
|
33
|
+
emptyText?: string;
|
|
34
|
+
/** Called on every keystroke in the search input. Use for server-side search. */
|
|
35
|
+
onSearchChange?: (search: string) => void;
|
|
36
|
+
isLoading?: boolean;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
className?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Forwarded to the trigger button so `<label htmlFor>` and the shadcn
|
|
41
|
+
* FormControl (Slot) id/aria wiring give the combobox an accessible name.
|
|
42
|
+
*/
|
|
43
|
+
id?: string;
|
|
44
|
+
'aria-label'?: string;
|
|
45
|
+
'aria-describedby'?: string;
|
|
46
|
+
'aria-invalid'?: React.AriaAttributes['aria-invalid'];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ComboboxSingleProps extends ComboboxBaseProps {
|
|
50
|
+
multiple?: false;
|
|
51
|
+
value?: string;
|
|
52
|
+
onValueChange: (value: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ComboboxMultipleProps extends ComboboxBaseProps {
|
|
56
|
+
multiple: true;
|
|
57
|
+
value?: string[];
|
|
58
|
+
onValueChange: (value: string[]) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps;
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Combobox
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export function Combobox(props: ComboboxProps) {
|
|
68
|
+
const {
|
|
69
|
+
options,
|
|
70
|
+
placeholder = 'Select…',
|
|
71
|
+
searchPlaceholder = 'Search…',
|
|
72
|
+
emptyText = 'No results found.',
|
|
73
|
+
onSearchChange,
|
|
74
|
+
isLoading = false,
|
|
75
|
+
disabled = false,
|
|
76
|
+
className,
|
|
77
|
+
id,
|
|
78
|
+
'aria-label': ariaLabel,
|
|
79
|
+
'aria-describedby': ariaDescribedby,
|
|
80
|
+
'aria-invalid': ariaInvalid,
|
|
81
|
+
} = props;
|
|
82
|
+
|
|
83
|
+
const [open, setOpen] = React.useState(false);
|
|
84
|
+
|
|
85
|
+
const isMultiple = props.multiple === true;
|
|
86
|
+
const selectedValues: string[] = isMultiple
|
|
87
|
+
? (props.value ?? [])
|
|
88
|
+
: props.value
|
|
89
|
+
? [props.value]
|
|
90
|
+
: [];
|
|
91
|
+
|
|
92
|
+
const handleSelect = (optionValue: string) => {
|
|
93
|
+
if (isMultiple) {
|
|
94
|
+
const current = props.value ?? [];
|
|
95
|
+
const next = current.includes(optionValue)
|
|
96
|
+
? current.filter((v) => v !== optionValue)
|
|
97
|
+
: [...current, optionValue];
|
|
98
|
+
(props as ComboboxMultipleProps).onValueChange(next);
|
|
99
|
+
} else {
|
|
100
|
+
(props as ComboboxSingleProps).onValueChange(
|
|
101
|
+
optionValue === props.value ? '' : optionValue
|
|
102
|
+
);
|
|
103
|
+
setOpen(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleRemoveBadge = (optionValue: string, e: React.MouseEvent) => {
|
|
108
|
+
e.stopPropagation();
|
|
109
|
+
if (isMultiple) {
|
|
110
|
+
const current = props.value ?? [];
|
|
111
|
+
(props as ComboboxMultipleProps).onValueChange(
|
|
112
|
+
current.filter((v) => v !== optionValue)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const selectedOptions = selectedValues
|
|
118
|
+
.map((v) => options.find((o) => o.value === v))
|
|
119
|
+
.filter((o): o is ComboboxOption => o !== undefined);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
123
|
+
<PopoverTrigger asChild>
|
|
124
|
+
<Button
|
|
125
|
+
type='button'
|
|
126
|
+
variant='outline'
|
|
127
|
+
role='combobox'
|
|
128
|
+
id={id}
|
|
129
|
+
aria-label={ariaLabel}
|
|
130
|
+
aria-describedby={ariaDescribedby}
|
|
131
|
+
aria-invalid={ariaInvalid}
|
|
132
|
+
aria-expanded={open}
|
|
133
|
+
disabled={disabled}
|
|
134
|
+
className={cn(
|
|
135
|
+
'h-auto min-h-9 w-full justify-between',
|
|
136
|
+
isMultiple && selectedValues.length > 0 ? 'px-2 py-1.5' : '',
|
|
137
|
+
className
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
{isMultiple && selectedOptions.length > 0 ? (
|
|
141
|
+
<span className='flex flex-wrap gap-1'>
|
|
142
|
+
{selectedOptions.map((o) => (
|
|
143
|
+
<Badge
|
|
144
|
+
key={o.value}
|
|
145
|
+
variant='secondary'
|
|
146
|
+
className='gap-0.5 pr-1'
|
|
147
|
+
>
|
|
148
|
+
{o.label}
|
|
149
|
+
<button
|
|
150
|
+
aria-label={`Remove ${o.label}`}
|
|
151
|
+
className='hover:bg-muted ml-0.5 rounded-full'
|
|
152
|
+
onMouseDown={(e) => handleRemoveBadge(o.value, e)}
|
|
153
|
+
>
|
|
154
|
+
<X className='h-3 w-3' />
|
|
155
|
+
</button>
|
|
156
|
+
</Badge>
|
|
157
|
+
))}
|
|
158
|
+
</span>
|
|
159
|
+
) : (
|
|
160
|
+
<span className='text-muted-foreground font-normal'>
|
|
161
|
+
{!isMultiple && selectedOptions[0]
|
|
162
|
+
? selectedOptions[0].label
|
|
163
|
+
: placeholder}
|
|
164
|
+
</span>
|
|
165
|
+
)}
|
|
166
|
+
<ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
|
167
|
+
</Button>
|
|
168
|
+
</PopoverTrigger>
|
|
169
|
+
|
|
170
|
+
<PopoverContent
|
|
171
|
+
className='w-[var(--radix-popover-trigger-width)] p-0'
|
|
172
|
+
align='start'
|
|
173
|
+
>
|
|
174
|
+
<Command shouldFilter={!onSearchChange}>
|
|
175
|
+
<CommandInput
|
|
176
|
+
placeholder={searchPlaceholder}
|
|
177
|
+
onValueChange={onSearchChange}
|
|
178
|
+
/>
|
|
179
|
+
<CommandList>
|
|
180
|
+
{isLoading ? (
|
|
181
|
+
<CommandEmpty>Searching…</CommandEmpty>
|
|
182
|
+
) : (
|
|
183
|
+
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
184
|
+
)}
|
|
185
|
+
<CommandGroup>
|
|
186
|
+
{options.map((option) => {
|
|
187
|
+
const isSelected = selectedValues.includes(option.value);
|
|
188
|
+
return (
|
|
189
|
+
<CommandItem
|
|
190
|
+
key={option.value}
|
|
191
|
+
value={option.value}
|
|
192
|
+
onSelect={handleSelect}
|
|
193
|
+
>
|
|
194
|
+
<Check
|
|
195
|
+
className={cn(
|
|
196
|
+
'mr-2 h-4 w-4 shrink-0',
|
|
197
|
+
isSelected ? 'opacity-100' : 'opacity-0'
|
|
198
|
+
)}
|
|
199
|
+
/>
|
|
200
|
+
<span className='flex flex-col'>
|
|
201
|
+
<span>{option.label}</span>
|
|
202
|
+
{option.description && (
|
|
203
|
+
<span className='text-muted-foreground text-xs'>
|
|
204
|
+
{option.description}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
</span>
|
|
208
|
+
</CommandItem>
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
211
|
+
</CommandGroup>
|
|
212
|
+
</CommandList>
|
|
213
|
+
</Command>
|
|
214
|
+
</PopoverContent>
|
|
215
|
+
</Popover>
|
|
216
|
+
);
|
|
217
|
+
}
|