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 +3 -1
- package/package.json +1 -1
- package/src/components/combobox/index.tsx +65 -12
- package/src/components/sortable/index.tsx +72 -16
- package/src/components/tag-input/index.tsx +619 -0
- package/src/index.ts +8 -0
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.
|
|
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.
|
|
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={() =>
|
|
919
|
+
onBlur={() => {
|
|
920
|
+
setFocused(false);
|
|
921
|
+
setMarked(null);
|
|
922
|
+
}}
|
|
878
923
|
onSubmitEditing={submit}
|
|
879
924
|
onKeyPress={({ nativeEvent }) => {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
)
|
|
889
|
-
|
|
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 (
|
|
589
|
-
|
|
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(
|
|
597
|
-
|
|
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
|
|
1147
|
-
|
|
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:
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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,
|