@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,50 @@
1
+ import { View } from 'react-native';
2
+
3
+ import { cn } from '../../lib/cn';
4
+
5
+ export type InputFrameProps = React.ComponentProps<typeof View> & {
6
+ disabled?: boolean;
7
+ focused: boolean;
8
+ invalid?: boolean;
9
+ };
10
+
11
+ /**
12
+ * The border, background and focus ring the web's `Input`, `Textarea` and
13
+ * `InputGroup` draw around their field. React Native has no `ring`, so a
14
+ * 3pt outline sits outside the border while the field has focus.
15
+ */
16
+ export function InputFrame({
17
+ children,
18
+ className,
19
+ disabled,
20
+ focused,
21
+ invalid,
22
+ ...props
23
+ }: InputFrameProps) {
24
+ return (
25
+ <View
26
+ className={cn(
27
+ 'border-input bg-background dark:bg-input/32 w-full flex-row rounded-lg border shadow-xs shadow-black/5',
28
+ invalid && 'border-destructive/36',
29
+ focused && (invalid ? 'border-destructive/64' : 'border-ring'),
30
+ (disabled || focused || invalid) && 'shadow-none',
31
+ disabled && 'opacity-64',
32
+ className,
33
+ )}
34
+ {...props}
35
+ >
36
+ {children}
37
+ {focused ? (
38
+ <View
39
+ className={cn(
40
+ 'absolute -inset-1 rounded-[calc(var(--radius)+3px)] border-[3px]',
41
+ invalid
42
+ ? 'border-destructive/16 dark:border-destructive/24'
43
+ : 'border-ring/24',
44
+ )}
45
+ pointerEvents="none"
46
+ />
47
+ ) : null}
48
+ </View>
49
+ );
50
+ }
@@ -0,0 +1,24 @@
1
+ import { View } from 'react-native';
2
+
3
+ import { cn } from '../../lib/cn';
4
+ import { Text } from '../Text';
5
+
6
+ /** A labelled box for the layout stories. */
7
+ export function StoryItem({
8
+ children,
9
+ className,
10
+ }: {
11
+ children: string;
12
+ className?: string;
13
+ }) {
14
+ return (
15
+ <View
16
+ className={cn(
17
+ 'border-border bg-muted items-center justify-center rounded-md border px-4 py-3',
18
+ className,
19
+ )}
20
+ >
21
+ <Text>{children}</Text>
22
+ </View>
23
+ );
24
+ }
@@ -0,0 +1,103 @@
1
+ import { cn } from '../../lib/cn';
2
+
3
+ // Same props and spacing scale as the layout helper of @pickle-ui/react. Each
4
+ // class is written out in full so Uniwind finds it in the source.
5
+
6
+ const alignClasses = {
7
+ baseline: 'items-baseline',
8
+ center: 'items-center',
9
+ end: 'items-end',
10
+ start: 'items-start',
11
+ stretch: 'items-stretch',
12
+ } as const;
13
+
14
+ const gapClasses = {
15
+ 0: 'gap-0',
16
+ 0.5: 'gap-0.5',
17
+ 1: 'gap-1',
18
+ 1.5: 'gap-1.5',
19
+ 2: 'gap-2',
20
+ 2.5: 'gap-2.5',
21
+ 3: 'gap-3',
22
+ 3.5: 'gap-3.5',
23
+ 4: 'gap-4',
24
+ 5: 'gap-5',
25
+ 6: 'gap-6',
26
+ 8: 'gap-8',
27
+ 10: 'gap-10',
28
+ 12: 'gap-12',
29
+ 16: 'gap-16',
30
+ } as const;
31
+
32
+ export const gapXClasses = {
33
+ 0: 'gap-x-0',
34
+ 0.5: 'gap-x-0.5',
35
+ 1: 'gap-x-1',
36
+ 1.5: 'gap-x-1.5',
37
+ 2: 'gap-x-2',
38
+ 2.5: 'gap-x-2.5',
39
+ 3: 'gap-x-3',
40
+ 3.5: 'gap-x-3.5',
41
+ 4: 'gap-x-4',
42
+ 5: 'gap-x-5',
43
+ 6: 'gap-x-6',
44
+ 8: 'gap-x-8',
45
+ 10: 'gap-x-10',
46
+ 12: 'gap-x-12',
47
+ 16: 'gap-x-16',
48
+ } as const;
49
+
50
+ export const gapYClasses = {
51
+ 0: 'gap-y-0',
52
+ 0.5: 'gap-y-0.5',
53
+ 1: 'gap-y-1',
54
+ 1.5: 'gap-y-1.5',
55
+ 2: 'gap-y-2',
56
+ 2.5: 'gap-y-2.5',
57
+ 3: 'gap-y-3',
58
+ 3.5: 'gap-y-3.5',
59
+ 4: 'gap-y-4',
60
+ 5: 'gap-y-5',
61
+ 6: 'gap-y-6',
62
+ 8: 'gap-y-8',
63
+ 10: 'gap-y-10',
64
+ 12: 'gap-y-12',
65
+ 16: 'gap-y-16',
66
+ } as const;
67
+
68
+ const justifyClasses = {
69
+ around: 'justify-around',
70
+ between: 'justify-between',
71
+ center: 'justify-center',
72
+ end: 'justify-end',
73
+ evenly: 'justify-evenly',
74
+ start: 'justify-start',
75
+ } as const;
76
+
77
+ export type LayoutAlign = keyof typeof alignClasses;
78
+ export type LayoutGap = keyof typeof gapClasses;
79
+ export type LayoutJustify = keyof typeof justifyClasses;
80
+
81
+ export type LayoutStyleProps = {
82
+ align?: LayoutAlign;
83
+ gap?: LayoutGap;
84
+ gapX?: LayoutGap;
85
+ gapY?: LayoutGap;
86
+ justify?: LayoutJustify;
87
+ };
88
+
89
+ export function getLayoutClasses({
90
+ align,
91
+ gap,
92
+ gapX,
93
+ gapY,
94
+ justify,
95
+ }: LayoutStyleProps) {
96
+ return cn(
97
+ align === undefined ? undefined : alignClasses[align],
98
+ gap === undefined ? undefined : gapClasses[gap],
99
+ gapX === undefined ? undefined : gapXClasses[gapX],
100
+ gapY === undefined ? undefined : gapYClasses[gapY],
101
+ justify === undefined ? undefined : justifyClasses[justify],
102
+ );
103
+ }
@@ -0,0 +1,25 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ /**
4
+ * State a component owns unless its parent controls it, like Base UI's
5
+ * `checked` / `defaultChecked` / `onCheckedChange` trios: `value` wins when
6
+ * defined, and every change is reported through `onChange`.
7
+ */
8
+ export function useControllableState<T>(
9
+ value: T | undefined,
10
+ defaultValue: T,
11
+ onChange?: (value: T) => void,
12
+ ) {
13
+ const [uncontrolled, setUncontrolled] = useState(defaultValue);
14
+ const controlled = value !== undefined;
15
+
16
+ const setValue = useCallback(
17
+ (next: T) => {
18
+ if (!controlled) setUncontrolled(next);
19
+ onChange?.(next);
20
+ },
21
+ [controlled, onChange],
22
+ );
23
+
24
+ return [controlled ? value : uncontrolled, setValue] as const;
25
+ }
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+
3
+ import { type ComponentMatrixCellProps, Story } from '../_internal/Story';
4
+ import { Checkbox, type CheckboxProps } from './Checkbox';
5
+
6
+ const meta = {
7
+ component: Checkbox,
8
+ parameters: { layout: 'fullscreen' },
9
+ title: 'components/form/Checkbox',
10
+ } satisfies Meta<typeof Checkbox>;
11
+
12
+ export default meta;
13
+ type CheckboxSize = NonNullable<CheckboxProps['size']>;
14
+ type StoryDefinition = StoryObj<typeof meta>;
15
+
16
+ const STATE_COLUMNS: {
17
+ key: string;
18
+ label: string;
19
+ props: Partial<CheckboxProps>;
20
+ }[] = [
21
+ { key: 'unchecked', label: 'Unchecked', props: {} },
22
+ { key: 'checked', label: 'Checked', props: { defaultChecked: true } },
23
+ { key: 'indeterminate', label: 'Mixed', props: { indeterminate: true } },
24
+ {
25
+ key: 'invalid',
26
+ label: 'Invalid',
27
+ props: { defaultChecked: true, invalid: true },
28
+ },
29
+ { key: 'disabled', label: 'Disabled', props: { disabled: true } },
30
+ ];
31
+
32
+ const SIZE_ROWS: { key: CheckboxSize; label: string }[] = [
33
+ { key: 'md', label: 'Medium' },
34
+ { key: 'sm', label: 'Small' },
35
+ ];
36
+
37
+ const STATE_PROPS = Object.fromEntries(
38
+ STATE_COLUMNS.map((state) => [state.key, state.props]),
39
+ ) as Record<string, Partial<CheckboxProps>>;
40
+
41
+ function CheckboxStateCell({ column, row }: ComponentMatrixCellProps) {
42
+ return (
43
+ <Checkbox
44
+ accessibilityLabel={`${row.label} ${column.label}`}
45
+ size={row.key as CheckboxSize}
46
+ {...STATE_PROPS[column.key]}
47
+ />
48
+ );
49
+ }
50
+
51
+ export const All: StoryDefinition = {
52
+ render: () => (
53
+ <Story.Layout className="max-w-5xl" title="Checkbox">
54
+ <Story.Section title="Sizes and states">
55
+ <Story.Matrix
56
+ Cell={CheckboxStateCell}
57
+ columns={STATE_COLUMNS}
58
+ rows={SIZE_ROWS}
59
+ />
60
+ </Story.Section>
61
+
62
+ <Story.Section title="Label">
63
+ <Checkbox label="Accept terms" />
64
+ </Story.Section>
65
+ </Story.Layout>
66
+ ),
67
+ };
@@ -0,0 +1,88 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react-native';
2
+
3
+ import { Checkbox } from './Checkbox';
4
+
5
+ describe('Checkbox', () => {
6
+ it('renders unchecked by default', async () => {
7
+ await render(<Checkbox accessibilityLabel="Accept" />);
8
+
9
+ expect(screen.getByRole('checkbox', { name: 'Accept' })).not.toBeChecked();
10
+ });
11
+
12
+ it('toggles on press', async () => {
13
+ await render(<Checkbox accessibilityLabel="Accept" />);
14
+ const checkbox = screen.getByRole('checkbox', { name: 'Accept' });
15
+
16
+ await fireEvent.press(checkbox);
17
+ expect(checkbox).toBeChecked();
18
+
19
+ await fireEvent.press(checkbox);
20
+ expect(checkbox).not.toBeChecked();
21
+ });
22
+
23
+ it('is named by its label, which toggles it', async () => {
24
+ const onCheckedChange = jest.fn();
25
+ await render(
26
+ <Checkbox label="Accept terms" onCheckedChange={onCheckedChange} />,
27
+ );
28
+
29
+ await fireEvent.press(screen.getByText('Accept terms'));
30
+
31
+ expect(
32
+ screen.getByRole('checkbox', { name: 'Accept terms' }),
33
+ ).toBeChecked();
34
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
35
+ });
36
+
37
+ it('follows the checked prop when controlled', async () => {
38
+ const onCheckedChange = jest.fn();
39
+ await render(
40
+ <Checkbox
41
+ accessibilityLabel="Accept"
42
+ checked={false}
43
+ onCheckedChange={onCheckedChange}
44
+ />,
45
+ );
46
+
47
+ await fireEvent.press(screen.getByRole('checkbox'));
48
+
49
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
50
+ expect(screen.getByRole('checkbox')).not.toBeChecked();
51
+ });
52
+
53
+ it('reports the mixed state when indeterminate', async () => {
54
+ await render(<Checkbox accessibilityLabel="Partial" indeterminate />);
55
+
56
+ expect(
57
+ screen.getByRole('checkbox', { name: 'Partial' }),
58
+ ).toBePartiallyChecked();
59
+ });
60
+
61
+ it('is disabled and ignores presses', async () => {
62
+ await render(<Checkbox accessibilityLabel="Accept" disabled />);
63
+ const checkbox = screen.getByRole('checkbox', { name: 'Accept' });
64
+
65
+ await fireEvent.press(checkbox);
66
+
67
+ expect(checkbox).toBeDisabled();
68
+ expect(checkbox).not.toBeChecked();
69
+ });
70
+
71
+ it('reaches a 44pt touch target', async () => {
72
+ await render(
73
+ <>
74
+ <Checkbox accessibilityLabel="Box" />
75
+ <Checkbox label="Row" size="sm" />
76
+ </>,
77
+ );
78
+
79
+ expect(screen.getByRole('checkbox', { name: 'Box' })).toHaveProp(
80
+ 'hitSlop',
81
+ 13,
82
+ );
83
+ expect(screen.getByRole('checkbox', { name: 'Row' })).toHaveProp(
84
+ 'hitSlop',
85
+ { bottom: 15, top: 15 },
86
+ );
87
+ });
88
+ });
@@ -0,0 +1,167 @@
1
+ import { Pressable, Text, View } from 'react-native';
2
+
3
+ import { cn } from '../../lib/cn';
4
+ import { useControllableState } from '../_internal/useControllableState';
5
+
6
+ // Same box sizes as `Checkbox` in @pickle-ui/react. `hitSlop` stretches the
7
+ // box to a 44pt touch target, vertically only when a label already widens it.
8
+ const boxClasses = { md: 'size-4.5', sm: 'size-3.5' } as const;
9
+ const boxHitSlop = { md: 13, sm: 15 } as const;
10
+ const fieldHitSlop = {
11
+ md: { bottom: 13, top: 13 },
12
+ sm: { bottom: 15, top: 15 },
13
+ } as const;
14
+
15
+ // The web draws Tabler's check and minus icons. Without SVG, the check is the
16
+ // bottom and right borders of a rotated box, and the minus a bar.
17
+ const checkClasses = {
18
+ md: 'h-[9px] w-[5px] border-b-[1.5px] border-r-[1.5px]',
19
+ sm: 'h-[7px] w-[4px] border-b-[1.25px] border-r-[1.25px]',
20
+ } as const;
21
+ const minusClasses = { md: 'h-[1.5px] w-2', sm: 'h-[1.25px] w-1.5' } as const;
22
+
23
+ export type CheckboxProps = Omit<
24
+ React.ComponentProps<typeof Pressable>,
25
+ 'children' | 'disabled'
26
+ > & {
27
+ checked?: boolean;
28
+ /** Class overrides for the row around the box and label, the check and the label. */
29
+ classNames?: { field?: string; indicator?: string; label?: string };
30
+ defaultChecked?: boolean;
31
+ disabled?: boolean;
32
+ /** Shows a minus instead of the check, for a partly selected group. */
33
+ indeterminate?: boolean;
34
+ /** Marks the value as invalid, like the web's `aria-invalid`. */
35
+ invalid?: boolean;
36
+ /** Text next to the box; pressing it toggles the checkbox and names it. */
37
+ label?: React.ReactNode;
38
+ onCheckedChange?: (checked: boolean) => void;
39
+ size?: 'md' | 'sm';
40
+ };
41
+
42
+ /**
43
+ * A checkbox, like `Checkbox` in @pickle-ui/react. Give it a `label`, or an
44
+ * `accessibilityLabel` when visible text elsewhere names it.
45
+ */
46
+ export function Checkbox({
47
+ accessibilityState,
48
+ checked: checkedProp,
49
+ className,
50
+ classNames,
51
+ defaultChecked = false,
52
+ disabled = false,
53
+ indeterminate = false,
54
+ invalid = false,
55
+ label,
56
+ onCheckedChange,
57
+ size = 'md',
58
+ ...props
59
+ }: CheckboxProps) {
60
+ const [checked, setChecked] = useControllableState(
61
+ checkedProp,
62
+ defaultChecked,
63
+ onCheckedChange,
64
+ );
65
+ return (
66
+ // `role` and `aria-*` are React Native's current accessibility props;
67
+ // `accessibilityState` keeps any extra state the caller passes.
68
+ <Pressable
69
+ accessibilityState={{
70
+ ...accessibilityState,
71
+ checked: indeterminate ? 'mixed' : checked,
72
+ disabled,
73
+ }}
74
+ aria-checked={indeterminate ? 'mixed' : checked}
75
+ aria-disabled={disabled}
76
+ className={
77
+ label
78
+ ? cn('flex-row items-center gap-2', classNames?.field)
79
+ : 'self-start'
80
+ }
81
+ disabled={disabled}
82
+ hitSlop={label ? fieldHitSlop[size] : boxHitSlop[size]}
83
+ onPress={() => setChecked(!checked)}
84
+ role="checkbox"
85
+ {...props}
86
+ >
87
+ <CheckboxBox
88
+ checked={checked}
89
+ className={className}
90
+ disabled={disabled}
91
+ indeterminate={indeterminate}
92
+ indicatorClassName={classNames?.indicator}
93
+ invalid={invalid}
94
+ size={size}
95
+ />
96
+ {label ? (
97
+ <Text
98
+ className={cn(
99
+ 'text-foreground font-sans text-sm leading-none font-medium',
100
+ disabled && 'opacity-50',
101
+ classNames?.label,
102
+ )}
103
+ >
104
+ {label}
105
+ </Text>
106
+ ) : null}
107
+ </Pressable>
108
+ );
109
+ }
110
+
111
+ function CheckboxBox({
112
+ checked,
113
+ className,
114
+ disabled,
115
+ indeterminate,
116
+ indicatorClassName,
117
+ invalid,
118
+ size,
119
+ }: {
120
+ checked: boolean;
121
+ className?: string;
122
+ disabled: boolean;
123
+ indeterminate: boolean;
124
+ indicatorClassName?: string;
125
+ invalid: boolean;
126
+ size: 'md' | 'sm';
127
+ }) {
128
+ const filled = checked && !indeterminate;
129
+
130
+ return (
131
+ <View
132
+ className={cn(
133
+ 'items-center justify-center rounded-[4px] border shadow-xs shadow-black/5',
134
+ boxClasses[size],
135
+ // A checked box is filled in both themes, so the dark input
136
+ // background must not apply to it.
137
+ filled
138
+ ? 'border-primary bg-primary'
139
+ : cn(
140
+ 'border-input bg-background dark:bg-input/32',
141
+ invalid && 'border-destructive/36',
142
+ ),
143
+ (checked || indeterminate || invalid || disabled) && 'shadow-none',
144
+ disabled && 'opacity-64',
145
+ className,
146
+ )}
147
+ >
148
+ {indeterminate ? (
149
+ <View
150
+ className={cn(
151
+ 'bg-foreground rounded-full',
152
+ minusClasses[size],
153
+ indicatorClassName,
154
+ )}
155
+ />
156
+ ) : checked ? (
157
+ <View
158
+ className={cn(
159
+ 'border-primary-foreground -mt-px rotate-45',
160
+ checkClasses[size],
161
+ indicatorClassName,
162
+ )}
163
+ />
164
+ ) : null}
165
+ </View>
166
+ );
167
+ }
@@ -0,0 +1,91 @@
1
+ import { createContext, isValidElement, type ReactNode, use } from 'react';
2
+ import {
3
+ useFormContext,
4
+ type UseFormReturn,
5
+ useFormState,
6
+ } from 'react-hook-form';
7
+
8
+ export type FormFieldContextValue = {
9
+ /** The field's label, used as its control's accessibility label when it is text. */
10
+ label?: ReactNode;
11
+ /** The `nativeID` of the field's label, for `aria-labelledby`. */
12
+ labelId?: string;
13
+ name?: string;
14
+ /** Registers the control the label focuses when pressed. */
15
+ setControl: (control: FocusTarget | null) => void;
16
+ };
17
+
18
+ type FocusTarget = { focus: () => void };
19
+
20
+ export const FormFieldContext = createContext<FormFieldContextValue | null>(
21
+ null,
22
+ );
23
+
24
+ /** True inside `Form.InputGroup`, where `Form.Input` renders `InputGroup.Input`. */
25
+ export const FormInputGroupContext = createContext(false);
26
+
27
+ /** Submits the form, like pressing a submit button or Enter on the web. */
28
+ export const FormSubmitContext = createContext<() => void>(() => {});
29
+
30
+ export function getErrorMessage(error: unknown): ReactNode {
31
+ if (!error || typeof error !== 'object') return undefined;
32
+
33
+ const message = (error as { message?: unknown }).message;
34
+
35
+ return isValidElement(message) ||
36
+ typeof message === 'string' ||
37
+ typeof message === 'number'
38
+ ? message
39
+ : undefined;
40
+ }
41
+
42
+ /**
43
+ * React Hook Form tracks field state but has no DOM to label: this maps the
44
+ * current `Form.Field` onto its control, naming it by the field's label and
45
+ * marking it `invalid` while the field has an error.
46
+ */
47
+ export function useFormControlProps() {
48
+ const field = use(FormFieldContext);
49
+ const { control, getFieldState } = useFormMethods();
50
+ const name = field?.name;
51
+ const formState = useFormState({
52
+ control,
53
+ disabled: !name,
54
+ exact: true,
55
+ name,
56
+ });
57
+
58
+ return {
59
+ accessibilityLabel:
60
+ typeof field?.label === 'string' ? field.label : undefined,
61
+ 'aria-labelledby': field?.labelId,
62
+ invalid: name ? getFieldState(name, formState).invalid : false,
63
+ };
64
+ }
65
+
66
+ export function useFormMethods() {
67
+ const methods = useFormContext() as null | UseFormReturn;
68
+
69
+ if (!methods) {
70
+ throw new Error(
71
+ 'Form components must be rendered inside <Form form={form}>.',
72
+ );
73
+ }
74
+
75
+ return methods;
76
+ }
77
+
78
+ export function useRequiredFieldName(
79
+ name: string | undefined,
80
+ componentName: string,
81
+ ) {
82
+ const fieldName = name ?? use(FormFieldContext)?.name;
83
+
84
+ if (!fieldName) {
85
+ throw new Error(
86
+ `${componentName} requires a name prop or a parent <Form.Field name="...">.`,
87
+ );
88
+ }
89
+
90
+ return fieldName;
91
+ }