@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.
- package/README.md +44 -11
- package/dist/components/Badge.d.ts +6 -3
- package/dist/components/Badge.d.ts.map +1 -1
- package/dist/components/Badge.js +36 -7
- package/dist/components/Badge.js.map +1 -1
- package/dist/components/Button.d.ts +11 -4
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Button.js +97 -15
- package/dist/components/Button.js.map +1 -1
- package/dist/components/Card.d.ts +15 -5
- package/dist/components/Card.d.ts.map +1 -1
- package/dist/components/Card.js +28 -10
- package/dist/components/Card.js.map +1 -1
- package/dist/components/Text.d.ts +7 -3
- package/dist/components/Text.d.ts.map +1 -1
- package/dist/components/Text.js +23 -11
- package/dist/components/Text.js.map +1 -1
- package/dist/components/_internal/Spinner.d.ts +9 -0
- package/dist/components/_internal/Spinner.d.ts.map +1 -0
- package/dist/components/_internal/Spinner.js +31 -0
- package/dist/components/_internal/Spinner.js.map +1 -0
- package/dist/entries/card.d.ts +1 -1
- package/dist/entries/card.d.ts.map +1 -1
- package/dist/entries/theme.d.ts +3 -0
- package/dist/entries/theme.d.ts.map +1 -0
- package/dist/entries/theme.js +2 -0
- package/dist/entries/theme.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.d.ts.map +1 -1
- package/dist/lib/cn.js +13 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +5 -2
- package/src/base.css +7 -2
- package/src/components/Badge.stories.tsx +51 -6
- package/src/components/Badge.test.tsx +15 -0
- package/src/components/Badge.tsx +73 -17
- package/src/components/Button.stories.tsx +96 -8
- package/src/components/Button.test.tsx +69 -0
- package/src/components/Button.tsx +156 -18
- package/src/components/Card.stories.tsx +17 -4
- package/src/components/Card.test.tsx +24 -1
- package/src/components/Card.tsx +71 -13
- package/src/components/Text.stories.tsx +52 -2
- package/src/components/Text.test.tsx +35 -3
- package/src/components/Text.tsx +42 -12
- package/src/components/_internal/Spinner.tsx +49 -0
- package/src/components/_internal/Story.tsx +34 -0
- package/src/entries/card.ts +2 -0
- package/src/entries/theme.ts +7 -0
- package/src/index.ts +1 -0
- package/src/lib/cn.test.ts +6 -0
- package/src/lib/cn.ts +14 -1
|
@@ -1,70 +1,208 @@
|
|
|
1
1
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
sm: '
|
|
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:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
60
|
+
<Card.Footer>
|
|
48
61
|
<Button>Continue</Button>
|
|
49
|
-
</Card.
|
|
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.
|
|
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
|
});
|
package/src/components/Card.tsx
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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('
|
|
23
|
+
return <View className={cn('px-6', className)} {...props} />;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
function CardDescription({ className, ...props }: CardDescriptionProps) {
|
|
17
|
-
return
|
|
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
|
|
21
|
-
return
|
|
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-
|
|
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="
|
|
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 {
|
|
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="
|
|
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
|
|
9
|
-
|
|
37
|
+
<Text style={{ color: 'red' }} variant="code">
|
|
38
|
+
pnpm test
|
|
10
39
|
</Text>,
|
|
11
40
|
);
|
|
12
41
|
|
|
13
|
-
expect(screen.getByText('
|
|
42
|
+
expect(screen.getByText('pnpm test')).toHaveStyle({
|
|
43
|
+
color: 'red',
|
|
44
|
+
fontFamily: expect.any(String),
|
|
45
|
+
});
|
|
14
46
|
});
|
|
15
47
|
});
|
package/src/components/Text.tsx
CHANGED
|
@@ -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
|
|
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-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
);
|