panelui-native 0.51.1 → 0.54.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 (46) hide show
  1. package/README.md +4 -1
  2. package/lib/module/components/candlestick-chart/index.js +1161 -0
  3. package/lib/module/components/candlestick-chart/index.js.map +1 -0
  4. package/lib/module/components/combobox/index.js +73 -7
  5. package/lib/module/components/combobox/index.js.map +1 -1
  6. package/lib/module/components/context-menu/index.js +529 -0
  7. package/lib/module/components/context-menu/index.js.map +1 -0
  8. package/lib/module/components/menu/index.js +18 -10
  9. package/lib/module/components/menu/index.js.map +1 -1
  10. package/lib/module/components/popover/index.js +71 -7
  11. package/lib/module/components/popover/index.js.map +1 -1
  12. package/lib/module/components/sortable/index.js +159 -23
  13. package/lib/module/components/sortable/index.js.map +1 -1
  14. package/lib/module/components/tabs/index.js +56 -15
  15. package/lib/module/components/tabs/index.js.map +1 -1
  16. package/lib/module/components/time-picker/index.js +295 -31
  17. package/lib/module/components/time-picker/index.js.map +1 -1
  18. package/lib/module/index.js +3 -1
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
  21. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
  22. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  23. package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
  24. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/menu/index.d.ts +20 -20
  26. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/popover/index.d.ts +51 -1
  28. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/sortable/index.d.ts +13 -3
  30. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/tabs/index.d.ts +34 -2
  32. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  34. package/lib/typescript/src/index.d.ts +3 -1
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/candlestick-chart/index.tsx +1360 -0
  38. package/src/components/combobox/index.tsx +149 -18
  39. package/src/components/context-menu/index.tsx +658 -0
  40. package/src/components/menu/index.tsx +17 -10
  41. package/src/components/popover/index.tsx +94 -6
  42. package/src/components/sortable/index.tsx +278 -36
  43. package/src/components/tabs/index.tsx +82 -16
  44. package/src/components/tag-input/index.tsx +619 -0
  45. package/src/components/time-picker/index.tsx +330 -35
  46. package/src/index.ts +35 -0
@@ -0,0 +1,619 @@
1
+ /**
2
+ * TagInput — a field whose value is a list of tokens rather than a string.
3
+ *
4
+ * The value is whatever gets typed. That is the whole distinction from a
5
+ * `Combobox` in `multiple` mode: a Combobox picks from a set of options you
6
+ * supply, so it needs a list, a filter and a surface to float that list on. A
7
+ * tag field has no options and no list — labels on a task, recipients on a
8
+ * message, keywords on a post — so it carries none of that machinery and never
9
+ * opens a portal.
10
+ *
11
+ * ```tsx
12
+ * <TagInput
13
+ * label="Topics"
14
+ * defaultValue={['expo', 'reanimated']}
15
+ * placeholder="Add a topic"
16
+ * />
17
+ * ```
18
+ *
19
+ * ## Three ways a tag gets committed
20
+ *
21
+ * Return commits what has been typed. So does any of `delimiters` — a comma by
22
+ * default — which is what makes pasting `design, research, ops` land as three
23
+ * tags instead of one long one. And `blurBehavior` decides what a field that
24
+ * loses focus mid-word does with the leftover.
25
+ *
26
+ * ## Backspace asks first
27
+ *
28
+ * Backspace on an empty field marks the last tag rather than taking it: the
29
+ * tag turns destructive, and a second backspace removes it. A held backspace
30
+ * repeats, and a field that deleted on the first one would empty itself in the
31
+ * time it takes to notice — the mark is the beat that lets you stop.
32
+ */
33
+ import {
34
+ forwardRef,
35
+ useCallback,
36
+ useEffect,
37
+ useImperativeHandle,
38
+ useMemo,
39
+ useRef,
40
+ useState,
41
+ type ReactNode,
42
+ } from 'react';
43
+ import {
44
+ Pressable,
45
+ TextInput,
46
+ View,
47
+ type NativeSyntheticEvent,
48
+ type TextInputKeyPressEventData,
49
+ type TextInputProps,
50
+ } from 'react-native';
51
+ import Animated, {
52
+ FadeIn,
53
+ FadeOut,
54
+ LinearTransition,
55
+ interpolateColor,
56
+ useAnimatedStyle,
57
+ useSharedValue,
58
+ withTiming,
59
+ } from 'react-native-reanimated';
60
+ import { tv, type VariantProps } from 'tailwind-variants';
61
+ import { useCSSVariable } from 'uniwind';
62
+ import { XIcon } from '../../icons';
63
+ import { Text } from '../../primitives/text';
64
+ import { selectionTick } from '../../utils/haptics';
65
+ import { Chip, type ChipSize, type ChipVariant } from '../chip';
66
+ import { Label } from '../label';
67
+
68
+ /** Matches Input and Combobox, so the three read as the same control focused. */
69
+ const FOCUS_DURATION = 150;
70
+ /** Long enough to see a tag arrive or leave, short enough not to queue up. */
71
+ const ENTER_DURATION = 140;
72
+ const EXIT_DURATION = 120;
73
+ const REFLOW_DURATION = 180;
74
+
75
+ const tagInputVariants = tv({
76
+ slots: {
77
+ container: 'w-full gap-1.5',
78
+ /*
79
+ * The border colour is animated between the resting and focused tokens, so
80
+ * it is deliberately absent from the class. `flex-wrap` is the load-bearing
81
+ * part: a tag field grows down, because the alternative is a row that
82
+ * scrolls sideways and hides the tags you just added.
83
+ */
84
+ field: 'w-full flex-row flex-wrap items-center rounded-lg border',
85
+ /*
86
+ * A length rather than a `text-*` step, for the reason Input gives: a step
87
+ * sets a size and a line height together, and the extra leading lands above
88
+ * the glyphs, so in a box of fixed height the text sits below the tags it
89
+ * is supposed to be level with.
90
+ */
91
+ input: 'min-w-24 flex-1 py-0 font-normal text-foreground',
92
+ clear: 'items-center justify-center rounded-full',
93
+ description: 'text-sm text-muted-foreground',
94
+ error: 'text-sm text-destructive',
95
+ count: 'text-xs text-muted-foreground',
96
+ },
97
+ variants: {
98
+ variant: {
99
+ outline: { field: 'bg-background' },
100
+ filled: { field: 'bg-muted' },
101
+ },
102
+ size: {
103
+ sm: {
104
+ field: 'gap-1 px-2.5 py-1.5',
105
+ input: 'h-7 text-[14px]',
106
+ clear: 'h-5 w-5',
107
+ },
108
+ md: {
109
+ field: 'gap-1.5 px-3 py-2',
110
+ input: 'h-8 text-[16px]',
111
+ clear: 'h-6 w-6',
112
+ },
113
+ lg: {
114
+ field: 'gap-2 px-3.5 py-2.5',
115
+ input: 'h-10 text-[16px]',
116
+ clear: 'h-7 w-7',
117
+ },
118
+ },
119
+ disabled: {
120
+ true: { field: 'opacity-[0.64]' },
121
+ },
122
+ },
123
+ defaultVariants: {
124
+ variant: 'outline',
125
+ size: 'md',
126
+ },
127
+ });
128
+
129
+ type TagInputVariantProps = VariantProps<typeof tagInputVariants>;
130
+ export type TagInputVariant = NonNullable<TagInputVariantProps['variant']>;
131
+ export type TagInputSize = NonNullable<TagInputVariantProps['size']>;
132
+
133
+ /** A tag sits inside the field, so it is a step smaller than the field is. */
134
+ const CHIP_SIZE: Record<TagInputSize, ChipSize> = {
135
+ sm: 'sm',
136
+ md: 'sm',
137
+ lg: 'md',
138
+ };
139
+
140
+ /** Why a tag was turned away, for a caller that wants to say so. */
141
+ export type TagRejection = 'duplicate' | 'max' | 'invalid';
142
+
143
+ /** What a field that loses focus mid-word does with the leftover text. */
144
+ export type TagBlurBehavior = 'add' | 'clear' | 'keep';
145
+
146
+ /**
147
+ * Splitting without a regex, so a delimiter never has to be escaped — `.` and
148
+ * `|` are ordinary characters to someone listing the separators they use.
149
+ */
150
+ function splitOn(text: string, delimiters: string[]): string[] {
151
+ let parts = [text];
152
+ for (const delimiter of delimiters) {
153
+ if (!delimiter) continue;
154
+ parts = parts.flatMap((part) => part.split(delimiter));
155
+ }
156
+ return parts;
157
+ }
158
+
159
+ export interface TagInputProps
160
+ extends Omit<
161
+ TextInputProps,
162
+ | 'value'
163
+ | 'defaultValue'
164
+ | 'onChangeText'
165
+ | 'editable'
166
+ | 'multiline'
167
+ | 'children'
168
+ >,
169
+ Omit<TagInputVariantProps, 'disabled'> {
170
+ /** Classes for the field box — the bordered container the tags sit in. */
171
+ className?: string;
172
+ /** Classes for the outer column that also holds the label and the error. */
173
+ containerClassName?: string;
174
+ /** The tags, controlled. Pair it with `onValueChange`. */
175
+ value?: string[];
176
+ /** The tags to start with, when the field keeps its own value. */
177
+ defaultValue?: string[];
178
+ /** Called with the whole list whenever a tag is added or removed. */
179
+ onValueChange?: (tags: string[]) => void;
180
+ /**
181
+ * The text being typed, controlled. Only needed to drive the draft from
182
+ * outside — the tags themselves are `value`.
183
+ */
184
+ inputValue?: string;
185
+ /** Called as the draft text changes, before it becomes a tag. */
186
+ onInputValueChange?: (text: string) => void;
187
+ /** The label above the field, and what the input is announced as. */
188
+ label?: string;
189
+ /** A line under the field, replaced by `errorMessage` when there is one. */
190
+ description?: string;
191
+ /** Error message. When set, the field renders in its invalid state. */
192
+ errorMessage?: string;
193
+ /** Marks the field required — an asterisk on the label, and the a11y state. */
194
+ isRequired?: boolean;
195
+ /** Dims the field and stops it being reached at all. */
196
+ disabled?: boolean;
197
+ /** Shows the tags but takes away the input and the ✕ on each one. */
198
+ readOnly?: boolean;
199
+ /**
200
+ * The most tags the field accepts. Past it nothing more is committed and
201
+ * `onReject` is called with `'max'`, unless `allowOverflow` is set.
202
+ */
203
+ max?: number;
204
+ /**
205
+ * Let the list go past `max` anyway. The field reports itself invalid while
206
+ * it is over, which is the point: some forms want the count shown as wrong
207
+ * rather than the typing refused.
208
+ */
209
+ allowOverflow?: boolean;
210
+ /** Accept a tag the list already holds. Off by default. */
211
+ allowDuplicates?: boolean;
212
+ /**
213
+ * Characters that end a tag as they are typed. A comma by default, which is
214
+ * what makes a pasted `a, b, c` land as three tags rather than one.
215
+ */
216
+ delimiters?: string[];
217
+ /**
218
+ * What happens to text still in the field when it loses focus. `add` commits
219
+ * it and clears it if it was accepted, leaving it in place if it was not, so
220
+ * a rejected word is still there to fix. `clear` drops it. `keep` leaves it
221
+ * exactly as typed.
222
+ */
223
+ blurBehavior?: TagBlurBehavior;
224
+ /**
225
+ * Decide whether a tag may be added, given the list it would join. Return
226
+ * `false` to turn it away — `onReject` is then called with `'invalid'`.
227
+ */
228
+ validate?: (tag: string, tags: string[]) => boolean;
229
+ /** Called when a tag was turned away, with the reason it was. */
230
+ onReject?: (tag: string, reason: TagRejection) => void;
231
+ /** Which Chip variant the tags are drawn as. */
232
+ chipVariant?: ChipVariant;
233
+ /** Draw the tag yourself — an avatar before the label, a count after it. */
234
+ renderTag?: (tag: string, index: number) => ReactNode;
235
+ /** A ✕ at the end of the field that empties it. */
236
+ clearable?: boolean;
237
+ /** Shows `3 / 8` under the field. Needs `max`. */
238
+ showCount?: boolean;
239
+ /**
240
+ * A tick under the finger as a tag lands or leaves. Off by default — needs
241
+ * the optional `expo-haptics`, and is silent without it.
242
+ */
243
+ haptics?: boolean;
244
+ }
245
+
246
+ export const TagInput = forwardRef<TextInput, TagInputProps>(
247
+ (
248
+ {
249
+ className,
250
+ containerClassName,
251
+ value,
252
+ defaultValue,
253
+ onValueChange,
254
+ inputValue,
255
+ onInputValueChange,
256
+ label,
257
+ description,
258
+ errorMessage,
259
+ isRequired,
260
+ disabled = false,
261
+ readOnly = false,
262
+ max,
263
+ allowOverflow = false,
264
+ allowDuplicates = false,
265
+ delimiters = [','],
266
+ blurBehavior = 'add',
267
+ validate,
268
+ onReject,
269
+ chipVariant = 'default',
270
+ renderTag,
271
+ clearable = false,
272
+ showCount = false,
273
+ haptics = false,
274
+ variant,
275
+ size = 'md',
276
+ placeholder,
277
+ onFocus,
278
+ onBlur,
279
+ onKeyPress,
280
+ onSubmitEditing,
281
+ accessibilityLabel,
282
+ ...props
283
+ },
284
+ ref
285
+ ) => {
286
+ const inputRef = useRef<TextInput>(null);
287
+ useImperativeHandle(ref, () => inputRef.current as TextInput);
288
+
289
+ const [ownTags, setOwnTags] = useState<string[]>(defaultValue ?? []);
290
+ const tags = value ?? ownTags;
291
+
292
+ const [ownText, setOwnText] = useState('');
293
+ const text = inputValue ?? ownText;
294
+
295
+ const [focused, setFocused] = useState(false);
296
+ /*
297
+ * Which tag a second backspace would take. An index rather than the tag
298
+ * itself, because with `allowDuplicates` two tags can read the same and
299
+ * marking "the last one" has to mean the last one.
300
+ */
301
+ const [marked, setMarked] = useState<number | null>(null);
302
+
303
+ const overflowing = max !== undefined && tags.length > max;
304
+ const invalid = !!errorMessage || overflowing;
305
+ const interactive = !disabled && !readOnly;
306
+
307
+ // The list can be emptied from outside, which would leave the mark pointing
308
+ // past the end of it — and a stale mark is a tag deleted by a backspace
309
+ // meant for the one that used to be there.
310
+ useEffect(() => {
311
+ if (marked !== null && marked >= tags.length) setMarked(null);
312
+ }, [marked, tags.length]);
313
+
314
+ const setText = useCallback(
315
+ (next: string) => {
316
+ if (inputValue === undefined) setOwnText(next);
317
+ onInputValueChange?.(next);
318
+ },
319
+ [inputValue, onInputValueChange]
320
+ );
321
+
322
+ const commit = useCallback(
323
+ (next: string[]) => {
324
+ if (value === undefined) setOwnTags(next);
325
+ onValueChange?.(next);
326
+ },
327
+ [value, onValueChange]
328
+ );
329
+
330
+ /**
331
+ * Adds every candidate that survives the rules, in order, and reports the
332
+ * ones that do not. Returns whether anything was actually taken, which is
333
+ * what tells the caller whether the draft text has been consumed.
334
+ */
335
+ const addTags = useCallback(
336
+ (candidates: string[]) => {
337
+ const incoming = candidates
338
+ .map((entry) => entry.trim())
339
+ .filter((entry) => entry.length > 0);
340
+ if (incoming.length === 0) return false;
341
+
342
+ const next = [...tags];
343
+ let added = false;
344
+
345
+ for (const tag of incoming) {
346
+ if (!allowDuplicates && next.includes(tag)) {
347
+ onReject?.(tag, 'duplicate');
348
+ continue;
349
+ }
350
+ if (max !== undefined && next.length >= max && !allowOverflow) {
351
+ onReject?.(tag, 'max');
352
+ continue;
353
+ }
354
+ if (validate && !validate(tag, next)) {
355
+ onReject?.(tag, 'invalid');
356
+ continue;
357
+ }
358
+ next.push(tag);
359
+ added = true;
360
+ }
361
+
362
+ if (!added) return false;
363
+ if (haptics) selectionTick();
364
+ commit(next);
365
+ return true;
366
+ },
367
+ [tags, allowDuplicates, max, allowOverflow, validate, onReject, haptics, commit]
368
+ );
369
+
370
+ const removeAt = useCallback(
371
+ (index: number) => {
372
+ if (index < 0 || index >= tags.length) return;
373
+ if (haptics) selectionTick();
374
+ commit(tags.filter((_, position) => position !== index));
375
+ },
376
+ [tags, haptics, commit]
377
+ );
378
+
379
+ const handleChangeText = useCallback(
380
+ (next: string) => {
381
+ // Any typing at all takes the mark off: the backspace that would have
382
+ // removed a tag has been overtaken by a new word.
383
+ setMarked(null);
384
+
385
+ if (delimiters.length > 0 && delimiters.some((entry) => next.includes(entry))) {
386
+ const parts = splitOn(next, delimiters);
387
+ // The last piece is what comes *after* the final delimiter — still
388
+ // being typed, so it stays in the field rather than becoming a tag.
389
+ const trailing = parts.pop() ?? '';
390
+ addTags(parts);
391
+ setText(trailing);
392
+ return;
393
+ }
394
+
395
+ setText(next);
396
+ },
397
+ [delimiters, addTags, setText]
398
+ );
399
+
400
+ const handleSubmit = useCallback<
401
+ NonNullable<TextInputProps['onSubmitEditing']>
402
+ >(
403
+ (event) => {
404
+ if (addTags([text])) setText('');
405
+ onSubmitEditing?.(event);
406
+ },
407
+ [addTags, text, setText, onSubmitEditing]
408
+ );
409
+
410
+ const handleKeyPress = useCallback(
411
+ (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
412
+ onKeyPress?.(event);
413
+ if (event.nativeEvent.key !== 'Backspace') {
414
+ setMarked(null);
415
+ return;
416
+ }
417
+ // There is still a character in front of the cursor: backspace means
418
+ // what it always means, and the tags are none of its business.
419
+ if (text.length > 0 || tags.length === 0) return;
420
+
421
+ if (marked !== null && marked < tags.length) {
422
+ removeAt(marked);
423
+ setMarked(null);
424
+ return;
425
+ }
426
+ setMarked(tags.length - 1);
427
+ },
428
+ [onKeyPress, text.length, tags.length, marked, removeAt]
429
+ );
430
+
431
+ const handleFocus = useCallback<NonNullable<TextInputProps['onFocus']>>(
432
+ (event) => {
433
+ setFocused(true);
434
+ onFocus?.(event);
435
+ },
436
+ [onFocus]
437
+ );
438
+
439
+ const handleBlur = useCallback<NonNullable<TextInputProps['onBlur']>>(
440
+ (event) => {
441
+ setFocused(false);
442
+ setMarked(null);
443
+
444
+ if (blurBehavior === 'clear') {
445
+ setText('');
446
+ } else if (blurBehavior === 'add') {
447
+ // Only cleared when it was taken. Text that was refused stays in the
448
+ // field, because a word that vanishes on blur looks like it was
449
+ // accepted and a rejected tag has to remain visible to be fixed.
450
+ if (addTags([text])) setText('');
451
+ }
452
+
453
+ onBlur?.(event);
454
+ },
455
+ [blurBehavior, setText, addTags, text, onBlur]
456
+ );
457
+
458
+ const focusField = useCallback(() => {
459
+ inputRef.current?.focus();
460
+ }, []);
461
+
462
+ const clear = useCallback(() => {
463
+ setText('');
464
+ setMarked(null);
465
+ commit([]);
466
+ inputRef.current?.focus();
467
+ }, [setText, commit]);
468
+
469
+ const slots = tagInputVariants({ variant, size, disabled });
470
+
471
+ const placeholderColor = useCSSVariable('--color-muted-foreground');
472
+ const restColor = useCSSVariable('--color-input');
473
+ const focusColor = useCSSVariable('--color-ring');
474
+ const errorColor = useCSSVariable('--color-destructive');
475
+ const mutedColor =
476
+ typeof placeholderColor === 'string' ? placeholderColor : '#737373';
477
+
478
+ /*
479
+ * One 0..1 value rather than a class per state: Uniwind can only swap a
480
+ * class wholesale, which is the snap this is here to avoid, and a shared
481
+ * value crosses between the two colours on the UI thread without a
482
+ * re-render for every frame of it.
483
+ */
484
+ const focus = useSharedValue(0);
485
+ useEffect(() => {
486
+ focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION });
487
+ }, [focused, focus]);
488
+
489
+ const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';
490
+ const active = invalid
491
+ ? typeof errorColor === 'string'
492
+ ? errorColor
493
+ : '#ef4444'
494
+ : typeof focusColor === 'string'
495
+ ? focusColor
496
+ : '#a3a3a3';
497
+ // An invalid field is tinted at rest too — the error is a fact about the
498
+ // value, not about whether the field happens to be focused.
499
+ const idle = invalid ? active : resting;
500
+
501
+ const borderStyle = useAnimatedStyle(() => ({
502
+ borderColor: interpolateColor(focus.value, [0, 1], [idle, active]),
503
+ }));
504
+
505
+ const hasContent = tags.length > 0 || text.length > 0;
506
+ const fieldLabel = accessibilityLabel ?? label ?? placeholder;
507
+
508
+ const countLine = useMemo(() => {
509
+ if (!showCount || max === undefined) return null;
510
+ return `${tags.length} / ${max}`;
511
+ }, [showCount, max, tags.length]);
512
+
513
+ return (
514
+ <View className={slots.container({ className: containerClassName })}>
515
+ {label ? (
516
+ <Label isRequired={isRequired} isInvalid={invalid} isDisabled={disabled}>
517
+ {label}
518
+ </Label>
519
+ ) : null}
520
+
521
+ <Pressable
522
+ onPress={focusField}
523
+ disabled={!interactive}
524
+ // The box is a way to reach the input, not a control of its own — the
525
+ // input below it already carries the label and the state.
526
+ accessible={false}
527
+ >
528
+ <Animated.View style={borderStyle} className={slots.field({ className })}>
529
+ {tags.map((tag, index) => (
530
+ <Animated.View
531
+ key={`${tag}-${index}`}
532
+ entering={FadeIn.duration(ENTER_DURATION)}
533
+ exiting={FadeOut.duration(EXIT_DURATION)}
534
+ layout={LinearTransition.duration(REFLOW_DURATION)}
535
+ >
536
+ {renderTag ? (
537
+ renderTag(tag, index)
538
+ ) : (
539
+ <Chip
540
+ size={CHIP_SIZE[size]}
541
+ // The marked tag turns destructive rather than growing an
542
+ // outline: it is about to be deleted, and that is the one
543
+ // colour in the theme that already means exactly that.
544
+ variant={marked === index ? 'destructive' : chipVariant}
545
+ onClose={interactive ? () => removeAt(index) : undefined}
546
+ closeLabel={`Remove ${tag}`}
547
+ haptics={haptics}
548
+ >
549
+ {tag}
550
+ </Chip>
551
+ )}
552
+ </Animated.View>
553
+ ))}
554
+
555
+ {readOnly ? null : (
556
+ <TextInput
557
+ ref={inputRef}
558
+ value={text}
559
+ onChangeText={handleChangeText}
560
+ onFocus={handleFocus}
561
+ onBlur={handleBlur}
562
+ onKeyPress={handleKeyPress}
563
+ onSubmitEditing={handleSubmit}
564
+ editable={!disabled}
565
+ // Return commits a tag and the field stays open for the next
566
+ // one — dismissing the keyboard after every tag would make
567
+ // adding three of them three separate visits to the field.
568
+ submitBehavior="submit"
569
+ returnKeyType="done"
570
+ // Android lays a single-line input's text against the top of
571
+ // its box unless told otherwise; iOS centres it. Without this
572
+ // the text sits above the tags on one platform and level with
573
+ // them on the other.
574
+ textAlignVertical="center"
575
+ placeholder={tags.length > 0 ? undefined : placeholder}
576
+ placeholderTextColor={mutedColor}
577
+ autoCapitalize="none"
578
+ autoCorrect={false}
579
+ autoComplete="off"
580
+ accessibilityLabel={fieldLabel}
581
+ accessibilityState={{ disabled }}
582
+ aria-required={isRequired}
583
+ aria-invalid={invalid}
584
+ className={slots.input()}
585
+ {...props}
586
+ />
587
+ )}
588
+
589
+ {clearable && hasContent && interactive ? (
590
+ <Pressable
591
+ accessibilityRole="button"
592
+ accessibilityLabel="Clear all tags"
593
+ hitSlop={8}
594
+ onPress={clear}
595
+ className={slots.clear()}
596
+ >
597
+ <XIcon size={16} color={mutedColor} />
598
+ </Pressable>
599
+ ) : null}
600
+ </Animated.View>
601
+ </Pressable>
602
+
603
+ {errorMessage ? (
604
+ <Text className={slots.error()}>{errorMessage}</Text>
605
+ ) : description ? (
606
+ <Text className={slots.description()}>{description}</Text>
607
+ ) : null}
608
+
609
+ {countLine ? (
610
+ <Text className={slots.count({ className: overflowing ? 'text-destructive' : undefined })}>
611
+ {countLine}
612
+ </Text>
613
+ ) : null}
614
+ </View>
615
+ );
616
+ }
617
+ );
618
+
619
+ TagInput.displayName = 'TagInput';