@boostdev/design-system-react-native 0.2.6 → 0.3.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 +77 -0
- package/dist/index.cjs +164 -158
- package/dist/index.d.cts +12 -2
- package/dist/index.d.ts +12 -2
- package/dist/index.js +164 -158
- package/package.json +1 -1
- package/src/components/interaction/Dialog/Dialog.mdx +3 -2
- package/src/components/interaction/Dialog/Dialog.spec.tsx +42 -11
- package/src/components/interaction/Dialog/Dialog.tsx +19 -12
- package/src/components/interaction/DropdownMenu/DropdownMenu.mdx +3 -1
- package/src/components/interaction/DropdownMenu/DropdownMenu.spec.tsx +73 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.tsx +79 -73
- package/src/components/interaction/Popover/Popover.mdx +3 -1
- package/src/components/interaction/Popover/Popover.spec.tsx +43 -3
- package/src/components/interaction/Popover/Popover.tsx +23 -13
- package/src/components/interaction/SideSheet/SideSheet.mdx +4 -3
- package/src/components/interaction/SideSheet/SideSheet.spec.tsx +8 -9
- package/src/components/interaction/SideSheet/SideSheet.tsx +9 -3
- package/src/components/ui/Alert/Alert.spec.tsx +32 -1
- package/src/components/ui/Alert/Alert.stories.tsx +45 -0
- package/src/components/ui/Alert/Alert.tsx +11 -2
- package/src/components/ui/NotificationBanner/NotificationBanner.spec.tsx +32 -1
- package/src/components/ui/NotificationBanner/NotificationBanner.stories.tsx +30 -0
- 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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
>
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
/>
|
|
236
|
+
importantForAccessibility="no-hide-descendants"
|
|
237
|
+
accessibilityElementsHidden={true}
|
|
238
|
+
>
|
|
239
|
+
{item.icon}
|
|
240
|
+
</View>
|
|
214
241
|
)}
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
{
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
//
|
|
62
|
-
|
|
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={
|
|
66
|
+
onRequestClose={close}
|
|
66
67
|
>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
</
|
|
83
|
-
</
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
152
|
+
accessibilityViewIsModal
|
|
153
|
+
onAccessibilityEscape={onClose}
|
|
148
154
|
>
|
|
149
155
|
<View
|
|
150
156
|
style={[styles.header, { borderBottomColor: colors.colorBgSubtle }]}
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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 { NotificationBanner } from './NotificationBanner';
|
|
5
5
|
|
|
@@ -13,6 +13,37 @@ describe('NotificationBanner (native)', () => {
|
|
|
13
13
|
expect(screen.getByText('Update available')).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
|
+
<NotificationBanner variant="warning">
|
|
20
|
+
<View>
|
|
21
|
+
<Text>Session expiring</Text>
|
|
22
|
+
<Pressable accessibilityRole="button" onPress={onPress}>
|
|
23
|
+
<Text>Extend</Text>
|
|
24
|
+
</Pressable>
|
|
25
|
+
</View>
|
|
26
|
+
</NotificationBanner>,
|
|
27
|
+
);
|
|
28
|
+
expect(screen.getByText('Session expiring')).toBeInTheDocument();
|
|
29
|
+
fireEvent.click(screen.getByRole('button', { name: 'Extend' }));
|
|
30
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('passes the resolved text color to a function child', () => {
|
|
34
|
+
let receivedColor = '';
|
|
35
|
+
renderWithTheme(
|
|
36
|
+
<NotificationBanner variant="info">
|
|
37
|
+
{({ color }) => {
|
|
38
|
+
receivedColor = color;
|
|
39
|
+
return <Text style={{ color }}>Themed content</Text>;
|
|
40
|
+
}}
|
|
41
|
+
</NotificationBanner>,
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByText('Themed content')).toBeInTheDocument();
|
|
44
|
+
expect(receivedColor).not.toBe('');
|
|
45
|
+
});
|
|
46
|
+
|
|
16
47
|
it('has alert role for error variant', () => {
|
|
17
48
|
renderWithTheme(
|
|
18
49
|
<NotificationBanner variant="error">Something went wrong</NotificationBanner>,
|
|
@@ -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 { NotificationBanner } from './NotificationBanner';
|
|
@@ -35,3 +36,32 @@ export const AllVariants: Story = {
|
|
|
35
36
|
</div>
|
|
36
37
|
),
|
|
37
38
|
};
|
|
39
|
+
|
|
40
|
+
// A string child keeps the default body styling. A non-text child renders as-is.
|
|
41
|
+
export const WithRichContent: Story = {
|
|
42
|
+
args: { children: '', variant: 'warning' },
|
|
43
|
+
render: (args) => (
|
|
44
|
+
<NotificationBanner {...args}>
|
|
45
|
+
<View style={{ gap: 2 }}>
|
|
46
|
+
<Text style={{ fontSize: 13, fontWeight: '600' }}>Session expiring</Text>
|
|
47
|
+
<Text style={{ fontSize: 13 }}>You will be signed out in 2 minutes.</Text>
|
|
48
|
+
</View>
|
|
49
|
+
</NotificationBanner>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Pass a function child to receive the variant's resolved text color, so custom
|
|
54
|
+
// content matches the banner's theme automatically.
|
|
55
|
+
export const WithThemedContent: Story = {
|
|
56
|
+
args: { children: '', variant: 'error' },
|
|
57
|
+
render: (args) => (
|
|
58
|
+
<NotificationBanner {...args}>
|
|
59
|
+
{({ color }) => (
|
|
60
|
+
<View style={{ gap: 2 }}>
|
|
61
|
+
<Text style={{ color, fontSize: 13, fontWeight: '600' }}>Sync failed</Text>
|
|
62
|
+
<Text style={{ color, fontSize: 13 }}>Check your connection and try again.</Text>
|
|
63
|
+
</View>
|
|
64
|
+
)}
|
|
65
|
+
</NotificationBanner>
|
|
66
|
+
),
|
|
67
|
+
};
|