@cdx-ui/components 0.3.3 → 0.3.5

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 (73) hide show
  1. package/coverage.svg +1 -1
  2. package/docs/components/color-field/content.md +2 -2
  3. package/docs/components/content.md +4 -4
  4. package/docs/components/date-field/content.md +2 -2
  5. package/docs/components/date-time-field/content.md +2 -2
  6. package/docs/components/input/content.md +20 -2
  7. package/docs/components/input/design.md +1 -1
  8. package/docs/components/insurance-disclosure/content.md +1 -1
  9. package/docs/components/otp-input/content.md +2 -2
  10. package/docs/components/select/content.md +365 -24
  11. package/docs/components/text/content.md +97 -0
  12. package/docs/foundations/expressions/icons/content.md +1 -1
  13. package/docs/llms.txt +6 -4
  14. package/docs/patterns/forms/content.md +152 -0
  15. package/docs/resources/release-notes/0.3.3/content.md +23 -0
  16. package/docs/resources/release-notes/0.3.4/content.md +27 -0
  17. package/docs/resources/release-notes/content.md +2 -0
  18. package/lib/commonjs/components/Chip/styles.js +3 -1
  19. package/lib/commonjs/components/Chip/styles.js.map +1 -1
  20. package/lib/commonjs/components/Drawer/index.js +1 -1
  21. package/lib/commonjs/components/Drawer/index.js.map +1 -1
  22. package/lib/commonjs/components/Input/index.js.map +1 -1
  23. package/lib/commonjs/components/Input/styles.js +13 -6
  24. package/lib/commonjs/components/Input/styles.js.map +1 -1
  25. package/lib/commonjs/components/OtpInput/index.js.map +1 -1
  26. package/lib/commonjs/components/Select/index.js +326 -24
  27. package/lib/commonjs/components/Select/index.js.map +1 -1
  28. package/lib/commonjs/components/Select/styles.js +32 -1
  29. package/lib/commonjs/components/Select/styles.js.map +1 -1
  30. package/lib/commonjs/components/picker/surfaces.js +18 -3
  31. package/lib/commonjs/components/picker/surfaces.js.map +1 -1
  32. package/lib/module/components/Chip/styles.js +3 -1
  33. package/lib/module/components/Chip/styles.js.map +1 -1
  34. package/lib/module/components/Drawer/index.js +1 -1
  35. package/lib/module/components/Drawer/index.js.map +1 -1
  36. package/lib/module/components/Input/index.js.map +1 -1
  37. package/lib/module/components/Input/styles.js +14 -6
  38. package/lib/module/components/Input/styles.js.map +1 -1
  39. package/lib/module/components/OtpInput/index.js.map +1 -1
  40. package/lib/module/components/Select/index.js +328 -26
  41. package/lib/module/components/Select/index.js.map +1 -1
  42. package/lib/module/components/Select/styles.js +31 -0
  43. package/lib/module/components/Select/styles.js.map +1 -1
  44. package/lib/module/components/picker/surfaces.js +17 -3
  45. package/lib/module/components/picker/surfaces.js.map +1 -1
  46. package/lib/typescript/components/Chip/styles.d.ts.map +1 -1
  47. package/lib/typescript/components/ColorField/styles.d.ts +4 -2
  48. package/lib/typescript/components/ColorField/styles.d.ts.map +1 -1
  49. package/lib/typescript/components/DateField/styles.d.ts +4 -2
  50. package/lib/typescript/components/DateField/styles.d.ts.map +1 -1
  51. package/lib/typescript/components/Input/index.d.ts +3 -2
  52. package/lib/typescript/components/Input/index.d.ts.map +1 -1
  53. package/lib/typescript/components/Input/styles.d.ts +1 -1
  54. package/lib/typescript/components/Input/styles.d.ts.map +1 -1
  55. package/lib/typescript/components/OtpInput/index.d.ts +3 -2
  56. package/lib/typescript/components/OtpInput/index.d.ts.map +1 -1
  57. package/lib/typescript/components/Select/index.d.ts +73 -6
  58. package/lib/typescript/components/Select/index.d.ts.map +1 -1
  59. package/lib/typescript/components/Select/styles.d.ts +9 -0
  60. package/lib/typescript/components/Select/styles.d.ts.map +1 -1
  61. package/lib/typescript/components/picker/surfaces.d.ts +14 -3
  62. package/lib/typescript/components/picker/surfaces.d.ts.map +1 -1
  63. package/package.json +5 -5
  64. package/src/components/Chip/styles.ts +3 -1
  65. package/src/components/ColorField/styles.ts +3 -1
  66. package/src/components/DateField/styles.ts +6 -1
  67. package/src/components/Drawer/index.tsx +1 -1
  68. package/src/components/Input/index.tsx +3 -2
  69. package/src/components/Input/styles.ts +35 -15
  70. package/src/components/OtpInput/index.tsx +3 -2
  71. package/src/components/Select/index.tsx +411 -29
  72. package/src/components/Select/styles.ts +36 -0
  73. package/src/components/picker/surfaces.tsx +29 -4
@@ -1,10 +1,25 @@
1
- import { cloneElement, forwardRef, isValidElement, useMemo, type ReactNode } from 'react';
2
- import { Text, type TextProps, View, type ViewProps } from 'react-native';
3
- import { KeyboardArrowDown, KeyboardArrowUp } from '@cdx-ui/icons';
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ createContext,
5
+ forwardRef,
6
+ isValidElement,
7
+ useContext,
8
+ useMemo,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { Platform, Text, type TextInput, type TextProps, View, type ViewProps } from 'react-native';
12
+ import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
13
+ import { Check, KeyboardArrowDown, KeyboardArrowUp, Search } from '@cdx-ui/icons';
4
14
  import {
5
15
  SelectContent as SelectContentPrimitive,
16
+ SelectEmpty as SelectEmptyPrimitive,
6
17
  SelectItem as SelectItemPrimitive,
18
+ SelectListbox as SelectListboxPrimitive,
7
19
  SelectRoot as SelectRootPrimitive,
20
+ SelectProvider,
21
+ SelectSearch as SelectSearchPrimitive,
22
+ SelectSheetContent as SelectSheetContentPrimitive,
8
23
  SelectTrigger as SelectTriggerPrimitive,
9
24
  Slot,
10
25
  dataAttributes,
@@ -12,35 +27,75 @@ import {
12
27
  useSelectContext,
13
28
  useSelectItemContext,
14
29
  type ISelectContentProps,
30
+ type ISelectEmptyProps,
15
31
  type ISelectItemLabelProps,
16
32
  type ISelectItemProps,
17
33
  type ISelectProps,
34
+ type ISelectSearchProps,
18
35
  type ISelectTriggerProps,
19
36
  type ISelectValueProps,
37
+ type SelectItemData,
38
+ type SelectValueRenderProps,
20
39
  } from '@cdx-ui/primitives';
21
- import { cn, ParentContext, useParentContext, useStyleContext } from '@cdx-ui/utils';
40
+ import {
41
+ cn,
42
+ FormControlContext,
43
+ ParentContext,
44
+ useParentContext,
45
+ useStyleContext,
46
+ type FormControlContextValue,
47
+ } from '@cdx-ui/utils';
48
+ import { BottomSheet } from '../BottomSheet';
49
+ import { checkboxIconVariants, checkboxIndicatorVariants } from '../Checkbox/styles';
22
50
  import { Icon, IconProps } from '../Icon';
51
+ import { Input, type InputProps } from '../Input';
23
52
  import { Menu } from '../Menu';
24
- import { menuItemVariants } from '../Menu/styles';
53
+ import { menuItemControlVariants, menuItemVariants } from '../Menu/styles';
54
+ import { PickerBottomSheetSurface, PickerSheetTextInput } from '../picker/surfaces';
25
55
  import { positionToDataIcon, type IconSlotPosition } from '../../utils/positionToDataIcon';
26
56
  import {
27
57
  type SelectVariantProps,
58
+ selectBottomSheetListboxVariants,
59
+ selectBottomSheetVariants,
60
+ selectEmptyTextVariants,
61
+ selectEmptyVariants,
28
62
  selectIconVariants,
63
+ selectItemVariants,
64
+ selectSearchListboxVariants,
65
+ selectSearchVariants,
29
66
  selectTriggerVariants,
30
67
  selectValueVariants,
31
68
  } from './styles';
32
69
 
70
+ export type {
71
+ SelectFilter,
72
+ SelectItemData,
73
+ SelectType,
74
+ SelectValueRenderProps,
75
+ } from '@cdx-ui/primitives';
76
+
33
77
  const SCOPE = 'SELECT';
34
78
 
35
79
  const useSelectStyleContext = () => useStyleContext(SCOPE) as SelectVariantProps;
36
80
 
81
+ const IS_WEB = Platform.OS === 'web';
82
+
83
+ /** `true` inside the native `Select.BottomSheet`, where `Select.Search` uses the sheet's input. */
84
+ const SelectSheetContext = createContext(false);
85
+
37
86
  // =============================================================================
38
87
  // STYLED ROOT
39
88
  // =============================================================================
40
89
 
41
- export interface SelectProps extends ViewProps, ISelectProps, SelectVariantProps {
42
- className?: string;
43
- }
90
+ /**
91
+ * `type` narrows `value` / `defaultValue` / `onValueChange` to `string` (single, the default)
92
+ * or `string[]` (multiple).
93
+ */
94
+ export type SelectProps = ViewProps &
95
+ SelectVariantProps &
96
+ ISelectProps & {
97
+ className?: string;
98
+ };
44
99
 
45
100
  const SelectRoot = forwardRef<View, SelectProps>(
46
101
  ({ variant = 'outline', size = 'default', className, children, style, ...props }, ref) => {
@@ -147,19 +202,32 @@ SelectTrigger.displayName = 'Select.Trigger';
147
202
  // STYLED VALUE (styled-only)
148
203
  // =============================================================================
149
204
 
150
- export interface SelectValueProps extends TextProps, ISelectValueProps {
205
+ export interface SelectValueProps extends Omit<TextProps, 'children'>, ISelectValueProps {
151
206
  asChild?: boolean;
152
207
  className?: string;
153
208
  }
154
209
 
210
+ /**
211
+ * `Intl.ListFormat` is not available on Hermes, so labels are joined with a fixed separator
212
+ * to keep the summary identical on iOS, Android, and web.
213
+ */
214
+ const SELECTED_TEXT_SEPARATOR = ', ';
215
+
155
216
  const SelectValue = forwardRef<Text, SelectValueProps>(
156
217
  ({ asChild, className, style, placeholder, children, ...props }, ref) => {
157
218
  const { size } = useSelectStyleContext();
158
- const { value, items, invalid } = useSelectContext();
219
+ const { type, items, selectedValues, invalid } = useSelectContext();
220
+
221
+ const selectedItems = useMemo<SelectItemData[]>(
222
+ () => items.filter((item) => selectedValues.includes(item.value)),
223
+ [items, selectedValues],
224
+ );
225
+ const isPlaceholder = !selectedValues.some((selected) => selected !== '');
226
+ const selectedText = selectedItems.map((item) => item.label).join(SELECTED_TEXT_SEPARATOR);
159
227
 
160
- const hasValue = value !== undefined && value !== '';
161
- const showPlaceholder = !hasValue;
162
- const displayText = hasValue ? items.find((item) => item.value === value)?.label : placeholder;
228
+ const renderState: SelectValueRenderProps = { isPlaceholder, selectedItems, selectedText };
229
+ const customDisplay = typeof children === 'function' ? children(renderState) : children;
230
+ const displayText = isPlaceholder ? placeholder : selectedText;
163
231
 
164
232
  const computedClassName = cn(selectValueVariants({ size }), className);
165
233
  const Comp = asChild ? Slot : Text;
@@ -169,17 +237,18 @@ const SelectValue = forwardRef<Text, SelectValueProps>(
169
237
  ref={ref}
170
238
  className={computedClassName}
171
239
  style={style}
240
+ numberOfLines={type === 'multiple' ? 1 : undefined}
172
241
  {...props}
173
242
  {...mergeDataAttributes(
174
243
  props,
175
244
  dataAttributes({
176
245
  slot: 'select-value',
177
- placeholder: showPlaceholder,
246
+ placeholder: isPlaceholder,
178
247
  invalid,
179
248
  }),
180
249
  )}
181
250
  >
182
- {children ?? displayText}
251
+ {customDisplay ?? displayText}
183
252
  </Comp>
184
253
  );
185
254
  },
@@ -231,6 +300,114 @@ const SelectIcon = ({ asChild, className, style, as, position, ...props }: Selec
231
300
 
232
301
  SelectIcon.displayName = 'Select.Icon';
233
302
 
303
+ // =============================================================================
304
+ // STYLED SEARCH — composes Input inside the popup
305
+ // =============================================================================
306
+
307
+ export interface SelectSearchProps extends ISelectSearchProps {
308
+ /**
309
+ * Merge the search wiring onto the single child text input instead of rendering the default
310
+ * `Input` with a search icon.
311
+ */
312
+ asChild?: boolean;
313
+ /** Additional Uniwind/Tailwind classes, merged after the search container's own styles. */
314
+ className?: string;
315
+ /**
316
+ * `Input` variant of the default search field. Ignored when `asChild` is set.
317
+ * @default 'subtle'
318
+ */
319
+ variant?: InputProps['variant'];
320
+ /** The text input to render when `asChild` is set. Otherwise not rendered. */
321
+ children?: ReactNode;
322
+ }
323
+
324
+ /**
325
+ * The search input sits inside the select's `Field` in the React tree but is not the field's
326
+ * control, so it must not pick up the field's id, label, validation, or label-press focus.
327
+ */
328
+ const ISOLATED_FIELD: Partial<FormControlContextValue> = {};
329
+
330
+ const SelectSearch = forwardRef<TextInput, SelectSearchProps>(
331
+ ({ asChild, className, style, variant = 'subtle', children, ...props }, ref) => {
332
+ const { size } = useSelectStyleContext();
333
+ const inSheet = useContext(SelectSheetContext);
334
+ const slotData = dataAttributes({ slot: 'select-search' });
335
+
336
+ if (asChild) {
337
+ return (
338
+ <FormControlContext.Provider value={ISOLATED_FIELD}>
339
+ <SelectSearchPrimitive
340
+ ref={ref}
341
+ asChild
342
+ className={className}
343
+ style={style}
344
+ {...props}
345
+ {...mergeDataAttributes(props, slotData)}
346
+ >
347
+ {children}
348
+ </SelectSearchPrimitive>
349
+ </FormControlContext.Provider>
350
+ );
351
+ }
352
+
353
+ return (
354
+ <FormControlContext.Provider value={ISOLATED_FIELD}>
355
+ <View className={cn(selectSearchVariants(), className)} style={style} {...slotData}>
356
+ <Input variant={variant} size={size ?? 'default'}>
357
+ <Input.Icon as={Search} />
358
+ <SelectSearchPrimitive ref={ref} asChild {...props}>
359
+ {inSheet ? (
360
+ <Input.Field asChild>
361
+ <PickerSheetTextInput />
362
+ </Input.Field>
363
+ ) : (
364
+ <Input.Field />
365
+ )}
366
+ </SelectSearchPrimitive>
367
+ </Input>
368
+ </View>
369
+ </FormControlContext.Provider>
370
+ );
371
+ },
372
+ );
373
+
374
+ SelectSearch.displayName = 'Select.Search';
375
+
376
+ // =============================================================================
377
+ // STYLED EMPTY — shown when no option matches the search
378
+ // =============================================================================
379
+
380
+ export interface SelectEmptyProps extends ISelectEmptyProps {
381
+ asChild?: boolean;
382
+ /** Additional Uniwind/Tailwind classes, merged after the empty state's own styles. */
383
+ className?: string;
384
+ }
385
+
386
+ const SelectEmpty = forwardRef<View, SelectEmptyProps>(
387
+ ({ asChild, className, style, children, ...props }, ref) => {
388
+ const { size } = useSelectStyleContext();
389
+
390
+ return (
391
+ <SelectEmptyPrimitive
392
+ ref={ref}
393
+ asChild={asChild}
394
+ className={cn(selectEmptyVariants(), className)}
395
+ style={style}
396
+ {...props}
397
+ {...mergeDataAttributes(props, dataAttributes({ slot: 'select-empty' }))}
398
+ >
399
+ {!asChild && typeof children === 'string' ? (
400
+ <Text className={selectEmptyTextVariants({ size })}>{children}</Text>
401
+ ) : (
402
+ children
403
+ )}
404
+ </SelectEmptyPrimitive>
405
+ );
406
+ },
407
+ );
408
+
409
+ SelectEmpty.displayName = 'Select.Empty';
410
+
234
411
  // =============================================================================
235
412
  // STYLED CONTENT — composes Menu surface (listbox semantics stay on primitive)
236
413
  // =============================================================================
@@ -240,8 +417,30 @@ export interface SelectContentProps extends ViewProps, ISelectContentProps {
240
417
  className?: string;
241
418
  }
242
419
 
420
+ const isSlot = (child: ReactNode, slot: unknown) => isValidElement(child) && child.type === slot;
421
+
422
+ /**
423
+ * Sorts direct children into regions: `Select.Search` above a scrollable listbox of the
424
+ * remaining children, and `Select.Empty` below it. `Select.Search` and `Select.Empty` must be
425
+ * direct children: a `memo()`, HOC, or custom wrapper is not recognized and renders among the
426
+ * options. A second `Select.Search` or `Select.Empty` is dropped.
427
+ */
428
+ function partitionPopupChildren(children: ReactNode) {
429
+ const childArray = Children.toArray(children);
430
+ return {
431
+ search: childArray.find((child) => isSlot(child, SelectSearch)),
432
+ empty: childArray.find((child) => isSlot(child, SelectEmpty)),
433
+ options: childArray.filter(
434
+ (child) => !isSlot(child, SelectSearch) && !isSlot(child, SelectEmpty),
435
+ ),
436
+ };
437
+ }
438
+
439
+ /** Without `asChild`, direct children are sorted into regions (see `partitionPopupChildren`). */
243
440
  const SelectContent = forwardRef<View, SelectContentProps>(
244
441
  ({ asChild, className, style, children, ...props }, ref) => {
442
+ const slotData = mergeDataAttributes(props, dataAttributes({ slot: 'select-content' }));
443
+
245
444
  if (asChild) {
246
445
  return (
247
446
  <SelectContentPrimitive
@@ -250,24 +449,33 @@ const SelectContent = forwardRef<View, SelectContentProps>(
250
449
  className={className}
251
450
  style={style}
252
451
  {...props}
253
- {...mergeDataAttributes(props, dataAttributes({ slot: 'select-content' }))}
452
+ {...slotData}
254
453
  >
255
454
  {children}
256
455
  </SelectContentPrimitive>
257
456
  );
258
457
  }
259
458
 
459
+ const { search, empty, options } = partitionPopupChildren(children);
460
+
260
461
  return (
261
- <SelectContentPrimitive
262
- ref={ref}
263
- asChild
264
- style={style}
265
- {...props}
266
- {...mergeDataAttributes(props, dataAttributes({ slot: 'select-content' }))}
267
- >
268
- <Menu.Content asSurface scrollable className={className}>
269
- {children}
270
- </Menu.Content>
462
+ <SelectContentPrimitive ref={ref} asChild style={style} {...props} {...slotData}>
463
+ {search ? (
464
+ <Menu.Content asSurface className={className}>
465
+ {search}
466
+ <SelectListboxPrimitive asChild>
467
+ <Menu.Content asSurface scrollable className={selectSearchListboxVariants()}>
468
+ {options}
469
+ </Menu.Content>
470
+ </SelectListboxPrimitive>
471
+ {empty}
472
+ </Menu.Content>
473
+ ) : (
474
+ <Menu.Content asSurface scrollable className={className}>
475
+ {options}
476
+ {empty}
477
+ </Menu.Content>
478
+ )}
271
479
  </SelectContentPrimitive>
272
480
  );
273
481
  },
@@ -275,6 +483,168 @@ const SelectContent = forwardRef<View, SelectContentProps>(
275
483
 
276
484
  SelectContent.displayName = 'Select.Content';
277
485
 
486
+ // =============================================================================
487
+ // STYLED BOTTOM SHEET — native surface in place of Select.Content
488
+ // =============================================================================
489
+
490
+ export interface SelectBottomSheetProps extends ViewProps {
491
+ /** Render the consumer's child as the sheet body, skipping the region split. */
492
+ asChild?: boolean;
493
+ /** Additional Uniwind/Tailwind classes, merged after the sheet body's own styles. */
494
+ className?: string;
495
+ /**
496
+ * Accessible name of the visually hidden dismiss button on web, where the surface is the
497
+ * `Select.Content` dropdown.
498
+ * @default 'Dismiss'
499
+ */
500
+ dismissAccessibilityLabel?: string;
501
+ /** `Select.Search`, `Select.Item` elements, and `Select.Empty`, as in `Select.Content`. */
502
+ children?: ReactNode;
503
+ }
504
+
505
+ const SAFE_AREA_FALLBACK = { bottom: 0 };
506
+
507
+ const SelectNativeBottomSheet = forwardRef<View, SelectBottomSheetProps>(
508
+ (
509
+ {
510
+ asChild,
511
+ className,
512
+ children,
513
+ dismissAccessibilityLabel: _dismissAccessibilityLabel,
514
+ ...props
515
+ },
516
+ ref,
517
+ ) => {
518
+ const ctx = useSelectContext();
519
+ const insets = useContext(SafeAreaInsetsContext) ?? SAFE_AREA_FALLBACK;
520
+ const slotData = mergeDataAttributes(props, dataAttributes({ slot: 'select-bottom-sheet' }));
521
+
522
+ const { search, empty, options } = partitionPopupChildren(children);
523
+
524
+ // The sheet renders in the BottomSheet.Provider portal, outside this tree, so the select
525
+ // context is provided again inside it.
526
+ return (
527
+ <PickerBottomSheetSurface
528
+ open={ctx.open && !ctx.native}
529
+ setOpen={ctx.setOpen}
530
+ fill
531
+ // gorhom makes the sheet one accessibility element by default, which hides the rows
532
+ // from VoiceOver.
533
+ accessible={false}
534
+ keyboardBehavior="extend"
535
+ keyboardBlurBehavior="restore"
536
+ >
537
+ <SelectProvider value={ctx}>
538
+ <SelectSheetContext.Provider value>
539
+ {asChild ? (
540
+ <SelectSheetContentPrimitive ref={ref} asChild {...props} {...slotData}>
541
+ {children}
542
+ </SelectSheetContentPrimitive>
543
+ ) : (
544
+ <SelectSheetContentPrimitive
545
+ ref={ref}
546
+ className={cn(selectBottomSheetVariants(), className)}
547
+ {...props}
548
+ {...slotData}
549
+ >
550
+ {search}
551
+ <BottomSheet.ScrollView
552
+ keyboardShouldPersistTaps="handled"
553
+ keyboardDismissMode="on-drag"
554
+ automaticallyAdjustKeyboardInsets
555
+ contentContainerStyle={{ paddingBottom: insets.bottom }}
556
+ >
557
+ <SelectListboxPrimitive className={selectBottomSheetListboxVariants()}>
558
+ {options}
559
+ </SelectListboxPrimitive>
560
+ {empty}
561
+ </BottomSheet.ScrollView>
562
+ </SelectSheetContentPrimitive>
563
+ )}
564
+ </SelectSheetContext.Provider>
565
+ </SelectProvider>
566
+ </PickerBottomSheetSurface>
567
+ );
568
+ },
569
+ );
570
+
571
+ /**
572
+ * Presents the options in a bottom sheet on iOS and Android, in place of `Select.Content`. The
573
+ * sheet keeps `Select.Search` and the results above the soft keyboard, and closes on swipe
574
+ * down, backdrop press, Android back, and the VoiceOver escape gesture without changing the
575
+ * value. On web it renders the `Select.Content` dropdown. Requires `BottomSheet.Provider`.
576
+ */
577
+ const SelectBottomSheet = forwardRef<View, SelectBottomSheetProps>((props, ref) =>
578
+ IS_WEB ? (
579
+ <SelectContent ref={ref} {...props} />
580
+ ) : (
581
+ <SelectNativeBottomSheet ref={ref} {...props} />
582
+ ),
583
+ );
584
+
585
+ SelectBottomSheet.displayName = 'Select.BottomSheet';
586
+
587
+ // =============================================================================
588
+ // STYLED ITEM CONTROL — Menu checkbox chrome driven by item selection
589
+ // =============================================================================
590
+
591
+ export interface SelectItemControlProps extends ViewProps {
592
+ /** Render the consumer's child element in place of the default checkbox glyph. */
593
+ asChild?: boolean;
594
+ /** Additional Uniwind/Tailwind classes, merged after the control's own styles. */
595
+ className?: string;
596
+ /** The element to render when `asChild` is set. Otherwise not rendered. */
597
+ children?: ReactNode;
598
+ }
599
+
600
+ const SelectItemControl = ({
601
+ asChild,
602
+ className,
603
+ style,
604
+ children,
605
+ ...props
606
+ }: SelectItemControlProps) => {
607
+ const { checked } = useSelectItemContext();
608
+ const fieldState = checked ? 'checked' : 'idle';
609
+
610
+ const hiddenProps = {
611
+ className: cn(menuItemControlVariants(), 'pointer-events-none', className),
612
+ style,
613
+ accessible: false,
614
+ accessibilityElementsHidden: true,
615
+ importantForAccessibility: 'no-hide-descendants' as const,
616
+ 'aria-hidden': true,
617
+ ...props,
618
+ ...mergeDataAttributes(props, dataAttributes({ slot: 'select-item-control', checked })),
619
+ };
620
+
621
+ if (asChild) {
622
+ return <Slot {...hiddenProps}>{children}</Slot>;
623
+ }
624
+
625
+ return (
626
+ <View {...hiddenProps}>
627
+ <View className={checkboxIndicatorVariants()} {...dataAttributes({ checked, fieldState })}>
628
+ {checked ? (
629
+ <Icon
630
+ as={Check}
631
+ className={checkboxIconVariants()}
632
+ accessibilityElementsHidden
633
+ aria-hidden
634
+ />
635
+ ) : null}
636
+ </View>
637
+ </View>
638
+ );
639
+ };
640
+
641
+ SelectItemControl.displayName = 'Select.ItemControl';
642
+
643
+ const hasItemControl = (children: ReactNode): boolean =>
644
+ Children.toArray(children).some(
645
+ (child) => isValidElement(child) && child.type === SelectItemControl,
646
+ );
647
+
278
648
  // =============================================================================
279
649
  // STYLED ITEM — composes Menu item visuals (option semantics stay on primitive)
280
650
  // =============================================================================
@@ -284,6 +654,7 @@ export interface SelectItemProps extends ISelectItemProps {
284
654
  /**
285
655
  * Replaces the default selected-item checkmark. The replacement is rendered
286
656
  * only while the item is selected. Pass `null` to suppress the indicator.
657
+ * The default checkmark is omitted when the item contains a `Select.ItemControl`.
287
658
  */
288
659
  indicator?: ReactNode;
289
660
  className?: string;
@@ -293,17 +664,20 @@ export interface SelectItemProps extends ISelectItemProps {
293
664
  const SelectItem = forwardRef<View, SelectItemProps>(
294
665
  ({ asChild, indicator, className, children, style, value: itemValue, ...props }, ref) => {
295
666
  const { size } = useSelectStyleContext();
296
- const { value: selectedValue } = useSelectContext();
297
- const checked = selectedValue === itemValue;
667
+ const { isSelected } = useSelectContext();
668
+ const checked = isSelected(itemValue);
298
669
 
299
670
  const computedClassName = cn(
300
671
  menuItemVariants(),
672
+ selectItemVariants(),
301
673
  size === 'small' ? 'px-2.5 py-1.5' : undefined,
302
674
  className,
303
675
  );
304
676
  const selectedIndicator =
305
677
  indicator === undefined ? (
306
- <Menu.ItemIndicator isChecked={checked} />
678
+ hasItemControl(children) ? null : (
679
+ <Menu.ItemIndicator isChecked={checked} />
680
+ )
307
681
  ) : checked ? (
308
682
  indicator
309
683
  ) : null;
@@ -385,8 +759,12 @@ type SelectCompoundComponent = typeof SelectRoot & {
385
759
  Value: typeof SelectValue;
386
760
  Icon: typeof SelectIcon;
387
761
  Content: typeof SelectContent;
762
+ BottomSheet: typeof SelectBottomSheet;
763
+ Search: typeof SelectSearch;
764
+ Empty: typeof SelectEmpty;
388
765
  Item: typeof SelectItem;
389
766
  ItemLabel: typeof SelectItemLabel;
767
+ ItemControl: typeof SelectItemControl;
390
768
  };
391
769
 
392
770
  export const Select = Object.assign(SelectRoot, {
@@ -394,6 +772,10 @@ export const Select = Object.assign(SelectRoot, {
394
772
  Value: SelectValue,
395
773
  Icon: SelectIcon,
396
774
  Content: SelectContent,
775
+ BottomSheet: SelectBottomSheet,
776
+ Search: SelectSearch,
777
+ Empty: SelectEmpty,
397
778
  Item: SelectItem,
398
779
  ItemLabel: SelectItemLabel,
780
+ ItemControl: SelectItemControl,
399
781
  }) as SelectCompoundComponent;
@@ -119,4 +119,40 @@ export const selectIconVariants = cva(
119
119
  },
120
120
  );
121
121
 
122
+ // ── Item ────────────────────────────────────────────────────
123
+
124
+ // Web keyboard navigation keeps DOM focus on the popup or the search input (virtual focus), so the
125
+ // highlighted option is painted from `data-highlighted` instead of a focus ring.
126
+ export const selectItemVariants = cva(['web:data-[highlighted=true]:bg-surface-action-tint-hover']);
127
+
128
+ // ── Search ──────────────────────────────────────────────────
129
+
130
+ export const selectSearchVariants = cva(['mb-1']);
131
+
132
+ export const selectSearchListboxVariants = cva([
133
+ 'rounded-none border-0 bg-transparent p-0 shadow-none',
134
+ ]);
135
+
136
+ // ── Bottom sheet (native) ───────────────────────────────────
137
+
138
+ export const selectBottomSheetVariants = cva(['flex-1 px-3']);
139
+
140
+ export const selectBottomSheetListboxVariants = cva(['py-1']);
141
+
142
+ // ── Empty ───────────────────────────────────────────────────
143
+
144
+ export const selectEmptyVariants = cva(['px-3 py-2']);
145
+
146
+ export const selectEmptyTextVariants = cva(['text-start text-content-secondary'], {
147
+ variants: {
148
+ size: {
149
+ default: 'text-[0.875rem]',
150
+ small: 'text-[0.75rem]',
151
+ },
152
+ },
153
+ defaultVariants: {
154
+ size: 'default',
155
+ },
156
+ });
157
+
122
158
  export type SelectVariantProps = VariantProps<typeof selectTriggerVariants>;
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
2
2
  import { ScrollView, View } from 'react-native';
3
3
  import { FadeIn, FadeOut } from 'react-native-reanimated';
4
+ import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
4
5
  import {
5
6
  dataAttributes,
6
7
  OverlayContainer,
@@ -41,23 +42,40 @@ export function PickerDialogSurface({
41
42
  type PickerBottomSheetSurfaceProps = {
42
43
  open: boolean;
43
44
  setOpen: (open: boolean) => void;
44
- registerSurface: (type: 'bottomSheet') => void;
45
+ registerSurface?: (type: 'bottomSheet') => void;
46
+ /**
47
+ * Lays the children out in a container that fills the sheet, so a fixed header can sit above a
48
+ * `BottomSheet.ScrollView`. By default the children size a `BottomSheet.View`.
49
+ */
50
+ fill?: boolean;
45
51
  children: ReactNode;
46
- } & Pick<BottomSheetModalProps, 'snapPoints' | 'enableDynamicSizing'>;
52
+ } & Pick<
53
+ BottomSheetModalProps,
54
+ 'snapPoints' | 'enableDynamicSizing' | 'keyboardBehavior' | 'keyboardBlurBehavior' | 'accessible'
55
+ >;
56
+
57
+ /**
58
+ * Text input for a picker sheet. It registers with the sheet's keyboard handling, which moves
59
+ * the sheet for a focused `BottomSheetTextInput` only; a plain `TextInput` stays under the
60
+ * keyboard.
61
+ */
62
+ export const PickerSheetTextInput = BottomSheetTextInput;
47
63
 
48
64
  export function PickerBottomSheetSurface({
49
65
  open,
50
66
  setOpen,
51
67
  registerSurface,
68
+ fill = false,
52
69
  children,
53
70
  snapPoints = ['70%', '90%'],
54
71
  enableDynamicSizing = false,
72
+ ...modalProps
55
73
  }: PickerBottomSheetSurfaceProps) {
56
74
  const { ref, dismiss } = useBottomSheet();
57
75
  const isPresentedRef = useRef(false);
58
76
 
59
77
  useEffect(() => {
60
- registerSurface('bottomSheet');
78
+ registerSurface?.('bottomSheet');
61
79
  }, [registerSurface]);
62
80
 
63
81
  const handleDismiss = useCallback(() => {
@@ -95,8 +113,15 @@ export function PickerBottomSheetSurface({
95
113
  onDismiss={handleDismiss}
96
114
  snapPoints={snapPoints}
97
115
  enableDynamicSizing={enableDynamicSizing}
116
+ {...modalProps}
98
117
  >
99
- <BottomSheet.View onLayout={tryPresent}>{children}</BottomSheet.View>
118
+ {fill ? (
119
+ <View className="flex-1" onLayout={tryPresent}>
120
+ {children}
121
+ </View>
122
+ ) : (
123
+ <BottomSheet.View onLayout={tryPresent}>{children}</BottomSheet.View>
124
+ )}
100
125
  </BottomSheet.Modal>
101
126
  );
102
127
  }