@pickle-ui/react-native 1.0.0-beta.1 → 1.0.0-beta.3
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 +62 -24
- package/dist/components/Badge.js +1 -1
- package/dist/components/Badge.js.map +1 -1
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Button.js +27 -23
- package/dist/components/Button.js.map +1 -1
- package/dist/components/Card.d.ts.map +1 -1
- package/dist/components/Card.js +7 -4
- package/dist/components/Card.js.map +1 -1
- package/dist/components/Flex.d.ts +25 -0
- package/dist/components/Flex.d.ts.map +1 -0
- package/dist/components/Flex.js +24 -0
- package/dist/components/Flex.js.map +1 -0
- package/dist/components/Grid.d.ts +36 -0
- package/dist/components/Grid.d.ts.map +1 -0
- package/dist/components/Grid.js +68 -0
- package/dist/components/Grid.js.map +1 -0
- package/dist/components/Separator.d.ts +11 -0
- package/dist/components/Separator.d.ts.map +1 -0
- package/dist/components/Separator.js +12 -0
- package/dist/components/Separator.js.map +1 -0
- package/dist/components/Spinner.d.ts +14 -0
- package/dist/components/Spinner.d.ts.map +1 -0
- package/dist/components/Spinner.js +41 -0
- package/dist/components/Spinner.js.map +1 -0
- package/dist/components/Text.d.ts.map +1 -1
- package/dist/components/Text.js +5 -4
- package/dist/components/Text.js.map +1 -1
- package/dist/components/XStack.d.ts +4 -0
- package/dist/components/XStack.d.ts.map +1 -0
- package/dist/components/XStack.js +6 -0
- package/dist/components/XStack.js.map +1 -0
- package/dist/components/YStack.d.ts +4 -0
- package/dist/components/YStack.d.ts.map +1 -0
- package/dist/components/YStack.js +6 -0
- package/dist/components/YStack.js.map +1 -0
- package/dist/components/_internal/InputFrame.d.ts +13 -0
- package/dist/components/_internal/InputFrame.d.ts.map +1 -0
- package/dist/components/_internal/InputFrame.js +14 -0
- package/dist/components/_internal/InputFrame.js.map +1 -0
- package/dist/components/_internal/StoryItem.d.ts +6 -0
- package/dist/components/_internal/StoryItem.d.ts.map +1 -0
- package/dist/components/_internal/StoryItem.js +9 -0
- package/dist/components/_internal/StoryItem.js.map +1 -0
- package/dist/components/_internal/layout.d.ts +79 -0
- package/dist/components/_internal/layout.d.ts.map +1 -0
- package/dist/components/_internal/layout.js +73 -0
- package/dist/components/_internal/layout.js.map +1 -0
- package/dist/components/_internal/useControllableState.d.ts +7 -0
- package/dist/components/_internal/useControllableState.d.ts.map +1 -0
- package/dist/components/_internal/useControllableState.js +17 -0
- package/dist/components/_internal/useControllableState.js.map +1 -0
- package/dist/components/form/Checkbox.d.ts +26 -0
- package/dist/components/form/Checkbox.d.ts.map +1 -0
- package/dist/components/form/Checkbox.js +43 -0
- package/dist/components/form/Checkbox.js.map +1 -0
- package/dist/components/form/Form.context.d.ts +34 -0
- package/dist/components/form/Form.context.d.ts.map +1 -0
- package/dist/components/form/Form.context.js +53 -0
- package/dist/components/form/Form.context.js.map +1 -0
- package/dist/components/form/Form.d.ts +33 -0
- package/dist/components/form/Form.d.ts.map +1 -0
- package/dist/components/form/Form.js +36 -0
- package/dist/components/form/Form.js.map +1 -0
- package/dist/components/form/FormCheckbox.d.ts +7 -0
- package/dist/components/form/FormCheckbox.d.ts.map +1 -0
- package/dist/components/form/FormCheckbox.js +24 -0
- package/dist/components/form/FormCheckbox.js.map +1 -0
- package/dist/components/form/FormDescription.d.ts +4 -0
- package/dist/components/form/FormDescription.d.ts.map +1 -0
- package/dist/components/form/FormDescription.js +7 -0
- package/dist/components/form/FormDescription.js.map +1 -0
- package/dist/components/form/FormError.d.ts +5 -0
- package/dist/components/form/FormError.d.ts.map +1 -0
- package/dist/components/form/FormError.js +8 -0
- package/dist/components/form/FormError.js.map +1 -0
- package/dist/components/form/FormField.d.ts +17 -0
- package/dist/components/form/FormField.d.ts.map +1 -0
- package/dist/components/form/FormField.js +36 -0
- package/dist/components/form/FormField.js.map +1 -0
- package/dist/components/form/FormInput.d.ts +10 -0
- package/dist/components/form/FormInput.d.ts.map +1 -0
- package/dist/components/form/FormInput.js +36 -0
- package/dist/components/form/FormInput.js.map +1 -0
- package/dist/components/form/FormInputGroup.d.ts +8 -0
- package/dist/components/form/FormInputGroup.d.ts.map +1 -0
- package/dist/components/form/FormInputGroup.js +18 -0
- package/dist/components/form/FormInputGroup.js.map +1 -0
- package/dist/components/form/FormLabel.d.ts +4 -0
- package/dist/components/form/FormLabel.d.ts.map +1 -0
- package/dist/components/form/FormLabel.js +7 -0
- package/dist/components/form/FormLabel.js.map +1 -0
- package/dist/components/form/FormRadioGroup.d.ts +11 -0
- package/dist/components/form/FormRadioGroup.d.ts.map +1 -0
- package/dist/components/form/FormRadioGroup.js +24 -0
- package/dist/components/form/FormRadioGroup.js.map +1 -0
- package/dist/components/form/FormRoot.d.ts +13 -0
- package/dist/components/form/FormRoot.d.ts.map +1 -0
- package/dist/components/form/FormRoot.js +19 -0
- package/dist/components/form/FormRoot.js.map +1 -0
- package/dist/components/form/FormTextarea.d.ts +7 -0
- package/dist/components/form/FormTextarea.d.ts.map +1 -0
- package/dist/components/form/FormTextarea.js +27 -0
- package/dist/components/form/FormTextarea.js.map +1 -0
- package/dist/components/form/Input.d.ts +17 -0
- package/dist/components/form/Input.d.ts.map +1 -0
- package/dist/components/form/Input.js +32 -0
- package/dist/components/form/Input.js.map +1 -0
- package/dist/components/form/InputGroup.context.d.ts +10 -0
- package/dist/components/form/InputGroup.context.d.ts.map +1 -0
- package/dist/components/form/InputGroup.context.js +8 -0
- package/dist/components/form/InputGroup.context.js.map +1 -0
- package/dist/components/form/InputGroup.d.ts +31 -0
- package/dist/components/form/InputGroup.d.ts.map +1 -0
- package/dist/components/form/InputGroup.js +123 -0
- package/dist/components/form/InputGroup.js.map +1 -0
- package/dist/components/form/RadioGroup.d.ts +29 -0
- package/dist/components/form/RadioGroup.d.ts.map +1 -0
- package/dist/components/form/RadioGroup.js +42 -0
- package/dist/components/form/RadioGroup.js.map +1 -0
- package/dist/components/form/Textarea.d.ts +19 -0
- package/dist/components/form/Textarea.d.ts.map +1 -0
- package/dist/components/form/Textarea.js +26 -0
- package/dist/components/form/Textarea.js.map +1 -0
- package/dist/entries/checkbox.d.ts +3 -0
- package/dist/entries/checkbox.d.ts.map +1 -0
- package/dist/entries/checkbox.js +2 -0
- package/dist/entries/checkbox.js.map +1 -0
- package/dist/entries/flex.d.ts +3 -0
- package/dist/entries/flex.d.ts.map +1 -0
- package/dist/entries/flex.js +2 -0
- package/dist/entries/flex.js.map +1 -0
- package/dist/entries/form.d.ts +3 -0
- package/dist/entries/form.d.ts.map +1 -0
- package/dist/entries/form.js +2 -0
- package/dist/entries/form.js.map +1 -0
- package/dist/entries/grid.d.ts +3 -0
- package/dist/entries/grid.d.ts.map +1 -0
- package/dist/entries/grid.js +2 -0
- package/dist/entries/grid.js.map +1 -0
- package/dist/entries/input-group.d.ts +3 -0
- package/dist/entries/input-group.d.ts.map +1 -0
- package/dist/entries/input-group.js +2 -0
- package/dist/entries/input-group.js.map +1 -0
- package/dist/entries/input.d.ts +3 -0
- package/dist/entries/input.d.ts.map +1 -0
- package/dist/entries/input.js +2 -0
- package/dist/entries/input.js.map +1 -0
- package/dist/entries/radio-group.d.ts +3 -0
- package/dist/entries/radio-group.d.ts.map +1 -0
- package/dist/entries/radio-group.js +2 -0
- package/dist/entries/radio-group.js.map +1 -0
- package/dist/entries/separator.d.ts +3 -0
- package/dist/entries/separator.d.ts.map +1 -0
- package/dist/entries/separator.js +2 -0
- package/dist/entries/separator.js.map +1 -0
- package/dist/entries/spinner.d.ts +3 -0
- package/dist/entries/spinner.d.ts.map +1 -0
- package/dist/entries/spinner.js +2 -0
- package/dist/entries/spinner.js.map +1 -0
- package/dist/entries/textarea.d.ts +3 -0
- package/dist/entries/textarea.d.ts.map +1 -0
- package/dist/entries/textarea.js +2 -0
- package/dist/entries/textarea.js.map +1 -0
- package/dist/entries/x-stack.d.ts +3 -0
- package/dist/entries/x-stack.d.ts.map +1 -0
- package/dist/entries/x-stack.js +2 -0
- package/dist/entries/x-stack.js.map +1 -0
- package/dist/entries/y-stack.d.ts +3 -0
- package/dist/entries/y-stack.d.ts.map +1 -0
- package/dist/entries/y-stack.js +2 -0
- package/dist/entries/y-stack.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/package.json +8 -3
- package/src/base.css +23 -5
- package/src/components/Badge.tsx +1 -1
- package/src/components/Button.tsx +27 -23
- package/src/components/Card.tsx +12 -8
- package/src/components/Flex.stories.tsx +58 -0
- package/src/components/Flex.test.tsx +57 -0
- package/src/components/Flex.tsx +52 -0
- package/src/components/Grid.stories.tsx +55 -0
- package/src/components/Grid.test.tsx +140 -0
- package/src/components/Grid.tsx +166 -0
- package/src/components/Separator.stories.tsx +39 -0
- package/src/components/Separator.test.tsx +25 -0
- package/src/components/Separator.tsx +30 -0
- package/src/components/Spinner.stories.tsx +35 -0
- package/src/components/Spinner.test.tsx +30 -0
- package/src/components/Spinner.tsx +75 -0
- package/src/components/Text.stories.tsx +49 -0
- package/src/components/Text.tsx +5 -4
- package/src/components/XStack.stories.tsx +46 -0
- package/src/components/XStack.test.tsx +13 -0
- package/src/components/XStack.tsx +7 -0
- package/src/components/YStack.stories.tsx +46 -0
- package/src/components/YStack.test.tsx +13 -0
- package/src/components/YStack.tsx +7 -0
- package/src/components/_internal/InputFrame.tsx +50 -0
- package/src/components/_internal/StoryItem.tsx +24 -0
- package/src/components/_internal/layout.ts +103 -0
- package/src/components/_internal/useControllableState.ts +25 -0
- package/src/components/form/Checkbox.stories.tsx +67 -0
- package/src/components/form/Checkbox.test.tsx +88 -0
- package/src/components/form/Checkbox.tsx +167 -0
- package/src/components/form/Form.context.ts +91 -0
- package/src/components/form/Form.stories.tsx +152 -0
- package/src/components/form/Form.test.tsx +195 -0
- package/src/components/form/Form.tsx +51 -0
- package/src/components/form/FormCheckbox.tsx +42 -0
- package/src/components/form/FormDescription.tsx +14 -0
- package/src/components/form/FormError.tsx +20 -0
- package/src/components/form/FormField.tsx +82 -0
- package/src/components/form/FormInput.tsx +70 -0
- package/src/components/form/FormInputGroup.tsx +30 -0
- package/src/components/form/FormLabel.tsx +17 -0
- package/src/components/form/FormRadioGroup.tsx +48 -0
- package/src/components/form/FormRoot.tsx +41 -0
- package/src/components/form/FormTextarea.tsx +56 -0
- package/src/components/form/Input.stories.tsx +105 -0
- package/src/components/form/Input.test.tsx +76 -0
- package/src/components/form/Input.tsx +81 -0
- package/src/components/form/InputGroup.context.ts +11 -0
- package/src/components/form/InputGroup.stories.tsx +129 -0
- package/src/components/form/InputGroup.test.tsx +95 -0
- package/src/components/form/InputGroup.tsx +266 -0
- package/src/components/form/RadioGroup.stories.tsx +62 -0
- package/src/components/form/RadioGroup.test.tsx +98 -0
- package/src/components/form/RadioGroup.tsx +151 -0
- package/src/components/form/Textarea.stories.tsx +61 -0
- package/src/components/form/Textarea.test.tsx +35 -0
- package/src/components/form/Textarea.tsx +74 -0
- package/src/entries/checkbox.ts +2 -0
- package/src/entries/flex.ts +2 -0
- package/src/entries/form.ts +13 -0
- package/src/entries/grid.ts +2 -0
- package/src/entries/input-group.ts +8 -0
- package/src/entries/input.ts +2 -0
- package/src/entries/radio-group.ts +5 -0
- package/src/entries/separator.ts +2 -0
- package/src/entries/spinner.ts +2 -0
- package/src/entries/textarea.ts +2 -0
- package/src/entries/x-stack.ts +2 -0
- package/src/entries/y-stack.ts +2 -0
- package/src/fonts.test.tsx +244 -0
- package/src/index.ts +12 -0
- package/src/styles.css +12 -0
- package/dist/components/_internal/Spinner.d.ts +0 -9
- package/dist/components/_internal/Spinner.d.ts.map +0 -1
- package/dist/components/_internal/Spinner.js +0 -31
- package/dist/components/_internal/Spinner.js.map +0 -1
- package/src/components/_internal/Spinner.tsx +0 -49
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useForm } from 'react-hook-form';
|
|
6
|
+
import { z } from 'zod/v4';
|
|
7
|
+
|
|
8
|
+
import { Story } from '../_internal/Story';
|
|
9
|
+
import { Text } from '../Text';
|
|
10
|
+
import { Form } from './Form';
|
|
11
|
+
import { InputGroup } from './InputGroup';
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
component: Form,
|
|
15
|
+
parameters: { layout: 'fullscreen' },
|
|
16
|
+
title: 'components/Form',
|
|
17
|
+
} satisfies Meta<typeof Form>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
type StoryDefinition = StoryObj;
|
|
22
|
+
|
|
23
|
+
const notificationChannels: {
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
label: string;
|
|
26
|
+
value: string;
|
|
27
|
+
}[] = [
|
|
28
|
+
{ label: 'Email', value: 'email' },
|
|
29
|
+
{ label: 'SMS', value: 'sms' },
|
|
30
|
+
{ disabled: true, label: 'Push (coming soon)', value: 'push' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const signUpSchema = z.object({
|
|
34
|
+
bio: z.string().max(160, 'Keep it under 160 characters.'),
|
|
35
|
+
email: z.email('Enter a valid email address.'),
|
|
36
|
+
firstName: z.string().trim().min(1, 'Enter your first name.'),
|
|
37
|
+
lastName: z.string().trim().min(1, 'Enter your last name.'),
|
|
38
|
+
notificationChannel: z.enum(['email', 'sms']),
|
|
39
|
+
password: z.string().min(8, 'Use at least 8 characters.'),
|
|
40
|
+
terms: z.boolean().refine(Boolean, 'Accept the terms to continue.'),
|
|
41
|
+
username: z.string().trim().min(1, 'Enter a username.'),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
type SignUpValues = z.infer<typeof signUpSchema>;
|
|
45
|
+
|
|
46
|
+
function SignUpForm() {
|
|
47
|
+
const form = useForm<SignUpValues>({
|
|
48
|
+
defaultValues: {
|
|
49
|
+
bio: '',
|
|
50
|
+
email: '',
|
|
51
|
+
firstName: '',
|
|
52
|
+
lastName: '',
|
|
53
|
+
notificationChannel: 'email',
|
|
54
|
+
password: '',
|
|
55
|
+
terms: false,
|
|
56
|
+
username: '',
|
|
57
|
+
},
|
|
58
|
+
resolver: zodResolver(signUpSchema),
|
|
59
|
+
});
|
|
60
|
+
const [submitted, setSubmitted] = useState<null | SignUpValues>(null);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Form className="max-w-md" form={form} onSubmit={setSubmitted}>
|
|
64
|
+
<Form.Field
|
|
65
|
+
description="We'll send a confirmation link to this address."
|
|
66
|
+
label="Email"
|
|
67
|
+
name="email"
|
|
68
|
+
>
|
|
69
|
+
<Form.Input
|
|
70
|
+
autoCapitalize="none"
|
|
71
|
+
autoComplete="email"
|
|
72
|
+
inputMode="email"
|
|
73
|
+
/>
|
|
74
|
+
</Form.Field>
|
|
75
|
+
|
|
76
|
+
<Form.Field label="Username" name="username">
|
|
77
|
+
<Form.InputGroup>
|
|
78
|
+
<InputGroup.Addon>@</InputGroup.Addon>
|
|
79
|
+
<Form.Input autoCapitalize="none" autoComplete="username" />
|
|
80
|
+
</Form.InputGroup>
|
|
81
|
+
</Form.Field>
|
|
82
|
+
|
|
83
|
+
<Form.Field
|
|
84
|
+
description="At least 8 characters."
|
|
85
|
+
label="Password"
|
|
86
|
+
name="password"
|
|
87
|
+
>
|
|
88
|
+
<Form.Input autoComplete="new-password" secureTextEntry />
|
|
89
|
+
</Form.Field>
|
|
90
|
+
|
|
91
|
+
<Form.Field label="Full name">
|
|
92
|
+
<Form.InputGroup>
|
|
93
|
+
<Form.Input
|
|
94
|
+
accessibilityLabel="First name"
|
|
95
|
+
name="firstName"
|
|
96
|
+
placeholder="First"
|
|
97
|
+
/>
|
|
98
|
+
<Form.Input
|
|
99
|
+
accessibilityLabel="Last name"
|
|
100
|
+
name="lastName"
|
|
101
|
+
placeholder="Last"
|
|
102
|
+
/>
|
|
103
|
+
</Form.InputGroup>
|
|
104
|
+
</Form.Field>
|
|
105
|
+
|
|
106
|
+
<Form.Field
|
|
107
|
+
description="A sentence or two for your public profile."
|
|
108
|
+
label="Bio"
|
|
109
|
+
name="bio"
|
|
110
|
+
>
|
|
111
|
+
<Form.Textarea placeholder="Tell us about yourself" />
|
|
112
|
+
</Form.Field>
|
|
113
|
+
|
|
114
|
+
<Form.Field
|
|
115
|
+
description="How should we reach you for account alerts?"
|
|
116
|
+
label="Notification channel"
|
|
117
|
+
name="notificationChannel"
|
|
118
|
+
>
|
|
119
|
+
<Form.RadioGroup>
|
|
120
|
+
{notificationChannels.map((channel) => (
|
|
121
|
+
<Form.RadioGroup.Item
|
|
122
|
+
disabled={channel.disabled}
|
|
123
|
+
key={channel.value}
|
|
124
|
+
label={channel.label}
|
|
125
|
+
value={channel.value}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</Form.RadioGroup>
|
|
129
|
+
</Form.Field>
|
|
130
|
+
|
|
131
|
+
<Form.Field name="terms">
|
|
132
|
+
<Form.Checkbox label="I agree to the terms of service and privacy policy" />
|
|
133
|
+
</Form.Field>
|
|
134
|
+
|
|
135
|
+
<Form.Button>Create account</Form.Button>
|
|
136
|
+
|
|
137
|
+
{submitted ? (
|
|
138
|
+
<Text tone="muted">Account created for {submitted.email}.</Text>
|
|
139
|
+
) : null}
|
|
140
|
+
</Form>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const SignUp: StoryDefinition = {
|
|
145
|
+
render: () => (
|
|
146
|
+
<Story.Layout className="max-w-lg" title="Form">
|
|
147
|
+
<Story.Section title="Sign up">
|
|
148
|
+
<SignUpForm />
|
|
149
|
+
</Story.Section>
|
|
150
|
+
</Story.Layout>
|
|
151
|
+
),
|
|
152
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fireEvent,
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
} from '@testing-library/react-native';
|
|
7
|
+
import { useEffect } from 'react';
|
|
8
|
+
import { useForm } from 'react-hook-form';
|
|
9
|
+
|
|
10
|
+
import { Form } from './Form';
|
|
11
|
+
import { InputGroup } from './InputGroup';
|
|
12
|
+
|
|
13
|
+
type TestFormValues = {
|
|
14
|
+
channel: string;
|
|
15
|
+
email: string;
|
|
16
|
+
message: string;
|
|
17
|
+
terms: boolean;
|
|
18
|
+
username: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const defaultValues: TestFormValues = {
|
|
22
|
+
channel: 'email',
|
|
23
|
+
email: '',
|
|
24
|
+
message: '',
|
|
25
|
+
terms: false,
|
|
26
|
+
username: '',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function TestForm({
|
|
30
|
+
fieldErrors,
|
|
31
|
+
onSubmit,
|
|
32
|
+
}: {
|
|
33
|
+
fieldErrors?: Partial<Record<keyof TestFormValues, string>>;
|
|
34
|
+
onSubmit: (values: TestFormValues) => void;
|
|
35
|
+
}) {
|
|
36
|
+
const form = useForm<TestFormValues>({ defaultValues });
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
for (const [name, message] of Object.entries(fieldErrors ?? {}) as [
|
|
40
|
+
keyof TestFormValues,
|
|
41
|
+
string,
|
|
42
|
+
][]) {
|
|
43
|
+
form.setError(name, { message, type: 'manual' });
|
|
44
|
+
}
|
|
45
|
+
}, [fieldErrors, form]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Form form={form} onSubmit={onSubmit}>
|
|
49
|
+
<Form.Field
|
|
50
|
+
description="We'll send a confirmation link."
|
|
51
|
+
label="Email"
|
|
52
|
+
name="email"
|
|
53
|
+
>
|
|
54
|
+
<Form.Input autoComplete="email" />
|
|
55
|
+
</Form.Field>
|
|
56
|
+
|
|
57
|
+
<Form.Field label="Username" name="username">
|
|
58
|
+
<Form.InputGroup testID="username-group">
|
|
59
|
+
<InputGroup.Addon>@</InputGroup.Addon>
|
|
60
|
+
<Form.Input />
|
|
61
|
+
</Form.InputGroup>
|
|
62
|
+
</Form.Field>
|
|
63
|
+
|
|
64
|
+
<Form.Field label="Message" name="message">
|
|
65
|
+
<Form.Textarea />
|
|
66
|
+
</Form.Field>
|
|
67
|
+
|
|
68
|
+
<Form.Field label="Notification channel" name="channel">
|
|
69
|
+
<Form.RadioGroup>
|
|
70
|
+
<Form.RadioGroup.Item label="Email" value="email" />
|
|
71
|
+
<Form.RadioGroup.Item label="SMS" value="sms" />
|
|
72
|
+
</Form.RadioGroup>
|
|
73
|
+
</Form.Field>
|
|
74
|
+
|
|
75
|
+
<Form.Field name="terms">
|
|
76
|
+
<Form.Checkbox label="Accept terms" />
|
|
77
|
+
</Form.Field>
|
|
78
|
+
|
|
79
|
+
<Form.Button>Submit</Form.Button>
|
|
80
|
+
</Form>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('Form', () => {
|
|
85
|
+
it('binds fields and submits React Hook Form values', async () => {
|
|
86
|
+
const handleSubmit = jest.fn();
|
|
87
|
+
await render(<TestForm onSubmit={handleSubmit} />);
|
|
88
|
+
|
|
89
|
+
await fireEvent.changeText(
|
|
90
|
+
screen.getByLabelText('Email'),
|
|
91
|
+
'ugo@example.com',
|
|
92
|
+
);
|
|
93
|
+
await fireEvent.changeText(screen.getByLabelText('Username'), 'ugo');
|
|
94
|
+
await fireEvent.changeText(screen.getByLabelText('Message'), 'Hello');
|
|
95
|
+
await fireEvent.press(
|
|
96
|
+
screen.getByRole('checkbox', { name: 'Accept terms' }),
|
|
97
|
+
);
|
|
98
|
+
await fireEvent.press(screen.getByRole('radio', { name: 'SMS' }));
|
|
99
|
+
await fireEvent.press(screen.getByRole('button', { name: 'Submit' }));
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(handleSubmit).toHaveBeenCalledWith(
|
|
103
|
+
{
|
|
104
|
+
channel: 'sms',
|
|
105
|
+
email: 'ugo@example.com',
|
|
106
|
+
message: 'Hello',
|
|
107
|
+
terms: true,
|
|
108
|
+
username: 'ugo',
|
|
109
|
+
},
|
|
110
|
+
undefined,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('submits from the return key, like Enter on the web', async () => {
|
|
116
|
+
const handleSubmit = jest.fn();
|
|
117
|
+
await render(<TestForm onSubmit={handleSubmit} />);
|
|
118
|
+
|
|
119
|
+
await fireEvent(screen.getByLabelText('Email'), 'submitEditing');
|
|
120
|
+
|
|
121
|
+
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('shows the description until the field has an error', async () => {
|
|
125
|
+
await render(
|
|
126
|
+
<TestForm
|
|
127
|
+
fieldErrors={{ email: 'Enter a valid email address.' }}
|
|
128
|
+
onSubmit={jest.fn()}
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(
|
|
133
|
+
await screen.findByText('Enter a valid email address.'),
|
|
134
|
+
).toBeOnTheScreen();
|
|
135
|
+
expect(screen.queryByText("We'll send a confirmation link.")).toBeNull();
|
|
136
|
+
expect(screen.getByLabelText('Email').parent).toHaveProp(
|
|
137
|
+
'className',
|
|
138
|
+
expect.stringContaining('border-destructive/36'),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('frames an input group as invalid from the field', async () => {
|
|
143
|
+
await render(
|
|
144
|
+
<TestForm
|
|
145
|
+
fieldErrors={{ username: 'Pick another username.' }}
|
|
146
|
+
onSubmit={jest.fn()}
|
|
147
|
+
/>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(await screen.findByText('Pick another username.')).toBeOnTheScreen();
|
|
151
|
+
expect(screen.getByTestId('username-group')).toHaveProp(
|
|
152
|
+
'className',
|
|
153
|
+
expect.stringContaining('border-destructive/36'),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('marks a checkbox invalid from React Hook Form state', async () => {
|
|
158
|
+
await render(
|
|
159
|
+
<TestForm
|
|
160
|
+
fieldErrors={{ terms: 'Accept the terms to continue.' }}
|
|
161
|
+
onSubmit={jest.fn()}
|
|
162
|
+
/>,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(
|
|
166
|
+
await screen.findByText('Accept the terms to continue.'),
|
|
167
|
+
).toBeOnTheScreen();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('names the radio group by the field label', async () => {
|
|
171
|
+
await render(<TestForm onSubmit={jest.fn()} />);
|
|
172
|
+
|
|
173
|
+
expect(screen.getByLabelText('Notification channel')).toHaveProp(
|
|
174
|
+
'role',
|
|
175
|
+
'radiogroup',
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('throws when a binding has no field name', async () => {
|
|
180
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
181
|
+
|
|
182
|
+
function Unnamed() {
|
|
183
|
+
const form = useForm();
|
|
184
|
+
return (
|
|
185
|
+
<Form form={form}>
|
|
186
|
+
<Form.Input />
|
|
187
|
+
</Form>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await expect(render(<Unnamed />)).rejects.toThrow(
|
|
192
|
+
'Form.Input requires a name prop or a parent <Form.Field name="...">.',
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { use } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Button, type ButtonProps } from '../Button';
|
|
4
|
+
import { FormSubmitContext } from './Form.context';
|
|
5
|
+
import { FormCheckbox, type FormCheckboxProps } from './FormCheckbox';
|
|
6
|
+
import { FormDescription, type FormDescriptionProps } from './FormDescription';
|
|
7
|
+
import { FormError, type FormErrorProps } from './FormError';
|
|
8
|
+
import { FormField, type FormFieldProps } from './FormField';
|
|
9
|
+
import { FormInput, type FormInputProps } from './FormInput';
|
|
10
|
+
import { FormInputGroup, type FormInputGroupProps } from './FormInputGroup';
|
|
11
|
+
import { FormLabel, type FormLabelProps } from './FormLabel';
|
|
12
|
+
import { FormRadioGroup, type FormRadioGroupProps } from './FormRadioGroup';
|
|
13
|
+
import { type FormProps, FormRoot } from './FormRoot';
|
|
14
|
+
import { FormTextarea, type FormTextareaProps } from './FormTextarea';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A `Button` that submits the form when pressed, like the web's submit
|
|
18
|
+
* button. Pass `onPress` to give it another action.
|
|
19
|
+
*/
|
|
20
|
+
function FormButton({ onPress, ...props }: ButtonProps) {
|
|
21
|
+
const submit = use(FormSubmitContext);
|
|
22
|
+
|
|
23
|
+
return <Button onPress={onPress ?? submit} {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const Form = Object.assign(FormRoot, {
|
|
27
|
+
Button: FormButton,
|
|
28
|
+
Checkbox: FormCheckbox,
|
|
29
|
+
Description: FormDescription,
|
|
30
|
+
Error: FormError,
|
|
31
|
+
Field: FormField,
|
|
32
|
+
Input: FormInput,
|
|
33
|
+
InputGroup: FormInputGroup,
|
|
34
|
+
Label: FormLabel,
|
|
35
|
+
RadioGroup: FormRadioGroup,
|
|
36
|
+
Textarea: FormTextarea,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export { Form };
|
|
40
|
+
export type {
|
|
41
|
+
FormCheckboxProps,
|
|
42
|
+
FormDescriptionProps,
|
|
43
|
+
FormErrorProps,
|
|
44
|
+
FormFieldProps,
|
|
45
|
+
FormInputGroupProps,
|
|
46
|
+
FormInputProps,
|
|
47
|
+
FormLabelProps,
|
|
48
|
+
FormProps,
|
|
49
|
+
FormRadioGroupProps,
|
|
50
|
+
FormTextareaProps,
|
|
51
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useController } from 'react-hook-form';
|
|
2
|
+
|
|
3
|
+
import { Checkbox, type CheckboxProps } from './Checkbox';
|
|
4
|
+
import {
|
|
5
|
+
useFormControlProps,
|
|
6
|
+
useFormMethods,
|
|
7
|
+
useRequiredFieldName,
|
|
8
|
+
} from './Form.context';
|
|
9
|
+
|
|
10
|
+
export type FormCheckboxProps = Omit<CheckboxProps, 'checked' | 'invalid'> & {
|
|
11
|
+
name?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A `Checkbox` bound to the field's boolean value. */
|
|
15
|
+
export function FormCheckbox({
|
|
16
|
+
name: nameProp,
|
|
17
|
+
onCheckedChange,
|
|
18
|
+
...props
|
|
19
|
+
}: FormCheckboxProps) {
|
|
20
|
+
const name = useRequiredFieldName(nameProp, 'Form.Checkbox');
|
|
21
|
+
const { control } = useFormMethods();
|
|
22
|
+
const { field } = useController({
|
|
23
|
+
control,
|
|
24
|
+
defaultValue: props.defaultChecked ?? false,
|
|
25
|
+
name,
|
|
26
|
+
});
|
|
27
|
+
const { accessibilityLabel, invalid } = useFormControlProps();
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Checkbox
|
|
31
|
+
// A `label` already names the checkbox.
|
|
32
|
+
accessibilityLabel={props.label ? undefined : accessibilityLabel}
|
|
33
|
+
{...props}
|
|
34
|
+
checked={Boolean(field.value)}
|
|
35
|
+
invalid={invalid}
|
|
36
|
+
onCheckedChange={(checked) => {
|
|
37
|
+
field.onChange(checked);
|
|
38
|
+
onCheckedChange?.(checked);
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Text } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type FormDescriptionProps = React.ComponentProps<typeof Text>;
|
|
6
|
+
|
|
7
|
+
export function FormDescription({ className, ...props }: FormDescriptionProps) {
|
|
8
|
+
return (
|
|
9
|
+
<Text
|
|
10
|
+
className={cn('text-muted-foreground font-sans text-sm', className)}
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Text } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type FormErrorProps = React.ComponentProps<typeof Text>;
|
|
6
|
+
|
|
7
|
+
/** Announced by screen readers when it appears, like the web's live error. */
|
|
8
|
+
export function FormError({ className, ...props }: FormErrorProps) {
|
|
9
|
+
return (
|
|
10
|
+
<Text
|
|
11
|
+
accessibilityLiveRegion="polite"
|
|
12
|
+
className={cn(
|
|
13
|
+
'text-destructive font-sans text-sm font-medium',
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
role="alert"
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type ReactNode, useId, useMemo, useRef } from 'react';
|
|
2
|
+
import { useFormState } from 'react-hook-form';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../lib/cn';
|
|
6
|
+
import {
|
|
7
|
+
FormFieldContext,
|
|
8
|
+
getErrorMessage,
|
|
9
|
+
useFormMethods,
|
|
10
|
+
} from './Form.context';
|
|
11
|
+
import { FormDescription } from './FormDescription';
|
|
12
|
+
import { FormError } from './FormError';
|
|
13
|
+
import { FormLabel } from './FormLabel';
|
|
14
|
+
|
|
15
|
+
export type FormFieldProps = Omit<
|
|
16
|
+
React.ComponentProps<typeof View>,
|
|
17
|
+
'children'
|
|
18
|
+
> & {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
description?: ReactNode;
|
|
21
|
+
/** Replaces the error message React Hook Form holds for the field. */
|
|
22
|
+
error?: ReactNode;
|
|
23
|
+
/** Names the control and focuses it when pressed, like the web's `<label>`. */
|
|
24
|
+
label?: ReactNode;
|
|
25
|
+
name?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A labelled field bound to `name`, like `Form.Field` in @pickle-ui/react:
|
|
30
|
+
* the label, the control, then the field's error or its description.
|
|
31
|
+
*/
|
|
32
|
+
export function FormField({
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
description,
|
|
36
|
+
error,
|
|
37
|
+
label,
|
|
38
|
+
name,
|
|
39
|
+
...props
|
|
40
|
+
}: FormFieldProps) {
|
|
41
|
+
const { control, getFieldState } = useFormMethods();
|
|
42
|
+
const formState = useFormState({
|
|
43
|
+
control,
|
|
44
|
+
disabled: !name,
|
|
45
|
+
exact: true,
|
|
46
|
+
name,
|
|
47
|
+
});
|
|
48
|
+
const fieldError = name ? getFieldState(name, formState).error : undefined;
|
|
49
|
+
const errorMessage = error ?? getErrorMessage(fieldError);
|
|
50
|
+
const labelId = useId();
|
|
51
|
+
const controlRef = useRef<null | { focus: () => void }>(null);
|
|
52
|
+
const context = useMemo(
|
|
53
|
+
() => ({
|
|
54
|
+
label,
|
|
55
|
+
labelId: label ? labelId : undefined,
|
|
56
|
+
name,
|
|
57
|
+
setControl: (target: null | { focus: () => void }) => {
|
|
58
|
+
controlRef.current = target;
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
[label, labelId, name],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<View className={cn('gap-2.5', className)} {...props}>
|
|
66
|
+
{label ? (
|
|
67
|
+
<FormLabel
|
|
68
|
+
nativeID={labelId}
|
|
69
|
+
onPress={() => controlRef.current?.focus()}
|
|
70
|
+
>
|
|
71
|
+
{label}
|
|
72
|
+
</FormLabel>
|
|
73
|
+
) : null}
|
|
74
|
+
<FormFieldContext value={context}>{children}</FormFieldContext>
|
|
75
|
+
{errorMessage ? (
|
|
76
|
+
<FormError>{errorMessage}</FormError>
|
|
77
|
+
) : description ? (
|
|
78
|
+
<FormDescription>{description}</FormDescription>
|
|
79
|
+
) : null}
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { use, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import { useController } from 'react-hook-form';
|
|
3
|
+
import { type TextInput } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
FormFieldContext,
|
|
7
|
+
FormInputGroupContext,
|
|
8
|
+
FormSubmitContext,
|
|
9
|
+
useFormControlProps,
|
|
10
|
+
useFormMethods,
|
|
11
|
+
useRequiredFieldName,
|
|
12
|
+
} from './Form.context';
|
|
13
|
+
import { Input, type InputProps } from './Input';
|
|
14
|
+
import { InputGroup } from './InputGroup';
|
|
15
|
+
|
|
16
|
+
export type FormInputProps = Omit<
|
|
17
|
+
InputProps,
|
|
18
|
+
'defaultValue' | 'invalid' | 'value'
|
|
19
|
+
> & { name?: string };
|
|
20
|
+
|
|
21
|
+
type TextInputInstance = React.ComponentRef<typeof TextInput>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An `Input` bound to the field's value. The return key submits the form,
|
|
25
|
+
* like Enter on the web, unless `onSubmitEditing` is set.
|
|
26
|
+
*/
|
|
27
|
+
export function FormInput({
|
|
28
|
+
name: nameProp,
|
|
29
|
+
onBlur,
|
|
30
|
+
onChangeText,
|
|
31
|
+
onSubmitEditing,
|
|
32
|
+
ref,
|
|
33
|
+
...props
|
|
34
|
+
}: FormInputProps) {
|
|
35
|
+
const name = useRequiredFieldName(nameProp, 'Form.Input');
|
|
36
|
+
const { control } = useFormMethods();
|
|
37
|
+
const { field } = useController({ control, name });
|
|
38
|
+
const controlProps = useFormControlProps();
|
|
39
|
+
const fieldContext = use(FormFieldContext);
|
|
40
|
+
const isInsideInputGroup = use(FormInputGroupContext);
|
|
41
|
+
const submit = use(FormSubmitContext);
|
|
42
|
+
const inputRef = useRef<null | TextInputInstance>(null);
|
|
43
|
+
useImperativeHandle(ref, () => inputRef.current as TextInputInstance);
|
|
44
|
+
|
|
45
|
+
const InputComponent = isInsideInputGroup ? InputGroup.Input : Input;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<InputComponent
|
|
49
|
+
{...controlProps}
|
|
50
|
+
{...props}
|
|
51
|
+
onBlur={(event) => {
|
|
52
|
+
field.onBlur();
|
|
53
|
+
onBlur?.(event);
|
|
54
|
+
}}
|
|
55
|
+
onChangeText={(text) => {
|
|
56
|
+
field.onChange(text);
|
|
57
|
+
onChangeText?.(text);
|
|
58
|
+
}}
|
|
59
|
+
onSubmitEditing={onSubmitEditing ?? submit}
|
|
60
|
+
ref={(node: null | TextInputInstance) => {
|
|
61
|
+
inputRef.current = node;
|
|
62
|
+
// React Hook Form focuses the first invalid field after a submit,
|
|
63
|
+
// and the field's label focuses its input when pressed.
|
|
64
|
+
field.ref(node);
|
|
65
|
+
fieldContext?.setControl(node);
|
|
66
|
+
}}
|
|
67
|
+
value={field.value ?? ''}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Children, isValidElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import { FormInputGroupContext, useFormControlProps } from './Form.context';
|
|
4
|
+
import { FormInput, type FormInputProps } from './FormInput';
|
|
5
|
+
import { InputGroup } from './InputGroup';
|
|
6
|
+
import { InputGroupStateContext } from './InputGroup.context';
|
|
7
|
+
|
|
8
|
+
export type FormInputGroupProps = React.ComponentProps<typeof InputGroup>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An `InputGroup` whose `Form.Input` renders `InputGroup.Input`, framed as
|
|
12
|
+
* invalid while the field has an error.
|
|
13
|
+
*/
|
|
14
|
+
export function FormInputGroup({ children, ...props }: FormInputGroupProps) {
|
|
15
|
+
const { invalid } = useFormControlProps();
|
|
16
|
+
const disabled = Children.toArray(children).some(
|
|
17
|
+
(child) =>
|
|
18
|
+
isValidElement<FormInputProps>(child) &&
|
|
19
|
+
child.type === FormInput &&
|
|
20
|
+
Boolean(child.props.disabled),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<FormInputGroupContext value>
|
|
25
|
+
<InputGroupStateContext value={{ disabled, invalid }}>
|
|
26
|
+
<InputGroup {...props}>{children}</InputGroup>
|
|
27
|
+
</InputGroupStateContext>
|
|
28
|
+
</FormInputGroupContext>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Text } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/cn';
|
|
4
|
+
|
|
5
|
+
export type FormLabelProps = React.ComponentProps<typeof Text>;
|
|
6
|
+
|
|
7
|
+
export function FormLabel({ className, ...props }: FormLabelProps) {
|
|
8
|
+
return (
|
|
9
|
+
<Text
|
|
10
|
+
className={cn(
|
|
11
|
+
'text-foreground font-sans text-sm leading-none font-medium',
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|