@pickle-ui/react-native 1.0.0-beta.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
|
@@ -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"}
|
package/dist/entries/card.d.ts
CHANGED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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
package/dist/index.d.ts.map
CHANGED
|
@@ -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
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"}
|
package/dist/lib/cn.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cn.d.ts","sourceRoot":"","sources":["../../src/lib/cn.ts"],"names":[],"mappings":"
|
|
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 {
|
|
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(' '));
|
package/dist/lib/cn.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cn.js","sourceRoot":"","sources":["../../src/lib/cn.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
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": "1.0.0-beta.
|
|
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.
|
|
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
|
|
4
|
-
*
|
|
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 = [
|
|
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: '
|
|
38
|
+
{ key: 'failed', label: 'Failed' },
|
|
39
|
+
{ key: 'expired', label: 'Expired' },
|
|
23
40
|
];
|
|
24
41
|
|
|
25
|
-
|
|
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
|
|
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={
|
|
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
|
});
|
package/src/components/Badge.tsx
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
const badgeVariants = cva(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
variant:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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('
|
|
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> & {
|
|
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({
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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}
|
|
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={
|
|
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
|
});
|