@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,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,8 +1,13 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
2
 
3
3
  import { View } from 'react-native';
4
+ import { ScopedVariables } from 'uniwind';
4
5
 
5
6
  import { type ComponentMatrixCellProps, Story } from './_internal/Story';
7
+ import { Badge } from './Badge';
8
+ import { Button } from './Button';
9
+ import { Card } from './Card';
10
+ import { Input } from './form/Input';
6
11
  import { Text, type TextProps } from './Text';
7
12
 
8
13
  const meta = {
@@ -38,11 +43,47 @@ const TONE_COLUMNS: { key: 'default' | TextTone; label: string }[] = [
38
43
  { key: 'destructive', label: 'Destructive' },
39
44
  ];
40
45
 
46
+ // An app sets `--font-sans` in its stylesheet; `ScopedVariables` sets it here
47
+ // so both fonts sit side by side. On the web, `--font-heading` resolves
48
+ // where it's declared, so the scope sets it too; native resolves it in place.
49
+ const FONT_COLUMNS = [
50
+ { description: 'No --font-sans', key: '', label: 'System font' },
51
+ {
52
+ description: "--font-sans: 'Georgia'",
53
+ key: 'Georgia',
54
+ label: 'App font',
55
+ },
56
+ ];
57
+
41
58
  const WEIGHT_ROWS: { key: TextWeight; label: string }[] = [
42
59
  { key: 'normal', label: 'Normal' },
43
60
  { key: 'bold', label: 'Bold' },
44
61
  ];
45
62
 
63
+ function FontCell({ column }: ComponentMatrixCellProps) {
64
+ return (
65
+ <ScopedVariables
66
+ variables={
67
+ column.key
68
+ ? { '--font-heading': column.key, '--font-sans': column.key }
69
+ : {}
70
+ }
71
+ >
72
+ <Card className="w-80">
73
+ <Card.Header>
74
+ <Card.Title>Weekly report</Card.Title>
75
+ <Card.Description>Every project, every Monday.</Card.Description>
76
+ </Card.Header>
77
+ <Card.Content className="items-start gap-3">
78
+ <Badge variant="success">Shipped</Badge>
79
+ <Input accessibilityLabel="Email" placeholder="ada@example.com" />
80
+ <Button>Subscribe</Button>
81
+ </Card.Content>
82
+ </Card>
83
+ </ScopedVariables>
84
+ );
85
+ }
86
+
46
87
  function ToneCell({ column, row }: ComponentMatrixCellProps) {
47
88
  return (
48
89
  <Text
@@ -98,6 +139,14 @@ export const All: StoryDefinition = {
98
139
  />
99
140
  </Story.Section>
100
141
 
142
+ <Story.Section title="App font">
143
+ <Story.Matrix
144
+ Cell={FontCell}
145
+ columns={FONT_COLUMNS}
146
+ rows={[{ key: 'card', label: 'Components' }]}
147
+ />
148
+ </Story.Section>
149
+
101
150
  <Story.Section title="Truncate">
102
151
  <View className="border-border w-64 rounded-md border p-3">
103
152
  <Text truncate>
@@ -5,7 +5,7 @@ import { cn } from '../lib/cn';
5
5
 
6
6
  // Same variants, tones and weights as `Text` in @pickle-ui/react, sized by the
7
7
  // shared type scale (`@pickle-ui/tokens/typography.css`).
8
- const textVariants = cva('text-foreground', {
8
+ const textVariants = cva('text-foreground font-sans', {
9
9
  defaultVariants: { variant: 'body' },
10
10
  variants: {
11
11
  tone: {
@@ -16,8 +16,8 @@ const textVariants = cva('text-foreground', {
16
16
  variant: {
17
17
  body: 'text-body',
18
18
  code: 'text-code bg-muted self-start overflow-hidden rounded px-2 py-1',
19
- h1: 'text-h1 font-semibold tracking-tight',
20
- h2: 'text-h2 font-semibold tracking-tight',
19
+ h1: 'font-heading text-h1 font-semibold tracking-tight',
20
+ h2: 'font-heading text-h2 font-semibold tracking-tight',
21
21
  h3: 'text-h3 font-semibold',
22
22
  h4: 'text-h4 font-medium',
23
23
  lead: 'text-lead',
@@ -33,7 +33,8 @@ const textVariants = cva('text-foreground', {
33
33
  const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4']);
34
34
 
35
35
  // Uniwind passes `font-mono`'s web font stack through as is, which native
36
- // fonts don't resolve, so `code` names each platform's monospace font.
36
+ // fonts don't resolve, so `code` names each platform's monospace font. The
37
+ // `style` prop wins over `font-sans` from `className`.
37
38
  const styles = StyleSheet.create({
38
39
  code: { fontFamily: Platform.select({ default: 'monospace', ios: 'Menlo' }) },
39
40
  });
@@ -0,0 +1,46 @@
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 { XStack } from './XStack';
6
+
7
+ const meta = {
8
+ component: XStack,
9
+ parameters: { layout: 'fullscreen' },
10
+ title: 'components/XStack',
11
+ } satisfies Meta<typeof XStack>;
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="XStack">
19
+ <Story.Section title="Horizontal spacing">
20
+ <XStack gap={3}>
21
+ <Item>Avatar</Item>
22
+ <Item>Profile details</Item>
23
+ <Item>Status</Item>
24
+ </XStack>
25
+ </Story.Section>
26
+
27
+ <Story.Section title="Distribution">
28
+ <XStack
29
+ align="center"
30
+ className="border-border min-h-48 rounded-md border border-dashed p-4"
31
+ justify="between"
32
+ >
33
+ <Item>Leading action</Item>
34
+ <Item>Trailing action</Item>
35
+ </XStack>
36
+ </Story.Section>
37
+
38
+ <Story.Section title="Responsive override">
39
+ <XStack className="max-sm:flex-col" gap={3}>
40
+ <Item>Horizontal by default</Item>
41
+ <Item>Vertical below sm</Item>
42
+ </XStack>
43
+ </Story.Section>
44
+ </Story.Layout>
45
+ ),
46
+ };
@@ -0,0 +1,13 @@
1
+ import { render, screen } from '@testing-library/react-native';
2
+
3
+ import { XStack } from './XStack';
4
+
5
+ describe('XStack', () => {
6
+ it('renders a row with the Flex props', async () => {
7
+ await render(<XStack align="center" gap={2} testID="stack" />);
8
+
9
+ expect(
10
+ String(screen.getByTestId('stack').props.className).split(' '),
11
+ ).toEqual(expect.arrayContaining(['flex-row', 'items-center', 'gap-2']));
12
+ });
13
+ });
@@ -0,0 +1,7 @@
1
+ import { Flex, type FlexProps } from './Flex';
2
+
3
+ export type XStackProps = Omit<FlexProps, 'direction'>;
4
+
5
+ export function XStack(props: XStackProps) {
6
+ return <Flex direction="row" {...props} />;
7
+ }
@@ -0,0 +1,46 @@
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 { YStack } from './YStack';
6
+
7
+ const meta = {
8
+ component: YStack,
9
+ parameters: { layout: 'fullscreen' },
10
+ title: 'components/YStack',
11
+ } satisfies Meta<typeof YStack>;
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="YStack">
19
+ <Story.Section title="Vertical spacing">
20
+ <YStack gap={3}>
21
+ <Item>Avatar</Item>
22
+ <Item>Profile details</Item>
23
+ <Item>Status</Item>
24
+ </YStack>
25
+ </Story.Section>
26
+
27
+ <Story.Section title="Distribution">
28
+ <YStack
29
+ align="center"
30
+ className="border-border min-h-48 rounded-md border border-dashed p-4"
31
+ justify="between"
32
+ >
33
+ <Item>Leading action</Item>
34
+ <Item>Trailing action</Item>
35
+ </YStack>
36
+ </Story.Section>
37
+
38
+ <Story.Section title="Responsive override">
39
+ <YStack className="sm:flex-row" gap={3}>
40
+ <Item>Vertical by default</Item>
41
+ <Item>Horizontal from sm</Item>
42
+ </YStack>
43
+ </Story.Section>
44
+ </Story.Layout>
45
+ ),
46
+ };
@@ -0,0 +1,13 @@
1
+ import { render, screen } from '@testing-library/react-native';
2
+
3
+ import { YStack } from './YStack';
4
+
5
+ describe('YStack', () => {
6
+ it('renders a column with the Flex props', async () => {
7
+ await render(<YStack gap={2} justify="center" testID="stack" />);
8
+
9
+ expect(
10
+ String(screen.getByTestId('stack').props.className).split(' '),
11
+ ).toEqual(expect.arrayContaining(['flex-col', 'justify-center', 'gap-2']));
12
+ });
13
+ });
@@ -0,0 +1,7 @@
1
+ import { Flex, type FlexProps } from './Flex';
2
+
3
+ export type YStackProps = Omit<FlexProps, 'direction'>;
4
+
5
+ export function YStack(props: YStackProps) {
6
+ return <Flex direction="column" {...props} />;
7
+ }