@optilogic/core 1.2.2 → 1.3.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 +582 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -3
- package/dist/index.d.ts +128 -3
- package/dist/index.js +578 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/combobox.tsx +340 -0
- package/src/components/multi-select.tsx +375 -0
- package/src/components/select.tsx +18 -2
- package/src/components/tabs.tsx +92 -28
- package/src/index.ts +16 -0
package/package.json
CHANGED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Check, ChevronDown, X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../utils/cn";
|
|
5
|
+
import { Popover, PopoverAnchor, PopoverContent } from "./popover";
|
|
6
|
+
import type { AutocompleteOption } from "./autocomplete";
|
|
7
|
+
|
|
8
|
+
export interface ComboboxProps {
|
|
9
|
+
/** Array of options to display */
|
|
10
|
+
options: AutocompleteOption[];
|
|
11
|
+
/** Currently selected value */
|
|
12
|
+
value?: string;
|
|
13
|
+
/** Callback when selection changes */
|
|
14
|
+
onChange?: (value: string | undefined) => void;
|
|
15
|
+
/** Callback when the input text changes (for controlled/async filtering) */
|
|
16
|
+
onInputChange?: (input: string) => void;
|
|
17
|
+
/** Placeholder text when empty */
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
/** Text to show when no options match */
|
|
20
|
+
emptyText?: string;
|
|
21
|
+
/** Whether the combobox is disabled */
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
/** Additional class name for the trigger */
|
|
24
|
+
className?: string;
|
|
25
|
+
/** Whether to allow clearing the selection */
|
|
26
|
+
clearable?: boolean;
|
|
27
|
+
/** Whether to allow custom values not in the options list */
|
|
28
|
+
allowCustomValue?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Combobox component - a searchable input that also allows custom values
|
|
33
|
+
*
|
|
34
|
+
* Unlike Autocomplete (button trigger, select-only), Combobox uses an
|
|
35
|
+
* inline text input as the trigger. Users can type to filter options
|
|
36
|
+
* AND optionally commit custom values not in the list.
|
|
37
|
+
*
|
|
38
|
+
* Features:
|
|
39
|
+
* - Inline text input trigger
|
|
40
|
+
* - Dropdown opens on focus/typing
|
|
41
|
+
* - Grouped options with descriptions
|
|
42
|
+
* - Custom value support (allowCustomValue)
|
|
43
|
+
* - Clearable selection
|
|
44
|
+
* - Async filtering via onInputChange
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* <Combobox
|
|
48
|
+
* options={[
|
|
49
|
+
* { value: 'react', label: 'React' },
|
|
50
|
+
* { value: 'vue', label: 'Vue' },
|
|
51
|
+
* ]}
|
|
52
|
+
* value={selected}
|
|
53
|
+
* onChange={setSelected}
|
|
54
|
+
* placeholder="Type or select..."
|
|
55
|
+
* allowCustomValue
|
|
56
|
+
* />
|
|
57
|
+
*/
|
|
58
|
+
export function Combobox({
|
|
59
|
+
options,
|
|
60
|
+
value,
|
|
61
|
+
onChange,
|
|
62
|
+
onInputChange,
|
|
63
|
+
placeholder = "Type or select...",
|
|
64
|
+
emptyText = "No options found.",
|
|
65
|
+
disabled = false,
|
|
66
|
+
className,
|
|
67
|
+
clearable = false,
|
|
68
|
+
allowCustomValue = true,
|
|
69
|
+
}: ComboboxProps) {
|
|
70
|
+
const [open, setOpen] = React.useState(false);
|
|
71
|
+
const [inputValue, setInputValue] = React.useState("");
|
|
72
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
73
|
+
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
|
74
|
+
|
|
75
|
+
// Sync input display with selected value
|
|
76
|
+
const selectedOption = React.useMemo(
|
|
77
|
+
() => options.find((opt) => opt.value === value),
|
|
78
|
+
[options, value]
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// When value changes externally, update input display
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
if (!open) {
|
|
84
|
+
setInputValue(selectedOption?.label ?? value ?? "");
|
|
85
|
+
}
|
|
86
|
+
}, [value, selectedOption, open]);
|
|
87
|
+
|
|
88
|
+
// Filter options based on input
|
|
89
|
+
const filteredOptions = React.useMemo(() => {
|
|
90
|
+
if (!inputValue.trim()) return options;
|
|
91
|
+
const searchLower = inputValue.toLowerCase();
|
|
92
|
+
return options.filter(
|
|
93
|
+
(opt) =>
|
|
94
|
+
opt.label.toLowerCase().includes(searchLower) ||
|
|
95
|
+
opt.description?.toLowerCase().includes(searchLower)
|
|
96
|
+
);
|
|
97
|
+
}, [options, inputValue]);
|
|
98
|
+
|
|
99
|
+
// Group options
|
|
100
|
+
const groupedOptions = React.useMemo(() => {
|
|
101
|
+
const groups: Record<string, AutocompleteOption[]> = {};
|
|
102
|
+
const ungrouped: AutocompleteOption[] = [];
|
|
103
|
+
|
|
104
|
+
filteredOptions.forEach((opt) => {
|
|
105
|
+
if (opt.group) {
|
|
106
|
+
if (!groups[opt.group]) groups[opt.group] = [];
|
|
107
|
+
groups[opt.group]!.push(opt);
|
|
108
|
+
} else {
|
|
109
|
+
ungrouped.push(opt);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return { groups, ungrouped };
|
|
114
|
+
}, [filteredOptions]);
|
|
115
|
+
|
|
116
|
+
const hasGroups = Object.keys(groupedOptions.groups).length > 0;
|
|
117
|
+
|
|
118
|
+
const handleInputChange = React.useCallback(
|
|
119
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
120
|
+
const newValue = e.target.value;
|
|
121
|
+
setInputValue(newValue);
|
|
122
|
+
onInputChange?.(newValue);
|
|
123
|
+
if (!open) setOpen(true);
|
|
124
|
+
},
|
|
125
|
+
[onInputChange, open]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const handleSelect = React.useCallback(
|
|
129
|
+
(optionValue: string) => {
|
|
130
|
+
const option = options.find((o) => o.value === optionValue);
|
|
131
|
+
onChange?.(optionValue);
|
|
132
|
+
setInputValue(option?.label ?? optionValue);
|
|
133
|
+
setOpen(false);
|
|
134
|
+
},
|
|
135
|
+
[onChange, options]
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const handleClear = React.useCallback(
|
|
139
|
+
(e: React.MouseEvent) => {
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
onChange?.(undefined);
|
|
143
|
+
setInputValue("");
|
|
144
|
+
inputRef.current?.focus();
|
|
145
|
+
},
|
|
146
|
+
[onChange]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const handleFocus = React.useCallback(() => {
|
|
150
|
+
setOpen(true);
|
|
151
|
+
// Select all text on focus for easy replacement
|
|
152
|
+
inputRef.current?.select();
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const handleBlur = React.useCallback(() => {
|
|
156
|
+
// Delay to allow click events on options to fire
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
// If focus moved outside the wrapper entirely, close
|
|
159
|
+
if (!wrapperRef.current?.contains(document.activeElement)) {
|
|
160
|
+
setOpen(false);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (allowCustomValue && inputValue.trim()) {
|
|
164
|
+
// Check if input matches an existing option label
|
|
165
|
+
const matchingOption = options.find(
|
|
166
|
+
(opt) => opt.label.toLowerCase() === inputValue.toLowerCase()
|
|
167
|
+
);
|
|
168
|
+
if (matchingOption) {
|
|
169
|
+
onChange?.(matchingOption.value);
|
|
170
|
+
setInputValue(matchingOption.label);
|
|
171
|
+
} else {
|
|
172
|
+
// Commit custom value
|
|
173
|
+
onChange?.(inputValue.trim());
|
|
174
|
+
}
|
|
175
|
+
} else if (!allowCustomValue) {
|
|
176
|
+
// Reset to selected option or clear
|
|
177
|
+
setInputValue(selectedOption?.label ?? "");
|
|
178
|
+
}
|
|
179
|
+
}, 200);
|
|
180
|
+
}, [allowCustomValue, inputValue, options, onChange, selectedOption]);
|
|
181
|
+
|
|
182
|
+
const handleKeyDown = React.useCallback(
|
|
183
|
+
(e: React.KeyboardEvent) => {
|
|
184
|
+
if (e.key === "Escape") {
|
|
185
|
+
setOpen(false);
|
|
186
|
+
setInputValue(selectedOption?.label ?? value ?? "");
|
|
187
|
+
inputRef.current?.blur();
|
|
188
|
+
} else if (e.key === "Enter" && open) {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
// If there's exactly one filtered option, select it
|
|
191
|
+
if (filteredOptions.length === 1) {
|
|
192
|
+
handleSelect(filteredOptions[0]!.value);
|
|
193
|
+
} else if (allowCustomValue && inputValue.trim()) {
|
|
194
|
+
// Check for exact match first
|
|
195
|
+
const exactMatch = filteredOptions.find(
|
|
196
|
+
(opt) => opt.label.toLowerCase() === inputValue.toLowerCase()
|
|
197
|
+
);
|
|
198
|
+
if (exactMatch) {
|
|
199
|
+
handleSelect(exactMatch.value);
|
|
200
|
+
} else {
|
|
201
|
+
onChange?.(inputValue.trim());
|
|
202
|
+
setOpen(false);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[
|
|
208
|
+
open,
|
|
209
|
+
filteredOptions,
|
|
210
|
+
handleSelect,
|
|
211
|
+
allowCustomValue,
|
|
212
|
+
inputValue,
|
|
213
|
+
onChange,
|
|
214
|
+
selectedOption,
|
|
215
|
+
value,
|
|
216
|
+
]
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const renderOption = (option: AutocompleteOption) => (
|
|
220
|
+
<button
|
|
221
|
+
key={option.value}
|
|
222
|
+
type="button"
|
|
223
|
+
disabled={option.disabled}
|
|
224
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
225
|
+
onClick={() => handleSelect(option.value)}
|
|
226
|
+
className={cn(
|
|
227
|
+
"relative flex w-full cursor-pointer select-none items-start gap-2 rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
228
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
229
|
+
"focus:bg-accent focus:text-accent-foreground",
|
|
230
|
+
option.disabled && "pointer-events-none opacity-50",
|
|
231
|
+
value === option.value && "bg-accent/50"
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
<span className="flex h-4 w-4 items-center justify-center flex-shrink-0 mt-0.5">
|
|
235
|
+
{value === option.value && <Check className="h-4 w-4" />}
|
|
236
|
+
</span>
|
|
237
|
+
<div className="flex-1 min-w-0">
|
|
238
|
+
<div className="truncate">{option.label}</div>
|
|
239
|
+
{option.description && (
|
|
240
|
+
<div className="text-xs text-muted-foreground truncate">
|
|
241
|
+
{option.description}
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
</button>
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
250
|
+
<PopoverAnchor asChild>
|
|
251
|
+
<div
|
|
252
|
+
ref={wrapperRef}
|
|
253
|
+
className={cn(
|
|
254
|
+
"flex h-9 w-full items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm shadow-sm ring-offset-background",
|
|
255
|
+
"hover:border-input-hover",
|
|
256
|
+
"focus-within:outline-none focus-within:ring-1 focus-within:ring-ring",
|
|
257
|
+
disabled &&
|
|
258
|
+
"cursor-not-allowed opacity-50 hover:border-input",
|
|
259
|
+
className
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
262
|
+
<input
|
|
263
|
+
ref={inputRef}
|
|
264
|
+
type="text"
|
|
265
|
+
value={inputValue}
|
|
266
|
+
onChange={handleInputChange}
|
|
267
|
+
onFocus={handleFocus}
|
|
268
|
+
onBlur={handleBlur}
|
|
269
|
+
onKeyDown={handleKeyDown}
|
|
270
|
+
placeholder={placeholder}
|
|
271
|
+
disabled={disabled}
|
|
272
|
+
className="flex-1 min-w-0 bg-transparent py-2 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
|
273
|
+
role="combobox"
|
|
274
|
+
aria-expanded={open}
|
|
275
|
+
aria-haspopup="listbox"
|
|
276
|
+
aria-autocomplete="list"
|
|
277
|
+
/>
|
|
278
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
279
|
+
{clearable && value && (
|
|
280
|
+
<span
|
|
281
|
+
role="button"
|
|
282
|
+
tabIndex={-1}
|
|
283
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
284
|
+
onClick={handleClear}
|
|
285
|
+
className="rounded-sm hover:bg-muted p-0.5"
|
|
286
|
+
>
|
|
287
|
+
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</PopoverAnchor>
|
|
294
|
+
<PopoverContent
|
|
295
|
+
className="p-0"
|
|
296
|
+
style={{ width: wrapperRef.current?.offsetWidth }}
|
|
297
|
+
align="start"
|
|
298
|
+
sideOffset={4}
|
|
299
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
300
|
+
onFocusOutside={(e) => {
|
|
301
|
+
if (wrapperRef.current?.contains(e.target as Node)) {
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
}
|
|
304
|
+
}}
|
|
305
|
+
onInteractOutside={(e) => {
|
|
306
|
+
if (wrapperRef.current?.contains(e.target as Node)) {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
}
|
|
309
|
+
}}
|
|
310
|
+
>
|
|
311
|
+
{/* Options list */}
|
|
312
|
+
<div className="max-h-[300px] overflow-y-auto p-1">
|
|
313
|
+
{filteredOptions.length === 0 ? (
|
|
314
|
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
315
|
+
{emptyText}
|
|
316
|
+
</div>
|
|
317
|
+
) : hasGroups ? (
|
|
318
|
+
<>
|
|
319
|
+
{groupedOptions.ungrouped.map(renderOption)}
|
|
320
|
+
{Object.entries(groupedOptions.groups).map(([group, opts]) => (
|
|
321
|
+
<div key={group}>
|
|
322
|
+
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
323
|
+
{group}
|
|
324
|
+
</div>
|
|
325
|
+
{opts.map(renderOption)}
|
|
326
|
+
</div>
|
|
327
|
+
))}
|
|
328
|
+
</>
|
|
329
|
+
) : (
|
|
330
|
+
filteredOptions.map(renderOption)
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
</PopoverContent>
|
|
334
|
+
</Popover>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
Combobox.displayName = "Combobox";
|
|
339
|
+
|
|
340
|
+
export default Combobox;
|