@optilogic/core 1.2.2 → 1.2.3

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.
@@ -0,0 +1,375 @@
1
+ import * as React from "react";
2
+ import { ChevronDown, X } from "lucide-react";
3
+
4
+ import { cn } from "../utils/cn";
5
+ import { Checkbox } from "./checkbox";
6
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
7
+ import type { AutocompleteOption } from "./autocomplete";
8
+
9
+ export interface MultiSelectProps {
10
+ /** Array of options to display */
11
+ options: AutocompleteOption[];
12
+ /** Currently selected values */
13
+ value?: string[];
14
+ /** Callback when selection changes */
15
+ onChange?: (values: string[]) => void;
16
+ /** Placeholder text when no selection */
17
+ placeholder?: string;
18
+ /** Placeholder for the search input */
19
+ searchPlaceholder?: string;
20
+ /** Text to show when no options match the search */
21
+ emptyText?: string;
22
+ /** Whether the multi-select is disabled */
23
+ disabled?: boolean;
24
+ /** Additional class name for the trigger */
25
+ className?: string;
26
+ /** Whether to show the clear-all button */
27
+ clearable?: boolean;
28
+ /** Max number of pills to display before showing "+N more" */
29
+ maxDisplayItems?: number;
30
+ /** Whether to show a "Select All" toggle */
31
+ showSelectAll?: boolean;
32
+ /** Custom label for the select all option */
33
+ selectAllLabel?: string;
34
+ }
35
+
36
+ /**
37
+ * MultiSelect component - a multi-select dropdown with checkboxes
38
+ *
39
+ * Features:
40
+ * - Checkbox selection for multiple options
41
+ * - Search filtering
42
+ * - Grouped options support
43
+ * - Descriptions per option
44
+ * - Tag pills with individual removal
45
+ * - "Select All" toggle
46
+ *
47
+ * @example
48
+ * <MultiSelect
49
+ * options={[
50
+ * { value: 'react', label: 'React', description: 'A JS library' },
51
+ * { value: 'vue', label: 'Vue', group: 'Frameworks' },
52
+ * ]}
53
+ * value={selected}
54
+ * onChange={setSelected}
55
+ * placeholder="Select frameworks..."
56
+ * showSelectAll
57
+ * />
58
+ */
59
+ export function MultiSelect({
60
+ options,
61
+ value,
62
+ onChange,
63
+ placeholder = "Select items...",
64
+ searchPlaceholder = "Search...",
65
+ emptyText = "No options found.",
66
+ disabled = false,
67
+ className,
68
+ clearable = true,
69
+ maxDisplayItems = 3,
70
+ showSelectAll = false,
71
+ selectAllLabel = "Select all",
72
+ }: MultiSelectProps) {
73
+ const [open, setOpen] = React.useState(false);
74
+ const [search, setSearch] = React.useState("");
75
+ const inputRef = React.useRef<HTMLInputElement>(null);
76
+
77
+ const safeOptions = options ?? [];
78
+ const safeValue = value ?? [];
79
+ const selectedSet = React.useMemo(() => new Set(safeValue), [safeValue]);
80
+
81
+ const filteredOptions = React.useMemo(() => {
82
+ if (!search.trim()) return safeOptions;
83
+ const searchLower = search.toLowerCase();
84
+ return safeOptions.filter(
85
+ (opt) =>
86
+ opt.label.toLowerCase().includes(searchLower) ||
87
+ opt.description?.toLowerCase().includes(searchLower)
88
+ );
89
+ }, [safeOptions, search]);
90
+
91
+ const groupedOptions = React.useMemo(() => {
92
+ const groups: Record<string, AutocompleteOption[]> = {};
93
+ const ungrouped: AutocompleteOption[] = [];
94
+
95
+ filteredOptions.forEach((opt) => {
96
+ if (opt.group) {
97
+ if (!groups[opt.group]) groups[opt.group] = [];
98
+ groups[opt.group]!.push(opt);
99
+ } else {
100
+ ungrouped.push(opt);
101
+ }
102
+ });
103
+
104
+ return { groups, ungrouped };
105
+ }, [filteredOptions]);
106
+
107
+ const hasGroups = Object.keys(groupedOptions.groups).length > 0;
108
+
109
+ // Select All logic
110
+ const selectableFiltered = React.useMemo(
111
+ () => filteredOptions.filter((opt) => !opt.disabled),
112
+ [filteredOptions]
113
+ );
114
+
115
+ const allFilteredSelected = React.useMemo(
116
+ () =>
117
+ selectableFiltered.length > 0 &&
118
+ selectableFiltered.every((opt) => selectedSet.has(opt.value)),
119
+ [selectableFiltered, selectedSet]
120
+ );
121
+
122
+ const someFilteredSelected = React.useMemo(
123
+ () =>
124
+ !allFilteredSelected &&
125
+ selectableFiltered.some((opt) => selectedSet.has(opt.value)),
126
+ [selectableFiltered, selectedSet, allFilteredSelected]
127
+ );
128
+
129
+ const handleToggle = React.useCallback(
130
+ (optionValue: string) => {
131
+ const next = selectedSet.has(optionValue)
132
+ ? safeValue.filter((v) => v !== optionValue)
133
+ : [...safeValue, optionValue];
134
+ onChange?.(next);
135
+ },
136
+ [onChange, safeValue, selectedSet]
137
+ );
138
+
139
+ const handleRemove = React.useCallback(
140
+ (optionValue: string, e: React.MouseEvent) => {
141
+ e.stopPropagation();
142
+ onChange?.(safeValue.filter((v) => v !== optionValue));
143
+ },
144
+ [onChange, safeValue]
145
+ );
146
+
147
+ const handleClearAll = React.useCallback(
148
+ (e: React.MouseEvent) => {
149
+ e.stopPropagation();
150
+ onChange?.([]);
151
+ },
152
+ [onChange]
153
+ );
154
+
155
+ const handleSelectAll = React.useCallback(() => {
156
+ if (allFilteredSelected) {
157
+ // Deselect all filtered
158
+ const filteredValues = new Set(selectableFiltered.map((o) => o.value));
159
+ onChange?.(safeValue.filter((v) => !filteredValues.has(v)));
160
+ } else {
161
+ // Select all filtered (merge with existing selections)
162
+ const existing = new Set(safeValue);
163
+ const next = [...safeValue];
164
+ for (const opt of selectableFiltered) {
165
+ if (!existing.has(opt.value)) {
166
+ next.push(opt.value);
167
+ }
168
+ }
169
+ onChange?.(next);
170
+ }
171
+ }, [allFilteredSelected, selectableFiltered, safeValue, onChange]);
172
+
173
+ React.useEffect(() => {
174
+ if (open) {
175
+ const timeout = setTimeout(() => inputRef.current?.focus(), 0);
176
+ return () => clearTimeout(timeout);
177
+ } else {
178
+ setSearch("");
179
+ }
180
+ }, [open]);
181
+
182
+ const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
183
+ if (e.key === "Escape") setOpen(false);
184
+ }, []);
185
+
186
+ const selectedLabels = React.useMemo(
187
+ () =>
188
+ safeValue
189
+ .map((v) => safeOptions.find((o) => o.value === v)?.label ?? v)
190
+ .slice(0, maxDisplayItems),
191
+ [safeValue, safeOptions, maxDisplayItems]
192
+ );
193
+
194
+ const overflow = safeValue.length - maxDisplayItems;
195
+
196
+ const isSearching = search.trim().length > 0;
197
+
198
+ const renderOption = (option: AutocompleteOption) => (
199
+ <button
200
+ key={option.value}
201
+ type="button"
202
+ disabled={option.disabled}
203
+ onClick={() => handleToggle(option.value)}
204
+ className={cn(
205
+ "relative flex w-full cursor-pointer select-none items-start gap-2 rounded-sm px-2 py-1.5 text-sm outline-none",
206
+ "hover:bg-accent hover:text-accent-foreground",
207
+ option.disabled && "pointer-events-none opacity-50"
208
+ )}
209
+ >
210
+ <Checkbox
211
+ checked={selectedSet.has(option.value)}
212
+ tabIndex={-1}
213
+ className="mt-0.5 pointer-events-none"
214
+ />
215
+ <div className="flex-1 min-w-0">
216
+ <div className="truncate">{option.label}</div>
217
+ {option.description && (
218
+ <div className="text-xs text-muted-foreground truncate">
219
+ {option.description}
220
+ </div>
221
+ )}
222
+ </div>
223
+ </button>
224
+ );
225
+
226
+ return (
227
+ <Popover open={open} onOpenChange={setOpen}>
228
+ <PopoverTrigger asChild disabled={disabled}>
229
+ <button
230
+ type="button"
231
+ role="combobox"
232
+ aria-expanded={open}
233
+ aria-haspopup="listbox"
234
+ disabled={disabled}
235
+ className={cn(
236
+ "flex min-h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-sm ring-offset-background",
237
+ "hover:border-input-hover",
238
+ "focus:outline-none focus:ring-1 focus:ring-ring",
239
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-input",
240
+ className
241
+ )}
242
+ >
243
+ <div className="flex flex-1 flex-wrap gap-1 items-center min-w-0">
244
+ {safeValue.length === 0 ? (
245
+ <span className="text-muted-foreground">{placeholder}</span>
246
+ ) : (
247
+ <>
248
+ {selectedLabels.map((label, i) => (
249
+ <span
250
+ key={safeValue[i]}
251
+ className="inline-flex items-center gap-0.5 rounded-sm bg-accent px-1.5 py-0.5 text-xs font-medium text-accent-foreground"
252
+ >
253
+ <span className="truncate max-w-[100px]">{label}</span>
254
+ <span
255
+ role="button"
256
+ tabIndex={-1}
257
+ onClick={(e) => handleRemove(safeValue[i]!, e)}
258
+ className="rounded-sm hover:bg-foreground/10 p-0.5"
259
+ >
260
+ <X className="h-3 w-3" />
261
+ </span>
262
+ </span>
263
+ ))}
264
+ {overflow > 0 && (
265
+ <span className="text-xs text-muted-foreground">
266
+ +{overflow}
267
+ </span>
268
+ )}
269
+ </>
270
+ )}
271
+ </div>
272
+ <div className="flex items-center gap-1 flex-shrink-0">
273
+ {clearable && safeValue.length > 0 && (
274
+ <span
275
+ role="button"
276
+ tabIndex={-1}
277
+ onClick={handleClearAll}
278
+ className="rounded-sm hover:bg-muted p-0.5"
279
+ >
280
+ <X className="h-3.5 w-3.5 text-muted-foreground" />
281
+ </span>
282
+ )}
283
+ <ChevronDown className="h-4 w-4 opacity-50" />
284
+ </div>
285
+ </button>
286
+ </PopoverTrigger>
287
+ <PopoverContent
288
+ className="w-[--radix-popover-trigger-width] p-0"
289
+ align="start"
290
+ sideOffset={4}
291
+ onKeyDown={handleKeyDown}
292
+ >
293
+ {/* Search input */}
294
+ <div className="flex items-center border-b border-border px-3">
295
+ <input
296
+ ref={inputRef}
297
+ type="text"
298
+ value={search}
299
+ onChange={(e) => setSearch(e.target.value)}
300
+ placeholder={searchPlaceholder}
301
+ className="flex h-9 w-full bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground"
302
+ />
303
+ {search && (
304
+ <button
305
+ type="button"
306
+ onClick={() => setSearch("")}
307
+ className="p-1 hover:bg-muted rounded-sm"
308
+ >
309
+ <X className="h-3.5 w-3.5 text-muted-foreground" />
310
+ </button>
311
+ )}
312
+ </div>
313
+
314
+ {/* Options list */}
315
+ <div className="max-h-[300px] overflow-y-auto p-1">
316
+ {/* Select All */}
317
+ {showSelectAll && selectableFiltered.length > 0 && (
318
+ <>
319
+ <button
320
+ type="button"
321
+ onClick={handleSelectAll}
322
+ className={cn(
323
+ "relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none",
324
+ "hover:bg-accent hover:text-accent-foreground"
325
+ )}
326
+ >
327
+ <Checkbox
328
+ checked={
329
+ allFilteredSelected
330
+ ? true
331
+ : someFilteredSelected
332
+ ? "indeterminate"
333
+ : false
334
+ }
335
+ tabIndex={-1}
336
+ className="pointer-events-none"
337
+ />
338
+ <span className="font-medium">
339
+ {isSearching
340
+ ? `${selectAllLabel} (filtered)`
341
+ : selectAllLabel}
342
+ </span>
343
+ </button>
344
+ <div className="-mx-1 my-1 h-px bg-border" />
345
+ </>
346
+ )}
347
+
348
+ {filteredOptions.length === 0 ? (
349
+ <div className="py-6 text-center text-sm text-muted-foreground">
350
+ {emptyText}
351
+ </div>
352
+ ) : hasGroups ? (
353
+ <>
354
+ {groupedOptions.ungrouped.map(renderOption)}
355
+ {Object.entries(groupedOptions.groups).map(([group, opts]) => (
356
+ <div key={group}>
357
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
358
+ {group}
359
+ </div>
360
+ {opts.map(renderOption)}
361
+ </div>
362
+ ))}
363
+ </>
364
+ ) : (
365
+ filteredOptions.map(renderOption)
366
+ )}
367
+ </div>
368
+ </PopoverContent>
369
+ </Popover>
370
+ );
371
+ }
372
+
373
+ MultiSelect.displayName = "MultiSelect";
374
+
375
+ export default MultiSelect;
@@ -119,12 +119,12 @@ const SelectItem = React.forwardRef<
119
119
  <SelectPrimitive.Item
120
120
  ref={ref}
121
121
  className={cn(
122
- "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
122
+ "relative flex w-full cursor-default select-none items-start rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
123
123
  className
124
124
  )}
125
125
  {...props}
126
126
  >
127
- <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
127
+ <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center mt-0.5">
128
128
  <SelectPrimitive.ItemIndicator>
129
129
  <Check className="h-4 w-4" />
130
130
  </SelectPrimitive.ItemIndicator>
@@ -134,6 +134,21 @@ const SelectItem = React.forwardRef<
134
134
  ));
135
135
  SelectItem.displayName = SelectPrimitive.Item.displayName;
136
136
 
137
+ export interface SelectItemDescriptionProps
138
+ extends React.HTMLAttributes<HTMLSpanElement> {}
139
+
140
+ const SelectItemDescription = React.forwardRef<
141
+ HTMLSpanElement,
142
+ SelectItemDescriptionProps
143
+ >(({ className, ...props }, ref) => (
144
+ <span
145
+ ref={ref}
146
+ className={cn("text-xs text-muted-foreground", className)}
147
+ {...props}
148
+ />
149
+ ));
150
+ SelectItemDescription.displayName = "SelectItemDescription";
151
+
137
152
  const SelectSeparator = React.forwardRef<
138
153
  React.ElementRef<typeof SelectPrimitive.Separator>,
139
154
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
@@ -154,6 +169,7 @@ export {
154
169
  SelectContent,
155
170
  SelectLabel,
156
171
  SelectItem,
172
+ SelectItemDescription,
157
173
  SelectSeparator,
158
174
  SelectScrollUpButton,
159
175
  SelectScrollDownButton,
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import * as TabsPrimitive from "@radix-ui/react-tabs";
3
+ import { cva, type VariantProps } from "class-variance-authority";
3
4
 
4
5
  import { cn } from "../utils/cn";
5
6
 
@@ -20,56 +21,113 @@ import { cn } from "../utils/cn";
20
21
  */
21
22
  const Tabs = TabsPrimitive.Root;
22
23
 
24
+ const tabsListVariants = cva(
25
+ "inline-flex h-10 items-center justify-start bg-transparent",
26
+ {
27
+ variants: {
28
+ variant: {
29
+ default: "border-b border-border",
30
+ pill: "gap-1 rounded-lg bg-muted p-1",
31
+ unstyled: "",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: "default",
36
+ },
37
+ }
38
+ );
39
+
40
+ const tabsTriggerVariants = cva(
41
+ [
42
+ "inline-flex items-center justify-center whitespace-nowrap",
43
+ "px-4 py-2.5 text-sm font-medium",
44
+ "transition-colors",
45
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
46
+ "disabled:pointer-events-none disabled:opacity-50",
47
+ ],
48
+ {
49
+ variants: {
50
+ variant: {
51
+ default: [
52
+ "-mb-px",
53
+ "border-transparent text-muted-foreground",
54
+ "hover:text-foreground hover:border-muted-foreground/50",
55
+ "data-[state=active]:border-foreground data-[state=active]:text-foreground",
56
+ ],
57
+ pill: [
58
+ "rounded-md",
59
+ "text-muted-foreground",
60
+ "hover:text-foreground",
61
+ "data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
62
+ ],
63
+ unstyled: [
64
+ "text-muted-foreground",
65
+ "hover:text-foreground",
66
+ "data-[state=active]:text-foreground",
67
+ ],
68
+ },
69
+ indicatorSize: {
70
+ sm: "border-b",
71
+ default: "border-b-2",
72
+ lg: "border-b-[3px]",
73
+ },
74
+ },
75
+ compoundVariants: [
76
+ { variant: "pill", indicatorSize: "sm", className: "border-b-0" },
77
+ { variant: "pill", indicatorSize: "default", className: "border-b-0" },
78
+ { variant: "pill", indicatorSize: "lg", className: "border-b-0" },
79
+ { variant: "unstyled", indicatorSize: "sm", className: "border-b-0" },
80
+ { variant: "unstyled", indicatorSize: "default", className: "border-b-0" },
81
+ { variant: "unstyled", indicatorSize: "lg", className: "border-b-0" },
82
+ ],
83
+ defaultVariants: {
84
+ variant: "default",
85
+ indicatorSize: "default",
86
+ },
87
+ }
88
+ );
89
+
90
+ export interface TabsListProps
91
+ extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>,
92
+ VariantProps<typeof tabsListVariants> {}
93
+
23
94
  /**
24
95
  * TabsList
25
96
  *
26
97
  * Container for tab triggers. Provides the tab header bar.
98
+ * Supports variants: default, pill, unstyled
27
99
  */
28
100
  const TabsList = React.forwardRef<
29
101
  React.ElementRef<typeof TabsPrimitive.List>,
30
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
31
- >(({ className, ...props }, ref) => (
102
+ TabsListProps
103
+ >(({ className, variant, ...props }, ref) => (
32
104
  <TabsPrimitive.List
33
105
  ref={ref}
34
- className={cn(
35
- "inline-flex h-10 items-center justify-start",
36
- "border-b border-border",
37
- "bg-transparent",
38
- className
39
- )}
106
+ className={cn(tabsListVariants({ variant }), className)}
40
107
  {...props}
41
108
  />
42
109
  ));
43
110
  TabsList.displayName = TabsPrimitive.List.displayName;
44
111
 
112
+ export interface TabsTriggerProps
113
+ extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>,
114
+ VariantProps<typeof tabsTriggerVariants> {}
115
+
45
116
  /**
46
117
  * TabsTrigger
47
118
  *
48
119
  * Individual tab button that activates its associated content.
120
+ * Supports variants: default, pill, unstyled
121
+ * Supports indicatorSize: sm, default, lg (only applies to default variant)
49
122
  */
50
123
  const TabsTrigger = React.forwardRef<
51
124
  React.ElementRef<typeof TabsPrimitive.Trigger>,
52
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
53
- >(({ className, ...props }, ref) => (
125
+ TabsTriggerProps
126
+ >(({ className, variant, indicatorSize, ...props }, ref) => (
54
127
  <TabsPrimitive.Trigger
55
128
  ref={ref}
56
129
  className={cn(
57
- // Base styles
58
- "inline-flex items-center justify-center whitespace-nowrap",
59
- "px-4 py-2.5 text-sm font-medium",
60
- "transition-colors",
61
- // Border-bottom indicator style
62
- "border-b-2 -mb-px",
63
- // Default state
64
- "border-transparent text-muted-foreground",
65
- // Hover state
66
- "hover:text-foreground hover:border-muted-foreground/50",
67
- // Active/selected state
68
- "data-[state=active]:border-foreground data-[state=active]:text-foreground",
69
- // Focus styles
70
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
71
- // Disabled styles
72
- "disabled:pointer-events-none disabled:opacity-50",
130
+ tabsTriggerVariants({ variant, indicatorSize }),
73
131
  className
74
132
  )}
75
133
  {...props}
@@ -90,7 +148,6 @@ const TabsContent = React.forwardRef<
90
148
  ref={ref}
91
149
  className={cn(
92
150
  "mt-2",
93
- // Focus styles
94
151
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
95
152
  className
96
153
  )}
@@ -99,4 +156,11 @@ const TabsContent = React.forwardRef<
99
156
  ));
100
157
  TabsContent.displayName = TabsPrimitive.Content.displayName;
101
158
 
102
- export { Tabs, TabsList, TabsTrigger, TabsContent };
159
+ export {
160
+ Tabs,
161
+ TabsList,
162
+ TabsTrigger,
163
+ TabsContent,
164
+ tabsListVariants,
165
+ tabsTriggerVariants,
166
+ };
package/src/index.ts CHANGED
@@ -22,10 +22,12 @@ export {
22
22
  SelectContent,
23
23
  SelectLabel,
24
24
  SelectItem,
25
+ SelectItemDescription,
25
26
  SelectSeparator,
26
27
  SelectScrollUpButton,
27
28
  SelectScrollDownButton,
28
29
  type SelectTriggerProps,
30
+ type SelectItemDescriptionProps,
29
31
  } from "./components/select";
30
32
 
31
33
  export {
@@ -33,6 +35,10 @@ export {
33
35
  TabsList,
34
36
  TabsTrigger,
35
37
  TabsContent,
38
+ tabsListVariants,
39
+ tabsTriggerVariants,
40
+ type TabsListProps,
41
+ type TabsTriggerProps,
36
42
  } from "./components/tabs";
37
43
 
38
44
  export {
@@ -287,6 +293,16 @@ export {
287
293
  type AutocompleteOption,
288
294
  } from "./components/autocomplete";
289
295
 
296
+ export {
297
+ MultiSelect,
298
+ type MultiSelectProps,
299
+ } from "./components/multi-select";
300
+
301
+ export {
302
+ Combobox,
303
+ type ComboboxProps,
304
+ } from "./components/combobox";
305
+
290
306
  // Utility components
291
307
  export {
292
308
  IconButton,