@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.
Files changed (47) hide show
  1. package/README.md +41 -29
  2. package/dist/components/Badge.js +1 -1
  3. package/dist/components/Badge.js.map +1 -1
  4. package/dist/components/Button.d.ts.map +1 -1
  5. package/dist/components/Button.js +4 -3
  6. package/dist/components/Button.js.map +1 -1
  7. package/dist/components/Card.d.ts.map +1 -1
  8. package/dist/components/Card.js +7 -4
  9. package/dist/components/Card.js.map +1 -1
  10. package/dist/components/Text.d.ts.map +1 -1
  11. package/dist/components/Text.js +5 -4
  12. package/dist/components/Text.js.map +1 -1
  13. package/dist/components/form/Checkbox.d.ts.map +1 -1
  14. package/dist/components/form/Checkbox.js +10 -8
  15. package/dist/components/form/Checkbox.js.map +1 -1
  16. package/dist/components/form/FormDescription.js +1 -1
  17. package/dist/components/form/FormDescription.js.map +1 -1
  18. package/dist/components/form/FormError.d.ts.map +1 -1
  19. package/dist/components/form/FormError.js +1 -1
  20. package/dist/components/form/FormError.js.map +1 -1
  21. package/dist/components/form/FormLabel.js +1 -1
  22. package/dist/components/form/FormLabel.js.map +1 -1
  23. package/dist/components/form/Input.js +1 -1
  24. package/dist/components/form/Input.js.map +1 -1
  25. package/dist/components/form/InputGroup.js +2 -2
  26. package/dist/components/form/InputGroup.js.map +1 -1
  27. package/dist/components/form/RadioGroup.js +1 -1
  28. package/dist/components/form/RadioGroup.js.map +1 -1
  29. package/dist/components/form/Textarea.js +1 -1
  30. package/dist/components/form/Textarea.js.map +1 -1
  31. package/package.json +3 -2
  32. package/src/base.css +23 -5
  33. package/src/components/Badge.tsx +1 -1
  34. package/src/components/Button.tsx +4 -3
  35. package/src/components/Card.tsx +12 -8
  36. package/src/components/Text.stories.tsx +49 -0
  37. package/src/components/Text.tsx +5 -4
  38. package/src/components/form/Checkbox.tsx +66 -40
  39. package/src/components/form/FormDescription.tsx +1 -1
  40. package/src/components/form/FormError.tsx +4 -1
  41. package/src/components/form/FormLabel.tsx +1 -1
  42. package/src/components/form/Input.tsx +1 -1
  43. package/src/components/form/InputGroup.tsx +2 -2
  44. package/src/components/form/RadioGroup.tsx +1 -1
  45. package/src/components/form/Textarea.tsx +1 -1
  46. package/src/fonts.test.tsx +244 -0
  47. 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. 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.
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
  ],
@@ -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 parts = Children.toArray(children);
49
- const actions = parts.filter(
50
- (child) => isValidElement(child) && child.type === CardAction,
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('text-card-foreground leading-none', className)}
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>
@@ -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
- const box = (
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
- classNames?.indicator,
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
- classNames?.indicator,
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('text-destructive text-sm font-medium', className)}
12
+ className={cn(
13
+ 'text-destructive font-sans text-sm font-medium',
14
+ className,
15
+ )}
13
16
  role="alert"
14
17
  {...props}
15
18
  />
@@ -8,7 +8,7 @@ export function FormLabel({ className, ...props }: FormLabelProps) {
8
8
  return (
9
9
  <Text
10
10
  className={cn(
11
- 'text-foreground text-sm leading-none font-medium',
11
+ 'text-foreground font-sans text-sm leading-none font-medium',
12
12
  className,
13
13
  )}
14
14
  {...props}
@@ -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
  />
@@ -100,7 +100,7 @@ function RadioGroupItem({
100
100
  {label ? (
101
101
  <Text
102
102
  className={cn(
103
- 'text-sm leading-none font-medium',
103
+ 'font-sans text-sm leading-none font-medium',
104
104
  disabled ? 'text-muted-foreground' : 'text-foreground',
105
105
  classNames?.label,
106
106
  )}
@@ -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
+ }