panelui-native 0.16.0 → 0.19.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 (66) hide show
  1. package/lib/module/components/bottom-sheet/index.js +38 -10
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/checkbox/index.js +24 -11
  4. package/lib/module/components/checkbox/index.js.map +1 -1
  5. package/lib/module/components/field/index.js +337 -0
  6. package/lib/module/components/field/index.js.map +1 -0
  7. package/lib/module/components/flow/flow-paths.js +378 -0
  8. package/lib/module/components/flow/flow-paths.js.map +1 -0
  9. package/lib/module/components/flow/index.js +1598 -0
  10. package/lib/module/components/flow/index.js.map +1 -0
  11. package/lib/module/components/form/index.js +77 -0
  12. package/lib/module/components/form/index.js.map +1 -0
  13. package/lib/module/components/form/use-field.js +50 -0
  14. package/lib/module/components/form/use-field.js.map +1 -0
  15. package/lib/module/components/form/use-form.js +217 -0
  16. package/lib/module/components/form/use-form.js.map +1 -0
  17. package/lib/module/components/frame/index.js +124 -22
  18. package/lib/module/components/frame/index.js.map +1 -1
  19. package/lib/module/components/otp-input/index.js +303 -0
  20. package/lib/module/components/otp-input/index.js.map +1 -0
  21. package/lib/module/components/signature/index.js +500 -0
  22. package/lib/module/components/signature/index.js.map +1 -0
  23. package/lib/module/icons/index.js +218 -0
  24. package/lib/module/icons/index.js.map +1 -1
  25. package/lib/module/index.js +6 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/typescript/src/components/bottom-sheet/index.d.ts +15 -1
  28. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
  30. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/field/index.d.ts +94 -0
  32. package/lib/typescript/src/components/field/index.d.ts.map +1 -0
  33. package/lib/typescript/src/components/flow/flow-paths.d.ts +89 -0
  34. package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -0
  35. package/lib/typescript/src/components/flow/index.d.ts +310 -0
  36. package/lib/typescript/src/components/flow/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/form/index.d.ts +64 -0
  38. package/lib/typescript/src/components/form/index.d.ts.map +1 -0
  39. package/lib/typescript/src/components/form/use-field.d.ts +16 -0
  40. package/lib/typescript/src/components/form/use-field.d.ts.map +1 -0
  41. package/lib/typescript/src/components/form/use-form.d.ts +37 -0
  42. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -0
  43. package/lib/typescript/src/components/frame/index.d.ts +39 -2
  44. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/otp-input/index.d.ts +141 -0
  46. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -0
  47. package/lib/typescript/src/components/signature/index.d.ts +254 -0
  48. package/lib/typescript/src/components/signature/index.d.ts.map +1 -0
  49. package/lib/typescript/src/icons/index.d.ts +17 -0
  50. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +7 -2
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/package.json +9 -1
  54. package/src/components/bottom-sheet/index.tsx +50 -12
  55. package/src/components/checkbox/index.tsx +40 -12
  56. package/src/components/field/index.tsx +329 -0
  57. package/src/components/flow/flow-paths.ts +348 -0
  58. package/src/components/flow/index.tsx +1924 -0
  59. package/src/components/form/index.tsx +85 -0
  60. package/src/components/form/use-field.ts +65 -0
  61. package/src/components/form/use-form.ts +252 -0
  62. package/src/components/frame/index.tsx +162 -14
  63. package/src/components/otp-input/index.tsx +396 -0
  64. package/src/components/signature/index.tsx +681 -0
  65. package/src/icons/index.tsx +159 -0
  66. package/src/index.ts +72 -0
@@ -0,0 +1,396 @@
1
+ /**
2
+ * OtpInput — a one-time-code field drawn as a row of separate cells.
3
+ *
4
+ * The problem this solves is that a code is read and typed one character at a
5
+ * time, and a single text box hides that: you cannot see, at a glance, how
6
+ * many digits are left. So the value is spread across `length` cells, each its
7
+ * own box, and the caret sits in the cell you are about to fill.
8
+ *
9
+ * There is still exactly one text field underneath — a transparent input laid
10
+ * over the whole row — because the software keyboard, autofill and paste all
11
+ * belong to a real `TextInput`, and splitting the value across N inputs fights
12
+ * every one of them. The cells are drawn from the value; the input owns it.
13
+ * Tapping any cell focuses that one input, so the platform never sees the seam.
14
+ *
15
+ * The active cell's border crosses to the focus colour on the UI thread rather
16
+ * than snapping, matching the rest of the field family, and a caret blinks in
17
+ * the cell awaiting input. Each digit zooms in as it lands, so a fast type or a
18
+ * one-tap autofill reads as the code arriving rather than the box redrawing.
19
+ *
20
+ * ```tsx
21
+ * const [code, setCode] = useState('');
22
+ * <OtpInput value={code} onChangeText={setCode} onComplete={submit} />
23
+ * <OtpInput length={4} mask groupEvery={2} type="numeric" />
24
+ * ```
25
+ */
26
+ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
27
+ import { Pressable, TextInput, View, type TextInputProps } from 'react-native';
28
+ import Animated, {
29
+ cancelAnimation,
30
+ interpolateColor,
31
+ useAnimatedStyle,
32
+ useSharedValue,
33
+ withRepeat,
34
+ withSequence,
35
+ withTiming,
36
+ ZoomIn,
37
+ } from 'react-native-reanimated';
38
+ import { tv, type VariantProps } from 'tailwind-variants';
39
+ import { useCSSVariable } from 'uniwind';
40
+ import { Text } from '../../primitives/text';
41
+
42
+ /** Long enough to read as a transition, short enough not to lag a fast type. */
43
+ const FOCUS_DURATION = 150;
44
+ /** A caret that is on for the same time it is off reads as a resting cursor. */
45
+ const CARET_PERIOD = 560;
46
+
47
+ const otpVariants = tv({
48
+ slots: {
49
+ container: 'gap-1.5',
50
+ row: 'flex-row items-center',
51
+ cell: 'items-center justify-center rounded-lg border border-input bg-background',
52
+ char: 'text-foreground',
53
+ caret: 'w-0.5 rounded-full bg-foreground',
54
+ separator: 'h-0.5 rounded-full bg-border',
55
+ error: 'text-sm text-destructive',
56
+ },
57
+ variants: {
58
+ size: {
59
+ sm: { cell: 'h-10 w-9', char: 'text-base', caret: 'h-5', separator: 'w-2' },
60
+ md: { cell: 'h-12 w-11', char: 'text-lg', caret: 'h-6', separator: 'w-2.5' },
61
+ lg: { cell: 'h-14 w-12', char: 'text-xl', caret: 'h-7', separator: 'w-3' },
62
+ },
63
+ disabled: {
64
+ true: { row: 'opacity-[0.64]' },
65
+ },
66
+ },
67
+ defaultVariants: {
68
+ size: 'md',
69
+ },
70
+ });
71
+
72
+ type OtpVariantProps = VariantProps<typeof otpVariants>;
73
+
74
+ export interface OtpInputProps
75
+ extends Omit<
76
+ TextInputProps,
77
+ 'value' | 'defaultValue' | 'onChangeText' | 'maxLength' | 'children'
78
+ >,
79
+ Omit<OtpVariantProps, 'disabled'> {
80
+ className?: string;
81
+ /** Class applied to every cell — for a taller box or a different radius. */
82
+ cellClassName?: string;
83
+ /** How many cells, i.e. the length of the code. */
84
+ length?: number;
85
+ /** Controlled value. Longer strings and stray characters are trimmed to fit. */
86
+ value?: string;
87
+ /** Starting value when the field manages its own state. */
88
+ defaultValue?: string;
89
+ onChangeText?: (value: string) => void;
90
+ /** Fires once, the moment the last cell is filled. */
91
+ onComplete?: (value: string) => void;
92
+ /**
93
+ * What the keyboard offers and what the field accepts. `numeric` keeps
94
+ * digits only and asks for the number pad; `text` accepts any character.
95
+ */
96
+ type?: 'numeric' | 'text';
97
+ /** Hide each filled character behind a dot, the way a passcode field does. */
98
+ mask?: boolean;
99
+ /** A single character shown, dimmed, in every cell still waiting for input. */
100
+ placeholder?: string;
101
+ /** Draw a separator between groups of this many cells — 3 gives `xxx — xxx`. */
102
+ groupEvery?: number;
103
+ disabled?: boolean;
104
+ /** Tint the field in its error colour and announce it as invalid. */
105
+ isInvalid?: boolean;
106
+ /** Error line under the field. Setting it also puts the field in its invalid state. */
107
+ errorMessage?: string;
108
+ /** Announced by a screen reader as the field's name. */
109
+ accessibilityLabel?: string;
110
+ }
111
+
112
+ interface CellProps {
113
+ char: string;
114
+ active: boolean;
115
+ focused: boolean;
116
+ mask: boolean;
117
+ placeholder?: string;
118
+ invalid: boolean;
119
+ className: string;
120
+ charClassName: string;
121
+ caretClassName: string;
122
+ }
123
+
124
+ /**
125
+ * One box. It owns three animations, all on the UI thread: the border crossing
126
+ * to the focus (or error) colour when it becomes the active cell, the caret
127
+ * blinking while it waits, and the entrance of the character that lands in it.
128
+ */
129
+ function OtpCell({
130
+ char,
131
+ active,
132
+ focused,
133
+ mask,
134
+ placeholder,
135
+ invalid,
136
+ className,
137
+ charClassName,
138
+ caretClassName,
139
+ }: CellProps) {
140
+ const restColor = useCSSVariable('--color-input');
141
+ const focusColor = useCSSVariable('--color-ring');
142
+ const errorColor = useCSSVariable('--color-destructive');
143
+ const placeholderColor = useCSSVariable('--color-muted-foreground');
144
+
145
+ const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';
146
+ const activeColor = invalid
147
+ ? typeof errorColor === 'string'
148
+ ? errorColor
149
+ : '#ef4444'
150
+ : typeof focusColor === 'string'
151
+ ? focusColor
152
+ : '#a3a3a3';
153
+ // An invalid field carries its tint even when it is not the active cell —
154
+ // the error is a fact about the value, not about where the caret happens to be.
155
+ const idle = invalid ? activeColor : resting;
156
+
157
+ const lit = active && focused;
158
+
159
+ const border = useSharedValue(lit ? 1 : 0);
160
+ useEffect(() => {
161
+ border.value = withTiming(lit ? 1 : 0, { duration: FOCUS_DURATION });
162
+ }, [lit, border]);
163
+
164
+ const borderStyle = useAnimatedStyle(() => ({
165
+ borderColor: interpolateColor(border.value, [0, 1], [idle, activeColor]),
166
+ // A ring only when lit — the resting cells keep their hairline.
167
+ borderWidth: 1 + border.value,
168
+ }));
169
+
170
+ const caret = useSharedValue(0);
171
+ useEffect(() => {
172
+ if (lit && char.length === 0) {
173
+ caret.value = withRepeat(
174
+ withSequence(
175
+ withTiming(1, { duration: 1 }),
176
+ withTiming(1, { duration: CARET_PERIOD }),
177
+ withTiming(0, { duration: CARET_PERIOD })
178
+ ),
179
+ -1,
180
+ false
181
+ );
182
+ } else {
183
+ cancelAnimation(caret);
184
+ caret.value = 0;
185
+ }
186
+ return () => cancelAnimation(caret);
187
+ }, [lit, char.length, caret]);
188
+
189
+ const caretStyle = useAnimatedStyle(() => ({ opacity: caret.value }));
190
+
191
+ return (
192
+ <Animated.View style={borderStyle} className={className}>
193
+ {char.length > 0 ? (
194
+ <Animated.Text
195
+ // Keyed by the character so a change re-mounts and replays the pop.
196
+ key={char}
197
+ entering={ZoomIn.duration(140)}
198
+ className={charClassName}
199
+ >
200
+ {mask ? '•' : char}
201
+ </Animated.Text>
202
+ ) : lit ? (
203
+ <Animated.View
204
+ style={caretStyle}
205
+ className={caretClassName}
206
+ pointerEvents="none"
207
+ />
208
+ ) : placeholder ? (
209
+ <Text
210
+ className={charClassName}
211
+ style={{
212
+ color:
213
+ typeof placeholderColor === 'string' ? placeholderColor : '#a3a3a3',
214
+ }}
215
+ >
216
+ {placeholder}
217
+ </Text>
218
+ ) : null}
219
+ </Animated.View>
220
+ );
221
+ }
222
+
223
+ /** Clamp and filter a raw string down to what this field will hold. */
224
+ function sanitize(raw: string, length: number, type: 'numeric' | 'text'): string {
225
+ const filtered = type === 'numeric' ? raw.replace(/[^0-9]/g, '') : raw;
226
+ return filtered.slice(0, length);
227
+ }
228
+
229
+ export const OtpInput = forwardRef<TextInput, OtpInputProps>(
230
+ (
231
+ {
232
+ className,
233
+ cellClassName,
234
+ length = 6,
235
+ value: valueProp,
236
+ defaultValue = '',
237
+ onChangeText,
238
+ onComplete,
239
+ type = 'numeric',
240
+ mask = false,
241
+ placeholder,
242
+ groupEvery = 0,
243
+ size,
244
+ disabled,
245
+ isInvalid,
246
+ errorMessage,
247
+ accessibilityLabel,
248
+ onFocus,
249
+ onBlur,
250
+ style,
251
+ ...props
252
+ },
253
+ ref
254
+ ) => {
255
+ const controlled = valueProp != null;
256
+ const [internal, setInternal] = useState(() =>
257
+ sanitize(defaultValue, length, type)
258
+ );
259
+ const value = sanitize(controlled ? valueProp! : internal, length, type);
260
+ const invalid = isInvalid || !!errorMessage;
261
+
262
+ const [focused, setFocused] = useState(false);
263
+ const inputRef = useRef<TextInput | null>(null);
264
+
265
+ // A one-shot guard so onComplete fires on the transition to full, not on
266
+ // every keystroke that leaves the field full (e.g. a trailing focus event).
267
+ const completedRef = useRef(false);
268
+
269
+ const slots = otpVariants({ size, disabled: !!disabled });
270
+
271
+ const setRef = useCallback(
272
+ (node: TextInput | null) => {
273
+ inputRef.current = node;
274
+ if (typeof ref === 'function') ref(node);
275
+ else if (ref) ref.current = node;
276
+ },
277
+ [ref]
278
+ );
279
+
280
+ const handleChange = useCallback(
281
+ (raw: string) => {
282
+ const next = sanitize(raw, length, type);
283
+ if (!controlled) setInternal(next);
284
+ onChangeText?.(next);
285
+ if (next.length === length) {
286
+ if (!completedRef.current) {
287
+ completedRef.current = true;
288
+ onComplete?.(next);
289
+ }
290
+ } else {
291
+ completedRef.current = false;
292
+ }
293
+ },
294
+ [controlled, length, type, onChangeText, onComplete]
295
+ );
296
+
297
+ // Keep the guard honest when the value is driven from outside (a reset, a
298
+ // paste through the controlled prop) rather than through the keyboard.
299
+ useEffect(() => {
300
+ completedRef.current = value.length === length;
301
+ }, [value.length, length]);
302
+
303
+ const focus = useCallback(() => {
304
+ if (!disabled) inputRef.current?.focus();
305
+ }, [disabled]);
306
+
307
+ const activeIndex = Math.min(value.length, length - 1);
308
+
309
+ const cells = useMemo(() => Array.from({ length }, (_, i) => i), [length]);
310
+
311
+ return (
312
+ <View className={slots.container({ className })}>
313
+ {/* The row is one big hit target; the field underneath is the a11y node. */}
314
+ <Pressable onPress={focus} disabled={disabled} accessible={false}>
315
+ <View className={slots.row()}>
316
+ {cells.map((i) => (
317
+ <View key={i} className="flex-row items-center">
318
+ <OtpCell
319
+ char={value[i] ?? ''}
320
+ active={i === activeIndex}
321
+ focused={focused}
322
+ mask={mask}
323
+ placeholder={placeholder}
324
+ invalid={invalid}
325
+ className={slots.cell({
326
+ className: `${i > 0 ? 'ml-2' : ''} ${cellClassName ?? ''}`,
327
+ })}
328
+ charClassName={slots.char()}
329
+ caretClassName={slots.caret()}
330
+ />
331
+ {groupEvery > 0 &&
332
+ (i + 1) % groupEvery === 0 &&
333
+ i < length - 1 ? (
334
+ <View className={`${slots.separator()} ml-2`} />
335
+ ) : null}
336
+ </View>
337
+ ))}
338
+ </View>
339
+
340
+ {/*
341
+ * The real field: one transparent input stretched over the whole row.
342
+ * It carries the keyboard, autofill and paste, and its value is the
343
+ * source the cells are drawn from — there is no per-cell input to keep
344
+ * in sync. `pointerEvents` off so taps fall through to the Pressable,
345
+ * which focuses this exact input.
346
+ */}
347
+ <TextInput
348
+ ref={setRef}
349
+ value={value}
350
+ onChangeText={handleChange}
351
+ editable={!disabled}
352
+ keyboardType={type === 'numeric' ? 'number-pad' : 'default'}
353
+ // The OS one-time-code affordances: iOS surfaces the SMS code above
354
+ // the keyboard, Android offers to autofill it.
355
+ textContentType="oneTimeCode"
356
+ autoComplete={type === 'numeric' ? 'sms-otp' : 'one-time-code'}
357
+ maxLength={length}
358
+ caretHidden
359
+ accessibilityLabel={accessibilityLabel}
360
+ accessibilityState={{ disabled: !!disabled }}
361
+ aria-invalid={invalid}
362
+ onFocus={(e) => {
363
+ setFocused(true);
364
+ onFocus?.(e);
365
+ }}
366
+ onBlur={(e) => {
367
+ setFocused(false);
368
+ onBlur?.(e);
369
+ }}
370
+ style={[
371
+ {
372
+ position: 'absolute',
373
+ top: 0,
374
+ left: 0,
375
+ right: 0,
376
+ bottom: 0,
377
+ opacity: 0,
378
+ // Taps fall through to the Pressable, which focuses this input —
379
+ // so the caret lands wherever the row is touched.
380
+ pointerEvents: 'none',
381
+ },
382
+ style,
383
+ ]}
384
+ {...props}
385
+ />
386
+ </Pressable>
387
+
388
+ {errorMessage ? (
389
+ <Text className={slots.error()}>{errorMessage}</Text>
390
+ ) : null}
391
+ </View>
392
+ );
393
+ }
394
+ );
395
+
396
+ OtpInput.displayName = 'OtpInput';