@boostdev/design-system-react-native 0.5.1 → 0.7.0
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/CHANGELOG.md +44 -0
- package/README.md +35 -1
- package/dist/index.cjs +40 -27
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +40 -27
- package/package.json +1 -1
- package/src/components/interaction/Button/Button.mdx +13 -3
- package/src/components/interaction/Button/Button.spec.tsx +88 -2
- package/src/components/interaction/Button/Button.stories.tsx +14 -4
- package/src/components/interaction/Button/Button.tsx +9 -8
- package/src/components/interaction/form/Combobox/Combobox.spec.tsx +14 -1
- package/src/components/interaction/form/Combobox/Combobox.tsx +4 -3
- package/src/components/interaction/form/FileInput/FileInput.spec.tsx +13 -1
- package/src/components/interaction/form/FileInput/FileInput.tsx +3 -3
- package/src/components/interaction/form/FormInput/FormInput.spec.tsx +14 -1
- package/src/components/interaction/form/FormInput/FormInput.tsx +5 -4
- package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +14 -1
- package/src/components/interaction/form/NumberInput/NumberInput.tsx +7 -6
- package/src/components/interaction/form/Select/Select.mdx +1 -1
- package/src/components/interaction/form/Select/Select.spec.tsx +14 -1
- package/src/components/interaction/form/Select/Select.tsx +2 -2
- package/src/components/interaction/form/Textarea/Textarea.spec.tsx +13 -1
- package/src/components/interaction/form/Textarea/Textarea.tsx +3 -3
- package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +3 -3
- package/src/index.ts +1 -1
- package/src/native/ThemeContext.spec.tsx +81 -1
- package/src/native/ThemeContext.tsx +32 -3
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
3
|
+
import type { ThemeRadius } from '../../../../native/ThemeContext';
|
|
3
4
|
import { FormInput } from './FormInput';
|
|
4
5
|
import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
|
|
5
6
|
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
6
7
|
|
|
8
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
9
|
+
|
|
7
10
|
function renderWithTheme(ui: React.ReactElement) {
|
|
8
11
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
9
12
|
}
|
|
@@ -100,12 +103,22 @@ describe('FormInput (native)', () => {
|
|
|
100
103
|
expect(getComputedStyle(wrapper).minHeight).toBe('44px');
|
|
101
104
|
});
|
|
102
105
|
|
|
103
|
-
it('gives the bordered field box the medium border radius', () => {
|
|
106
|
+
it('gives the bordered field box the medium border radius by default', () => {
|
|
104
107
|
renderWithTheme(<FormInput label="Email" name="email" />);
|
|
105
108
|
const wrapper = screen.getByRole('textbox').parentElement as HTMLElement;
|
|
106
109
|
expect(cornerRadii(wrapper)).toEqual(['16px', '16px', '16px', '16px']);
|
|
107
110
|
});
|
|
108
111
|
|
|
112
|
+
it('takes the border radius of the field box from the theme', () => {
|
|
113
|
+
render(
|
|
114
|
+
<ThemeProvider tokens={{ radius: radius12 }}>
|
|
115
|
+
<FormInput label="Email" name="email" />
|
|
116
|
+
</ThemeProvider>,
|
|
117
|
+
);
|
|
118
|
+
const wrapper = screen.getByRole('textbox').parentElement as HTMLElement;
|
|
119
|
+
expect(cornerRadii(wrapper)).toEqual(['12px', '12px', '12px', '12px']);
|
|
120
|
+
});
|
|
121
|
+
|
|
109
122
|
/**
|
|
110
123
|
* Optical-centering invariant: the TextInput must have a translateY transform equal
|
|
111
124
|
* to OPTICAL_TEXT_SHIFT to compensate for Arial's asymmetric vertical metrics.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ReactNode, useState } from 'react';
|
|
2
2
|
import { StyleSheet, View, KeyboardTypeOptions, StyleProp, ViewStyle } from 'react-native';
|
|
3
3
|
import { ScaledTextInput as TextInput } from '../../../../native/ScaledText';
|
|
4
|
-
import { spacing, font
|
|
4
|
+
import { spacing, font } from '../../../../native/tokens';
|
|
5
5
|
import { useTheme } from '../../../../native/ThemeContext';
|
|
6
6
|
import { Label } from '../atoms/Label';
|
|
7
7
|
import { Message } from '../atoms/Message';
|
|
@@ -43,14 +43,14 @@ const styles = StyleSheet.create({
|
|
|
43
43
|
gap: spacing.xxs,
|
|
44
44
|
},
|
|
45
45
|
/**
|
|
46
|
-
* The wrapper draws the rounded border
|
|
46
|
+
* The wrapper draws the rounded border (`radius.field` from the theme) and provides
|
|
47
|
+
* at least 44pt touch-target height.
|
|
47
48
|
* justifyContent: 'center' vertically centers the inner TextInput on both iOS and Android.
|
|
48
49
|
*/
|
|
49
50
|
inputWrapper: {
|
|
50
51
|
minHeight: INPUT_HEIGHT,
|
|
51
52
|
justifyContent: 'center',
|
|
52
53
|
borderWidth: 1,
|
|
53
|
-
borderRadius: border.radius.m,
|
|
54
54
|
},
|
|
55
55
|
/**
|
|
56
56
|
* No vertical padding: iOS centers text automatically inside the wrapper height.
|
|
@@ -84,7 +84,7 @@ export function FormInput({
|
|
|
84
84
|
onFocus,
|
|
85
85
|
style,
|
|
86
86
|
}: Readonly<FormInputProps>) {
|
|
87
|
-
const { colors } = useTheme();
|
|
87
|
+
const { colors, radius } = useTheme();
|
|
88
88
|
const [focused, setFocused] = useState(false);
|
|
89
89
|
|
|
90
90
|
const borderColor = error
|
|
@@ -101,6 +101,7 @@ export function FormInput({
|
|
|
101
101
|
styles.inputWrapper,
|
|
102
102
|
{
|
|
103
103
|
borderColor,
|
|
104
|
+
borderRadius: radius.field,
|
|
104
105
|
backgroundColor: colors.colorBg,
|
|
105
106
|
},
|
|
106
107
|
disabled && { opacity: 0.4 },
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
3
|
+
import type { ThemeRadius } from '../../../../native/ThemeContext';
|
|
3
4
|
import { NumberInput } from './NumberInput';
|
|
4
5
|
import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
|
|
5
6
|
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
6
7
|
|
|
8
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
9
|
+
|
|
7
10
|
function renderWithTheme(ui: React.ReactElement) {
|
|
8
11
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
9
12
|
}
|
|
@@ -99,10 +102,20 @@ describe('NumberInput (native)', () => {
|
|
|
99
102
|
expect(transform).toMatch(/(-1\.5|-1\.50)/);
|
|
100
103
|
});
|
|
101
104
|
|
|
102
|
-
it('rounds the outer corners of the field with the medium border radius', () => {
|
|
105
|
+
it('rounds the outer corners of the field with the medium border radius by default', () => {
|
|
103
106
|
renderWithTheme(<NumberInput label="Quantity" name="qty" value={3} />);
|
|
104
107
|
// Clockwise from top-left: the steppers round only the field's outer corners.
|
|
105
108
|
expect(cornerRadii(screen.getByRole('button', { name: 'Decrease' }))).toEqual(['16px', '', '', '16px']);
|
|
106
109
|
expect(cornerRadii(screen.getByRole('button', { name: 'Increase' }))).toEqual(['', '16px', '16px', '']);
|
|
107
110
|
});
|
|
111
|
+
|
|
112
|
+
it('takes the radius of the outer corners from the theme', () => {
|
|
113
|
+
render(
|
|
114
|
+
<ThemeProvider tokens={{ radius: radius12 }}>
|
|
115
|
+
<NumberInput label="Quantity" name="qty" value={3} />
|
|
116
|
+
</ThemeProvider>,
|
|
117
|
+
);
|
|
118
|
+
expect(cornerRadii(screen.getByRole('button', { name: 'Decrease' }))).toEqual(['12px', '', '', '12px']);
|
|
119
|
+
expect(cornerRadii(screen.getByRole('button', { name: 'Increase' }))).toEqual(['', '12px', '12px', '']);
|
|
120
|
+
});
|
|
108
121
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { StyleSheet, View, Pressable, StyleProp, ViewStyle } from 'react-native';
|
|
3
3
|
import { ScaledText as Text, ScaledTextInput as TextInput } from '../../../../native/ScaledText';
|
|
4
|
-
import { spacing, font
|
|
4
|
+
import { spacing, font } from '../../../../native/tokens';
|
|
5
5
|
import { useTheme } from '../../../../native/ThemeContext';
|
|
6
6
|
import { Label } from '../atoms/Label';
|
|
7
7
|
import { Message } from '../atoms/Message';
|
|
@@ -39,14 +39,11 @@ const styles = StyleSheet.create({
|
|
|
39
39
|
justifyContent: 'center',
|
|
40
40
|
borderWidth: 1,
|
|
41
41
|
},
|
|
42
|
+
// The steppers round only the field's outer corners, with `radius.field` from the theme.
|
|
42
43
|
stepperStart: {
|
|
43
|
-
borderTopStartRadius: border.radius.m,
|
|
44
|
-
borderBottomStartRadius: border.radius.m,
|
|
45
44
|
borderEndWidth: 0,
|
|
46
45
|
},
|
|
47
46
|
stepperEnd: {
|
|
48
|
-
borderTopEndRadius: border.radius.m,
|
|
49
|
-
borderBottomEndRadius: border.radius.m,
|
|
50
47
|
borderStartWidth: 0,
|
|
51
48
|
},
|
|
52
49
|
stepperText: {
|
|
@@ -97,7 +94,7 @@ export function NumberInput({
|
|
|
97
94
|
onChange,
|
|
98
95
|
style,
|
|
99
96
|
}: Readonly<NumberInputProps>) {
|
|
100
|
-
const { colors } = useTheme();
|
|
97
|
+
const { colors, radius } = useTheme();
|
|
101
98
|
const isControlled = value !== undefined;
|
|
102
99
|
const [internalValue, setInternalValue] = useState(defaultValue ?? 0);
|
|
103
100
|
|
|
@@ -134,6 +131,8 @@ export function NumberInput({
|
|
|
134
131
|
styles.stepperStart,
|
|
135
132
|
{
|
|
136
133
|
borderColor,
|
|
134
|
+
borderTopStartRadius: radius.field,
|
|
135
|
+
borderBottomStartRadius: radius.field,
|
|
137
136
|
backgroundColor: colors.colorBgSubtle,
|
|
138
137
|
},
|
|
139
138
|
pressed && { opacity: 0.7 },
|
|
@@ -192,6 +191,8 @@ export function NumberInput({
|
|
|
192
191
|
styles.stepperEnd,
|
|
193
192
|
{
|
|
194
193
|
borderColor,
|
|
194
|
+
borderTopEndRadius: radius.field,
|
|
195
|
+
borderBottomEndRadius: radius.field,
|
|
195
196
|
backgroundColor: colors.colorBgSubtle,
|
|
196
197
|
},
|
|
197
198
|
pressed && { opacity: 0.7 },
|
|
@@ -5,7 +5,7 @@ import * as Stories from './Select.stories';
|
|
|
5
5
|
|
|
6
6
|
# Select (React Native)
|
|
7
7
|
|
|
8
|
-
Dropdown-style select component. Renders a field-shaped trigger
|
|
8
|
+
Dropdown-style select component. Renders a field-shaped trigger with a downward chevron. Its corner radius is the theme's `radius.field`, like the other form fields (`border.radius.m` by default; set it through `ThemeProvider` `tokens={{ radius: { field } }}`). Pressing it opens a bottom sheet modal with a scrollable list of options.
|
|
9
9
|
|
|
10
10
|
> **Status: alpha** — API may change before stable release.
|
|
11
11
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { render, screen, within } from '@testing-library/react';
|
|
2
2
|
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
3
|
+
import type { ThemeRadius } from '../../../../native/ThemeContext';
|
|
3
4
|
import { Select } from './Select';
|
|
4
5
|
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
5
6
|
|
|
7
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
8
|
+
|
|
6
9
|
const options = [
|
|
7
10
|
{ value: 'apple', label: 'Apple' },
|
|
8
11
|
{ value: 'banana', label: 'Banana' },
|
|
@@ -63,12 +66,22 @@ describe('Select (native)', () => {
|
|
|
63
66
|
expect(trigger).toBeDisabled();
|
|
64
67
|
});
|
|
65
68
|
|
|
66
|
-
it('gives the trigger the medium border radius', () => {
|
|
69
|
+
it('gives the trigger the medium border radius by default', () => {
|
|
67
70
|
renderWithTheme(<Select label="Fruit" name="fruit" options={options} />);
|
|
68
71
|
const trigger = screen.getByRole('button', { name: /Fruit/ });
|
|
69
72
|
expect(cornerRadii(trigger)).toEqual(['16px', '16px', '16px', '16px']);
|
|
70
73
|
});
|
|
71
74
|
|
|
75
|
+
it('takes the border radius of the trigger from the theme', () => {
|
|
76
|
+
render(
|
|
77
|
+
<ThemeProvider tokens={{ radius: radius12 }}>
|
|
78
|
+
<Select label="Fruit" name="fruit" options={options} />
|
|
79
|
+
</ThemeProvider>,
|
|
80
|
+
);
|
|
81
|
+
const trigger = screen.getByRole('button', { name: /Fruit/ });
|
|
82
|
+
expect(cornerRadii(trigger)).toEqual(['12px', '12px', '12px', '12px']);
|
|
83
|
+
});
|
|
84
|
+
|
|
72
85
|
it('draws the dropdown indicator as a chevron, not a ▾ glyph', () => {
|
|
73
86
|
renderWithTheme(<Select label="Fruit" name="fruit" options={options} />);
|
|
74
87
|
const trigger = screen.getByRole('button', { name: /Fruit/ });
|
|
@@ -37,7 +37,6 @@ const styles = StyleSheet.create({
|
|
|
37
37
|
paddingHorizontal: spacing.s,
|
|
38
38
|
paddingVertical: spacing.xs,
|
|
39
39
|
borderWidth: 1,
|
|
40
|
-
borderRadius: border.radius.m,
|
|
41
40
|
minHeight: 44,
|
|
42
41
|
},
|
|
43
42
|
triggerText: {
|
|
@@ -103,7 +102,7 @@ export function Select({
|
|
|
103
102
|
onChange,
|
|
104
103
|
style,
|
|
105
104
|
}: Readonly<SelectProps>) {
|
|
106
|
-
const { colors } = useTheme();
|
|
105
|
+
const { colors, radius } = useTheme();
|
|
107
106
|
const [open, setOpen] = useState(false);
|
|
108
107
|
|
|
109
108
|
const selectedOption = options.find((o) => o.value === value);
|
|
@@ -135,6 +134,7 @@ export function Select({
|
|
|
135
134
|
styles.trigger,
|
|
136
135
|
{
|
|
137
136
|
borderColor,
|
|
137
|
+
borderRadius: radius.field,
|
|
138
138
|
backgroundColor: colors.colorBg,
|
|
139
139
|
},
|
|
140
140
|
disabled && { opacity: 0.4 },
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import { ThemeProvider } from '../../../../native/ThemeContext';
|
|
3
|
+
import type { ThemeRadius } from '../../../../native/ThemeContext';
|
|
3
4
|
import { Textarea } from './Textarea';
|
|
4
5
|
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
5
6
|
|
|
7
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
8
|
+
|
|
6
9
|
function renderWithTheme(ui: React.ReactElement) {
|
|
7
10
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
8
11
|
}
|
|
@@ -49,8 +52,17 @@ describe('Textarea (native)', () => {
|
|
|
49
52
|
expect(inputs.length).toBeGreaterThan(0);
|
|
50
53
|
});
|
|
51
54
|
|
|
52
|
-
it('gives the bordered field box the medium border radius', () => {
|
|
55
|
+
it('gives the bordered field box the medium border radius by default', () => {
|
|
53
56
|
renderWithTheme(<Textarea label="Message" name="message" />);
|
|
54
57
|
expect(cornerRadii(screen.getByRole('textbox'))).toEqual(['16px', '16px', '16px', '16px']);
|
|
55
58
|
});
|
|
59
|
+
|
|
60
|
+
it('takes the border radius of the field box from the theme', () => {
|
|
61
|
+
render(
|
|
62
|
+
<ThemeProvider tokens={{ radius: radius12 }}>
|
|
63
|
+
<Textarea label="Message" name="message" />
|
|
64
|
+
</ThemeProvider>,
|
|
65
|
+
);
|
|
66
|
+
expect(cornerRadii(screen.getByRole('textbox'))).toEqual(['12px', '12px', '12px', '12px']);
|
|
67
|
+
});
|
|
56
68
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ReactNode, useState } from 'react';
|
|
2
2
|
import { StyleSheet, View, StyleProp, ViewStyle } from 'react-native';
|
|
3
3
|
import { ScaledTextInput as TextInput } from '../../../../native/ScaledText';
|
|
4
|
-
import { spacing, font,
|
|
4
|
+
import { spacing, font, lineHeight } from '../../../../native/tokens';
|
|
5
5
|
import { useTheme } from '../../../../native/ThemeContext';
|
|
6
6
|
import { Label } from '../atoms/Label';
|
|
7
7
|
import { Message } from '../atoms/Message';
|
|
@@ -33,7 +33,6 @@ const styles = StyleSheet.create({
|
|
|
33
33
|
paddingHorizontal: spacing.s,
|
|
34
34
|
paddingVertical: spacing.xs,
|
|
35
35
|
borderWidth: 1,
|
|
36
|
-
borderRadius: border.radius.m,
|
|
37
36
|
minHeight: 100,
|
|
38
37
|
textAlignVertical: 'top',
|
|
39
38
|
},
|
|
@@ -54,7 +53,7 @@ export function Textarea({
|
|
|
54
53
|
onFocus,
|
|
55
54
|
style,
|
|
56
55
|
}: Readonly<TextareaProps>) {
|
|
57
|
-
const { colors } = useTheme();
|
|
56
|
+
const { colors, radius } = useTheme();
|
|
58
57
|
const [focused, setFocused] = useState(false);
|
|
59
58
|
|
|
60
59
|
const borderColor = error
|
|
@@ -90,6 +89,7 @@ export function Textarea({
|
|
|
90
89
|
styles.textarea,
|
|
91
90
|
{
|
|
92
91
|
borderColor,
|
|
92
|
+
borderRadius: radius.field,
|
|
93
93
|
color: colors.colorOnBg,
|
|
94
94
|
backgroundColor: colors.colorBg,
|
|
95
95
|
},
|
|
@@ -22,7 +22,7 @@ export const Flow: Story = {
|
|
|
22
22
|
render: (args) => (
|
|
23
23
|
<ButtonGroup {...args} variant="flow">
|
|
24
24
|
<Button>Primary</Button>
|
|
25
|
-
<Button variant="
|
|
25
|
+
<Button variant="outline">Secondary</Button>
|
|
26
26
|
</ButtonGroup>
|
|
27
27
|
),
|
|
28
28
|
};
|
|
@@ -30,7 +30,7 @@ export const Card: Story = {
|
|
|
30
30
|
render: (args) => (
|
|
31
31
|
<ButtonGroup {...args} variant="card">
|
|
32
32
|
<Button>Confirm</Button>
|
|
33
|
-
<Button variant="
|
|
33
|
+
<Button variant="outline">Cancel</Button>
|
|
34
34
|
</ButtonGroup>
|
|
35
35
|
),
|
|
36
36
|
};
|
|
@@ -38,7 +38,7 @@ export const Modal: Story = {
|
|
|
38
38
|
render: (args) => (
|
|
39
39
|
<ButtonGroup {...args} variant="modal">
|
|
40
40
|
<Button>Save</Button>
|
|
41
|
-
<Button variant="
|
|
41
|
+
<Button variant="outline">Discard</Button>
|
|
42
42
|
</ButtonGroup>
|
|
43
43
|
),
|
|
44
44
|
};
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Infrastructure
|
|
4
4
|
export { ThemeProvider, useTheme } from './native/ThemeContext';
|
|
5
|
-
export type { ThemeTokenOverrides, ThemeColors, ThemeColorName } from './native/ThemeContext';
|
|
5
|
+
export type { ThemeTokenOverrides, ThemeRadius, ThemeColors, ThemeColorName } from './native/ThemeContext';
|
|
6
6
|
export {
|
|
7
7
|
createReactNavigationTheme,
|
|
8
8
|
useReactNavigationTheme,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import { Text } from 'react-native';
|
|
3
|
-
import { colors } from './tokens';
|
|
3
|
+
import { border, colors } from './tokens';
|
|
4
4
|
import { ThemeProvider, useTheme } from './ThemeContext';
|
|
5
5
|
|
|
6
6
|
function Probe() {
|
|
@@ -55,3 +55,83 @@ describe('ThemeProvider token overrides', () => {
|
|
|
55
55
|
expect(screen.getByTestId('interactive')).toHaveTextContent(colors.dark.colorInteractive);
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
function RadiusProbe() {
|
|
60
|
+
const { radius } = useTheme();
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<Text testID="radius-button">{radius.button}</Text>
|
|
64
|
+
<Text testID="radius-icon-button">{radius.iconButton}</Text>
|
|
65
|
+
<Text testID="radius-field">{radius.field}</Text>
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function radii(): string[] {
|
|
71
|
+
return ['radius-button', 'radius-icon-button', 'radius-field'].map(
|
|
72
|
+
(id) => screen.getByTestId(id).textContent ?? '',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('ThemeProvider radius overrides', () => {
|
|
77
|
+
it('provides the medium and full radii when no overrides given', () => {
|
|
78
|
+
render(
|
|
79
|
+
<ThemeProvider>
|
|
80
|
+
<RadiusProbe />
|
|
81
|
+
</ThemeProvider>,
|
|
82
|
+
);
|
|
83
|
+
expect(radii()).toEqual([String(border.radius.m), String(border.radius.full), String(border.radius.m)]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('merges a partial radius override over the defaults', () => {
|
|
87
|
+
render(
|
|
88
|
+
<ThemeProvider tokens={{ radius: { button: 4 } }}>
|
|
89
|
+
<RadiusProbe />
|
|
90
|
+
</ThemeProvider>,
|
|
91
|
+
);
|
|
92
|
+
expect(radii()).toEqual(['4', String(border.radius.full), String(border.radius.m)]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('applies the same radii in the dark scheme', () => {
|
|
96
|
+
render(
|
|
97
|
+
<ThemeProvider colorScheme="dark" tokens={{ radius: { button: 12, iconButton: 12, field: 12 } }}>
|
|
98
|
+
<RadiusProbe />
|
|
99
|
+
</ThemeProvider>,
|
|
100
|
+
);
|
|
101
|
+
expect(radii()).toEqual(['12', '12', '12']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("lets a nested provider inherit its parent's radii", () => {
|
|
105
|
+
render(
|
|
106
|
+
<ThemeProvider tokens={{ radius: { button: 12, field: 8 } }}>
|
|
107
|
+
<ThemeProvider tokens={{ light: { colorInteractive: '#00c853' } }}>
|
|
108
|
+
<RadiusProbe />
|
|
109
|
+
</ThemeProvider>
|
|
110
|
+
</ThemeProvider>,
|
|
111
|
+
);
|
|
112
|
+
expect(radii()).toEqual(['12', String(border.radius.full), '8']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("merges a nested provider's radius override over its parent's radii", () => {
|
|
116
|
+
render(
|
|
117
|
+
<ThemeProvider tokens={{ radius: { button: 12, field: 8 } }}>
|
|
118
|
+
<ThemeProvider tokens={{ radius: { field: 2 } }}>
|
|
119
|
+
<RadiusProbe />
|
|
120
|
+
</ThemeProvider>
|
|
121
|
+
</ThemeProvider>,
|
|
122
|
+
);
|
|
123
|
+
expect(radii()).toEqual(['12', String(border.radius.full), '2']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("keeps merging a nested provider's colors over the foundation, not over its parent", () => {
|
|
127
|
+
render(
|
|
128
|
+
<ThemeProvider tokens={{ light: { colorBg: '#fafafa' } }}>
|
|
129
|
+
<ThemeProvider tokens={{ light: { colorInteractive: '#00c853' } }}>
|
|
130
|
+
<Probe />
|
|
131
|
+
</ThemeProvider>
|
|
132
|
+
</ThemeProvider>,
|
|
133
|
+
);
|
|
134
|
+
expect(screen.getByTestId('interactive')).toHaveTextContent('#00c853');
|
|
135
|
+
expect(screen.getByTestId('bg')).toHaveTextContent(colors.light.colorBg);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import { createContext, ReactNode, useContext, useMemo } from 'react';
|
|
2
2
|
import { useColorScheme } from 'react-native';
|
|
3
|
-
import { colors } from './tokens';
|
|
3
|
+
import { border, colors } from './tokens';
|
|
4
4
|
|
|
5
5
|
export type ThemeColorName = keyof typeof colors.light;
|
|
6
6
|
|
|
7
7
|
/** Semantic theme colors, widened to `string` so brand overrides typecheck. */
|
|
8
8
|
export type ThemeColors = Readonly<Record<ThemeColorName, string>>;
|
|
9
9
|
|
|
10
|
+
/** Corner radii of the controls, in points. */
|
|
11
|
+
export interface ThemeRadius {
|
|
12
|
+
/** Buttons with a text label. */
|
|
13
|
+
button: number;
|
|
14
|
+
/** Icon-only buttons (`isIconOnly`). */
|
|
15
|
+
iconButton: number;
|
|
16
|
+
/** Input fields: FormInput, Select, Combobox, Textarea, FileInput, NumberInput. */
|
|
17
|
+
field: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const defaultRadius: ThemeRadius = {
|
|
21
|
+
button: border.radius.m,
|
|
22
|
+
iconButton: border.radius.full,
|
|
23
|
+
field: border.radius.m,
|
|
24
|
+
};
|
|
25
|
+
|
|
10
26
|
/**
|
|
11
27
|
* Per-scheme token overrides, merged over the foundation colors.
|
|
12
28
|
* The React Native equivalent of overriding tokens in the web
|
|
@@ -15,11 +31,18 @@ export type ThemeColors = Readonly<Record<ThemeColorName, string>>;
|
|
|
15
31
|
export interface ThemeTokenOverrides {
|
|
16
32
|
light?: Partial<Record<ThemeColorName, string>>;
|
|
17
33
|
dark?: Partial<Record<ThemeColorName, string>>;
|
|
34
|
+
/**
|
|
35
|
+
* Radius overrides, the same in light and dark. The native equivalent of the
|
|
36
|
+
* web `--button_radius` custom property. Merged over the parent provider's
|
|
37
|
+
* radii, so a nested provider that only overrides colors keeps the app's radii.
|
|
38
|
+
*/
|
|
39
|
+
radius?: Partial<ThemeRadius>;
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
interface ThemeContextValue {
|
|
21
43
|
colors: ThemeColors;
|
|
22
44
|
isDark: boolean;
|
|
45
|
+
radius: ThemeRadius;
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
@@ -33,11 +56,17 @@ interface ThemeProviderProps {
|
|
|
33
56
|
export function ThemeProvider({ children, colorScheme, tokens }: ThemeProviderProps) {
|
|
34
57
|
const systemScheme = useColorScheme();
|
|
35
58
|
const isDark = (colorScheme ?? systemScheme) === 'dark';
|
|
59
|
+
// Colors merge over the foundation; radii merge over the parent provider's.
|
|
60
|
+
const parentRadius = useContext(ThemeContext)?.radius ?? defaultRadius;
|
|
36
61
|
const value = useMemo<ThemeContextValue>(() => {
|
|
37
62
|
const base = isDark ? colors.dark : colors.light;
|
|
38
63
|
const overrides = isDark ? tokens?.dark : tokens?.light;
|
|
39
|
-
return {
|
|
40
|
-
|
|
64
|
+
return {
|
|
65
|
+
colors: { ...base, ...overrides },
|
|
66
|
+
isDark,
|
|
67
|
+
radius: { ...parentRadius, ...tokens?.radius },
|
|
68
|
+
};
|
|
69
|
+
}, [isDark, tokens, parentRadius]);
|
|
41
70
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
42
71
|
}
|
|
43
72
|
|