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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/README.md +70 -11
  2. package/dist/components/Badge.d.ts +6 -3
  3. package/dist/components/Badge.d.ts.map +1 -1
  4. package/dist/components/Badge.js +36 -7
  5. package/dist/components/Badge.js.map +1 -1
  6. package/dist/components/Button.d.ts +11 -4
  7. package/dist/components/Button.d.ts.map +1 -1
  8. package/dist/components/Button.js +100 -15
  9. package/dist/components/Button.js.map +1 -1
  10. package/dist/components/Card.d.ts +15 -5
  11. package/dist/components/Card.d.ts.map +1 -1
  12. package/dist/components/Card.js +28 -10
  13. package/dist/components/Card.js.map +1 -1
  14. package/dist/components/Flex.d.ts +25 -0
  15. package/dist/components/Flex.d.ts.map +1 -0
  16. package/dist/components/Flex.js +24 -0
  17. package/dist/components/Flex.js.map +1 -0
  18. package/dist/components/Grid.d.ts +36 -0
  19. package/dist/components/Grid.d.ts.map +1 -0
  20. package/dist/components/Grid.js +68 -0
  21. package/dist/components/Grid.js.map +1 -0
  22. package/dist/components/Separator.d.ts +11 -0
  23. package/dist/components/Separator.d.ts.map +1 -0
  24. package/dist/components/Separator.js +12 -0
  25. package/dist/components/Separator.js.map +1 -0
  26. package/dist/components/Spinner.d.ts +14 -0
  27. package/dist/components/Spinner.d.ts.map +1 -0
  28. package/dist/components/Spinner.js +41 -0
  29. package/dist/components/Spinner.js.map +1 -0
  30. package/dist/components/Text.d.ts +7 -3
  31. package/dist/components/Text.d.ts.map +1 -1
  32. package/dist/components/Text.js +23 -11
  33. package/dist/components/Text.js.map +1 -1
  34. package/dist/components/XStack.d.ts +4 -0
  35. package/dist/components/XStack.d.ts.map +1 -0
  36. package/dist/components/XStack.js +6 -0
  37. package/dist/components/XStack.js.map +1 -0
  38. package/dist/components/YStack.d.ts +4 -0
  39. package/dist/components/YStack.d.ts.map +1 -0
  40. package/dist/components/YStack.js +6 -0
  41. package/dist/components/YStack.js.map +1 -0
  42. package/dist/components/_internal/InputFrame.d.ts +13 -0
  43. package/dist/components/_internal/InputFrame.d.ts.map +1 -0
  44. package/dist/components/_internal/InputFrame.js +14 -0
  45. package/dist/components/_internal/InputFrame.js.map +1 -0
  46. package/dist/components/_internal/StoryItem.d.ts +6 -0
  47. package/dist/components/_internal/StoryItem.d.ts.map +1 -0
  48. package/dist/components/_internal/StoryItem.js +9 -0
  49. package/dist/components/_internal/StoryItem.js.map +1 -0
  50. package/dist/components/_internal/layout.d.ts +79 -0
  51. package/dist/components/_internal/layout.d.ts.map +1 -0
  52. package/dist/components/_internal/layout.js +73 -0
  53. package/dist/components/_internal/layout.js.map +1 -0
  54. package/dist/components/_internal/useControllableState.d.ts +7 -0
  55. package/dist/components/_internal/useControllableState.d.ts.map +1 -0
  56. package/dist/components/_internal/useControllableState.js +17 -0
  57. package/dist/components/_internal/useControllableState.js.map +1 -0
  58. package/dist/components/form/Checkbox.d.ts +26 -0
  59. package/dist/components/form/Checkbox.d.ts.map +1 -0
  60. package/dist/components/form/Checkbox.js +41 -0
  61. package/dist/components/form/Checkbox.js.map +1 -0
  62. package/dist/components/form/Form.context.d.ts +34 -0
  63. package/dist/components/form/Form.context.d.ts.map +1 -0
  64. package/dist/components/form/Form.context.js +53 -0
  65. package/dist/components/form/Form.context.js.map +1 -0
  66. package/dist/components/form/Form.d.ts +33 -0
  67. package/dist/components/form/Form.d.ts.map +1 -0
  68. package/dist/components/form/Form.js +36 -0
  69. package/dist/components/form/Form.js.map +1 -0
  70. package/dist/components/form/FormCheckbox.d.ts +7 -0
  71. package/dist/components/form/FormCheckbox.d.ts.map +1 -0
  72. package/dist/components/form/FormCheckbox.js +24 -0
  73. package/dist/components/form/FormCheckbox.js.map +1 -0
  74. package/dist/components/form/FormDescription.d.ts +4 -0
  75. package/dist/components/form/FormDescription.d.ts.map +1 -0
  76. package/dist/components/form/FormDescription.js +7 -0
  77. package/dist/components/form/FormDescription.js.map +1 -0
  78. package/dist/components/form/FormError.d.ts +5 -0
  79. package/dist/components/form/FormError.d.ts.map +1 -0
  80. package/dist/components/form/FormError.js +8 -0
  81. package/dist/components/form/FormError.js.map +1 -0
  82. package/dist/components/form/FormField.d.ts +17 -0
  83. package/dist/components/form/FormField.d.ts.map +1 -0
  84. package/dist/components/form/FormField.js +36 -0
  85. package/dist/components/form/FormField.js.map +1 -0
  86. package/dist/components/form/FormInput.d.ts +10 -0
  87. package/dist/components/form/FormInput.d.ts.map +1 -0
  88. package/dist/components/form/FormInput.js +36 -0
  89. package/dist/components/form/FormInput.js.map +1 -0
  90. package/dist/components/form/FormInputGroup.d.ts +8 -0
  91. package/dist/components/form/FormInputGroup.d.ts.map +1 -0
  92. package/dist/components/form/FormInputGroup.js +18 -0
  93. package/dist/components/form/FormInputGroup.js.map +1 -0
  94. package/dist/components/form/FormLabel.d.ts +4 -0
  95. package/dist/components/form/FormLabel.d.ts.map +1 -0
  96. package/dist/components/form/FormLabel.js +7 -0
  97. package/dist/components/form/FormLabel.js.map +1 -0
  98. package/dist/components/form/FormRadioGroup.d.ts +11 -0
  99. package/dist/components/form/FormRadioGroup.d.ts.map +1 -0
  100. package/dist/components/form/FormRadioGroup.js +24 -0
  101. package/dist/components/form/FormRadioGroup.js.map +1 -0
  102. package/dist/components/form/FormRoot.d.ts +13 -0
  103. package/dist/components/form/FormRoot.d.ts.map +1 -0
  104. package/dist/components/form/FormRoot.js +19 -0
  105. package/dist/components/form/FormRoot.js.map +1 -0
  106. package/dist/components/form/FormTextarea.d.ts +7 -0
  107. package/dist/components/form/FormTextarea.d.ts.map +1 -0
  108. package/dist/components/form/FormTextarea.js +27 -0
  109. package/dist/components/form/FormTextarea.js.map +1 -0
  110. package/dist/components/form/Input.d.ts +17 -0
  111. package/dist/components/form/Input.d.ts.map +1 -0
  112. package/dist/components/form/Input.js +32 -0
  113. package/dist/components/form/Input.js.map +1 -0
  114. package/dist/components/form/InputGroup.context.d.ts +10 -0
  115. package/dist/components/form/InputGroup.context.d.ts.map +1 -0
  116. package/dist/components/form/InputGroup.context.js +8 -0
  117. package/dist/components/form/InputGroup.context.js.map +1 -0
  118. package/dist/components/form/InputGroup.d.ts +31 -0
  119. package/dist/components/form/InputGroup.d.ts.map +1 -0
  120. package/dist/components/form/InputGroup.js +123 -0
  121. package/dist/components/form/InputGroup.js.map +1 -0
  122. package/dist/components/form/RadioGroup.d.ts +29 -0
  123. package/dist/components/form/RadioGroup.d.ts.map +1 -0
  124. package/dist/components/form/RadioGroup.js +42 -0
  125. package/dist/components/form/RadioGroup.js.map +1 -0
  126. package/dist/components/form/Textarea.d.ts +19 -0
  127. package/dist/components/form/Textarea.d.ts.map +1 -0
  128. package/dist/components/form/Textarea.js +26 -0
  129. package/dist/components/form/Textarea.js.map +1 -0
  130. package/dist/entries/card.d.ts +1 -1
  131. package/dist/entries/card.d.ts.map +1 -1
  132. package/dist/entries/checkbox.d.ts +3 -0
  133. package/dist/entries/checkbox.d.ts.map +1 -0
  134. package/dist/entries/checkbox.js +2 -0
  135. package/dist/entries/checkbox.js.map +1 -0
  136. package/dist/entries/flex.d.ts +3 -0
  137. package/dist/entries/flex.d.ts.map +1 -0
  138. package/dist/entries/flex.js +2 -0
  139. package/dist/entries/flex.js.map +1 -0
  140. package/dist/entries/form.d.ts +3 -0
  141. package/dist/entries/form.d.ts.map +1 -0
  142. package/dist/entries/form.js +2 -0
  143. package/dist/entries/form.js.map +1 -0
  144. package/dist/entries/grid.d.ts +3 -0
  145. package/dist/entries/grid.d.ts.map +1 -0
  146. package/dist/entries/grid.js +2 -0
  147. package/dist/entries/grid.js.map +1 -0
  148. package/dist/entries/input-group.d.ts +3 -0
  149. package/dist/entries/input-group.d.ts.map +1 -0
  150. package/dist/entries/input-group.js +2 -0
  151. package/dist/entries/input-group.js.map +1 -0
  152. package/dist/entries/input.d.ts +3 -0
  153. package/dist/entries/input.d.ts.map +1 -0
  154. package/dist/entries/input.js +2 -0
  155. package/dist/entries/input.js.map +1 -0
  156. package/dist/entries/radio-group.d.ts +3 -0
  157. package/dist/entries/radio-group.d.ts.map +1 -0
  158. package/dist/entries/radio-group.js +2 -0
  159. package/dist/entries/radio-group.js.map +1 -0
  160. package/dist/entries/separator.d.ts +3 -0
  161. package/dist/entries/separator.d.ts.map +1 -0
  162. package/dist/entries/separator.js +2 -0
  163. package/dist/entries/separator.js.map +1 -0
  164. package/dist/entries/spinner.d.ts +3 -0
  165. package/dist/entries/spinner.d.ts.map +1 -0
  166. package/dist/entries/spinner.js +2 -0
  167. package/dist/entries/spinner.js.map +1 -0
  168. package/dist/entries/textarea.d.ts +3 -0
  169. package/dist/entries/textarea.d.ts.map +1 -0
  170. package/dist/entries/textarea.js +2 -0
  171. package/dist/entries/textarea.js.map +1 -0
  172. package/dist/entries/theme.d.ts +3 -0
  173. package/dist/entries/theme.d.ts.map +1 -0
  174. package/dist/entries/theme.js +2 -0
  175. package/dist/entries/theme.js.map +1 -0
  176. package/dist/entries/x-stack.d.ts +3 -0
  177. package/dist/entries/x-stack.d.ts.map +1 -0
  178. package/dist/entries/x-stack.js +2 -0
  179. package/dist/entries/x-stack.js.map +1 -0
  180. package/dist/entries/y-stack.d.ts +3 -0
  181. package/dist/entries/y-stack.d.ts.map +1 -0
  182. package/dist/entries/y-stack.js +2 -0
  183. package/dist/entries/y-stack.js.map +1 -0
  184. package/dist/index.d.ts +13 -0
  185. package/dist/index.d.ts.map +1 -1
  186. package/dist/index.js +13 -0
  187. package/dist/index.js.map +1 -1
  188. package/dist/lib/cn.d.ts.map +1 -1
  189. package/dist/lib/cn.js +13 -1
  190. package/dist/lib/cn.js.map +1 -1
  191. package/package.json +10 -3
  192. package/src/base.css +7 -2
  193. package/src/components/Badge.stories.tsx +51 -6
  194. package/src/components/Badge.test.tsx +15 -0
  195. package/src/components/Badge.tsx +73 -17
  196. package/src/components/Button.stories.tsx +96 -8
  197. package/src/components/Button.test.tsx +69 -0
  198. package/src/components/Button.tsx +159 -18
  199. package/src/components/Card.stories.tsx +17 -4
  200. package/src/components/Card.test.tsx +24 -1
  201. package/src/components/Card.tsx +71 -13
  202. package/src/components/Flex.stories.tsx +58 -0
  203. package/src/components/Flex.test.tsx +57 -0
  204. package/src/components/Flex.tsx +52 -0
  205. package/src/components/Grid.stories.tsx +55 -0
  206. package/src/components/Grid.test.tsx +140 -0
  207. package/src/components/Grid.tsx +166 -0
  208. package/src/components/Separator.stories.tsx +39 -0
  209. package/src/components/Separator.test.tsx +25 -0
  210. package/src/components/Separator.tsx +30 -0
  211. package/src/components/Spinner.stories.tsx +35 -0
  212. package/src/components/Spinner.test.tsx +30 -0
  213. package/src/components/Spinner.tsx +75 -0
  214. package/src/components/Text.stories.tsx +52 -2
  215. package/src/components/Text.test.tsx +35 -3
  216. package/src/components/Text.tsx +42 -12
  217. package/src/components/XStack.stories.tsx +46 -0
  218. package/src/components/XStack.test.tsx +13 -0
  219. package/src/components/XStack.tsx +7 -0
  220. package/src/components/YStack.stories.tsx +46 -0
  221. package/src/components/YStack.test.tsx +13 -0
  222. package/src/components/YStack.tsx +7 -0
  223. package/src/components/_internal/InputFrame.tsx +50 -0
  224. package/src/components/_internal/Story.tsx +34 -0
  225. package/src/components/_internal/StoryItem.tsx +24 -0
  226. package/src/components/_internal/layout.ts +103 -0
  227. package/src/components/_internal/useControllableState.ts +25 -0
  228. package/src/components/form/Checkbox.stories.tsx +67 -0
  229. package/src/components/form/Checkbox.test.tsx +88 -0
  230. package/src/components/form/Checkbox.tsx +141 -0
  231. package/src/components/form/Form.context.ts +91 -0
  232. package/src/components/form/Form.stories.tsx +152 -0
  233. package/src/components/form/Form.test.tsx +195 -0
  234. package/src/components/form/Form.tsx +51 -0
  235. package/src/components/form/FormCheckbox.tsx +42 -0
  236. package/src/components/form/FormDescription.tsx +14 -0
  237. package/src/components/form/FormError.tsx +17 -0
  238. package/src/components/form/FormField.tsx +82 -0
  239. package/src/components/form/FormInput.tsx +70 -0
  240. package/src/components/form/FormInputGroup.tsx +30 -0
  241. package/src/components/form/FormLabel.tsx +17 -0
  242. package/src/components/form/FormRadioGroup.tsx +48 -0
  243. package/src/components/form/FormRoot.tsx +41 -0
  244. package/src/components/form/FormTextarea.tsx +56 -0
  245. package/src/components/form/Input.stories.tsx +105 -0
  246. package/src/components/form/Input.test.tsx +76 -0
  247. package/src/components/form/Input.tsx +81 -0
  248. package/src/components/form/InputGroup.context.ts +11 -0
  249. package/src/components/form/InputGroup.stories.tsx +129 -0
  250. package/src/components/form/InputGroup.test.tsx +95 -0
  251. package/src/components/form/InputGroup.tsx +266 -0
  252. package/src/components/form/RadioGroup.stories.tsx +62 -0
  253. package/src/components/form/RadioGroup.test.tsx +98 -0
  254. package/src/components/form/RadioGroup.tsx +151 -0
  255. package/src/components/form/Textarea.stories.tsx +61 -0
  256. package/src/components/form/Textarea.test.tsx +35 -0
  257. package/src/components/form/Textarea.tsx +74 -0
  258. package/src/entries/card.ts +2 -0
  259. package/src/entries/checkbox.ts +2 -0
  260. package/src/entries/flex.ts +2 -0
  261. package/src/entries/form.ts +13 -0
  262. package/src/entries/grid.ts +2 -0
  263. package/src/entries/input-group.ts +8 -0
  264. package/src/entries/input.ts +2 -0
  265. package/src/entries/radio-group.ts +5 -0
  266. package/src/entries/separator.ts +2 -0
  267. package/src/entries/spinner.ts +2 -0
  268. package/src/entries/textarea.ts +2 -0
  269. package/src/entries/theme.ts +7 -0
  270. package/src/entries/x-stack.ts +2 -0
  271. package/src/entries/y-stack.ts +2 -0
  272. package/src/index.ts +13 -0
  273. package/src/lib/cn.test.ts +6 -0
  274. package/src/lib/cn.ts +14 -1
@@ -0,0 +1,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
+ });
@@ -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 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 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
+ });