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.
- package/README.md +4 -1
- package/lib/module/components/candlestick-chart/index.js +1161 -0
- package/lib/module/components/candlestick-chart/index.js.map +1 -0
- package/lib/module/components/combobox/index.js +73 -7
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/context-menu/index.js +529 -0
- package/lib/module/components/context-menu/index.js.map +1 -0
- package/lib/module/components/menu/index.js +18 -10
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/popover/index.js +71 -7
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/sortable/index.js +159 -23
- package/lib/module/components/sortable/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +56 -15
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +295 -31
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
- package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
- package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts +20 -20
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +51 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sortable/index.d.ts +13 -3
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +34 -2
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/candlestick-chart/index.tsx +1360 -0
- package/src/components/combobox/index.tsx +149 -18
- package/src/components/context-menu/index.tsx +658 -0
- package/src/components/menu/index.tsx +17 -10
- package/src/components/popover/index.tsx +94 -6
- package/src/components/sortable/index.tsx +278 -36
- package/src/components/tabs/index.tsx +82 -16
- package/src/components/tag-input/index.tsx +619 -0
- package/src/components/time-picker/index.tsx +330 -35
- 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';
|