panelui-native 0.36.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +13 -1
  2. package/lib/module/components/combobox/index.js +718 -0
  3. package/lib/module/components/combobox/index.js.map +1 -0
  4. package/lib/module/components/form/use-field.js +18 -0
  5. package/lib/module/components/form/use-field.js.map +1 -1
  6. package/lib/module/components/form/use-form.js +91 -8
  7. package/lib/module/components/form/use-form.js.map +1 -1
  8. package/lib/module/components/heatmap-chart/index.js +113 -6
  9. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  10. package/lib/module/components/input/index.js +10 -3
  11. package/lib/module/components/input/index.js.map +1 -1
  12. package/lib/module/components/pagination/index.js +519 -0
  13. package/lib/module/components/pagination/index.js.map +1 -0
  14. package/lib/module/components/ring-chart/index.js +183 -45
  15. package/lib/module/components/ring-chart/index.js.map +1 -1
  16. package/lib/module/components/select/index.js +116 -15
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/steps/index.js +8 -6
  19. package/lib/module/components/steps/index.js.map +1 -1
  20. package/lib/module/components/table/index.js +100 -26
  21. package/lib/module/components/table/index.js.map +1 -1
  22. package/lib/module/components/timeline/index.js +8 -7
  23. package/lib/module/components/timeline/index.js.map +1 -1
  24. package/lib/module/index.js +2 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/theme/use-theme.js +1 -1
  27. package/lib/typescript/src/components/combobox/index.d.ts +164 -0
  28. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -0
  29. package/lib/typescript/src/components/form/use-field.d.ts.map +1 -1
  30. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
  31. package/lib/typescript/src/components/heatmap-chart/index.d.ts +51 -3
  32. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/input/index.d.ts +18 -4
  34. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/pagination/index.d.ts +285 -0
  36. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/ring-chart/index.d.ts +75 -8
  38. package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/select/index.d.ts +32 -0
  40. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/steps/index.d.ts +8 -6
  42. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/table/index.d.ts +59 -17
  44. package/lib/typescript/src/components/table/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/timeline/index.d.ts +8 -7
  46. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  47. package/lib/typescript/src/index.d.ts +4 -2
  48. package/lib/typescript/src/index.d.ts.map +1 -1
  49. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/src/components/combobox/index.tsx +927 -0
  52. package/src/components/form/use-field.ts +23 -0
  53. package/src/components/form/use-form.ts +106 -16
  54. package/src/components/heatmap-chart/index.tsx +147 -5
  55. package/src/components/input/index.tsx +21 -7
  56. package/src/components/pagination/index.tsx +591 -0
  57. package/src/components/ring-chart/index.tsx +269 -47
  58. package/src/components/select/index.tsx +114 -15
  59. package/src/components/steps/index.tsx +8 -6
  60. package/src/components/table/index.tsx +146 -30
  61. package/src/components/timeline/index.tsx +8 -7
  62. package/src/index.ts +25 -0
@@ -0,0 +1,927 @@
1
+ /**
2
+ * Combobox — a text field that filters a list of options as you type.
3
+ *
4
+ * The difference from Select is where the typing happens, and it is not a
5
+ * detail: a Select is a button that opens a list, and its optional filter lives
6
+ * *inside* the list once it is open. A Combobox is the field itself. You are
7
+ * already typing when the options appear, which is what you want when the value
8
+ * is something you know the name of — a city, a repository, a tag — rather than
9
+ * something you expect to recognise by scrolling.
10
+ *
11
+ * ```tsx
12
+ * <Combobox value={framework} onValueChange={setFramework}>
13
+ * <Combobox.Item value="expo" label="Expo" />
14
+ * <Combobox.Item value="next" label="Next.js" />
15
+ * </Combobox>
16
+ * ```
17
+ *
18
+ * ## Two presentations, and why there is no sheet
19
+ *
20
+ * `overlay` (default) floats the list above the page through a portal, anchored
21
+ * under the field and flipped above it when the keyboard leaves no room below.
22
+ * `inline` expands the list in normal layout flow instead, which is right in a
23
+ * form where nothing should be covered.
24
+ *
25
+ * There is deliberately no sheet presentation. A sheet takes the bottom of the
26
+ * screen, which is exactly where the keyboard is, and the field you are typing
27
+ * into would end up behind one or the other. Select can offer a sheet because
28
+ * its trigger stops mattering once the list is open; a Combobox's never does.
29
+ *
30
+ * ## Filtering is yours to turn off
31
+ *
32
+ * Filtering happens here by default, matching case-insensitively on any part of
33
+ * an option's label. That is the whole feature for a list you already have in
34
+ * hand. When the options come from a server that is doing the matching itself,
35
+ * pass `filter={false}` and render whatever came back — the field stops second-
36
+ * guessing results it cannot see the query behind.
37
+ *
38
+ * ## Values it does not know about
39
+ *
40
+ * `allowCustomValue` lets the typed text become the value when it matches no
41
+ * option, which is how a tag field works: the list is a set of suggestions
42
+ * rather than the set of legal answers.
43
+ */
44
+ import {
45
+ Children,
46
+ cloneElement,
47
+ createContext,
48
+ isValidElement,
49
+ useCallback,
50
+ useContext,
51
+ useEffect,
52
+ useMemo,
53
+ useRef,
54
+ useState,
55
+ type ReactElement,
56
+ type ReactNode,
57
+ } from 'react';
58
+ import {
59
+ Pressable,
60
+ ScrollView,
61
+ TextInput,
62
+ useWindowDimensions,
63
+ View,
64
+ type LayoutChangeEvent,
65
+ type ViewProps,
66
+ } from 'react-native';
67
+ import Animated, {
68
+ FadeIn,
69
+ FadeOut,
70
+ interpolateColor,
71
+ useAnimatedStyle,
72
+ useSharedValue,
73
+ withTiming,
74
+ } from 'react-native-reanimated';
75
+ import { tv } from 'tailwind-variants';
76
+ import { useCSSVariable } from 'uniwind';
77
+ import { CheckIcon, ChevronDownIcon, XIcon } from '../../icons';
78
+ import { Portal } from '../../primitives/portal';
79
+ import { Text, textChildren } from '../../primitives/text';
80
+ import { useBackHandler } from '../../hooks/use-back-handler';
81
+ import { useKeyboard } from '../../hooks/use-keyboard';
82
+ import { cn } from '../../utils/cn';
83
+ import { Chip } from '../chip';
84
+ import { Spinner } from '../spinner';
85
+
86
+ /** Matches Input's focus crossfade, so the two read as the same control. */
87
+ const FOCUS_DURATION = 150;
88
+
89
+ const comboboxVariants = tv({
90
+ slots: {
91
+ root: 'w-full',
92
+ /*
93
+ * `rounded-lg` and the same padding scale as Select's trigger and Input's
94
+ * field: a Combobox sitting in a form beside either of them has to read as
95
+ * the same family of control, not as a text field that happens to be near
96
+ * a picker.
97
+ *
98
+ * The border colour is animated between the resting and focused tokens, so
99
+ * it is deliberately absent from the class.
100
+ */
101
+ field:
102
+ 'w-full flex-row items-center gap-2 rounded-lg border bg-background px-4 py-2.5',
103
+ // Chips wrap onto their own lines; the input keeps a sane minimum so it is
104
+ // still tappable once a few of them are in front of it.
105
+ fieldContent: 'flex-1 flex-row flex-wrap items-center gap-1.5 py-1',
106
+ input: 'min-w-24 flex-1 py-1 text-base font-normal text-foreground',
107
+ action: 'h-6 w-6 items-center justify-center rounded-full',
108
+ list: 'overflow-hidden rounded-xl border border-border bg-popover p-2 shadow-sm',
109
+ item: 'flex-row items-center gap-2 rounded-lg px-3 py-3',
110
+ itemLabel: 'flex-1 text-base font-medium text-foreground',
111
+ itemIndicator: 'h-5 w-5 items-center justify-center',
112
+ group: 'gap-1',
113
+ groupLabel: 'px-3 pb-1 pt-2',
114
+ status: 'flex-row items-center justify-center gap-2 px-3 py-6',
115
+ },
116
+ variants: {
117
+ selected: {
118
+ true: { item: 'bg-accent' },
119
+ },
120
+ disabled: {
121
+ true: { field: 'opacity-[0.64]' },
122
+ },
123
+ itemDisabled: {
124
+ true: { item: 'opacity-[0.64]' },
125
+ },
126
+ presentation: {
127
+ overlay: { list: 'shadow-lg' },
128
+ inline: { list: 'mt-2' },
129
+ },
130
+ },
131
+ defaultVariants: {
132
+ presentation: 'overlay',
133
+ },
134
+ });
135
+
136
+ export type ComboboxPresentation = 'overlay' | 'inline';
137
+
138
+ /** Which selection shape a `mode` produces. */
139
+ export type ComboboxMode = 'single' | 'multiple';
140
+
141
+ export interface ComboboxSelection {
142
+ single: string | undefined;
143
+ multiple: string[];
144
+ }
145
+
146
+ interface ComboboxContextValue {
147
+ values: string[];
148
+ onSelect: (value: string, label: string) => void;
149
+ }
150
+
151
+ const ComboboxContext = createContext<ComboboxContextValue | null>(null);
152
+
153
+ export interface ComboboxItemProps {
154
+ value: string;
155
+ label: string;
156
+ /**
157
+ * Shows the option but refuses it. Kept in the list rather than dropped from
158
+ * it, because an option that vanishes reads as one that never existed.
159
+ */
160
+ disabled?: boolean;
161
+ /** Anything to draw before the label — an avatar, a flag, a status dot. */
162
+ start?: ReactNode;
163
+ /** A second line under the label, for what the label alone cannot say. */
164
+ description?: string;
165
+ }
166
+
167
+ /** Declarative option. Rendered inside whichever surface is presenting. */
168
+ function ComboboxItem({
169
+ value,
170
+ label,
171
+ disabled,
172
+ start,
173
+ description,
174
+ }: ComboboxItemProps) {
175
+ const context = useContext(ComboboxContext);
176
+ if (!context) {
177
+ throw new Error('Combobox.Item must be used within a <Combobox>');
178
+ }
179
+
180
+ const selected = context.values.includes(value);
181
+ const { item, itemLabel, itemIndicator } = comboboxVariants({
182
+ selected,
183
+ itemDisabled: !!disabled,
184
+ });
185
+ const checkColor = useCSSVariable('--color-muted-foreground');
186
+
187
+ return (
188
+ <Pressable
189
+ accessibilityRole="menuitem"
190
+ accessibilityState={{ selected, disabled: !!disabled }}
191
+ disabled={disabled}
192
+ onPress={() => context.onSelect(value, label)}
193
+ className={item()}
194
+ >
195
+ {start}
196
+ <View className="flex-1">
197
+ <Text className={itemLabel()}>{label}</Text>
198
+ {description ? (
199
+ <Text size="sm" muted numberOfLines={1}>
200
+ {description}
201
+ </Text>
202
+ ) : null}
203
+ </View>
204
+ <View className={itemIndicator()}>
205
+ {selected ? (
206
+ <CheckIcon
207
+ size={16}
208
+ color={typeof checkColor === 'string' ? checkColor : '#737373'}
209
+ />
210
+ ) : null}
211
+ </View>
212
+ </Pressable>
213
+ );
214
+ }
215
+
216
+ export interface ComboboxGroupProps {
217
+ /**
218
+ * Heading over the run of options. Announced as a header, so a screen reader
219
+ * reaching the group is told what it is before walking into it.
220
+ */
221
+ label?: string;
222
+ /** Extra classes for the group wrapper. */
223
+ className?: string;
224
+ /** Extra classes for the heading. */
225
+ labelClassName?: string;
226
+ children: ReactNode;
227
+ }
228
+
229
+ /**
230
+ * A titled run of options.
231
+ *
232
+ * Presentational only: a grouped Combobox reports the same values a flat one
233
+ * would, and `Combobox.Item` needs to know nothing about being inside one.
234
+ */
235
+ function ComboboxGroup({
236
+ label,
237
+ className,
238
+ labelClassName,
239
+ children,
240
+ }: ComboboxGroupProps) {
241
+ const { group, groupLabel } = comboboxVariants();
242
+
243
+ return (
244
+ <View className={cn(group(), className)}>
245
+ {label ? (
246
+ <View accessibilityRole="header" className={cn(groupLabel(), labelClassName)}>
247
+ <Text size="xs" weight="medium" muted className="uppercase tracking-wide">
248
+ {label}
249
+ </Text>
250
+ </View>
251
+ ) : null}
252
+ {textChildren(children)}
253
+ </View>
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Walk the declared children, visiting every option — including the ones nested
259
+ * inside a `Combobox.Group`.
260
+ *
261
+ * The flat set is what the field's own text needs: the label to show for a
262
+ * selected value, and the chips to draw for several of them. Rendering keeps
263
+ * the tree; only the lookup is flattened.
264
+ */
265
+ function eachOption(children: ReactNode, visit: (option: ComboboxItemProps) => void) {
266
+ Children.forEach(children, (child) => {
267
+ if (!isValidElement(child)) return;
268
+ if (child.type === ComboboxGroup) {
269
+ eachOption((child.props as ComboboxGroupProps).children, visit);
270
+ return;
271
+ }
272
+ if (child.type !== ComboboxItem) return;
273
+ visit(child.props as ComboboxItemProps);
274
+ });
275
+ }
276
+
277
+ /**
278
+ * The children a query leaves standing.
279
+ *
280
+ * A group is rebuilt around whatever survives inside it and dropped when that
281
+ * is nothing — a heading over no options reads as a section that failed to load
282
+ * rather than one the query emptied.
283
+ */
284
+ function filterOptions(
285
+ children: ReactNode,
286
+ matches: (option: ComboboxItemProps) => boolean
287
+ ): ReactNode[] {
288
+ const kept: ReactNode[] = [];
289
+
290
+ Children.forEach(children, (child) => {
291
+ if (!isValidElement(child)) return;
292
+
293
+ if (child.type === ComboboxGroup) {
294
+ const props = child.props as ComboboxGroupProps;
295
+ const inner = filterOptions(props.children, matches);
296
+ if (inner.length) {
297
+ kept.push(cloneElement(child as ReactElement<ComboboxGroupProps>, {}, inner));
298
+ }
299
+ return;
300
+ }
301
+
302
+ if (child.type === ComboboxItem && matches(child.props as ComboboxItemProps)) {
303
+ kept.push(child);
304
+ }
305
+ });
306
+
307
+ return kept;
308
+ }
309
+
310
+ /** Field frame in window coordinates, measured when the list opens. */
311
+ interface Anchor {
312
+ x: number;
313
+ y: number;
314
+ width: number;
315
+ height: number;
316
+ }
317
+
318
+ export interface ComboboxProps<Mode extends ComboboxMode = 'single'>
319
+ extends Omit<ViewProps, 'children' | 'onLayout'> {
320
+ className?: string;
321
+ /**
322
+ * One value or several. `multiple` draws the chosen options as removable
323
+ * chips in front of the input and keeps the list open between picks.
324
+ */
325
+ mode?: Mode;
326
+ /** Controlled selection. Its shape follows `mode`. */
327
+ value?: ComboboxSelection[Mode];
328
+ /** Starting selection when uncontrolled. */
329
+ defaultValue?: ComboboxSelection[Mode];
330
+ onValueChange?: (value: ComboboxSelection[Mode]) => void;
331
+ /**
332
+ * Controlled query — the text actually in the field. Pair it with
333
+ * `onInputValueChange` when the options are fetched for it.
334
+ */
335
+ inputValue?: string;
336
+ /** Starting query when uncontrolled. */
337
+ defaultInputValue?: string;
338
+ onInputValueChange?: (value: string) => void;
339
+ placeholder?: string;
340
+ disabled?: boolean;
341
+ /** Where the options appear. */
342
+ presentation?: ComboboxPresentation;
343
+ /**
344
+ * Narrow the options to the query here. `true` matches case-insensitively on
345
+ * any part of an option's label; pass a function to match on something else —
346
+ * a description, an alias list, an initialism.
347
+ *
348
+ * Pass `false` when a server is doing the matching: the options you render
349
+ * are then shown exactly as given, since a second filter over results the
350
+ * field cannot see the query behind would only remove correct answers.
351
+ */
352
+ filter?: boolean | ((option: ComboboxItemProps, query: string) => boolean);
353
+ /**
354
+ * Let the typed text become the value when it matches no option, committed on
355
+ * submit. Turns the list into a set of suggestions rather than the set of
356
+ * legal answers — which is what a tag field is.
357
+ */
358
+ allowCustomValue?: boolean;
359
+ /** Show a spinner in place of the list. For options still being fetched. */
360
+ loading?: boolean;
361
+ /** Shown in place of the list when nothing matches. */
362
+ emptyMessage?: string;
363
+ /** Shown in place of the list while `loading`. */
364
+ loadingMessage?: string;
365
+ /** Offer a ✕ that clears the query and the selection. */
366
+ clearable?: boolean;
367
+ /** Open the list as soon as the field takes focus, before anything is typed. */
368
+ openOnFocus?: boolean;
369
+ /** Called when the list opens or closes. */
370
+ onOpenChange?: (open: boolean) => void;
371
+ /**
372
+ * Width of the floating list. `field` matches the field, `content` sizes to
373
+ * the longest option, or pass a pixel value. `overlay` only.
374
+ */
375
+ contentWidth?: 'field' | 'content' | number;
376
+ /** Gap between the field and the floating list. `overlay` only. */
377
+ offset?: number;
378
+ /** Extra classes for the list surface. */
379
+ listClassName?: string;
380
+ /** Accessible name for the field. */
381
+ accessibilityLabel?: string;
382
+ children: ReactNode;
383
+ }
384
+
385
+ function ComboboxRoot<Mode extends ComboboxMode = 'single'>({
386
+ className,
387
+ mode,
388
+ value,
389
+ defaultValue,
390
+ onValueChange,
391
+ inputValue,
392
+ defaultInputValue = '',
393
+ onInputValueChange,
394
+ placeholder = 'Search',
395
+ disabled = false,
396
+ presentation = 'overlay',
397
+ filter = true,
398
+ allowCustomValue = false,
399
+ loading = false,
400
+ emptyMessage = 'No matches',
401
+ loadingMessage = 'Searching',
402
+ clearable = false,
403
+ openOnFocus = false,
404
+ onOpenChange,
405
+ contentWidth = 'field',
406
+ offset = 8,
407
+ listClassName,
408
+ accessibilityLabel,
409
+ children,
410
+ ...props
411
+ }: ComboboxProps<Mode>) {
412
+ const multiple = mode === 'multiple';
413
+ const [open, setOpen] = useState(false);
414
+ const [focused, setFocused] = useState(false);
415
+ const [anchor, setAnchor] = useState<Anchor | null>(null);
416
+ const [listHeight, setListHeight] = useState(0);
417
+ /*
418
+ * The anchor is measured off the plain wrapper rather than off the animated
419
+ * field inside it. In `overlay` the wrapper *is* the field's box — the list
420
+ * is portalled out — and a host View is the thing with a dependable
421
+ * `measureInWindow`. `inline` never reads the anchor, so the list it also
422
+ * wraps cannot skew anything.
423
+ */
424
+ const fieldRef = useRef<View>(null);
425
+ const inputRef = useRef<TextInput>(null);
426
+ const { height: screenHeight } = useWindowDimensions();
427
+ /*
428
+ * The keyboard is up whenever this list is open — the field is a text input
429
+ * and opening the list is what typing in it does. So the space the list has
430
+ * to work with is never the window: it is the window above the keyboard, and
431
+ * measuring against the window would put the options behind it.
432
+ */
433
+ const { height: keyboardHeight } = useKeyboard();
434
+
435
+ const [internalValue, setInternalValue] = useState<ComboboxSelection[Mode]>(
436
+ () =>
437
+ (defaultValue ??
438
+ (mode === 'multiple' ? [] : undefined)) as ComboboxSelection[Mode]
439
+ );
440
+ const selection = (value !== undefined ? value : internalValue) as
441
+ | string
442
+ | string[]
443
+ | undefined;
444
+
445
+ const [internalQuery, setInternalQuery] = useState(defaultInputValue);
446
+ const query = inputValue !== undefined ? inputValue : internalQuery;
447
+
448
+ /** The selection as a list, which is the shape everything downstream wants. */
449
+ const values = useMemo(() => {
450
+ if (selection == null) return [];
451
+ return Array.isArray(selection) ? selection : [selection];
452
+ }, [selection]);
453
+
454
+ const options = useMemo(() => {
455
+ const collected: ComboboxItemProps[] = [];
456
+ eachOption(children, (option) => collected.push(option));
457
+ return collected;
458
+ }, [children]);
459
+
460
+ const labelOf = useCallback(
461
+ (candidate: string) =>
462
+ options.find((option) => option.value === candidate)?.label ?? candidate,
463
+ [options]
464
+ );
465
+
466
+ const setQuery = useCallback(
467
+ (next: string) => {
468
+ if (inputValue === undefined) setInternalQuery(next);
469
+ onInputValueChange?.(next);
470
+ },
471
+ [inputValue, onInputValueChange]
472
+ );
473
+
474
+ const commit = useCallback(
475
+ (next: ComboboxSelection[Mode]) => {
476
+ if (value === undefined) setInternalValue(next);
477
+ onValueChange?.(next);
478
+ },
479
+ [value, onValueChange]
480
+ );
481
+
482
+ const setOpenState = useCallback(
483
+ (next: boolean) => {
484
+ setOpen((current) => {
485
+ if (current === next) return current;
486
+ onOpenChange?.(next);
487
+ return next;
488
+ });
489
+ },
490
+ [onOpenChange]
491
+ );
492
+
493
+ /**
494
+ * The floating list is positioned in window coordinates, so it has to know
495
+ * where the field actually landed — not where layout said it would.
496
+ */
497
+ const openList = useCallback(() => {
498
+ if (disabled) return;
499
+ if (presentation !== 'overlay') {
500
+ setOpenState(true);
501
+ return;
502
+ }
503
+ fieldRef.current?.measureInWindow((x, y, width, height) => {
504
+ setAnchor({ x, y, width, height });
505
+ setOpenState(true);
506
+ });
507
+ }, [disabled, presentation, setOpenState]);
508
+
509
+ const close = useCallback(() => setOpenState(false), [setOpenState]);
510
+
511
+ // An open overlay list catches the Android back button, closing itself
512
+ // instead of popping the screen behind it.
513
+ useBackHandler(open && presentation === 'overlay', close);
514
+
515
+ /*
516
+ * The anchor is a snapshot, and the keyboard invalidates it: a scroll view
517
+ * that lifts its content clear of the keyboard moves the field after it was
518
+ * measured, and the list would stay at the old position. Re-measure whenever
519
+ * the keyboard's height changes while the list is open.
520
+ */
521
+ useEffect(() => {
522
+ if (!open || presentation !== 'overlay') return;
523
+ fieldRef.current?.measureInWindow((x, y, width, height) =>
524
+ setAnchor({ x, y, width, height })
525
+ );
526
+ }, [open, presentation, keyboardHeight]);
527
+
528
+ /*
529
+ * A single-select field shows the chosen option's label when it is not being
530
+ * typed into. Re-deriving it on every keystroke would fight the typing, so it
531
+ * is only written back when the selection itself changes and the field is not
532
+ * focused — which covers a value arriving from outside, and the blur after a
533
+ * pick. A custom-value field is left alone: the text *is* the value there.
534
+ */
535
+ const singleValue = multiple ? undefined : (selection as string | undefined);
536
+ useEffect(() => {
537
+ if (multiple || focused || allowCustomValue) return;
538
+ setQuery(singleValue == null ? '' : labelOf(singleValue));
539
+ // `setQuery` is stable per controlled-ness; re-running on every identity
540
+ // change would overwrite the query the caller is controlling.
541
+ // eslint-disable-next-line react-hooks/exhaustive-deps
542
+ }, [multiple, focused, allowCustomValue, singleValue, labelOf]);
543
+
544
+ const matcher = useCallback(
545
+ (option: ComboboxItemProps) => {
546
+ if (filter === false) return true;
547
+ const needle = query.trim().toLowerCase();
548
+ if (!needle) return true;
549
+ if (typeof filter === 'function') return filter(option, query.trim());
550
+ return option.label.toLowerCase().includes(needle);
551
+ },
552
+ [filter, query]
553
+ );
554
+
555
+ /*
556
+ * `null` means "render the children as given" — nothing is being narrowed, so
557
+ * an unfiltered list does no per-option work at all.
558
+ */
559
+ const filtered = useMemo(() => {
560
+ if (filter === false) return null;
561
+ if (!query.trim()) return null;
562
+ return filterOptions(children, matcher);
563
+ }, [children, filter, query, matcher]);
564
+
565
+ const exactMatch = useMemo(
566
+ () =>
567
+ options.some(
568
+ (option) => option.label.toLowerCase() === query.trim().toLowerCase()
569
+ ),
570
+ [options, query]
571
+ );
572
+
573
+ const select = useCallback(
574
+ (next: string) => {
575
+ if (multiple) {
576
+ const current = Array.isArray(selection) ? selection : [];
577
+ const without = current.filter((entry) => entry !== next);
578
+ // Toggling: picking a chosen option again removes it, which is the only
579
+ // way to undo a pick without reaching for its chip.
580
+ const updated = without.length === current.length ? [...current, next] : without;
581
+ commit(updated as ComboboxSelection[Mode]);
582
+ // The query has done its job once the pick is made, and leaving it
583
+ // would hide every option that does not also match it.
584
+ setQuery('');
585
+ return;
586
+ }
587
+
588
+ commit(next as ComboboxSelection[Mode]);
589
+ setQuery(labelOf(next));
590
+ close();
591
+ inputRef.current?.blur();
592
+ },
593
+ [multiple, selection, commit, setQuery, labelOf, close]
594
+ );
595
+
596
+ /** Enter, or the keyboard's Done: take the typed text if it can be taken. */
597
+ const submit = useCallback(() => {
598
+ const typed = query.trim();
599
+ if (!typed) return;
600
+
601
+ const match = options.find(
602
+ (option) => option.label.toLowerCase() === typed.toLowerCase()
603
+ );
604
+ if (match && !match.disabled) {
605
+ select(match.value);
606
+ return;
607
+ }
608
+
609
+ if (!allowCustomValue) return;
610
+
611
+ if (multiple) {
612
+ const current = Array.isArray(selection) ? selection : [];
613
+ if (!current.includes(typed)) {
614
+ commit([...current, typed] as ComboboxSelection[Mode]);
615
+ }
616
+ setQuery('');
617
+ return;
618
+ }
619
+
620
+ commit(typed as ComboboxSelection[Mode]);
621
+ close();
622
+ }, [
623
+ query,
624
+ options,
625
+ allowCustomValue,
626
+ multiple,
627
+ selection,
628
+ select,
629
+ commit,
630
+ setQuery,
631
+ close,
632
+ ]);
633
+
634
+ const remove = useCallback(
635
+ (target: string) => {
636
+ const current = Array.isArray(selection) ? selection : [];
637
+ commit(current.filter((entry) => entry !== target) as ComboboxSelection[Mode]);
638
+ },
639
+ [selection, commit]
640
+ );
641
+
642
+ const clear = useCallback(() => {
643
+ setQuery('');
644
+ commit((multiple ? [] : undefined) as ComboboxSelection[Mode]);
645
+ inputRef.current?.focus();
646
+ }, [setQuery, commit, multiple]);
647
+
648
+ const context = useMemo<ComboboxContextValue>(
649
+ () => ({ values, onSelect: (next) => select(next) }),
650
+ [values, select]
651
+ );
652
+
653
+ const slots = comboboxVariants({ disabled, presentation });
654
+ const mutedColor = useCSSVariable('--color-muted-foreground');
655
+ const restColor = useCSSVariable('--color-input');
656
+ const focusColor = useCSSVariable('--color-ring');
657
+ const placeholderColor = typeof mutedColor === 'string' ? mutedColor : '#737373';
658
+
659
+ const focus = useSharedValue(0);
660
+ useEffect(() => {
661
+ focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION });
662
+ }, [focused, focus]);
663
+
664
+ const fieldStyle = useAnimatedStyle(() => {
665
+ const idle = typeof restColor === 'string' ? restColor : 'rgba(0,0,0,0.1)';
666
+ const active = typeof focusColor === 'string' ? focusColor : '#a3a3a3';
667
+ return {
668
+ borderColor: interpolateColor(focus.value, [0, 1], [idle, active]),
669
+ };
670
+ });
671
+
672
+ const chevron = useSharedValue(0);
673
+ useEffect(() => {
674
+ chevron.value = withTiming(open ? 1 : 0, { duration: 160 });
675
+ }, [open, chevron]);
676
+
677
+ const chevronStyle = useAnimatedStyle(() => ({
678
+ transform: [{ rotate: `${chevron.value * 180}deg` }],
679
+ }));
680
+
681
+ const hasContent = query.length > 0 || values.length > 0;
682
+
683
+ /*
684
+ * The list body, built once and handed to whichever surface is presenting.
685
+ * The two differ in where they put it, not in what it is.
686
+ */
687
+ /*
688
+ * `filtered === null` means nothing was narrowed — either there is no query
689
+ * or a server is doing the matching — so the children are rendered as given
690
+ * and emptiness is a question about the options themselves. A server that
691
+ * came back with nothing still has to say so, which is why this is not just
692
+ * `filtered.length`.
693
+ */
694
+ const shown = filtered === null ? textChildren(children) : filtered;
695
+ const isEmpty = filtered === null ? options.length === 0 : filtered.length === 0;
696
+
697
+ const body = loading ? (
698
+ <View className={slots.status()}>
699
+ <Spinner size="sm" />
700
+ <Text size="sm" muted>
701
+ {loadingMessage}
702
+ </Text>
703
+ </View>
704
+ ) : isEmpty ? (
705
+ <Text className="px-3 py-6 text-center text-sm text-muted-foreground">
706
+ {allowCustomValue && query.trim() && !exactMatch
707
+ ? `Press return to add “${query.trim()}”`
708
+ : emptyMessage}
709
+ </Text>
710
+ ) : (
711
+ shown
712
+ );
713
+
714
+ const list = (
715
+ <ScrollView
716
+ bounces={false}
717
+ showsVerticalScrollIndicator={false}
718
+ // The field above the list is a text input: without this a tap on an
719
+ // option would be swallowed by the keyboard dismissing first.
720
+ keyboardShouldPersistTaps="always"
721
+ keyboardDismissMode="on-drag"
722
+ >
723
+ <View className="gap-1">{body}</View>
724
+ </ScrollView>
725
+ );
726
+
727
+ const field = (
728
+ <Animated.View
729
+ style={fieldStyle}
730
+ className={slots.field()}
731
+ // The field is one control made of several views. Announcing it as a
732
+ // combobox that owns an expandable list is what makes the chips and the
733
+ // input read as parts of it rather than as loose siblings.
734
+ accessibilityRole="combobox"
735
+ accessibilityLabel={accessibilityLabel}
736
+ accessibilityState={{ disabled, expanded: open }}
737
+ >
738
+ <View className={slots.fieldContent()}>
739
+ {multiple
740
+ ? values.map((entry) => (
741
+ <Chip
742
+ key={entry}
743
+ size="sm"
744
+ onClose={disabled ? undefined : () => remove(entry)}
745
+ closeLabel={`Remove ${labelOf(entry)}`}
746
+ >
747
+ {labelOf(entry)}
748
+ </Chip>
749
+ ))
750
+ : null}
751
+ <TextInput
752
+ ref={inputRef}
753
+ className={slots.input()}
754
+ value={query}
755
+ onChangeText={(next) => {
756
+ setQuery(next);
757
+ if (!open) openList();
758
+ }}
759
+ onFocus={() => {
760
+ setFocused(true);
761
+ if (openOnFocus) openList();
762
+ }}
763
+ onBlur={() => setFocused(false)}
764
+ onSubmitEditing={submit}
765
+ onKeyPress={({ nativeEvent }) => {
766
+ // Backspace on an empty field takes the last chip back — the same
767
+ // reflex that deletes a character, extended to the thing in front
768
+ // of the cursor when there is no character left to delete.
769
+ if (
770
+ multiple &&
771
+ nativeEvent.key === 'Backspace' &&
772
+ query.length === 0 &&
773
+ values.length > 0
774
+ ) {
775
+ remove(values[values.length - 1]!);
776
+ }
777
+ }}
778
+ editable={!disabled}
779
+ placeholder={values.length && multiple ? undefined : placeholder}
780
+ placeholderTextColor={placeholderColor}
781
+ autoCapitalize="none"
782
+ autoCorrect={false}
783
+ autoComplete="off"
784
+ returnKeyType={allowCustomValue ? 'done' : 'search'}
785
+ submitBehavior={multiple ? 'submit' : 'blurAndSubmit'}
786
+ accessibilityLabel={accessibilityLabel ?? placeholder}
787
+ />
788
+ </View>
789
+
790
+ {clearable && hasContent && !disabled ? (
791
+ <Pressable
792
+ accessibilityRole="button"
793
+ accessibilityLabel="Clear"
794
+ onPress={clear}
795
+ className={slots.action()}
796
+ >
797
+ <XIcon size={16} color={placeholderColor} />
798
+ </Pressable>
799
+ ) : null}
800
+
801
+ <Pressable
802
+ accessibilityRole="button"
803
+ accessibilityLabel={open ? 'Hide options' : 'Show options'}
804
+ accessibilityState={{ expanded: open }}
805
+ disabled={disabled}
806
+ onPress={() => {
807
+ if (open) {
808
+ close();
809
+ return;
810
+ }
811
+ openList();
812
+ inputRef.current?.focus();
813
+ }}
814
+ className={slots.action()}
815
+ >
816
+ <Animated.View style={chevronStyle}>
817
+ <ChevronDownIcon color={placeholderColor} />
818
+ </Animated.View>
819
+ </Pressable>
820
+ </Animated.View>
821
+ );
822
+
823
+ if (presentation === 'inline') {
824
+ return (
825
+ <ComboboxContext.Provider value={context}>
826
+ <View ref={fieldRef} className={cn(slots.root(), className)} {...props}>
827
+ {field}
828
+ {open ? (
829
+ <Animated.View
830
+ entering={FadeIn.duration(140)}
831
+ exiting={FadeOut.duration(120)}
832
+ className={cn(slots.list(), 'max-h-80', listClassName)}
833
+ >
834
+ {list}
835
+ </Animated.View>
836
+ ) : null}
837
+ </View>
838
+ </ComboboxContext.Provider>
839
+ );
840
+ }
841
+
842
+ // Flip above the field when the list would run off the bottom — where the
843
+ // bottom is the top of the keyboard, not the bottom of the screen. listHeight
844
+ // is 0 on the first frame, so it opens downwards and corrects itself once
845
+ // measured — invisible inside the 140ms fade.
846
+ const viewportBottom = screenHeight - keyboardHeight;
847
+ const spaceBelow = anchor ? viewportBottom - (anchor.y + anchor.height) - offset : 0;
848
+ const flip = !!anchor && listHeight > 0 && listHeight > spaceBelow;
849
+
850
+ const overlayPosition = anchor
851
+ ? {
852
+ position: 'absolute' as const,
853
+ left: anchor.x,
854
+ ...(flip
855
+ ? { bottom: screenHeight - anchor.y + offset }
856
+ : { top: anchor.y + anchor.height + offset }),
857
+ ...(contentWidth === 'field'
858
+ ? { width: anchor.width }
859
+ : typeof contentWidth === 'number'
860
+ ? { width: contentWidth }
861
+ : { minWidth: anchor.width }),
862
+ // Never collapse to nothing in a cramped viewport — the list scrolls.
863
+ maxHeight: Math.max((flip ? anchor.y : spaceBelow) - offset, 160),
864
+ }
865
+ : null;
866
+
867
+ return (
868
+ <ComboboxContext.Provider value={context}>
869
+ <View ref={fieldRef} className={cn(slots.root(), className)} {...props}>
870
+ {field}
871
+ </View>
872
+
873
+ {open && overlayPosition ? (
874
+ <Portal>
875
+ {/*
876
+ * Full-screen catcher so a press anywhere else dismisses the list.
877
+ *
878
+ * Hidden from assistive tech, and deliberately: it is a dismiss
879
+ * affordance for a pointer, and announcing it would put an unlabelled
880
+ * full-screen "button" ahead of the options in the reading order.
881
+ * Escaping the list is the back gesture's job.
882
+ */}
883
+ <Pressable
884
+ accessible={false}
885
+ importantForAccessibility="no-hide-descendants"
886
+ accessibilityElementsHidden
887
+ onPress={() => {
888
+ close();
889
+ inputRef.current?.blur();
890
+ }}
891
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
892
+ />
893
+ {/* Portalled out of this subtree — re-provide the context so
894
+ Combobox.Item keeps working. */}
895
+ <ComboboxContext.Provider value={context}>
896
+ <Animated.View
897
+ entering={FadeIn.duration(140)}
898
+ exiting={FadeOut.duration(120)}
899
+ onLayout={(event: LayoutChangeEvent) =>
900
+ setListHeight(event.nativeEvent.layout.height)
901
+ }
902
+ style={overlayPosition}
903
+ /*
904
+ * The floating list covers the screen with a catcher and takes
905
+ * the back button, so it is a modal layer. Without this the page
906
+ * behind it stays in the accessibility tree and a screen reader
907
+ * could walk out of the open list into content it is covering.
908
+ */
909
+ accessibilityViewIsModal
910
+ className={cn(slots.list(), listClassName)}
911
+ >
912
+ {list}
913
+ </Animated.View>
914
+ </ComboboxContext.Provider>
915
+ </Portal>
916
+ ) : null}
917
+ </ComboboxContext.Provider>
918
+ );
919
+ }
920
+
921
+ ComboboxItem.displayName = 'Combobox.Item';
922
+ ComboboxGroup.displayName = 'Combobox.Group';
923
+
924
+ export const Combobox = Object.assign(ComboboxRoot, {
925
+ Item: ComboboxItem,
926
+ Group: ComboboxGroup,
927
+ });