@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.
Files changed (255) hide show
  1. package/README.md +62 -24
  2. package/dist/components/Badge.js +1 -1
  3. package/dist/components/Badge.js.map +1 -1
  4. package/dist/components/Button.d.ts.map +1 -1
  5. package/dist/components/Button.js +27 -23
  6. package/dist/components/Button.js.map +1 -1
  7. package/dist/components/Card.d.ts.map +1 -1
  8. package/dist/components/Card.js +7 -4
  9. package/dist/components/Card.js.map +1 -1
  10. package/dist/components/Flex.d.ts +25 -0
  11. package/dist/components/Flex.d.ts.map +1 -0
  12. package/dist/components/Flex.js +24 -0
  13. package/dist/components/Flex.js.map +1 -0
  14. package/dist/components/Grid.d.ts +36 -0
  15. package/dist/components/Grid.d.ts.map +1 -0
  16. package/dist/components/Grid.js +68 -0
  17. package/dist/components/Grid.js.map +1 -0
  18. package/dist/components/Separator.d.ts +11 -0
  19. package/dist/components/Separator.d.ts.map +1 -0
  20. package/dist/components/Separator.js +12 -0
  21. package/dist/components/Separator.js.map +1 -0
  22. package/dist/components/Spinner.d.ts +14 -0
  23. package/dist/components/Spinner.d.ts.map +1 -0
  24. package/dist/components/Spinner.js +41 -0
  25. package/dist/components/Spinner.js.map +1 -0
  26. package/dist/components/Text.d.ts.map +1 -1
  27. package/dist/components/Text.js +5 -4
  28. package/dist/components/Text.js.map +1 -1
  29. package/dist/components/XStack.d.ts +4 -0
  30. package/dist/components/XStack.d.ts.map +1 -0
  31. package/dist/components/XStack.js +6 -0
  32. package/dist/components/XStack.js.map +1 -0
  33. package/dist/components/YStack.d.ts +4 -0
  34. package/dist/components/YStack.d.ts.map +1 -0
  35. package/dist/components/YStack.js +6 -0
  36. package/dist/components/YStack.js.map +1 -0
  37. package/dist/components/_internal/InputFrame.d.ts +13 -0
  38. package/dist/components/_internal/InputFrame.d.ts.map +1 -0
  39. package/dist/components/_internal/InputFrame.js +14 -0
  40. package/dist/components/_internal/InputFrame.js.map +1 -0
  41. package/dist/components/_internal/StoryItem.d.ts +6 -0
  42. package/dist/components/_internal/StoryItem.d.ts.map +1 -0
  43. package/dist/components/_internal/StoryItem.js +9 -0
  44. package/dist/components/_internal/StoryItem.js.map +1 -0
  45. package/dist/components/_internal/layout.d.ts +79 -0
  46. package/dist/components/_internal/layout.d.ts.map +1 -0
  47. package/dist/components/_internal/layout.js +73 -0
  48. package/dist/components/_internal/layout.js.map +1 -0
  49. package/dist/components/_internal/useControllableState.d.ts +7 -0
  50. package/dist/components/_internal/useControllableState.d.ts.map +1 -0
  51. package/dist/components/_internal/useControllableState.js +17 -0
  52. package/dist/components/_internal/useControllableState.js.map +1 -0
  53. package/dist/components/form/Checkbox.d.ts +26 -0
  54. package/dist/components/form/Checkbox.d.ts.map +1 -0
  55. package/dist/components/form/Checkbox.js +43 -0
  56. package/dist/components/form/Checkbox.js.map +1 -0
  57. package/dist/components/form/Form.context.d.ts +34 -0
  58. package/dist/components/form/Form.context.d.ts.map +1 -0
  59. package/dist/components/form/Form.context.js +53 -0
  60. package/dist/components/form/Form.context.js.map +1 -0
  61. package/dist/components/form/Form.d.ts +33 -0
  62. package/dist/components/form/Form.d.ts.map +1 -0
  63. package/dist/components/form/Form.js +36 -0
  64. package/dist/components/form/Form.js.map +1 -0
  65. package/dist/components/form/FormCheckbox.d.ts +7 -0
  66. package/dist/components/form/FormCheckbox.d.ts.map +1 -0
  67. package/dist/components/form/FormCheckbox.js +24 -0
  68. package/dist/components/form/FormCheckbox.js.map +1 -0
  69. package/dist/components/form/FormDescription.d.ts +4 -0
  70. package/dist/components/form/FormDescription.d.ts.map +1 -0
  71. package/dist/components/form/FormDescription.js +7 -0
  72. package/dist/components/form/FormDescription.js.map +1 -0
  73. package/dist/components/form/FormError.d.ts +5 -0
  74. package/dist/components/form/FormError.d.ts.map +1 -0
  75. package/dist/components/form/FormError.js +8 -0
  76. package/dist/components/form/FormError.js.map +1 -0
  77. package/dist/components/form/FormField.d.ts +17 -0
  78. package/dist/components/form/FormField.d.ts.map +1 -0
  79. package/dist/components/form/FormField.js +36 -0
  80. package/dist/components/form/FormField.js.map +1 -0
  81. package/dist/components/form/FormInput.d.ts +10 -0
  82. package/dist/components/form/FormInput.d.ts.map +1 -0
  83. package/dist/components/form/FormInput.js +36 -0
  84. package/dist/components/form/FormInput.js.map +1 -0
  85. package/dist/components/form/FormInputGroup.d.ts +8 -0
  86. package/dist/components/form/FormInputGroup.d.ts.map +1 -0
  87. package/dist/components/form/FormInputGroup.js +18 -0
  88. package/dist/components/form/FormInputGroup.js.map +1 -0
  89. package/dist/components/form/FormLabel.d.ts +4 -0
  90. package/dist/components/form/FormLabel.d.ts.map +1 -0
  91. package/dist/components/form/FormLabel.js +7 -0
  92. package/dist/components/form/FormLabel.js.map +1 -0
  93. package/dist/components/form/FormRadioGroup.d.ts +11 -0
  94. package/dist/components/form/FormRadioGroup.d.ts.map +1 -0
  95. package/dist/components/form/FormRadioGroup.js +24 -0
  96. package/dist/components/form/FormRadioGroup.js.map +1 -0
  97. package/dist/components/form/FormRoot.d.ts +13 -0
  98. package/dist/components/form/FormRoot.d.ts.map +1 -0
  99. package/dist/components/form/FormRoot.js +19 -0
  100. package/dist/components/form/FormRoot.js.map +1 -0
  101. package/dist/components/form/FormTextarea.d.ts +7 -0
  102. package/dist/components/form/FormTextarea.d.ts.map +1 -0
  103. package/dist/components/form/FormTextarea.js +27 -0
  104. package/dist/components/form/FormTextarea.js.map +1 -0
  105. package/dist/components/form/Input.d.ts +17 -0
  106. package/dist/components/form/Input.d.ts.map +1 -0
  107. package/dist/components/form/Input.js +32 -0
  108. package/dist/components/form/Input.js.map +1 -0
  109. package/dist/components/form/InputGroup.context.d.ts +10 -0
  110. package/dist/components/form/InputGroup.context.d.ts.map +1 -0
  111. package/dist/components/form/InputGroup.context.js +8 -0
  112. package/dist/components/form/InputGroup.context.js.map +1 -0
  113. package/dist/components/form/InputGroup.d.ts +31 -0
  114. package/dist/components/form/InputGroup.d.ts.map +1 -0
  115. package/dist/components/form/InputGroup.js +123 -0
  116. package/dist/components/form/InputGroup.js.map +1 -0
  117. package/dist/components/form/RadioGroup.d.ts +29 -0
  118. package/dist/components/form/RadioGroup.d.ts.map +1 -0
  119. package/dist/components/form/RadioGroup.js +42 -0
  120. package/dist/components/form/RadioGroup.js.map +1 -0
  121. package/dist/components/form/Textarea.d.ts +19 -0
  122. package/dist/components/form/Textarea.d.ts.map +1 -0
  123. package/dist/components/form/Textarea.js +26 -0
  124. package/dist/components/form/Textarea.js.map +1 -0
  125. package/dist/entries/checkbox.d.ts +3 -0
  126. package/dist/entries/checkbox.d.ts.map +1 -0
  127. package/dist/entries/checkbox.js +2 -0
  128. package/dist/entries/checkbox.js.map +1 -0
  129. package/dist/entries/flex.d.ts +3 -0
  130. package/dist/entries/flex.d.ts.map +1 -0
  131. package/dist/entries/flex.js +2 -0
  132. package/dist/entries/flex.js.map +1 -0
  133. package/dist/entries/form.d.ts +3 -0
  134. package/dist/entries/form.d.ts.map +1 -0
  135. package/dist/entries/form.js +2 -0
  136. package/dist/entries/form.js.map +1 -0
  137. package/dist/entries/grid.d.ts +3 -0
  138. package/dist/entries/grid.d.ts.map +1 -0
  139. package/dist/entries/grid.js +2 -0
  140. package/dist/entries/grid.js.map +1 -0
  141. package/dist/entries/input-group.d.ts +3 -0
  142. package/dist/entries/input-group.d.ts.map +1 -0
  143. package/dist/entries/input-group.js +2 -0
  144. package/dist/entries/input-group.js.map +1 -0
  145. package/dist/entries/input.d.ts +3 -0
  146. package/dist/entries/input.d.ts.map +1 -0
  147. package/dist/entries/input.js +2 -0
  148. package/dist/entries/input.js.map +1 -0
  149. package/dist/entries/radio-group.d.ts +3 -0
  150. package/dist/entries/radio-group.d.ts.map +1 -0
  151. package/dist/entries/radio-group.js +2 -0
  152. package/dist/entries/radio-group.js.map +1 -0
  153. package/dist/entries/separator.d.ts +3 -0
  154. package/dist/entries/separator.d.ts.map +1 -0
  155. package/dist/entries/separator.js +2 -0
  156. package/dist/entries/separator.js.map +1 -0
  157. package/dist/entries/spinner.d.ts +3 -0
  158. package/dist/entries/spinner.d.ts.map +1 -0
  159. package/dist/entries/spinner.js +2 -0
  160. package/dist/entries/spinner.js.map +1 -0
  161. package/dist/entries/textarea.d.ts +3 -0
  162. package/dist/entries/textarea.d.ts.map +1 -0
  163. package/dist/entries/textarea.js +2 -0
  164. package/dist/entries/textarea.js.map +1 -0
  165. package/dist/entries/x-stack.d.ts +3 -0
  166. package/dist/entries/x-stack.d.ts.map +1 -0
  167. package/dist/entries/x-stack.js +2 -0
  168. package/dist/entries/x-stack.js.map +1 -0
  169. package/dist/entries/y-stack.d.ts +3 -0
  170. package/dist/entries/y-stack.d.ts.map +1 -0
  171. package/dist/entries/y-stack.js +2 -0
  172. package/dist/entries/y-stack.js.map +1 -0
  173. package/dist/index.d.ts +12 -0
  174. package/dist/index.d.ts.map +1 -1
  175. package/dist/index.js +12 -0
  176. package/dist/index.js.map +1 -1
  177. package/package.json +8 -3
  178. package/src/base.css +23 -5
  179. package/src/components/Badge.tsx +1 -1
  180. package/src/components/Button.tsx +27 -23
  181. package/src/components/Card.tsx +12 -8
  182. package/src/components/Flex.stories.tsx +58 -0
  183. package/src/components/Flex.test.tsx +57 -0
  184. package/src/components/Flex.tsx +52 -0
  185. package/src/components/Grid.stories.tsx +55 -0
  186. package/src/components/Grid.test.tsx +140 -0
  187. package/src/components/Grid.tsx +166 -0
  188. package/src/components/Separator.stories.tsx +39 -0
  189. package/src/components/Separator.test.tsx +25 -0
  190. package/src/components/Separator.tsx +30 -0
  191. package/src/components/Spinner.stories.tsx +35 -0
  192. package/src/components/Spinner.test.tsx +30 -0
  193. package/src/components/Spinner.tsx +75 -0
  194. package/src/components/Text.stories.tsx +49 -0
  195. package/src/components/Text.tsx +5 -4
  196. package/src/components/XStack.stories.tsx +46 -0
  197. package/src/components/XStack.test.tsx +13 -0
  198. package/src/components/XStack.tsx +7 -0
  199. package/src/components/YStack.stories.tsx +46 -0
  200. package/src/components/YStack.test.tsx +13 -0
  201. package/src/components/YStack.tsx +7 -0
  202. package/src/components/_internal/InputFrame.tsx +50 -0
  203. package/src/components/_internal/StoryItem.tsx +24 -0
  204. package/src/components/_internal/layout.ts +103 -0
  205. package/src/components/_internal/useControllableState.ts +25 -0
  206. package/src/components/form/Checkbox.stories.tsx +67 -0
  207. package/src/components/form/Checkbox.test.tsx +88 -0
  208. package/src/components/form/Checkbox.tsx +167 -0
  209. package/src/components/form/Form.context.ts +91 -0
  210. package/src/components/form/Form.stories.tsx +152 -0
  211. package/src/components/form/Form.test.tsx +195 -0
  212. package/src/components/form/Form.tsx +51 -0
  213. package/src/components/form/FormCheckbox.tsx +42 -0
  214. package/src/components/form/FormDescription.tsx +14 -0
  215. package/src/components/form/FormError.tsx +20 -0
  216. package/src/components/form/FormField.tsx +82 -0
  217. package/src/components/form/FormInput.tsx +70 -0
  218. package/src/components/form/FormInputGroup.tsx +30 -0
  219. package/src/components/form/FormLabel.tsx +17 -0
  220. package/src/components/form/FormRadioGroup.tsx +48 -0
  221. package/src/components/form/FormRoot.tsx +41 -0
  222. package/src/components/form/FormTextarea.tsx +56 -0
  223. package/src/components/form/Input.stories.tsx +105 -0
  224. package/src/components/form/Input.test.tsx +76 -0
  225. package/src/components/form/Input.tsx +81 -0
  226. package/src/components/form/InputGroup.context.ts +11 -0
  227. package/src/components/form/InputGroup.stories.tsx +129 -0
  228. package/src/components/form/InputGroup.test.tsx +95 -0
  229. package/src/components/form/InputGroup.tsx +266 -0
  230. package/src/components/form/RadioGroup.stories.tsx +62 -0
  231. package/src/components/form/RadioGroup.test.tsx +98 -0
  232. package/src/components/form/RadioGroup.tsx +151 -0
  233. package/src/components/form/Textarea.stories.tsx +61 -0
  234. package/src/components/form/Textarea.test.tsx +35 -0
  235. package/src/components/form/Textarea.tsx +74 -0
  236. package/src/entries/checkbox.ts +2 -0
  237. package/src/entries/flex.ts +2 -0
  238. package/src/entries/form.ts +13 -0
  239. package/src/entries/grid.ts +2 -0
  240. package/src/entries/input-group.ts +8 -0
  241. package/src/entries/input.ts +2 -0
  242. package/src/entries/radio-group.ts +5 -0
  243. package/src/entries/separator.ts +2 -0
  244. package/src/entries/spinner.ts +2 -0
  245. package/src/entries/textarea.ts +2 -0
  246. package/src/entries/x-stack.ts +2 -0
  247. package/src/entries/y-stack.ts +2 -0
  248. package/src/fonts.test.tsx +244 -0
  249. package/src/index.ts +12 -0
  250. package/src/styles.css +12 -0
  251. package/dist/components/_internal/Spinner.d.ts +0 -9
  252. package/dist/components/_internal/Spinner.d.ts.map +0 -1
  253. package/dist/components/_internal/Spinner.js +0 -31
  254. package/dist/components/_internal/Spinner.js.map +0 -1
  255. package/src/components/_internal/Spinner.tsx +0 -49
@@ -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 font-sans 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);
@@ -0,0 +1,129 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+
3
+ import { IconEye, IconEyeOff, IconSearch } from '@tabler/icons-react-native';
4
+ import { useState } from 'react';
5
+
6
+ import { type ComponentMatrixCellProps, Story } from '../_internal/Story';
7
+ import { InputGroup } from './InputGroup';
8
+
9
+ const meta = {
10
+ component: InputGroup,
11
+ parameters: { layout: 'fullscreen' },
12
+ title: 'components/form/InputGroup',
13
+ } satisfies Meta<typeof InputGroup>;
14
+
15
+ export default meta;
16
+ type StoryDefinition = StoryObj<typeof meta>;
17
+
18
+ const STATE_COLUMNS = [
19
+ { key: 'default', label: 'Default' },
20
+ { key: 'disabled', label: 'Disabled' },
21
+ { key: 'invalid', label: 'Invalid' },
22
+ ];
23
+
24
+ const PATTERN_ROWS = [
25
+ { description: 'Leading icon addon', key: 'search', label: 'Search' },
26
+ {
27
+ description: 'Trailing visibility toggle',
28
+ key: 'password',
29
+ label: 'Password',
30
+ },
31
+ { description: 'Leading text addon', key: 'currency', label: 'Currency' },
32
+ { description: 'Trailing text addon', key: 'domain', label: 'Domain' },
33
+ ];
34
+
35
+ function InputGroupMatrixCell({ column, row }: ComponentMatrixCellProps) {
36
+ const inputProps = {
37
+ disabled: column.key === 'disabled',
38
+ invalid: column.key === 'invalid',
39
+ };
40
+
41
+ if (row.key === 'search') {
42
+ return (
43
+ <InputGroup>
44
+ <InputGroup.Addon>
45
+ <Story.Icon icon={IconSearch} />
46
+ </InputGroup.Addon>
47
+ <InputGroup.Input
48
+ accessibilityLabel="Search"
49
+ inputMode="search"
50
+ placeholder="Search…"
51
+ {...inputProps}
52
+ />
53
+ </InputGroup>
54
+ );
55
+ }
56
+
57
+ if (row.key === 'password') {
58
+ return <PasswordInputGroupCell {...inputProps} />;
59
+ }
60
+
61
+ if (row.key === 'currency') {
62
+ return (
63
+ <InputGroup>
64
+ <InputGroup.Addon>$</InputGroup.Addon>
65
+ <InputGroup.Input
66
+ accessibilityLabel="Amount in USD"
67
+ defaultValue="1200"
68
+ inputMode="decimal"
69
+ {...inputProps}
70
+ />
71
+ </InputGroup>
72
+ );
73
+ }
74
+
75
+ return (
76
+ <InputGroup>
77
+ <InputGroup.Input
78
+ accessibilityLabel="Pickle subdomain ending in .dev"
79
+ defaultValue="pickle"
80
+ {...inputProps}
81
+ />
82
+ <InputGroup.Addon align="inline-end">.dev</InputGroup.Addon>
83
+ </InputGroup>
84
+ );
85
+ }
86
+
87
+ function PasswordInputGroupCell({
88
+ disabled,
89
+ invalid,
90
+ }: {
91
+ disabled: boolean;
92
+ invalid: boolean;
93
+ }) {
94
+ const [showPassword, setShowPassword] = useState(false);
95
+
96
+ return (
97
+ <InputGroup>
98
+ <InputGroup.Input
99
+ accessibilityLabel="Password with toggle visibility"
100
+ disabled={disabled}
101
+ invalid={invalid}
102
+ placeholder="Enter your password"
103
+ secureTextEntry={!showPassword}
104
+ />
105
+ <InputGroup.Button
106
+ accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
107
+ disabled={disabled}
108
+ onPress={() => setShowPassword((visible) => !visible)}
109
+ >
110
+ <Story.Icon icon={showPassword ? IconEyeOff : IconEye} />
111
+ </InputGroup.Button>
112
+ </InputGroup>
113
+ );
114
+ }
115
+
116
+ export const All: StoryDefinition = {
117
+ render: () => (
118
+ <Story.Layout className="max-w-6xl" title="InputGroup">
119
+ <Story.Section title="Patterns and states">
120
+ <Story.Matrix
121
+ Cell={InputGroupMatrixCell}
122
+ cellClassName="items-stretch"
123
+ columns={STATE_COLUMNS}
124
+ rows={PATTERN_ROWS}
125
+ />
126
+ </Story.Section>
127
+ </Story.Layout>
128
+ ),
129
+ };
@@ -0,0 +1,95 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react-native';
2
+ import { Text } from 'react-native';
3
+
4
+ import { InputGroup } from './InputGroup';
5
+
6
+ describe('InputGroup', () => {
7
+ it('places addons by align, whatever their order in the code', async () => {
8
+ await render(
9
+ <InputGroup testID="group">
10
+ <InputGroup.Addon align="inline-end">
11
+ <Text>.dev</Text>
12
+ </InputGroup.Addon>
13
+ <InputGroup.Input accessibilityLabel="Subdomain" />
14
+ <InputGroup.Addon>
15
+ <Text>https://</Text>
16
+ </InputGroup.Addon>
17
+ </InputGroup>,
18
+ );
19
+
20
+ const texts = screen
21
+ .getByTestId('group')
22
+ .children.map((child) =>
23
+ typeof child === 'string'
24
+ ? child
25
+ : (child.props.accessibilityLabel ??
26
+ child.children
27
+ .map((c) => (typeof c === 'string' ? c : c.props.children))
28
+ .join('')),
29
+ );
30
+ expect(texts).toEqual(['https://', 'Subdomain', '.dev']);
31
+ });
32
+
33
+ it('pads the input on the sides that have an addon', async () => {
34
+ await render(
35
+ <InputGroup>
36
+ <InputGroup.Addon>$</InputGroup.Addon>
37
+ <InputGroup.Input accessibilityLabel="Amount" />
38
+ </InputGroup>,
39
+ );
40
+
41
+ expect(screen.getByLabelText('Amount')).toHaveProp(
42
+ 'className',
43
+ expect.stringContaining('ps-2'),
44
+ );
45
+ expect(screen.getByLabelText('Amount')).not.toHaveProp(
46
+ 'className',
47
+ expect.stringContaining('pe-2'),
48
+ );
49
+ });
50
+
51
+ it('frames the group while its input has focus', async () => {
52
+ await render(
53
+ <InputGroup testID="group">
54
+ <InputGroup.Input accessibilityLabel="Search" />
55
+ </InputGroup>,
56
+ );
57
+
58
+ await fireEvent(screen.getByLabelText('Search'), 'focus');
59
+
60
+ expect(screen.getByTestId('group')).toHaveProp(
61
+ 'className',
62
+ expect.stringContaining('border-ring'),
63
+ );
64
+ });
65
+
66
+ it('marks the frame from the input’s invalid and disabled props', async () => {
67
+ await render(
68
+ <InputGroup testID="group">
69
+ <InputGroup.Input accessibilityLabel="Email" disabled invalid />
70
+ </InputGroup>,
71
+ );
72
+
73
+ expect(screen.getByTestId('group')).toHaveProp(
74
+ 'className',
75
+ expect.stringMatching(/border-destructive\/36.*opacity-64/),
76
+ );
77
+ expect(screen.getByLabelText('Email')).toBeDisabled();
78
+ });
79
+
80
+ it('renders InputGroup.Button as a ghost button after the input', async () => {
81
+ const onPress = jest.fn();
82
+ await render(
83
+ <InputGroup>
84
+ <InputGroup.Button accessibilityLabel="Clear" onPress={onPress}>
85
+ <Text>×</Text>
86
+ </InputGroup.Button>
87
+ <InputGroup.Input accessibilityLabel="Search" />
88
+ </InputGroup>,
89
+ );
90
+
91
+ await fireEvent.press(screen.getByRole('button', { name: 'Clear' }));
92
+
93
+ expect(onPress).toHaveBeenCalledTimes(1);
94
+ });
95
+ });