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