@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.
Files changed (38) hide show
  1. package/components.css +69 -29
  2. package/dist/cjs/components/combobox.d.ts +57 -7
  3. package/dist/cjs/components/combobox.d.ts.map +1 -1
  4. package/dist/cjs/components/combobox.js +1 -1
  5. package/dist/cjs/components/combobox.js.map +3 -3
  6. package/dist/cjs/components/text-field.d.ts +2 -2
  7. package/dist/cjs/components/text-field.d.ts.map +1 -1
  8. package/dist/cjs/components/text-field.js +2 -2
  9. package/dist/cjs/components/text-field.js.map +3 -3
  10. package/dist/cjs/components/text-field.props.d.ts +26 -0
  11. package/dist/cjs/components/text-field.props.d.ts.map +1 -1
  12. package/dist/cjs/components/text-field.props.js +1 -1
  13. package/dist/cjs/components/text-field.props.js.map +1 -1
  14. package/dist/esm/components/combobox.d.ts +57 -7
  15. package/dist/esm/components/combobox.d.ts.map +1 -1
  16. package/dist/esm/components/combobox.js +1 -1
  17. package/dist/esm/components/combobox.js.map +3 -3
  18. package/dist/esm/components/text-field.d.ts +2 -2
  19. package/dist/esm/components/text-field.d.ts.map +1 -1
  20. package/dist/esm/components/text-field.js +2 -2
  21. package/dist/esm/components/text-field.js.map +3 -3
  22. package/dist/esm/components/text-field.props.d.ts +26 -0
  23. package/dist/esm/components/text-field.props.d.ts.map +1 -1
  24. package/dist/esm/components/text-field.props.js +1 -1
  25. package/dist/esm/components/text-field.props.js.map +1 -1
  26. package/package.json +2 -2
  27. package/schemas/base-button.json +1 -1
  28. package/schemas/button.json +1 -1
  29. package/schemas/icon-button.json +1 -1
  30. package/schemas/index.json +6 -6
  31. package/schemas/toggle-button.json +1 -1
  32. package/schemas/toggle-icon-button.json +1 -1
  33. package/src/components/combobox.css +56 -55
  34. package/src/components/combobox.tsx +305 -73
  35. package/src/components/text-field.css +83 -0
  36. package/src/components/text-field.props.tsx +28 -0
  37. package/src/components/text-field.tsx +222 -5
  38. 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
- const selectedLabel = value != null ? labelMapRef.current.get(value) : undefined;
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
- setSearchValue('');
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={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
- const displayValue = context.selectedLabel ?? context.value ?? undefined;
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
- let sanitizedClassName = className;
326
- if (typeof sizeProp === 'string') {
327
- sanitizedClassName =
328
- className
329
- ?.split(/\s+/)
330
- .filter(Boolean)
331
- .filter((token) => !/^rt-r-size-\d$/.test(token))
332
- .join(' ') || undefined;
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={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
- // Map combobox variant to textfield variant: solid -> surface, soft -> soft unless overridden
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 {...inputProps} ref={forwardedRef} placeholder={placeholder ?? context.searchPlaceholder} className={classNames('rt-reset', 'rt-TextFieldInput', className)} />
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
- <ScrollArea type="auto" className="rt-ComboboxScrollArea" scrollbars="vertical" size="1">
421
- <div className={classNames('rt-BaseMenuViewport', 'rt-ComboboxViewport')}>
422
- <CommandPrimitive.List {...listProps} ref={forwardedRef} className={classNames('rt-ComboboxList', className)} />
423
- </div>
424
- </ScrollArea>
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
- const ComboboxItem = React.forwardRef<ComboboxItemElement, ComboboxItemProps>(({ className, children, label, value, disabled, onSelect, ...itemProps }, forwardedRef) => {
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
- const itemLabel = label ?? (typeof children === 'string' ? children : String(value));
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
- context.registerItemLabel(value, itemLabel);
735
+ registerItemLabel(value, itemLabel);
736
+ return () => unregisterItemLabel(value);
483
737
  }
484
- }, [context, value, itemLabel]);
738
+ }, [registerItemLabel, unregisterItemLabel, value, itemLabel]);
485
739
 
486
740
  const handleSelect = React.useCallback(
487
741
  (selectedValue: string) => {
488
- context.handleSelect(selectedValue);
742
+ contextHandleSelect(selectedValue);
489
743
  onSelect?.(selectedValue);
490
744
  },
491
- [context, onSelect],
745
+ [contextHandleSelect, onSelect],
492
746
  );
493
747
 
494
748
  const isDisabled = disabled ?? context.disabled ?? false;
495
749
 
496
- // Internal ref to clean up data-disabled attribute
497
- const internalRef = React.useRef<ComboboxItemElement | null>(null);
498
-
499
- // Ref callback to handle both forwarded ref and internal ref
500
- const itemRef = React.useCallback(
501
- (node: ComboboxItemElement | null) => {
502
- internalRef.current = node;
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
- {...(isDisabled ? { disabled: true } : {})}
536
- ref={itemRef}
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
+ }