@boostdev/design-system-react-native 0.5.1 → 0.6.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.
@@ -1,5 +1,6 @@
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 { Button } from './Button';
4
5
  import { cornerRadii } from '../../../test/cornerRadii';
5
6
 
@@ -72,14 +73,14 @@ describe('Button (native)', () => {
72
73
  });
73
74
 
74
75
  it.each(['small', 'medium', 'large'] as const)(
75
- 'gives a %s text button the medium border radius',
76
+ 'gives a %s text button the medium border radius by default',
76
77
  (size) => {
77
78
  renderWithTheme(<Button size={size}>Save</Button>);
78
79
  expect(cornerRadii(screen.getByRole('button'))).toEqual(['16px', '16px', '16px', '16px']);
79
80
  },
80
81
  );
81
82
 
82
- it('keeps an icon-only button round', () => {
83
+ it('keeps an icon-only button round by default', () => {
83
84
  renderWithTheme(
84
85
  <Button isIconOnly accessibilityLabel="Confirm">
85
86
  <span>✓</span>
@@ -92,4 +93,63 @@ describe('Button (native)', () => {
92
93
  renderWithTheme(<Button style={{ borderRadius: 4 }}>Save</Button>);
93
94
  expect(cornerRadii(screen.getByRole('button'))).toEqual(['4px', '4px', '4px', '4px']);
94
95
  });
96
+
97
+ describe('with a theme radius', () => {
98
+ const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
99
+
100
+ it('gives a text button the theme button radius', () => {
101
+ render(
102
+ <ThemeProvider tokens={{ radius: radius12 }}>
103
+ <Button>Save</Button>
104
+ </ThemeProvider>,
105
+ );
106
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['12px', '12px', '12px', '12px']);
107
+ });
108
+
109
+ it('gives an icon-only button the theme icon-button radius', () => {
110
+ render(
111
+ <ThemeProvider tokens={{ radius: radius12 }}>
112
+ <Button isIconOnly accessibilityLabel="Confirm">
113
+ <span>✓</span>
114
+ </Button>
115
+ </ThemeProvider>,
116
+ );
117
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['12px', '12px', '12px', '12px']);
118
+ });
119
+
120
+ it('changes only the text button with a partial override', () => {
121
+ render(
122
+ <ThemeProvider tokens={{ radius: { button: 4 } }}>
123
+ <Button>Save</Button>
124
+ <Button isIconOnly accessibilityLabel="Confirm">
125
+ <span>✓</span>
126
+ </Button>
127
+ </ThemeProvider>,
128
+ );
129
+ expect(cornerRadii(screen.getByRole('button', { name: 'Save' }))).toEqual(['4px', '4px', '4px', '4px']);
130
+ expect(cornerRadii(screen.getByRole('button', { name: 'Confirm' }))).toEqual([
131
+ '9999px', '9999px', '9999px', '9999px',
132
+ ]);
133
+ });
134
+
135
+ it("keeps the app's radius inside a nested provider that only overrides colours", () => {
136
+ render(
137
+ <ThemeProvider tokens={{ radius: { button: 12 } }}>
138
+ <ThemeProvider tokens={{ light: { colorInteractive: '#00c853' } }}>
139
+ <Button>Save</Button>
140
+ </ThemeProvider>
141
+ </ThemeProvider>,
142
+ );
143
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['12px', '12px', '12px', '12px']);
144
+ });
145
+
146
+ it("lets the caller's style override the theme radius", () => {
147
+ render(
148
+ <ThemeProvider tokens={{ radius: radius12 }}>
149
+ <Button style={{ borderRadius: 4 }}>Save</Button>
150
+ </ThemeProvider>,
151
+ );
152
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['4px', '4px', '4px', '4px']);
153
+ });
154
+ });
95
155
  });
@@ -1,7 +1,7 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { StyleSheet, Pressable, View, StyleProp, ViewStyle } from 'react-native';
3
3
  import { ScaledText as Text } from '../../../native/ScaledText';
4
- import { spacing, font, border } from '../../../native/tokens';
4
+ import { spacing, font } from '../../../native/tokens';
5
5
  import { useTheme } from '../../../native/ThemeContext';
6
6
 
7
7
  type ButtonVariant = 'default' | 'outline' | 'ghost'; // 'ghost' is deprecated, use 'outline'
@@ -56,7 +56,7 @@ export function Button({
56
56
  accessibilityLabel,
57
57
  style,
58
58
  }: Readonly<ButtonProps>) {
59
- const { colors } = useTheme();
59
+ const { colors, radius } = useTheme();
60
60
  const { height, px, fontSize } = sizeTokens[size];
61
61
 
62
62
  // Color model: filled = interactive bg, outline = transparent bg + interactive_on-bg border/text
@@ -69,8 +69,8 @@ export function Button({
69
69
  const containerStyle: ViewStyle = {
70
70
  minHeight: height,
71
71
  paddingHorizontal: isIconOnly ? spacing.xs : px,
72
- // A text button has medium corners on every size; an icon-only button stays round.
73
- borderRadius: isIconOnly ? border.radius.full : border.radius.m,
72
+ // Corners come from the theme (`tokens.radius`); a borderRadius in `style` still wins.
73
+ borderRadius: isIconOnly ? radius.iconButton : radius.button,
74
74
  opacity: disabled ? 0.4 : 1,
75
75
  // Icon-only: square with equal xs padding on both axes.
76
76
  ...(isIconOnly && { aspectRatio: 1, paddingVertical: spacing.xs }),
@@ -1,10 +1,13 @@
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 { Combobox } from './Combobox';
4
5
  import type { ComboboxOption } from './Combobox';
5
6
  import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
6
7
  import { cornerRadii } from '../../../../test/cornerRadii';
7
8
 
9
+ const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
10
+
8
11
  function renderWithTheme(ui: React.ReactElement) {
9
12
  return render(<ThemeProvider>{ui}</ThemeProvider>);
10
13
  }
@@ -102,11 +105,21 @@ describe('Combobox (native)', () => {
102
105
  expect(transform).toMatch(/(-1\.5|-1\.50)/);
103
106
  });
104
107
 
105
- it('gives the bordered field box the medium border radius', () => {
108
+ it('gives the bordered field box the medium border radius by default', () => {
106
109
  renderWithTheme(
107
110
  <Combobox label="Fruit" name="fruit" options={fruits} />,
108
111
  );
109
112
  const wrapper = screen.getByRole('combobox').parentElement as HTMLElement;
110
113
  expect(cornerRadii(wrapper)).toEqual(['16px', '16px', '16px', '16px']);
111
114
  });
115
+
116
+ it('takes the border radius of the field box from the theme', () => {
117
+ render(
118
+ <ThemeProvider tokens={{ radius: radius12 }}>
119
+ <Combobox label="Fruit" name="fruit" options={fruits} />
120
+ </ThemeProvider>,
121
+ );
122
+ const wrapper = screen.getByRole('combobox').parentElement as HTMLElement;
123
+ expect(cornerRadii(wrapper)).toEqual(['12px', '12px', '12px', '12px']);
124
+ });
112
125
  });
@@ -36,7 +36,8 @@ const styles = StyleSheet.create({
36
36
  gap: spacing.xxs,
37
37
  },
38
38
  /**
39
- * Draws the rounded border and provides the fixed 44pt touch-target height.
39
+ * Draws the rounded border (`radius.field` from the theme) and provides the fixed
40
+ * 44pt touch-target height.
40
41
  * justifyContent: 'center' vertically centers the inner TextInput.
41
42
  * position: 'relative' is required for the dropdown's absolute positioning.
42
43
  */
@@ -45,7 +46,6 @@ const styles = StyleSheet.create({
45
46
  height: INPUT_HEIGHT,
46
47
  justifyContent: 'center',
47
48
  borderWidth: 1,
48
- borderRadius: border.radius.m,
49
49
  },
50
50
  /**
51
51
  * No vertical padding: iOS centers text automatically inside the wrapper height.
@@ -99,7 +99,7 @@ export function Combobox({
99
99
  hint,
100
100
  style,
101
101
  }: Readonly<ComboboxProps>) {
102
- const { colors } = useTheme();
102
+ const { colors, radius } = useTheme();
103
103
 
104
104
  const selectedOption = useMemo(
105
105
  () => options.find((o) => o.value === value),
@@ -147,6 +147,7 @@ export function Combobox({
147
147
  styles.inputWrapper,
148
148
  {
149
149
  borderColor,
150
+ borderRadius: radius.field,
150
151
  backgroundColor: colors.colorBg,
151
152
  },
152
153
  disabled && { opacity: 0.4 },
@@ -1,8 +1,11 @@
1
1
  import { render, screen, fireEvent } from '@testing-library/react';
2
2
  import { ThemeProvider } from '../../../../native/ThemeContext';
3
+ import type { ThemeRadius } from '../../../../native/ThemeContext';
3
4
  import { FileInput } from './FileInput';
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
  }
@@ -72,8 +75,17 @@ describe('FileInput (native)', () => {
72
75
  expect(screen.getByText('Resume *')).toBeInTheDocument();
73
76
  });
74
77
 
75
- it('gives the drop zone the medium border radius', () => {
78
+ it('gives the drop zone the medium border radius by default', () => {
76
79
  renderWithTheme(<FileInput label="Upload" name="upload" />);
77
80
  expect(cornerRadii(screen.getByRole('button'))).toEqual(['16px', '16px', '16px', '16px']);
78
81
  });
82
+
83
+ it('takes the border radius of the drop zone from the theme', () => {
84
+ render(
85
+ <ThemeProvider tokens={{ radius: radius12 }}>
86
+ <FileInput label="Upload" name="upload" />
87
+ </ThemeProvider>,
88
+ );
89
+ expect(cornerRadii(screen.getByRole('button'))).toEqual(['12px', '12px', '12px', '12px']);
90
+ });
79
91
  });
@@ -1,6 +1,6 @@
1
1
  import { StyleSheet, View, Pressable, StyleProp, ViewStyle } from 'react-native';
2
2
  import { ScaledText as Text } from '../../../../native/ScaledText';
3
- import { spacing, font, border, lineHeight } from '../../../../native/tokens';
3
+ import { spacing, font, lineHeight } from '../../../../native/tokens';
4
4
  import { useTheme } from '../../../../native/ThemeContext';
5
5
  import { Label } from '../atoms/Label';
6
6
  import { Message } from '../atoms/Message';
@@ -33,7 +33,6 @@ const styles = StyleSheet.create({
33
33
  paddingHorizontal: spacing.m,
34
34
  borderWidth: 2,
35
35
  borderStyle: 'dashed',
36
- borderRadius: border.radius.m,
37
36
  minHeight: 120,
38
37
  },
39
38
  icon: {
@@ -75,7 +74,7 @@ export function FileInput({
75
74
  fileNames = [],
76
75
  style,
77
76
  }: Readonly<FileInputProps>) {
78
- const { colors } = useTheme();
77
+ const { colors, radius } = useTheme();
79
78
 
80
79
  const borderColor = error
81
80
  ? colors.colorError
@@ -96,6 +95,7 @@ export function FileInput({
96
95
  styles.dropZone,
97
96
  {
98
97
  borderColor,
98
+ borderRadius: radius.field,
99
99
  backgroundColor: 'transparent',
100
100
  },
101
101
  pressed && { opacity: 0.7 },
@@ -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, border } from '../../../../native/tokens';
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 and provides at least 44pt touch-target height.
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, border } from '../../../../native/tokens';
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 (`border.radius.m`, like the other form fields) with a downward chevron. Pressing it opens a bottom sheet modal with a scrollable list of options.
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, border, lineHeight } from '../../../../native/tokens';
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
  },
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
+ });