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.
Files changed (107) hide show
  1. package/components.json +21 -0
  2. package/dist/styles.css +3452 -0
  3. package/package.json +103 -0
  4. package/src/layout/app-shell.tsx +115 -0
  5. package/src/layout/app-topbar.stories.tsx +74 -0
  6. package/src/layout/app-topbar.tsx +145 -0
  7. package/src/layout/auth-layout.tsx +45 -0
  8. package/src/layout/box.stories.tsx +86 -0
  9. package/src/layout/box.tsx +34 -0
  10. package/src/layout/center.tsx +24 -0
  11. package/src/layout/container.stories.tsx +44 -0
  12. package/src/layout/container.tsx +39 -0
  13. package/src/layout/divider.tsx +64 -0
  14. package/src/layout/flex.stories.tsx +109 -0
  15. package/src/layout/flex.test.tsx +71 -0
  16. package/src/layout/flex.tsx +112 -0
  17. package/src/layout/grid.stories.tsx +91 -0
  18. package/src/layout/grid.tsx +132 -0
  19. package/src/layout/heading.tsx +53 -0
  20. package/src/layout/index.ts +47 -0
  21. package/src/layout/layout-style.ts +291 -0
  22. package/src/layout/link.stories.tsx +54 -0
  23. package/src/layout/link.test.tsx +75 -0
  24. package/src/layout/link.tsx +100 -0
  25. package/src/layout/navbar.stories.tsx +70 -0
  26. package/src/layout/navbar.tsx +128 -0
  27. package/src/layout/page-header.stories.tsx +80 -0
  28. package/src/layout/page-header.tsx +59 -0
  29. package/src/layout/section.tsx +22 -0
  30. package/src/layout/spacer.tsx +21 -0
  31. package/src/layout/stack.stories.tsx +58 -0
  32. package/src/layout/stack.tsx +19 -0
  33. package/src/layout/text.tsx +139 -0
  34. package/src/layout/typography.stories.tsx +112 -0
  35. package/src/lib/utils.ts +6 -0
  36. package/src/story-types.ts +80 -0
  37. package/src/styles/build.css +33 -0
  38. package/src/styles/globals.css +9 -0
  39. package/src/styles/tokens.css +281 -0
  40. package/src/ui/accordion.stories.tsx +84 -0
  41. package/src/ui/accordion.tsx +57 -0
  42. package/src/ui/alert-dialog.stories.tsx +57 -0
  43. package/src/ui/alert-dialog.tsx +141 -0
  44. package/src/ui/alert.stories.tsx +46 -0
  45. package/src/ui/alert.tsx +59 -0
  46. package/src/ui/avatar.stories.tsx +56 -0
  47. package/src/ui/avatar.tsx +50 -0
  48. package/src/ui/badge.stories.tsx +105 -0
  49. package/src/ui/badge.tsx +57 -0
  50. package/src/ui/breadcrumb.stories.tsx +69 -0
  51. package/src/ui/breadcrumb.tsx +140 -0
  52. package/src/ui/button.stories.tsx +85 -0
  53. package/src/ui/button.tsx +60 -0
  54. package/src/ui/card.stories.tsx +62 -0
  55. package/src/ui/card.tsx +96 -0
  56. package/src/ui/carousel.stories.tsx +55 -0
  57. package/src/ui/carousel.tsx +262 -0
  58. package/src/ui/checkbox.stories.tsx +52 -0
  59. package/src/ui/checkbox.tsx +30 -0
  60. package/src/ui/combobox.stories.tsx +138 -0
  61. package/src/ui/combobox.tsx +217 -0
  62. package/src/ui/command.tsx +184 -0
  63. package/src/ui/dialog.stories.tsx +66 -0
  64. package/src/ui/dialog.tsx +163 -0
  65. package/src/ui/drawer.stories.tsx +80 -0
  66. package/src/ui/drawer.tsx +118 -0
  67. package/src/ui/dropdown-menu.stories.tsx +57 -0
  68. package/src/ui/dropdown-menu.tsx +201 -0
  69. package/src/ui/form.stories.tsx +70 -0
  70. package/src/ui/form.tsx +181 -0
  71. package/src/ui/input-otp.stories.tsx +52 -0
  72. package/src/ui/input-otp.tsx +71 -0
  73. package/src/ui/input.stories.tsx +68 -0
  74. package/src/ui/input.tsx +22 -0
  75. package/src/ui/label.stories.tsx +35 -0
  76. package/src/ui/label.tsx +26 -0
  77. package/src/ui/navigation-menu.stories.tsx +79 -0
  78. package/src/ui/navigation-menu.tsx +128 -0
  79. package/src/ui/pagination.stories.tsx +62 -0
  80. package/src/ui/pagination.tsx +137 -0
  81. package/src/ui/popover.tsx +89 -0
  82. package/src/ui/progress.stories.tsx +33 -0
  83. package/src/ui/progress.tsx +28 -0
  84. package/src/ui/radio-group.stories.tsx +52 -0
  85. package/src/ui/radio-group.tsx +44 -0
  86. package/src/ui/scheduling-chip.stories.tsx +117 -0
  87. package/src/ui/scheduling-chip.tsx +62 -0
  88. package/src/ui/scroll-area.stories.tsx +52 -0
  89. package/src/ui/scroll-area.tsx +48 -0
  90. package/src/ui/select.stories.tsx +56 -0
  91. package/src/ui/select.tsx +159 -0
  92. package/src/ui/sheet.stories.tsx +63 -0
  93. package/src/ui/sheet.tsx +141 -0
  94. package/src/ui/skeleton.stories.tsx +40 -0
  95. package/src/ui/skeleton.tsx +15 -0
  96. package/src/ui/sonner.stories.tsx +62 -0
  97. package/src/ui/sonner.tsx +34 -0
  98. package/src/ui/switch.stories.tsx +41 -0
  99. package/src/ui/switch.tsx +29 -0
  100. package/src/ui/table.stories.tsx +88 -0
  101. package/src/ui/table.tsx +132 -0
  102. package/src/ui/tabs.stories.tsx +63 -0
  103. package/src/ui/tabs.tsx +55 -0
  104. package/src/ui/textarea.stories.tsx +45 -0
  105. package/src/ui/textarea.tsx +22 -0
  106. package/src/ui/tooltip.stories.tsx +48 -0
  107. 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
+ }