@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,266 @@
1
+ import {
2
+ Children,
3
+ createContext,
4
+ isValidElement,
5
+ type ReactNode,
6
+ use,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+ import { Pressable, Text, type TextInput, View } from 'react-native';
13
+
14
+ import { cn } from '../../lib/cn';
15
+ import { InputFrame } from '../_internal/InputFrame';
16
+ import { Button, type ButtonProps } from '../Button';
17
+ import { Input, type InputProps } from './Input';
18
+ import { InputGroupStateContext } from './InputGroup.context';
19
+
20
+ type InputGroupAlign =
21
+ | 'block-end'
22
+ | 'block-start'
23
+ | 'inline-end'
24
+ | 'inline-start';
25
+
26
+ type TextInputInstance = React.ComponentRef<typeof TextInput>;
27
+
28
+ // The web's addon paddings minus the 1pt border. Addons holding a button sit
29
+ // closer to the edge, like the web's `has-[>button]` margins.
30
+ const addonClasses = {
31
+ 'block-end': 'w-full justify-start px-[11px] pb-[11px]',
32
+ 'block-start': 'w-full justify-start px-[11px] pt-[11px]',
33
+ 'inline-end': 'pe-[11px]',
34
+ 'inline-start': 'ps-[11px]',
35
+ } as const;
36
+ const buttonAddonClasses = {
37
+ 'block-end': '',
38
+ 'block-start': '',
39
+ 'inline-end': 'pe-[3px]',
40
+ 'inline-start': 'ps-[3px]',
41
+ } as const;
42
+
43
+ type InputGroupContextValue = {
44
+ focusInput: () => void;
45
+ hasInlineEnd: boolean;
46
+ hasInlineStart: boolean;
47
+ setFocused: (focused: boolean) => void;
48
+ setInput: (input: null | TextInputInstance) => void;
49
+ };
50
+
51
+ const InputGroupContext = createContext<InputGroupContextValue | null>(null);
52
+
53
+ export type InputGroupAddonProps = React.ComponentProps<typeof Pressable> & {
54
+ align?: InputGroupAlign;
55
+ /** Text is wrapped in a label; other children, such as icons, render as they are. */
56
+ children?: ReactNode;
57
+ };
58
+
59
+ export type InputGroupButtonProps = ButtonProps & { align?: InputGroupAlign };
60
+
61
+ export type InputGroupInputProps = Omit<InputProps, 'unstyled'>;
62
+
63
+ export type InputGroupProps = React.ComponentProps<typeof View>;
64
+
65
+ export type InputGroupTextProps = React.ComponentProps<typeof Text>;
66
+
67
+ /** Pressing an addon focuses the field, like clicking it on the web. */
68
+ function InputGroupAddon({
69
+ align = 'inline-start',
70
+ children,
71
+ className,
72
+ ...props
73
+ }: InputGroupAddonProps) {
74
+ const group = useInputGroup('Addon');
75
+
76
+ return (
77
+ <Pressable
78
+ accessible={false}
79
+ className={cn(
80
+ 'flex-row items-center justify-center gap-2',
81
+ addonClasses[align],
82
+ className,
83
+ )}
84
+ onPress={group.focusInput}
85
+ {...props}
86
+ >
87
+ {Children.map(children, (child) =>
88
+ typeof child === 'string' || typeof child === 'number' ? (
89
+ <Text className="text-foreground font-sans text-[14px]">{child}</Text>
90
+ ) : (
91
+ child
92
+ ),
93
+ )}
94
+ </Pressable>
95
+ );
96
+ }
97
+
98
+ function InputGroupButton({
99
+ align = 'inline-end',
100
+ className,
101
+ size = 'icon-xs',
102
+ variant = 'ghost',
103
+ ...props
104
+ }: InputGroupButtonProps) {
105
+ return (
106
+ <InputGroupAddon align={align} className={buttonAddonClasses[align]}>
107
+ <Button className={className} size={size} variant={variant} {...props} />
108
+ </InputGroupAddon>
109
+ );
110
+ }
111
+
112
+ function InputGroupInput({
113
+ className,
114
+ onBlur,
115
+ onFocus,
116
+ ref,
117
+ ...props
118
+ }: InputGroupInputProps) {
119
+ const group = useInputGroup('Input');
120
+ const inputRef = useRef<null | TextInputInstance>(null);
121
+ useImperativeHandle(ref, () => inputRef.current as TextInputInstance);
122
+
123
+ return (
124
+ <Input
125
+ className={cn(
126
+ group.hasInlineStart && 'ps-2',
127
+ group.hasInlineEnd && 'pe-2',
128
+ className,
129
+ )}
130
+ onBlur={(event) => {
131
+ group.setFocused(false);
132
+ onBlur?.(event);
133
+ }}
134
+ onFocus={(event) => {
135
+ group.setFocused(true);
136
+ onFocus?.(event);
137
+ }}
138
+ ref={(node: null | TextInputInstance) => {
139
+ inputRef.current = node;
140
+ // The group focuses the input when an addon is pressed.
141
+ group.setInput(node);
142
+ }}
143
+ unstyled
144
+ {...props}
145
+ />
146
+ );
147
+ }
148
+
149
+ /** Places addons around the input and frames the group like an `Input`. */
150
+ function InputGroupRoot({ children, className, ...props }: InputGroupProps) {
151
+ const [focused, setFocused] = useState(false);
152
+ const inputRef = useRef<null | TextInputInstance>(null);
153
+ const state = use(InputGroupStateContext);
154
+ const sorted = sortChildren(children);
155
+ const { parts } = sorted;
156
+ const disabled = sorted.disabled || Boolean(state?.disabled);
157
+ const invalid = sorted.invalid || Boolean(state?.invalid);
158
+ const hasInlineEnd = parts['inline-end'].length > 0;
159
+ const hasInlineStart = parts['inline-start'].length > 0;
160
+ const context = useMemo(
161
+ () => ({
162
+ focusInput: () => inputRef.current?.focus(),
163
+ hasInlineEnd,
164
+ hasInlineStart,
165
+ setFocused,
166
+ setInput: (input: null | TextInputInstance) => {
167
+ inputRef.current = input;
168
+ },
169
+ }),
170
+ [hasInlineEnd, hasInlineStart],
171
+ );
172
+ const hasBlock =
173
+ parts['block-start'].length > 0 || parts['block-end'].length > 0;
174
+ const row = [
175
+ ...parts['inline-start'],
176
+ ...parts.field,
177
+ ...parts['inline-end'],
178
+ ];
179
+
180
+ return (
181
+ <InputGroupContext value={context}>
182
+ <InputFrame
183
+ className={cn(hasBlock ? 'flex-col' : 'h-8 items-center', className)}
184
+ disabled={disabled}
185
+ focused={focused}
186
+ invalid={invalid}
187
+ role="group"
188
+ {...props}
189
+ >
190
+ {hasBlock ? (
191
+ <>
192
+ {parts['block-start']}
193
+ <View className="flex-row items-center">{row}</View>
194
+ {parts['block-end']}
195
+ </>
196
+ ) : (
197
+ row
198
+ )}
199
+ </InputFrame>
200
+ </InputGroupContext>
201
+ );
202
+ }
203
+
204
+ function InputGroupText({ className, ...props }: InputGroupTextProps) {
205
+ return (
206
+ <Text
207
+ className={cn('text-muted-foreground font-sans text-[14px]', className)}
208
+ numberOfLines={1}
209
+ {...props}
210
+ />
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Splits the group's direct children by `align`, since React Native has no
216
+ * CSS `order`, and reads `disabled` and `invalid` from its inputs.
217
+ */
218
+ function sortChildren(children: ReactNode) {
219
+ const parts: Record<'field' | InputGroupAlign, ReactNode[]> = {
220
+ 'block-end': [],
221
+ 'block-start': [],
222
+ field: [],
223
+ 'inline-end': [],
224
+ 'inline-start': [],
225
+ };
226
+ let disabled = false;
227
+ let invalid = false;
228
+
229
+ for (const child of Children.toArray(children)) {
230
+ if (
231
+ isValidElement<{ align?: InputGroupAlign }>(child) &&
232
+ (child.type === InputGroupAddon || child.type === InputGroupButton)
233
+ ) {
234
+ const fallback =
235
+ child.type === InputGroupButton ? 'inline-end' : 'inline-start';
236
+ parts[child.props.align ?? fallback].push(child);
237
+ } else {
238
+ if (
239
+ isValidElement<InputGroupInputProps>(child) &&
240
+ child.type === InputGroupInput
241
+ ) {
242
+ disabled ||= Boolean(child.props.disabled);
243
+ invalid ||= Boolean(child.props.invalid);
244
+ }
245
+ parts.field.push(child);
246
+ }
247
+ }
248
+
249
+ return { disabled, invalid, parts };
250
+ }
251
+
252
+ function useInputGroup(part: string) {
253
+ const group = use(InputGroupContext);
254
+ if (!group)
255
+ throw new Error(`InputGroup.${part} must be inside an InputGroup.`);
256
+ return group;
257
+ }
258
+
259
+ const InputGroup = Object.assign(InputGroupRoot, {
260
+ Addon: InputGroupAddon,
261
+ Button: InputGroupButton,
262
+ Input: InputGroupInput,
263
+ Text: InputGroupText,
264
+ });
265
+
266
+ export { InputGroup };
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+
3
+ import { type ComponentMatrixCellProps, Story } from '../_internal/Story';
4
+ import { RadioGroup, type RadioGroupItemProps } from './RadioGroup';
5
+
6
+ const meta = {
7
+ component: RadioGroup,
8
+ parameters: { layout: 'fullscreen' },
9
+ title: 'components/form/RadioGroup',
10
+ } satisfies Meta<typeof RadioGroup>;
11
+
12
+ export default meta;
13
+ type RadioSize = NonNullable<RadioGroupItemProps['size']>;
14
+ type StoryDefinition = StoryObj<typeof meta>;
15
+
16
+ const SIZE_ROWS: { key: RadioSize; label: string }[] = [
17
+ { key: 'md', label: 'Medium' },
18
+ { key: 'sm', label: 'Small' },
19
+ ];
20
+
21
+ const STATE_COLUMNS = [
22
+ { key: 'unchecked', label: 'Unchecked' },
23
+ { key: 'checked', label: 'Checked' },
24
+ { key: 'disabled', label: 'Disabled' },
25
+ ];
26
+
27
+ function RadioStateCell({ column, row }: ComponentMatrixCellProps) {
28
+ const value = column.key === 'checked' ? 'option' : undefined;
29
+
30
+ return (
31
+ <RadioGroup defaultValue={value}>
32
+ <RadioGroup.Item
33
+ disabled={column.key === 'disabled'}
34
+ label={column.label}
35
+ size={row.key as RadioSize}
36
+ value="option"
37
+ />
38
+ </RadioGroup>
39
+ );
40
+ }
41
+
42
+ export const All: StoryDefinition = {
43
+ render: () => (
44
+ <Story.Layout className="max-w-5xl" title="RadioGroup">
45
+ <Story.Section title="Sizes and states">
46
+ <Story.Matrix
47
+ Cell={RadioStateCell}
48
+ columns={STATE_COLUMNS}
49
+ rows={SIZE_ROWS}
50
+ />
51
+ </Story.Section>
52
+
53
+ <Story.Section title="Group">
54
+ <RadioGroup accessibilityLabel="Notifications" defaultValue="email">
55
+ <RadioGroup.Item label="Email" value="email" />
56
+ <RadioGroup.Item label="SMS" value="sms" />
57
+ <RadioGroup.Item disabled label="Push" value="push" />
58
+ </RadioGroup>
59
+ </Story.Section>
60
+ </Story.Layout>
61
+ ),
62
+ };
@@ -0,0 +1,98 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react-native';
2
+
3
+ import { RadioGroup } from './RadioGroup';
4
+
5
+ describe('RadioGroup', () => {
6
+ it('renders a group of radios named by their labels', async () => {
7
+ await render(
8
+ <RadioGroup accessibilityLabel="Plan">
9
+ <RadioGroup.Item label="Option A" value="a" />
10
+ <RadioGroup.Item label="Option B" value="b" />
11
+ </RadioGroup>,
12
+ );
13
+
14
+ expect(screen.getByLabelText('Plan')).toHaveProp('role', 'radiogroup');
15
+ expect(screen.getByRole('radio', { name: 'Option A' })).toBeOnTheScreen();
16
+ expect(screen.getByRole('radio', { name: 'Option B' })).toBeOnTheScreen();
17
+ });
18
+
19
+ it('selects defaultValue on mount', async () => {
20
+ await render(
21
+ <RadioGroup defaultValue="b">
22
+ <RadioGroup.Item label="Option A" value="a" />
23
+ <RadioGroup.Item label="Option B" value="b" />
24
+ </RadioGroup>,
25
+ );
26
+
27
+ expect(screen.getByRole('radio', { name: 'Option B' })).toBeChecked();
28
+ expect(screen.getByRole('radio', { name: 'Option A' })).not.toBeChecked();
29
+ });
30
+
31
+ it('selecting one item deselects the others', async () => {
32
+ const onValueChange = jest.fn();
33
+ await render(
34
+ <RadioGroup defaultValue="a" onValueChange={onValueChange}>
35
+ <RadioGroup.Item label="Option A" value="a" />
36
+ <RadioGroup.Item label="Option B" value="b" />
37
+ </RadioGroup>,
38
+ );
39
+
40
+ await fireEvent.press(screen.getByText('Option B'));
41
+
42
+ expect(screen.getByRole('radio', { name: 'Option B' })).toBeChecked();
43
+ expect(screen.getByRole('radio', { name: 'Option A' })).not.toBeChecked();
44
+ expect(onValueChange).toHaveBeenCalledWith('b');
45
+ });
46
+
47
+ it('follows the value prop when controlled', async () => {
48
+ const onValueChange = jest.fn();
49
+ await render(
50
+ <RadioGroup onValueChange={onValueChange} value="a">
51
+ <RadioGroup.Item label="Option A" value="a" />
52
+ <RadioGroup.Item label="Option B" value="b" />
53
+ </RadioGroup>,
54
+ );
55
+
56
+ await fireEvent.press(screen.getByRole('radio', { name: 'Option B' }));
57
+
58
+ expect(onValueChange).toHaveBeenCalledWith('b');
59
+ expect(screen.getByRole('radio', { name: 'Option A' })).toBeChecked();
60
+ });
61
+
62
+ it('disables every item when the group is disabled', async () => {
63
+ const onValueChange = jest.fn();
64
+ await render(
65
+ <RadioGroup disabled onValueChange={onValueChange}>
66
+ <RadioGroup.Item label="Option A" value="a" />
67
+ <RadioGroup.Item disabled={false} label="Option B" value="b" />
68
+ </RadioGroup>,
69
+ );
70
+
71
+ await fireEvent.press(screen.getByRole('radio', { name: 'Option A' }));
72
+
73
+ expect(screen.getByRole('radio', { name: 'Option A' })).toBeDisabled();
74
+ expect(screen.getByRole('radio', { name: 'Option B' })).toBeDisabled();
75
+ expect(onValueChange).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('styles the row and the label text through classNames', async () => {
79
+ await render(
80
+ <RadioGroup>
81
+ <RadioGroup.Item
82
+ classNames={{ field: 'min-h-11', label: 'text-base font-normal' }}
83
+ label="Option A"
84
+ value="a"
85
+ />
86
+ </RadioGroup>,
87
+ );
88
+
89
+ expect(screen.getByRole('radio', { name: 'Option A' })).toHaveProp(
90
+ 'className',
91
+ expect.stringContaining('min-h-11'),
92
+ );
93
+ expect(screen.getByText('Option A')).toHaveProp(
94
+ 'className',
95
+ expect.stringContaining('text-base font-normal'),
96
+ );
97
+ });
98
+ });
@@ -0,0 +1,151 @@
1
+ import { createContext, use, useMemo } from 'react';
2
+ import { Pressable, Text, View } from 'react-native';
3
+
4
+ import { cn } from '../../lib/cn';
5
+ import { useControllableState } from '../_internal/useControllableState';
6
+
7
+ // Same sizes as `RadioGroup` in @pickle-ui/react. `hitSlop` stretches the
8
+ // circle to a 44pt touch target, vertically only when a label widens it.
9
+ const controlClasses = { md: 'size-4', sm: 'size-3.5' } as const;
10
+ const indicatorClasses = { md: 'size-2', sm: 'size-1.5' } as const;
11
+ const controlHitSlop = { md: 14, sm: 15 } as const;
12
+ const fieldHitSlop = {
13
+ md: { bottom: 14, top: 14 },
14
+ sm: { bottom: 15, top: 15 },
15
+ } as const;
16
+
17
+ type RadioGroupContextValue = {
18
+ disabled: boolean;
19
+ onSelect: (value: string) => void;
20
+ value: string | undefined;
21
+ };
22
+
23
+ const RadioGroupContext = createContext<null | RadioGroupContextValue>(null);
24
+
25
+ export type RadioGroupItemProps = Omit<
26
+ React.ComponentProps<typeof Pressable>,
27
+ 'children' | 'disabled'
28
+ > & {
29
+ /** Class overrides for the row around the circle and label, the dot and the label. */
30
+ classNames?: { field?: string; indicator?: string; label?: string };
31
+ disabled?: boolean;
32
+ /** Marks the choice as invalid, like the web's `aria-invalid`. */
33
+ invalid?: boolean;
34
+ /** Text next to the circle; pressing it selects the item and names it. */
35
+ label?: React.ReactNode;
36
+ size?: 'md' | 'sm';
37
+ value: string;
38
+ };
39
+
40
+ export type RadioGroupProps = React.ComponentProps<typeof View> & {
41
+ defaultValue?: string;
42
+ disabled?: boolean;
43
+ onValueChange?: (value: string) => void;
44
+ value?: string;
45
+ };
46
+
47
+ function RadioGroupItem({
48
+ accessibilityState,
49
+ className,
50
+ classNames,
51
+ disabled: disabledProp = false,
52
+ invalid = false,
53
+ label,
54
+ size = 'md',
55
+ value,
56
+ ...props
57
+ }: RadioGroupItemProps) {
58
+ const group = use(RadioGroupContext);
59
+ if (!group) throw new Error('RadioGroup.Item must be inside a RadioGroup.');
60
+
61
+ const checked = group.value === value;
62
+ const disabled = group.disabled || disabledProp;
63
+
64
+ return (
65
+ <Pressable
66
+ accessibilityState={{ ...accessibilityState, checked, disabled }}
67
+ aria-checked={checked}
68
+ aria-disabled={disabled}
69
+ className={
70
+ label
71
+ ? cn('flex-row items-center gap-2', classNames?.field)
72
+ : 'self-start'
73
+ }
74
+ disabled={disabled}
75
+ hitSlop={label ? fieldHitSlop[size] : controlHitSlop[size]}
76
+ onPress={() => group.onSelect(value)}
77
+ role="radio"
78
+ {...props}
79
+ >
80
+ <View
81
+ className={cn(
82
+ 'border-input bg-background items-center justify-center rounded-full border',
83
+ controlClasses[size],
84
+ checked && 'border-primary',
85
+ invalid && 'border-destructive',
86
+ disabled && 'opacity-50',
87
+ className,
88
+ )}
89
+ >
90
+ {checked ? (
91
+ <View
92
+ className={cn(
93
+ 'bg-primary rounded-full',
94
+ indicatorClasses[size],
95
+ classNames?.indicator,
96
+ )}
97
+ />
98
+ ) : null}
99
+ </View>
100
+ {label ? (
101
+ <Text
102
+ className={cn(
103
+ 'font-sans text-sm leading-none font-medium',
104
+ disabled ? 'text-muted-foreground' : 'text-foreground',
105
+ classNames?.label,
106
+ )}
107
+ >
108
+ {label}
109
+ </Text>
110
+ ) : null}
111
+ </Pressable>
112
+ );
113
+ }
114
+
115
+ function RadioGroupRoot({
116
+ className,
117
+ defaultValue,
118
+ disabled = false,
119
+ onValueChange,
120
+ value: valueProp,
121
+ ...props
122
+ }: RadioGroupProps) {
123
+ const [value, setValue] = useControllableState(
124
+ valueProp,
125
+ defaultValue,
126
+ onValueChange as ((value: string | undefined) => void) | undefined,
127
+ );
128
+
129
+ const context = useMemo(
130
+ () => ({
131
+ disabled,
132
+ onSelect: (next: string) => {
133
+ if (next !== value) setValue(next);
134
+ },
135
+ value,
136
+ }),
137
+ [disabled, setValue, value],
138
+ );
139
+
140
+ return (
141
+ <RadioGroupContext value={context}>
142
+ <View className={cn('gap-2', className)} role="radiogroup" {...props} />
143
+ </RadioGroupContext>
144
+ );
145
+ }
146
+
147
+ const RadioGroup = Object.assign(RadioGroupRoot, {
148
+ Item: RadioGroupItem,
149
+ });
150
+
151
+ export { RadioGroup, RadioGroupItem };
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+
3
+ import { type ComponentMatrixCellProps, Story } from '../_internal/Story';
4
+ import { Textarea, type TextareaProps } from './Textarea';
5
+
6
+ const meta = {
7
+ component: Textarea,
8
+ parameters: { layout: 'fullscreen' },
9
+ title: 'components/form/Textarea',
10
+ } satisfies Meta<typeof Textarea>;
11
+
12
+ export default meta;
13
+ type StoryDefinition = StoryObj<typeof meta>;
14
+
15
+ // No focus column: React Native can't force a focus state. Tap a field.
16
+ const STATE_COLUMNS = [
17
+ { key: 'empty', label: 'Empty' },
18
+ { key: 'filled', label: 'Filled' },
19
+ { key: 'disabled', label: 'Disabled' },
20
+ { key: 'invalid', label: 'Invalid' },
21
+ ];
22
+
23
+ const SIZE_ROWS = [
24
+ { key: 'sm', label: 'Small' },
25
+ { key: 'md', label: 'Medium' },
26
+ { key: 'lg', label: 'Large' },
27
+ ];
28
+
29
+ function TextareaMatrixCell({ column, row }: ComponentMatrixCellProps) {
30
+ const isInvalid = column.key === 'invalid';
31
+
32
+ return (
33
+ <Textarea
34
+ accessibilityLabel={`${row.label} ${column.label}`}
35
+ defaultValue={
36
+ column.key === 'filled'
37
+ ? 'Pickle UI grows this field with its content, line after line.'
38
+ : undefined
39
+ }
40
+ disabled={column.key === 'disabled'}
41
+ invalid={isInvalid}
42
+ placeholder={isInvalid ? 'Tell us a bit more' : 'Write a message'}
43
+ size={row.key as NonNullable<TextareaProps['size']>}
44
+ />
45
+ );
46
+ }
47
+
48
+ export const All: StoryDefinition = {
49
+ render: () => (
50
+ <Story.Layout className="max-w-6xl" title="Textarea">
51
+ <Story.Section title="Sizes and states">
52
+ <Story.Matrix
53
+ Cell={TextareaMatrixCell}
54
+ cellClassName="items-stretch"
55
+ columns={STATE_COLUMNS}
56
+ rows={SIZE_ROWS}
57
+ />
58
+ </Story.Section>
59
+ </Story.Layout>
60
+ ),
61
+ };
@@ -0,0 +1,35 @@
1
+ import { render, screen } from '@testing-library/react-native';
2
+
3
+ import { Textarea } from './Textarea';
4
+
5
+ describe('Textarea', () => {
6
+ it('renders a multi-line field', async () => {
7
+ await render(
8
+ <Textarea accessibilityLabel="Message" defaultValue="Hello" />,
9
+ );
10
+
11
+ const field = screen.getByLabelText('Message');
12
+ expect(field).toHaveProp('multiline', true);
13
+ expect(field).toHaveDisplayValue('Hello');
14
+ });
15
+
16
+ it('styles the control with className and the field with classNames.textarea', async () => {
17
+ await render(
18
+ <Textarea
19
+ accessibilityLabel="Message"
20
+ className="control-x"
21
+ classNames={{ textarea: 'textarea-x' }}
22
+ />,
23
+ );
24
+
25
+ const field = screen.getByLabelText('Message');
26
+ expect(field).toHaveProp(
27
+ 'className',
28
+ expect.stringContaining('textarea-x'),
29
+ );
30
+ expect(field.parent).toHaveProp(
31
+ 'className',
32
+ expect.stringContaining('control-x'),
33
+ );
34
+ });
35
+ });