@boostdev/design-system-react-native 0.5.0 → 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.
- package/CHANGELOG.md +70 -0
- package/README.md +35 -1
- package/dist/index.cjs +596 -545
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +488 -437
- package/package.json +1 -1
- package/src/components/interaction/Button/Button.mdx +13 -1
- package/src/components/interaction/Button/Button.spec.tsx +83 -0
- package/src/components/interaction/Button/Button.tsx +9 -8
- package/src/components/interaction/form/Combobox/Combobox.spec.tsx +22 -0
- package/src/components/interaction/form/Combobox/Combobox.tsx +4 -3
- package/src/components/interaction/form/FileInput/FileInput.spec.tsx +18 -0
- package/src/components/interaction/form/FileInput/FileInput.tsx +3 -3
- package/src/components/interaction/form/FormInput/FormInput.spec.tsx +20 -0
- package/src/components/interaction/form/FormInput/FormInput.tsx +5 -4
- package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +21 -0
- package/src/components/interaction/form/NumberInput/NumberInput.tsx +7 -6
- package/src/components/interaction/form/Select/Select.mdx +2 -1
- package/src/components/interaction/form/Select/Select.spec.tsx +35 -1
- package/src/components/interaction/form/Select/Select.tsx +8 -7
- package/src/components/interaction/form/Textarea/Textarea.spec.tsx +18 -0
- package/src/components/interaction/form/Textarea/Textarea.tsx +3 -3
- package/src/components/ui/Collapsible/Collapsible.spec.tsx +1 -1
- package/src/components/ui/Collapsible/Collapsible.tsx +5 -10
- package/src/index.ts +1 -1
- package/src/native/Chevron.spec.tsx +34 -0
- package/src/native/Chevron.tsx +50 -0
- package/src/native/ThemeContext.spec.tsx +81 -1
- package/src/native/ThemeContext.tsx +32 -3
- package/src/test/cornerRadii.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boostdev/design-system-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "BoostDev React Native component library: accessible, token-driven components built on @boostdev/design-system-foundation",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -45,6 +45,18 @@ export default function App() {
|
|
|
45
45
|
<Canvas of={Stories.Medium} />
|
|
46
46
|
<Canvas of={Stories.Large} />
|
|
47
47
|
|
|
48
|
+
## Shape
|
|
49
|
+
|
|
50
|
+
The corner radius comes from the theme, not from the button: a button with a text label uses `radius.button` on every size, an icon-only button (`isIconOnly`) uses `radius.iconButton`. By default these are `border.radius.m` (the same as the form fields) and `border.radius.full` (round). Set them per app through `ThemeProvider`, the native equivalent of the web `--button_radius`:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
<ThemeProvider tokens={{ radius: { button: border.radius.s, iconButton: border.radius.s } }}>
|
|
54
|
+
<App />
|
|
55
|
+
</ThemeProvider>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A nested `ThemeProvider` keeps its parent's radii unless it sets its own. A `borderRadius` in `style` overrides the theme.
|
|
59
|
+
|
|
48
60
|
## Icons
|
|
49
61
|
|
|
50
62
|
<Canvas of={Stories.WithIconStart} />
|
|
@@ -52,7 +64,7 @@ export default function App() {
|
|
|
52
64
|
|
|
53
65
|
### Icon-only (1:1 aspect ratio)
|
|
54
66
|
|
|
55
|
-
Set `isIconOnly` to render a
|
|
67
|
+
Set `isIconOnly` to render a 1:1 button (round by default, see [Shape](#shape)) with equal `spacing.xs` padding on both axes. Composable with any `variant`. Always pair with `accessibilityLabel` for assistive tech.
|
|
56
68
|
|
|
57
69
|
<Canvas of={Stories.IconOnly} />
|
|
58
70
|
<Canvas of={Stories.IconOnlyOutline} />
|
|
@@ -1,6 +1,8 @@
|
|
|
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';
|
|
5
|
+
import { cornerRadii } from '../../../test/cornerRadii';
|
|
4
6
|
|
|
5
7
|
function renderWithTheme(ui: React.ReactElement) {
|
|
6
8
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
@@ -69,4 +71,85 @@ describe('Button (native)', () => {
|
|
|
69
71
|
const style = screen.getByRole('button').getAttribute('style') ?? '';
|
|
70
72
|
expect(style).toMatch(/(^|[;\s])height:\s*56px/);
|
|
71
73
|
});
|
|
74
|
+
|
|
75
|
+
it.each(['small', 'medium', 'large'] as const)(
|
|
76
|
+
'gives a %s text button the medium border radius by default',
|
|
77
|
+
(size) => {
|
|
78
|
+
renderWithTheme(<Button size={size}>Save</Button>);
|
|
79
|
+
expect(cornerRadii(screen.getByRole('button'))).toEqual(['16px', '16px', '16px', '16px']);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
it('keeps an icon-only button round by default', () => {
|
|
84
|
+
renderWithTheme(
|
|
85
|
+
<Button isIconOnly accessibilityLabel="Confirm">
|
|
86
|
+
<span>✓</span>
|
|
87
|
+
</Button>,
|
|
88
|
+
);
|
|
89
|
+
expect(cornerRadii(screen.getByRole('button'))).toEqual(['9999px', '9999px', '9999px', '9999px']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("lets the caller's style override the border radius", () => {
|
|
93
|
+
renderWithTheme(<Button style={{ borderRadius: 4 }}>Save</Button>);
|
|
94
|
+
expect(cornerRadii(screen.getByRole('button'))).toEqual(['4px', '4px', '4px', '4px']);
|
|
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
|
+
});
|
|
72
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
|
|
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'
|
|
@@ -21,10 +21,10 @@ export interface ButtonProps {
|
|
|
21
21
|
style?: StyleProp<ViewStyle>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const sizeTokens: Record<ButtonSize, { height: number; px: number; fontSize: number
|
|
25
|
-
small: { height: 36, px: spacing.m, fontSize: font.size.bodyS
|
|
26
|
-
medium: { height: 48, px: spacing.m, fontSize: font.size.body
|
|
27
|
-
large: { height: 56, px: spacing.xxl, fontSize: font.size.heading3
|
|
24
|
+
const sizeTokens: Record<ButtonSize, { height: number; px: number; fontSize: number }> = {
|
|
25
|
+
small: { height: 36, px: spacing.m, fontSize: font.size.bodyS },
|
|
26
|
+
medium: { height: 48, px: spacing.m, fontSize: font.size.body },
|
|
27
|
+
large: { height: 56, px: spacing.xxl, fontSize: font.size.heading3 },
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const styles = StyleSheet.create({
|
|
@@ -56,8 +56,8 @@ export function Button({
|
|
|
56
56
|
accessibilityLabel,
|
|
57
57
|
style,
|
|
58
58
|
}: Readonly<ButtonProps>) {
|
|
59
|
-
const { colors } = useTheme();
|
|
60
|
-
const { height, px, fontSize
|
|
59
|
+
const { colors, radius } = useTheme();
|
|
60
|
+
const { height, px, fontSize } = sizeTokens[size];
|
|
61
61
|
|
|
62
62
|
// Color model: filled = interactive bg, outline = transparent bg + interactive_on-bg border/text
|
|
63
63
|
const primaryColor = colors.colorInteractive;
|
|
@@ -69,7 +69,8 @@ export function Button({
|
|
|
69
69
|
const containerStyle: ViewStyle = {
|
|
70
70
|
minHeight: height,
|
|
71
71
|
paddingHorizontal: isIconOnly ? spacing.xs : px,
|
|
72
|
-
borderRadius
|
|
72
|
+
// Corners come from the theme (`tokens.radius`); a borderRadius in `style` still wins.
|
|
73
|
+
borderRadius: isIconOnly ? radius.iconButton : radius.button,
|
|
73
74
|
opacity: disabled ? 0.4 : 1,
|
|
74
75
|
// Icon-only: square with equal xs padding on both axes.
|
|
75
76
|
...(isIconOnly && { aspectRatio: 1, paddingVertical: spacing.xs }),
|
|
@@ -1,8 +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 { Combobox } from './Combobox';
|
|
4
5
|
import type { ComboboxOption } from './Combobox';
|
|
5
6
|
import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
|
|
7
|
+
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
8
|
+
|
|
9
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
6
10
|
|
|
7
11
|
function renderWithTheme(ui: React.ReactElement) {
|
|
8
12
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
@@ -100,4 +104,22 @@ describe('Combobox (native)', () => {
|
|
|
100
104
|
expect(OPTICAL_TEXT_SHIFT).toBe(-1.5);
|
|
101
105
|
expect(transform).toMatch(/(-1\.5|-1\.50)/);
|
|
102
106
|
});
|
|
107
|
+
|
|
108
|
+
it('gives the bordered field box the medium border radius by default', () => {
|
|
109
|
+
renderWithTheme(
|
|
110
|
+
<Combobox label="Fruit" name="fruit" options={fruits} />,
|
|
111
|
+
);
|
|
112
|
+
const wrapper = screen.getByRole('combobox').parentElement as HTMLElement;
|
|
113
|
+
expect(cornerRadii(wrapper)).toEqual(['16px', '16px', '16px', '16px']);
|
|
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
|
+
});
|
|
103
125
|
});
|
|
@@ -36,7 +36,8 @@ const styles = StyleSheet.create({
|
|
|
36
36
|
gap: spacing.xxs,
|
|
37
37
|
},
|
|
38
38
|
/**
|
|
39
|
-
* Draws the
|
|
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.full,
|
|
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,6 +1,10 @@
|
|
|
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';
|
|
5
|
+
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
6
|
+
|
|
7
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
4
8
|
|
|
5
9
|
function renderWithTheme(ui: React.ReactElement) {
|
|
6
10
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
@@ -70,4 +74,18 @@ describe('FileInput (native)', () => {
|
|
|
70
74
|
);
|
|
71
75
|
expect(screen.getByText('Resume *')).toBeInTheDocument();
|
|
72
76
|
});
|
|
77
|
+
|
|
78
|
+
it('gives the drop zone the medium border radius by default', () => {
|
|
79
|
+
renderWithTheme(<FileInput label="Upload" name="upload" />);
|
|
80
|
+
expect(cornerRadii(screen.getByRole('button'))).toEqual(['16px', '16px', '16px', '16px']);
|
|
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
|
+
});
|
|
73
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,
|
|
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.s,
|
|
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,7 +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 { FormInput } from './FormInput';
|
|
4
5
|
import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
|
|
6
|
+
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
7
|
+
|
|
8
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
5
9
|
|
|
6
10
|
function renderWithTheme(ui: React.ReactElement) {
|
|
7
11
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
@@ -99,6 +103,22 @@ describe('FormInput (native)', () => {
|
|
|
99
103
|
expect(getComputedStyle(wrapper).minHeight).toBe('44px');
|
|
100
104
|
});
|
|
101
105
|
|
|
106
|
+
it('gives the bordered field box the medium border radius by default', () => {
|
|
107
|
+
renderWithTheme(<FormInput label="Email" name="email" />);
|
|
108
|
+
const wrapper = screen.getByRole('textbox').parentElement as HTMLElement;
|
|
109
|
+
expect(cornerRadii(wrapper)).toEqual(['16px', '16px', '16px', '16px']);
|
|
110
|
+
});
|
|
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
|
+
|
|
102
122
|
/**
|
|
103
123
|
* Optical-centering invariant: the TextInput must have a translateY transform equal
|
|
104
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
|
|
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.full,
|
|
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,7 +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 { NumberInput } from './NumberInput';
|
|
4
5
|
import { OPTICAL_TEXT_SHIFT } from '../atoms/inputHelpers';
|
|
6
|
+
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
7
|
+
|
|
8
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
5
9
|
|
|
6
10
|
function renderWithTheme(ui: React.ReactElement) {
|
|
7
11
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
@@ -97,4 +101,21 @@ describe('NumberInput (native)', () => {
|
|
|
97
101
|
expect(OPTICAL_TEXT_SHIFT).toBe(-1.5);
|
|
98
102
|
expect(transform).toMatch(/(-1\.5|-1\.50)/);
|
|
99
103
|
});
|
|
104
|
+
|
|
105
|
+
it('rounds the outer corners of the field with the medium border radius by default', () => {
|
|
106
|
+
renderWithTheme(<NumberInput label="Quantity" name="qty" value={3} />);
|
|
107
|
+
// Clockwise from top-left: the steppers round only the field's outer corners.
|
|
108
|
+
expect(cornerRadii(screen.getByRole('button', { name: 'Decrease' }))).toEqual(['16px', '', '', '16px']);
|
|
109
|
+
expect(cornerRadii(screen.getByRole('button', { name: 'Increase' }))).toEqual(['', '16px', '16px', '']);
|
|
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
|
+
});
|
|
100
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.full,
|
|
44
|
-
borderBottomStartRadius: border.radius.full,
|
|
45
44
|
borderEndWidth: 0,
|
|
46
45
|
},
|
|
47
46
|
stepperEnd: {
|
|
48
|
-
borderTopEndRadius: border.radius.full,
|
|
49
|
-
borderBottomEndRadius: border.radius.full,
|
|
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 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
|
|
|
@@ -80,6 +80,7 @@ function Example() {
|
|
|
80
80
|
## Accessibility
|
|
81
81
|
|
|
82
82
|
- Trigger announces the label and current value via `accessibilityLabel`
|
|
83
|
+
- The chevron is decorative: it is drawn with borders (no glyph) and hidden from the accessibility tree
|
|
83
84
|
- `accessibilityState.expanded` reflects whether the modal is open
|
|
84
85
|
- Each option announces its label and selected state
|
|
85
86
|
- Disabled options are communicated via `accessibilityState.disabled`
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
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';
|
|
5
|
+
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
6
|
+
|
|
7
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
4
8
|
|
|
5
9
|
const options = [
|
|
6
10
|
{ value: 'apple', label: 'Apple' },
|
|
@@ -61,4 +65,34 @@ describe('Select (native)', () => {
|
|
|
61
65
|
const trigger = screen.getByRole('button', { name: /Fruit/ });
|
|
62
66
|
expect(trigger).toBeDisabled();
|
|
63
67
|
});
|
|
68
|
+
|
|
69
|
+
it('gives the trigger the medium border radius by default', () => {
|
|
70
|
+
renderWithTheme(<Select label="Fruit" name="fruit" options={options} />);
|
|
71
|
+
const trigger = screen.getByRole('button', { name: /Fruit/ });
|
|
72
|
+
expect(cornerRadii(trigger)).toEqual(['16px', '16px', '16px', '16px']);
|
|
73
|
+
});
|
|
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
|
+
|
|
85
|
+
it('draws the dropdown indicator as a chevron, not a ▾ glyph', () => {
|
|
86
|
+
renderWithTheme(<Select label="Fruit" name="fruit" options={options} />);
|
|
87
|
+
const trigger = screen.getByRole('button', { name: /Fruit/ });
|
|
88
|
+
expect(trigger).not.toHaveTextContent('\u25BE');
|
|
89
|
+
expect(within(trigger).getByTestId('bds-select-chevron')).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('keeps the chevron out of the trigger accessibility label', () => {
|
|
93
|
+
renderWithTheme(
|
|
94
|
+
<Select label="Fruit" name="fruit" options={options} placeholder="Choose..." />,
|
|
95
|
+
);
|
|
96
|
+
expect(screen.getByRole('button', { name: 'Fruit, Choose...' })).toBeInTheDocument();
|
|
97
|
+
});
|
|
64
98
|
});
|
|
@@ -3,6 +3,7 @@ import { StyleSheet, View, Pressable, Modal, FlatList, SafeAreaView, StyleProp,
|
|
|
3
3
|
import { ScaledText as Text } from '../../../../native/ScaledText';
|
|
4
4
|
import { spacing, font, border, lineHeight } from '../../../../native/tokens';
|
|
5
5
|
import { useTheme } from '../../../../native/ThemeContext';
|
|
6
|
+
import { Chevron } from '../../../../native/Chevron';
|
|
6
7
|
import { Label } from '../atoms/Label';
|
|
7
8
|
import { Message } from '../atoms/Message';
|
|
8
9
|
|
|
@@ -36,7 +37,6 @@ const styles = StyleSheet.create({
|
|
|
36
37
|
paddingHorizontal: spacing.s,
|
|
37
38
|
paddingVertical: spacing.xs,
|
|
38
39
|
borderWidth: 1,
|
|
39
|
-
borderRadius: border.radius.full,
|
|
40
40
|
minHeight: 44,
|
|
41
41
|
},
|
|
42
42
|
triggerText: {
|
|
@@ -46,8 +46,6 @@ const styles = StyleSheet.create({
|
|
|
46
46
|
flex: 1,
|
|
47
47
|
},
|
|
48
48
|
chevron: {
|
|
49
|
-
fontFamily: font.family.body,
|
|
50
|
-
fontSize: font.size.body,
|
|
51
49
|
marginStart: spacing.xs,
|
|
52
50
|
},
|
|
53
51
|
overlay: {
|
|
@@ -104,7 +102,7 @@ export function Select({
|
|
|
104
102
|
onChange,
|
|
105
103
|
style,
|
|
106
104
|
}: Readonly<SelectProps>) {
|
|
107
|
-
const { colors } = useTheme();
|
|
105
|
+
const { colors, radius } = useTheme();
|
|
108
106
|
const [open, setOpen] = useState(false);
|
|
109
107
|
|
|
110
108
|
const selectedOption = options.find((o) => o.value === value);
|
|
@@ -136,6 +134,7 @@ export function Select({
|
|
|
136
134
|
styles.trigger,
|
|
137
135
|
{
|
|
138
136
|
borderColor,
|
|
137
|
+
borderRadius: radius.field,
|
|
139
138
|
backgroundColor: colors.colorBg,
|
|
140
139
|
},
|
|
141
140
|
disabled && { opacity: 0.4 },
|
|
@@ -151,9 +150,11 @@ export function Select({
|
|
|
151
150
|
>
|
|
152
151
|
{displayText}
|
|
153
152
|
</Text>
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
<Chevron
|
|
154
|
+
testID="bds-select-chevron"
|
|
155
|
+
color={colors.colorOnBgSubtle}
|
|
156
|
+
style={styles.chevron}
|
|
157
|
+
/>
|
|
157
158
|
</Pressable>
|
|
158
159
|
|
|
159
160
|
<Modal
|
|
@@ -1,6 +1,10 @@
|
|
|
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';
|
|
5
|
+
import { cornerRadii } from '../../../../test/cornerRadii';
|
|
6
|
+
|
|
7
|
+
const radius12: ThemeRadius = { button: 12, iconButton: 12, field: 12 };
|
|
4
8
|
|
|
5
9
|
function renderWithTheme(ui: React.ReactElement) {
|
|
6
10
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
|
@@ -47,4 +51,18 @@ describe('Textarea (native)', () => {
|
|
|
47
51
|
const inputs = container.querySelectorAll('textarea, [style*="opacity"]');
|
|
48
52
|
expect(inputs.length).toBeGreaterThan(0);
|
|
49
53
|
});
|
|
54
|
+
|
|
55
|
+
it('gives the bordered field box the medium border radius by default', () => {
|
|
56
|
+
renderWithTheme(<Textarea label="Message" name="message" />);
|
|
57
|
+
expect(cornerRadii(screen.getByRole('textbox'))).toEqual(['16px', '16px', '16px', '16px']);
|
|
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
|
+
});
|
|
50
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.xs,
|
|
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
|
},
|