@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,55 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+
3
+ import { Story } from './_internal/Story';
4
+ import { StoryItem as Item } from './_internal/StoryItem';
5
+ import { Grid } from './Grid';
6
+
7
+ const meta = {
8
+ component: Grid,
9
+ parameters: { layout: 'fullscreen' },
10
+ title: 'components/Grid',
11
+ } satisfies Meta<typeof Grid>;
12
+
13
+ export default meta;
14
+ type StoryDefinition = StoryObj<typeof meta>;
15
+
16
+ export const All: StoryDefinition = {
17
+ render: () => (
18
+ <Story.Layout className="max-w-6xl" title="Grid">
19
+ <Story.Section title="Columns and gaps">
20
+ <Grid columns={3} gap={4}>
21
+ {['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven'].map(
22
+ (label) => (
23
+ <Item className="min-h-20" key={label}>
24
+ {label}
25
+ </Item>
26
+ ),
27
+ )}
28
+ </Grid>
29
+ </Story.Section>
30
+
31
+ <Story.Section title="Rows and column flow">
32
+ <Grid flow="column" gapX={4} gapY={2} rows={2}>
33
+ {['A', 'B', 'C', 'D', 'E', 'F'].map((label) => (
34
+ <Item className="min-h-20" key={label}>
35
+ {label}
36
+ </Item>
37
+ ))}
38
+ </Grid>
39
+ </Story.Section>
40
+
41
+ <Story.Section title="Placement">
42
+ <Grid
43
+ className="border-border rounded-md border border-dashed p-4"
44
+ columns={3}
45
+ gap={4}
46
+ placeItems="center"
47
+ >
48
+ <Item className="min-h-40">Tall item</Item>
49
+ <Item>Centered</Item>
50
+ <Item className="flex-1">Full height</Item>
51
+ </Grid>
52
+ </Story.Section>
53
+ </Story.Layout>
54
+ ),
55
+ };
@@ -0,0 +1,140 @@
1
+ import { render, screen, within } from '@testing-library/react-native';
2
+ import { Text } from 'react-native';
3
+
4
+ import { Grid } from './Grid';
5
+
6
+ const LABELS = ['A', 'B', 'C', 'D', 'E'];
7
+
8
+ /** The labels of each grid row, empty cells as `''`. */
9
+ function gridRows() {
10
+ return screen
11
+ .getByTestId('grid')
12
+ .children.map((row) =>
13
+ typeof row === 'string'
14
+ ? []
15
+ : row.children.map((cell) =>
16
+ typeof cell === 'string'
17
+ ? cell
18
+ : (within(cell).queryByText(/./)?.props.children ?? ''),
19
+ ),
20
+ );
21
+ }
22
+
23
+ function renderGrid(props: React.ComponentProps<typeof Grid>) {
24
+ return render(
25
+ <Grid testID="grid" {...props}>
26
+ {LABELS.map((label) => (
27
+ <Text key={label}>{label}</Text>
28
+ ))}
29
+ </Grid>,
30
+ );
31
+ }
32
+
33
+ describe('Grid', () => {
34
+ it('fills rows first, keeping empty cells in the last row', async () => {
35
+ await renderGrid({ columns: 3 });
36
+
37
+ expect(gridRows()).toEqual([
38
+ ['A', 'B', 'C'],
39
+ ['D', 'E', ''],
40
+ ]);
41
+ });
42
+
43
+ it('fills columns first with flow="column"', async () => {
44
+ await renderGrid({ flow: 'column', rows: 2 });
45
+
46
+ expect(gridRows()).toEqual([
47
+ ['A', 'C', 'E'],
48
+ ['B', 'D', ''],
49
+ ]);
50
+ });
51
+
52
+ it('keeps the rows it is given, even empty ones', async () => {
53
+ await renderGrid({ flow: 'column', rows: 3 });
54
+
55
+ expect(gridRows()).toEqual([
56
+ ['A', 'D'],
57
+ ['B', 'E'],
58
+ ['C', ''],
59
+ ]);
60
+
61
+ await renderGrid({ columns: 2, rows: 4 });
62
+
63
+ expect(gridRows()).toEqual([
64
+ ['A', 'B'],
65
+ ['C', 'D'],
66
+ ['E', ''],
67
+ ['', ''],
68
+ ]);
69
+ });
70
+
71
+ it('places the dense flows like row and column', async () => {
72
+ await renderGrid({ flow: 'column-dense', rows: 2 });
73
+
74
+ expect(gridRows()).toEqual([
75
+ ['A', 'C', 'E'],
76
+ ['B', 'D', ''],
77
+ ]);
78
+ });
79
+
80
+ it('gives each child of a fragment its own cell', async () => {
81
+ await render(
82
+ <Grid columns={2} testID="grid">
83
+ <>
84
+ <Text>A</Text>
85
+ <Text>B</Text>
86
+ </>
87
+ <Text>C</Text>
88
+ </Grid>,
89
+ );
90
+
91
+ expect(gridRows()).toEqual([
92
+ ['A', 'B'],
93
+ ['C', ''],
94
+ ]);
95
+ });
96
+
97
+ it('distributes rows and cells with placeContent', async () => {
98
+ await renderGrid({ columns: 2, justify: 'end', placeContent: 'stretch' });
99
+
100
+ const grid = screen.getByTestId('grid');
101
+ expect(grid).toHaveProp('className', 'justify-start');
102
+ expect(grid.children[0]).toHaveProp(
103
+ 'className',
104
+ 'flex-row justify-start grow',
105
+ );
106
+
107
+ await renderGrid({ columns: 2, justify: 'end' });
108
+
109
+ expect(screen.getByTestId('grid').children[0]).toHaveProp(
110
+ 'className',
111
+ 'flex-row justify-end',
112
+ );
113
+ });
114
+
115
+ it('stacks its children in one column by default', async () => {
116
+ await renderGrid({});
117
+
118
+ expect(gridRows()).toEqual([['A'], ['B'], ['C'], ['D'], ['E']]);
119
+ });
120
+
121
+ it('spaces rows with gapY and columns with gapX', async () => {
122
+ await renderGrid({ columns: 2, gap: 4, gapX: 2 });
123
+
124
+ const grid = screen.getByTestId('grid');
125
+ expect(grid).toHaveProp('className', 'gap-y-4');
126
+ expect(grid.children[0]).toHaveProp('className', 'flex-row gap-x-2');
127
+ });
128
+
129
+ it('places items in their cells', async () => {
130
+ await renderGrid({ align: 'end', columns: 2 });
131
+ const cell = screen.getByText('A').parent;
132
+ expect(cell).toHaveProp('className', 'flex-1 justify-end');
133
+
134
+ await renderGrid({ align: 'end', columns: 2, placeItems: 'center' });
135
+ expect(screen.getByText('A').parent).toHaveProp(
136
+ 'className',
137
+ 'flex-1 items-center justify-center',
138
+ );
139
+ });
140
+ });
@@ -0,0 +1,166 @@
1
+ import { Children, Fragment, isValidElement, type ReactNode } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import {
6
+ gapXClasses,
7
+ gapYClasses,
8
+ type LayoutAlign,
9
+ type LayoutGap,
10
+ type LayoutJustify,
11
+ } from './_internal/layout';
12
+
13
+ // React Native has no CSS grid, so `Grid` places its children itself: one row
14
+ // of equal `flex-1` cells per grid row, spaced by `gapX`, and the rows stacked
15
+ // and spaced by `gapY`. Empty cells and rows keep their size, so every column
16
+ // stays the same width and `rows` keeps its tracks, as on the web.
17
+
18
+ type GridFlow = 'column' | 'column-dense' | 'dense' | 'row' | 'row-dense';
19
+ type GridPlaceContent = 'baseline' | 'stretch' | LayoutJustify;
20
+ type GridTrackCount = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
21
+
22
+ // Cells are columns: `items-*` places an item across the cell's width and
23
+ // `justify-*` down its height. A cell can't stretch an item vertically, so
24
+ // `stretch` and `baseline` place it at the top; give the item `flex-1` to fill
25
+ // its cell's height.
26
+ const horizontalClasses = {
27
+ baseline: 'items-start',
28
+ center: 'items-center',
29
+ end: 'items-end',
30
+ start: 'items-start',
31
+ stretch: 'items-stretch',
32
+ } as const;
33
+
34
+ const verticalClasses = {
35
+ baseline: 'justify-start',
36
+ center: 'justify-center',
37
+ end: 'justify-end',
38
+ start: 'justify-start',
39
+ stretch: 'justify-start',
40
+ } as const;
41
+
42
+ // Distributes the rows down the grid and the cells along each row, like CSS
43
+ // `align-content` and `justify-content`. Cells share each row's width, so only
44
+ // a grid taller than its rows shows the difference; `stretch` grows the rows
45
+ // into that height instead.
46
+ const contentClasses = {
47
+ around: 'justify-around',
48
+ baseline: 'justify-start',
49
+ between: 'justify-between',
50
+ center: 'justify-center',
51
+ end: 'justify-end',
52
+ evenly: 'justify-evenly',
53
+ start: 'justify-start',
54
+ stretch: 'justify-start',
55
+ } as const;
56
+
57
+ export type GridProps = React.ComponentProps<typeof View> & {
58
+ /**
59
+ * Places each item down its cell, like CSS `align-items`. `stretch` and
60
+ * `baseline` place it at the top: give the item `flex-1` to fill the cell.
61
+ */
62
+ align?: LayoutAlign;
63
+ columns?: GridTrackCount;
64
+ /**
65
+ * Fills rows first (`row`) or columns first (`column`). Items can't span
66
+ * cells on native, so the `dense` flows place them like `row` and `column`.
67
+ */
68
+ flow?: GridFlow;
69
+ gap?: LayoutGap;
70
+ gapX?: LayoutGap;
71
+ gapY?: LayoutGap;
72
+ /** Distributes the cells along each row, like CSS `justify-content`. */
73
+ justify?: LayoutJustify;
74
+ /** Distributes the rows and the cells, like CSS `place-content`. */
75
+ placeContent?: GridPlaceContent;
76
+ /** Places each item across and down its cell, like CSS `place-items`. */
77
+ placeItems?: LayoutAlign;
78
+ rows?: GridTrackCount;
79
+ };
80
+
81
+ /**
82
+ * Lays its children out in equal columns, like `Grid` in @pickle-ui/react.
83
+ * Items fill their cell's width by default and the cells of a row share its
84
+ * height.
85
+ */
86
+ export function Grid({
87
+ align,
88
+ children,
89
+ className,
90
+ columns,
91
+ flow = 'row',
92
+ gap,
93
+ gapX = gap,
94
+ gapY = gap,
95
+ justify,
96
+ placeContent,
97
+ placeItems,
98
+ rows,
99
+ ...props
100
+ }: GridProps) {
101
+ const items = flattenChildren(children);
102
+ const byColumn = flow === 'column' || flow === 'column-dense';
103
+ const rowCount = byColumn
104
+ ? (rows ?? 1)
105
+ : Math.max(rows ?? 0, Math.ceil(items.length / (columns ?? 1)));
106
+ const columnCount = byColumn
107
+ ? Math.max(columns ?? 1, Math.ceil(items.length / rowCount))
108
+ : (columns ?? 1);
109
+ const vertical = placeItems ?? align;
110
+ const distribution = placeContent ?? justify;
111
+
112
+ return (
113
+ <View
114
+ className={cn(
115
+ gapY === undefined ? undefined : gapYClasses[gapY],
116
+ placeContent === undefined ? undefined : contentClasses[placeContent],
117
+ className,
118
+ )}
119
+ {...props}
120
+ >
121
+ {Array.from({ length: rowCount }, (_, row) => (
122
+ <View
123
+ className={cn(
124
+ 'flex-row',
125
+ gapX === undefined ? undefined : gapXClasses[gapX],
126
+ distribution === undefined
127
+ ? undefined
128
+ : contentClasses[distribution],
129
+ placeContent === 'stretch' ? 'grow' : undefined,
130
+ )}
131
+ key={row}
132
+ >
133
+ {Array.from({ length: columnCount }, (_, column) => (
134
+ <View
135
+ className={cn(
136
+ 'flex-1',
137
+ placeItems === undefined
138
+ ? undefined
139
+ : horizontalClasses[placeItems],
140
+ vertical === undefined ? undefined : verticalClasses[vertical],
141
+ )}
142
+ key={column}
143
+ >
144
+ {
145
+ items[
146
+ byColumn
147
+ ? column * rowCount + row
148
+ : row * columnCount + column
149
+ ]
150
+ }
151
+ </View>
152
+ ))}
153
+ </View>
154
+ ))}
155
+ </View>
156
+ );
157
+ }
158
+
159
+ /** The grid items, with fragments opened up as on the web. */
160
+ function flattenChildren(children: ReactNode): ReactNode[] {
161
+ return Children.toArray(children).flatMap((child) =>
162
+ isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment
163
+ ? flattenChildren(child.props.children)
164
+ : [child],
165
+ );
166
+ }
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+
3
+ import { Story } from './_internal/Story';
4
+ import { Separator } from './Separator';
5
+ import { Text } from './Text';
6
+ import { XStack } from './XStack';
7
+ import { YStack } from './YStack';
8
+
9
+ const meta = {
10
+ component: Separator,
11
+ parameters: { layout: 'fullscreen' },
12
+ title: 'components/Separator',
13
+ } satisfies Meta<typeof Separator>;
14
+
15
+ export default meta;
16
+ type StoryDefinition = StoryObj<typeof meta>;
17
+
18
+ export const All: StoryDefinition = {
19
+ render: () => (
20
+ <Story.Layout className="max-w-3xl" title="Separator">
21
+ <Story.Section title="Horizontal">
22
+ <YStack className="max-w-sm" gap={3}>
23
+ <Text>Account</Text>
24
+ <Separator />
25
+ <Text tone="muted">Manage your profile and preferences.</Text>
26
+ </YStack>
27
+ </Story.Section>
28
+ <Story.Section title="Vertical">
29
+ <XStack align="center" className="h-5" gap={3}>
30
+ <Text>Docs</Text>
31
+ <Separator orientation="vertical" />
32
+ <Text>Components</Text>
33
+ <Separator orientation="vertical" />
34
+ <Text>Theming</Text>
35
+ </XStack>
36
+ </Story.Section>
37
+ </Story.Layout>
38
+ ),
39
+ };
@@ -0,0 +1,25 @@
1
+ import { render, screen } from '@testing-library/react-native';
2
+
3
+ import { Separator } from './Separator';
4
+
5
+ describe('Separator', () => {
6
+ it('renders a horizontal separator by default', async () => {
7
+ await render(<Separator testID="separator" />);
8
+
9
+ const separator = screen.getByTestId('separator');
10
+ expect(separator).toHaveProp('role', 'separator');
11
+ expect(separator).toHaveProp(
12
+ 'className',
13
+ expect.stringContaining('h-px w-full'),
14
+ );
15
+ });
16
+
17
+ it('renders a vertical separator', async () => {
18
+ await render(<Separator orientation="vertical" testID="separator" />);
19
+
20
+ expect(screen.getByTestId('separator')).toHaveProp(
21
+ 'className',
22
+ expect.stringContaining('w-px self-stretch'),
23
+ );
24
+ });
25
+ });
@@ -0,0 +1,30 @@
1
+ import { View } from 'react-native';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type SeparatorProps = React.ComponentProps<typeof View> & {
6
+ orientation?: 'horizontal' | 'vertical';
7
+ };
8
+
9
+ /**
10
+ * A 1px rule in the border color, like `Separator` in @pickle-ui/react. Its
11
+ * `separator` role reaches screen readers on the web; iOS and Android have no
12
+ * separator trait, so they skip it like any non-focusable view.
13
+ */
14
+ export function Separator({
15
+ className,
16
+ orientation = 'horizontal',
17
+ ...props
18
+ }: SeparatorProps) {
19
+ return (
20
+ <View
21
+ className={cn(
22
+ 'bg-border shrink-0',
23
+ orientation === 'horizontal' ? 'h-px w-full' : 'w-px self-stretch',
24
+ className,
25
+ )}
26
+ role="separator"
27
+ {...props}
28
+ />
29
+ );
30
+ }
@@ -0,0 +1,35 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
+
3
+ import { Story } from './_internal/Story';
4
+ import { Spinner } from './Spinner';
5
+ import { Text } from './Text';
6
+ import { XStack } from './XStack';
7
+
8
+ const meta = {
9
+ component: Spinner,
10
+ parameters: { layout: 'fullscreen' },
11
+ title: 'components/Spinner',
12
+ } satisfies Meta<typeof Spinner>;
13
+
14
+ export default meta;
15
+ type StoryDefinition = StoryObj<typeof meta>;
16
+
17
+ export const All: StoryDefinition = {
18
+ render: () => (
19
+ <Story.Layout className="max-w-3xl" title="Spinner">
20
+ <Story.Section title="Sizes">
21
+ <XStack align="center" gap={6}>
22
+ <Spinner className="size-4" />
23
+ <Spinner className="size-6" />
24
+ <Spinner className="size-8" />
25
+ </XStack>
26
+ </Story.Section>
27
+ <Story.Section title="With a label">
28
+ <XStack align="center" gap={2}>
29
+ <Spinner aria-hidden className="text-muted-foreground size-4" />
30
+ <Text tone="muted">Loading your workspace…</Text>
31
+ </XStack>
32
+ </Story.Section>
33
+ </Story.Layout>
34
+ ),
35
+ };
@@ -0,0 +1,30 @@
1
+ import { render, screen } from '@testing-library/react-native';
2
+
3
+ import { Spinner } from './Spinner';
4
+
5
+ describe('Spinner', () => {
6
+ it('announces that something is loading', async () => {
7
+ await render(<Spinner />);
8
+
9
+ expect(
10
+ screen.getByRole('progressbar', { name: 'Loading' }),
11
+ ).toBeOnTheScreen();
12
+ });
13
+
14
+ it('accepts a custom accessible name', async () => {
15
+ await render(<Spinner accessibilityLabel="Saving" />);
16
+
17
+ expect(
18
+ screen.getByRole('progressbar', { name: 'Saving' }),
19
+ ).toBeOnTheScreen();
20
+ });
21
+
22
+ it('is decorative when aria-hidden', async () => {
23
+ await render(<Spinner aria-hidden testID="spinner" />);
24
+
25
+ expect(screen.queryByRole('progressbar')).toBeNull();
26
+ expect(
27
+ screen.getByTestId('spinner', { includeHiddenElements: true }),
28
+ ).not.toHaveProp('accessibilityLabel');
29
+ });
30
+ });
@@ -0,0 +1,75 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Animated, Easing, Platform, View } from 'react-native';
3
+ import { useResolveClassNames } from 'uniwind';
4
+
5
+ import { cn } from '../lib/cn';
6
+
7
+ export type SpinnerProps = Omit<
8
+ React.ComponentProps<typeof View>,
9
+ 'children'
10
+ > & {
11
+ /** Hides the spinner from screen readers, when visible text already says what is loading. */
12
+ 'aria-hidden'?: boolean;
13
+ };
14
+
15
+ /**
16
+ * The web `Spinner`'s look on native: a three-quarter ring turning once a
17
+ * second, drawn inside a `size-6` box like the web icon. Size it with
18
+ * `size-*` and color it with `text-*`, as on the web. Announced as "Loading"
19
+ * by default; `accessibilityLabel` replaces the name and `aria-hidden` makes
20
+ * it decorative.
21
+ */
22
+ export function Spinner({
23
+ accessibilityLabel = 'Loading',
24
+ 'aria-hidden': ariaHidden = false,
25
+ className,
26
+ style,
27
+ ...props
28
+ }: SpinnerProps) {
29
+ const [turn] = useState(() => new Animated.Value(0));
30
+ const box = cn('text-foreground size-6', className);
31
+ // React Native has no `currentColor`: the ring reads the `text-*` color, and
32
+ // its stroke follows the box like the icon's (2px at 24px).
33
+ const { color, width } = useResolveClassNames(box);
34
+ const strokeWidth = typeof width === 'number' ? width / 12 : 2;
35
+
36
+ useEffect(() => {
37
+ const loop = Animated.loop(
38
+ Animated.timing(turn, {
39
+ duration: 1000,
40
+ easing: Easing.linear,
41
+ toValue: 1,
42
+ // react-native-web has no native driver and warns when asked for it.
43
+ useNativeDriver: Platform.OS !== 'web',
44
+ }),
45
+ );
46
+ loop.start();
47
+ return () => loop.stop();
48
+ }, [turn]);
49
+
50
+ const rotate = turn.interpolate({
51
+ inputRange: [0, 1],
52
+ outputRange: ['0deg', '360deg'],
53
+ });
54
+
55
+ return (
56
+ <Animated.View
57
+ accessibilityLabel={ariaHidden ? undefined : accessibilityLabel}
58
+ accessibilityRole={ariaHidden ? undefined : 'progressbar'}
59
+ accessible={!ariaHidden}
60
+ aria-hidden={ariaHidden}
61
+ className={cn('items-center justify-center', box)}
62
+ style={[style, { transform: [{ rotate }] }]}
63
+ {...props}
64
+ >
65
+ <View
66
+ className="size-3/4 rounded-full"
67
+ style={{
68
+ borderColor: typeof color === 'string' ? color : undefined,
69
+ borderTopColor: 'transparent',
70
+ borderWidth: strokeWidth,
71
+ }}
72
+ />
73
+ </Animated.View>
74
+ );
75
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
2
 
3
+ import { View } from 'react-native';
4
+
3
5
  import { type ComponentMatrixCellProps, Story } from './_internal/Story';
4
6
  import { Text, type TextProps } from './Text';
5
7
 
@@ -16,6 +18,7 @@ type StoryDefinition = StoryObj<typeof meta>;
16
18
 
17
19
  type TextTone = NonNullable<TextProps['tone']>;
18
20
  type TextVariant = NonNullable<TextProps['variant']>;
21
+ type TextWeight = NonNullable<TextProps['weight']>;
19
22
 
20
23
  const VARIANT_ROWS: { key: TextVariant; label: string }[] = [
21
24
  { key: 'h1', label: 'H1' },
@@ -25,6 +28,7 @@ const VARIANT_ROWS: { key: TextVariant; label: string }[] = [
25
28
  { key: 'lead', label: 'Lead' },
26
29
  { key: 'body', label: 'Body' },
27
30
  { key: 'small', label: 'Small' },
31
+ { key: 'code', label: 'Code' },
28
32
  ];
29
33
 
30
34
  const TONE_COLUMNS: { key: 'default' | TextTone; label: string }[] = [
@@ -34,6 +38,11 @@ const TONE_COLUMNS: { key: 'default' | TextTone; label: string }[] = [
34
38
  { key: 'destructive', label: 'Destructive' },
35
39
  ];
36
40
 
41
+ const WEIGHT_ROWS: { key: TextWeight; label: string }[] = [
42
+ { key: 'normal', label: 'Normal' },
43
+ { key: 'bold', label: 'Bold' },
44
+ ];
45
+
37
46
  function ToneCell({ column, row }: ComponentMatrixCellProps) {
38
47
  return (
39
48
  <Text
@@ -45,16 +54,57 @@ function ToneCell({ column, row }: ComponentMatrixCellProps) {
45
54
  );
46
55
  }
47
56
 
57
+ function WeightCell({ row }: ComponentMatrixCellProps) {
58
+ return (
59
+ <Text variant="body" weight={row.key as TextWeight}>
60
+ {row.label} weight
61
+ </Text>
62
+ );
63
+ }
64
+
48
65
  export const All: StoryDefinition = {
49
66
  render: () => (
50
67
  <Story.Layout className="max-w-6xl" title="Text">
51
- <Story.Section title="Variants and tones">
68
+ <Story.Section title="Variant scale">
69
+ <View className="gap-4">
70
+ {VARIANT_ROWS.map(({ key, label }) => (
71
+ <View className="flex-row items-baseline gap-4" key={key}>
72
+ <Text className="w-12" tone="muted" variant="small">
73
+ {label}
74
+ </Text>
75
+ <Text className="flex-1" variant={key}>
76
+ {key === 'code'
77
+ ? 'npx expo install @pickle-ui/react-native'
78
+ : 'The quick brown fox jumps over the lazy dog.'}
79
+ </Text>
80
+ </View>
81
+ ))}
82
+ </View>
83
+ </Story.Section>
84
+
85
+ <Story.Section title="Tone (color axis)">
52
86
  <Story.Matrix
53
87
  Cell={ToneCell}
54
88
  columns={TONE_COLUMNS}
55
- rows={VARIANT_ROWS}
89
+ rows={VARIANT_ROWS.slice(0, 4)}
90
+ />
91
+ </Story.Section>
92
+
93
+ <Story.Section title="Weight">
94
+ <Story.Matrix
95
+ Cell={WeightCell}
96
+ columns={[{ key: 'body', label: 'Body' }]}
97
+ rows={WEIGHT_ROWS}
56
98
  />
57
99
  </Story.Section>
100
+
101
+ <Story.Section title="Truncate">
102
+ <View className="border-border w-64 rounded-md border p-3">
103
+ <Text truncate>
104
+ A project name long enough to run past the edge of its container
105
+ </Text>
106
+ </View>
107
+ </Story.Section>
58
108
  </Story.Layout>
59
109
  ),
60
110
  };