@fragments-sdk/ui 0.5.0 → 0.6.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.
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Combobox as BaseCombobox } from '@base-ui/react/combobox';
|
|
3
|
+
import styles from './Combobox.module.scss';
|
|
4
|
+
// Import globals to ensure CSS variables are defined
|
|
5
|
+
import '../../styles/globals.scss';
|
|
6
|
+
|
|
7
|
+
// ============================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================
|
|
10
|
+
|
|
11
|
+
export interface ComboboxProps {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
/** Controlled selected value (string for single, string[] for multiple) */
|
|
14
|
+
value?: string | string[] | null;
|
|
15
|
+
/** Default selected value (uncontrolled) */
|
|
16
|
+
defaultValue?: string | string[];
|
|
17
|
+
/** Called when selection changes */
|
|
18
|
+
onValueChange?: (value: string | string[] | null) => void;
|
|
19
|
+
/** Whether multiple items can be selected */
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
open?: boolean;
|
|
22
|
+
defaultOpen?: boolean;
|
|
23
|
+
onOpenChange?: (open: boolean) => void;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
required?: boolean;
|
|
26
|
+
name?: string;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
/** Auto-highlight first matching item while filtering */
|
|
29
|
+
autoHighlight?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ComboboxInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ComboboxTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
37
|
+
children?: React.ReactNode;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ComboboxContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
42
|
+
children: React.ReactNode;
|
|
43
|
+
sideOffset?: number;
|
|
44
|
+
align?: 'start' | 'center' | 'end';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ComboboxItemProps {
|
|
48
|
+
children: React.ReactNode;
|
|
49
|
+
value: string;
|
|
50
|
+
disabled?: boolean;
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ComboboxEmptyProps {
|
|
55
|
+
children: React.ReactNode;
|
|
56
|
+
className?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ComboboxGroupProps {
|
|
60
|
+
children: React.ReactNode;
|
|
61
|
+
className?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ComboboxGroupLabelProps {
|
|
65
|
+
children: React.ReactNode;
|
|
66
|
+
className?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================
|
|
70
|
+
// Icons
|
|
71
|
+
// ============================================
|
|
72
|
+
|
|
73
|
+
function ChevronDownIcon() {
|
|
74
|
+
return (
|
|
75
|
+
<svg
|
|
76
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
77
|
+
width="16"
|
|
78
|
+
height="16"
|
|
79
|
+
viewBox="0 0 24 24"
|
|
80
|
+
fill="none"
|
|
81
|
+
stroke="currentColor"
|
|
82
|
+
strokeWidth="2"
|
|
83
|
+
strokeLinecap="round"
|
|
84
|
+
strokeLinejoin="round"
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
>
|
|
87
|
+
<polyline points="6 9 12 15 18 9" />
|
|
88
|
+
</svg>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function CheckIcon() {
|
|
93
|
+
return (
|
|
94
|
+
<svg
|
|
95
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
96
|
+
width="14"
|
|
97
|
+
height="14"
|
|
98
|
+
viewBox="0 0 24 24"
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke="currentColor"
|
|
101
|
+
strokeWidth="2.5"
|
|
102
|
+
strokeLinecap="round"
|
|
103
|
+
strokeLinejoin="round"
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
>
|
|
106
|
+
<polyline points="20 6 9 17 4 12" />
|
|
107
|
+
</svg>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function XIcon() {
|
|
112
|
+
return (
|
|
113
|
+
<svg
|
|
114
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
115
|
+
width="12"
|
|
116
|
+
height="12"
|
|
117
|
+
viewBox="0 0 24 24"
|
|
118
|
+
fill="none"
|
|
119
|
+
stroke="currentColor"
|
|
120
|
+
strokeWidth="2.5"
|
|
121
|
+
strokeLinecap="round"
|
|
122
|
+
strokeLinejoin="round"
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
>
|
|
125
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
126
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
127
|
+
</svg>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================
|
|
132
|
+
// Context for Combobox state
|
|
133
|
+
// ============================================
|
|
134
|
+
|
|
135
|
+
interface ComboboxContextValue {
|
|
136
|
+
placeholder?: string;
|
|
137
|
+
multiple?: boolean;
|
|
138
|
+
selectedValues: string[];
|
|
139
|
+
itemsRef: React.MutableRefObject<Map<string, string>>;
|
|
140
|
+
itemsVersion: number;
|
|
141
|
+
incrementItemsVersion: () => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ComboboxContext = React.createContext<ComboboxContextValue>({
|
|
145
|
+
selectedValues: [],
|
|
146
|
+
itemsRef: { current: new Map() },
|
|
147
|
+
itemsVersion: 0,
|
|
148
|
+
incrementItemsVersion: () => {},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ============================================
|
|
152
|
+
// Components
|
|
153
|
+
// ============================================
|
|
154
|
+
|
|
155
|
+
function ComboboxRoot({
|
|
156
|
+
children,
|
|
157
|
+
value,
|
|
158
|
+
defaultValue,
|
|
159
|
+
onValueChange,
|
|
160
|
+
multiple = false,
|
|
161
|
+
open,
|
|
162
|
+
defaultOpen,
|
|
163
|
+
onOpenChange,
|
|
164
|
+
disabled,
|
|
165
|
+
required,
|
|
166
|
+
name,
|
|
167
|
+
placeholder,
|
|
168
|
+
autoHighlight = true,
|
|
169
|
+
}: ComboboxProps) {
|
|
170
|
+
// Track selected values for chip rendering
|
|
171
|
+
const [internalValue, setInternalValue] = React.useState<string | string[] | null>(
|
|
172
|
+
value ?? defaultValue ?? (multiple ? [] : null)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Sync with controlled value
|
|
176
|
+
React.useEffect(() => {
|
|
177
|
+
if (value !== undefined) {
|
|
178
|
+
setInternalValue(value);
|
|
179
|
+
}
|
|
180
|
+
}, [value]);
|
|
181
|
+
|
|
182
|
+
// Registry for item value → label mapping
|
|
183
|
+
const itemsRef = React.useRef<Map<string, string>>(new Map());
|
|
184
|
+
const [itemsVersion, setItemsVersion] = React.useState(0);
|
|
185
|
+
const incrementItemsVersion = React.useCallback(() => {
|
|
186
|
+
setItemsVersion((v) => v + 1);
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
const handleValueChange = React.useCallback(
|
|
190
|
+
(newValue: string | string[] | null) => {
|
|
191
|
+
if (value === undefined) {
|
|
192
|
+
setInternalValue(newValue);
|
|
193
|
+
}
|
|
194
|
+
onValueChange?.(newValue);
|
|
195
|
+
},
|
|
196
|
+
[value, onValueChange]
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const handleOpenChange = React.useCallback(
|
|
200
|
+
(nextOpen: boolean) => {
|
|
201
|
+
onOpenChange?.(nextOpen);
|
|
202
|
+
},
|
|
203
|
+
[onOpenChange]
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Convert value → label for input display
|
|
207
|
+
const itemToStringLabel = React.useCallback(
|
|
208
|
+
(itemValue: string) => {
|
|
209
|
+
return itemsRef.current.get(itemValue) ?? itemValue;
|
|
210
|
+
},
|
|
211
|
+
[]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Derive selected values array for chip rendering
|
|
215
|
+
const currentValue = value !== undefined ? value : internalValue;
|
|
216
|
+
const selectedValues = React.useMemo(() => {
|
|
217
|
+
if (currentValue == null) return [];
|
|
218
|
+
if (Array.isArray(currentValue)) return currentValue;
|
|
219
|
+
return [currentValue];
|
|
220
|
+
}, [currentValue]);
|
|
221
|
+
|
|
222
|
+
const contextValue = React.useMemo(
|
|
223
|
+
() => ({ placeholder, multiple, selectedValues, itemsRef, itemsVersion, incrementItemsVersion }),
|
|
224
|
+
[placeholder, multiple, selectedValues, itemsVersion, incrementItemsVersion]
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<ComboboxContext.Provider value={contextValue}>
|
|
229
|
+
<BaseCombobox.Root
|
|
230
|
+
value={value as any}
|
|
231
|
+
defaultValue={defaultValue as any}
|
|
232
|
+
onValueChange={handleValueChange as any}
|
|
233
|
+
open={open}
|
|
234
|
+
defaultOpen={defaultOpen}
|
|
235
|
+
onOpenChange={handleOpenChange as any}
|
|
236
|
+
disabled={disabled}
|
|
237
|
+
required={required}
|
|
238
|
+
name={name}
|
|
239
|
+
multiple={multiple as any}
|
|
240
|
+
autoHighlight={autoHighlight}
|
|
241
|
+
itemToStringLabel={itemToStringLabel}
|
|
242
|
+
>
|
|
243
|
+
{children}
|
|
244
|
+
</BaseCombobox.Root>
|
|
245
|
+
</ComboboxContext.Provider>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function ComboboxInput({ className, ...htmlProps }: ComboboxInputProps) {
|
|
250
|
+
const context = React.useContext(ComboboxContext);
|
|
251
|
+
const classes = [styles.input, className].filter(Boolean).join(' ');
|
|
252
|
+
|
|
253
|
+
if (context.multiple) {
|
|
254
|
+
return (
|
|
255
|
+
<div className={styles.multiContainer}>
|
|
256
|
+
<div className={styles.inputWrapper}>
|
|
257
|
+
<BaseCombobox.Input
|
|
258
|
+
placeholder={context.selectedValues.length === 0 ? context.placeholder : undefined}
|
|
259
|
+
{...htmlProps}
|
|
260
|
+
className={classes}
|
|
261
|
+
/>
|
|
262
|
+
<BaseCombobox.Trigger className={styles.trigger}>
|
|
263
|
+
<ChevronDownIcon />
|
|
264
|
+
</BaseCombobox.Trigger>
|
|
265
|
+
</div>
|
|
266
|
+
{context.selectedValues.length > 0 && (
|
|
267
|
+
<BaseCombobox.Chips className={styles.chips}>
|
|
268
|
+
{context.selectedValues.map((chipValue) => (
|
|
269
|
+
<BaseCombobox.Chip key={chipValue} className={styles.chip}>
|
|
270
|
+
<span className={styles.chipLabel}>
|
|
271
|
+
{context.itemsRef.current.get(chipValue) ?? chipValue}
|
|
272
|
+
</span>
|
|
273
|
+
<BaseCombobox.ChipRemove className={styles.chipRemove}>
|
|
274
|
+
<XIcon />
|
|
275
|
+
</BaseCombobox.ChipRemove>
|
|
276
|
+
</BaseCombobox.Chip>
|
|
277
|
+
))}
|
|
278
|
+
</BaseCombobox.Chips>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<div className={styles.inputWrapper}>
|
|
286
|
+
<BaseCombobox.Input
|
|
287
|
+
placeholder={context.placeholder}
|
|
288
|
+
{...htmlProps}
|
|
289
|
+
className={classes}
|
|
290
|
+
/>
|
|
291
|
+
<BaseCombobox.Trigger className={styles.trigger}>
|
|
292
|
+
<ChevronDownIcon />
|
|
293
|
+
</BaseCombobox.Trigger>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function ComboboxTrigger({ children, className, ...htmlProps }: ComboboxTriggerProps) {
|
|
299
|
+
const classes = [styles.trigger, className].filter(Boolean).join(' ');
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<BaseCombobox.Trigger {...htmlProps} className={classes}>
|
|
303
|
+
{children ?? <ChevronDownIcon />}
|
|
304
|
+
</BaseCombobox.Trigger>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function ComboboxContent({
|
|
309
|
+
children,
|
|
310
|
+
className,
|
|
311
|
+
sideOffset = 4,
|
|
312
|
+
align = 'start',
|
|
313
|
+
...htmlProps
|
|
314
|
+
}: ComboboxContentProps) {
|
|
315
|
+
const popupClasses = [styles.popup, className].filter(Boolean).join(' ');
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<BaseCombobox.Portal>
|
|
319
|
+
<BaseCombobox.Positioner
|
|
320
|
+
side="bottom"
|
|
321
|
+
sideOffset={sideOffset}
|
|
322
|
+
align={align}
|
|
323
|
+
className={styles.positioner}
|
|
324
|
+
>
|
|
325
|
+
<BaseCombobox.Popup {...htmlProps} className={popupClasses}>
|
|
326
|
+
{children}
|
|
327
|
+
</BaseCombobox.Popup>
|
|
328
|
+
</BaseCombobox.Positioner>
|
|
329
|
+
</BaseCombobox.Portal>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function ComboboxItem({ children, value, disabled, className }: ComboboxItemProps) {
|
|
334
|
+
const { itemsRef, incrementItemsVersion } = React.useContext(ComboboxContext);
|
|
335
|
+
const classes = [styles.item, className].filter(Boolean).join(' ');
|
|
336
|
+
|
|
337
|
+
// Register this item's label in the registry so the input can display it
|
|
338
|
+
const label = typeof children === 'string' ? children : String(children);
|
|
339
|
+
React.useEffect(() => {
|
|
340
|
+
itemsRef.current.set(value, label);
|
|
341
|
+
incrementItemsVersion();
|
|
342
|
+
return () => {
|
|
343
|
+
itemsRef.current.delete(value);
|
|
344
|
+
};
|
|
345
|
+
// itemsRef is a stable ref, incrementItemsVersion is a stable callback
|
|
346
|
+
}, [itemsRef, incrementItemsVersion, value, label]);
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<BaseCombobox.Item value={value} disabled={disabled} className={classes}>
|
|
350
|
+
{children}
|
|
351
|
+
<BaseCombobox.ItemIndicator className={styles.itemIndicator}>
|
|
352
|
+
<CheckIcon />
|
|
353
|
+
</BaseCombobox.ItemIndicator>
|
|
354
|
+
</BaseCombobox.Item>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function ComboboxEmpty({ children, className }: ComboboxEmptyProps) {
|
|
359
|
+
const classes = [styles.empty, className].filter(Boolean).join(' ');
|
|
360
|
+
return <BaseCombobox.Empty className={classes}>{children}</BaseCombobox.Empty>;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function ComboboxGroup({ children, className }: ComboboxGroupProps) {
|
|
364
|
+
const classes = [styles.group, className].filter(Boolean).join(' ');
|
|
365
|
+
return <BaseCombobox.Group className={classes}>{children}</BaseCombobox.Group>;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function ComboboxGroupLabel({ children, className }: ComboboxGroupLabelProps) {
|
|
369
|
+
const classes = [styles.groupLabel, className].filter(Boolean).join(' ');
|
|
370
|
+
return <BaseCombobox.GroupLabel className={classes}>{children}</BaseCombobox.GroupLabel>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ============================================
|
|
374
|
+
// Export compound component
|
|
375
|
+
// ============================================
|
|
376
|
+
|
|
377
|
+
export const Combobox = Object.assign(ComboboxRoot, {
|
|
378
|
+
Input: ComboboxInput,
|
|
379
|
+
Trigger: ComboboxTrigger,
|
|
380
|
+
Content: ComboboxContent,
|
|
381
|
+
Item: ComboboxItem,
|
|
382
|
+
ItemIndicator: BaseCombobox.ItemIndicator,
|
|
383
|
+
Empty: ComboboxEmpty,
|
|
384
|
+
Group: ComboboxGroup,
|
|
385
|
+
GroupLabel: ComboboxGroupLabel,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Re-export individual components
|
|
389
|
+
export {
|
|
390
|
+
ComboboxRoot,
|
|
391
|
+
ComboboxInput,
|
|
392
|
+
ComboboxTrigger,
|
|
393
|
+
ComboboxContent,
|
|
394
|
+
ComboboxItem,
|
|
395
|
+
ComboboxEmpty,
|
|
396
|
+
ComboboxGroup,
|
|
397
|
+
ComboboxGroupLabel,
|
|
398
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -156,6 +156,19 @@ export {
|
|
|
156
156
|
// Checkbox
|
|
157
157
|
export { Checkbox, type CheckboxProps } from './components/Checkbox';
|
|
158
158
|
|
|
159
|
+
// Combobox
|
|
160
|
+
export {
|
|
161
|
+
Combobox,
|
|
162
|
+
type ComboboxProps,
|
|
163
|
+
type ComboboxInputProps,
|
|
164
|
+
type ComboboxTriggerProps,
|
|
165
|
+
type ComboboxContentProps,
|
|
166
|
+
type ComboboxItemProps,
|
|
167
|
+
type ComboboxEmptyProps,
|
|
168
|
+
type ComboboxGroupProps,
|
|
169
|
+
type ComboboxGroupLabelProps,
|
|
170
|
+
} from './components/Combobox';
|
|
171
|
+
|
|
159
172
|
// RadioGroup
|
|
160
173
|
export {
|
|
161
174
|
RadioGroup,
|