panelui-native 0.53.0 → 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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # PanelUI — React Native UI components for Expo, styled with Tailwind CSS
2
2
 
3
3
  **PanelUI** (`panelui-native`) is an accessible, high-performance React Native component
4
- library for Expo apps. 94 typed components — buttons, bottom sheets, dialogs, selects,
4
+ library for Expo apps. 96 typed components — buttons, bottom sheets, dialogs, selects,
5
5
  toasts, forms — styled with Tailwind CSS v4 and animated on the UI thread with Reanimated.
6
6
  Zero native code, so it runs in Expo Go.
7
7
 
@@ -177,6 +177,7 @@ you are done. Unstyled text on a white screen means the styles are not reaching
177
177
  | `Button` | Pressable action with variants, sizes, loading state and icon slots |
178
178
  | `ButtonGroup` | Several buttons drawn as one control |
179
179
  | `Calendar` | A month of days, for picking one, several, or a range |
180
+ | `CandlestickChart` | Open, high, low and close for a period, drawn as one mark |
180
181
  | `Card` | Content surface with header, body and footer |
181
182
  | `Carousel` | A run of slides, one at a time, dragged with a finger |
182
183
  | `Checkbox` | Animated checkbox, as a row or a selectable card |
@@ -248,6 +249,7 @@ you are done. Unstyled text on a white screen means the styles are not reaching
248
249
  | `Switch` | Animated on/off toggle |
249
250
  | `Table` | Rows and columns that stay lined up, with sortable headers |
250
251
  | `Tabs` | Segmented navigation with an animated indicator |
252
+ | `TagInput` | A field whose value is a list of tokens rather than a string |
251
253
  | `Task` | A step an agent is working through, with its files |
252
254
  | `TextAnimation` | Five ways a piece of text or a number arrives |
253
255
  | `Textarea` | Multi-line text field that can grow with its content |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panelui-native",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "High-performance React Native UI components for Expo. Semantic design tokens, powered by Uniwind (Tailwind v4) and Reanimated.",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",
@@ -489,6 +489,15 @@ function ComboboxRoot<Mode extends ComboboxMode = 'single'>({
489
489
  const [internalQuery, setInternalQuery] = useState(defaultInputValue);
490
490
  const query = inputValue !== undefined ? inputValue : internalQuery;
491
491
 
492
+ /*
493
+ * Which chip a second backspace would take, in `multiple` mode. A held
494
+ * backspace repeats, and a field that removed on the first one would empty
495
+ * itself in the time it takes to notice — the mark is the beat that lets you
496
+ * stop. It is an index rather than a value because the same label can appear
497
+ * twice once the caller allows a custom value that matches an option.
498
+ */
499
+ const [marked, setMarked] = useState<number | null>(null);
500
+
492
501
  /** The selection as a list, which is the shape everything downstream wants. */
493
502
  const values = useMemo(() => {
494
503
  if (selection == null) return [];
@@ -742,12 +751,38 @@ function ComboboxRoot<Mode extends ComboboxMode = 'single'>({
742
751
  [selection, commit]
743
752
  );
744
753
 
754
+ /**
755
+ * Removal by position, which is what the backspace mark holds. Removing by
756
+ * value would take both of a repeated label rather than the marked one.
757
+ */
758
+ const removeAt = useCallback(
759
+ (index: number) => {
760
+ const current = Array.isArray(selection) ? selection : [];
761
+ if (index < 0 || index >= current.length) return;
762
+ commit(
763
+ current.filter((_, position) => position !== index) as ComboboxSelection[Mode]
764
+ );
765
+ },
766
+ [selection, commit]
767
+ );
768
+
745
769
  const clear = useCallback(() => {
746
770
  setQuery('');
771
+ setMarked(null);
747
772
  commit((multiple ? [] : undefined) as ComboboxSelection[Mode]);
748
773
  inputRef.current?.focus();
749
774
  }, [setQuery, commit, multiple]);
750
775
 
776
+ /*
777
+ * The chips can also change from outside — a form reset, a pick undone in the
778
+ * list — which would leave the mark pointing past the end of them. A stale
779
+ * mark is a chip deleted by a backspace meant for the one that used to be
780
+ * there.
781
+ */
782
+ useEffect(() => {
783
+ if (marked !== null && marked >= values.length) setMarked(null);
784
+ }, [marked, values.length]);
785
+
751
786
  const context = useMemo<ComboboxContextValue>(
752
787
  () => ({ values, onSelect: (next) => select(next) }),
753
788
  [values, select]
@@ -847,10 +882,14 @@ function ComboboxRoot<Mode extends ComboboxMode = 'single'>({
847
882
  >
848
883
  <View className={slots.fieldContent()}>
849
884
  {multiple
850
- ? values.map((entry) => (
885
+ ? values.map((entry, index) => (
851
886
  <Chip
852
887
  key={entry}
853
888
  size="sm"
889
+ // The marked chip turns destructive rather than growing an
890
+ // outline: it is about to be deleted, and that is the one
891
+ // colour in the theme that already means exactly that.
892
+ variant={marked === index ? 'destructive' : 'default'}
854
893
  // A chip is `self-start` by default, which overrides the row's
855
894
  // `items-center` and leaves it riding high against the input.
856
895
  // Inside a field it is one of several things sharing a line.
@@ -867,6 +906,9 @@ function ComboboxRoot<Mode extends ComboboxMode = 'single'>({
867
906
  className={slots.input()}
868
907
  value={query}
869
908
  onChangeText={(next) => {
909
+ // Typing takes the mark off: the backspace that would have removed
910
+ // a chip has been overtaken by a new query.
911
+ setMarked(null);
870
912
  setQuery(next);
871
913
  if (!open) openList();
872
914
  }}
@@ -874,20 +916,31 @@ function ComboboxRoot<Mode extends ComboboxMode = 'single'>({
874
916
  setFocused(true);
875
917
  if (openOnFocus) openList();
876
918
  }}
877
- onBlur={() => setFocused(false)}
919
+ onBlur={() => {
920
+ setFocused(false);
921
+ setMarked(null);
922
+ }}
878
923
  onSubmitEditing={submit}
879
924
  onKeyPress={({ nativeEvent }) => {
880
- // Backspace on an empty field takes the last chip back — the same
881
- // reflex that deletes a character, extended to the thing in front
882
- // of the cursor when there is no character left to delete.
883
- if (
884
- multiple &&
885
- nativeEvent.key === 'Backspace' &&
886
- query.length === 0 &&
887
- values.length > 0
888
- ) {
889
- remove(values[values.length - 1]!);
925
+ if (!multiple) return;
926
+ if (nativeEvent.key !== 'Backspace') {
927
+ setMarked(null);
928
+ return;
929
+ }
930
+ // There is still a character in front of the cursor: backspace
931
+ // means what it always means, and the chips are none of its
932
+ // business.
933
+ if (query.length > 0 || values.length === 0) return;
934
+
935
+ // Backspace on an empty field reaches the thing in front of the
936
+ // cursor when there is no character left to delete — but it marks
937
+ // that chip first, and only the next one takes it.
938
+ if (marked !== null && marked < values.length) {
939
+ removeAt(marked);
940
+ setMarked(null);
941
+ return;
890
942
  }
943
+ setMarked(values.length - 1);
891
944
  }}
892
945
  editable={!disabled}
893
946
  // Android lays a single-line input's text against the top of its box
@@ -583,18 +583,32 @@ function SortableRoot({
583
583
 
584
584
  const indexOf = useCallback((id: string) => indices.get(id) ?? -1, [indices]);
585
585
 
586
+ /*
587
+ * Both maps are accumulated in a ref and then published, rather than built by
588
+ * reading the shared value back and spreading it. Every row reports its
589
+ * layout in the same batch on mount, and a write to `.value` is not visible
590
+ * to the next read in that batch — so a read-modify-write there has all the
591
+ * rows spreading the same empty map and only the last one surviving. A list
592
+ * that knows one row's height puts every slot a gap apart, and the first
593
+ * drag drops the row at the end of the list.
594
+ */
595
+ const measuredHeights = useRef<Record<string, number>>({});
596
+ const pinnedFlags = useRef<Record<string, boolean>>({});
597
+
586
598
  const measured = useCallback(
587
599
  (id: string, height: number) => {
588
- if (heights.value[id] === height) return;
589
- heights.value = { ...heights.value, [id]: height };
600
+ if (measuredHeights.current[id] === height) return;
601
+ measuredHeights.current = { ...measuredHeights.current, [id]: height };
602
+ heights.value = measuredHeights.current;
590
603
  },
591
604
  [heights]
592
605
  );
593
606
 
594
607
  const setPinned = useCallback(
595
608
  (id: string, next: boolean) => {
596
- if (Boolean(pinned.value[id]) === next) return;
597
- pinned.value = { ...pinned.value, [id]: next };
609
+ if (Boolean(pinnedFlags.current[id]) === next) return;
610
+ pinnedFlags.current = { ...pinnedFlags.current, [id]: next };
611
+ pinned.value = pinnedFlags.current;
598
612
  },
599
613
  [pinned]
600
614
  );
@@ -1031,10 +1045,15 @@ function SortableItem({
1031
1045
  * drop unreported; `seq` is what tells the two cases apart, because
1032
1046
  * the only interruption that should be ignored is the row being picked
1033
1047
  * up again.
1048
+ *
1049
+ * `activeId` is checked as well as `seq` because reporting the drop is
1050
+ * itself what interrupts the spring: the caller applies the reorder,
1051
+ * and the reset that follows puts `translate` back to rest, which ends
1052
+ * the animation and calls this a second time under the same `seq`.
1034
1053
  */
1035
1054
  const land = () => {
1036
1055
  'worklet';
1037
- if (dragSeq.value !== seq) return;
1056
+ if (dragSeq.value !== seq || activeId.value === null) return;
1038
1057
  activeId.value = null;
1039
1058
  runOnJS(notifySettled)(id);
1040
1059
  };
@@ -1142,17 +1161,27 @@ function SortableItem({
1142
1161
  * no way to discover it from the row. Moving by whole slots is published as
1143
1162
  * an accessibility action instead, which is the only path to reordering for
1144
1163
  * someone who is not dragging anything.
1164
+ *
1165
+ * The actions sit wherever the drag does. A handle list keeps them on the
1166
+ * grip, which is an element in its own right; put here they would never be
1167
+ * offered, because the actions of a view that is not itself an accessibility
1168
+ * element are not reachable, and a row full of text is not one. A long-press
1169
+ * list has no grip and gives the whole row to the drag, so the row becomes
1170
+ * the element — which is what a screen reader wants from a row in any case.
1145
1171
  */
1146
- const a11y = locked
1147
- ? undefined
1148
- : [
1172
+ const carriesActions = activation === 'longPress' && !locked;
1173
+
1174
+ const a11y = carriesActions
1175
+ ? [
1149
1176
  { name: 'moveUp', label: 'Move up' },
1150
1177
  { name: 'moveDown', label: 'Move down' },
1151
- ];
1178
+ ]
1179
+ : undefined;
1152
1180
 
1153
1181
  const row = (
1154
1182
  <Animated.View
1155
1183
  onLayout={onLayout}
1184
+ accessible={carriesActions}
1156
1185
  accessibilityActions={a11y}
1157
1186
  onAccessibilityAction={(event) => {
1158
1187
  if (event.nativeEvent.actionName === 'moveUp') step(id, -1);
@@ -1224,7 +1253,7 @@ function SortableHandle({
1224
1253
  accessibilityLabel = 'Drag to reorder',
1225
1254
  ...props
1226
1255
  }: SortableHandleProps) {
1227
- const { activation, disabled: rootDisabled } = useSortableRoot('Sortable.Handle');
1256
+ const { activation, disabled: rootDisabled, step } = useSortableRoot('Sortable.Handle');
1228
1257
  const item = useContext(SortableItemContext);
1229
1258
 
1230
1259
  /*
@@ -1235,17 +1264,44 @@ function SortableHandle({
1235
1264
  const muted = useCSSVariable('--color-muted-foreground');
1236
1265
  const tint = typeof muted === 'string' ? muted : undefined;
1237
1266
 
1267
+ const locked = rootDisabled || item?.disabled;
1268
+
1269
+ /*
1270
+ * `adjustable` promises an element that answers a swipe up or down, and the
1271
+ * promise was never kept: the grip published the role and nothing else, so
1272
+ * the one part of a row a screen reader could reach did nothing at all.
1273
+ * Moving by whole slots is what it was always meant to do. The same move is
1274
+ * offered as a named action too, because a swipe says nothing about which
1275
+ * way the row is going to travel.
1276
+ */
1277
+ const move = (delta: number) => {
1278
+ if (locked || !item) return;
1279
+ step(item.id, delta);
1280
+ };
1281
+
1238
1282
  const glyph = (
1239
1283
  <View
1240
1284
  accessible
1241
1285
  accessibilityRole="adjustable"
1242
1286
  accessibilityLabel={accessibilityLabel}
1243
- accessibilityState={{ disabled: rootDisabled || item?.disabled }}
1244
- className={cn(
1245
- 'items-center justify-center px-2 py-1.5',
1246
- (rootDisabled || item?.disabled) && 'opacity-40',
1247
- className
1248
- )}
1287
+ accessibilityState={{ disabled: locked }}
1288
+ accessibilityValue={item ? { text: `Position ${item.index + 1}` } : undefined}
1289
+ accessibilityActions={
1290
+ locked
1291
+ ? undefined
1292
+ : [
1293
+ { name: 'increment' },
1294
+ { name: 'decrement' },
1295
+ { name: 'moveUp', label: 'Move up' },
1296
+ { name: 'moveDown', label: 'Move down' },
1297
+ ]
1298
+ }
1299
+ onAccessibilityAction={(event) => {
1300
+ const action = event.nativeEvent.actionName;
1301
+ if (action === 'increment' || action === 'moveUp') move(-1);
1302
+ if (action === 'decrement' || action === 'moveDown') move(1);
1303
+ }}
1304
+ className={cn('items-center justify-center px-2 py-1.5', locked && 'opacity-40', className)}
1249
1305
  {...props}
1250
1306
  >
1251
1307
  <IconColorProvider color={tint}>
@@ -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';
package/src/index.ts CHANGED
@@ -514,6 +514,14 @@ export {
514
514
  type PieDatum,
515
515
  } from './components/pie-chart';
516
516
  export { Textarea, type TextareaProps } from './components/textarea';
517
+ export {
518
+ TagInput,
519
+ type TagInputProps,
520
+ type TagInputVariant,
521
+ type TagInputSize,
522
+ type TagBlurBehavior,
523
+ type TagRejection,
524
+ } from './components/tag-input';
517
525
  export {
518
526
  Item,
519
527
  type ItemProps,