@optilogic/core 1.2.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optilogic/core",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Core UI components for Optilogic - A professional React component library",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -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;
@@ -33,6 +33,8 @@ const PERMISSIONS_POLICY = [
33
33
  "geolocation=()",
34
34
  "payment=()",
35
35
  "usb=()",
36
+ "clipboard-read=()",
37
+ "clipboard-write=()",
36
38
  "display-capture=()",
37
39
  "fullscreen=()",
38
40
  "autoplay=()",