@expokit/cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +20 -2
  2. package/bin/expo-kit.js +219 -44
  3. package/bin/lib.mjs +110 -1
  4. package/bin/mcp.mjs +48 -19
  5. package/package.json +9 -4
  6. package/registry/_docs/accordion.json +16 -0
  7. package/registry/_docs/action-sheet.json +28 -0
  8. package/registry/_docs/alert.json +28 -0
  9. package/registry/_docs/assistant-app.json +47 -0
  10. package/registry/_docs/avatar.json +25 -1
  11. package/registry/_docs/badge.json +36 -5
  12. package/registry/_docs/bottom-nav.json +30 -6
  13. package/registry/_docs/bottom-sheet.json +28 -0
  14. package/registry/_docs/bullet.json +3 -0
  15. package/registry/_docs/button-3d.json +18 -2
  16. package/registry/_docs/button.json +16 -0
  17. package/registry/_docs/calendar.json +21 -5
  18. package/registry/_docs/card-stack.json +16 -0
  19. package/registry/_docs/card.json +20 -0
  20. package/registry/_docs/carousel.json +20 -0
  21. package/registry/_docs/chat.json +184 -0
  22. package/registry/_docs/checkbox.json +16 -0
  23. package/registry/_docs/checkout.json +10 -1
  24. package/registry/_docs/chip-group.json +16 -0
  25. package/registry/_docs/confetti.json +23 -3
  26. package/registry/_docs/coupon.json +16 -0
  27. package/registry/_docs/cube-pager.json +16 -0
  28. package/registry/_docs/dashboard.json +3 -0
  29. package/registry/_docs/date-picker.json +24 -4
  30. package/registry/_docs/dialog.json +24 -0
  31. package/registry/_docs/donut-shop.json +88 -0
  32. package/registry/_docs/drawer.json +32 -4
  33. package/registry/_docs/fab.json +24 -0
  34. package/registry/_docs/fintech-app.json +49 -0
  35. package/registry/_docs/flip-card.json +16 -0
  36. package/registry/_docs/floating-input.json +25 -5
  37. package/registry/_docs/food-app.json +47 -0
  38. package/registry/_docs/gift-card.json +16 -0
  39. package/registry/_docs/glass-button.json +24 -0
  40. package/registry/_docs/glass-nav-bar.json +24 -0
  41. package/registry/_docs/glass-search-bar.json +24 -0
  42. package/registry/_docs/glass-segmented.json +28 -0
  43. package/registry/_docs/glass-sheet.json +28 -0
  44. package/registry/_docs/glass-tab-bar.json +24 -0
  45. package/registry/_docs/glass.json +17 -1
  46. package/registry/_docs/grain.json +80 -0
  47. package/registry/_docs/inbox.json +3 -0
  48. package/registry/_docs/isometric-bar.json +3 -0
  49. package/registry/_docs/kit.json +3 -0
  50. package/registry/_docs/liquidglass-tab.json +3 -0
  51. package/registry/_docs/list-item.json +16 -0
  52. package/registry/_docs/loyalty-card.json +16 -0
  53. package/registry/_docs/morphing-button-group.json +96 -0
  54. package/registry/_docs/number-ticker.json +16 -0
  55. package/registry/_docs/offers.json +3 -0
  56. package/registry/_docs/onboarding.json +3 -0
  57. package/registry/_docs/orb-reveal.json +132 -0
  58. package/registry/_docs/otp-input.json +28 -4
  59. package/registry/_docs/parallax-layers.json +20 -0
  60. package/registry/_docs/parallax-scroll.json +24 -0
  61. package/registry/_docs/password-input.json +23 -3
  62. package/registry/_docs/payment-card.json +16 -0
  63. package/registry/_docs/pixel-bar.json +3 -0
  64. package/registry/_docs/polar-area.json +3 -0
  65. package/registry/_docs/profile.json +3 -0
  66. package/registry/_docs/progress-steps.json +24 -0
  67. package/registry/_docs/progress.json +20 -0
  68. package/registry/_docs/qr-code-button.json +122 -0
  69. package/registry/_docs/radio-group.json +20 -0
  70. package/registry/_docs/rating.json +24 -0
  71. package/registry/_docs/ring-carousel.json +20 -0
  72. package/registry/_docs/scratch-card.json +20 -0
  73. package/registry/_docs/search-bar.json +23 -3
  74. package/registry/_docs/search-results.json +3 -0
  75. package/registry/_docs/segmented-control.json +20 -0
  76. package/registry/_docs/select.json +33 -8
  77. package/registry/_docs/settings.json +9 -0
  78. package/registry/_docs/sign-in.json +3 -0
  79. package/registry/_docs/skeleton.json +22 -2
  80. package/registry/_docs/slide-to-confirm.json +24 -0
  81. package/registry/_docs/slider.json +27 -7
  82. package/registry/_docs/spin-wheel.json +24 -0
  83. package/registry/_docs/spinner.json +41 -11
  84. package/registry/_docs/stateful-button.json +184 -0
  85. package/registry/_docs/stepper.json +23 -7
  86. package/registry/_docs/swipe-deck.json +16 -0
  87. package/registry/_docs/swipeable-row.json +24 -1
  88. package/registry/_docs/switch.json +20 -0
  89. package/registry/_docs/tabs.json +24 -0
  90. package/registry/_docs/text-animation.json +141 -0
  91. package/registry/_docs/textarea.json +18 -2
  92. package/registry/_docs/ticket-card.json +20 -0
  93. package/registry/_docs/ticket-shape.json +4 -1
  94. package/registry/_docs/tilt-card.json +20 -4
  95. package/registry/_docs/time-picker.json +22 -6
  96. package/registry/_docs/timeline-chart.json +3 -0
  97. package/registry/_docs/timeline.json +18 -2
  98. package/registry/_docs/toast.json +32 -8
  99. package/registry/_docs/treemap.json +3 -0
  100. package/registry/_docs/waterfall.json +3 -0
  101. package/registry/_docs/wheel-list.json +20 -0
  102. package/registry/accordion/files/src/components/ui/accordion.tsx +5 -4
  103. package/registry/accordion/files/src/lib/kit.tsx +8 -5
  104. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +9 -8
  105. package/registry/action-sheet/files/src/lib/kit.tsx +8 -5
  106. package/registry/alert/files/src/components/ui/alert.tsx +7 -4
  107. package/registry/alert/files/src/components/ui/button.tsx +4 -3
  108. package/registry/alert/files/src/lib/kit.tsx +8 -5
  109. package/registry/assistant-app.json +49 -0
  110. package/registry/avatar/files/src/components/ui/avatar.tsx +7 -4
  111. package/registry/avatar/files/src/lib/kit.tsx +8 -5
  112. package/registry/badge/files/src/components/ui/badge.tsx +345 -21
  113. package/registry/badge/files/src/lib/kit.tsx +8 -5
  114. package/registry/badge.json +1 -1
  115. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +14 -7
  116. package/registry/bottom-nav/files/src/lib/kit.tsx +8 -5
  117. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -6
  118. package/registry/bottom-sheet/files/src/lib/kit.tsx +8 -5
  119. package/registry/button/files/src/components/ui/button.tsx +4 -3
  120. package/registry/button/files/src/lib/kit.tsx +8 -5
  121. package/registry/button-3d/files/src/components/ui/button-3d.tsx +3 -2
  122. package/registry/button-3d/files/src/lib/kit.tsx +8 -5
  123. package/registry/calendar/files/src/components/ui/calendar.tsx +9 -5
  124. package/registry/calendar/files/src/lib/kit.tsx +8 -5
  125. package/registry/card/files/src/components/ui/card.tsx +8 -6
  126. package/registry/card/files/src/lib/kit.tsx +8 -5
  127. package/registry/chat.json +18 -0
  128. package/registry/checkbox/files/src/components/ui/checkbox.tsx +6 -5
  129. package/registry/checkbox/files/src/lib/kit.tsx +8 -5
  130. package/registry/chip-group/files/src/components/ui/chip-group.tsx +4 -2
  131. package/registry/chip-group/files/src/lib/kit.tsx +8 -5
  132. package/registry/confetti/files/src/lib/kit.tsx +8 -5
  133. package/registry/coupon/files/src/components/ui/coupon.tsx +23 -20
  134. package/registry/coupon/files/src/components/ui/flip-card.tsx +1 -1
  135. package/registry/coupon/files/src/components/ui/tilt-card.tsx +2 -2
  136. package/registry/coupon/files/src/lib/kit.tsx +8 -5
  137. package/registry/date-picker/files/src/components/ui/calendar.tsx +9 -5
  138. package/registry/date-picker/files/src/components/ui/date-picker.tsx +2 -2
  139. package/registry/date-picker/files/src/lib/kit.tsx +8 -5
  140. package/registry/dialog/files/src/components/ui/dialog.tsx +12 -6
  141. package/registry/dialog/files/src/lib/kit.tsx +8 -5
  142. package/registry/donut-shop.json +18 -0
  143. package/registry/drawer/files/src/components/ui/drawer.tsx +25 -19
  144. package/registry/drawer/files/src/lib/kit.tsx +8 -5
  145. package/registry/fintech-app.json +57 -0
  146. package/registry/flip-card/files/src/components/ui/flip-card.tsx +1 -1
  147. package/registry/flip-card/files/src/lib/kit.tsx +8 -5
  148. package/registry/floating-input/files/src/components/ui/floating-input.tsx +16 -6
  149. package/registry/floating-input/files/src/lib/kit.tsx +8 -5
  150. package/registry/food-app.json +55 -0
  151. package/registry/grain/files/src/components/ui/grain.tsx +113 -0
  152. package/registry/{tag-input → grain}/files/src/lib/kit.tsx +8 -5
  153. package/registry/grain.json +17 -0
  154. package/registry/kit/files/src/lib/kit.tsx +8 -5
  155. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -1
  156. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +1 -1
  157. package/registry/list-item/files/src/components/ui/list-item.tsx +20 -16
  158. package/registry/list-item/files/src/lib/kit.tsx +8 -5
  159. package/registry/morphing-button-group.json +18 -0
  160. package/registry/orb-reveal.json +20 -0
  161. package/registry/otp-input/files/src/components/ui/otp-input.tsx +8 -6
  162. package/registry/otp-input/files/src/lib/kit.tsx +8 -5
  163. package/registry/password-input/files/src/components/ui/floating-input.tsx +16 -6
  164. package/registry/password-input/files/src/components/ui/password-input.tsx +2 -2
  165. package/registry/password-input/files/src/lib/kit.tsx +8 -5
  166. package/registry/payment-card/files/src/components/ui/flip-card.tsx +1 -1
  167. package/registry/payment-card/files/src/components/ui/payment-card.tsx +27 -14
  168. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +2 -2
  169. package/registry/payment-card/files/src/lib/kit.tsx +8 -5
  170. package/registry/progress/files/src/components/ui/progress.tsx +8 -5
  171. package/registry/progress/files/src/lib/kit.tsx +8 -5
  172. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +15 -11
  173. package/registry/progress-steps/files/src/lib/kit.tsx +8 -5
  174. package/registry/qr-code-button.json +18 -0
  175. package/registry/radio-group/files/src/components/ui/radio-group.tsx +10 -9
  176. package/registry/radio-group/files/src/lib/kit.tsx +8 -5
  177. package/registry/search-bar/files/src/components/ui/search-bar.tsx +13 -11
  178. package/registry/search-bar/files/src/lib/kit.tsx +8 -5
  179. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +4 -2
  180. package/registry/segmented-control/files/src/lib/kit.tsx +8 -5
  181. package/registry/select/files/src/components/ui/select.tsx +3 -3
  182. package/registry/select/files/src/lib/kit.tsx +8 -5
  183. package/registry/skeleton/files/src/components/ui/skeleton.tsx +4 -4
  184. package/registry/skeleton/files/src/lib/kit.tsx +8 -5
  185. package/registry/slider/files/src/components/ui/slider.tsx +5 -4
  186. package/registry/slider/files/src/lib/kit.tsx +8 -5
  187. package/registry/spinner/files/src/components/ui/spinner.tsx +452 -34
  188. package/registry/spinner/files/src/lib/kit.tsx +8 -5
  189. package/registry/spinner.json +1 -1
  190. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
  191. package/registry/{pagination-dots → stateful-button}/files/src/lib/kit.tsx +8 -5
  192. package/registry/stateful-button.json +18 -0
  193. package/registry/stepper/files/src/components/ui/stepper.tsx +8 -4
  194. package/registry/stepper/files/src/lib/kit.tsx +8 -5
  195. package/registry/switch/files/src/components/ui/switch.tsx +4 -3
  196. package/registry/switch/files/src/lib/kit.tsx +8 -5
  197. package/registry/tabs/files/src/components/ui/tabs.tsx +10 -8
  198. package/registry/tabs/files/src/lib/kit.tsx +8 -5
  199. package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
  200. package/registry/text-animation/files/src/lib/kit.tsx +222 -0
  201. package/registry/text-animation.json +15 -0
  202. package/registry/textarea/files/src/components/ui/textarea.tsx +11 -9
  203. package/registry/textarea/files/src/lib/kit.tsx +8 -5
  204. package/registry/ticket-shape/files/src/lib/kit.tsx +8 -5
  205. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +2 -2
  206. package/registry/tilt-card/files/src/lib/kit.tsx +8 -5
  207. package/registry/time-picker/files/src/components/ui/time-picker.tsx +2 -2
  208. package/registry/time-picker/files/src/lib/kit.tsx +8 -5
  209. package/registry/timeline/files/src/components/ui/avatar.tsx +7 -4
  210. package/registry/timeline/files/src/components/ui/timeline.tsx +12 -10
  211. package/registry/timeline/files/src/lib/kit.tsx +8 -5
  212. package/registry/toast/files/src/components/ui/button.tsx +4 -3
  213. package/registry/toast/files/src/components/ui/toast.tsx +13 -11
  214. package/registry/toast/files/src/lib/kit.tsx +8 -5
  215. package/schema/components.schema.json +1 -1
  216. package/registry/_docs/color-swatch.json +0 -66
  217. package/registry/_docs/kpi-card.json +0 -11
  218. package/registry/_docs/like-button.json +0 -72
  219. package/registry/_docs/morph-button.json +0 -108
  220. package/registry/_docs/pagination-dots.json +0 -88
  221. package/registry/_docs/shimmer-button.json +0 -78
  222. package/registry/_docs/tag-input.json +0 -128
  223. package/registry/color-swatch.json +0 -17
  224. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +0 -45
  225. package/registry/kpi-card.json +0 -14
  226. package/registry/like-button.json +0 -18
  227. package/registry/morph-button.json +0 -18
  228. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +0 -262
  229. package/registry/pagination-dots.json +0 -15
  230. package/registry/shimmer-button.json +0 -17
  231. package/registry/tag-input/files/src/components/ui/tag-input.tsx +0 -720
  232. package/registry/tag-input.json +0 -17
@@ -1,720 +0,0 @@
1
- import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
2
- import {
3
- Animated,
4
- Easing,
5
- Platform,
6
- Pressable,
7
- ScrollView,
8
- StyleSheet,
9
- Text,
10
- TextInput,
11
- View,
12
- type StyleProp,
13
- type TextInputProps,
14
- type ViewStyle,
15
- } from 'react-native';
16
- import Svg, { Circle, Path } from 'react-native-svg';
17
-
18
- import { alpha, motion, readableOn, useKit } from '@/lib/kit';
19
-
20
- export type TagInputVariant = 'outlined' | 'filled';
21
- export type TagChipVariant = 'soft' | 'solid' | 'outline';
22
-
23
- export interface TagInputProps
24
- extends Omit<TextInputProps, 'style' | 'value' | 'defaultValue' | 'onChange' | 'onChangeText' | 'multiline'> {
25
- value?: string[];
26
- defaultValue?: string[];
27
- onChange?: (tags: string[]) => void;
28
- /** Field style. */
29
- variant?: TagInputVariant;
30
- /** Chip style. */
31
- chipVariant?: TagChipVariant;
32
- /** Tint each chip with a colour picked from a hash of its label. */
33
- colorize?: boolean;
34
- /** Text drawn before every chip label, e.g. '#'. Typed copies of it are stripped. */
35
- prefix?: string;
36
- /** Normalise a tag before it is validated and added (e.g. lowercase, strip spaces). */
37
- transform?: (tag: string) => string;
38
- /** Stop accepting tags after this many (a "3/5" counter appears under the field). */
39
- maxTags?: number;
40
- /** Tappable suggestions: a row of pills while the field is empty, a list with highlighted matches while typing. */
41
- suggestions?: string[];
42
- /** Characters that commit the typed text as a tag (Enter always does). Default [',']. */
43
- separators?: string[];
44
- /** Return false (or an error message) to reject a tag — the field shakes. */
45
- validate?: (tag: string, tags: string[]) => boolean | string;
46
- /** Hint under the field (hidden while an error is shown). */
47
- helper?: string;
48
- error?: string;
49
- /** Focus and chip colour (default: theme accent). */
50
- accent?: string;
51
- style?: StyleProp<ViewStyle>;
52
- }
53
-
54
- const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
55
- const PALETTE = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#0ea5e9', '#8b5cf6', '#ef4444', '#14b8a6'];
56
- const hashColor = (s: string) => {
57
- let h = 0;
58
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.toLowerCase().charCodeAt(i)) | 0;
59
- return PALETTE[Math.abs(h) % PALETTE.length];
60
- };
61
- /**
62
- * The order chips are drawn in: the current tags, plus tags that were just removed (and are still
63
- * shrinking out) slotted back in after the chip they used to follow.
64
- */
65
- const mergeOrder = (prev: string[], tags: string[]) => {
66
- const out = [...tags];
67
- prev.forEach((t, i) => {
68
- if (out.includes(t)) return;
69
- let at = 0;
70
- for (let j = i - 1; j >= 0; j--) {
71
- const k = out.indexOf(prev[j]);
72
- if (k !== -1) {
73
- at = k + 1;
74
- break;
75
- }
76
- }
77
- out.splice(at, 0, t);
78
- });
79
- return out;
80
- };
81
- const webInput = Platform.OS === 'web' ? ({ outlineStyle: 'none', outlineWidth: 0 } as object) : null;
82
-
83
- /**
84
- * Type and press Enter (or a comma) to turn text into chips. Chips pop in with a spring and
85
- * shrink out when removed; backspace on an empty field first highlights the last chip, a
86
- * second press removes it. Duplicates bump the existing chip, invalid tags shake the field,
87
- * and suggestions appear as pills or as a filtered list with the typed text highlighted.
88
- */
89
- export const TagInput = forwardRef<TextInput, TagInputProps>(function TagInput(
90
- {
91
- value,
92
- defaultValue,
93
- onChange,
94
- variant = 'outlined',
95
- chipVariant = 'soft',
96
- colorize,
97
- prefix,
98
- transform,
99
- maxTags,
100
- suggestions,
101
- separators = [','],
102
- validate,
103
- helper,
104
- error,
105
- accent,
106
- style,
107
- placeholder = 'Add a tag…',
108
- editable = true,
109
- onFocus,
110
- onBlur,
111
- onKeyPress,
112
- onSubmitEditing,
113
- ...props
114
- },
115
- ref
116
- ) {
117
- const { colors, radius } = useKit();
118
- const tint = accent ?? colors.accent;
119
- const inputRef = useRef<TextInput>(null);
120
- useImperativeHandle(ref, () => inputRef.current as TextInput);
121
-
122
- const [inner, setInner] = useState<string[]>(defaultValue ?? []);
123
- const tags = value ?? inner;
124
- const tagsRef = useRef(tags);
125
- tagsRef.current = tags;
126
- const [text, setText] = useState('');
127
- const [focused, setFocused] = useState(false);
128
- // Removed tags stay in `order` until their chip has shrunk out, whether they were removed with
129
- // the ✕, backspace, or by the parent changing `value`. Leaving = in `order` but not in `tags`.
130
- const [order, setOrder] = useState<string[]>(tags);
131
- const [synced, setSynced] = useState(tags);
132
- let display = order;
133
- if (synced !== tags && (synced.length !== tags.length || synced.some((t, i) => t !== tags[i]))) {
134
- display = mergeOrder(order, tags);
135
- setSynced(tags);
136
- setOrder(display);
137
- }
138
- const leaving = display.filter((t) => !tags.includes(t));
139
- const mounted = useRef(true);
140
- const [bumps, setBumps] = useState<Record<string, number>>({});
141
- const [armed, setArmed] = useState<string | null>(null); // last chip highlighted by the first backspace
142
- const [rejected, setRejected] = useState<string>(); // message returned by validate
143
- const full = maxTags !== undefined && tags.length >= maxTags;
144
- const shownError = error ?? rejected;
145
-
146
- const focus = useRef(new Animated.Value(0)).current; // JS: border colour
147
- const halo = useRef(new Animated.Value(0)).current; // native: focus halo
148
- const shake = useRef(new Animated.Value(0)).current; // native
149
- useEffect(() => {
150
- Animated.timing(focus, { toValue: focused ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
151
- Animated.spring(halo, { toValue: focused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
152
- }, [focused, focus, halo]);
153
- useEffect(() => {
154
- mounted.current = true;
155
- return () => {
156
- mounted.current = false;
157
- focus.stopAnimation();
158
- halo.stopAnimation();
159
- shake.stopAnimation();
160
- };
161
- }, [focus, halo, shake]);
162
-
163
- const nudge = () => {
164
- shake.setValue(0);
165
- Animated.sequence(
166
- [8, -6, 4, -2, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 50, useNativeDriver: true }))
167
- ).start();
168
- };
169
- useEffect(() => {
170
- if (error) nudge();
171
- // eslint-disable-next-line react-hooks/exhaustive-deps
172
- }, [error]);
173
-
174
- const commit = (next: string[]) => {
175
- tagsRef.current = next;
176
- if (value === undefined) setInner(next);
177
- onChange?.(next);
178
- };
179
-
180
- const clean = (raw: string) => {
181
- let tag = raw.trim();
182
- if (prefix) while (tag.startsWith(prefix)) tag = tag.slice(prefix.length);
183
- return transform ? transform(tag).trim() : tag;
184
- };
185
-
186
- /** Adds each candidate; returns true if anything was added. */
187
- const add = (candidates: string[]) => {
188
- let next = [...tagsRef.current];
189
- let added = false;
190
- setArmed(null);
191
- for (const raw of candidates) {
192
- const tag = clean(raw);
193
- if (!tag) continue;
194
- const exiting = leaving.find((t) => same(t, tag) && !next.includes(t));
195
- if (exiting && (maxTags === undefined || next.length < maxTags)) {
196
- // Re-added while shrinking out: put it back where it was so the chip grows back in place.
197
- const before = display.slice(0, display.indexOf(exiting)).filter((t) => next.includes(t)).length;
198
- next = [...next.slice(0, before), exiting, ...next.slice(before)];
199
- added = true;
200
- continue;
201
- }
202
- const existing = next.find((t) => same(t, tag));
203
- if (existing) {
204
- setBumps((b) => ({ ...b, [existing]: (b[existing] ?? 0) + 1 }));
205
- continue;
206
- }
207
- if (maxTags !== undefined && next.length >= maxTags) {
208
- nudge();
209
- break;
210
- }
211
- const verdict = validate ? validate(tag, next) : true;
212
- if (verdict !== true) {
213
- if (typeof verdict === 'string') setRejected(verdict);
214
- nudge();
215
- continue;
216
- }
217
- setRejected(undefined);
218
- next = [...next, tag];
219
- added = true;
220
- }
221
- if (added) commit(next);
222
- return added;
223
- };
224
-
225
- // The tag leaves `value` at once; its chip keeps rendering (see `order`) until it has shrunk out.
226
- const remove = (tag: string) => {
227
- setArmed(null);
228
- if (tagsRef.current.includes(tag)) commit(tagsRef.current.filter((t) => t !== tag));
229
- };
230
- const exited = (tag: string) => {
231
- if (!mounted.current) return;
232
- setOrder((o) => o.filter((t) => t !== tag || tagsRef.current.includes(t)));
233
- };
234
-
235
- const onText = (next: string) => {
236
- setArmed(null);
237
- if (rejected) setRejected(undefined);
238
- const sep = separators.find((s) => next.includes(s));
239
- if (!sep) return setText(next);
240
- const parts = next.split(new RegExp(`[${separators.map((s) => s.replace(/[\\\]^-]/g, '\\$&')).join('')}]`));
241
- const rest = parts.pop() ?? '';
242
- add(parts);
243
- setText(rest);
244
- };
245
-
246
- const visible = tags;
247
- const query = clean(text).toLowerCase();
248
- const filtered = (suggestions ?? [])
249
- .filter((s) => !visible.some((t) => same(t, s)))
250
- .filter((s) => !query || s.toLowerCase().includes(query));
251
- const typing = !!query && focused;
252
- const exact = filtered.some((s) => same(s, clean(text)));
253
-
254
- const active = shownError ? colors.danger : tint;
255
- const borderColor = focus.interpolate({
256
- inputRange: [0, 1],
257
- outputRange: [shownError ? colors.danger : variant === 'filled' ? alpha(colors.input, 0) : colors.input, active],
258
- });
259
- const message = shownError ?? helper;
260
-
261
- return (
262
- <Animated.View style={[{ transform: [{ translateX: shake }] }, style]}>
263
- <Pressable onPress={() => inputRef.current?.focus()} disabled={!editable} accessible={false} style={{ opacity: editable ? 1 : 0.5 }}>
264
- <Animated.View
265
- pointerEvents="none"
266
- style={[
267
- styles.halo,
268
- {
269
- borderRadius: radius.md + 4,
270
- borderColor: alpha(active, 0.2),
271
- opacity: halo,
272
- transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.98, 1] }) }],
273
- },
274
- ]}
275
- />
276
- <Animated.View
277
- style={[
278
- styles.field,
279
- { borderColor, borderRadius: radius.md, backgroundColor: variant === 'filled' ? colors.muted : colors.background },
280
- ]}
281
- >
282
- {display.map((tag) => (
283
- <Chip
284
- key={tag}
285
- label={tag}
286
- prefix={prefix}
287
- variant={chipVariant}
288
- tint={colorize ? hashColor(tag) : tint}
289
- leaving={leaving.includes(tag)}
290
- armed={armed === tag}
291
- bump={bumps[tag] ?? 0}
292
- disabled={!editable}
293
- onRemove={() => remove(tag)}
294
- onExited={() => exited(tag)}
295
- />
296
- ))}
297
- <TextInput
298
- ref={inputRef}
299
- accessibilityLabel={props.accessibilityLabel ?? placeholder}
300
- autoCapitalize="none"
301
- autoCorrect={false}
302
- {...props}
303
- value={text}
304
- editable={editable && !full}
305
- onChangeText={onText}
306
- placeholder={full ? `Limit of ${maxTags} reached` : visible.length ? '' : placeholder}
307
- placeholderTextColor={colors.mutedForeground}
308
- selectionColor={tint}
309
- cursorColor={tint}
310
- submitBehavior="submit"
311
- returnKeyType="done"
312
- onSubmitEditing={(e) => {
313
- add([text]);
314
- setText('');
315
- onSubmitEditing?.(e);
316
- }}
317
- onKeyPress={(e) => {
318
- if (e.nativeEvent.key === 'Backspace' && !text && visible.length) {
319
- const last = visible[visible.length - 1];
320
- // First press highlights the last chip, the second removes it.
321
- if (armed === last) remove(last);
322
- else setArmed(last);
323
- }
324
- onKeyPress?.(e);
325
- }}
326
- onFocus={(e) => {
327
- setFocused(true);
328
- onFocus?.(e);
329
- }}
330
- onBlur={(e) => {
331
- setFocused(false);
332
- setArmed(null);
333
- if (text.trim()) {
334
- add([text]);
335
- setText('');
336
- }
337
- onBlur?.(e);
338
- }}
339
- style={[styles.input, { color: colors.foreground }, full && styles.inputFull, webInput]}
340
- />
341
- </Animated.View>
342
- </Pressable>
343
-
344
- {typing && !full && (filtered.length || !exact) && suggestions?.length ? (
345
- <Appear key="list" style={[styles.list, { backgroundColor: colors.popover, borderColor: colors.border, borderRadius: radius.md }]}>
346
- {filtered.slice(0, 5).map((s) => {
347
- const i = s.toLowerCase().indexOf(query);
348
- return (
349
- <Pressable
350
- key={s}
351
- accessibilityRole="button"
352
- accessibilityLabel={`Add ${s}`}
353
- onPress={() => {
354
- add([s]);
355
- setText('');
356
- }}
357
- style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => [
358
- styles.listRow,
359
- { borderRadius: radius.md - 4, backgroundColor: pressed || hovered ? colors.muted : 'transparent' },
360
- ]}
361
- >
362
- <Glyph name="plus" size={16} color={tint} />
363
- <Text numberOfLines={1} style={[styles.listText, { color: colors.foreground }]}>
364
- {prefix}
365
- {s.slice(0, i)}
366
- <Text style={[styles.match, { color: tint }]}>{s.slice(i, i + query.length)}</Text>
367
- {s.slice(i + query.length)}
368
- </Text>
369
- </Pressable>
370
- );
371
- })}
372
- {!exact ? (
373
- <Pressable
374
- accessibilityRole="button"
375
- onPress={() => {
376
- add([text]);
377
- setText('');
378
- }}
379
- style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => [
380
- styles.listRow,
381
- { borderRadius: radius.md - 4, backgroundColor: pressed || hovered ? colors.muted : 'transparent' },
382
- ]}
383
- >
384
- <Glyph name="enter" size={16} color={colors.mutedForeground} />
385
- <Text numberOfLines={1} style={[styles.listText, { color: colors.mutedForeground }]}>
386
- Add “{prefix}
387
- {clean(text)}”
388
- </Text>
389
- </Pressable>
390
- ) : null}
391
- </Appear>
392
- ) : null}
393
-
394
- {!typing && filtered.length && !full ? (
395
- <ScrollView
396
- horizontal
397
- showsHorizontalScrollIndicator={false}
398
- keyboardShouldPersistTaps="always"
399
- style={styles.suggestions}
400
- contentContainerStyle={styles.suggestionsRow}
401
- >
402
- {filtered.map((s) => (
403
- <Pressable
404
- key={s}
405
- accessibilityRole="button"
406
- accessibilityLabel={`Add ${s}`}
407
- onPress={() => {
408
- add([s]);
409
- setText('');
410
- }}
411
- style={({ pressed }) => [
412
- styles.suggestion,
413
- { borderRadius: radius.capsule(34), borderColor: colors.border, backgroundColor: pressed ? colors.muted : 'transparent', transform: [{ scale: pressed ? 0.96 : 1 }] },
414
- ]}
415
- >
416
- <Glyph name="plus" size={14} color={colorize ? hashColor(s) : tint} />
417
- <Text style={[styles.suggestionText, { color: colors.foreground }]}>
418
- {prefix}
419
- {s}
420
- </Text>
421
- </Pressable>
422
- ))}
423
- </ScrollView>
424
- ) : null}
425
-
426
- {message || maxTags !== undefined ? (
427
- <View style={styles.footer}>
428
- {message ? (
429
- <Appear key={`${shownError ? 'e' : 'h'}${message}`} style={styles.message} live={!!shownError}>
430
- {shownError ? <Glyph name="alert" size={14} color={colors.danger} /> : null}
431
- <Text style={[styles.helper, { color: shownError ? colors.danger : colors.mutedForeground }]}>{message}</Text>
432
- </Appear>
433
- ) : (
434
- <View style={styles.flex} />
435
- )}
436
- {maxTags !== undefined ? <Counter count={visible.length} max={maxTags} tint={tint} /> : null}
437
- </View>
438
- ) : null}
439
- </Animated.View>
440
- );
441
- });
442
-
443
- const GAP = 6;
444
-
445
- /** Fades and slides its children in once on mount. */
446
- function Appear({ children, style, live }: { children: React.ReactNode; style?: StyleProp<ViewStyle>; live?: boolean }) {
447
- const enter = useRef(new Animated.Value(0)).current; // native
448
- useEffect(() => {
449
- Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
450
- return () => enter.stopAnimation();
451
- }, [enter]);
452
- return (
453
- <Animated.View
454
- accessibilityLiveRegion={live ? 'polite' : undefined}
455
- style={[style, { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] }]}
456
- >
457
- {children}
458
- </Animated.View>
459
- );
460
- }
461
-
462
- /** "3/5" with a small ring that fills as tags are added. */
463
- function Counter({ count, max, tint }: { count: number; max: number; tint: string }) {
464
- const { colors } = useKit();
465
- const full = count >= max;
466
- const color = full ? colors.warning : tint;
467
- const r = 6;
468
- const c = 2 * Math.PI * r;
469
- const pop = useRef(new Animated.Value(1)).current; // native
470
- const first = useRef(true);
471
- useEffect(() => {
472
- if (first.current) {
473
- first.current = false;
474
- return;
475
- }
476
- pop.setValue(1.25);
477
- Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
478
- }, [count, pop]);
479
- useEffect(() => () => pop.stopAnimation(), [pop]);
480
- return (
481
- <Animated.View style={[styles.counter, { transform: [{ scale: pop }] }]} accessibilityLabel={`${count} of ${max} tags`}>
482
- <Svg width={16} height={16} viewBox="0 0 16 16">
483
- <Circle cx={8} cy={8} r={r} stroke={alpha(colors.mutedForeground, 0.25)} strokeWidth={2} fill="none" />
484
- <Circle
485
- cx={8}
486
- cy={8}
487
- r={r}
488
- stroke={color}
489
- strokeWidth={2}
490
- fill="none"
491
- strokeLinecap="round"
492
- strokeDasharray={`${c} ${c}`}
493
- strokeDashoffset={c * (1 - Math.min(1, count / max))}
494
- transform="rotate(-90 8 8)"
495
- />
496
- </Svg>
497
- <Text style={[styles.count, { color: full ? colors.warning : colors.mutedForeground }]}>
498
- {count}/{max}
499
- </Text>
500
- </Animated.View>
501
- );
502
- }
503
-
504
- function Chip({
505
- label,
506
- prefix,
507
- variant,
508
- tint,
509
- leaving,
510
- armed,
511
- bump,
512
- disabled,
513
- onRemove,
514
- onExited,
515
- }: {
516
- label: string;
517
- prefix?: string;
518
- variant: TagChipVariant;
519
- tint: string;
520
- leaving: boolean;
521
- armed: boolean;
522
- bump: number;
523
- disabled: boolean;
524
- onRemove: () => void;
525
- onExited: () => void;
526
- }) {
527
- const { colors, radius, dark } = useKit();
528
- // Inner chip (native driver): scale + fade. Outer wrapper (JS driver, separate view): width and
529
- // the trailing gap collapse so the neighbours and the text input slide over.
530
- const scale = useRef(new Animated.Value(0.85)).current; // native
531
- const fade = useRef(new Animated.Value(0)).current; // native
532
- const size = useRef(new Animated.Value(1)).current; // JS: 1 = full width, 0 = collapsed
533
- const width = useRef(0);
534
- const [frozen, setFrozen] = useState<number | null>(null); // measured width while collapsing
535
-
536
- useEffect(() => {
537
- Animated.parallel([
538
- Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
539
- Animated.spring(fade, { toValue: 1, useNativeDriver: true, ...motion.snappy }),
540
- ]).start();
541
- return () => {
542
- scale.stopAnimation();
543
- fade.stopAnimation();
544
- size.stopAnimation();
545
- };
546
- }, [scale, fade, size]);
547
-
548
- useEffect(() => {
549
- if (!bump) return;
550
- scale.setValue(1.18);
551
- Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
552
- }, [bump, scale]);
553
-
554
- useEffect(() => {
555
- if (!armed) return;
556
- scale.setValue(1.1);
557
- Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
558
- }, [armed, scale]);
559
-
560
- const wasLeaving = useRef(false);
561
- useEffect(() => {
562
- if (!leaving) {
563
- if (!wasLeaving.current) return;
564
- wasLeaving.current = false;
565
- // Exit cancelled (tag re-added): grow back in place, then drop the fixed width.
566
- scale.stopAnimation();
567
- fade.stopAnimation();
568
- size.stopAnimation();
569
- Animated.parallel([
570
- Animated.spring(scale, { toValue: 1, useNativeDriver: true, ...motion.bouncy }),
571
- Animated.spring(fade, { toValue: 1, useNativeDriver: true, ...motion.snappy }),
572
- ]).start();
573
- Animated.spring(size, { toValue: 1, useNativeDriver: false, ...motion.snappy }).start(({ finished }) => {
574
- if (finished) setFrozen(null);
575
- });
576
- return;
577
- }
578
- wasLeaving.current = true;
579
- if (width.current > 0) setFrozen((f) => f ?? width.current);
580
- Animated.parallel([
581
- Animated.timing(scale, { toValue: 0.8, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: true }),
582
- Animated.timing(fade, { toValue: 0, duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true }),
583
- Animated.timing(size, { toValue: 0, duration: 240, delay: 40, easing: Easing.inOut(Easing.cubic), useNativeDriver: false }),
584
- ]).start(({ finished }) => {
585
- // `finished` is false when stopped (unmount, or the exit was cancelled).
586
- if (finished) onExited();
587
- });
588
- // eslint-disable-next-line react-hooks/exhaustive-deps
589
- }, [leaving]);
590
-
591
- const look = armed
592
- ? { bg: colors.danger, fg: colors.dangerForeground, border: colors.danger, dot: false }
593
- : variant === 'solid'
594
- ? { bg: tint, fg: readableOn(tint), border: tint, dot: false }
595
- : variant === 'outline'
596
- ? { bg: colors.background, fg: colors.foreground, border: alpha(tint, 0.45), dot: true }
597
- : { bg: alpha(tint, dark ? 0.2 : 0.12), fg: tint, border: alpha(tint, dark ? 0.32 : 0.22), dot: false };
598
-
599
- return (
600
- <Animated.View
601
- pointerEvents={leaving ? 'none' : 'auto'}
602
- accessibilityElementsHidden={leaving}
603
- importantForAccessibility={leaving ? 'no-hide-descendants' : 'auto'}
604
- onLayout={(e) => {
605
- if (frozen === null) width.current = e.nativeEvent.layout.width;
606
- }}
607
- style={
608
- frozen !== null
609
- ? {
610
- overflow: 'hidden',
611
- width: size.interpolate({ inputRange: [0, 1], outputRange: [0, frozen], extrapolate: 'clamp' }),
612
- // Cancel the field's `gap` as the chip disappears so the next item lands flush.
613
- marginRight: size.interpolate({ inputRange: [0, 1], outputRange: [-GAP, 0], extrapolate: 'clamp' }),
614
- }
615
- : null
616
- }
617
- >
618
- <Animated.View
619
- accessibilityState={{ selected: armed }}
620
- style={[
621
- styles.chip,
622
- variant === 'solid' && !armed && styles.chipRaised,
623
- {
624
- backgroundColor: look.bg,
625
- borderColor: look.border,
626
- borderRadius: radius.capsule(30),
627
- shadowColor: tint,
628
- opacity: fade.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
629
- transform: [{ scale }],
630
- },
631
- ]}
632
- >
633
- {look.dot ? <View style={[styles.dot, { backgroundColor: tint }]} /> : null}
634
- <Text numberOfLines={1} style={[styles.chipText, { color: look.fg }]}>
635
- {prefix ? <Text style={styles.prefix}>{prefix}</Text> : null}
636
- {label}
637
- </Text>
638
- <Pressable
639
- hitSlop={{ top: 13, bottom: 13, left: 6, right: 12 }}
640
- accessibilityRole="button"
641
- accessibilityLabel={`Remove ${label}`}
642
- disabled={disabled || leaving}
643
- onPress={onRemove}
644
- style={({ pressed }) => [styles.remove, { backgroundColor: alpha(look.fg === colors.foreground ? colors.mutedForeground : look.fg, pressed ? 0.3 : 0.16) }]}
645
- >
646
- <Glyph name="x" size={10} color={look.fg} strokeWidth={2.6} />
647
- </Pressable>
648
- </Animated.View>
649
- </Animated.View>
650
- );
651
- }
652
-
653
- function Glyph({ name, size, color, strokeWidth = 1.8 }: { name: 'x' | 'plus' | 'enter' | 'alert'; size: number; color: string; strokeWidth?: number }) {
654
- const p = { stroke: color, strokeWidth, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
655
- return (
656
- <Svg width={size} height={size} viewBox="0 0 24 24">
657
- {name === 'x' ? <Path d="M6 6l12 12M18 6 6 18" {...p} /> : null}
658
- {name === 'plus' ? <Path d="M12 5v14M5 12h14" {...p} strokeWidth={2.2} /> : null}
659
- {name === 'enter' ? <Path d="M19 6v5a3 3 0 0 1-3 3H6m0 0 4-4m-4 4 4 4" {...p} /> : null}
660
- {name === 'alert' ? (
661
- <>
662
- <Circle cx={12} cy={12} r={9} {...p} />
663
- <Path d="M12 7.5v5.5M12 16.5v.01" {...p} strokeWidth={2.2} />
664
- </>
665
- ) : null}
666
- </Svg>
667
- );
668
- }
669
-
670
- const styles = StyleSheet.create({
671
- halo: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4, borderWidth: 4 },
672
- field: {
673
- flexDirection: 'row',
674
- flexWrap: 'wrap',
675
- alignItems: 'center',
676
- gap: GAP,
677
- borderWidth: 1.5,
678
- minHeight: 52,
679
- paddingHorizontal: 10,
680
- paddingVertical: 9,
681
- },
682
- input: { flexGrow: 1, flexBasis: 90, minWidth: 90, height: 30, fontSize: 16, paddingVertical: 0, paddingHorizontal: 4 },
683
- inputFull: { flexBasis: 160 },
684
- chip: { flexDirection: 'row', alignItems: 'center', height: 30, paddingLeft: 12, paddingRight: 6, gap: 6, borderWidth: 1 },
685
- chipRaised: { shadowOpacity: 0.3, shadowRadius: 6, shadowOffset: { width: 0, height: 2 }, elevation: 2 },
686
- dot: { width: 7, height: 7, borderRadius: 4, marginLeft: -2 },
687
- chipText: { fontSize: 14, fontWeight: '400', maxWidth: 200 },
688
- prefix: { opacity: 0.6 },
689
- remove: { width: 18, height: 18, borderRadius: 9, alignItems: 'center', justifyContent: 'center' },
690
- list: {
691
- marginTop: 8,
692
- padding: 4,
693
- borderWidth: StyleSheet.hairlineWidth,
694
- shadowColor: '#000',
695
- shadowOpacity: 0.1,
696
- shadowRadius: 14,
697
- shadowOffset: { width: 0, height: 6 },
698
- elevation: 6,
699
- },
700
- listRow: { flexDirection: 'row', alignItems: 'center', minHeight: 44, paddingHorizontal: 12, gap: 10 },
701
- listText: { flex: 1, fontSize: 15 },
702
- match: { fontWeight: '400' },
703
- suggestions: { marginTop: 10, flexGrow: 0 },
704
- suggestionsRow: { gap: 8, paddingRight: 4 },
705
- suggestion: {
706
- flexDirection: 'row',
707
- alignItems: 'center',
708
- height: 34,
709
- paddingHorizontal: 12,
710
- borderWidth: 1,
711
- gap: 5,
712
- },
713
- suggestionText: { fontSize: 13.5, fontWeight: '400' },
714
- footer: { flexDirection: 'row', alignItems: 'flex-start', marginTop: 6, gap: 12 },
715
- flex: { flex: 1 },
716
- message: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 5, marginLeft: 4 },
717
- helper: { flex: 1, fontSize: 12.5 },
718
- counter: { flexDirection: 'row', alignItems: 'center', gap: 5 },
719
- count: { fontSize: 12.5, fontWeight: '400', fontVariant: ['tabular-nums'] },
720
- });