@kushagradhawan/kookie-ui 0.1.78 → 0.1.79
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/components.css +69 -29
- package/dist/cjs/components/combobox.d.ts +57 -7
- package/dist/cjs/components/combobox.d.ts.map +1 -1
- package/dist/cjs/components/combobox.js +1 -1
- package/dist/cjs/components/combobox.js.map +3 -3
- package/dist/cjs/components/text-field.d.ts +2 -2
- package/dist/cjs/components/text-field.d.ts.map +1 -1
- package/dist/cjs/components/text-field.js +2 -2
- package/dist/cjs/components/text-field.js.map +3 -3
- package/dist/cjs/components/text-field.props.d.ts +26 -0
- package/dist/cjs/components/text-field.props.d.ts.map +1 -1
- package/dist/cjs/components/text-field.props.js +1 -1
- package/dist/cjs/components/text-field.props.js.map +1 -1
- package/dist/esm/components/combobox.d.ts +57 -7
- package/dist/esm/components/combobox.d.ts.map +1 -1
- package/dist/esm/components/combobox.js +1 -1
- package/dist/esm/components/combobox.js.map +3 -3
- package/dist/esm/components/text-field.d.ts +2 -2
- package/dist/esm/components/text-field.d.ts.map +1 -1
- package/dist/esm/components/text-field.js +2 -2
- package/dist/esm/components/text-field.js.map +3 -3
- package/dist/esm/components/text-field.props.d.ts +26 -0
- package/dist/esm/components/text-field.props.d.ts.map +1 -1
- package/dist/esm/components/text-field.props.js +1 -1
- package/dist/esm/components/text-field.props.js.map +1 -1
- package/package.json +2 -2
- package/schemas/base-button.json +1 -1
- package/schemas/button.json +1 -1
- package/schemas/icon-button.json +1 -1
- package/schemas/index.json +6 -6
- package/schemas/toggle-button.json +1 -1
- package/schemas/toggle-icon-button.json +1 -1
- package/src/components/combobox.css +56 -55
- package/src/components/combobox.tsx +305 -73
- package/src/components/text-field.css +83 -0
- package/src/components/text-field.props.tsx +28 -0
- package/src/components/text-field.tsx +222 -5
- package/styles.css +69 -29
|
@@ -29,6 +29,14 @@ import type { GetPropDefTypes } from '../props/prop-def.js';
|
|
|
29
29
|
|
|
30
30
|
type TextFieldVariant = (typeof textFieldRootPropDefs.variant.values)[number];
|
|
31
31
|
type ComboboxValue = string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Custom filter function for Combobox search.
|
|
34
|
+
* @param value - The item's value being tested
|
|
35
|
+
* @param search - The current search string
|
|
36
|
+
* @param keywords - Optional keywords associated with the item
|
|
37
|
+
* @returns A number between 0 and 1 where 0 means no match and 1 means exact match.
|
|
38
|
+
* Fractional values indicate relevance for sorting.
|
|
39
|
+
*/
|
|
32
40
|
type CommandFilter = (value: string, search: string, keywords?: string[]) => number;
|
|
33
41
|
|
|
34
42
|
/**
|
|
@@ -50,6 +58,42 @@ type ComboboxRootOwnProps = GetPropDefTypes<typeof comboboxRootPropDefs> & {
|
|
|
50
58
|
shouldFilter?: boolean;
|
|
51
59
|
loop?: boolean;
|
|
52
60
|
disabled?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Whether to reset the search value when an option is selected.
|
|
63
|
+
* @default true
|
|
64
|
+
*/
|
|
65
|
+
resetSearchOnSelect?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Accent color for the combobox trigger and content.
|
|
68
|
+
*/
|
|
69
|
+
color?: (typeof comboboxTriggerPropDefs.color.values)[number];
|
|
70
|
+
/**
|
|
71
|
+
* Display value shown in the trigger. This is the recommended approach for
|
|
72
|
+
* best performance as it avoids needing to mount items to register labels.
|
|
73
|
+
*
|
|
74
|
+
* Can be either:
|
|
75
|
+
* - A string: Static display value
|
|
76
|
+
* - A function: `(value: string | null) => string | undefined` - Called with current value
|
|
77
|
+
*
|
|
78
|
+
* Use this when:
|
|
79
|
+
* - You have the selected item's label available (e.g., from your data source)
|
|
80
|
+
* - Items haven't mounted yet (e.g., on initial render with a defaultValue)
|
|
81
|
+
* - You want optimal performance with forceMount={false} (default)
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // Static string
|
|
85
|
+
* <Combobox.Root value="usa" displayValue="United States">
|
|
86
|
+
*
|
|
87
|
+
* // Function (recommended for dynamic data)
|
|
88
|
+
* <Combobox.Root
|
|
89
|
+
* value={selectedCountry}
|
|
90
|
+
* displayValue={(value) => countries.find(c => c.code === value)?.name}
|
|
91
|
+
* >
|
|
92
|
+
*
|
|
93
|
+
* If not provided, falls back to the label registered by the selected item
|
|
94
|
+
* (requires forceMount={true}), then to the raw value.
|
|
95
|
+
*/
|
|
96
|
+
displayValue?: string | ((value: ComboboxValue) => string | undefined);
|
|
53
97
|
};
|
|
54
98
|
|
|
55
99
|
/**
|
|
@@ -62,9 +106,16 @@ interface ComboboxContextValue extends ComboboxRootOwnProps {
|
|
|
62
106
|
setValue: (value: ComboboxValue) => void;
|
|
63
107
|
searchValue: string;
|
|
64
108
|
setSearchValue: (value: string) => void;
|
|
109
|
+
/** Label registered by the selected item */
|
|
65
110
|
selectedLabel?: string;
|
|
111
|
+
/** Resolved display value (already computed from string or function) */
|
|
112
|
+
resolvedDisplayValue?: string;
|
|
66
113
|
registerItemLabel: (value: string, label: string) => void;
|
|
114
|
+
unregisterItemLabel: (value: string) => void;
|
|
67
115
|
handleSelect: (value: string) => void;
|
|
116
|
+
listboxId: string;
|
|
117
|
+
activeDescendantId: string | undefined;
|
|
118
|
+
setActiveDescendantId: (id: string | undefined) => void;
|
|
68
119
|
}
|
|
69
120
|
|
|
70
121
|
const ComboboxContext = React.createContext<ComboboxContextValue | null>(null);
|
|
@@ -116,9 +167,17 @@ const ComboboxRoot: React.FC<ComboboxRootProps> = (props) => {
|
|
|
116
167
|
shouldFilter = true,
|
|
117
168
|
loop = true,
|
|
118
169
|
disabled,
|
|
170
|
+
resetSearchOnSelect = true,
|
|
171
|
+
color,
|
|
172
|
+
displayValue: displayValueProp,
|
|
119
173
|
...rootProps
|
|
120
174
|
} = props;
|
|
121
175
|
|
|
176
|
+
// Generate stable IDs for accessibility
|
|
177
|
+
const generatedId = React.useId();
|
|
178
|
+
const listboxId = `combobox-listbox-${generatedId}`;
|
|
179
|
+
const [activeDescendantId, setActiveDescendantId] = React.useState<string | undefined>(undefined);
|
|
180
|
+
|
|
122
181
|
const [open, setOpen] = useControllableState({
|
|
123
182
|
prop: openProp,
|
|
124
183
|
defaultProp: defaultOpen,
|
|
@@ -138,21 +197,67 @@ const ComboboxRoot: React.FC<ComboboxRootProps> = (props) => {
|
|
|
138
197
|
});
|
|
139
198
|
|
|
140
199
|
const labelMapRef = React.useRef(new Map<string, string>());
|
|
200
|
+
// Track the selected label in state so it triggers re-renders when items register
|
|
201
|
+
const [selectedLabel, setSelectedLabel] = React.useState<string | undefined>(undefined);
|
|
202
|
+
|
|
141
203
|
const registerItemLabel = React.useCallback((itemValue: string, label: string) => {
|
|
142
204
|
labelMapRef.current.set(itemValue, label);
|
|
205
|
+
// If this item matches the current value, update the selected label
|
|
206
|
+
if (itemValue === value) {
|
|
207
|
+
setSelectedLabel(label);
|
|
208
|
+
}
|
|
209
|
+
}, [value]);
|
|
210
|
+
|
|
211
|
+
const unregisterItemLabel = React.useCallback((itemValue: string) => {
|
|
212
|
+
labelMapRef.current.delete(itemValue);
|
|
143
213
|
}, []);
|
|
144
214
|
|
|
145
|
-
|
|
215
|
+
// Update selected label when value changes
|
|
216
|
+
React.useEffect(() => {
|
|
217
|
+
if (value != null) {
|
|
218
|
+
const label = labelMapRef.current.get(value);
|
|
219
|
+
setSelectedLabel(label);
|
|
220
|
+
} else {
|
|
221
|
+
setSelectedLabel(undefined);
|
|
222
|
+
}
|
|
223
|
+
}, [value]);
|
|
146
224
|
|
|
147
225
|
const handleSelect = React.useCallback(
|
|
148
226
|
(nextValue: string) => {
|
|
149
227
|
setValue(nextValue);
|
|
150
228
|
setOpen(false);
|
|
151
|
-
|
|
229
|
+
if (resetSearchOnSelect) {
|
|
230
|
+
setSearchValue('');
|
|
231
|
+
}
|
|
152
232
|
},
|
|
153
|
-
[setOpen, setSearchValue, setValue],
|
|
233
|
+
[setOpen, setSearchValue, setValue, resetSearchOnSelect],
|
|
154
234
|
);
|
|
155
235
|
|
|
236
|
+
// Development mode warning for value not matching any registered item
|
|
237
|
+
React.useEffect(() => {
|
|
238
|
+
if (process.env.NODE_ENV !== 'production' && value != null && !labelMapRef.current.has(value)) {
|
|
239
|
+
// Defer the check to allow items to register first
|
|
240
|
+
const timeoutId = setTimeout(() => {
|
|
241
|
+
if (value != null && !labelMapRef.current.has(value)) {
|
|
242
|
+
console.warn(
|
|
243
|
+
`[Combobox] The value "${value}" does not match any Combobox.Item. ` +
|
|
244
|
+
`Make sure each Item has a matching value prop.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}, 0);
|
|
248
|
+
return () => clearTimeout(timeoutId);
|
|
249
|
+
}
|
|
250
|
+
}, [value]);
|
|
251
|
+
|
|
252
|
+
// Resolve displayValue: compute if function, use directly if string
|
|
253
|
+
const resolvedDisplayValue = React.useMemo(() => {
|
|
254
|
+
if (displayValueProp == null) return undefined;
|
|
255
|
+
if (typeof displayValueProp === 'function') {
|
|
256
|
+
return displayValueProp(value);
|
|
257
|
+
}
|
|
258
|
+
return displayValueProp;
|
|
259
|
+
}, [displayValueProp, value]);
|
|
260
|
+
|
|
156
261
|
const contextValue = React.useMemo<ComboboxContextValue>(
|
|
157
262
|
() => ({
|
|
158
263
|
size,
|
|
@@ -163,6 +268,9 @@ const ComboboxRoot: React.FC<ComboboxRootProps> = (props) => {
|
|
|
163
268
|
shouldFilter,
|
|
164
269
|
loop,
|
|
165
270
|
disabled,
|
|
271
|
+
resetSearchOnSelect,
|
|
272
|
+
color,
|
|
273
|
+
resolvedDisplayValue,
|
|
166
274
|
open,
|
|
167
275
|
setOpen,
|
|
168
276
|
value,
|
|
@@ -171,7 +279,11 @@ const ComboboxRoot: React.FC<ComboboxRootProps> = (props) => {
|
|
|
171
279
|
setSearchValue,
|
|
172
280
|
selectedLabel,
|
|
173
281
|
registerItemLabel,
|
|
282
|
+
unregisterItemLabel,
|
|
174
283
|
handleSelect,
|
|
284
|
+
listboxId,
|
|
285
|
+
activeDescendantId,
|
|
286
|
+
setActiveDescendantId,
|
|
175
287
|
}),
|
|
176
288
|
[
|
|
177
289
|
size,
|
|
@@ -182,6 +294,9 @@ const ComboboxRoot: React.FC<ComboboxRootProps> = (props) => {
|
|
|
182
294
|
shouldFilter,
|
|
183
295
|
loop,
|
|
184
296
|
disabled,
|
|
297
|
+
resetSearchOnSelect,
|
|
298
|
+
color,
|
|
299
|
+
resolvedDisplayValue,
|
|
185
300
|
open,
|
|
186
301
|
setOpen,
|
|
187
302
|
value,
|
|
@@ -190,7 +305,11 @@ const ComboboxRoot: React.FC<ComboboxRootProps> = (props) => {
|
|
|
190
305
|
setSearchValue,
|
|
191
306
|
selectedLabel,
|
|
192
307
|
registerItemLabel,
|
|
308
|
+
unregisterItemLabel,
|
|
193
309
|
handleSelect,
|
|
310
|
+
listboxId,
|
|
311
|
+
activeDescendantId,
|
|
312
|
+
setActiveDescendantId,
|
|
194
313
|
],
|
|
195
314
|
);
|
|
196
315
|
|
|
@@ -225,13 +344,22 @@ const ComboboxTrigger = React.forwardRef<ComboboxTriggerElement, ComboboxTrigger
|
|
|
225
344
|
const { material, panelBackground } = props;
|
|
226
345
|
|
|
227
346
|
const isDisabled = disabled ?? context.disabled;
|
|
347
|
+
|
|
348
|
+
// Use color from props or fall back to context color
|
|
349
|
+
const resolvedColor = color ?? context.color;
|
|
350
|
+
|
|
351
|
+
// Comprehensive ARIA attributes for combobox pattern (WAI-ARIA 1.2)
|
|
228
352
|
const ariaProps = React.useMemo(
|
|
229
353
|
() => ({
|
|
354
|
+
role: 'combobox' as const,
|
|
230
355
|
'aria-expanded': context.open,
|
|
231
356
|
'aria-disabled': isDisabled || undefined,
|
|
232
357
|
'aria-haspopup': 'listbox' as const,
|
|
358
|
+
'aria-controls': context.open ? context.listboxId : undefined,
|
|
359
|
+
'aria-activedescendant': context.open ? context.activeDescendantId : undefined,
|
|
360
|
+
'aria-autocomplete': 'list' as const,
|
|
233
361
|
}),
|
|
234
|
-
[context.open, isDisabled],
|
|
362
|
+
[context.open, context.listboxId, context.activeDescendantId, isDisabled],
|
|
235
363
|
);
|
|
236
364
|
|
|
237
365
|
const defaultContent = (
|
|
@@ -256,7 +384,7 @@ const ComboboxTrigger = React.forwardRef<ComboboxTriggerElement, ComboboxTrigger
|
|
|
256
384
|
|
|
257
385
|
const triggerChild = (
|
|
258
386
|
<button
|
|
259
|
-
data-accent-color={
|
|
387
|
+
data-accent-color={resolvedColor}
|
|
260
388
|
data-radius={radius}
|
|
261
389
|
data-panel-background={panelBackground}
|
|
262
390
|
data-material={material}
|
|
@@ -286,10 +414,13 @@ interface ComboboxValueProps extends React.ComponentPropsWithoutRef<'span'> {
|
|
|
286
414
|
/**
|
|
287
415
|
* Value mirrors Select.Value by showing the selected item's label
|
|
288
416
|
* or falling back to placeholder text supplied by the consumer or context.
|
|
417
|
+
*
|
|
418
|
+
* Priority: resolvedDisplayValue (explicit) > selectedLabel (from items) > raw value > children > placeholder
|
|
289
419
|
*/
|
|
290
420
|
const ComboboxValue = React.forwardRef<ComboboxValueElement, ComboboxValueProps>(({ placeholder, children, className, ...valueProps }, forwardedRef) => {
|
|
291
421
|
const context = useComboboxContext('Combobox.Value');
|
|
292
|
-
|
|
422
|
+
// Priority: explicit displayValue (resolved) > registered label > raw value
|
|
423
|
+
const displayValue = context.resolvedDisplayValue ?? context.selectedLabel ?? context.value ?? undefined;
|
|
293
424
|
const fallback = placeholder ?? context.placeholder;
|
|
294
425
|
return (
|
|
295
426
|
<span {...valueProps} ref={forwardedRef} className={classNames('rt-ComboboxValue', className)}>
|
|
@@ -321,16 +452,28 @@ const ComboboxContent = React.forwardRef<ComboboxContentElement, ComboboxContent
|
|
|
321
452
|
{ ...props, size: sizeProp, variant: variantProp, highContrast: highContrastProp },
|
|
322
453
|
comboboxContentPropDefs,
|
|
323
454
|
);
|
|
324
|
-
const resolvedColor = color || themeContext.accentColor;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
455
|
+
const resolvedColor = color || context.color || themeContext.accentColor;
|
|
456
|
+
|
|
457
|
+
// Memoize className sanitization to avoid string operations on every render
|
|
458
|
+
const sanitizedClassName = React.useMemo(() => {
|
|
459
|
+
if (typeof sizeProp !== 'string') return className;
|
|
460
|
+
return className
|
|
461
|
+
?.split(/\s+/)
|
|
462
|
+
.filter(Boolean)
|
|
463
|
+
.filter((token) => !/^rt-r-size-\d$/.test(token))
|
|
464
|
+
.join(' ') || undefined;
|
|
465
|
+
}, [className, sizeProp]);
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* forceMount behavior:
|
|
469
|
+
* - When true: Content stays mounted when closed, allowing items to register labels
|
|
470
|
+
* for display in the trigger. Use this if you need dynamic label resolution.
|
|
471
|
+
* - When false/undefined (default): Content unmounts when closed for better performance.
|
|
472
|
+
* Use the `displayValue` prop on Root to show the selected label instead.
|
|
473
|
+
*
|
|
474
|
+
* For best performance with large lists, keep forceMount=undefined and provide displayValue.
|
|
475
|
+
*/
|
|
476
|
+
const shouldForceMount = forceMount === true ? true : undefined;
|
|
334
477
|
|
|
335
478
|
return (
|
|
336
479
|
<Popover.Content
|
|
@@ -342,7 +485,7 @@ const ComboboxContent = React.forwardRef<ComboboxContentElement, ComboboxContent
|
|
|
342
485
|
sideOffset={4}
|
|
343
486
|
collisionPadding={10}
|
|
344
487
|
{...contentProps}
|
|
345
|
-
forceMount={
|
|
488
|
+
forceMount={shouldForceMount}
|
|
346
489
|
container={container}
|
|
347
490
|
ref={forwardedRef}
|
|
348
491
|
className={classNames('rt-PopperContent', 'rt-BaseMenuContent', 'rt-ComboboxContent', sanitizedClassName)}
|
|
@@ -351,8 +494,6 @@ const ComboboxContent = React.forwardRef<ComboboxContentElement, ComboboxContent
|
|
|
351
494
|
<ComboboxContentContext.Provider value={{ variant: variantProp, size: String(sizeProp), color: resolvedColor, material: effectiveMaterial, highContrast: highContrastProp }}>
|
|
352
495
|
<CommandPrimitive
|
|
353
496
|
loop={context.loop}
|
|
354
|
-
value={context.searchValue}
|
|
355
|
-
onValueChange={context.setSearchValue}
|
|
356
497
|
shouldFilter={context.shouldFilter}
|
|
357
498
|
filter={context.filter}
|
|
358
499
|
className="rt-ComboboxCommand"
|
|
@@ -367,25 +508,38 @@ const ComboboxContent = React.forwardRef<ComboboxContentElement, ComboboxContent
|
|
|
367
508
|
ComboboxContent.displayName = 'Combobox.Content';
|
|
368
509
|
|
|
369
510
|
type ComboboxInputElement = React.ElementRef<typeof CommandPrimitive.Input>;
|
|
370
|
-
interface ComboboxInputProps extends React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> {
|
|
511
|
+
interface ComboboxInputProps extends Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>, 'value' | 'onValueChange'> {
|
|
371
512
|
startAdornment?: React.ReactNode;
|
|
372
513
|
endAdornment?: React.ReactNode;
|
|
373
514
|
variant?: TextFieldVariant;
|
|
515
|
+
/** Controlled search value. Falls back to Root's searchValue if not provided. */
|
|
516
|
+
value?: string;
|
|
517
|
+
/** Callback when search value changes. Falls back to Root's onSearchValueChange if not provided. */
|
|
518
|
+
onValueChange?: (value: string) => void;
|
|
374
519
|
}
|
|
375
520
|
/**
|
|
376
521
|
* Input composes TextField tokens with cmdk's Command.Input to provide
|
|
377
522
|
* automatic focus management and optional adornments.
|
|
378
523
|
*/
|
|
379
|
-
const ComboboxInput = React.forwardRef<ComboboxInputElement, ComboboxInputProps>(({ className, startAdornment, endAdornment, placeholder, variant: inputVariant, ...inputProps }, forwardedRef) => {
|
|
524
|
+
const ComboboxInput = React.forwardRef<ComboboxInputElement, ComboboxInputProps>(({ className, startAdornment, endAdornment, placeholder, variant: inputVariant, value, onValueChange, ...inputProps }, forwardedRef) => {
|
|
380
525
|
const context = useComboboxContext('Combobox.Input');
|
|
381
526
|
const contentContext = useComboboxContentContext();
|
|
382
527
|
const contentVariant = contentContext?.variant ?? 'solid';
|
|
383
528
|
const color = contentContext?.color;
|
|
384
529
|
const material = contentContext?.material;
|
|
385
530
|
|
|
386
|
-
|
|
531
|
+
/**
|
|
532
|
+
* Map combobox content variant to TextField variant:
|
|
533
|
+
* - Content 'solid' → Input 'surface' (elevated input on solid background)
|
|
534
|
+
* - Content 'soft' → Input 'soft' (subtle input on soft background)
|
|
535
|
+
* This ensures visual harmony between the input and surrounding content.
|
|
536
|
+
*/
|
|
387
537
|
const textFieldVariant = inputVariant ?? (contentVariant === 'solid' ? 'surface' : 'soft');
|
|
388
538
|
|
|
539
|
+
// Use controlled search value from context, allow override via props
|
|
540
|
+
const searchValue = value ?? context.searchValue;
|
|
541
|
+
const handleSearchChange = onValueChange ?? context.setSearchValue;
|
|
542
|
+
|
|
389
543
|
const inputField = (
|
|
390
544
|
<div
|
|
391
545
|
className={classNames('rt-TextFieldRoot', 'rt-ComboboxInputRoot', `rt-r-size-${context.size}`, `rt-variant-${textFieldVariant}`)}
|
|
@@ -394,7 +548,14 @@ const ComboboxInput = React.forwardRef<ComboboxInputElement, ComboboxInputProps>
|
|
|
394
548
|
data-panel-background={material}
|
|
395
549
|
>
|
|
396
550
|
{startAdornment ? <div className="rt-TextFieldSlot">{startAdornment}</div> : null}
|
|
397
|
-
<CommandPrimitive.Input
|
|
551
|
+
<CommandPrimitive.Input
|
|
552
|
+
{...inputProps}
|
|
553
|
+
ref={forwardedRef}
|
|
554
|
+
value={searchValue}
|
|
555
|
+
onValueChange={handleSearchChange}
|
|
556
|
+
placeholder={placeholder ?? context.searchPlaceholder}
|
|
557
|
+
className={classNames('rt-reset', 'rt-TextFieldInput', className)}
|
|
558
|
+
/>
|
|
398
559
|
{endAdornment ? (
|
|
399
560
|
<div className="rt-TextFieldSlot" data-side="right">
|
|
400
561
|
{endAdornment}
|
|
@@ -415,14 +576,68 @@ type ComboboxListElement = React.ElementRef<typeof CommandPrimitive.List>;
|
|
|
415
576
|
interface ComboboxListProps extends React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> {}
|
|
416
577
|
/**
|
|
417
578
|
* List wraps cmdk's Command.List to inherit base menu styles and provides ScrollArea for the items.
|
|
579
|
+
* Also handles aria-activedescendant tracking via a single MutationObserver for all items.
|
|
418
580
|
*/
|
|
419
|
-
const ComboboxList = React.forwardRef<ComboboxListElement, ComboboxListProps>(({ className, ...listProps }, forwardedRef) =>
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
)
|
|
581
|
+
const ComboboxList = React.forwardRef<ComboboxListElement, ComboboxListProps>(({ className, ...listProps }, forwardedRef) => {
|
|
582
|
+
const context = useComboboxContext('Combobox.List');
|
|
583
|
+
const listRef = React.useRef<HTMLDivElement | null>(null);
|
|
584
|
+
|
|
585
|
+
// Combined ref handling
|
|
586
|
+
const combinedRef = React.useCallback(
|
|
587
|
+
(node: HTMLDivElement | null) => {
|
|
588
|
+
listRef.current = node;
|
|
589
|
+
if (typeof forwardedRef === 'function') {
|
|
590
|
+
forwardedRef(node);
|
|
591
|
+
} else if (forwardedRef) {
|
|
592
|
+
(forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
[forwardedRef],
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Single MutationObserver at List level to track aria-activedescendant.
|
|
600
|
+
* This replaces per-item observers for better performance with large lists.
|
|
601
|
+
*/
|
|
602
|
+
React.useEffect(() => {
|
|
603
|
+
const listNode = listRef.current;
|
|
604
|
+
if (!listNode) return;
|
|
605
|
+
|
|
606
|
+
const updateActiveDescendant = () => {
|
|
607
|
+
const selectedItem = listNode.querySelector('[data-selected="true"], [aria-selected="true"]');
|
|
608
|
+
const itemId = selectedItem?.id;
|
|
609
|
+
context.setActiveDescendantId(itemId || undefined);
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// Initial check
|
|
613
|
+
updateActiveDescendant();
|
|
614
|
+
|
|
615
|
+
// Watch for attribute changes on any descendant
|
|
616
|
+
const observer = new MutationObserver(updateActiveDescendant);
|
|
617
|
+
observer.observe(listNode, {
|
|
618
|
+
attributes: true,
|
|
619
|
+
attributeFilter: ['data-selected', 'aria-selected'],
|
|
620
|
+
subtree: true,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
return () => observer.disconnect();
|
|
624
|
+
}, [context.setActiveDescendantId]);
|
|
625
|
+
|
|
626
|
+
return (
|
|
627
|
+
<ScrollArea type="auto" className="rt-ComboboxScrollArea" scrollbars="vertical" size="1">
|
|
628
|
+
<div className={classNames('rt-BaseMenuViewport', 'rt-ComboboxViewport')}>
|
|
629
|
+
<CommandPrimitive.List
|
|
630
|
+
{...listProps}
|
|
631
|
+
ref={combinedRef}
|
|
632
|
+
id={context.listboxId}
|
|
633
|
+
role="listbox"
|
|
634
|
+
aria-label="Options"
|
|
635
|
+
className={classNames('rt-ComboboxList', className)}
|
|
636
|
+
/>
|
|
637
|
+
</div>
|
|
638
|
+
</ScrollArea>
|
|
639
|
+
);
|
|
640
|
+
});
|
|
426
641
|
ComboboxList.displayName = 'Combobox.List';
|
|
427
642
|
|
|
428
643
|
type ComboboxEmptyElement = React.ElementRef<typeof CommandPrimitive.Empty>;
|
|
@@ -463,77 +678,93 @@ const ComboboxSeparator = React.forwardRef<ComboboxSeparatorElement, ComboboxSep
|
|
|
463
678
|
ComboboxSeparator.displayName = 'Combobox.Separator';
|
|
464
679
|
|
|
465
680
|
type ComboboxItemElement = React.ElementRef<typeof CommandPrimitive.Item>;
|
|
466
|
-
interface ComboboxItemProps extends React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> {
|
|
681
|
+
interface ComboboxItemProps extends Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>, 'keywords'> {
|
|
682
|
+
/** Display label for the item. Also used for search unless keywords are provided. */
|
|
467
683
|
label?: string;
|
|
684
|
+
/** Additional keywords for search filtering (overrides automatic label-based search). */
|
|
685
|
+
keywords?: string[];
|
|
468
686
|
}
|
|
469
687
|
/**
|
|
470
688
|
* Item wires cmdk's selection handling with Kookie UI tokens and
|
|
471
689
|
* ensures labels are registered for displaying the current value.
|
|
472
690
|
*/
|
|
473
|
-
|
|
691
|
+
/**
|
|
692
|
+
* Extracts text content from React children recursively.
|
|
693
|
+
* Used to derive searchable labels from JSX children.
|
|
694
|
+
*/
|
|
695
|
+
function extractTextFromChildren(children: React.ReactNode): string {
|
|
696
|
+
if (typeof children === 'string') return children;
|
|
697
|
+
if (typeof children === 'number') return String(children);
|
|
698
|
+
if (children == null || typeof children === 'boolean') return '';
|
|
699
|
+
if (Array.isArray(children)) {
|
|
700
|
+
return children.map(extractTextFromChildren).filter(Boolean).join(' ');
|
|
701
|
+
}
|
|
702
|
+
if (React.isValidElement(children)) {
|
|
703
|
+
const props = children.props as { children?: React.ReactNode };
|
|
704
|
+
if (props.children) {
|
|
705
|
+
return extractTextFromChildren(props.children);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return '';
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const ComboboxItem = React.forwardRef<ComboboxItemElement, ComboboxItemProps>(({ className, children, label, value, disabled, onSelect, keywords, ...itemProps }, forwardedRef) => {
|
|
474
712
|
const context = useComboboxContext('Combobox.Item');
|
|
475
713
|
const contentContext = useComboboxContentContext();
|
|
476
|
-
|
|
714
|
+
|
|
715
|
+
// Memoize label extraction to avoid recursive traversal on every render
|
|
716
|
+
const extractedLabel = React.useMemo(() => extractTextFromChildren(children), [children]);
|
|
717
|
+
const itemLabel = label ?? (extractedLabel || String(value));
|
|
477
718
|
const isSelected = value != null && context.value === value;
|
|
478
719
|
const sizeClass = contentContext?.size ? `rt-r-size-${contentContext.size}` : undefined;
|
|
479
720
|
|
|
721
|
+
// Use provided keywords, or default to the item label for search
|
|
722
|
+
// This allows searching by display text even when value is different (e.g., "usa" vs "United States")
|
|
723
|
+
const searchKeywords = keywords ?? [itemLabel];
|
|
724
|
+
|
|
725
|
+
// Generate stable ID for this item for aria-activedescendant
|
|
726
|
+
const generatedId = React.useId();
|
|
727
|
+
const itemId = `combobox-item-${generatedId}`;
|
|
728
|
+
|
|
729
|
+
// Destructure stable references to avoid effect re-runs when unrelated context values change
|
|
730
|
+
const { registerItemLabel, unregisterItemLabel, handleSelect: contextHandleSelect } = context;
|
|
731
|
+
|
|
732
|
+
// Register/unregister label for display in trigger
|
|
480
733
|
React.useEffect(() => {
|
|
481
734
|
if (value) {
|
|
482
|
-
|
|
735
|
+
registerItemLabel(value, itemLabel);
|
|
736
|
+
return () => unregisterItemLabel(value);
|
|
483
737
|
}
|
|
484
|
-
}, [
|
|
738
|
+
}, [registerItemLabel, unregisterItemLabel, value, itemLabel]);
|
|
485
739
|
|
|
486
740
|
const handleSelect = React.useCallback(
|
|
487
741
|
(selectedValue: string) => {
|
|
488
|
-
|
|
742
|
+
contextHandleSelect(selectedValue);
|
|
489
743
|
onSelect?.(selectedValue);
|
|
490
744
|
},
|
|
491
|
-
[
|
|
745
|
+
[contextHandleSelect, onSelect],
|
|
492
746
|
);
|
|
493
747
|
|
|
494
748
|
const isDisabled = disabled ?? context.disabled ?? false;
|
|
495
749
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
if (typeof forwardedRef === 'function') {
|
|
504
|
-
forwardedRef(node);
|
|
505
|
-
} else if (forwardedRef) {
|
|
506
|
-
(forwardedRef as React.MutableRefObject<ComboboxItemElement | null>).current = node;
|
|
507
|
-
}
|
|
508
|
-
},
|
|
509
|
-
[forwardedRef],
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
// Remove data-disabled attribute if cmdk sets it incorrectly
|
|
513
|
-
React.useEffect(() => {
|
|
514
|
-
if (!isDisabled && internalRef.current) {
|
|
515
|
-
const node = internalRef.current;
|
|
516
|
-
// Check and remove immediately
|
|
517
|
-
if (node.getAttribute('data-disabled') === 'false' || node.getAttribute('data-disabled') === '') {
|
|
518
|
-
node.removeAttribute('data-disabled');
|
|
519
|
-
}
|
|
520
|
-
// Also watch for changes
|
|
521
|
-
const observer = new MutationObserver(() => {
|
|
522
|
-
if (node.getAttribute('data-disabled') === 'false' || node.getAttribute('data-disabled') === '') {
|
|
523
|
-
node.removeAttribute('data-disabled');
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
observer.observe(node, { attributes: true, attributeFilter: ['data-disabled'] });
|
|
527
|
-
return () => observer.disconnect();
|
|
528
|
-
}
|
|
529
|
-
}, [isDisabled]);
|
|
750
|
+
/**
|
|
751
|
+
* Performance notes:
|
|
752
|
+
* - data-disabled workaround: Handled via CSS selectors in combobox.css
|
|
753
|
+
* rather than per-item MutationObservers.
|
|
754
|
+
* - aria-activedescendant: Tracked by a single observer in ComboboxList
|
|
755
|
+
* rather than per-item observers.
|
|
756
|
+
*/
|
|
530
757
|
|
|
531
758
|
return (
|
|
532
759
|
<CommandPrimitive.Item
|
|
533
760
|
{...itemProps}
|
|
761
|
+
id={itemId}
|
|
534
762
|
value={value}
|
|
535
|
-
{
|
|
536
|
-
|
|
763
|
+
keywords={searchKeywords}
|
|
764
|
+
role="option"
|
|
765
|
+
aria-selected={isSelected}
|
|
766
|
+
{...(isDisabled ? { disabled: true, 'aria-disabled': true } : {})}
|
|
767
|
+
ref={forwardedRef}
|
|
537
768
|
onSelect={handleSelect}
|
|
538
769
|
className={classNames('rt-reset', 'rt-BaseMenuItem', 'rt-ComboboxItem', className)}
|
|
539
770
|
>
|
|
@@ -564,6 +795,7 @@ export {
|
|
|
564
795
|
export type {
|
|
565
796
|
ComboboxRootProps as RootProps,
|
|
566
797
|
ComboboxTriggerProps as TriggerProps,
|
|
798
|
+
ComboboxValueProps as ValueProps,
|
|
567
799
|
ComboboxContentProps as ContentProps,
|
|
568
800
|
ComboboxInputProps as InputProps,
|
|
569
801
|
ComboboxListProps as ListProps,
|
|
@@ -852,3 +852,86 @@
|
|
|
852
852
|
font-size: var(--font-size-1);
|
|
853
853
|
line-height: 1;
|
|
854
854
|
}
|
|
855
|
+
|
|
856
|
+
/***************************************************************************************************
|
|
857
|
+
* *
|
|
858
|
+
* SCRUBBING *
|
|
859
|
+
* *
|
|
860
|
+
***************************************************************************************************/
|
|
861
|
+
|
|
862
|
+
/* Scrub-enabled slot hover state */
|
|
863
|
+
.rt-TextFieldSlot:where([data-scrub]) {
|
|
864
|
+
cursor: ew-resize !important;
|
|
865
|
+
user-select: none;
|
|
866
|
+
position: relative;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/* Ensure children also show ew-resize cursor */
|
|
870
|
+
.rt-TextFieldSlot:where([data-scrub]) * {
|
|
871
|
+
cursor: ew-resize !important;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* Active scrubbing state - hide real cursor */
|
|
875
|
+
.rt-TextFieldSlot:where([data-scrubbing]) {
|
|
876
|
+
cursor: none;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/* Hide cursor on entire document body during scrubbing via pointer capture */
|
|
880
|
+
.rt-TextFieldSlot:where([data-scrubbing]) * {
|
|
881
|
+
cursor: none;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/* Virtual cursor indicator - viewport-wide floating cursor */
|
|
885
|
+
.rt-TextFieldSlotScrubCursor {
|
|
886
|
+
/* Position is set inline via JS */
|
|
887
|
+
width: 20px;
|
|
888
|
+
height: 20px;
|
|
889
|
+
background-color: var(--accent-9);
|
|
890
|
+
border-radius: 50%;
|
|
891
|
+
pointer-events: none;
|
|
892
|
+
|
|
893
|
+
/* Subtle glow effect */
|
|
894
|
+
box-shadow:
|
|
895
|
+
0 0 0 2px var(--color-background),
|
|
896
|
+
0 0 8px var(--accent-a8),
|
|
897
|
+
0 0 16px var(--accent-a6);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/* Left/right arrows to indicate scrub direction */
|
|
901
|
+
.rt-TextFieldSlotScrubCursor::before,
|
|
902
|
+
.rt-TextFieldSlotScrubCursor::after {
|
|
903
|
+
content: '';
|
|
904
|
+
position: absolute;
|
|
905
|
+
top: 50%;
|
|
906
|
+
width: 0;
|
|
907
|
+
height: 0;
|
|
908
|
+
border-style: solid;
|
|
909
|
+
transform: translateY(-50%);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/* Left arrow */
|
|
913
|
+
.rt-TextFieldSlotScrubCursor::before {
|
|
914
|
+
left: -10px;
|
|
915
|
+
border-width: 5px 6px 5px 0;
|
|
916
|
+
border-color: transparent var(--accent-9) transparent transparent;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/* Right arrow */
|
|
920
|
+
.rt-TextFieldSlotScrubCursor::after {
|
|
921
|
+
right: -10px;
|
|
922
|
+
border-width: 5px 0 5px 6px;
|
|
923
|
+
border-color: transparent transparent transparent var(--accent-9);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/* Visual feedback during scrubbing - subtle highlight on the slot */
|
|
927
|
+
.rt-TextFieldSlot:where([data-scrubbing]) {
|
|
928
|
+
background-color: var(--accent-a3);
|
|
929
|
+
border-radius: calc(var(--text-field-border-radius, var(--radius-2)) - 2px);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/* Reduced motion support - hide glow animations */
|
|
933
|
+
@media (prefers-reduced-motion: reduce) {
|
|
934
|
+
.rt-TextFieldSlotScrubCursor {
|
|
935
|
+
box-shadow: 0 0 0 2px var(--color-background);
|
|
936
|
+
}
|
|
937
|
+
}
|