@pickle-ui/react-native 0.0.0 → 1.0.0-beta.1

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 (55) hide show
  1. package/README.md +44 -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 +97 -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/Text.d.ts +7 -3
  15. package/dist/components/Text.d.ts.map +1 -1
  16. package/dist/components/Text.js +23 -11
  17. package/dist/components/Text.js.map +1 -1
  18. package/dist/components/_internal/Spinner.d.ts +9 -0
  19. package/dist/components/_internal/Spinner.d.ts.map +1 -0
  20. package/dist/components/_internal/Spinner.js +31 -0
  21. package/dist/components/_internal/Spinner.js.map +1 -0
  22. package/dist/entries/card.d.ts +1 -1
  23. package/dist/entries/card.d.ts.map +1 -1
  24. package/dist/entries/theme.d.ts +3 -0
  25. package/dist/entries/theme.d.ts.map +1 -0
  26. package/dist/entries/theme.js +2 -0
  27. package/dist/entries/theme.js.map +1 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/lib/cn.d.ts.map +1 -1
  33. package/dist/lib/cn.js +13 -1
  34. package/dist/lib/cn.js.map +1 -1
  35. package/package.json +5 -2
  36. package/src/base.css +7 -2
  37. package/src/components/Badge.stories.tsx +51 -6
  38. package/src/components/Badge.test.tsx +15 -0
  39. package/src/components/Badge.tsx +73 -17
  40. package/src/components/Button.stories.tsx +96 -8
  41. package/src/components/Button.test.tsx +69 -0
  42. package/src/components/Button.tsx +156 -18
  43. package/src/components/Card.stories.tsx +17 -4
  44. package/src/components/Card.test.tsx +24 -1
  45. package/src/components/Card.tsx +71 -13
  46. package/src/components/Text.stories.tsx +52 -2
  47. package/src/components/Text.test.tsx +35 -3
  48. package/src/components/Text.tsx +42 -12
  49. package/src/components/_internal/Spinner.tsx +49 -0
  50. package/src/components/_internal/Story.tsx +34 -0
  51. package/src/entries/card.ts +2 -0
  52. package/src/entries/theme.ts +7 -0
  53. package/src/index.ts +1 -0
  54. package/src/lib/cn.test.ts +6 -0
  55. package/src/lib/cn.ts +14 -1
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Animated, Easing, Platform, View } from 'react-native';
4
+ import { cn } from '../../lib/cn';
5
+ /**
6
+ * The web `Spinner`'s look on native: a three-quarter ring turning once a
7
+ * second. Color it with a `border-*` class. Decorative: the control showing
8
+ * it reports the busy state.
9
+ */
10
+ export function Spinner({ className }) {
11
+ const [turn] = useState(() => new Animated.Value(0));
12
+ useEffect(() => {
13
+ const loop = Animated.loop(Animated.timing(turn, {
14
+ duration: 1000,
15
+ easing: Easing.linear,
16
+ toValue: 1,
17
+ // react-native-web has no native driver and warns when asked for it.
18
+ useNativeDriver: Platform.OS !== 'web',
19
+ }));
20
+ loop.start();
21
+ return () => loop.stop();
22
+ }, [turn]);
23
+ const rotate = turn.interpolate({
24
+ inputRange: [0, 1],
25
+ outputRange: ['0deg', '360deg'],
26
+ });
27
+ return (_jsx(Animated.View, { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", style: { transform: [{ rotate }] }, children: _jsx(View, { className: cn('border-foreground size-3 rounded-full border-[1.5px] opacity-80', className,
28
+ // After the color, which would otherwise override the open side.
29
+ 'border-t-transparent') }) }));
30
+ }
31
+ //# sourceMappingURL=Spinner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Spinner.js","sourceRoot":"","sources":["../../../src/components/_internal/Spinner.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEhE,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAC;AAElC;;;;GAIG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,SAAS,EAA0B;IAC3D,MAAM,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAErD,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CACxB,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE;YACpB,QAAQ,EAAE,IAAI;YACd,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,OAAO,EAAE,CAAC;YACV,qEAAqE;YACrE,eAAe,EAAE,QAAQ,CAAC,EAAE,KAAK,KAAK;SACvC,CAAC,CACH,CAAC;QACF,IAAI,CAAC,KAAK,EAAE,CAAC;QACb,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;IAC3B,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IAEX,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW,CAAC;QAC9B,UAAU,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;QAClB,WAAW,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC;KAChC,CAAC,CAAC;IAEH,OAAO,CACL,KAAC,QAAQ,CAAC,IAAI,IACZ,2BAA2B,QAC3B,yBAAyB,EAAC,qBAAqB,EAC/C,KAAK,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,YAElC,KAAC,IAAI,IACH,SAAS,EAAE,EAAE,CACX,iEAAiE,EACjE,SAAS;YACT,iEAAiE;YACjE,sBAAsB,CACvB,GACD,GACY,CACjB,CAAC;AACJ,CAAC"}
@@ -1,3 +1,3 @@
1
1
  export { Card } from '../components/Card';
2
- export type { CardContentProps, CardDescriptionProps, CardHeaderProps, CardProps, CardTitleProps, } from '../components/Card';
2
+ export type { CardActionProps, CardContentProps, CardDescriptionProps, CardFooterProps, CardHeaderProps, CardProps, CardTitleProps, } from '../components/Card';
3
3
  //# sourceMappingURL=card.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"card.d.ts","sourceRoot":"","sources":["../../src/entries/card.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAC1C,YAAY,EACV,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,EACf,SAAS,EACT,cAAc,GACf,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"card.d.ts","sourceRoot":"","sources":["../../src/entries/card.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAC1C,YAAY,EACV,eAAe,EACf,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,EACf,eAAe,EACf,SAAS,EACT,cAAc,GACf,MAAM,oBAAoB,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { createTheme } from '@pickle-ui/tokens/theme';
2
+ export type { Theme, ThemeFonts, ThemeOptions, ThemeVariables, } from '@pickle-ui/tokens/theme';
3
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/entries/theme.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACtD,YAAY,EACV,KAAK,EACL,UAAU,EACV,YAAY,EACZ,cAAc,GACf,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { createTheme } from '@pickle-ui/tokens/theme';
2
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/entries/theme.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC"}
package/dist/index.d.ts CHANGED
@@ -3,5 +3,6 @@ export * from './entries/button';
3
3
  export * from './entries/card';
4
4
  export * from './entries/cn';
5
5
  export * from './entries/text';
6
+ export * from './entries/theme';
6
7
  export * from './entries/use-color-token';
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,2BAA2B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,2BAA2B,CAAC"}
package/dist/index.js CHANGED
@@ -5,5 +5,6 @@ export * from './entries/button';
5
5
  export * from './entries/card';
6
6
  export * from './entries/cn';
7
7
  export * from './entries/text';
8
+ export * from './entries/theme';
8
9
  export * from './entries/use-color-token';
9
10
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,yEAAyE;AACzE,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,2BAA2B,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,yEAAyE;AACzE,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,2BAA2B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"cn.d.ts","sourceRoot":"","sources":["../../src/lib/cn.ts"],"names":[],"mappings":"AAEA,wEAAwE;AACxE,wBAAgB,EAAE,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,KAAK,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAAC,UAEtE"}
1
+ {"version":3,"file":"cn.d.ts","sourceRoot":"","sources":["../../src/lib/cn.ts"],"names":[],"mappings":"AAeA,wEAAwE;AACxE,wBAAgB,EAAE,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,KAAK,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAAC,UAEtE"}
package/dist/lib/cn.js CHANGED
@@ -1,4 +1,16 @@
1
- import { twMerge } from 'tailwind-merge';
1
+ import { extendTailwindMerge } from 'tailwind-merge';
2
+ /**
3
+ * Teaches tailwind-merge Pickle's type scale: `text-body`, `text-h1`, … are
4
+ * font sizes, so they must not be mistaken for text colors and dropped next
5
+ * to one.
6
+ */
7
+ const twMerge = extendTailwindMerge({
8
+ extend: {
9
+ theme: {
10
+ text: ['body', 'code', 'h1', 'h2', 'h3', 'h4', 'lead', 'small'],
11
+ },
12
+ },
13
+ });
2
14
  /** Joins class names; later classes win, like `@pickle-ui/react/cn`. */
3
15
  export function cn(...classes) {
4
16
  return twMerge(classes.filter(Boolean).join(' '));
@@ -1 +1 @@
1
- {"version":3,"file":"cn.js","sourceRoot":"","sources":["../../src/lib/cn.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAEzC,wEAAwE;AACxE,MAAM,UAAU,EAAE,CAAC,GAAG,OAAiD;IACrE,OAAO,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AACpD,CAAC"}
1
+ {"version":3,"file":"cn.js","sourceRoot":"","sources":["../../src/lib/cn.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAErD;;;;GAIG;AACH,MAAM,OAAO,GAAG,mBAAmB,CAAC;IAClC,MAAM,EAAE;QACN,KAAK,EAAE;YACL,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC;SAChE;KACF;CACF,CAAC,CAAC;AAEH,wEAAwE;AACxE,MAAM,UAAU,EAAE,CAAC,GAAG,OAAiD;IACrE,OAAO,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AACpD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pickle-ui/react-native",
3
- "version": "0.0.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "React Native components for Expo, styled with Pickle UI's tokens through Uniwind.",
6
6
  "main": "dist/index.js",
@@ -49,12 +49,14 @@
49
49
  "dependencies": {
50
50
  "class-variance-authority": "^0.7.1",
51
51
  "tailwind-merge": "^3.6.0",
52
- "@pickle-ui/tokens": "1.0.0-beta.6"
52
+ "@pickle-ui/tokens": "1.0.0-beta.7"
53
53
  },
54
54
  "devDependencies": {
55
+ "@react-native/assets-registry": "0.88.0",
55
56
  "@react-native/jest-preset": "^0.88.1",
56
57
  "@storybook/addon-docs": "^10.6.3",
57
58
  "@storybook/react-native-web-vite": "^10.6.5",
59
+ "@tabler/icons-react-native": "^3.50.0",
58
60
  "@tailwindcss/vite": "^4.3.3",
59
61
  "@testing-library/react-native": "^14.1.0",
60
62
  "@types/jest": "^30.0.0",
@@ -67,6 +69,7 @@
67
69
  "react-doctor": "^0.5.6",
68
70
  "react-dom": "19.3.0",
69
71
  "react-native": "0.88.0",
72
+ "react-native-svg": "^15.16.0",
70
73
  "react-native-web": "^0.21.4",
71
74
  "storybook": "^10.6.3",
72
75
  "tailwindcss": "^4.3.3",
package/src/base.css CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Pickle's components as a content source, so Uniwind generates the classes
3
- * they use. `styles.css` imports it; import it alone, after `uniwind`, to
4
- * bring your own token set instead of `@pickle-ui/tokens/native.css`.
3
+ * they use, and the type scale behind `Text`'s variants, shared with
4
+ * `@pickle-ui/react`. `styles.css` imports this file; import it alone, after
5
+ * `uniwind`, to bring your own token set instead of
6
+ * `@pickle-ui/tokens/native.css`. The type scale only declares theme
7
+ * variables, so it compiles in any order.
5
8
  */
9
+ @import '@pickle-ui/tokens/typography.css';
10
+
6
11
  @source './components/**/*.{ts,tsx}';
@@ -10,21 +10,48 @@ const meta = {
10
10
  } satisfies Meta<typeof Badge>;
11
11
 
12
12
  export default meta;
13
+ type BadgeSize = NonNullable<BadgeProps['size']>;
14
+ type BadgeVariant = NonNullable<BadgeProps['variant']>;
13
15
  type StoryDefinition = StoryObj<typeof meta>;
14
16
 
15
- const DISPLAY_COLUMNS = [{ key: 'label', label: 'Label only' }];
17
+ const DISPLAY_COLUMNS = [
18
+ { key: 'label', label: 'Label only' },
19
+ { key: 'icon', label: 'With icon' },
20
+ ];
16
21
 
17
- const SEMANTIC_ROWS = [
22
+ const SEMANTIC_ROWS: { key: BadgeVariant; label: string }[] = [
18
23
  { key: 'default', label: 'Default' },
19
24
  { key: 'secondary', label: 'Secondary' },
25
+ { key: 'outline', label: 'Outline' },
26
+ { key: 'info', label: 'Info' },
27
+ { key: 'success', label: 'Success' },
28
+ { key: 'warning', label: 'Warning' },
29
+ { key: 'error', label: 'Error' },
30
+ { key: 'destructive', label: 'Destructive' },
31
+ ];
32
+
33
+ const STATUS_ROWS: { key: BadgeVariant; label: string }[] = [
34
+ { key: 'pending', label: 'Pending' },
35
+ { key: 'submitted', label: 'Submitted' },
20
36
  { key: 'inProgress', label: 'In progress' },
21
37
  { key: 'inReview', label: 'In review' },
22
- { key: 'success', label: 'Success' },
38
+ { key: 'failed', label: 'Failed' },
39
+ { key: 'expired', label: 'Expired' },
23
40
  ];
24
41
 
25
- function SemanticBadgeCell({ row }: ComponentMatrixCellProps) {
42
+ const SIZE_COLUMNS: { description: string; key: BadgeSize; label: string }[] = [
43
+ { description: 'h-4', key: 'sm', label: 'Small' },
44
+ { description: 'h-4.5', key: 'md', label: 'Medium' },
45
+ { description: 'h-5.5', key: 'lg', label: 'Large' },
46
+ ];
47
+
48
+ function BadgeCell({ row }: ComponentMatrixCellProps) {
49
+ return <Badge variant={row.key as BadgeVariant}>{row.label}</Badge>;
50
+ }
51
+
52
+ function BadgeSizeCell({ column, row }: ComponentMatrixCellProps) {
26
53
  return (
27
- <Badge variant={row.key as NonNullable<BadgeProps['variant']>}>
54
+ <Badge size={column.key as BadgeSize} variant={row.key as BadgeVariant}>
28
55
  {row.label}
29
56
  </Badge>
30
57
  );
@@ -36,12 +63,30 @@ export const All: StoryDefinition = {
36
63
  <Story.Layout className="max-w-4xl" title="Badge">
37
64
  <Story.Section title="Semantic badges">
38
65
  <Story.Matrix
39
- Cell={SemanticBadgeCell}
66
+ Cell={BadgeCell}
40
67
  cellClassName="min-h-16"
41
68
  columns={DISPLAY_COLUMNS}
42
69
  rows={SEMANTIC_ROWS}
43
70
  />
44
71
  </Story.Section>
72
+
73
+ <Story.Section title="Statuses">
74
+ <Story.Matrix
75
+ Cell={BadgeCell}
76
+ cellClassName="min-h-16"
77
+ columns={DISPLAY_COLUMNS}
78
+ rows={STATUS_ROWS}
79
+ />
80
+ </Story.Section>
81
+
82
+ <Story.Section title="Sizes">
83
+ <Story.Matrix
84
+ Cell={BadgeSizeCell}
85
+ cellClassName="min-h-16"
86
+ columns={SIZE_COLUMNS}
87
+ rows={SEMANTIC_ROWS.slice(0, 3)}
88
+ />
89
+ </Story.Section>
45
90
  </Story.Layout>
46
91
  ),
47
92
  };
@@ -1,4 +1,5 @@
1
1
  import { render, screen } from '@testing-library/react-native';
2
+ import { View } from 'react-native';
2
3
 
3
4
  import { Badge } from './Badge';
4
5
 
@@ -8,4 +9,18 @@ describe('Badge', () => {
8
9
 
9
10
  expect(screen.getByText('Shipped')).toBeOnTheScreen();
10
11
  });
12
+
13
+ it('renders icons next to the label', async () => {
14
+ await render(
15
+ <Badge size="lg" testID="badge" variant="warning">
16
+ <View testID="icon" />
17
+ Pending
18
+ </Badge>,
19
+ );
20
+
21
+ expect(screen.getByTestId('badge')).toContainElement(
22
+ screen.getByTestId('icon'),
23
+ );
24
+ expect(screen.getByText('Pending')).toBeOnTheScreen();
25
+ });
11
26
  });
@@ -1,42 +1,98 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
+ import { Children, isValidElement } from 'react';
2
3
  import { Text, View } from 'react-native';
3
4
 
4
5
  import { cn } from '../lib/cn';
5
6
 
6
- // Status variants use the `-subtle` token pairs, like `Badge` on the web.
7
- const badgeVariants = cva('self-start rounded-sm px-1.5 py-0.5', {
8
- defaultVariants: { variant: 'default' },
9
- variants: {
10
- variant: {
11
- default: 'bg-primary',
12
- inProgress: 'bg-info-subtle',
13
- inReview: 'bg-warning-subtle',
14
- secondary: 'bg-secondary',
15
- success: 'bg-success-subtle',
7
+ // Same variants and sizes as `Badge` in @pickle-ui/react.
8
+ const badgeVariants = cva(
9
+ 'flex-row items-center justify-center gap-1 self-start rounded-sm border border-transparent',
10
+ {
11
+ defaultVariants: { size: 'md', variant: 'default' },
12
+ variants: {
13
+ size: {
14
+ lg: 'h-5.5 min-w-5.5 px-1.5',
15
+ md: 'h-4.5 min-w-4.5 px-1',
16
+ sm: 'h-4 min-w-4 rounded px-1',
17
+ },
18
+ variant: {
19
+ default: 'bg-primary',
20
+ destructive: 'bg-destructive',
21
+ error: 'bg-destructive-subtle',
22
+ expired: 'bg-secondary',
23
+ failed: 'bg-destructive-subtle',
24
+ info: 'bg-info-subtle',
25
+ inProgress: 'bg-info-subtle',
26
+ inReview: 'bg-warning-subtle',
27
+ outline: 'border-input bg-background dark:bg-input/32',
28
+ pending: 'bg-warning-subtle',
29
+ secondary: 'bg-secondary',
30
+ submitted: 'bg-info-subtle',
31
+ success: 'bg-success-subtle',
32
+ warning: 'bg-warning-subtle',
33
+ },
16
34
  },
17
35
  },
18
- });
36
+ );
19
37
 
20
- const labelVariants = cva('text-xs font-medium', {
21
- defaultVariants: { variant: 'default' },
38
+ const labelVariants = cva('font-medium', {
39
+ defaultVariants: { size: 'md', variant: 'default' },
22
40
  variants: {
41
+ size: {
42
+ lg: 'text-sm',
43
+ md: 'text-xs',
44
+ sm: 'text-[10px]',
45
+ },
23
46
  variant: {
24
47
  default: 'text-primary-foreground',
48
+ destructive: 'text-destructive-foreground',
49
+ error: 'text-destructive-subtle-foreground',
50
+ expired: 'text-secondary-foreground',
51
+ failed: 'text-destructive-subtle-foreground',
52
+ info: 'text-info-subtle-foreground',
25
53
  inProgress: 'text-info-subtle-foreground',
26
54
  inReview: 'text-warning-subtle-foreground',
55
+ outline: 'text-foreground',
56
+ pending: 'text-warning-subtle-foreground',
27
57
  secondary: 'text-secondary-foreground',
58
+ submitted: 'text-info-subtle-foreground',
28
59
  success: 'text-success-subtle-foreground',
60
+ warning: 'text-warning-subtle-foreground',
29
61
  },
30
62
  },
31
63
  });
32
64
 
33
65
  export type BadgeProps = React.ComponentProps<typeof View> &
34
- VariantProps<typeof badgeVariants> & { children: string };
66
+ VariantProps<typeof badgeVariants> & {
67
+ /** Text is wrapped in the label; other children, such as icons, render as they are. */
68
+ children: React.ReactNode;
69
+ labelClassName?: string;
70
+ };
35
71
 
36
- export function Badge({ children, className, variant, ...props }: BadgeProps) {
72
+ export function Badge({
73
+ children,
74
+ className,
75
+ labelClassName,
76
+ size = 'md',
77
+ variant = 'default',
78
+ ...props
79
+ }: BadgeProps) {
37
80
  return (
38
- <View className={cn(badgeVariants({ variant }), className)} {...props}>
39
- <Text className={labelVariants({ variant })}>{children}</Text>
81
+ <View
82
+ className={cn(badgeVariants({ size, variant }), className)}
83
+ {...props}
84
+ >
85
+ {Children.map(children, (child) =>
86
+ typeof child === 'string' || typeof child === 'number' ? (
87
+ <Text
88
+ className={cn(labelVariants({ size, variant }), labelClassName)}
89
+ >
90
+ {child}
91
+ </Text>
92
+ ) : isValidElement(child) ? (
93
+ child
94
+ ) : null,
95
+ )}
40
96
  </View>
41
97
  );
42
98
  }
@@ -1,5 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
2
 
3
+ import { IconPlus } from '@tabler/icons-react-native';
4
+
3
5
  import { type ComponentMatrixCellProps, Story } from './_internal/Story';
4
6
  import { Button, type ButtonProps } from './Button';
5
7
 
@@ -23,6 +25,7 @@ const STATE_COLUMNS: {
23
25
  }[] = [
24
26
  { key: 'default', label: 'Default', props: {} },
25
27
  { key: 'disabled', label: 'Disabled', props: { disabled: true } },
28
+ { key: 'loading', label: 'Loading', props: { loading: true } },
26
29
  ];
27
30
 
28
31
  const STATE_PROPS = Object.fromEntries(
@@ -36,20 +39,93 @@ const VARIANT_ROWS: {
36
39
  { key: 'default', label: 'Default' },
37
40
  { key: 'secondary', label: 'Secondary' },
38
41
  { key: 'destructive', label: 'Destructive' },
42
+ { key: 'destructive-outline', label: 'Destructive outline' },
39
43
  { key: 'outline', label: 'Outline' },
40
44
  { key: 'ghost', label: 'Ghost' },
45
+ { key: 'link', label: 'Link' },
46
+ ];
47
+
48
+ const SIZE_COLUMNS: {
49
+ description: string;
50
+ icon: ButtonSize;
51
+ key: ButtonSize;
52
+ label: string;
53
+ }[] = [
54
+ { description: 'h-6', icon: 'icon-xs', key: 'xs', label: 'Extra small' },
55
+ { description: 'h-7', icon: 'icon-sm', key: 'sm', label: 'Small' },
56
+ { description: 'h-8', icon: 'icon', key: 'md', label: 'Medium' },
57
+ { description: 'h-9', icon: 'icon-lg', key: 'lg', label: 'Large' },
58
+ { description: 'h-10', icon: 'icon-xl', key: 'xl', label: 'Extra large' },
59
+ ];
60
+
61
+ const ICON_SIZES = Object.fromEntries(
62
+ SIZE_COLUMNS.map((column) => [column.key, column.icon]),
63
+ ) as Record<string, ButtonSize>;
64
+
65
+ // The web's `[&_svg]:size-*` per button size, in points.
66
+ const ICON_PIXELS: Record<string, number> = { xl: 18, xs: 14 };
67
+
68
+ const CONTENT_ROWS = [
69
+ { key: 'text', label: 'Text' },
70
+ { key: 'with-icon', label: 'Icon and text' },
71
+ {
72
+ description: 'Use an icon size with accessibilityLabel',
73
+ key: 'icon',
74
+ label: 'Icon only',
75
+ },
41
76
  ];
42
77
 
43
- const SIZE_COLUMNS: { description: string; key: ButtonSize; label: string }[] =
44
- [
45
- { description: 'h-8', key: 'sm', label: 'Small' },
46
- { description: 'h-10', key: 'md', label: 'Medium' },
47
- { description: 'h-11', key: 'lg', label: 'Large' },
48
- ];
78
+ // Icons don't inherit the label's color on native; pass it to the icon.
79
+ const ICON_COLORS: Record<ButtonVariant, string> = {
80
+ default: 'text-primary-foreground',
81
+ destructive: 'text-destructive-foreground',
82
+ 'destructive-outline': 'text-destructive-subtle-foreground',
83
+ ghost: 'text-foreground',
84
+ link: 'text-foreground',
85
+ outline: 'text-foreground',
86
+ secondary: 'text-secondary-foreground',
87
+ };
88
+
89
+ function ButtonContentCell({ column, row }: ComponentMatrixCellProps) {
90
+ const variant = column.key as ButtonVariant;
91
+ const icon = <Story.Icon className={ICON_COLORS[variant]} icon={IconPlus} />;
92
+
93
+ if (row.key === 'icon') {
94
+ return (
95
+ <Button accessibilityLabel="Add" size="icon" variant={variant}>
96
+ {icon}
97
+ </Button>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <Button variant={variant}>
103
+ {row.key === 'with-icon' ? icon : null}
104
+ Add
105
+ </Button>
106
+ );
107
+ }
49
108
 
50
109
  function ButtonSizeCell({ column, row }: ComponentMatrixCellProps) {
110
+ const icon = (
111
+ <Story.Icon
112
+ className={ICON_COLORS.default}
113
+ icon={IconPlus}
114
+ size={ICON_PIXELS[column.key]}
115
+ />
116
+ );
117
+
118
+ if (row.key === 'icon') {
119
+ return (
120
+ <Button accessibilityLabel="Add" size={ICON_SIZES[column.key]}>
121
+ {icon}
122
+ </Button>
123
+ );
124
+ }
125
+
51
126
  return (
52
- <Button size={column.key as ButtonSize} variant={row.key as ButtonVariant}>
127
+ <Button size={column.key as ButtonSize}>
128
+ {row.key === 'with-icon' ? icon : null}
53
129
  Add
54
130
  </Button>
55
131
  );
@@ -75,11 +151,23 @@ export const All: StoryDefinition = {
75
151
  />
76
152
  </Story.Section>
77
153
 
154
+ <Story.Section title="Content patterns">
155
+ <Story.Matrix
156
+ Cell={ButtonContentCell}
157
+ columns={[
158
+ { key: 'default', label: 'Default' },
159
+ { key: 'outline', label: 'Outline' },
160
+ { key: 'ghost', label: 'Ghost' },
161
+ ]}
162
+ rows={CONTENT_ROWS}
163
+ />
164
+ </Story.Section>
165
+
78
166
  <Story.Section title="Sizes">
79
167
  <Story.Matrix
80
168
  Cell={ButtonSizeCell}
81
169
  columns={SIZE_COLUMNS}
82
- rows={VARIANT_ROWS}
170
+ rows={CONTENT_ROWS}
83
171
  />
84
172
  </Story.Section>
85
173
  </Story.Layout>
@@ -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
  });