@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
@@ -1,4 +1,5 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react-native';
2
+ import { View } from 'react-native';
2
3
 
3
4
  import { Button } from './Button';
4
5
 
@@ -31,4 +32,72 @@ describe('Button', () => {
31
32
  expect(screen.getByRole('button')).toBeDisabled();
32
33
  expect(onPress).not.toHaveBeenCalled();
33
34
  });
35
+
36
+ it('renders icons next to the label', async () => {
37
+ await render(
38
+ <Button>
39
+ <View testID="icon" />
40
+ Add project
41
+ </Button>,
42
+ );
43
+
44
+ expect(screen.getByRole('button')).toContainElement(
45
+ screen.getByTestId('icon'),
46
+ );
47
+ expect(screen.getByText('Add project')).toBeOnTheScreen();
48
+ });
49
+
50
+ it('names an icon-only button with accessibilityLabel', async () => {
51
+ await render(
52
+ <Button accessibilityLabel="Add project" size="icon">
53
+ <View />
54
+ </Button>,
55
+ );
56
+
57
+ expect(
58
+ screen.getByRole('button', { name: 'Add project' }),
59
+ ).toBeOnTheScreen();
60
+ });
61
+
62
+ it('is busy and disabled while loading', async () => {
63
+ const onPress = jest.fn();
64
+ await render(
65
+ <Button loading onPress={onPress}>
66
+ Save
67
+ </Button>,
68
+ );
69
+
70
+ await fireEvent.press(screen.getByRole('button'));
71
+
72
+ expect(screen.getByRole('button')).toBeBusy();
73
+ expect(screen.getByRole('button')).toBeDisabled();
74
+ expect(onPress).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('stretches every size to a 44pt touch target', async () => {
78
+ await render(
79
+ <>
80
+ <Button size="sm">Small</Button>
81
+ <Button accessibilityLabel="Add" size="icon">
82
+ +
83
+ </Button>
84
+ <Button hitSlop={0} size="xs">
85
+ Extra small
86
+ </Button>
87
+ </>,
88
+ );
89
+
90
+ expect(screen.getByRole('button', { name: 'Small' })).toHaveProp(
91
+ 'hitSlop',
92
+ { bottom: 8, top: 8 },
93
+ );
94
+ expect(screen.getByRole('button', { name: 'Add' })).toHaveProp(
95
+ 'hitSlop',
96
+ 6,
97
+ );
98
+ expect(screen.getByRole('button', { name: 'Extra small' })).toHaveProp(
99
+ 'hitSlop',
100
+ 0,
101
+ );
102
+ });
34
103
  });
@@ -1,70 +1,211 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
- import { Pressable, Text } from 'react-native';
2
+ import { Children, isValidElement } from 'react';
3
+ import { type Insets, Pressable, Text, View } from 'react-native';
3
4
 
4
5
  import { cn } from '../lib/cn';
6
+ import { Spinner } from './Spinner';
5
7
 
6
- // Same variant and size names as `Button` in @pickle-ui/react.
8
+ // Same variants, sizes and dimensions as `Button` in @pickle-ui/react on a
9
+ // phone, with its text sizes from `sm` up. Like the web's `pointer-coarse` hit area, `hitSlop` stretches every
10
+ // size to a 44pt touch target. Paddings leave room for the 1pt border.
7
11
  const buttonVariants = cva(
8
- 'flex-row items-center justify-center gap-2 rounded-lg border active:opacity-80 disabled:opacity-60',
12
+ 'flex-row items-center justify-center gap-2 rounded-lg border disabled:opacity-64',
9
13
  {
10
14
  defaultVariants: { size: 'md', variant: 'default' },
11
15
  variants: {
12
16
  size: {
13
- lg: 'h-11 px-5',
14
- md: 'h-10 px-4',
15
- sm: 'h-8 px-3',
17
+ icon: 'size-8',
18
+ 'icon-lg': 'size-9',
19
+ 'icon-sm': 'size-7',
20
+ 'icon-xl': 'size-10',
21
+ 'icon-xs': 'size-6 rounded-md',
22
+ lg: 'h-9 px-[13px]',
23
+ md: 'h-8 px-[11px]',
24
+ sm: 'h-7 gap-1.5 px-[9px]',
25
+ xl: 'h-10 px-[15px]',
26
+ xs: 'h-6 gap-1 rounded-md px-[7px]',
16
27
  },
17
28
  variant: {
18
- default: 'border-primary bg-primary',
19
- destructive: 'border-destructive bg-destructive',
20
- ghost: 'border-transparent active:bg-accent',
21
- outline: 'border-input bg-popover',
22
- secondary: 'border-transparent bg-secondary',
29
+ default:
30
+ 'border-primary bg-primary shadow-primary/24 shadow-xs active:bg-primary/90 disabled:shadow-none',
31
+ destructive:
32
+ 'border-destructive bg-destructive shadow-destructive/24 shadow-xs active:bg-destructive/90 disabled:shadow-none',
33
+ 'destructive-outline':
34
+ 'border-input bg-popover active:border-destructive/32 active:bg-destructive/4 dark:bg-input/32 shadow-xs shadow-black/5 disabled:shadow-none',
35
+ ghost: 'active:bg-accent border-transparent',
36
+ link: 'border-transparent',
37
+ outline:
38
+ 'border-input bg-popover active:bg-accent/50 dark:bg-input/32 dark:active:bg-input/64 shadow-xs shadow-black/5 disabled:shadow-none',
39
+ secondary: 'bg-secondary active:bg-secondary/80 border-transparent',
23
40
  },
24
41
  },
25
42
  },
26
43
  );
27
44
 
28
45
  const labelVariants = cva('font-medium', {
46
+ compoundVariants: [
47
+ { className: 'underline', pressed: true, variant: 'link' },
48
+ ],
29
49
  defaultVariants: { size: 'md', variant: 'default' },
30
50
  variants: {
31
- size: { lg: 'text-base', md: 'text-sm', sm: 'text-xs' },
51
+ pressed: { false: '', true: '' },
52
+ // The web's sizes from `sm` up, not its larger phone sizes, which only
53
+ // stop iOS Safari zooming into focused fields.
54
+ size: {
55
+ icon: 'text-sm',
56
+ 'icon-lg': 'text-sm',
57
+ 'icon-sm': 'text-sm',
58
+ 'icon-xl': 'text-sm',
59
+ 'icon-xs': 'text-sm',
60
+ lg: 'text-sm',
61
+ md: 'text-sm',
62
+ sm: 'text-sm',
63
+ xl: 'text-base',
64
+ xs: 'text-xs',
65
+ },
66
+ variant: {
67
+ default: 'text-primary-foreground',
68
+ destructive: 'text-destructive-foreground',
69
+ 'destructive-outline': 'text-destructive-subtle-foreground',
70
+ ghost: 'text-foreground',
71
+ link: 'text-foreground',
72
+ outline: 'text-foreground',
73
+ secondary: 'text-secondary-foreground',
74
+ },
75
+ },
76
+ });
77
+
78
+ // Sized and dimmed like the web button's icons, in the label's color.
79
+ const spinnerVariants = cva('size-4 opacity-80', {
80
+ variants: {
32
81
  variant: {
33
82
  default: 'text-primary-foreground',
34
83
  destructive: 'text-destructive-foreground',
84
+ 'destructive-outline': 'text-foreground',
35
85
  ghost: 'text-foreground',
86
+ link: 'text-foreground',
36
87
  outline: 'text-foreground',
37
88
  secondary: 'text-secondary-foreground',
38
89
  },
39
90
  },
40
91
  });
41
92
 
93
+ // The web's 1px inner highlight on solid buttons. Uniwind draws
94
+ // `inset-shadow` outside the view, so a top border on an overlay stands in.
95
+ const highlightVariants = cva('absolute inset-0 rounded-lg border-t', {
96
+ variants: {
97
+ pressed: { false: 'border-white/16', true: 'border-black/8' },
98
+ size: {
99
+ icon: '',
100
+ 'icon-lg': '',
101
+ 'icon-sm': '',
102
+ 'icon-xl': '',
103
+ 'icon-xs': 'rounded-md',
104
+ lg: '',
105
+ md: '',
106
+ sm: '',
107
+ xl: '',
108
+ xs: 'rounded-md',
109
+ },
110
+ },
111
+ });
112
+
113
+ type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>['size']>;
114
+
115
+ // Half the gap between each size and a 44pt touch target, vertical only for
116
+ // buttons with a label, whose width already reaches it.
117
+ const HIT_SLOP: Record<ButtonSize, Insets | number> = {
118
+ icon: 6,
119
+ 'icon-lg': 4,
120
+ 'icon-sm': 8,
121
+ 'icon-xl': 2,
122
+ 'icon-xs': 10,
123
+ lg: { bottom: 4, top: 4 },
124
+ md: { bottom: 6, top: 6 },
125
+ sm: { bottom: 8, top: 8 },
126
+ xl: { bottom: 2, top: 2 },
127
+ xs: { bottom: 10, top: 10 },
128
+ };
129
+
42
130
  export type ButtonProps = Omit<
43
131
  React.ComponentProps<typeof Pressable>,
44
132
  'children'
45
133
  > &
46
134
  VariantProps<typeof buttonVariants> & {
47
- children: string;
135
+ /**
136
+ * Text is wrapped in the label; other children, such as icons, render as
137
+ * they are. Icon-only buttons need an `icon*` size and an
138
+ * `accessibilityLabel`.
139
+ */
140
+ children: React.ReactNode;
48
141
  labelClassName?: string;
142
+ /** Shows a spinner in place of the content and disables the button. */
143
+ loading?: boolean;
49
144
  };
50
145
 
51
146
  export function Button({
147
+ accessibilityState,
52
148
  children,
53
149
  className,
150
+ disabled,
54
151
  labelClassName,
55
- size,
56
- variant,
152
+ loading = false,
153
+ size = 'md',
154
+ variant = 'default',
57
155
  ...props
58
156
  }: ButtonProps) {
157
+ const isDisabled = Boolean(disabled || loading);
158
+ const isSolid = variant === 'default' || variant === 'destructive';
159
+
59
160
  return (
60
161
  <Pressable
61
162
  accessibilityRole="button"
163
+ accessibilityState={{
164
+ ...accessibilityState,
165
+ busy: loading,
166
+ disabled: isDisabled,
167
+ }}
62
168
  className={cn(buttonVariants({ size, variant }), className)}
169
+ disabled={isDisabled}
170
+ hitSlop={size ? HIT_SLOP[size] : undefined}
63
171
  {...props}
64
172
  >
65
- <Text className={cn(labelVariants({ size, variant }), labelClassName)}>
66
- {children}
67
- </Text>
173
+ {({ pressed }) => (
174
+ <>
175
+ {isSolid && !isDisabled ? (
176
+ <View
177
+ className={highlightVariants({ pressed, size })}
178
+ pointerEvents="none"
179
+ />
180
+ ) : null}
181
+ {/* While loading, the content stays in place, invisible, so the
182
+ button keeps its width under the spinner. */}
183
+ {Children.map(children, (child) =>
184
+ typeof child === 'string' || typeof child === 'number' ? (
185
+ <Text
186
+ className={cn(
187
+ labelVariants({ pressed, size, variant }),
188
+ loading && 'opacity-0',
189
+ labelClassName,
190
+ )}
191
+ >
192
+ {child}
193
+ </Text>
194
+ ) : isValidElement(child) ? (
195
+ loading ? (
196
+ <View className="opacity-0">{child}</View>
197
+ ) : (
198
+ child
199
+ )
200
+ ) : null,
201
+ )}
202
+ {loading ? (
203
+ <View className="absolute">
204
+ <Spinner aria-hidden className={spinnerVariants({ variant })} />
205
+ </View>
206
+ ) : null}
207
+ </>
208
+ )}
68
209
  </Pressable>
69
210
  );
70
211
  }
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
2
 
3
+ import { IconArrowUpRight, IconDots } from '@tabler/icons-react-native';
3
4
  import { View } from 'react-native';
4
5
 
5
6
  import { Story } from './_internal/Story';
@@ -27,14 +28,26 @@ export const All: StoryDefinition = {
27
28
  <Card.Description>
28
29
  Changes made in the last week.
29
30
  </Card.Description>
31
+ <Card.Action>
32
+ <Button
33
+ accessibilityLabel="More options"
34
+ size="icon"
35
+ variant="ghost"
36
+ >
37
+ <Story.Icon icon={IconDots} />
38
+ </Button>
39
+ </Card.Action>
30
40
  </Card.Header>
31
41
  <Card.Content className="gap-2">
32
42
  <Text variant="h1">1,284</Text>
33
43
  <Text tone="muted">Events across 8 active projects</Text>
34
- <Button className="self-end" size="sm" variant="ghost">
44
+ </Card.Content>
45
+ <Card.Footer>
46
+ <Button className="ml-auto" size="sm" variant="ghost">
35
47
  View report
48
+ <Story.Icon icon={IconArrowUpRight} />
36
49
  </Button>
37
- </Card.Content>
50
+ </Card.Footer>
38
51
  </Card>
39
52
 
40
53
  <Card>
@@ -44,9 +57,9 @@ export const All: StoryDefinition = {
44
57
  Parts can be omitted when the content needs less structure.
45
58
  </Card.Description>
46
59
  </Card.Header>
47
- <Card.Content>
60
+ <Card.Footer>
48
61
  <Button>Continue</Button>
49
- </Card.Content>
62
+ </Card.Footer>
50
63
  </Card>
51
64
  </View>
52
65
  </Story.Section>
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react-native';
2
2
 
3
+ import { Button } from './Button';
3
4
  import { Card } from './Card';
4
5
 
5
6
  describe('Card', () => {
@@ -11,13 +12,35 @@ describe('Card', () => {
11
12
  <Card.Description>Everything in flight</Card.Description>
12
13
  </Card.Header>
13
14
  <Card.Content testID="content" />
15
+ <Card.Footer testID="footer" />
14
16
  </Card>,
15
17
  );
16
18
 
17
19
  expect(screen.getByTestId('card')).toContainElement(
18
- screen.getByText('Projects'),
20
+ screen.getByRole('header', { name: 'Projects' }),
19
21
  );
20
22
  expect(screen.getByText('Everything in flight')).toBeOnTheScreen();
21
23
  expect(screen.getByTestId('content')).toBeOnTheScreen();
24
+ expect(screen.getByTestId('footer')).toBeOnTheScreen();
25
+ });
26
+
27
+ it('places Card.Action in the header', async () => {
28
+ await render(
29
+ <Card>
30
+ <Card.Header testID="header">
31
+ <Card.Title>Projects</Card.Title>
32
+ <Card.Action>
33
+ <Button size="sm" variant="outline">
34
+ View all
35
+ </Button>
36
+ </Card.Action>
37
+ </Card.Header>
38
+ </Card>,
39
+ );
40
+
41
+ expect(screen.getByTestId('header')).toContainElement(
42
+ screen.getByRole('button', { name: 'View all' }),
43
+ );
44
+ expect(screen.getByText('Projects')).toBeOnTheScreen();
22
45
  });
23
46
  });
@@ -1,31 +1,81 @@
1
+ import { Children, isValidElement } from 'react';
1
2
  import { View } from 'react-native';
2
3
 
3
4
  import { cn } from '../lib/cn';
4
- import { Text } from './Text';
5
+ import { Text, type TextProps } from './Text';
5
6
 
7
+ export type CardActionProps = React.ComponentProps<typeof View>;
6
8
  export type CardContentProps = React.ComponentProps<typeof View>;
7
- export type CardDescriptionProps = React.ComponentProps<typeof Text>;
9
+ export type CardDescriptionProps = Omit<TextProps, 'tone' | 'variant'>;
10
+ export type CardFooterProps = React.ComponentProps<typeof View>;
8
11
  export type CardHeaderProps = React.ComponentProps<typeof View>;
9
12
  export type CardProps = React.ComponentProps<typeof View>;
10
- export type CardTitleProps = React.ComponentProps<typeof Text>;
13
+ export type CardTitleProps = Omit<TextProps, 'tone' | 'variant'>;
14
+
15
+ // The parts sit in a column with `gap-4` and their own side padding, which
16
+ // reproduces the web's spacing between header, content and footer.
17
+
18
+ function CardAction({ className, ...props }: CardActionProps) {
19
+ return <View className={cn('self-start', className)} {...props} />;
20
+ }
11
21
 
12
22
  function CardContent({ className, ...props }: CardContentProps) {
13
- return <View className={cn('gap-3 px-5', className)} {...props} />;
23
+ return <View className={cn('px-6', className)} {...props} />;
14
24
  }
15
25
 
16
26
  function CardDescription({ className, ...props }: CardDescriptionProps) {
17
- return <Text className={className} tone="muted" {...props} />;
27
+ return (
28
+ <Text
29
+ className={cn('text-sm', className)}
30
+ tone="muted"
31
+ variant="body"
32
+ {...props}
33
+ />
34
+ );
18
35
  }
19
36
 
20
- function CardHeader({ className, ...props }: CardHeaderProps) {
21
- return <View className={cn('gap-1 px-5', className)} {...props} />;
37
+ function CardFooter({ className, ...props }: CardFooterProps) {
38
+ return (
39
+ <View className={cn('flex-row items-center px-6', className)} {...props} />
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Lays a `Card.Action` out beside the title and description, top-aligned,
45
+ * like the web header's grid.
46
+ */
47
+ function CardHeader({ children, className, ...props }: CardHeaderProps) {
48
+ const parts = Children.toArray(children);
49
+ const actions = parts.filter(
50
+ (child) => isValidElement(child) && child.type === CardAction,
51
+ );
52
+
53
+ if (actions.length === 0) {
54
+ return (
55
+ <View className={cn('gap-1.5 px-6', className)} {...props}>
56
+ {children}
57
+ </View>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <View
63
+ className={cn('flex-row items-start gap-4 px-6', className)}
64
+ {...props}
65
+ >
66
+ <View className="flex-1 gap-1.5">
67
+ {parts.filter((child) => !actions.includes(child))}
68
+ </View>
69
+ {actions}
70
+ </View>
71
+ );
22
72
  }
23
73
 
24
74
  function CardRoot({ className, ...props }: CardProps) {
25
75
  return (
26
76
  <View
27
77
  className={cn(
28
- 'border-border bg-card gap-4 rounded-xl border py-5',
78
+ 'border-border bg-card gap-4 rounded-2xl border py-6 shadow-xs shadow-black/5',
29
79
  className,
30
80
  )}
31
81
  {...props}
@@ -36,20 +86,28 @@ function CardRoot({ className, ...props }: CardProps) {
36
86
  function CardTitle({ className, ...props }: CardTitleProps) {
37
87
  return (
38
88
  <Text
39
- className={cn('text-card-foreground', className)}
40
- variant="h4"
89
+ className={cn('text-card-foreground leading-none', className)}
90
+ variant="h3"
41
91
  {...props}
42
92
  />
43
93
  );
44
94
  }
45
95
 
46
- // Same compound shape as `Card` in @pickle-ui/react. The parts are exported
47
- // so Fast Refresh keeps their state; the public entry only exports `Card`.
48
96
  const Card = Object.assign(CardRoot, {
97
+ Action: CardAction,
49
98
  Content: CardContent,
50
99
  Description: CardDescription,
100
+ Footer: CardFooter,
51
101
  Header: CardHeader,
52
102
  Title: CardTitle,
53
103
  });
54
104
 
55
- export { Card, CardContent, CardDescription, CardHeader, CardTitle };
105
+ export {
106
+ Card,
107
+ CardAction,
108
+ CardContent,
109
+ CardDescription,
110
+ CardFooter,
111
+ CardHeader,
112
+ CardTitle,
113
+ };
@@ -0,0 +1,58 @@
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 { Flex } from './Flex';
6
+
7
+ const meta = {
8
+ component: Flex,
9
+ parameters: { layout: 'fullscreen' },
10
+ title: 'components/Flex',
11
+ } satisfies Meta<typeof Flex>;
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="Flex">
19
+ <Story.Section title="Direction and spacing">
20
+ <Flex direction="column" gap={3}>
21
+ <Item className="min-h-16">First</Item>
22
+ <Item className="min-h-16">Second</Item>
23
+ <Item className="min-h-16">Third</Item>
24
+ </Flex>
25
+ </Story.Section>
26
+
27
+ <Story.Section title="Alignment and distribution">
28
+ <Flex
29
+ align="center"
30
+ className="border-border min-h-40 rounded-md border border-dashed p-4"
31
+ gap={4}
32
+ justify="between"
33
+ >
34
+ <Item className="min-h-16 min-w-20">Start</Item>
35
+ <Item className="min-h-16 min-w-20">Center</Item>
36
+ <Item className="min-h-16 min-w-20">End</Item>
37
+ </Flex>
38
+ </Story.Section>
39
+
40
+ <Story.Section title="Wrapping">
41
+ <Flex gap={3} wrap="wrap">
42
+ {['One', 'Two', 'Three', 'Four', 'Five', 'Six'].map((label) => (
43
+ <Item className="min-h-16 min-w-24" key={label}>
44
+ {label}
45
+ </Item>
46
+ ))}
47
+ </Flex>
48
+ </Story.Section>
49
+
50
+ <Story.Section title="Responsive override">
51
+ <Flex className="flex-col md:flex-row" gap={3}>
52
+ <Item className="min-h-16">Column on small screens</Item>
53
+ <Item className="min-h-16">Row from md</Item>
54
+ </Flex>
55
+ </Story.Section>
56
+ </Story.Layout>
57
+ ),
58
+ };
@@ -0,0 +1,57 @@
1
+ import { render, screen } from '@testing-library/react-native';
2
+
3
+ import { Flex } from './Flex';
4
+
5
+ function classesOf(testID: string) {
6
+ return String(screen.getByTestId(testID).props.className).split(' ');
7
+ }
8
+
9
+ describe('Flex', () => {
10
+ it('lays its children out in a row, like the web', async () => {
11
+ await render(<Flex testID="flex" />);
12
+
13
+ expect(classesOf('flex')).toContain('flex-row');
14
+ });
15
+
16
+ it('renders layout props as Tailwind classes', async () => {
17
+ await render(
18
+ <Flex
19
+ align="center"
20
+ direction="column"
21
+ gap={4}
22
+ gapX={2}
23
+ gapY={3}
24
+ justify="between"
25
+ testID="flex"
26
+ wrap="wrap"
27
+ />,
28
+ );
29
+
30
+ expect(classesOf('flex')).toEqual(
31
+ expect.arrayContaining([
32
+ 'flex-col',
33
+ 'flex-wrap',
34
+ 'items-center',
35
+ 'justify-between',
36
+ 'gap-4',
37
+ 'gap-x-2',
38
+ 'gap-y-3',
39
+ ]),
40
+ );
41
+ });
42
+
43
+ it('lets className override the props', async () => {
44
+ await render(
45
+ <Flex
46
+ className="custom-class flex-row"
47
+ direction="column"
48
+ testID="flex"
49
+ />,
50
+ );
51
+
52
+ expect(classesOf('flex')).toEqual(
53
+ expect.arrayContaining(['flex-row', 'custom-class']),
54
+ );
55
+ expect(classesOf('flex')).not.toContain('flex-col');
56
+ });
57
+ });
@@ -0,0 +1,52 @@
1
+ import { View } from 'react-native';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { getLayoutClasses, type LayoutStyleProps } from './_internal/layout';
5
+
6
+ const directionClasses = {
7
+ column: 'flex-col',
8
+ 'column-reverse': 'flex-col-reverse',
9
+ row: 'flex-row',
10
+ 'row-reverse': 'flex-row-reverse',
11
+ } as const;
12
+
13
+ const wrapClasses = {
14
+ nowrap: 'flex-nowrap',
15
+ wrap: 'flex-wrap',
16
+ 'wrap-reverse': 'flex-wrap-reverse',
17
+ } as const;
18
+
19
+ export type FlexProps = LayoutStyleProps &
20
+ Omit<React.ComponentProps<typeof View>, keyof LayoutStyleProps> & {
21
+ direction?: keyof typeof directionClasses;
22
+ wrap?: keyof typeof wrapClasses;
23
+ };
24
+
25
+ /**
26
+ * A `View` laid out with flexbox props, like `Flex` in @pickle-ui/react. It
27
+ * defaults to a row, as on the web, where React Native's `View` stacks its
28
+ * children in a column.
29
+ */
30
+ export function Flex({
31
+ align,
32
+ className,
33
+ direction = 'row',
34
+ gap,
35
+ gapX,
36
+ gapY,
37
+ justify,
38
+ wrap,
39
+ ...props
40
+ }: FlexProps) {
41
+ return (
42
+ <View
43
+ className={cn(
44
+ getLayoutClasses({ align, gap, gapX, gapY, justify }),
45
+ directionClasses[direction],
46
+ wrap === undefined ? undefined : wrapClasses[wrap],
47
+ className,
48
+ )}
49
+ {...props}
50
+ />
51
+ );
52
+ }