@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +35 -1
  3. package/dist/index.cjs +596 -545
  4. package/dist/index.d.cts +17 -1
  5. package/dist/index.d.ts +17 -1
  6. package/dist/index.js +488 -437
  7. package/package.json +1 -1
  8. package/src/components/interaction/Button/Button.mdx +13 -1
  9. package/src/components/interaction/Button/Button.spec.tsx +83 -0
  10. package/src/components/interaction/Button/Button.tsx +9 -8
  11. package/src/components/interaction/form/Combobox/Combobox.spec.tsx +22 -0
  12. package/src/components/interaction/form/Combobox/Combobox.tsx +4 -3
  13. package/src/components/interaction/form/FileInput/FileInput.spec.tsx +18 -0
  14. package/src/components/interaction/form/FileInput/FileInput.tsx +3 -3
  15. package/src/components/interaction/form/FormInput/FormInput.spec.tsx +20 -0
  16. package/src/components/interaction/form/FormInput/FormInput.tsx +5 -4
  17. package/src/components/interaction/form/NumberInput/NumberInput.spec.tsx +21 -0
  18. package/src/components/interaction/form/NumberInput/NumberInput.tsx +7 -6
  19. package/src/components/interaction/form/Select/Select.mdx +2 -1
  20. package/src/components/interaction/form/Select/Select.spec.tsx +35 -1
  21. package/src/components/interaction/form/Select/Select.tsx +8 -7
  22. package/src/components/interaction/form/Textarea/Textarea.spec.tsx +18 -0
  23. package/src/components/interaction/form/Textarea/Textarea.tsx +3 -3
  24. package/src/components/ui/Collapsible/Collapsible.spec.tsx +1 -1
  25. package/src/components/ui/Collapsible/Collapsible.tsx +5 -10
  26. package/src/index.ts +1 -1
  27. package/src/native/Chevron.spec.tsx +34 -0
  28. package/src/native/Chevron.tsx +50 -0
  29. package/src/native/ThemeContext.spec.tsx +81 -1
  30. package/src/native/ThemeContext.tsx +32 -3
  31. package/src/test/cornerRadii.ts +15 -0
@@ -23,7 +23,7 @@ describe('Collapsible (native)', () => {
23
23
  </Collapsible>,
24
24
  );
25
25
  const chevron = screen.getByTestId('bds-collapsible-chevron');
26
- expect(chevron).toHaveStyle({ borderRightWidth: 2 });
26
+ expect(chevron).toHaveStyle({ borderRightWidth: '1.5px' });
27
27
  expect(chevron).not.toHaveTextContent('▸');
28
28
  });
29
29
 
@@ -3,6 +3,7 @@ import { StyleSheet, View, Pressable, Animated, LayoutAnimation, Platform, UIMan
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
 
7
8
  if (
8
9
  Platform.OS === 'android' &&
@@ -48,12 +49,6 @@ const styles = StyleSheet.create({
48
49
  lineHeight: lineHeight.body,
49
50
  },
50
51
  chevron: {
51
- // Border-box chevron (no glyph font dependency): a small square with two
52
- // adjacent borders, rotated to form a crisp "›" / "⌄" disclosure indicator.
53
- width: 8,
54
- height: 8,
55
- borderRightWidth: 2,
56
- borderBottomWidth: 2,
57
52
  marginStart: spacing.xs,
58
53
  },
59
54
  content: {
@@ -117,11 +112,11 @@ export function Collapsible({
117
112
  style={({ pressed }) => [styles.header, pressed && styles.pressed]}
118
113
  >
119
114
  <Text style={[styles.summaryText, { color: colors.colorOnBg }]}>{summary}</Text>
120
- <Animated.View
115
+ <Chevron
121
116
  testID="bds-collapsible-chevron"
122
- style={[styles.chevron, { borderColor: colors.colorOnBgSubtle, transform: [{ rotate }] }]}
123
- importantForAccessibility="no"
124
- accessibilityElementsHidden={true}
117
+ color={colors.colorOnBgSubtle}
118
+ rotate={rotate}
119
+ style={styles.chevron}
125
120
  />
126
121
  </Pressable>
127
122
  {isOpen && (
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,
@@ -0,0 +1,34 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { Chevron } from './Chevron';
3
+
4
+ describe('Chevron', () => {
5
+ it('draws an open V from two adjacent borders, without a glyph', () => {
6
+ render(<Chevron color="#123456" testID="chevron" />);
7
+ const chevron = screen.getByTestId('chevron');
8
+ expect(chevron).toHaveStyle({ borderRightWidth: '1.5px', borderBottomWidth: '1.5px' });
9
+ expect(chevron).toBeEmptyDOMElement();
10
+ });
11
+
12
+ it('strokes the V in the given colour', () => {
13
+ render(<Chevron color="#123456" testID="chevron" />);
14
+ expect(screen.getByTestId('chevron')).toHaveStyle({
15
+ borderRightColor: 'rgb(18, 52, 86)',
16
+ borderBottomColor: 'rgb(18, 52, 86)',
17
+ });
18
+ });
19
+
20
+ it('points down by default', () => {
21
+ render(<Chevron color="#123456" testID="chevron" />);
22
+ expect(getComputedStyle(screen.getByTestId('chevron')).transform).toMatch(/rotate\(45deg\)/);
23
+ });
24
+
25
+ it('takes another rotation', () => {
26
+ render(<Chevron color="#123456" rotate="-45deg" testID="chevron" />);
27
+ expect(getComputedStyle(screen.getByTestId('chevron')).transform).toMatch(/rotate\(-45deg\)/);
28
+ });
29
+
30
+ it('is hidden from assistive technology', () => {
31
+ render(<Chevron color="#123456" testID="chevron" />);
32
+ expect(screen.getByTestId('chevron')).toHaveAttribute('aria-hidden', 'true');
33
+ });
34
+ });
@@ -0,0 +1,50 @@
1
+ import { Animated, StyleSheet, StyleProp, ViewStyle } from 'react-native';
2
+
3
+ /** Edge of the bordered square. Rotated 45deg, the V is about 11pt wide: body-text size. */
4
+ const SIZE = 8;
5
+ const STROKE = 1.5;
6
+ /**
7
+ * The V's ink sits in the bordered corner of the square, not on its centre. Shifting the
8
+ * square back along its own diagonal (before the rotation) centres the ink on the layout
9
+ * box whichever way the chevron points: (SIZE - STROKE) / 4 on each axis.
10
+ */
11
+ const CENTRE_SHIFT = -(SIZE - STROKE) / 4;
12
+
13
+ const styles = StyleSheet.create({
14
+ chevron: {
15
+ // Border-box chevron (no glyph font dependency): a small square with two
16
+ // adjacent borders, rotated to form a crisp "›" / "⌄" indicator.
17
+ width: SIZE,
18
+ height: SIZE,
19
+ borderRightWidth: STROKE,
20
+ borderBottomWidth: STROKE,
21
+ },
22
+ });
23
+
24
+ export interface ChevronProps {
25
+ color: string;
26
+ /** '45deg' points down (the default), '-45deg' points right. Takes an Animated interpolation. */
27
+ rotate?: string | Animated.AnimatedInterpolation<string>;
28
+ testID?: string;
29
+ style?: StyleProp<ViewStyle>;
30
+ }
31
+
32
+ /** Decorative open "V", hidden from assistive technology. Internal: not part of the public API. */
33
+ export function Chevron({ color, rotate = '45deg', testID, style }: Readonly<ChevronProps>) {
34
+ return (
35
+ <Animated.View
36
+ testID={testID}
37
+ style={[
38
+ styles.chevron,
39
+ {
40
+ borderColor: color,
41
+ transform: [{ rotate }, { translateX: CENTRE_SHIFT }, { translateY: CENTRE_SHIFT }],
42
+ },
43
+ style,
44
+ ]}
45
+ aria-hidden
46
+ importantForAccessibility="no-hide-descendants"
47
+ accessibilityElementsHidden
48
+ />
49
+ );
50
+ }
@@ -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 { colors: { ...base, ...overrides }, isDark };
40
- }, [isDark, tokens]);
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
 
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The four corner radii of an element, clockwise from top-left.
3
+ *
4
+ * react-native-web expands `borderRadius` into the four corner longhands, and jsdom
5
+ * does not rebuild the `border-radius` shorthand from them, so a spec reads the corners.
6
+ */
7
+ export function cornerRadii(element: Element): string[] {
8
+ const style = getComputedStyle(element);
9
+ return [
10
+ style.borderTopLeftRadius,
11
+ style.borderTopRightRadius,
12
+ style.borderBottomRightRadius,
13
+ style.borderBottomLeftRadius,
14
+ ];
15
+ }