@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.
- package/CHANGELOG.md +94 -0
- package/dist/index.cjs +166 -159
- package/dist/index.d.cts +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +166 -159
- 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/interaction/form/SegmentedControl/SegmentedControl.mdx +17 -3
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.spec.tsx +15 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.stories.tsx +9 -0
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.tsx +2 -1
- 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 }]}
|
|
@@ -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
|
|
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
|
-
|
|
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
|