@pickle-ui/react-native 1.0.0-beta.2 → 1.0.0-beta.3
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 +41 -29
- package/dist/components/Badge.js +1 -1
- package/dist/components/Badge.js.map +1 -1
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Button.js +4 -3
- package/dist/components/Button.js.map +1 -1
- package/dist/components/Card.d.ts.map +1 -1
- package/dist/components/Card.js +7 -4
- package/dist/components/Card.js.map +1 -1
- package/dist/components/Text.d.ts.map +1 -1
- package/dist/components/Text.js +5 -4
- package/dist/components/Text.js.map +1 -1
- package/dist/components/form/Checkbox.d.ts.map +1 -1
- package/dist/components/form/Checkbox.js +10 -8
- package/dist/components/form/Checkbox.js.map +1 -1
- package/dist/components/form/FormDescription.js +1 -1
- package/dist/components/form/FormDescription.js.map +1 -1
- package/dist/components/form/FormError.d.ts.map +1 -1
- package/dist/components/form/FormError.js +1 -1
- package/dist/components/form/FormError.js.map +1 -1
- package/dist/components/form/FormLabel.js +1 -1
- package/dist/components/form/FormLabel.js.map +1 -1
- package/dist/components/form/Input.js +1 -1
- package/dist/components/form/Input.js.map +1 -1
- package/dist/components/form/InputGroup.js +2 -2
- package/dist/components/form/InputGroup.js.map +1 -1
- package/dist/components/form/RadioGroup.js +1 -1
- package/dist/components/form/RadioGroup.js.map +1 -1
- package/dist/components/form/Textarea.js +1 -1
- package/dist/components/form/Textarea.js.map +1 -1
- package/package.json +3 -2
- package/src/base.css +23 -5
- package/src/components/Badge.tsx +1 -1
- package/src/components/Button.tsx +4 -3
- package/src/components/Card.tsx +12 -8
- package/src/components/Text.stories.tsx +49 -0
- package/src/components/Text.tsx +5 -4
- package/src/components/form/Checkbox.tsx +66 -40
- package/src/components/form/FormDescription.tsx +1 -1
- package/src/components/form/FormError.tsx +4 -1
- package/src/components/form/FormLabel.tsx +1 -1
- package/src/components/form/Input.tsx +1 -1
- package/src/components/form/InputGroup.tsx +2 -2
- package/src/components/form/RadioGroup.tsx +1 -1
- package/src/components/form/Textarea.tsx +1 -1
- package/src/fonts.test.tsx +244 -0
- package/src/styles.css +12 -0
|
@@ -6,8 +6,9 @@ import { cn } from '../lib/cn';
|
|
|
6
6
|
import { Spinner } from './Spinner';
|
|
7
7
|
|
|
8
8
|
// Same variants, sizes and dimensions as `Button` in @pickle-ui/react on a
|
|
9
|
-
// phone, with its text sizes from `sm` up.
|
|
10
|
-
//
|
|
9
|
+
// phone, with its text sizes from `sm` up. `hitSlop` stretches every size to a
|
|
10
|
+
// 44pt touch target, like the web's `pointer-coarse` hit area. Paddings leave
|
|
11
|
+
// room for the 1pt border.
|
|
11
12
|
const buttonVariants = cva(
|
|
12
13
|
'flex-row items-center justify-center gap-2 rounded-lg border disabled:opacity-64',
|
|
13
14
|
{
|
|
@@ -42,7 +43,7 @@ const buttonVariants = cva(
|
|
|
42
43
|
},
|
|
43
44
|
);
|
|
44
45
|
|
|
45
|
-
const labelVariants = cva('font-medium', {
|
|
46
|
+
const labelVariants = cva('font-sans font-medium', {
|
|
46
47
|
compoundVariants: [
|
|
47
48
|
{ className: 'underline', pressed: true, variant: 'link' },
|
|
48
49
|
],
|
package/src/components/Card.tsx
CHANGED
|
@@ -45,10 +45,13 @@ function CardFooter({ className, ...props }: CardFooterProps) {
|
|
|
45
45
|
* like the web header's grid.
|
|
46
46
|
*/
|
|
47
47
|
function CardHeader({ children, className, ...props }: CardHeaderProps) {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const actions: React.ReactNode[] = [];
|
|
49
|
+
const rest: React.ReactNode[] = [];
|
|
50
|
+
for (const child of Children.toArray(children)) {
|
|
51
|
+
(isValidElement(child) && child.type === CardAction ? actions : rest).push(
|
|
52
|
+
child,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
52
55
|
|
|
53
56
|
if (actions.length === 0) {
|
|
54
57
|
return (
|
|
@@ -63,9 +66,7 @@ function CardHeader({ children, className, ...props }: CardHeaderProps) {
|
|
|
63
66
|
className={cn('flex-row items-start gap-4 px-6', className)}
|
|
64
67
|
{...props}
|
|
65
68
|
>
|
|
66
|
-
<View className="flex-1 gap-1.5">
|
|
67
|
-
{parts.filter((child) => !actions.includes(child))}
|
|
68
|
-
</View>
|
|
69
|
+
<View className="flex-1 gap-1.5">{rest}</View>
|
|
69
70
|
{actions}
|
|
70
71
|
</View>
|
|
71
72
|
);
|
|
@@ -86,7 +87,10 @@ function CardRoot({ className, ...props }: CardProps) {
|
|
|
86
87
|
function CardTitle({ className, ...props }: CardTitleProps) {
|
|
87
88
|
return (
|
|
88
89
|
<Text
|
|
89
|
-
className={cn(
|
|
90
|
+
className={cn(
|
|
91
|
+
'text-card-foreground font-heading leading-none',
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
90
94
|
variant="h3"
|
|
91
95
|
{...props}
|
|
92
96
|
/>
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
|
|
2
2
|
|
|
3
3
|
import { View } from 'react-native';
|
|
4
|
+
import { ScopedVariables } from 'uniwind';
|
|
4
5
|
|
|
5
6
|
import { type ComponentMatrixCellProps, Story } from './_internal/Story';
|
|
7
|
+
import { Badge } from './Badge';
|
|
8
|
+
import { Button } from './Button';
|
|
9
|
+
import { Card } from './Card';
|
|
10
|
+
import { Input } from './form/Input';
|
|
6
11
|
import { Text, type TextProps } from './Text';
|
|
7
12
|
|
|
8
13
|
const meta = {
|
|
@@ -38,11 +43,47 @@ const TONE_COLUMNS: { key: 'default' | TextTone; label: string }[] = [
|
|
|
38
43
|
{ key: 'destructive', label: 'Destructive' },
|
|
39
44
|
];
|
|
40
45
|
|
|
46
|
+
// An app sets `--font-sans` in its stylesheet; `ScopedVariables` sets it here
|
|
47
|
+
// so both fonts sit side by side. On the web, `--font-heading` resolves
|
|
48
|
+
// where it's declared, so the scope sets it too; native resolves it in place.
|
|
49
|
+
const FONT_COLUMNS = [
|
|
50
|
+
{ description: 'No --font-sans', key: '', label: 'System font' },
|
|
51
|
+
{
|
|
52
|
+
description: "--font-sans: 'Georgia'",
|
|
53
|
+
key: 'Georgia',
|
|
54
|
+
label: 'App font',
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
41
58
|
const WEIGHT_ROWS: { key: TextWeight; label: string }[] = [
|
|
42
59
|
{ key: 'normal', label: 'Normal' },
|
|
43
60
|
{ key: 'bold', label: 'Bold' },
|
|
44
61
|
];
|
|
45
62
|
|
|
63
|
+
function FontCell({ column }: ComponentMatrixCellProps) {
|
|
64
|
+
return (
|
|
65
|
+
<ScopedVariables
|
|
66
|
+
variables={
|
|
67
|
+
column.key
|
|
68
|
+
? { '--font-heading': column.key, '--font-sans': column.key }
|
|
69
|
+
: {}
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
<Card className="w-80">
|
|
73
|
+
<Card.Header>
|
|
74
|
+
<Card.Title>Weekly report</Card.Title>
|
|
75
|
+
<Card.Description>Every project, every Monday.</Card.Description>
|
|
76
|
+
</Card.Header>
|
|
77
|
+
<Card.Content className="items-start gap-3">
|
|
78
|
+
<Badge variant="success">Shipped</Badge>
|
|
79
|
+
<Input accessibilityLabel="Email" placeholder="ada@example.com" />
|
|
80
|
+
<Button>Subscribe</Button>
|
|
81
|
+
</Card.Content>
|
|
82
|
+
</Card>
|
|
83
|
+
</ScopedVariables>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
46
87
|
function ToneCell({ column, row }: ComponentMatrixCellProps) {
|
|
47
88
|
return (
|
|
48
89
|
<Text
|
|
@@ -98,6 +139,14 @@ export const All: StoryDefinition = {
|
|
|
98
139
|
/>
|
|
99
140
|
</Story.Section>
|
|
100
141
|
|
|
142
|
+
<Story.Section title="App font">
|
|
143
|
+
<Story.Matrix
|
|
144
|
+
Cell={FontCell}
|
|
145
|
+
columns={FONT_COLUMNS}
|
|
146
|
+
rows={[{ key: 'card', label: 'Components' }]}
|
|
147
|
+
/>
|
|
148
|
+
</Story.Section>
|
|
149
|
+
|
|
101
150
|
<Story.Section title="Truncate">
|
|
102
151
|
<View className="border-border w-64 rounded-md border p-3">
|
|
103
152
|
<Text truncate>
|
package/src/components/Text.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { cn } from '../lib/cn';
|
|
|
5
5
|
|
|
6
6
|
// Same variants, tones and weights as `Text` in @pickle-ui/react, sized by the
|
|
7
7
|
// shared type scale (`@pickle-ui/tokens/typography.css`).
|
|
8
|
-
const textVariants = cva('text-foreground', {
|
|
8
|
+
const textVariants = cva('text-foreground font-sans', {
|
|
9
9
|
defaultVariants: { variant: 'body' },
|
|
10
10
|
variants: {
|
|
11
11
|
tone: {
|
|
@@ -16,8 +16,8 @@ const textVariants = cva('text-foreground', {
|
|
|
16
16
|
variant: {
|
|
17
17
|
body: 'text-body',
|
|
18
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',
|
|
19
|
+
h1: 'font-heading text-h1 font-semibold tracking-tight',
|
|
20
|
+
h2: 'font-heading text-h2 font-semibold tracking-tight',
|
|
21
21
|
h3: 'text-h3 font-semibold',
|
|
22
22
|
h4: 'text-h4 font-medium',
|
|
23
23
|
lead: 'text-lead',
|
|
@@ -33,7 +33,8 @@ const textVariants = cva('text-foreground', {
|
|
|
33
33
|
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4']);
|
|
34
34
|
|
|
35
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.
|
|
36
|
+
// fonts don't resolve, so `code` names each platform's monospace font. The
|
|
37
|
+
// `style` prop wins over `font-sans` from `className`.
|
|
37
38
|
const styles = StyleSheet.create({
|
|
38
39
|
code: { fontFamily: Platform.select({ default: 'monospace', ios: 'Menlo' }) },
|
|
39
40
|
});
|
|
@@ -62,9 +62,72 @@ export function Checkbox({
|
|
|
62
62
|
defaultChecked,
|
|
63
63
|
onCheckedChange,
|
|
64
64
|
);
|
|
65
|
+
return (
|
|
66
|
+
// `role` and `aria-*` are React Native's current accessibility props;
|
|
67
|
+
// `accessibilityState` keeps any extra state the caller passes.
|
|
68
|
+
<Pressable
|
|
69
|
+
accessibilityState={{
|
|
70
|
+
...accessibilityState,
|
|
71
|
+
checked: indeterminate ? 'mixed' : checked,
|
|
72
|
+
disabled,
|
|
73
|
+
}}
|
|
74
|
+
aria-checked={indeterminate ? 'mixed' : checked}
|
|
75
|
+
aria-disabled={disabled}
|
|
76
|
+
className={
|
|
77
|
+
label
|
|
78
|
+
? cn('flex-row items-center gap-2', classNames?.field)
|
|
79
|
+
: 'self-start'
|
|
80
|
+
}
|
|
81
|
+
disabled={disabled}
|
|
82
|
+
hitSlop={label ? fieldHitSlop[size] : boxHitSlop[size]}
|
|
83
|
+
onPress={() => setChecked(!checked)}
|
|
84
|
+
role="checkbox"
|
|
85
|
+
{...props}
|
|
86
|
+
>
|
|
87
|
+
<CheckboxBox
|
|
88
|
+
checked={checked}
|
|
89
|
+
className={className}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
indeterminate={indeterminate}
|
|
92
|
+
indicatorClassName={classNames?.indicator}
|
|
93
|
+
invalid={invalid}
|
|
94
|
+
size={size}
|
|
95
|
+
/>
|
|
96
|
+
{label ? (
|
|
97
|
+
<Text
|
|
98
|
+
className={cn(
|
|
99
|
+
'text-foreground font-sans text-sm leading-none font-medium',
|
|
100
|
+
disabled && 'opacity-50',
|
|
101
|
+
classNames?.label,
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{label}
|
|
105
|
+
</Text>
|
|
106
|
+
) : null}
|
|
107
|
+
</Pressable>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function CheckboxBox({
|
|
112
|
+
checked,
|
|
113
|
+
className,
|
|
114
|
+
disabled,
|
|
115
|
+
indeterminate,
|
|
116
|
+
indicatorClassName,
|
|
117
|
+
invalid,
|
|
118
|
+
size,
|
|
119
|
+
}: {
|
|
120
|
+
checked: boolean;
|
|
121
|
+
className?: string;
|
|
122
|
+
disabled: boolean;
|
|
123
|
+
indeterminate: boolean;
|
|
124
|
+
indicatorClassName?: string;
|
|
125
|
+
invalid: boolean;
|
|
126
|
+
size: 'md' | 'sm';
|
|
127
|
+
}) {
|
|
65
128
|
const filled = checked && !indeterminate;
|
|
66
129
|
|
|
67
|
-
|
|
130
|
+
return (
|
|
68
131
|
<View
|
|
69
132
|
className={cn(
|
|
70
133
|
'items-center justify-center rounded-[4px] border shadow-xs shadow-black/5',
|
|
@@ -87,7 +150,7 @@ export function Checkbox({
|
|
|
87
150
|
className={cn(
|
|
88
151
|
'bg-foreground rounded-full',
|
|
89
152
|
minusClasses[size],
|
|
90
|
-
|
|
153
|
+
indicatorClassName,
|
|
91
154
|
)}
|
|
92
155
|
/>
|
|
93
156
|
) : checked ? (
|
|
@@ -95,47 +158,10 @@ export function Checkbox({
|
|
|
95
158
|
className={cn(
|
|
96
159
|
'border-primary-foreground -mt-px rotate-45',
|
|
97
160
|
checkClasses[size],
|
|
98
|
-
|
|
161
|
+
indicatorClassName,
|
|
99
162
|
)}
|
|
100
163
|
/>
|
|
101
164
|
) : null}
|
|
102
165
|
</View>
|
|
103
166
|
);
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
// `role` and `aria-*` are React Native's current accessibility props;
|
|
107
|
-
// `accessibilityState` keeps any extra state the caller passes.
|
|
108
|
-
<Pressable
|
|
109
|
-
accessibilityState={{
|
|
110
|
-
...accessibilityState,
|
|
111
|
-
checked: indeterminate ? 'mixed' : checked,
|
|
112
|
-
disabled,
|
|
113
|
-
}}
|
|
114
|
-
aria-checked={indeterminate ? 'mixed' : checked}
|
|
115
|
-
aria-disabled={disabled}
|
|
116
|
-
className={
|
|
117
|
-
label
|
|
118
|
-
? cn('flex-row items-center gap-2', classNames?.field)
|
|
119
|
-
: 'self-start'
|
|
120
|
-
}
|
|
121
|
-
disabled={disabled}
|
|
122
|
-
hitSlop={label ? fieldHitSlop[size] : boxHitSlop[size]}
|
|
123
|
-
onPress={() => setChecked(!checked)}
|
|
124
|
-
role="checkbox"
|
|
125
|
-
{...props}
|
|
126
|
-
>
|
|
127
|
-
{box}
|
|
128
|
-
{label ? (
|
|
129
|
-
<Text
|
|
130
|
-
className={cn(
|
|
131
|
-
'text-foreground text-sm leading-none font-medium',
|
|
132
|
-
disabled && 'opacity-50',
|
|
133
|
-
classNames?.label,
|
|
134
|
-
)}
|
|
135
|
-
>
|
|
136
|
-
{label}
|
|
137
|
-
</Text>
|
|
138
|
-
) : null}
|
|
139
|
-
</Pressable>
|
|
140
|
-
);
|
|
141
167
|
}
|
|
@@ -7,7 +7,7 @@ export type FormDescriptionProps = React.ComponentProps<typeof Text>;
|
|
|
7
7
|
export function FormDescription({ className, ...props }: FormDescriptionProps) {
|
|
8
8
|
return (
|
|
9
9
|
<Text
|
|
10
|
-
className={cn('text-muted-foreground text-sm', className)}
|
|
10
|
+
className={cn('text-muted-foreground font-sans text-sm', className)}
|
|
11
11
|
{...props}
|
|
12
12
|
/>
|
|
13
13
|
);
|
|
@@ -9,7 +9,10 @@ export function FormError({ className, ...props }: FormErrorProps) {
|
|
|
9
9
|
return (
|
|
10
10
|
<Text
|
|
11
11
|
accessibilityLiveRegion="polite"
|
|
12
|
-
className={cn(
|
|
12
|
+
className={cn(
|
|
13
|
+
'text-destructive font-sans text-sm font-medium',
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
13
16
|
role="alert"
|
|
14
17
|
{...props}
|
|
15
18
|
/>
|
|
@@ -47,7 +47,7 @@ export function Input({
|
|
|
47
47
|
<TextInput
|
|
48
48
|
accessibilityState={{ disabled }}
|
|
49
49
|
className={cn(
|
|
50
|
-
'text-foreground flex-1 text-[14px] outline-none',
|
|
50
|
+
'text-foreground flex-1 font-sans text-[14px] outline-none',
|
|
51
51
|
paddingClasses[size],
|
|
52
52
|
unstyled ? cn(heightClasses[size], disabled && 'opacity-64') : 'h-full',
|
|
53
53
|
unstyled ? className : classNames?.input,
|
|
@@ -86,7 +86,7 @@ function InputGroupAddon({
|
|
|
86
86
|
>
|
|
87
87
|
{Children.map(children, (child) =>
|
|
88
88
|
typeof child === 'string' || typeof child === 'number' ? (
|
|
89
|
-
<Text className="text-foreground text-[14px]">{child}</Text>
|
|
89
|
+
<Text className="text-foreground font-sans text-[14px]">{child}</Text>
|
|
90
90
|
) : (
|
|
91
91
|
child
|
|
92
92
|
),
|
|
@@ -204,7 +204,7 @@ function InputGroupRoot({ children, className, ...props }: InputGroupProps) {
|
|
|
204
204
|
function InputGroupText({ className, ...props }: InputGroupTextProps) {
|
|
205
205
|
return (
|
|
206
206
|
<Text
|
|
207
|
-
className={cn('text-muted-foreground text-[14px]', className)}
|
|
207
|
+
className={cn('text-muted-foreground font-sans text-[14px]', className)}
|
|
208
208
|
numberOfLines={1}
|
|
209
209
|
{...props}
|
|
210
210
|
/>
|
|
@@ -51,7 +51,7 @@ export function Textarea({
|
|
|
51
51
|
<TextInput
|
|
52
52
|
accessibilityState={{ disabled }}
|
|
53
53
|
className={cn(
|
|
54
|
-
'text-foreground web:field-sizing-content web:resize-none flex-1 py-2 text-sm outline-none',
|
|
54
|
+
'text-foreground web:field-sizing-content web:resize-none flex-1 py-2 font-sans text-sm outline-none',
|
|
55
55
|
sizeClasses[size],
|
|
56
56
|
classNames?.textarea,
|
|
57
57
|
)}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import {
|
|
3
|
+
render,
|
|
4
|
+
renderHook,
|
|
5
|
+
type RenderHookOptions,
|
|
6
|
+
screen,
|
|
7
|
+
} from '@testing-library/react-native';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { useForm } from 'react-hook-form';
|
|
12
|
+
import { type StyleProp, StyleSheet, type TextStyle } from 'react-native';
|
|
13
|
+
import { ScopedVariables, useResolveClassNames } from 'uniwind';
|
|
14
|
+
|
|
15
|
+
import { Badge } from './components/Badge';
|
|
16
|
+
import { Button } from './components/Button';
|
|
17
|
+
import { Card } from './components/Card';
|
|
18
|
+
import { Checkbox } from './components/form/Checkbox';
|
|
19
|
+
import { Form } from './components/form/Form';
|
|
20
|
+
import { Input } from './components/form/Input';
|
|
21
|
+
import { InputGroup } from './components/form/InputGroup';
|
|
22
|
+
import { RadioGroup } from './components/form/RadioGroup';
|
|
23
|
+
import { Textarea } from './components/form/Textarea';
|
|
24
|
+
import { Text } from './components/Text';
|
|
25
|
+
|
|
26
|
+
// Resolves the components' classes the way an app does: `styles.css`
|
|
27
|
+
// compiled by Uniwind for iOS, followed by the app's own CSS, then loaded
|
|
28
|
+
// into Uniwind's runtime.
|
|
29
|
+
|
|
30
|
+
type HostElement = { props: { className?: string; style?: TextStyle } };
|
|
31
|
+
|
|
32
|
+
/** The family the element renders with: its classes, then its `style`. */
|
|
33
|
+
async function fontFamily(
|
|
34
|
+
element: HostElement,
|
|
35
|
+
options?: RenderHookOptions<unknown>,
|
|
36
|
+
) {
|
|
37
|
+
const { result } = await renderHook(
|
|
38
|
+
() => useResolveClassNames(element.props.className ?? ''),
|
|
39
|
+
options,
|
|
40
|
+
);
|
|
41
|
+
// Uniwind's components render `style={[classNameStyle, props.style]}`.
|
|
42
|
+
const style: StyleProp<TextStyle> = [result.current, element.props.style];
|
|
43
|
+
return StyleSheet.flatten(style)?.fontFamily;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function FormFields() {
|
|
47
|
+
const form = useForm({ defaultValues: { email: '', name: '' } });
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Form form={form} onSubmit={() => {}}>
|
|
51
|
+
<Form.Field
|
|
52
|
+
description="Field description"
|
|
53
|
+
label="Field label"
|
|
54
|
+
name="name"
|
|
55
|
+
>
|
|
56
|
+
<Form.Input placeholder="Form input" />
|
|
57
|
+
</Form.Field>
|
|
58
|
+
<Form.Field error="Field error" name="email">
|
|
59
|
+
<Form.Input />
|
|
60
|
+
</Form.Field>
|
|
61
|
+
</Form>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Compiles `styles.css` and `css` as an app's `global.css` and loads them. */
|
|
66
|
+
function loadStyles(css: string) {
|
|
67
|
+
// Inside the package, so the compiled module's `require('uniwind')` resolves.
|
|
68
|
+
const cache = join(__dirname, '../node_modules/.cache');
|
|
69
|
+
mkdirSync(cache, { recursive: true });
|
|
70
|
+
const dir = mkdtempSync(join(cache, 'pickle-fonts-'));
|
|
71
|
+
const file = join(dir, 'global.css');
|
|
72
|
+
writeFileSync(file, `@import '${join(__dirname, 'styles.css')}';\n${css}`);
|
|
73
|
+
try {
|
|
74
|
+
const code = execFileSync(
|
|
75
|
+
process.execPath,
|
|
76
|
+
[join(__dirname, '../test/compile-uniwind.mjs'), file, 'ios'],
|
|
77
|
+
{ encoding: 'utf8' },
|
|
78
|
+
);
|
|
79
|
+
writeFileSync(join(dir, 'styles.js'), code);
|
|
80
|
+
jest.requireActual(join(dir, 'styles.js'));
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(dir, { force: true, recursive: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function renderComponents() {
|
|
87
|
+
await render(
|
|
88
|
+
<>
|
|
89
|
+
<Text>Body text</Text>
|
|
90
|
+
<Text variant="h1">Heading 1</Text>
|
|
91
|
+
<Text variant="h2">Heading 2</Text>
|
|
92
|
+
<Text variant="h3">Heading 3</Text>
|
|
93
|
+
<Text variant="code">pnpm test</Text>
|
|
94
|
+
<Card>
|
|
95
|
+
<Card.Header>
|
|
96
|
+
<Card.Title>Card title</Card.Title>
|
|
97
|
+
<Card.Description>Card description</Card.Description>
|
|
98
|
+
</Card.Header>
|
|
99
|
+
</Card>
|
|
100
|
+
<Badge>Badge label</Badge>
|
|
101
|
+
<Button>Button label</Button>
|
|
102
|
+
<Input placeholder="Input" />
|
|
103
|
+
<Textarea placeholder="Textarea" />
|
|
104
|
+
<InputGroup>
|
|
105
|
+
<InputGroup.Addon>@</InputGroup.Addon>
|
|
106
|
+
<InputGroup.Input placeholder="Group input" />
|
|
107
|
+
<InputGroup.Addon align="inline-end">
|
|
108
|
+
<InputGroup.Text>.com</InputGroup.Text>
|
|
109
|
+
</InputGroup.Addon>
|
|
110
|
+
</InputGroup>
|
|
111
|
+
<Checkbox label="Checkbox label" />
|
|
112
|
+
<RadioGroup>
|
|
113
|
+
<RadioGroup.Item label="Radio label" value="a" />
|
|
114
|
+
</RadioGroup>
|
|
115
|
+
<FormFields />
|
|
116
|
+
</>,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SANS_TEXT = [
|
|
121
|
+
'Body text',
|
|
122
|
+
'Heading 3',
|
|
123
|
+
'Card description',
|
|
124
|
+
'Badge label',
|
|
125
|
+
'Button label',
|
|
126
|
+
'@',
|
|
127
|
+
'.com',
|
|
128
|
+
'Checkbox label',
|
|
129
|
+
'Radio label',
|
|
130
|
+
'Field label',
|
|
131
|
+
'Field description',
|
|
132
|
+
'Field error',
|
|
133
|
+
];
|
|
134
|
+
const HEADING_TEXT = ['Heading 1', 'Heading 2', 'Card title'];
|
|
135
|
+
const FIELDS = ['Input', 'Textarea', 'Group input', 'Form input'];
|
|
136
|
+
|
|
137
|
+
function elements(names: string[]) {
|
|
138
|
+
return names.map((name) => ({
|
|
139
|
+
element: FIELDS.includes(name)
|
|
140
|
+
? screen.getByPlaceholderText(name)
|
|
141
|
+
: screen.getByText(name),
|
|
142
|
+
name,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function every(names: string[], value: unknown) {
|
|
147
|
+
return Object.fromEntries(names.map((name) => [name, value]));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Finds every element first: `renderHook` replaces what `screen` queries. */
|
|
151
|
+
async function fontFamilies(
|
|
152
|
+
names: string[],
|
|
153
|
+
options?: RenderHookOptions<unknown>,
|
|
154
|
+
) {
|
|
155
|
+
const families: Record<string, unknown> = {};
|
|
156
|
+
for (const { element, name } of elements(names)) {
|
|
157
|
+
families[name] = await fontFamily(element, options);
|
|
158
|
+
}
|
|
159
|
+
return families;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ALL = [...SANS_TEXT, ...HEADING_TEXT, ...FIELDS];
|
|
163
|
+
|
|
164
|
+
describe('fonts', () => {
|
|
165
|
+
describe('when the app sets no font', () => {
|
|
166
|
+
beforeAll(() => loadStyles(''), 60_000);
|
|
167
|
+
|
|
168
|
+
it('sets no family, so text keeps the system font', async () => {
|
|
169
|
+
await renderComponents();
|
|
170
|
+
|
|
171
|
+
expect(await fontFamilies(ALL)).toEqual(every(ALL, undefined));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('keeps the monospace font for code', async () => {
|
|
175
|
+
await renderComponents();
|
|
176
|
+
|
|
177
|
+
expect(await fontFamily(screen.getByText('pnpm test'))).toBe('Menlo');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('when the app sets --font-sans', () => {
|
|
182
|
+
beforeAll(() => loadStyles("@theme { --font-sans: 'Nunito'; }"), 60_000);
|
|
183
|
+
|
|
184
|
+
it('applies it to every component, headings included', async () => {
|
|
185
|
+
await renderComponents();
|
|
186
|
+
|
|
187
|
+
expect(await fontFamilies(ALL)).toEqual(every(ALL, 'Nunito'));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('keeps the monospace font for code', async () => {
|
|
191
|
+
await renderComponents();
|
|
192
|
+
|
|
193
|
+
expect(await fontFamily(screen.getByText('pnpm test'))).toBe('Menlo');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('when the app sets --font-sans and --font-heading', () => {
|
|
198
|
+
beforeAll(
|
|
199
|
+
() =>
|
|
200
|
+
loadStyles("@theme { --font-sans: 'Nunito'; --font-heading: 'Lora'; }"),
|
|
201
|
+
60_000,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
it('applies --font-heading to h1, h2 and Card.Title', async () => {
|
|
205
|
+
await renderComponents();
|
|
206
|
+
|
|
207
|
+
expect(await fontFamilies(ALL)).toEqual({
|
|
208
|
+
...every([...SANS_TEXT, ...FIELDS], 'Nunito'),
|
|
209
|
+
...every(HEADING_TEXT, 'Lora'),
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('when the app sets --font-sans in a :root rule', () => {
|
|
215
|
+
beforeAll(
|
|
216
|
+
() => loadStyles("@layer theme { :root { --font-sans: 'Nunito'; } }"),
|
|
217
|
+
60_000,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
it('applies it', async () => {
|
|
221
|
+
await renderComponents();
|
|
222
|
+
|
|
223
|
+
expect(await fontFamilies(ALL)).toEqual(every(ALL, 'Nunito'));
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('when the app sets --font-sans at runtime', () => {
|
|
228
|
+
beforeAll(() => loadStyles(''), 60_000);
|
|
229
|
+
|
|
230
|
+
it('applies it inside ScopedVariables, headings included', async () => {
|
|
231
|
+
await renderComponents();
|
|
232
|
+
|
|
233
|
+
expect(
|
|
234
|
+
await fontFamilies(ALL, {
|
|
235
|
+
wrapper: ({ children }) => (
|
|
236
|
+
<ScopedVariables variables={{ '--font-sans': 'Nunito' }}>
|
|
237
|
+
{children}
|
|
238
|
+
</ScopedVariables>
|
|
239
|
+
),
|
|
240
|
+
}),
|
|
241
|
+
).toEqual(every(ALL, 'Nunito'));
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
package/src/styles.css
CHANGED
|
@@ -10,3 +10,15 @@
|
|
|
10
10
|
@import 'uniwind';
|
|
11
11
|
@import '@pickle-ui/tokens/native.css';
|
|
12
12
|
@import './base.css';
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* The tokens' `--font-*` variables read the app's own on the web, where an
|
|
16
|
+
* unset one falls back to the inherited font. Uniwind resolves them to
|
|
17
|
+
* themselves and overflows the stack, so take the native defaults from
|
|
18
|
+
* `base.css` again. Declare your own `--font-*` after importing this file.
|
|
19
|
+
*/
|
|
20
|
+
@theme {
|
|
21
|
+
--font-sans: var(--pickle-no-font);
|
|
22
|
+
--font-heading: var(--font-sans);
|
|
23
|
+
--font-mono: var(--pickle-no-font);
|
|
24
|
+
}
|