@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
@@ -1,70 +1,208 @@
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 './_internal/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. 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
+ size: {
53
+ icon: 'text-base',
54
+ 'icon-lg': 'text-base',
55
+ 'icon-sm': 'text-base',
56
+ 'icon-xl': 'text-base',
57
+ 'icon-xs': 'text-base',
58
+ lg: 'text-base',
59
+ md: 'text-base',
60
+ sm: 'text-base',
61
+ xl: 'text-lg',
62
+ xs: 'text-sm',
63
+ },
32
64
  variant: {
33
65
  default: 'text-primary-foreground',
34
66
  destructive: 'text-destructive-foreground',
67
+ 'destructive-outline': 'text-destructive-subtle-foreground',
35
68
  ghost: 'text-foreground',
69
+ link: 'text-foreground',
36
70
  outline: 'text-foreground',
37
71
  secondary: 'text-secondary-foreground',
38
72
  },
39
73
  },
40
74
  });
41
75
 
76
+ const spinnerVariants = cva('', {
77
+ variants: {
78
+ variant: {
79
+ default: 'border-primary-foreground',
80
+ destructive: 'border-destructive-foreground',
81
+ 'destructive-outline': 'border-foreground',
82
+ ghost: 'border-foreground',
83
+ link: 'border-foreground',
84
+ outline: 'border-foreground',
85
+ secondary: 'border-secondary-foreground',
86
+ },
87
+ },
88
+ });
89
+
90
+ // The web's 1px inner highlight on solid buttons. Uniwind draws
91
+ // `inset-shadow` outside the view, so a top border on an overlay stands in.
92
+ const highlightVariants = cva('absolute inset-0 rounded-lg border-t', {
93
+ variants: {
94
+ pressed: { false: 'border-white/16', true: 'border-black/8' },
95
+ size: {
96
+ icon: '',
97
+ 'icon-lg': '',
98
+ 'icon-sm': '',
99
+ 'icon-xl': '',
100
+ 'icon-xs': 'rounded-md',
101
+ lg: '',
102
+ md: '',
103
+ sm: '',
104
+ xl: '',
105
+ xs: 'rounded-md',
106
+ },
107
+ },
108
+ });
109
+
110
+ type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>['size']>;
111
+
112
+ // Half the gap between each size and a 44pt touch target, vertical only for
113
+ // buttons with a label, whose width already reaches it.
114
+ const HIT_SLOP: Record<ButtonSize, Insets | number> = {
115
+ icon: 6,
116
+ 'icon-lg': 4,
117
+ 'icon-sm': 8,
118
+ 'icon-xl': 2,
119
+ 'icon-xs': 10,
120
+ lg: { bottom: 4, top: 4 },
121
+ md: { bottom: 6, top: 6 },
122
+ sm: { bottom: 8, top: 8 },
123
+ xl: { bottom: 2, top: 2 },
124
+ xs: { bottom: 10, top: 10 },
125
+ };
126
+
42
127
  export type ButtonProps = Omit<
43
128
  React.ComponentProps<typeof Pressable>,
44
129
  'children'
45
130
  > &
46
131
  VariantProps<typeof buttonVariants> & {
47
- children: string;
132
+ /**
133
+ * Text is wrapped in the label; other children, such as icons, render as
134
+ * they are. Icon-only buttons need an `icon*` size and an
135
+ * `accessibilityLabel`.
136
+ */
137
+ children: React.ReactNode;
48
138
  labelClassName?: string;
139
+ /** Shows a spinner in place of the content and disables the button. */
140
+ loading?: boolean;
49
141
  };
50
142
 
51
143
  export function Button({
144
+ accessibilityState,
52
145
  children,
53
146
  className,
147
+ disabled,
54
148
  labelClassName,
55
- size,
56
- variant,
149
+ loading = false,
150
+ size = 'md',
151
+ variant = 'default',
57
152
  ...props
58
153
  }: ButtonProps) {
154
+ const isDisabled = Boolean(disabled || loading);
155
+ const isSolid = variant === 'default' || variant === 'destructive';
156
+
59
157
  return (
60
158
  <Pressable
61
159
  accessibilityRole="button"
160
+ accessibilityState={{
161
+ ...accessibilityState,
162
+ busy: loading,
163
+ disabled: isDisabled,
164
+ }}
62
165
  className={cn(buttonVariants({ size, variant }), className)}
166
+ disabled={isDisabled}
167
+ hitSlop={size ? HIT_SLOP[size] : undefined}
63
168
  {...props}
64
169
  >
65
- <Text className={cn(labelVariants({ size, variant }), labelClassName)}>
66
- {children}
67
- </Text>
170
+ {({ pressed }) => (
171
+ <>
172
+ {isSolid && !isDisabled ? (
173
+ <View
174
+ className={highlightVariants({ pressed, size })}
175
+ pointerEvents="none"
176
+ />
177
+ ) : null}
178
+ {/* While loading, the content stays in place, invisible, so the
179
+ button keeps its width under the spinner. */}
180
+ {Children.map(children, (child) =>
181
+ typeof child === 'string' || typeof child === 'number' ? (
182
+ <Text
183
+ className={cn(
184
+ labelVariants({ pressed, size, variant }),
185
+ loading && 'opacity-0',
186
+ labelClassName,
187
+ )}
188
+ >
189
+ {child}
190
+ </Text>
191
+ ) : isValidElement(child) ? (
192
+ loading ? (
193
+ <View className="opacity-0">{child}</View>
194
+ ) : (
195
+ child
196
+ )
197
+ ) : null,
198
+ )}
199
+ {loading ? (
200
+ <View className="absolute">
201
+ <Spinner className={spinnerVariants({ variant })} />
202
+ </View>
203
+ ) : null}
204
+ </>
205
+ )}
68
206
  </Pressable>
69
207
  );
70
208
  }
@@ -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
+ };
@@ -1,5 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
2
2
 
3
+ import { View } from 'react-native';
4
+
3
5
  import { type ComponentMatrixCellProps, Story } from './_internal/Story';
4
6
  import { Text, type TextProps } from './Text';
5
7
 
@@ -16,6 +18,7 @@ type StoryDefinition = StoryObj<typeof meta>;
16
18
 
17
19
  type TextTone = NonNullable<TextProps['tone']>;
18
20
  type TextVariant = NonNullable<TextProps['variant']>;
21
+ type TextWeight = NonNullable<TextProps['weight']>;
19
22
 
20
23
  const VARIANT_ROWS: { key: TextVariant; label: string }[] = [
21
24
  { key: 'h1', label: 'H1' },
@@ -25,6 +28,7 @@ const VARIANT_ROWS: { key: TextVariant; label: string }[] = [
25
28
  { key: 'lead', label: 'Lead' },
26
29
  { key: 'body', label: 'Body' },
27
30
  { key: 'small', label: 'Small' },
31
+ { key: 'code', label: 'Code' },
28
32
  ];
29
33
 
30
34
  const TONE_COLUMNS: { key: 'default' | TextTone; label: string }[] = [
@@ -34,6 +38,11 @@ const TONE_COLUMNS: { key: 'default' | TextTone; label: string }[] = [
34
38
  { key: 'destructive', label: 'Destructive' },
35
39
  ];
36
40
 
41
+ const WEIGHT_ROWS: { key: TextWeight; label: string }[] = [
42
+ { key: 'normal', label: 'Normal' },
43
+ { key: 'bold', label: 'Bold' },
44
+ ];
45
+
37
46
  function ToneCell({ column, row }: ComponentMatrixCellProps) {
38
47
  return (
39
48
  <Text
@@ -45,16 +54,57 @@ function ToneCell({ column, row }: ComponentMatrixCellProps) {
45
54
  );
46
55
  }
47
56
 
57
+ function WeightCell({ row }: ComponentMatrixCellProps) {
58
+ return (
59
+ <Text variant="body" weight={row.key as TextWeight}>
60
+ {row.label} weight
61
+ </Text>
62
+ );
63
+ }
64
+
48
65
  export const All: StoryDefinition = {
49
66
  render: () => (
50
67
  <Story.Layout className="max-w-6xl" title="Text">
51
- <Story.Section title="Variants and tones">
68
+ <Story.Section title="Variant scale">
69
+ <View className="gap-4">
70
+ {VARIANT_ROWS.map(({ key, label }) => (
71
+ <View className="flex-row items-baseline gap-4" key={key}>
72
+ <Text className="w-12" tone="muted" variant="small">
73
+ {label}
74
+ </Text>
75
+ <Text className="flex-1" variant={key}>
76
+ {key === 'code'
77
+ ? 'npx expo install @pickle-ui/react-native'
78
+ : 'The quick brown fox jumps over the lazy dog.'}
79
+ </Text>
80
+ </View>
81
+ ))}
82
+ </View>
83
+ </Story.Section>
84
+
85
+ <Story.Section title="Tone (color axis)">
52
86
  <Story.Matrix
53
87
  Cell={ToneCell}
54
88
  columns={TONE_COLUMNS}
55
- rows={VARIANT_ROWS}
89
+ rows={VARIANT_ROWS.slice(0, 4)}
90
+ />
91
+ </Story.Section>
92
+
93
+ <Story.Section title="Weight">
94
+ <Story.Matrix
95
+ Cell={WeightCell}
96
+ columns={[{ key: 'body', label: 'Body' }]}
97
+ rows={WEIGHT_ROWS}
56
98
  />
57
99
  </Story.Section>
100
+
101
+ <Story.Section title="Truncate">
102
+ <View className="border-border w-64 rounded-md border p-3">
103
+ <Text truncate>
104
+ A project name long enough to run past the edge of its container
105
+ </Text>
106
+ </View>
107
+ </Story.Section>
58
108
  </Story.Layout>
59
109
  ),
60
110
  };
@@ -4,12 +4,44 @@ import { Text } from './Text';
4
4
 
5
5
  describe('Text', () => {
6
6
  it('renders its children', async () => {
7
+ await render(<Text tone="muted">Hello</Text>);
8
+
9
+ expect(screen.getByText('Hello')).toBeOnTheScreen();
10
+ });
11
+
12
+ it('announces heading variants as headers', async () => {
13
+ await render(
14
+ <>
15
+ <Text variant="h2">Projects</Text>
16
+ <Text variant="lead">Everything in flight</Text>
17
+ </>,
18
+ );
19
+
20
+ expect(screen.getByRole('header', { name: 'Projects' })).toBeOnTheScreen();
21
+ expect(
22
+ screen.queryByRole('header', { name: 'Everything in flight' }),
23
+ ).toBeNull();
24
+ });
25
+
26
+ it('cuts truncated text to one line', async () => {
27
+ await render(<Text truncate>A long project name</Text>);
28
+
29
+ expect(screen.getByText('A long project name')).toHaveProp(
30
+ 'numberOfLines',
31
+ 1,
32
+ );
33
+ });
34
+
35
+ it('sets the code variant in a monospace font', async () => {
7
36
  await render(
8
- <Text tone="muted" variant="h1">
9
- Hello
37
+ <Text style={{ color: 'red' }} variant="code">
38
+ pnpm test
10
39
  </Text>,
11
40
  );
12
41
 
13
- expect(screen.getByText('Hello')).toBeOnTheScreen();
42
+ expect(screen.getByText('pnpm test')).toHaveStyle({
43
+ color: 'red',
44
+ fontFamily: expect.any(String),
45
+ });
14
46
  });
15
47
  });
@@ -1,9 +1,10 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
- import { Text as NativeText } from 'react-native';
2
+ import { Text as NativeText, Platform, StyleSheet } from 'react-native';
3
3
 
4
4
  import { cn } from '../lib/cn';
5
5
 
6
- // Same variant and tone names as `Text` in @pickle-ui/react.
6
+ // Same variants, tones and weights as `Text` in @pickle-ui/react, sized by the
7
+ // shared type scale (`@pickle-ui/tokens/typography.css`).
7
8
  const textVariants = cva('text-foreground', {
8
9
  defaultVariants: { variant: 'body' },
9
10
  variants: {
@@ -13,24 +14,53 @@ const textVariants = cva('text-foreground', {
13
14
  primary: 'text-primary',
14
15
  },
15
16
  variant: {
16
- body: 'text-sm',
17
- h1: 'text-3xl font-semibold tracking-tight',
18
- h2: 'text-2xl font-semibold tracking-tight',
19
- h3: 'text-xl font-semibold',
20
- h4: 'text-lg font-medium',
21
- lead: 'text-base',
22
- small: 'text-xs',
17
+ body: 'text-body',
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',
21
+ h3: 'text-h3 font-semibold',
22
+ h4: 'text-h4 font-medium',
23
+ lead: 'text-lead',
24
+ small: 'text-small',
25
+ },
26
+ weight: {
27
+ bold: 'font-semibold',
28
+ normal: 'font-normal',
23
29
  },
24
30
  },
25
31
  });
26
32
 
33
+ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4']);
34
+
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.
37
+ const styles = StyleSheet.create({
38
+ code: { fontFamily: Platform.select({ default: 'monospace', ios: 'Menlo' }) },
39
+ });
40
+
27
41
  export type TextProps = React.ComponentProps<typeof NativeText> &
28
- VariantProps<typeof textVariants>;
42
+ VariantProps<typeof textVariants> & {
43
+ /** Cuts the text to one line with an ellipsis. */
44
+ truncate?: boolean;
45
+ };
29
46
 
30
- export function Text({ className, tone, variant, ...props }: TextProps) {
47
+ export function Text({
48
+ className,
49
+ style,
50
+ tone,
51
+ truncate,
52
+ variant = 'body',
53
+ weight,
54
+ ...props
55
+ }: TextProps) {
31
56
  return (
32
57
  <NativeText
33
- className={cn(textVariants({ tone, variant }), className)}
58
+ accessibilityRole={
59
+ variant && HEADINGS.has(variant) ? 'header' : undefined
60
+ }
61
+ className={cn(textVariants({ tone, variant, weight }), className)}
62
+ numberOfLines={truncate ? 1 : undefined}
63
+ style={variant === 'code' ? [styles.code, style] : style}
34
64
  {...props}
35
65
  />
36
66
  );