@boostdev/design-system-react-native 0.2.6 → 0.4.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 (28) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/index.cjs +166 -159
  3. package/dist/index.d.cts +13 -3
  4. package/dist/index.d.ts +13 -3
  5. package/dist/index.js +166 -159
  6. package/package.json +1 -1
  7. package/src/components/interaction/Dialog/Dialog.mdx +3 -2
  8. package/src/components/interaction/Dialog/Dialog.spec.tsx +42 -11
  9. package/src/components/interaction/Dialog/Dialog.tsx +19 -12
  10. package/src/components/interaction/DropdownMenu/DropdownMenu.mdx +3 -1
  11. package/src/components/interaction/DropdownMenu/DropdownMenu.spec.tsx +73 -0
  12. package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +79 -73
  13. package/src/components/interaction/Popover/Popover.mdx +3 -1
  14. package/src/components/interaction/Popover/Popover.spec.tsx +43 -3
  15. package/src/components/interaction/Popover/Popover.tsx +23 -13
  16. package/src/components/interaction/SideSheet/SideSheet.mdx +4 -3
  17. package/src/components/interaction/SideSheet/SideSheet.spec.tsx +8 -9
  18. package/src/components/interaction/SideSheet/SideSheet.tsx +9 -3
  19. package/src/components/interaction/form/SegmentedControl/SegmentedControl.mdx +17 -3
  20. package/src/components/interaction/form/SegmentedControl/SegmentedControl.spec.tsx +15 -0
  21. package/src/components/interaction/form/SegmentedControl/SegmentedControl.stories.tsx +9 -0
  22. package/src/components/interaction/form/SegmentedControl/SegmentedControl.tsx +2 -1
  23. package/src/components/ui/Alert/Alert.spec.tsx +32 -1
  24. package/src/components/ui/Alert/Alert.stories.tsx +45 -0
  25. package/src/components/ui/Alert/Alert.tsx +11 -2
  26. package/src/components/ui/NotificationBanner/NotificationBanner.spec.tsx +32 -1
  27. package/src/components/ui/NotificationBanner/NotificationBanner.stories.tsx +30 -0
  28. package/src/components/ui/NotificationBanner/NotificationBanner.tsx +16 -3
@@ -151,11 +151,17 @@ export function DropdownMenu({
151
151
  { backgroundColor: 'rgba(0, 0, 0, 0.5)', opacity: backdropAnim },
152
152
  ]}
153
153
  >
154
+ {/* The sheet sits on top of this backdrop as a sibling, so a
155
+ touch on the sheet never reaches this Pressable — it only
156
+ needs to handle taps on the exposed backdrop area. Hiding it
157
+ from assistive tech avoids a duplicate, full-screen "close"
158
+ control alongside the sheet's own Done button. */}
154
159
  <Pressable
155
160
  style={StyleSheet.absoluteFill}
156
161
  onPress={() => setOpen(false)}
157
- accessibilityRole="button"
158
- accessibilityLabel="Close menu"
162
+ testID="bds-dropdownmenu-backdrop"
163
+ accessible={false}
164
+ importantForAccessibility="no"
159
165
  />
160
166
  </Animated.View>
161
167
  <Animated.View
@@ -166,86 +172,86 @@ export function DropdownMenu({
166
172
  transform: [{ translateY: slideAnim }],
167
173
  },
168
174
  ]}
175
+ accessibilityViewIsModal
176
+ onAccessibilityEscape={() => setOpen(false)}
169
177
  >
170
- <Pressable
171
- onPress={() => {
172
- /* prevent close when pressing sheet */
173
- }}
174
- >
175
- <SafeAreaView>
176
- <View
177
- style={[
178
- styles.sheetHeader,
179
- { borderBottomColor: colors.colorBgSubtle },
180
- ]}
178
+ {/* No press-swallowing wrapper here: the backdrop is a sibling
179
+ behind the sheet, so sheet touches never reach it. On iOS a
180
+ Pressable is accessible by default and would turn the whole
181
+ sheet into one VoiceOver element, hiding every item. */}
182
+ <SafeAreaView>
183
+ <View
184
+ style={[
185
+ styles.sheetHeader,
186
+ { borderBottomColor: colors.colorBgSubtle },
187
+ ]}
188
+ >
189
+ <Text style={[styles.sheetTitle, { color: colors.colorOnBg }]}>
190
+ {title}
191
+ </Text>
192
+ <Pressable
193
+ accessibilityRole="button"
194
+ accessibilityLabel="Close"
195
+ onPress={() => setOpen(false)}
196
+ hitSlop={8}
181
197
  >
182
- <Text style={[styles.sheetTitle, { color: colors.colorOnBg }]}>
183
- {title}
184
- </Text>
185
- <Pressable
186
- accessibilityRole="button"
187
- accessibilityLabel="Close"
188
- onPress={() => setOpen(false)}
189
- hitSlop={8}
198
+ <Text
199
+ style={[
200
+ styles.closeText,
201
+ { color: colors.colorInteractive },
202
+ ]}
190
203
  >
191
- <Text
192
- style={[
193
- styles.closeText,
194
- { color: colors.colorInteractive },
204
+ Done
205
+ </Text>
206
+ </Pressable>
207
+ </View>
208
+ <FlatList
209
+ data={items}
210
+ keyExtractor={(item) => item.id}
211
+ renderItem={({ item }) => (
212
+ <>
213
+ {item.separator && (
214
+ <View
215
+ style={[
216
+ styles.separator,
217
+ { backgroundColor: colors.colorBgSubtle },
218
+ ]}
219
+ accessibilityRole="none"
220
+ />
221
+ )}
222
+ <Pressable
223
+ accessibilityRole="menuitem"
224
+ accessibilityLabel={item.label}
225
+ accessibilityState={{ disabled: item.disabled }}
226
+ disabled={item.disabled}
227
+ onPress={() => handleItemPress(item)}
228
+ style={({ pressed }) => [
229
+ styles.item,
230
+ pressed && { backgroundColor: colors.colorBgSubtle },
231
+ item.disabled && { opacity: 0.4 },
195
232
  ]}
196
233
  >
197
- Done
198
- </Text>
199
- </Pressable>
200
- </View>
201
- <FlatList
202
- data={items}
203
- keyExtractor={(item) => item.id}
204
- renderItem={({ item }) => (
205
- <>
206
- {item.separator && (
234
+ {!!item.icon && (
207
235
  <View
208
- style={[
209
- styles.separator,
210
- { backgroundColor: colors.colorBgSubtle },
211
- ]}
212
- accessibilityRole="none"
213
- />
236
+ importantForAccessibility="no-hide-descendants"
237
+ accessibilityElementsHidden={true}
238
+ >
239
+ {item.icon}
240
+ </View>
214
241
  )}
215
- <Pressable
216
- accessibilityRole="menuitem"
217
- accessibilityLabel={item.label}
218
- accessibilityState={{ disabled: item.disabled }}
219
- disabled={item.disabled}
220
- onPress={() => handleItemPress(item)}
221
- style={({ pressed }) => [
222
- styles.item,
223
- pressed && { backgroundColor: colors.colorBgSubtle },
224
- item.disabled && { opacity: 0.4 },
242
+ <Text
243
+ style={[
244
+ styles.itemText,
245
+ { color: colors.colorOnBg },
225
246
  ]}
226
247
  >
227
- {!!item.icon && (
228
- <View
229
- importantForAccessibility="no-hide-descendants"
230
- accessibilityElementsHidden={true}
231
- >
232
- {item.icon}
233
- </View>
234
- )}
235
- <Text
236
- style={[
237
- styles.itemText,
238
- { color: colors.colorOnBg },
239
- ]}
240
- >
241
- {item.label}
242
- </Text>
243
- </Pressable>
244
- </>
245
- )}
246
- />
247
- </SafeAreaView>
248
- </Pressable>
248
+ {item.label}
249
+ </Text>
250
+ </Pressable>
251
+ </>
252
+ )}
253
+ />
254
+ </SafeAreaView>
249
255
  </Animated.View>
250
256
  </View>
251
257
  </Modal>
@@ -56,6 +56,8 @@ export default function App() {
56
56
  ## Accessibility
57
57
 
58
58
  - Trigger has `accessibilityRole="button"` with `expanded` state
59
- - Backdrop press closes the popover
59
+ - The backdrop is a separate "Close popover" button beside the panel, not wrapped around it, so the content stays independently reachable; a VoiceOver double-tap on it closes the popover
60
+ - The content inside the panel is separately reachable by VoiceOver and XCUITest
61
+ - VoiceOver's escape gesture also closes the popover
60
62
  - Android back button triggers `onRequestClose` to dismiss
61
63
  - Panel has shadow elevation for visual depth
@@ -11,6 +11,18 @@ function Trigger({ children, ...rest }: { children: string; onPress?: () => void
11
11
  return <Pressable {...rest}><Text>{children}</Text></Pressable>;
12
12
  }
13
13
 
14
+ // react-native-web's Modal renders its fade animation wrapper as the parent of
15
+ // the ModalFocusTrap's `role="presentation"` sentinels. jsdom never dispatches
16
+ // the CSS `animationend` event that wrapper waits for before unmounting, so we
17
+ // read its `pointerEvents` instead to observe whether the Modal has closed.
18
+ function animationRoot(): HTMLElement {
19
+ const el = document.body.querySelector('[role="presentation"]')?.parentElement;
20
+ if (!(el instanceof HTMLElement)) {
21
+ throw new Error('Modal animation root not found');
22
+ }
23
+ return el;
24
+ }
25
+
14
26
  describe('Popover (native)', () => {
15
27
  it('renders the trigger element', () => {
16
28
  renderWithTheme(
@@ -50,16 +62,44 @@ describe('Popover (native)', () => {
50
62
  expect(trigger).toBeInTheDocument();
51
63
  });
52
64
 
53
- it('renders backdrop with close label when open', () => {
65
+ it('renders a backdrop button, separate from the content, that closes the popover', () => {
66
+ renderWithTheme(
67
+ <Popover content={<Text>Closable</Text>} accessibilityLabel="Test">
68
+ <Trigger>Trigger</Trigger>
69
+ </Popover>,
70
+ );
71
+ fireEvent.click(screen.getByRole('button', { name: 'Test' }));
72
+ expect(screen.getByText('Closable')).toBeInTheDocument();
73
+
74
+ const backdrop = screen.getByTestId('bds-popover-backdrop');
75
+ expect(backdrop).toHaveAttribute('role', 'button');
76
+ expect(screen.getByRole('button', { name: 'Close popover' })).toBe(backdrop);
77
+ expect(backdrop).not.toContainElement(screen.getByText('Closable'));
78
+
79
+ // jsdom never dispatches the CSS `animationend` event that react-native-web's
80
+ // fade-out Modal waits for before it unmounts, so a press that closes the
81
+ // popover can't be observed here as the content leaving the DOM. Assert the
82
+ // state change react-native-web *does* apply synchronously instead: the
83
+ // Modal's animation root switches from interactive to inert.
84
+ expect(getComputedStyle(animationRoot()).pointerEvents).not.toBe('none');
85
+ fireEvent.click(backdrop);
86
+ expect(getComputedStyle(animationRoot()).pointerEvents).toBe('none');
87
+ });
88
+
89
+ it('does not close the popover when the content is pressed', () => {
54
90
  renderWithTheme(
55
91
  <Popover content={<Text>Closable</Text>} accessibilityLabel="Test">
56
92
  <Trigger>Trigger</Trigger>
57
93
  </Popover>,
58
94
  );
59
95
  fireEvent.click(screen.getByRole('button', { name: 'Test' }));
96
+ fireEvent.click(screen.getByText('Closable'));
60
97
  expect(screen.getByText('Closable')).toBeInTheDocument();
61
- // Verify the close backdrop button exists
62
- expect(screen.getByRole('button', { name: 'Close popover' })).toBeInTheDocument();
98
+ // `getByText` alone would still pass even if the press HAD closed the
99
+ // popover, since the fade-out Modal never unmounts in jsdom (see
100
+ // `animationRoot` above). Assert the Modal stayed interactive too, so this
101
+ // test actually fails if a content press starts closing the popover.
102
+ expect(getComputedStyle(animationRoot()).pointerEvents).not.toBe('none');
63
103
  });
64
104
 
65
105
  it('uses default accessibility label when none provided', () => {
@@ -49,6 +49,7 @@ export function Popover({
49
49
  }: Readonly<PopoverProps>) {
50
50
  const { colors } = useTheme();
51
51
  const [isOpen, setIsOpen] = useState(false);
52
+ const close = () => setIsOpen(false);
52
53
 
53
54
  return (
54
55
  <View style={[styles.wrapper, style]}>
@@ -62,25 +63,34 @@ export function Popover({
62
63
  visible={isOpen}
63
64
  transparent
64
65
  animationType="fade"
65
- onRequestClose={() => setIsOpen(false)}
66
+ onRequestClose={close}
66
67
  >
67
- <Pressable
68
- style={styles.overlay}
69
- onPress={() => setIsOpen(false)}
70
- accessibilityRole="button"
71
- accessibilityLabel="Close popover"
72
- >
68
+ {/*
69
+ * A Pressable is accessible by default on iOS, which makes it a leaf:
70
+ * VoiceOver cannot reach anything nested inside it. Popover has no ✕
71
+ * button, so the backdrop stays the accessible close — a sibling
72
+ * "Close popover" button beside the panel, not wrapped around it, so
73
+ * the content underneath stays separately reachable. onAccessibilityTap
74
+ * matters here: without it, a VoiceOver double-tap activates the
75
+ * element's centre, which lies under the centred panel, so it would
76
+ * never close.
77
+ */}
78
+ <View style={styles.overlay} onAccessibilityEscape={close}>
73
79
  <Pressable
80
+ testID="bds-popover-backdrop"
81
+ style={StyleSheet.absoluteFill}
82
+ onPress={close}
83
+ onAccessibilityTap={close}
84
+ accessibilityRole="button"
85
+ accessibilityLabel="Close popover"
86
+ />
87
+ <View
74
88
  style={[styles.panel, { backgroundColor: colors.colorBg }]}
75
- onPress={() => {
76
- /* prevent close when pressing panel */
77
- }}
78
- accessibilityRole="none"
79
89
  accessibilityLabel={accessibilityLabel}
80
90
  >
81
91
  {content}
82
- </Pressable>
83
- </Pressable>
92
+ </View>
93
+ </View>
84
94
  </Modal>
85
95
  </View>
86
96
  );
@@ -47,14 +47,15 @@ export default function App() {
47
47
  | Prop | Type | Default | Description |
48
48
  |------|------|---------|-------------|
49
49
  | `isOpen` | `boolean` | — | Controls visibility of the side sheet |
50
- | `onClose` | `() => void` | — | Called when backdrop or close button is pressed |
50
+ | `onClose` | `() => void` | — | Called when the backdrop or the close (✕) button is pressed |
51
51
  | `title` | `ReactNode` | — | Title displayed in the side sheet header |
52
52
  | `children` | `ReactNode` | — | Side sheet body content |
53
53
  | `style` | `StyleProp<ViewStyle>` | — | Override panel styles |
54
54
 
55
55
  ## Accessibility
56
56
 
57
- - Backdrop press and close button both call `onClose`
58
- - Close button has `accessibilityLabel="Close side sheet"`
57
+ - Backdrop press and the close (✕) button both call `onClose`, but the backdrop is hidden from assistive tech (`accessible={false}`, `importantForAccessibility="no"`) so it doesn't duplicate the accessible close control
58
+ - The close (✕) button is the accessible close control, with `accessibilityLabel="Close side sheet"`
59
+ - The panel is marked `accessibilityViewIsModal`; VoiceOver's escape gesture (`onAccessibilityEscape`) also calls `onClose`
59
60
  - Android back button triggers `onRequestClose` which calls `onClose`
60
61
  - Backdrop fades in, panel slides from bottom
@@ -34,38 +34,37 @@ describe('SideSheet (native)', () => {
34
34
  expect(screen.getByText('Settings')).toBeInTheDocument();
35
35
  });
36
36
 
37
- it('renders a close button with accessible label', () => {
37
+ it('renders exactly one accessible close button, labelled for assistive tech', () => {
38
38
  renderWithTheme(
39
39
  <SideSheet isOpen onClose={() => {}}>
40
40
  <span>Body</span>
41
41
  </SideSheet>,
42
42
  );
43
43
  const closeButtons = screen.getAllByRole('button', { name: 'Close side sheet' });
44
- expect(closeButtons.length).toBeGreaterThanOrEqual(1);
44
+ expect(closeButtons).toHaveLength(1);
45
45
  });
46
46
 
47
- it('calls onClose when close button is pressed', () => {
47
+ it('calls onClose when the close (X) button is pressed', () => {
48
48
  const onClose = vi.fn();
49
49
  renderWithTheme(
50
50
  <SideSheet isOpen onClose={onClose} title="Test">
51
51
  <span>Body</span>
52
52
  </SideSheet>,
53
53
  );
54
- const closeButtons = screen.getAllByRole('button', { name: 'Close side sheet' });
55
- const innerClose = closeButtons[closeButtons.length - 1];
56
- fireEvent.click(innerClose);
54
+ fireEvent.click(screen.getByRole('button', { name: 'Close side sheet' }));
57
55
  expect(onClose).toHaveBeenCalledTimes(1);
58
56
  });
59
57
 
60
- it('calls onClose when backdrop is pressed', () => {
58
+ it('has an unlabelled, roleless backdrop that calls onClose when pressed', () => {
61
59
  const onClose = vi.fn();
62
60
  renderWithTheme(
63
61
  <SideSheet isOpen onClose={onClose}>
64
62
  <span>Body</span>
65
63
  </SideSheet>,
66
64
  );
67
- const closeButtons = screen.getAllByRole('button', { name: 'Close side sheet' });
68
- fireEvent.click(closeButtons[0]);
65
+ const backdrop = screen.getByTestId('bds-sidesheet-backdrop');
66
+ expect(backdrop).not.toHaveAttribute('role');
67
+ fireEvent.click(backdrop);
69
68
  expect(onClose).toHaveBeenCalledTimes(1);
70
69
  });
71
70
 
@@ -128,11 +128,16 @@ export function SideSheet({
128
128
  { backgroundColor: 'rgba(0, 0, 0, 0.5)', opacity: backdropAnim },
129
129
  ]}
130
130
  >
131
+ {/* The panel sits on top of this backdrop as a sibling, so a
132
+ touch on the panel never reaches this Pressable. Hiding it
133
+ from assistive tech avoids a duplicate, full-screen "close"
134
+ control alongside the panel's own close button. */}
131
135
  <Pressable
132
136
  style={StyleSheet.absoluteFill}
133
137
  onPress={onClose}
134
- accessibilityRole="button"
135
- accessibilityLabel="Close side sheet"
138
+ testID="bds-sidesheet-backdrop"
139
+ accessible={false}
140
+ importantForAccessibility="no"
136
141
  />
137
142
  </Animated.View>
138
143
  <Animated.View
@@ -144,7 +149,8 @@ export function SideSheet({
144
149
  },
145
150
  style,
146
151
  ]}
147
- accessibilityRole="none"
152
+ accessibilityViewIsModal
153
+ onAccessibilityEscape={onClose}
148
154
  >
149
155
  <View
150
156
  style={[styles.header, { borderBottomColor: colors.colorBgSubtle }]}
@@ -5,7 +5,7 @@ import * as Stories from './SegmentedControl.stories';
5
5
 
6
6
  # SegmentedControl (React Native)
7
7
 
8
- A horizontal control with multiple segments, where each segment acts as a tab. Supports `outline` and `filled` variants with three size options.
8
+ A horizontal control with multiple segments, where each segment acts as a tab. Supports `outline` and `filled` variants with four size options.
9
9
 
10
10
  > **Status: alpha** -- API may change before stable release.
11
11
 
@@ -46,6 +46,9 @@ function Example() {
46
46
  ### Large
47
47
  <Canvas of={Stories.Large} />
48
48
 
49
+ ### XLarge
50
+ <Canvas of={Stories.XLarge} />
51
+
49
52
  ### Disabled
50
53
  <Canvas of={Stories.Disabled} />
51
54
 
@@ -65,16 +68,27 @@ function Example() {
65
68
  |------|------|---------|-------------|
66
69
  | `selectedIndex` | number | `0` | Zero-based index of the active segment |
67
70
  | `disabled` | boolean | `false` | Disables all segments |
68
- | `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Controls height and font size |
71
+ | `size` | `'small' \| 'medium' \| 'large' \| 'xlarge'` | `'medium'` | Controls height and font size |
69
72
  | `variant` | `'outline' \| 'filled'` | `'outline'` | Visual style of the control |
70
73
  | `children` | ReactNode | -- | Segment labels (strings or elements) |
71
74
  | `accessibilityLabel` | string | -- | Label for the tab group |
72
75
  | `onValueChange` | `(index: number) => void` | -- | Called with the tapped segment index |
73
76
 
77
+ ## Sizes
78
+
79
+ | Size | minHeight | fontSize | Horizontal padding | Vertical padding |
80
+ |------|-----------|----------|---------------------|-------------------|
81
+ | `small` | 32 | `font.size.bodyS` | `spacing.s` | `spacing.xxs` |
82
+ | `medium` | 44 | `font.size.body` | `spacing.m` | `spacing.xs` |
83
+ | `large` | 48 | `font.size.body` | `spacing.m` | `spacing.s` |
84
+ | `xlarge` | 56 | `font.size.body` | `spacing.m` | `spacing.s` |
85
+
86
+ `xlarge` matches the DS `Button`'s own `large` height (56 pt) — use it wherever a 56 pt tap target is required (e.g. CoachHelpr's app-wide minimum).
87
+
74
88
  ## Visual structure
75
89
 
76
90
  - **Track** — a single rounded container (`flexDirection: 'row'`, `borderRadius: full`, `colorBgSubtle` background, 2 px inner padding). No per-segment borders.
77
- - **Segments** — equal-width (`flex: 1`), min touch target 44 dp for medium/large.
91
+ - **Segments** — equal-width (`flex: 1`), min touch target 44 dp for medium/large, 56 dp for xlarge.
78
92
  - **Selected thumb** — contrasting background rendered on the active segment only:
79
93
  - `outline` variant → `colorBg` (elevated white / dark-elevated) + subtle shadow, label uses `colorInteractiveOnBg`.
80
94
  - `filled` variant → `colorInteractive` background, label uses `colorOnInteractive`.
@@ -121,4 +121,19 @@ describe('SegmentedControl (native)', () => {
121
121
  expect(screen.getByText('Day')).toBeInTheDocument();
122
122
  expect(screen.getByText('Week')).toBeInTheDocument();
123
123
  });
124
+
125
+ it('renders an xlarge segment with a 56pt minHeight', () => {
126
+ renderWithTheme(
127
+ <SegmentedControl accessibilityLabel="View" size="xlarge">
128
+ {'Day'}
129
+ {'Week'}
130
+ </SegmentedControl>,
131
+ );
132
+ const tabs = screen.getAllByRole('tab');
133
+ expect(tabs).toHaveLength(2);
134
+ tabs.forEach((tab) => {
135
+ const style = tab.getAttribute('style') ?? '';
136
+ expect(style).toMatch(/min-height:\s*56px/);
137
+ });
138
+ });
124
139
  });
@@ -50,6 +50,15 @@ export const Large: Story = {
50
50
  },
51
51
  };
52
52
 
53
+ export const XLarge: Story = {
54
+ args: {
55
+ size: 'xlarge',
56
+ selectedIndex: 0,
57
+ children: ['First', 'Second', 'Third'],
58
+ accessibilityLabel: 'Selection',
59
+ },
60
+ };
61
+
53
62
  export const Disabled: Story = {
54
63
  args: {
55
64
  selectedIndex: 0,
@@ -3,7 +3,7 @@ import { StyleSheet, View, Pressable, Text, StyleProp, ViewStyle } from 'react-n
3
3
  import { spacing, font, border } from '../../../../native/tokens';
4
4
  import { useTheme } from '../../../../native/ThemeContext';
5
5
 
6
- type SegmentedControlSize = 'small' | 'medium' | 'large';
6
+ type SegmentedControlSize = 'small' | 'medium' | 'large' | 'xlarge';
7
7
  type SegmentedControlVariant = 'outline' | 'filled';
8
8
 
9
9
  interface SegmentedControlProps {
@@ -24,6 +24,7 @@ const sizeTokens: Record<SegmentedControlSize, { minHeight: number; fontSize: nu
24
24
  small: { minHeight: 32, fontSize: font.size.bodyS, px: spacing.s, py: spacing.xxs },
25
25
  medium: { minHeight: 44, fontSize: font.size.body, px: spacing.m, py: spacing.xs },
26
26
  large: { minHeight: 48, fontSize: font.size.body, px: spacing.m, py: spacing.s },
27
+ xlarge: { minHeight: 56, fontSize: font.size.body, px: spacing.m, py: spacing.s },
27
28
  };
28
29
 
29
30
  const thumbShadow = {
@@ -1,5 +1,5 @@
1
1
  import { render, screen, fireEvent } from '@testing-library/react';
2
- import { Text } from 'react-native';
2
+ import { Text, View, Pressable } from 'react-native';
3
3
  import { ThemeProvider } from '../../../native/ThemeContext';
4
4
  import { Alert } from './Alert';
5
5
 
@@ -13,6 +13,37 @@ describe('Alert (native)', () => {
13
13
  expect(screen.getByText('Something happened')).toBeInTheDocument();
14
14
  });
15
15
 
16
+ it('renders non-text children such as a view with an action', () => {
17
+ const onPress = vi.fn();
18
+ renderWithTheme(
19
+ <Alert variant="warning" title="Storage almost full">
20
+ <View>
21
+ <Text>Almost out of space</Text>
22
+ <Pressable accessibilityRole="button" onPress={onPress}>
23
+ <Text>Upgrade</Text>
24
+ </Pressable>
25
+ </View>
26
+ </Alert>,
27
+ );
28
+ expect(screen.getByText('Almost out of space')).toBeInTheDocument();
29
+ fireEvent.click(screen.getByRole('button', { name: 'Upgrade' }));
30
+ expect(onPress).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ it('passes the resolved text color to a function child', () => {
34
+ let receivedColor = '';
35
+ renderWithTheme(
36
+ <Alert variant="info">
37
+ {({ color }) => {
38
+ receivedColor = color;
39
+ return <Text style={{ color }}>Themed body</Text>;
40
+ }}
41
+ </Alert>,
42
+ );
43
+ expect(screen.getByText('Themed body')).toBeInTheDocument();
44
+ expect(receivedColor).not.toBe('');
45
+ });
46
+
16
47
  it('renders the title when provided', () => {
17
48
  renderWithTheme(<Alert title="Heads up">Details here</Alert>);
18
49
  expect(screen.getByText('Heads up')).toBeInTheDocument();
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { View, Text, Pressable } from 'react-native';
2
3
  import type { Meta, StoryObj } from '@storybook/react';
3
4
  import { ThemeProvider } from '../../../native/ThemeContext';
4
5
  import { Alert } from './Alert';
@@ -39,3 +40,47 @@ export const AllVariants: Story = {
39
40
  </div>
40
41
  ),
41
42
  };
43
+
44
+ // A string child gets the default body styling automatically. A non-text child
45
+ // (View, Pressable, custom nodes) is rendered as-is, so it can hold any layout.
46
+ export const WithRichContent: Story = {
47
+ args: { children: '', variant: 'warning', title: 'Storage almost full' },
48
+ render: (args) => (
49
+ <Alert {...args}>
50
+ <View style={{ gap: 8 }}>
51
+ <Text style={{ fontSize: 13 }}>You are using 95% of your storage quota.</Text>
52
+ <Pressable
53
+ accessibilityRole="button"
54
+ onPress={() => {}}
55
+ style={{ alignSelf: 'flex-start' }}
56
+ >
57
+ <Text style={{ fontWeight: '600' }}>Upgrade plan</Text>
58
+ </Pressable>
59
+ </View>
60
+ </Alert>
61
+ ),
62
+ };
63
+
64
+ // Pass a function child to receive the variant's resolved text color, so custom
65
+ // content matches the alert's theme automatically.
66
+ export const WithThemedContent: Story = {
67
+ args: { children: '', variant: 'error', title: 'Payment failed' },
68
+ render: (args) => (
69
+ <Alert {...args}>
70
+ {({ color }) => (
71
+ <View style={{ gap: 8 }}>
72
+ <Text style={{ color, fontSize: 13 }}>
73
+ Your card was declined. Update your payment method to continue.
74
+ </Text>
75
+ <Pressable
76
+ accessibilityRole="button"
77
+ onPress={() => {}}
78
+ style={{ alignSelf: 'flex-start' }}
79
+ >
80
+ <Text style={{ color, fontWeight: '600' }}>Update payment method</Text>
81
+ </Pressable>
82
+ </View>
83
+ )}
84
+ </Alert>
85
+ ),
86
+ };
@@ -5,11 +5,15 @@ import { useTheme } from '../../../native/ThemeContext';
5
5
 
6
6
  type AlertVariant = 'info' | 'success' | 'warning' | 'error';
7
7
 
8
+ /** Function form of children: receives the variant's resolved text color so
9
+ * custom content can match the alert's theme. */
10
+ type AlertContentRenderProp = (props: { color: string }) => ReactNode;
11
+
8
12
  interface AlertProps {
9
13
  variant?: AlertVariant;
10
14
  icon?: ReactNode;
11
15
  title?: string;
12
- children: ReactNode;
16
+ children: ReactNode | AlertContentRenderProp;
13
17
  onDismiss?: () => void;
14
18
  style?: StyleProp<ViewStyle>;
15
19
  }
@@ -66,6 +70,7 @@ export function Alert({
66
70
  };
67
71
 
68
72
  const { bg, text, border: borderColor } = variantColors[variant];
73
+ const resolvedChildren = typeof children === 'function' ? children({ color: text }) : children;
69
74
 
70
75
  return (
71
76
  <View
@@ -76,7 +81,11 @@ export function Alert({
76
81
  {icon && <View style={styles.icon}>{icon}</View>}
77
82
  <View style={styles.content}>
78
83
  {title && <Text style={[styles.title, { color: text }]}>{title}</Text>}
79
- <Text style={[styles.body, { color: text }]}>{children}</Text>
84
+ {typeof resolvedChildren === 'string' || typeof resolvedChildren === 'number' ? (
85
+ <Text style={[styles.body, { color: text }]}>{resolvedChildren}</Text>
86
+ ) : (
87
+ resolvedChildren
88
+ )}
80
89
  </View>
81
90
  {onDismiss && (
82
91
  <Pressable