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.
- package/lib/module/components/bottom-sheet/index.js +38 -10
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/checkbox/index.js +24 -11
- package/lib/module/components/checkbox/index.js.map +1 -1
- package/lib/module/components/field/index.js +337 -0
- package/lib/module/components/field/index.js.map +1 -0
- package/lib/module/components/flow/flow-paths.js +378 -0
- package/lib/module/components/flow/flow-paths.js.map +1 -0
- package/lib/module/components/flow/index.js +1598 -0
- package/lib/module/components/flow/index.js.map +1 -0
- package/lib/module/components/form/index.js +77 -0
- package/lib/module/components/form/index.js.map +1 -0
- package/lib/module/components/form/use-field.js +50 -0
- package/lib/module/components/form/use-field.js.map +1 -0
- package/lib/module/components/form/use-form.js +217 -0
- package/lib/module/components/form/use-form.js.map +1 -0
- package/lib/module/components/frame/index.js +124 -22
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/otp-input/index.js +303 -0
- package/lib/module/components/otp-input/index.js.map +1 -0
- package/lib/module/components/signature/index.js +500 -0
- package/lib/module/components/signature/index.js.map +1 -0
- package/lib/module/icons/index.js +218 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +6 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +15 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/field/index.d.ts +94 -0
- package/lib/typescript/src/components/field/index.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/flow-paths.d.ts +89 -0
- package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/index.d.ts +310 -0
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -0
- package/lib/typescript/src/components/form/index.d.ts +64 -0
- package/lib/typescript/src/components/form/index.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-field.d.ts +16 -0
- package/lib/typescript/src/components/form/use-field.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-form.d.ts +37 -0
- package/lib/typescript/src/components/form/use-form.d.ts.map +1 -0
- package/lib/typescript/src/components/frame/index.d.ts +39 -2
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/otp-input/index.d.ts +141 -0
- package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -0
- package/lib/typescript/src/components/signature/index.d.ts +254 -0
- package/lib/typescript/src/components/signature/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +17 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +7 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/components/bottom-sheet/index.tsx +50 -12
- package/src/components/checkbox/index.tsx +40 -12
- package/src/components/field/index.tsx +329 -0
- package/src/components/flow/flow-paths.ts +348 -0
- package/src/components/flow/index.tsx +1924 -0
- package/src/components/form/index.tsx +85 -0
- package/src/components/form/use-field.ts +65 -0
- package/src/components/form/use-form.ts +252 -0
- package/src/components/frame/index.tsx +162 -14
- package/src/components/otp-input/index.tsx +396 -0
- package/src/components/signature/index.tsx +681 -0
- package/src/icons/index.tsx +159 -0
- package/src/index.ts +72 -0
|
@@ -24,15 +24,35 @@ import Animated, {
|
|
|
24
24
|
withTiming,
|
|
25
25
|
} from 'react-native-reanimated';
|
|
26
26
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
27
|
+
import { tv } from 'tailwind-variants';
|
|
27
28
|
import { getNativeUI } from '../../native';
|
|
28
29
|
import { XIcon } from '../../icons';
|
|
29
30
|
import { Portal } from '../../primitives/portal';
|
|
31
|
+
import { Scrim } from '../../primitives/scrim';
|
|
30
32
|
import { cn } from '../../utils/cn';
|
|
31
33
|
|
|
32
34
|
const SPRING = { damping: 22, stiffness: 280, mass: 0.7 } as const;
|
|
33
35
|
const DISMISS_DISTANCE = 120;
|
|
34
36
|
const DISMISS_VELOCITY = 800;
|
|
35
37
|
|
|
38
|
+
const sheetVariants = tv({
|
|
39
|
+
base: 'border border-border bg-popover px-5 pt-2 shadow-lg',
|
|
40
|
+
variants: {
|
|
41
|
+
detached: {
|
|
42
|
+
// Docked: the sheet is continuous with the bottom of the screen, so the
|
|
43
|
+
// bottom edge is not a real edge and drawing a line along it would be a
|
|
44
|
+
// rule through the middle of nothing.
|
|
45
|
+
false: 'rounded-t-3xl border-b-0',
|
|
46
|
+
// Floating: all four edges are real, so all four are drawn and all four
|
|
47
|
+
// corners are rounded.
|
|
48
|
+
true: 'mx-4 mb-6 rounded-3xl',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
defaultVariants: {
|
|
52
|
+
detached: false,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
36
56
|
interface BottomSheetContextValue {
|
|
37
57
|
open: boolean;
|
|
38
58
|
setOpen: (open: boolean) => void;
|
|
@@ -178,6 +198,20 @@ export interface BottomSheetContentProps extends ViewProps {
|
|
|
178
198
|
* sheet; ignored by the native sheet, which has its own dismiss affordances.
|
|
179
199
|
*/
|
|
180
200
|
showClose?: boolean;
|
|
201
|
+
/**
|
|
202
|
+
* Float the sheet clear of the screen edges instead of docking it to the
|
|
203
|
+
* bottom, so it reads as a card laid over the app rather than a drawer
|
|
204
|
+
* pulled out of it. All four corners round and the bottom border comes back,
|
|
205
|
+
* since a floating sheet has four real edges where a docked one has three.
|
|
206
|
+
* Ignored by the native sheet, which the platform positions itself.
|
|
207
|
+
*/
|
|
208
|
+
detached?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Frost the screen behind the sheet instead of dimming it, so what is behind
|
|
211
|
+
* stays legible as shape and colour while losing its detail. Needs the
|
|
212
|
+
* optional `expo-blur`; without it this dims, rather than failing.
|
|
213
|
+
*/
|
|
214
|
+
blur?: boolean;
|
|
181
215
|
children?: ReactNode;
|
|
182
216
|
}
|
|
183
217
|
|
|
@@ -185,6 +219,8 @@ function BottomSheetContent({
|
|
|
185
219
|
className,
|
|
186
220
|
dismissible = true,
|
|
187
221
|
showClose = true,
|
|
222
|
+
detached = false,
|
|
223
|
+
blur = false,
|
|
188
224
|
children,
|
|
189
225
|
...props
|
|
190
226
|
}: BottomSheetContentProps) {
|
|
@@ -275,27 +311,29 @@ function BottomSheetContent({
|
|
|
275
311
|
subtree — re-provide the context so nested consumers keep working. */}
|
|
276
312
|
<BottomSheetContext.Provider value={context}>
|
|
277
313
|
<View className="absolute inset-0 justify-end">
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
>
|
|
314
|
+
<View className="absolute inset-0">
|
|
315
|
+
{/* Scrim draws the backdrop and its own fade; the Pressable over it
|
|
316
|
+
is what closes the sheet, since the scrim takes no touches. */}
|
|
317
|
+
<Scrim blur={blur} />
|
|
283
318
|
<Pressable
|
|
284
319
|
accessibilityLabel="Close sheet"
|
|
285
|
-
className="flex-1
|
|
320
|
+
className="flex-1"
|
|
286
321
|
onPress={dismissible ? close : undefined}
|
|
287
322
|
/>
|
|
288
|
-
</
|
|
323
|
+
</View>
|
|
289
324
|
<GestureDetector gesture={pan}>
|
|
290
325
|
<Animated.View
|
|
291
326
|
entering={SlideInDown.springify().damping(22).stiffness(240).mass(0.8)}
|
|
292
327
|
exiting={SlideOutDown.duration(200)}
|
|
293
|
-
style={[
|
|
328
|
+
style={[
|
|
329
|
+
sheetStyle,
|
|
330
|
+
// A detached sheet's own bottom margin already clears the home
|
|
331
|
+
// indicator, so it takes plain padding rather than stacking the
|
|
332
|
+
// inset on top of the gap.
|
|
333
|
+
{ paddingBottom: detached ? 16 : Math.max(insets.bottom, 16) },
|
|
334
|
+
]}
|
|
294
335
|
accessibilityViewIsModal
|
|
295
|
-
className={
|
|
296
|
-
'rounded-t-3xl border border-b-0 border-border bg-popover px-5 pt-2 shadow-lg',
|
|
297
|
-
className
|
|
298
|
-
)}
|
|
336
|
+
className={sheetVariants({ detached, className })}
|
|
299
337
|
{...props}
|
|
300
338
|
>
|
|
301
339
|
<View className="mb-3 self-center">
|
|
@@ -8,7 +8,7 @@ import Animated, {
|
|
|
8
8
|
} from 'react-native-reanimated';
|
|
9
9
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
10
10
|
import { useCSSVariable } from 'uniwind';
|
|
11
|
-
import { CheckIcon } from '../../icons';
|
|
11
|
+
import { CheckIcon, MinusIcon } from '../../icons';
|
|
12
12
|
import { Text } from '../../primitives/text';
|
|
13
13
|
|
|
14
14
|
const checkboxVariants = tv({
|
|
@@ -55,6 +55,14 @@ export interface CheckboxProps extends VariantProps<typeof checkboxVariants> {
|
|
|
55
55
|
className?: string;
|
|
56
56
|
checked: boolean;
|
|
57
57
|
onCheckedChange?: (checked: boolean) => void;
|
|
58
|
+
/**
|
|
59
|
+
* A third, in-between state for a box that governs a group of others — some
|
|
60
|
+
* on, some off. It fills like a checked box but shows a dash rather than a
|
|
61
|
+
* tick, and announces itself as `mixed` to a screen reader. Pressing it
|
|
62
|
+
* resolves the ambiguity by turning the whole group on, so the press reports
|
|
63
|
+
* `true`. `indeterminate` overrides `checked` for what is drawn.
|
|
64
|
+
*/
|
|
65
|
+
indeterminate?: boolean;
|
|
58
66
|
disabled?: boolean;
|
|
59
67
|
/** Optional label rendered next to the box; pressing it also toggles. */
|
|
60
68
|
label?: string;
|
|
@@ -64,31 +72,45 @@ export interface CheckboxProps extends VariantProps<typeof checkboxVariants> {
|
|
|
64
72
|
|
|
65
73
|
export const Checkbox = forwardRef<View, CheckboxProps>(
|
|
66
74
|
(
|
|
67
|
-
{
|
|
75
|
+
{
|
|
76
|
+
className,
|
|
77
|
+
checked,
|
|
78
|
+
onCheckedChange,
|
|
79
|
+
indeterminate,
|
|
80
|
+
disabled,
|
|
81
|
+
label,
|
|
82
|
+
description,
|
|
83
|
+
variant,
|
|
84
|
+
},
|
|
68
85
|
ref
|
|
69
86
|
) => {
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
// The dash and the tick share one fill, so the box is "on" for either —
|
|
88
|
+
// what changes is the glyph, not whether the fill is shown.
|
|
89
|
+
const on = checked || !!indeterminate;
|
|
90
|
+
const progress = useSharedValue(on ? 1 : 0);
|
|
91
|
+
const slots = checkboxVariants({ variant, checked: on, disabled: !!disabled });
|
|
72
92
|
const checkColor = useCSSVariable('--color-primary-foreground');
|
|
73
93
|
|
|
74
94
|
useEffect(() => {
|
|
75
|
-
progress.value =
|
|
95
|
+
progress.value = on
|
|
76
96
|
? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })
|
|
77
97
|
: withTiming(0, { duration: 120 });
|
|
78
|
-
}, [
|
|
98
|
+
}, [on, progress]);
|
|
79
99
|
|
|
80
100
|
const fillStyle = useAnimatedStyle(() => ({
|
|
81
101
|
opacity: progress.value,
|
|
82
102
|
transform: [{ scale: 0.6 + progress.value * 0.4 }],
|
|
83
103
|
}));
|
|
84
104
|
|
|
105
|
+
const glyphColor = typeof checkColor === 'string' ? checkColor : '#fff';
|
|
85
106
|
const box = (
|
|
86
107
|
<View className={slots.box()}>
|
|
87
108
|
<Animated.View style={fillStyle} className={slots.fill()}>
|
|
88
|
-
|
|
89
|
-
size={12}
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
{indeterminate ? (
|
|
110
|
+
<MinusIcon size={12} color={glyphColor} />
|
|
111
|
+
) : (
|
|
112
|
+
<CheckIcon size={12} color={glyphColor} />
|
|
113
|
+
)}
|
|
92
114
|
</Animated.View>
|
|
93
115
|
</View>
|
|
94
116
|
);
|
|
@@ -97,11 +119,17 @@ export const Checkbox = forwardRef<View, CheckboxProps>(
|
|
|
97
119
|
<Pressable
|
|
98
120
|
ref={ref}
|
|
99
121
|
accessibilityRole="checkbox"
|
|
100
|
-
|
|
122
|
+
// `mixed` is the a11y state for a box that governs a partly-on group;
|
|
123
|
+
// a plain boolean would tell a screen reader the group is fully one way.
|
|
124
|
+
accessibilityState={{
|
|
125
|
+
checked: indeterminate ? 'mixed' : checked,
|
|
126
|
+
disabled: !!disabled,
|
|
127
|
+
}}
|
|
101
128
|
accessibilityLabel={label}
|
|
102
129
|
accessibilityHint={description}
|
|
103
130
|
disabled={disabled}
|
|
104
|
-
|
|
131
|
+
// An indeterminate box resolves upward: the press turns the group on.
|
|
132
|
+
onPress={() => onCheckedChange?.(indeterminate ? true : !checked)}
|
|
105
133
|
hitSlop={8}
|
|
106
134
|
// A description makes the row two lines tall — align the box to the
|
|
107
135
|
// label rather than centring it against the whole block.
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field — the layout and validation-state kit a form control composes into.
|
|
3
|
+
*
|
|
4
|
+
* PanelUI's own controls (`Input`, `Checkbox`, `Switch`, `RadioGroup`) already
|
|
5
|
+
* carry their own label, description and error slot, so the common case needs
|
|
6
|
+
* no wrapper at all — pass those props straight through. `Field` exists for
|
|
7
|
+
* what doesn't fit that shape: a horizontal row pairing a control with a
|
|
8
|
+
* label off to the side (a `Switch` in a settings list), several controls
|
|
9
|
+
* grouped under one legend, or a rule breaking a long form into sections.
|
|
10
|
+
*
|
|
11
|
+
* `invalid` / `disabled` / `required` set on the root flow to `Field.Label`
|
|
12
|
+
* and `Field.Description` through context, so a multi-field row states its
|
|
13
|
+
* validity once instead of repeating the prop at every leaf.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <Field orientation="horizontal">
|
|
17
|
+
* <Field.Content>
|
|
18
|
+
* <Field.Title>Marketing emails</Field.Title>
|
|
19
|
+
* <Field.Description>Product updates, at most weekly.</Field.Description>
|
|
20
|
+
* </Field.Content>
|
|
21
|
+
* <Switch value={subscribed} onValueChange={setSubscribed} />
|
|
22
|
+
* </Field>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
createContext,
|
|
27
|
+
forwardRef,
|
|
28
|
+
useContext,
|
|
29
|
+
useMemo,
|
|
30
|
+
type ReactNode,
|
|
31
|
+
} from 'react';
|
|
32
|
+
import { View, type Text as RNText, type ViewProps } from 'react-native';
|
|
33
|
+
import { tv } from 'tailwind-variants';
|
|
34
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
35
|
+
import { Label, type LabelProps } from '../label';
|
|
36
|
+
import { Separator } from '../separator';
|
|
37
|
+
|
|
38
|
+
const fieldVariants = tv({
|
|
39
|
+
slots: {
|
|
40
|
+
root: 'gap-2',
|
|
41
|
+
content: 'flex-1 flex-col gap-0.5',
|
|
42
|
+
description: 'text-sm text-muted-foreground',
|
|
43
|
+
error: 'gap-1',
|
|
44
|
+
errorText: 'text-sm text-destructive',
|
|
45
|
+
bullet: 'flex-row gap-1.5',
|
|
46
|
+
bulletDot: 'text-sm text-destructive',
|
|
47
|
+
set: 'gap-4',
|
|
48
|
+
legend: 'text-foreground',
|
|
49
|
+
group: 'gap-6',
|
|
50
|
+
separator: 'flex-row items-center gap-3',
|
|
51
|
+
separatorLabel: 'text-xs text-muted-foreground',
|
|
52
|
+
title: 'text-base font-medium text-foreground',
|
|
53
|
+
},
|
|
54
|
+
variants: {
|
|
55
|
+
orientation: {
|
|
56
|
+
vertical: { root: 'flex-col' },
|
|
57
|
+
horizontal: { root: 'flex-row items-center justify-between gap-4' },
|
|
58
|
+
},
|
|
59
|
+
disabled: {
|
|
60
|
+
true: { root: 'opacity-[0.64]' },
|
|
61
|
+
},
|
|
62
|
+
legendVariant: {
|
|
63
|
+
legend: { legend: 'text-base font-semibold' },
|
|
64
|
+
label: { legend: 'text-sm font-medium' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
defaultVariants: {
|
|
68
|
+
orientation: 'vertical',
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
interface FieldState {
|
|
73
|
+
invalid: boolean;
|
|
74
|
+
disabled: boolean;
|
|
75
|
+
required: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const FieldContext = createContext<FieldState>({
|
|
79
|
+
invalid: false,
|
|
80
|
+
disabled: false,
|
|
81
|
+
required: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export interface FieldProps extends ViewProps {
|
|
85
|
+
className?: string;
|
|
86
|
+
/** `horizontal` puts a label beside the control instead of above it. */
|
|
87
|
+
orientation?: 'vertical' | 'horizontal';
|
|
88
|
+
/** Marks every `Field.Label`/`Field.Description` below as invalid. */
|
|
89
|
+
invalid?: boolean;
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
/** Marks every `Field.Label` below as required, same as `Label`'s own prop. */
|
|
92
|
+
required?: boolean;
|
|
93
|
+
children?: ReactNode;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const FieldRoot = forwardRef<View, FieldProps>(
|
|
97
|
+
(
|
|
98
|
+
{
|
|
99
|
+
className,
|
|
100
|
+
orientation = 'vertical',
|
|
101
|
+
invalid = false,
|
|
102
|
+
disabled = false,
|
|
103
|
+
required = false,
|
|
104
|
+
children,
|
|
105
|
+
...props
|
|
106
|
+
},
|
|
107
|
+
ref
|
|
108
|
+
) => {
|
|
109
|
+
const { root } = fieldVariants({ orientation, disabled });
|
|
110
|
+
const state = useMemo(
|
|
111
|
+
() => ({ invalid, disabled, required }),
|
|
112
|
+
[invalid, disabled, required]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<FieldContext.Provider value={state}>
|
|
117
|
+
<View
|
|
118
|
+
ref={ref}
|
|
119
|
+
role="group"
|
|
120
|
+
accessibilityState={{ disabled }}
|
|
121
|
+
className={root({ className })}
|
|
122
|
+
{...props}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</View>
|
|
126
|
+
</FieldContext.Provider>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
FieldRoot.displayName = 'Field';
|
|
131
|
+
|
|
132
|
+
export interface FieldContentProps extends ViewProps {
|
|
133
|
+
className?: string;
|
|
134
|
+
children?: ReactNode;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const FieldContent = forwardRef<View, FieldContentProps>(
|
|
138
|
+
({ className, ...props }, ref) => {
|
|
139
|
+
const { content } = fieldVariants();
|
|
140
|
+
return <View ref={ref} className={content({ className })} {...props} />;
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
FieldContent.displayName = 'Field.Content';
|
|
144
|
+
|
|
145
|
+
export interface FieldLabelProps extends LabelProps {}
|
|
146
|
+
|
|
147
|
+
const FieldLabel = forwardRef<View, FieldLabelProps>(
|
|
148
|
+
({ isRequired, isInvalid, isDisabled, ...props }, ref) => {
|
|
149
|
+
const ctx = useContext(FieldContext);
|
|
150
|
+
return (
|
|
151
|
+
<Label
|
|
152
|
+
ref={ref}
|
|
153
|
+
isRequired={isRequired ?? ctx.required}
|
|
154
|
+
isInvalid={isInvalid ?? ctx.invalid}
|
|
155
|
+
isDisabled={isDisabled ?? ctx.disabled}
|
|
156
|
+
{...props}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
FieldLabel.displayName = 'Field.Label';
|
|
162
|
+
|
|
163
|
+
export interface FieldDescriptionProps extends TextProps {
|
|
164
|
+
className?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const FieldDescription = forwardRef<RNText, FieldDescriptionProps>(
|
|
168
|
+
({ className, ...props }, ref) => {
|
|
169
|
+
const { disabled } = useContext(FieldContext);
|
|
170
|
+
const { description } = fieldVariants({ disabled });
|
|
171
|
+
return <Text ref={ref} className={description({ className })} {...props} />;
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
FieldDescription.displayName = 'Field.Description';
|
|
175
|
+
|
|
176
|
+
export interface FieldErrorProps extends Omit<ViewProps, 'children'> {
|
|
177
|
+
className?: string;
|
|
178
|
+
/**
|
|
179
|
+
* Error messages to render, deduplicated by message. A single entry renders
|
|
180
|
+
* as plain text; more than one renders as a bulleted list, since RN has no
|
|
181
|
+
* `<ul>`. Prefer this over `children` when the messages come from a form's
|
|
182
|
+
* validation state, which is naturally an array.
|
|
183
|
+
*/
|
|
184
|
+
errors?: Array<string | { message?: string } | null | undefined>;
|
|
185
|
+
children?: ReactNode;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const FieldError = forwardRef<View, FieldErrorProps>(
|
|
189
|
+
({ className, errors, children, ...props }, ref) => {
|
|
190
|
+
const messages = useMemo(() => {
|
|
191
|
+
if (!errors) return null;
|
|
192
|
+
const seen = new Set<string>();
|
|
193
|
+
const out: string[] = [];
|
|
194
|
+
for (const item of errors) {
|
|
195
|
+
const message = typeof item === 'string' ? item : item?.message;
|
|
196
|
+
if (message && !seen.has(message)) {
|
|
197
|
+
seen.add(message);
|
|
198
|
+
out.push(message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}, [errors]);
|
|
203
|
+
|
|
204
|
+
const hasContent = messages ? messages.length > 0 : !!children;
|
|
205
|
+
if (!hasContent) return null;
|
|
206
|
+
|
|
207
|
+
const { error, errorText, bullet, bulletDot } = fieldVariants();
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<View
|
|
211
|
+
ref={ref}
|
|
212
|
+
// Web-aligned `role`, same as `Separator` already uses — RN's older
|
|
213
|
+
// accessibilityRole list has no alert, the ARIA-aligned one does.
|
|
214
|
+
role="alert"
|
|
215
|
+
accessibilityLiveRegion="polite"
|
|
216
|
+
className={error({ className })}
|
|
217
|
+
{...props}
|
|
218
|
+
>
|
|
219
|
+
{messages ? (
|
|
220
|
+
messages.length === 1 ? (
|
|
221
|
+
<Text className={errorText()}>{messages[0]}</Text>
|
|
222
|
+
) : (
|
|
223
|
+
messages.map((message) => (
|
|
224
|
+
<View key={message} className={bullet()}>
|
|
225
|
+
<Text className={bulletDot()}>{'•'}</Text>
|
|
226
|
+
<Text className={errorText()}>{message}</Text>
|
|
227
|
+
</View>
|
|
228
|
+
))
|
|
229
|
+
)
|
|
230
|
+
) : typeof children === 'string' ? (
|
|
231
|
+
<Text className={errorText()}>{children}</Text>
|
|
232
|
+
) : (
|
|
233
|
+
children
|
|
234
|
+
)}
|
|
235
|
+
</View>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
FieldError.displayName = 'Field.Error';
|
|
240
|
+
|
|
241
|
+
export interface FieldSetProps extends ViewProps {
|
|
242
|
+
className?: string;
|
|
243
|
+
children?: ReactNode;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const FieldSet = forwardRef<View, FieldSetProps>(
|
|
247
|
+
({ className, ...props }, ref) => {
|
|
248
|
+
const { set } = fieldVariants();
|
|
249
|
+
return <View ref={ref} role="group" className={set({ className })} {...props} />;
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
FieldSet.displayName = 'Field.Set';
|
|
253
|
+
|
|
254
|
+
export interface FieldLegendProps extends TextProps {
|
|
255
|
+
className?: string;
|
|
256
|
+
/** `legend` reads as a section heading; `label` sits closer to a field label. */
|
|
257
|
+
variant?: 'legend' | 'label';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const FieldLegend = forwardRef<RNText, FieldLegendProps>(
|
|
261
|
+
({ className, variant = 'legend', ...props }, ref) => {
|
|
262
|
+
const { legend } = fieldVariants({ legendVariant: variant });
|
|
263
|
+
return (
|
|
264
|
+
<Text ref={ref} role="heading" className={legend({ className })} {...props} />
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
FieldLegend.displayName = 'Field.Legend';
|
|
269
|
+
|
|
270
|
+
export interface FieldGroupProps extends ViewProps {
|
|
271
|
+
className?: string;
|
|
272
|
+
children?: ReactNode;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const FieldGroup = forwardRef<View, FieldGroupProps>(
|
|
276
|
+
({ className, ...props }, ref) => {
|
|
277
|
+
const { group } = fieldVariants();
|
|
278
|
+
return <View ref={ref} className={group({ className })} {...props} />;
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
FieldGroup.displayName = 'Field.Group';
|
|
282
|
+
|
|
283
|
+
export interface FieldSeparatorProps extends ViewProps {
|
|
284
|
+
className?: string;
|
|
285
|
+
/** Centered text, e.g. "Or" — omit for a plain rule. */
|
|
286
|
+
children?: ReactNode;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const FieldSeparator = forwardRef<View, FieldSeparatorProps>(
|
|
290
|
+
({ className, children, ...props }, ref) => {
|
|
291
|
+
const { separator, separatorLabel } = fieldVariants();
|
|
292
|
+
return (
|
|
293
|
+
<View ref={ref} className={separator({ className })} {...props}>
|
|
294
|
+
<Separator className="flex-1" />
|
|
295
|
+
{children ? (
|
|
296
|
+
<>
|
|
297
|
+
<Text className={separatorLabel()}>{children}</Text>
|
|
298
|
+
<Separator className="flex-1" />
|
|
299
|
+
</>
|
|
300
|
+
) : null}
|
|
301
|
+
</View>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
FieldSeparator.displayName = 'Field.Separator';
|
|
306
|
+
|
|
307
|
+
export interface FieldTitleProps extends TextProps {
|
|
308
|
+
className?: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const FieldTitle = forwardRef<RNText, FieldTitleProps>(
|
|
312
|
+
({ className, ...props }, ref) => {
|
|
313
|
+
const { title } = fieldVariants();
|
|
314
|
+
return <Text ref={ref} className={title({ className })} {...props} />;
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
FieldTitle.displayName = 'Field.Title';
|
|
318
|
+
|
|
319
|
+
export const Field = Object.assign(FieldRoot, {
|
|
320
|
+
Content: FieldContent,
|
|
321
|
+
Label: FieldLabel,
|
|
322
|
+
Description: FieldDescription,
|
|
323
|
+
Error: FieldError,
|
|
324
|
+
Set: FieldSet,
|
|
325
|
+
Legend: FieldLegend,
|
|
326
|
+
Group: FieldGroup,
|
|
327
|
+
Separator: FieldSeparator,
|
|
328
|
+
Title: FieldTitle,
|
|
329
|
+
});
|