@pickle-ui/react-native 1.0.0-beta.0 → 1.0.0-beta.2

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 (274) hide show
  1. package/README.md +70 -11
  2. package/dist/components/Badge.d.ts +6 -3
  3. package/dist/components/Badge.d.ts.map +1 -1
  4. package/dist/components/Badge.js +36 -7
  5. package/dist/components/Badge.js.map +1 -1
  6. package/dist/components/Button.d.ts +11 -4
  7. package/dist/components/Button.d.ts.map +1 -1
  8. package/dist/components/Button.js +100 -15
  9. package/dist/components/Button.js.map +1 -1
  10. package/dist/components/Card.d.ts +15 -5
  11. package/dist/components/Card.d.ts.map +1 -1
  12. package/dist/components/Card.js +28 -10
  13. package/dist/components/Card.js.map +1 -1
  14. package/dist/components/Flex.d.ts +25 -0
  15. package/dist/components/Flex.d.ts.map +1 -0
  16. package/dist/components/Flex.js +24 -0
  17. package/dist/components/Flex.js.map +1 -0
  18. package/dist/components/Grid.d.ts +36 -0
  19. package/dist/components/Grid.d.ts.map +1 -0
  20. package/dist/components/Grid.js +68 -0
  21. package/dist/components/Grid.js.map +1 -0
  22. package/dist/components/Separator.d.ts +11 -0
  23. package/dist/components/Separator.d.ts.map +1 -0
  24. package/dist/components/Separator.js +12 -0
  25. package/dist/components/Separator.js.map +1 -0
  26. package/dist/components/Spinner.d.ts +14 -0
  27. package/dist/components/Spinner.d.ts.map +1 -0
  28. package/dist/components/Spinner.js +41 -0
  29. package/dist/components/Spinner.js.map +1 -0
  30. package/dist/components/Text.d.ts +7 -3
  31. package/dist/components/Text.d.ts.map +1 -1
  32. package/dist/components/Text.js +23 -11
  33. package/dist/components/Text.js.map +1 -1
  34. package/dist/components/XStack.d.ts +4 -0
  35. package/dist/components/XStack.d.ts.map +1 -0
  36. package/dist/components/XStack.js +6 -0
  37. package/dist/components/XStack.js.map +1 -0
  38. package/dist/components/YStack.d.ts +4 -0
  39. package/dist/components/YStack.d.ts.map +1 -0
  40. package/dist/components/YStack.js +6 -0
  41. package/dist/components/YStack.js.map +1 -0
  42. package/dist/components/_internal/InputFrame.d.ts +13 -0
  43. package/dist/components/_internal/InputFrame.d.ts.map +1 -0
  44. package/dist/components/_internal/InputFrame.js +14 -0
  45. package/dist/components/_internal/InputFrame.js.map +1 -0
  46. package/dist/components/_internal/StoryItem.d.ts +6 -0
  47. package/dist/components/_internal/StoryItem.d.ts.map +1 -0
  48. package/dist/components/_internal/StoryItem.js +9 -0
  49. package/dist/components/_internal/StoryItem.js.map +1 -0
  50. package/dist/components/_internal/layout.d.ts +79 -0
  51. package/dist/components/_internal/layout.d.ts.map +1 -0
  52. package/dist/components/_internal/layout.js +73 -0
  53. package/dist/components/_internal/layout.js.map +1 -0
  54. package/dist/components/_internal/useControllableState.d.ts +7 -0
  55. package/dist/components/_internal/useControllableState.d.ts.map +1 -0
  56. package/dist/components/_internal/useControllableState.js +17 -0
  57. package/dist/components/_internal/useControllableState.js.map +1 -0
  58. package/dist/components/form/Checkbox.d.ts +26 -0
  59. package/dist/components/form/Checkbox.d.ts.map +1 -0
  60. package/dist/components/form/Checkbox.js +41 -0
  61. package/dist/components/form/Checkbox.js.map +1 -0
  62. package/dist/components/form/Form.context.d.ts +34 -0
  63. package/dist/components/form/Form.context.d.ts.map +1 -0
  64. package/dist/components/form/Form.context.js +53 -0
  65. package/dist/components/form/Form.context.js.map +1 -0
  66. package/dist/components/form/Form.d.ts +33 -0
  67. package/dist/components/form/Form.d.ts.map +1 -0
  68. package/dist/components/form/Form.js +36 -0
  69. package/dist/components/form/Form.js.map +1 -0
  70. package/dist/components/form/FormCheckbox.d.ts +7 -0
  71. package/dist/components/form/FormCheckbox.d.ts.map +1 -0
  72. package/dist/components/form/FormCheckbox.js +24 -0
  73. package/dist/components/form/FormCheckbox.js.map +1 -0
  74. package/dist/components/form/FormDescription.d.ts +4 -0
  75. package/dist/components/form/FormDescription.d.ts.map +1 -0
  76. package/dist/components/form/FormDescription.js +7 -0
  77. package/dist/components/form/FormDescription.js.map +1 -0
  78. package/dist/components/form/FormError.d.ts +5 -0
  79. package/dist/components/form/FormError.d.ts.map +1 -0
  80. package/dist/components/form/FormError.js +8 -0
  81. package/dist/components/form/FormError.js.map +1 -0
  82. package/dist/components/form/FormField.d.ts +17 -0
  83. package/dist/components/form/FormField.d.ts.map +1 -0
  84. package/dist/components/form/FormField.js +36 -0
  85. package/dist/components/form/FormField.js.map +1 -0
  86. package/dist/components/form/FormInput.d.ts +10 -0
  87. package/dist/components/form/FormInput.d.ts.map +1 -0
  88. package/dist/components/form/FormInput.js +36 -0
  89. package/dist/components/form/FormInput.js.map +1 -0
  90. package/dist/components/form/FormInputGroup.d.ts +8 -0
  91. package/dist/components/form/FormInputGroup.d.ts.map +1 -0
  92. package/dist/components/form/FormInputGroup.js +18 -0
  93. package/dist/components/form/FormInputGroup.js.map +1 -0
  94. package/dist/components/form/FormLabel.d.ts +4 -0
  95. package/dist/components/form/FormLabel.d.ts.map +1 -0
  96. package/dist/components/form/FormLabel.js +7 -0
  97. package/dist/components/form/FormLabel.js.map +1 -0
  98. package/dist/components/form/FormRadioGroup.d.ts +11 -0
  99. package/dist/components/form/FormRadioGroup.d.ts.map +1 -0
  100. package/dist/components/form/FormRadioGroup.js +24 -0
  101. package/dist/components/form/FormRadioGroup.js.map +1 -0
  102. package/dist/components/form/FormRoot.d.ts +13 -0
  103. package/dist/components/form/FormRoot.d.ts.map +1 -0
  104. package/dist/components/form/FormRoot.js +19 -0
  105. package/dist/components/form/FormRoot.js.map +1 -0
  106. package/dist/components/form/FormTextarea.d.ts +7 -0
  107. package/dist/components/form/FormTextarea.d.ts.map +1 -0
  108. package/dist/components/form/FormTextarea.js +27 -0
  109. package/dist/components/form/FormTextarea.js.map +1 -0
  110. package/dist/components/form/Input.d.ts +17 -0
  111. package/dist/components/form/Input.d.ts.map +1 -0
  112. package/dist/components/form/Input.js +32 -0
  113. package/dist/components/form/Input.js.map +1 -0
  114. package/dist/components/form/InputGroup.context.d.ts +10 -0
  115. package/dist/components/form/InputGroup.context.d.ts.map +1 -0
  116. package/dist/components/form/InputGroup.context.js +8 -0
  117. package/dist/components/form/InputGroup.context.js.map +1 -0
  118. package/dist/components/form/InputGroup.d.ts +31 -0
  119. package/dist/components/form/InputGroup.d.ts.map +1 -0
  120. package/dist/components/form/InputGroup.js +123 -0
  121. package/dist/components/form/InputGroup.js.map +1 -0
  122. package/dist/components/form/RadioGroup.d.ts +29 -0
  123. package/dist/components/form/RadioGroup.d.ts.map +1 -0
  124. package/dist/components/form/RadioGroup.js +42 -0
  125. package/dist/components/form/RadioGroup.js.map +1 -0
  126. package/dist/components/form/Textarea.d.ts +19 -0
  127. package/dist/components/form/Textarea.d.ts.map +1 -0
  128. package/dist/components/form/Textarea.js +26 -0
  129. package/dist/components/form/Textarea.js.map +1 -0
  130. package/dist/entries/card.d.ts +1 -1
  131. package/dist/entries/card.d.ts.map +1 -1
  132. package/dist/entries/checkbox.d.ts +3 -0
  133. package/dist/entries/checkbox.d.ts.map +1 -0
  134. package/dist/entries/checkbox.js +2 -0
  135. package/dist/entries/checkbox.js.map +1 -0
  136. package/dist/entries/flex.d.ts +3 -0
  137. package/dist/entries/flex.d.ts.map +1 -0
  138. package/dist/entries/flex.js +2 -0
  139. package/dist/entries/flex.js.map +1 -0
  140. package/dist/entries/form.d.ts +3 -0
  141. package/dist/entries/form.d.ts.map +1 -0
  142. package/dist/entries/form.js +2 -0
  143. package/dist/entries/form.js.map +1 -0
  144. package/dist/entries/grid.d.ts +3 -0
  145. package/dist/entries/grid.d.ts.map +1 -0
  146. package/dist/entries/grid.js +2 -0
  147. package/dist/entries/grid.js.map +1 -0
  148. package/dist/entries/input-group.d.ts +3 -0
  149. package/dist/entries/input-group.d.ts.map +1 -0
  150. package/dist/entries/input-group.js +2 -0
  151. package/dist/entries/input-group.js.map +1 -0
  152. package/dist/entries/input.d.ts +3 -0
  153. package/dist/entries/input.d.ts.map +1 -0
  154. package/dist/entries/input.js +2 -0
  155. package/dist/entries/input.js.map +1 -0
  156. package/dist/entries/radio-group.d.ts +3 -0
  157. package/dist/entries/radio-group.d.ts.map +1 -0
  158. package/dist/entries/radio-group.js +2 -0
  159. package/dist/entries/radio-group.js.map +1 -0
  160. package/dist/entries/separator.d.ts +3 -0
  161. package/dist/entries/separator.d.ts.map +1 -0
  162. package/dist/entries/separator.js +2 -0
  163. package/dist/entries/separator.js.map +1 -0
  164. package/dist/entries/spinner.d.ts +3 -0
  165. package/dist/entries/spinner.d.ts.map +1 -0
  166. package/dist/entries/spinner.js +2 -0
  167. package/dist/entries/spinner.js.map +1 -0
  168. package/dist/entries/textarea.d.ts +3 -0
  169. package/dist/entries/textarea.d.ts.map +1 -0
  170. package/dist/entries/textarea.js +2 -0
  171. package/dist/entries/textarea.js.map +1 -0
  172. package/dist/entries/theme.d.ts +3 -0
  173. package/dist/entries/theme.d.ts.map +1 -0
  174. package/dist/entries/theme.js +2 -0
  175. package/dist/entries/theme.js.map +1 -0
  176. package/dist/entries/x-stack.d.ts +3 -0
  177. package/dist/entries/x-stack.d.ts.map +1 -0
  178. package/dist/entries/x-stack.js +2 -0
  179. package/dist/entries/x-stack.js.map +1 -0
  180. package/dist/entries/y-stack.d.ts +3 -0
  181. package/dist/entries/y-stack.d.ts.map +1 -0
  182. package/dist/entries/y-stack.js +2 -0
  183. package/dist/entries/y-stack.js.map +1 -0
  184. package/dist/index.d.ts +13 -0
  185. package/dist/index.d.ts.map +1 -1
  186. package/dist/index.js +13 -0
  187. package/dist/index.js.map +1 -1
  188. package/dist/lib/cn.d.ts.map +1 -1
  189. package/dist/lib/cn.js +13 -1
  190. package/dist/lib/cn.js.map +1 -1
  191. package/package.json +10 -3
  192. package/src/base.css +7 -2
  193. package/src/components/Badge.stories.tsx +51 -6
  194. package/src/components/Badge.test.tsx +15 -0
  195. package/src/components/Badge.tsx +73 -17
  196. package/src/components/Button.stories.tsx +96 -8
  197. package/src/components/Button.test.tsx +69 -0
  198. package/src/components/Button.tsx +159 -18
  199. package/src/components/Card.stories.tsx +17 -4
  200. package/src/components/Card.test.tsx +24 -1
  201. package/src/components/Card.tsx +71 -13
  202. package/src/components/Flex.stories.tsx +58 -0
  203. package/src/components/Flex.test.tsx +57 -0
  204. package/src/components/Flex.tsx +52 -0
  205. package/src/components/Grid.stories.tsx +55 -0
  206. package/src/components/Grid.test.tsx +140 -0
  207. package/src/components/Grid.tsx +166 -0
  208. package/src/components/Separator.stories.tsx +39 -0
  209. package/src/components/Separator.test.tsx +25 -0
  210. package/src/components/Separator.tsx +30 -0
  211. package/src/components/Spinner.stories.tsx +35 -0
  212. package/src/components/Spinner.test.tsx +30 -0
  213. package/src/components/Spinner.tsx +75 -0
  214. package/src/components/Text.stories.tsx +52 -2
  215. package/src/components/Text.test.tsx +35 -3
  216. package/src/components/Text.tsx +42 -12
  217. package/src/components/XStack.stories.tsx +46 -0
  218. package/src/components/XStack.test.tsx +13 -0
  219. package/src/components/XStack.tsx +7 -0
  220. package/src/components/YStack.stories.tsx +46 -0
  221. package/src/components/YStack.test.tsx +13 -0
  222. package/src/components/YStack.tsx +7 -0
  223. package/src/components/_internal/InputFrame.tsx +50 -0
  224. package/src/components/_internal/Story.tsx +34 -0
  225. package/src/components/_internal/StoryItem.tsx +24 -0
  226. package/src/components/_internal/layout.ts +103 -0
  227. package/src/components/_internal/useControllableState.ts +25 -0
  228. package/src/components/form/Checkbox.stories.tsx +67 -0
  229. package/src/components/form/Checkbox.test.tsx +88 -0
  230. package/src/components/form/Checkbox.tsx +141 -0
  231. package/src/components/form/Form.context.ts +91 -0
  232. package/src/components/form/Form.stories.tsx +152 -0
  233. package/src/components/form/Form.test.tsx +195 -0
  234. package/src/components/form/Form.tsx +51 -0
  235. package/src/components/form/FormCheckbox.tsx +42 -0
  236. package/src/components/form/FormDescription.tsx +14 -0
  237. package/src/components/form/FormError.tsx +17 -0
  238. package/src/components/form/FormField.tsx +82 -0
  239. package/src/components/form/FormInput.tsx +70 -0
  240. package/src/components/form/FormInputGroup.tsx +30 -0
  241. package/src/components/form/FormLabel.tsx +17 -0
  242. package/src/components/form/FormRadioGroup.tsx +48 -0
  243. package/src/components/form/FormRoot.tsx +41 -0
  244. package/src/components/form/FormTextarea.tsx +56 -0
  245. package/src/components/form/Input.stories.tsx +105 -0
  246. package/src/components/form/Input.test.tsx +76 -0
  247. package/src/components/form/Input.tsx +81 -0
  248. package/src/components/form/InputGroup.context.ts +11 -0
  249. package/src/components/form/InputGroup.stories.tsx +129 -0
  250. package/src/components/form/InputGroup.test.tsx +95 -0
  251. package/src/components/form/InputGroup.tsx +266 -0
  252. package/src/components/form/RadioGroup.stories.tsx +62 -0
  253. package/src/components/form/RadioGroup.test.tsx +98 -0
  254. package/src/components/form/RadioGroup.tsx +151 -0
  255. package/src/components/form/Textarea.stories.tsx +61 -0
  256. package/src/components/form/Textarea.test.tsx +35 -0
  257. package/src/components/form/Textarea.tsx +74 -0
  258. package/src/entries/card.ts +2 -0
  259. package/src/entries/checkbox.ts +2 -0
  260. package/src/entries/flex.ts +2 -0
  261. package/src/entries/form.ts +13 -0
  262. package/src/entries/grid.ts +2 -0
  263. package/src/entries/input-group.ts +8 -0
  264. package/src/entries/input.ts +2 -0
  265. package/src/entries/radio-group.ts +5 -0
  266. package/src/entries/separator.ts +2 -0
  267. package/src/entries/spinner.ts +2 -0
  268. package/src/entries/textarea.ts +2 -0
  269. package/src/entries/theme.ts +7 -0
  270. package/src/entries/x-stack.ts +2 -0
  271. package/src/entries/y-stack.ts +2 -0
  272. package/src/index.ts +13 -0
  273. package/src/lib/cn.test.ts +6 -0
  274. package/src/lib/cn.ts +14 -1
@@ -0,0 +1,17 @@
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('text-destructive text-sm font-medium', className)}
13
+ role="alert"
14
+ {...props}
15
+ />
16
+ );
17
+ }
@@ -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 text-sm leading-none font-medium',
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ }
@@ -0,0 +1,48 @@
1
+ import { useController } from 'react-hook-form';
2
+
3
+ import {
4
+ useFormControlProps,
5
+ useFormMethods,
6
+ useRequiredFieldName,
7
+ } from './Form.context';
8
+ import { RadioGroup, type RadioGroupProps } from './RadioGroup';
9
+
10
+ export type FormRadioGroupProps = Omit<RadioGroupProps, 'value'> & {
11
+ name?: string;
12
+ };
13
+
14
+ /** A `RadioGroup` bound to the field's value. */
15
+ function FormRadioGroupRoot({
16
+ name: nameProp,
17
+ onValueChange,
18
+ ...props
19
+ }: FormRadioGroupProps) {
20
+ const name = useRequiredFieldName(nameProp, 'Form.RadioGroup');
21
+ const { control } = useFormMethods();
22
+ const { field } = useController({
23
+ control,
24
+ defaultValue: props.defaultValue,
25
+ name,
26
+ });
27
+ const { accessibilityLabel, 'aria-labelledby': labelledBy } =
28
+ useFormControlProps();
29
+
30
+ return (
31
+ <RadioGroup
32
+ accessibilityLabel={accessibilityLabel}
33
+ aria-labelledby={labelledBy}
34
+ {...props}
35
+ onValueChange={(value) => {
36
+ field.onChange(value);
37
+ onValueChange?.(value);
38
+ }}
39
+ value={field.value}
40
+ />
41
+ );
42
+ }
43
+
44
+ const FormRadioGroup = Object.assign(FormRadioGroupRoot, {
45
+ Item: RadioGroup.Item,
46
+ });
47
+
48
+ export { FormRadioGroup };
@@ -0,0 +1,41 @@
1
+ import { useCallback } from 'react';
2
+ import {
3
+ type FieldValues,
4
+ FormProvider,
5
+ type SubmitHandler,
6
+ type UseFormReturn,
7
+ } from 'react-hook-form';
8
+ import { View } from 'react-native';
9
+
10
+ import { cn } from '../../lib/cn';
11
+ import { FormSubmitContext } from './Form.context';
12
+
13
+ export type FormProps<FormValues extends FieldValues = FieldValues> =
14
+ React.ComponentProps<typeof View> & {
15
+ form: UseFormReturn<FormValues>;
16
+ onSubmit?: SubmitHandler<FormValues>;
17
+ };
18
+
19
+ /**
20
+ * React Native has no `<form>`, so the root shares a submit action:
21
+ * `Form.Button` and the return key in `Form.Input` call it, like a submit
22
+ * button and Enter on the web.
23
+ */
24
+ export function FormRoot<FormValues extends FieldValues = FieldValues>({
25
+ className,
26
+ form,
27
+ onSubmit,
28
+ ...props
29
+ }: FormProps<FormValues>) {
30
+ const submit = useCallback(() => {
31
+ if (onSubmit) void form.handleSubmit(onSubmit)();
32
+ }, [form, onSubmit]);
33
+
34
+ return (
35
+ <FormProvider {...form}>
36
+ <FormSubmitContext value={submit}>
37
+ <View className={cn('gap-6', className)} {...props} />
38
+ </FormSubmitContext>
39
+ </FormProvider>
40
+ );
41
+ }
@@ -0,0 +1,56 @@
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
+ useFormControlProps,
8
+ useFormMethods,
9
+ useRequiredFieldName,
10
+ } from './Form.context';
11
+ import { Textarea, type TextareaProps } from './Textarea';
12
+
13
+ export type FormTextareaProps = Omit<
14
+ TextareaProps,
15
+ 'defaultValue' | 'invalid' | 'value'
16
+ > & { name?: string };
17
+
18
+ type TextInputInstance = React.ComponentRef<typeof TextInput>;
19
+
20
+ /** A `Textarea` bound to the field's value. */
21
+ export function FormTextarea({
22
+ name: nameProp,
23
+ onBlur,
24
+ onChangeText,
25
+ ref,
26
+ ...props
27
+ }: FormTextareaProps) {
28
+ const name = useRequiredFieldName(nameProp, 'Form.Textarea');
29
+ const { control } = useFormMethods();
30
+ const { field } = useController({ control, name });
31
+ const controlProps = useFormControlProps();
32
+ const fieldContext = use(FormFieldContext);
33
+ const inputRef = useRef<null | TextInputInstance>(null);
34
+ useImperativeHandle(ref, () => inputRef.current as TextInputInstance);
35
+
36
+ return (
37
+ <Textarea
38
+ {...controlProps}
39
+ {...props}
40
+ onBlur={(event) => {
41
+ field.onBlur();
42
+ onBlur?.(event);
43
+ }}
44
+ onChangeText={(text) => {
45
+ field.onChange(text);
46
+ onChangeText?.(text);
47
+ }}
48
+ ref={(node: null | TextInputInstance) => {
49
+ inputRef.current = node;
50
+ field.ref(node);
51
+ fieldContext?.setControl(node);
52
+ }}
53
+ value={field.value ?? ''}
54
+ />
55
+ );
56
+ }
@@ -0,0 +1,105 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+
3
+ import { type ComponentMatrixCellProps, Story } from '../_internal/Story';
4
+ import { Button } from '../Button';
5
+ import { XStack } from '../XStack';
6
+ import { Input } from './Input';
7
+
8
+ const meta = {
9
+ component: Input,
10
+ parameters: { layout: 'fullscreen' },
11
+ title: 'components/form/Input',
12
+ } satisfies Meta<typeof Input>;
13
+
14
+ export default meta;
15
+ type StoryDefinition = StoryObj<typeof meta>;
16
+
17
+ // No focus column: React Native can't force a focus state. Tap a field.
18
+ const STATE_COLUMNS = [
19
+ { key: 'empty', label: 'Empty' },
20
+ { key: 'filled', label: 'Filled' },
21
+ { key: 'disabled', label: 'Disabled' },
22
+ { key: 'invalid', label: 'Invalid' },
23
+ ];
24
+
25
+ const TYPE_ROWS = [
26
+ { description: 'General text input', key: 'text', label: 'Text' },
27
+ { description: 'Keyboard for email', key: 'email', label: 'Email' },
28
+ { description: 'Masked text entry', key: 'password', label: 'Password' },
29
+ ];
30
+
31
+ function InputMatrixCell({ column, row }: ComponentMatrixCellProps) {
32
+ const isInvalid = column.key === 'invalid';
33
+
34
+ return (
35
+ <Input
36
+ accessibilityLabel={`${row.label} ${column.label}`}
37
+ defaultValue={
38
+ column.key === 'filled'
39
+ ? row.key === 'email'
40
+ ? 'hello@pickle.dev'
41
+ : 'Pickle UI'
42
+ : undefined
43
+ }
44
+ disabled={column.key === 'disabled'}
45
+ invalid={isInvalid}
46
+ keyboardType={row.key === 'email' ? 'email-address' : 'default'}
47
+ placeholder={isInvalid ? 'Check this value' : row.label}
48
+ secureTextEntry={row.key === 'password'}
49
+ />
50
+ );
51
+ }
52
+
53
+ const SIZE_COLUMNS = [
54
+ { key: 'sm', label: 'Small' },
55
+ { key: 'md', label: 'Medium' },
56
+ { key: 'lg', label: 'Large' },
57
+ ];
58
+
59
+ function InputSizeCell({ column }: ComponentMatrixCellProps) {
60
+ const size = column.key as 'lg' | 'md' | 'sm';
61
+
62
+ return (
63
+ <XStack gap={2}>
64
+ <Input
65
+ accessibilityLabel={`${column.label} email`}
66
+ className="flex-1"
67
+ placeholder="Email"
68
+ size={size}
69
+ />
70
+ <Button size={size} variant="outline">
71
+ Subscribe
72
+ </Button>
73
+ </XStack>
74
+ );
75
+ }
76
+
77
+ export const All: StoryDefinition = {
78
+ render: () => (
79
+ <Story.Layout className="max-w-6xl" title="Input">
80
+ <Story.Section title="Types and states">
81
+ <Story.Matrix
82
+ Cell={InputMatrixCell}
83
+ cellClassName="items-stretch"
84
+ columns={STATE_COLUMNS}
85
+ rows={TYPE_ROWS}
86
+ />
87
+ </Story.Section>
88
+
89
+ <Story.Section title="Sizes">
90
+ <Story.Matrix
91
+ Cell={InputSizeCell}
92
+ cellClassName="items-stretch"
93
+ columns={SIZE_COLUMNS}
94
+ rows={[
95
+ {
96
+ description: 'Matches the Button of the same size',
97
+ key: 'with-button',
98
+ label: 'With button',
99
+ },
100
+ ]}
101
+ />
102
+ </Story.Section>
103
+ </Story.Layout>
104
+ ),
105
+ };
@@ -0,0 +1,76 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react-native';
2
+
3
+ import { Input } from './Input';
4
+
5
+ describe('Input', () => {
6
+ it('renders a text field named by its accessibility label', async () => {
7
+ await render(<Input accessibilityLabel="Email" />);
8
+
9
+ expect(screen.getByLabelText('Email')).toBeOnTheScreen();
10
+ });
11
+
12
+ it.each([
13
+ ['sm', 'h-7'],
14
+ ['md', 'h-8'],
15
+ ['lg', 'h-9'],
16
+ ] as const)('matches the Button height at size %s', async (size, height) => {
17
+ await render(<Input accessibilityLabel="Email" size={size} />);
18
+
19
+ expect(screen.getByLabelText('Email').parent).toHaveProp(
20
+ 'className',
21
+ expect.stringContaining(height),
22
+ );
23
+ });
24
+
25
+ it('styles the control with className and the field with classNames.input', async () => {
26
+ await render(
27
+ <Input
28
+ accessibilityLabel="Email"
29
+ className="control-x"
30
+ classNames={{ input: 'input-x' }}
31
+ />,
32
+ );
33
+
34
+ const field = screen.getByLabelText('Email');
35
+ expect(field).toHaveProp('className', expect.stringContaining('input-x'));
36
+ expect(field.parent).toHaveProp(
37
+ 'className',
38
+ expect.stringContaining('control-x'),
39
+ );
40
+ });
41
+
42
+ it('stops editing when disabled', async () => {
43
+ await render(<Input accessibilityLabel="Email" disabled />);
44
+
45
+ expect(screen.getByLabelText('Email')).toHaveProp('editable', false);
46
+ expect(screen.getByLabelText('Email')).toBeDisabled();
47
+ });
48
+
49
+ it('shows a focus ring while focused and still calls onFocus', async () => {
50
+ const onFocus = jest.fn();
51
+ await render(<Input accessibilityLabel="Email" onFocus={onFocus} />);
52
+
53
+ const control = () => screen.getByLabelText('Email').parent;
54
+ expect(control()).not.toHaveProp(
55
+ 'className',
56
+ expect.stringContaining('border-ring'),
57
+ );
58
+
59
+ await fireEvent(screen.getByLabelText('Email'), 'focus');
60
+
61
+ expect(onFocus).toHaveBeenCalledTimes(1);
62
+ expect(control()).toHaveProp(
63
+ 'className',
64
+ expect.stringContaining('border-ring'),
65
+ );
66
+ });
67
+
68
+ it('renders the bare field when unstyled', async () => {
69
+ await render(<Input accessibilityLabel="Search" className="x" unstyled />);
70
+
71
+ expect(screen.getByLabelText('Search')).toHaveProp(
72
+ 'className',
73
+ expect.stringMatching(/h-8.*x$/),
74
+ );
75
+ });
76
+ });
@@ -0,0 +1,81 @@
1
+ import { useState } from 'react';
2
+ import { TextInput } from 'react-native';
3
+
4
+ import { cn } from '../../lib/cn';
5
+ import { InputFrame } from '../_internal/InputFrame';
6
+
7
+ // Same heights as `Button` for the same size, so they line up side by side,
8
+ // and the web's paddings minus the 1pt border.
9
+ const heightClasses = { lg: 'h-9', md: 'h-8', sm: 'h-7' } as const;
10
+ const paddingClasses = {
11
+ lg: 'px-[13px]',
12
+ md: 'px-[11px]',
13
+ sm: 'px-[9px]',
14
+ } as const;
15
+
16
+ export type InputProps = React.ComponentProps<typeof TextInput> & {
17
+ /** Class overrides for the inner `TextInput`; `className` styles the control around it. */
18
+ classNames?: { input?: string };
19
+ /** Dims the field and stops editing, like the web's `disabled`. */
20
+ disabled?: boolean;
21
+ /** Marks the value as invalid, like the web's `aria-invalid`. */
22
+ invalid?: boolean;
23
+ size?: 'lg' | 'md' | 'sm';
24
+ /** Renders the bare `TextInput`, without the border and background. */
25
+ unstyled?: boolean;
26
+ };
27
+
28
+ /** A single-line text field, like `Input` in @pickle-ui/react. */
29
+ export function Input({
30
+ className,
31
+ classNames,
32
+ disabled = false,
33
+ editable = true,
34
+ invalid = false,
35
+ onBlur,
36
+ onFocus,
37
+ size = 'md',
38
+ unstyled = false,
39
+ ...props
40
+ }: InputProps) {
41
+ const [focused, setFocused] = useState(false);
42
+ // The web's 14px from `sm` up: the 16px it uses on phones only stops iOS
43
+ // Safari zooming into a focused field, which native apps don't do.
44
+ // `text-[14px]` sets the size without `text-sm`'s line height, which
45
+ // pushes a single-line field's text off center on iOS.
46
+ const input = (
47
+ <TextInput
48
+ accessibilityState={{ disabled }}
49
+ className={cn(
50
+ 'text-foreground flex-1 text-[14px] outline-none',
51
+ paddingClasses[size],
52
+ unstyled ? cn(heightClasses[size], disabled && 'opacity-64') : 'h-full',
53
+ unstyled ? className : classNames?.input,
54
+ )}
55
+ editable={editable && !disabled}
56
+ onBlur={(event) => {
57
+ setFocused(false);
58
+ onBlur?.(event);
59
+ }}
60
+ onFocus={(event) => {
61
+ setFocused(true);
62
+ onFocus?.(event);
63
+ }}
64
+ placeholderTextColorClassName="accent-muted-foreground/72"
65
+ {...props}
66
+ />
67
+ );
68
+
69
+ if (unstyled) return input;
70
+
71
+ return (
72
+ <InputFrame
73
+ className={cn(heightClasses[size], className)}
74
+ disabled={disabled}
75
+ focused={focused}
76
+ invalid={invalid}
77
+ >
78
+ {input}
79
+ </InputFrame>
80
+ );
81
+ }
@@ -0,0 +1,11 @@
1
+ import { createContext } from 'react';
2
+
3
+ /**
4
+ * Lets a wrapper mark the frame `disabled` or `invalid` when its input isn't
5
+ * a direct `InputGroup.Input` child, like `Form.InputGroup` does with
6
+ * `Form.Input`, whose invalid state comes from the form.
7
+ */
8
+ export const InputGroupStateContext = createContext<null | {
9
+ disabled?: boolean;
10
+ invalid?: boolean;
11
+ }>(null);