@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
package/dist/index.js CHANGED
@@ -435,6 +435,7 @@ function Alert({
435
435
  error: { bg: colors2.colorRedSubtle, text: colors2.colorOnBg, border: colors2.colorError }
436
436
  };
437
437
  const { bg, text, border: borderColor } = variantColors[variant];
438
+ const resolvedChildren = typeof children === "function" ? children({ color: text }) : children;
438
439
  return /* @__PURE__ */ jsxs2(
439
440
  View5,
440
441
  {
@@ -445,7 +446,7 @@ function Alert({
445
446
  icon && /* @__PURE__ */ jsx9(View5, { style: styles7.icon, children: icon }),
446
447
  /* @__PURE__ */ jsxs2(View5, { style: styles7.content, children: [
447
448
  title && /* @__PURE__ */ jsx9(Text5, { style: [styles7.title, { color: text }], children: title }),
448
- /* @__PURE__ */ jsx9(Text5, { style: [styles7.body, { color: text }], children })
449
+ typeof resolvedChildren === "string" || typeof resolvedChildren === "number" ? /* @__PURE__ */ jsx9(Text5, { style: [styles7.body, { color: text }], children: resolvedChildren }) : resolvedChildren
449
450
  ] }),
450
451
  onDismiss && /* @__PURE__ */ jsx9(
451
452
  Pressable,
@@ -474,8 +475,10 @@ var styles8 = StyleSheet8.create({
474
475
  paddingHorizontal: spacing.m,
475
476
  gap: spacing.xs
476
477
  },
478
+ body: {
479
+ flex: 1
480
+ },
477
481
  content: {
478
- flex: 1,
479
482
  fontSize: font.size.bodyS,
480
483
  fontFamily: font.family.body,
481
484
  lineHeight: lineHeight.bodyS
@@ -501,6 +504,7 @@ function NotificationBanner({
501
504
  error: { bg: colors2.colorRedSubtle, text: colors2.colorOnBg, border: colors2.colorError }
502
505
  };
503
506
  const { bg, text, border: borderColor } = variantColors[variant];
507
+ const resolvedChildren = typeof children === "function" ? children({ color: text }) : children;
504
508
  return /* @__PURE__ */ jsxs3(
505
509
  View6,
506
510
  {
@@ -508,7 +512,7 @@ function NotificationBanner({
508
512
  accessibilityLiveRegion: isUrgent ? "assertive" : "polite",
509
513
  style: [styles8.banner, { backgroundColor: bg, borderBottomWidth: 3, borderBottomColor: borderColor }, style],
510
514
  children: [
511
- /* @__PURE__ */ jsx10(Text6, { style: [styles8.content, { color: text }], children }),
515
+ /* @__PURE__ */ jsx10(View6, { style: styles8.body, children: typeof resolvedChildren === "string" || typeof resolvedChildren === "number" ? /* @__PURE__ */ jsx10(Text6, { style: [styles8.content, { color: text }], children: resolvedChildren }) : resolvedChildren }),
512
516
  action,
513
517
  onDismiss && /* @__PURE__ */ jsx10(
514
518
  Pressable2,
@@ -2840,7 +2844,8 @@ var TRACK_PADDING = spacing.xxxs;
2840
2844
  var sizeTokens2 = {
2841
2845
  small: { minHeight: 32, fontSize: font.size.bodyS, px: spacing.s, py: spacing.xxs },
2842
2846
  medium: { minHeight: 44, fontSize: font.size.body, px: spacing.m, py: spacing.xs },
2843
- large: { minHeight: 48, fontSize: font.size.body, px: spacing.m, py: spacing.s }
2847
+ large: { minHeight: 48, fontSize: font.size.body, px: spacing.m, py: spacing.s },
2848
+ xlarge: { minHeight: 56, fontSize: font.size.body, px: spacing.m, py: spacing.s }
2844
2849
  };
2845
2850
  var thumbShadow = {
2846
2851
  shadowColor: "#000",
@@ -3782,46 +3787,48 @@ function Dialog({
3782
3787
  transparent: true,
3783
3788
  animationType: "fade",
3784
3789
  onRequestClose: onClose,
3785
- children: /* @__PURE__ */ jsx44(
3786
- Pressable22,
3787
- {
3788
- style: styles40.overlay,
3789
- onPress: onClose,
3790
- accessibilityRole: "button",
3791
- accessibilityLabel: "Close dialog",
3792
- children: /* @__PURE__ */ jsxs29(
3793
- Pressable22,
3794
- {
3795
- style: [styles40.panel, { backgroundColor: colors2.colorGreySubtle }, style],
3796
- onPress: () => {
3797
- },
3798
- accessibilityRole: "none",
3799
- children: [
3800
- /* @__PURE__ */ jsx44(
3801
- Pressable22,
3802
- {
3803
- style: styles40.closeButton,
3804
- onPress: onClose,
3805
- accessibilityRole: "button",
3806
- accessibilityLabel: "Close dialog",
3807
- hitSlop: 8,
3808
- children: /* @__PURE__ */ jsx44(
3809
- Text32,
3810
- {
3811
- style: [styles40.closeText, { color: colors2.colorOnBgSubtle }],
3812
- "aria-hidden": true,
3813
- children: "\u2715"
3814
- }
3815
- )
3816
- }
3817
- ),
3818
- !!title && /* @__PURE__ */ jsx44(View37, { style: styles40.header, children: /* @__PURE__ */ jsx44(Text32, { style: [styles40.title, { color: colors2.colorOnGreySubtle }], children: title }) }),
3819
- children
3820
- ]
3821
- }
3822
- )
3823
- }
3824
- )
3790
+ children: /* @__PURE__ */ jsxs29(View37, { style: styles40.overlay, children: [
3791
+ /* @__PURE__ */ jsx44(
3792
+ Pressable22,
3793
+ {
3794
+ testID: "bds-dialog-backdrop",
3795
+ style: StyleSheet40.absoluteFill,
3796
+ onPress: onClose,
3797
+ accessible: false,
3798
+ importantForAccessibility: "no"
3799
+ }
3800
+ ),
3801
+ /* @__PURE__ */ jsxs29(
3802
+ View37,
3803
+ {
3804
+ style: [styles40.panel, { backgroundColor: colors2.colorGreySubtle }, style],
3805
+ accessibilityViewIsModal: true,
3806
+ onAccessibilityEscape: onClose,
3807
+ children: [
3808
+ /* @__PURE__ */ jsx44(
3809
+ Pressable22,
3810
+ {
3811
+ style: styles40.closeButton,
3812
+ onPress: onClose,
3813
+ accessibilityRole: "button",
3814
+ accessibilityLabel: "Close dialog",
3815
+ hitSlop: 8,
3816
+ children: /* @__PURE__ */ jsx44(
3817
+ Text32,
3818
+ {
3819
+ style: [styles40.closeText, { color: colors2.colorOnBgSubtle }],
3820
+ "aria-hidden": true,
3821
+ children: "\u2715"
3822
+ }
3823
+ )
3824
+ }
3825
+ ),
3826
+ !!title && /* @__PURE__ */ jsx44(View37, { style: styles40.header, children: /* @__PURE__ */ jsx44(Text32, { style: [styles40.title, { color: colors2.colorOnGreySubtle }], children: title }) }),
3827
+ children
3828
+ ]
3829
+ }
3830
+ )
3831
+ ] })
3825
3832
  }
3826
3833
  );
3827
3834
  }
@@ -3947,8 +3954,9 @@ function SideSheet({
3947
3954
  {
3948
3955
  style: StyleSheet41.absoluteFill,
3949
3956
  onPress: onClose,
3950
- accessibilityRole: "button",
3951
- accessibilityLabel: "Close side sheet"
3957
+ testID: "bds-sidesheet-backdrop",
3958
+ accessible: false,
3959
+ importantForAccessibility: "no"
3952
3960
  }
3953
3961
  )
3954
3962
  }
@@ -3964,7 +3972,8 @@ function SideSheet({
3964
3972
  },
3965
3973
  style
3966
3974
  ],
3967
- accessibilityRole: "none",
3975
+ accessibilityViewIsModal: true,
3976
+ onAccessibilityEscape: onClose,
3968
3977
  children: [
3969
3978
  /* @__PURE__ */ jsxs30(
3970
3979
  View38,
@@ -4300,8 +4309,9 @@ function DropdownMenu({
4300
4309
  {
4301
4310
  style: StyleSheet43.absoluteFill,
4302
4311
  onPress: () => setOpen(false),
4303
- accessibilityRole: "button",
4304
- accessibilityLabel: "Close menu"
4312
+ testID: "bds-dropdownmenu-backdrop",
4313
+ accessible: false,
4314
+ importantForAccessibility: "no"
4305
4315
  }
4306
4316
  )
4307
4317
  }
@@ -4316,100 +4326,95 @@ function DropdownMenu({
4316
4326
  transform: [{ translateY: slideAnim }]
4317
4327
  }
4318
4328
  ],
4319
- children: /* @__PURE__ */ jsx47(
4320
- Pressable25,
4321
- {
4322
- onPress: () => {
4323
- },
4324
- children: /* @__PURE__ */ jsxs32(SafeAreaView2, { children: [
4325
- /* @__PURE__ */ jsxs32(
4326
- View40,
4327
- {
4328
- style: [
4329
- styles43.sheetHeader,
4330
- { borderBottomColor: colors2.colorBgSubtle }
4331
- ],
4332
- children: [
4333
- /* @__PURE__ */ jsx47(Text35, { style: [styles43.sheetTitle, { color: colors2.colorOnBg }], children: title }),
4334
- /* @__PURE__ */ jsx47(
4335
- Pressable25,
4336
- {
4337
- accessibilityRole: "button",
4338
- accessibilityLabel: "Close",
4339
- onPress: () => setOpen(false),
4340
- hitSlop: 8,
4341
- children: /* @__PURE__ */ jsx47(
4342
- Text35,
4343
- {
4344
- style: [
4345
- styles43.closeText,
4346
- { color: colors2.colorInteractive }
4347
- ],
4348
- children: "Done"
4349
- }
4350
- )
4351
- }
4352
- )
4353
- ]
4354
- }
4355
- ),
4356
- /* @__PURE__ */ jsx47(
4357
- FlatList2,
4358
- {
4359
- data: items,
4360
- keyExtractor: (item) => item.id,
4361
- renderItem: ({ item }) => /* @__PURE__ */ jsxs32(Fragment2, { children: [
4362
- item.separator && /* @__PURE__ */ jsx47(
4363
- View40,
4329
+ accessibilityViewIsModal: true,
4330
+ onAccessibilityEscape: () => setOpen(false),
4331
+ children: /* @__PURE__ */ jsxs32(SafeAreaView2, { children: [
4332
+ /* @__PURE__ */ jsxs32(
4333
+ View40,
4334
+ {
4335
+ style: [
4336
+ styles43.sheetHeader,
4337
+ { borderBottomColor: colors2.colorBgSubtle }
4338
+ ],
4339
+ children: [
4340
+ /* @__PURE__ */ jsx47(Text35, { style: [styles43.sheetTitle, { color: colors2.colorOnBg }], children: title }),
4341
+ /* @__PURE__ */ jsx47(
4342
+ Pressable25,
4343
+ {
4344
+ accessibilityRole: "button",
4345
+ accessibilityLabel: "Close",
4346
+ onPress: () => setOpen(false),
4347
+ hitSlop: 8,
4348
+ children: /* @__PURE__ */ jsx47(
4349
+ Text35,
4364
4350
  {
4365
4351
  style: [
4366
- styles43.separator,
4367
- { backgroundColor: colors2.colorBgSubtle }
4352
+ styles43.closeText,
4353
+ { color: colors2.colorInteractive }
4368
4354
  ],
4369
- accessibilityRole: "none"
4370
- }
4371
- ),
4372
- /* @__PURE__ */ jsxs32(
4373
- Pressable25,
4374
- {
4375
- accessibilityRole: "menuitem",
4376
- accessibilityLabel: item.label,
4377
- accessibilityState: { disabled: item.disabled },
4378
- disabled: item.disabled,
4379
- onPress: () => handleItemPress(item),
4380
- style: ({ pressed }) => [
4381
- styles43.item,
4382
- pressed && { backgroundColor: colors2.colorBgSubtle },
4383
- item.disabled && { opacity: 0.4 }
4384
- ],
4385
- children: [
4386
- !!item.icon && /* @__PURE__ */ jsx47(
4387
- View40,
4388
- {
4389
- importantForAccessibility: "no-hide-descendants",
4390
- accessibilityElementsHidden: true,
4391
- children: item.icon
4392
- }
4393
- ),
4394
- /* @__PURE__ */ jsx47(
4395
- Text35,
4396
- {
4397
- style: [
4398
- styles43.itemText,
4399
- { color: colors2.colorOnBg }
4400
- ],
4401
- children: item.label
4402
- }
4403
- )
4404
- ]
4355
+ children: "Done"
4405
4356
  }
4406
4357
  )
4407
- ] })
4408
- }
4409
- )
4410
- ] })
4411
- }
4412
- )
4358
+ }
4359
+ )
4360
+ ]
4361
+ }
4362
+ ),
4363
+ /* @__PURE__ */ jsx47(
4364
+ FlatList2,
4365
+ {
4366
+ data: items,
4367
+ keyExtractor: (item) => item.id,
4368
+ renderItem: ({ item }) => /* @__PURE__ */ jsxs32(Fragment2, { children: [
4369
+ item.separator && /* @__PURE__ */ jsx47(
4370
+ View40,
4371
+ {
4372
+ style: [
4373
+ styles43.separator,
4374
+ { backgroundColor: colors2.colorBgSubtle }
4375
+ ],
4376
+ accessibilityRole: "none"
4377
+ }
4378
+ ),
4379
+ /* @__PURE__ */ jsxs32(
4380
+ Pressable25,
4381
+ {
4382
+ accessibilityRole: "menuitem",
4383
+ accessibilityLabel: item.label,
4384
+ accessibilityState: { disabled: item.disabled },
4385
+ disabled: item.disabled,
4386
+ onPress: () => handleItemPress(item),
4387
+ style: ({ pressed }) => [
4388
+ styles43.item,
4389
+ pressed && { backgroundColor: colors2.colorBgSubtle },
4390
+ item.disabled && { opacity: 0.4 }
4391
+ ],
4392
+ children: [
4393
+ !!item.icon && /* @__PURE__ */ jsx47(
4394
+ View40,
4395
+ {
4396
+ importantForAccessibility: "no-hide-descendants",
4397
+ accessibilityElementsHidden: true,
4398
+ children: item.icon
4399
+ }
4400
+ ),
4401
+ /* @__PURE__ */ jsx47(
4402
+ Text35,
4403
+ {
4404
+ style: [
4405
+ styles43.itemText,
4406
+ { color: colors2.colorOnBg }
4407
+ ],
4408
+ children: item.label
4409
+ }
4410
+ )
4411
+ ]
4412
+ }
4413
+ )
4414
+ ] })
4415
+ }
4416
+ )
4417
+ ] })
4413
4418
  }
4414
4419
  )
4415
4420
  ] })
@@ -4458,6 +4463,7 @@ function Popover({
4458
4463
  }) {
4459
4464
  const { colors: colors2 } = useTheme();
4460
4465
  const [isOpen, setIsOpen] = useState15(false);
4466
+ const close = () => setIsOpen(false);
4461
4467
  return /* @__PURE__ */ jsxs33(View41, { style: [styles44.wrapper, style], children: [
4462
4468
  React2.cloneElement(children, {
4463
4469
  onPress: () => setIsOpen(true),
@@ -4470,27 +4476,28 @@ function Popover({
4470
4476
  visible: isOpen,
4471
4477
  transparent: true,
4472
4478
  animationType: "fade",
4473
- onRequestClose: () => setIsOpen(false),
4474
- children: /* @__PURE__ */ jsx48(
4475
- Pressable26,
4476
- {
4477
- style: styles44.overlay,
4478
- onPress: () => setIsOpen(false),
4479
- accessibilityRole: "button",
4480
- accessibilityLabel: "Close popover",
4481
- children: /* @__PURE__ */ jsx48(
4482
- Pressable26,
4483
- {
4484
- style: [styles44.panel, { backgroundColor: colors2.colorBg }],
4485
- onPress: () => {
4486
- },
4487
- accessibilityRole: "none",
4488
- accessibilityLabel,
4489
- children: content
4490
- }
4491
- )
4492
- }
4493
- )
4479
+ onRequestClose: close,
4480
+ children: /* @__PURE__ */ jsxs33(View41, { style: styles44.overlay, onAccessibilityEscape: close, children: [
4481
+ /* @__PURE__ */ jsx48(
4482
+ Pressable26,
4483
+ {
4484
+ testID: "bds-popover-backdrop",
4485
+ style: StyleSheet44.absoluteFill,
4486
+ onPress: close,
4487
+ onAccessibilityTap: close,
4488
+ accessibilityRole: "button",
4489
+ accessibilityLabel: "Close popover"
4490
+ }
4491
+ ),
4492
+ /* @__PURE__ */ jsx48(
4493
+ View41,
4494
+ {
4495
+ style: [styles44.panel, { backgroundColor: colors2.colorBg }],
4496
+ accessibilityLabel,
4497
+ children: content
4498
+ }
4499
+ )
4500
+ ] })
4494
4501
  }
4495
4502
  )
4496
4503
  ] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-react-native",
3
- "version": "0.2.6",
3
+ "version": "0.4.0",
4
4
  "description": "BoostDev React Native component library: accessible, token-driven components built on @boostdev/design-system-foundation",
5
5
  "keywords": [
6
6
  "react-native",
@@ -56,6 +56,7 @@ export default function App() {
56
56
 
57
57
  ## Accessibility
58
58
 
59
- - Backdrop press and close button both call `onClose`
60
- - Close button has `accessibilityLabel="Close dialog"`
59
+ - The backdrop still closes on press, but is hidden from assistive tech (a leaf `Pressable` would otherwise group the whole dialog into one element on iOS); the close button, labelled `accessibilityLabel="Close dialog"`, is the accessible way to close
60
+ - The title, the content and any controls inside are separately reachable by VoiceOver and XCUITest, not grouped into one element
61
+ - VoiceOver's escape gesture also calls `onClose`
61
62
  - Android back button triggers `onRequestClose` which calls `onClose`
@@ -40,34 +40,65 @@ describe('Dialog (native)', () => {
40
40
  <span>Body</span>
41
41
  </Dialog>,
42
42
  );
43
- const closeButtons = screen.getAllByRole('button', { name: 'Close dialog' });
44
- expect(closeButtons.length).toBeGreaterThanOrEqual(1);
43
+ expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument();
45
44
  });
46
45
 
47
- it('calls onClose when close button is pressed', () => {
46
+ it('calls onClose when the close button is pressed', () => {
48
47
  const onClose = vi.fn();
49
48
  renderWithTheme(
50
49
  <Dialog isOpen onClose={onClose}>
51
50
  <span>Body</span>
52
51
  </Dialog>,
53
52
  );
54
- const closeButtons = screen.getAllByRole('button', { name: 'Close dialog' });
55
- // The inner close button (not the backdrop)
56
- const innerClose = closeButtons[closeButtons.length - 1];
57
- fireEvent.click(innerClose);
53
+ fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }));
58
54
  expect(onClose).toHaveBeenCalledTimes(1);
59
55
  });
60
56
 
61
- it('calls onClose when backdrop is pressed', () => {
57
+ it('calls onClose when the backdrop is pressed', () => {
62
58
  const onClose = vi.fn();
63
59
  renderWithTheme(
64
60
  <Dialog isOpen onClose={onClose}>
65
61
  <span>Body</span>
66
62
  </Dialog>,
67
63
  );
68
- const closeButtons = screen.getAllByRole('button', { name: 'Close dialog' });
69
- // The first one is the backdrop
70
- fireEvent.click(closeButtons[0]);
64
+ fireEvent.click(screen.getByTestId('bds-dialog-backdrop'));
71
65
  expect(onClose).toHaveBeenCalledTimes(1);
72
66
  });
67
+
68
+ it('does not include the content inside the backdrop', () => {
69
+ renderWithTheme(
70
+ <Dialog isOpen onClose={() => {}}>
71
+ <span>Dialog body</span>
72
+ </Dialog>,
73
+ );
74
+ const backdrop = screen.getByTestId('bds-dialog-backdrop');
75
+ expect(backdrop).not.toContainElement(screen.getByText('Dialog body'));
76
+ expect(backdrop).not.toHaveAttribute('role');
77
+ });
78
+
79
+ it('does not call onClose when the content is pressed', () => {
80
+ const onClose = vi.fn();
81
+ renderWithTheme(
82
+ <Dialog isOpen onClose={onClose}>
83
+ <span>Dialog body</span>
84
+ </Dialog>,
85
+ );
86
+ fireEvent.click(screen.getByText('Dialog body'));
87
+ expect(onClose).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('keeps a child button reachable and separate from onClose', () => {
91
+ const onClose = vi.fn();
92
+ const onAction = vi.fn();
93
+ renderWithTheme(
94
+ <Dialog isOpen onClose={onClose}>
95
+ <button type="button" onClick={onAction}>
96
+ Confirm
97
+ </button>
98
+ </Dialog>,
99
+ );
100
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
101
+ expect(onAction).toHaveBeenCalledTimes(1);
102
+ expect(onClose).not.toHaveBeenCalled();
103
+ });
73
104
  });
@@ -84,18 +84,25 @@ export function Dialog({
84
84
  animationType="fade"
85
85
  onRequestClose={onClose}
86
86
  >
87
- <Pressable
88
- style={styles.overlay}
89
- onPress={onClose}
90
- accessibilityRole="button"
91
- accessibilityLabel="Close dialog"
92
- >
87
+ <View style={styles.overlay}>
88
+ {/*
89
+ * A Pressable is accessible by default on iOS, which makes it a leaf:
90
+ * VoiceOver cannot reach anything nested inside it. As a sibling behind
91
+ * the panel (not its parent), the backdrop still closes on press for
92
+ * sighted/touch users but never swallows the panel's contents, so the
93
+ * close button below stays reachable as the accessible way to close.
94
+ */}
93
95
  <Pressable
96
+ testID="bds-dialog-backdrop"
97
+ style={StyleSheet.absoluteFill}
98
+ onPress={onClose}
99
+ accessible={false}
100
+ importantForAccessibility="no"
101
+ />
102
+ <View
94
103
  style={[styles.panel, { backgroundColor: colors.colorGreySubtle }, style]}
95
- onPress={() => {
96
- /* prevent close when pressing content */
97
- }}
98
- accessibilityRole="none"
104
+ accessibilityViewIsModal
105
+ onAccessibilityEscape={onClose}
99
106
  >
100
107
  <Pressable
101
108
  style={styles.closeButton}
@@ -121,8 +128,8 @@ export function Dialog({
121
128
  )}
122
129
 
123
130
  {children}
124
- </Pressable>
125
- </Pressable>
131
+ </View>
132
+ </View>
126
133
  </Modal>
127
134
  );
128
135
  }
@@ -69,6 +69,8 @@ export default function App() {
69
69
  ## Accessibility
70
70
 
71
71
  - Trigger has `accessibilityRole="button"` with `expanded` state
72
- - Menu items have `accessibilityRole="menuitem"`
72
+ - Menu items have `accessibilityRole="menuitem"` and are each reachable on their own — on iOS the sheet is no longer wrapped in a `Pressable`, which used to group the whole sheet (title, Done, every item) into a single VoiceOver element
73
73
  - Disabled items communicate their state to assistive technologies
74
+ - The backdrop still closes the menu on press, but is hidden from assistive tech (`accessible={false}`, `importantForAccessibility="no"`) so it doesn't duplicate the sheet's own close control
75
+ - "Done" is the accessible close control; VoiceOver's escape gesture (`onAccessibilityEscape`) also closes the menu
74
76
  - Android back button closes the menu
@@ -75,4 +75,77 @@ describe('DropdownMenu (native)', () => {
75
75
  fireEvent.click(screen.getByRole('button', { name: 'My Menu' }));
76
76
  expect(screen.getByText('My Menu')).toBeInTheDocument();
77
77
  });
78
+
79
+ it('has an unlabelled, roleless backdrop that closes the menu on press', () => {
80
+ renderWithTheme(
81
+ <DropdownMenu trigger={<Trigger>Menu</Trigger>} items={items} title="Menu" />,
82
+ );
83
+ fireEvent.click(screen.getByRole('button', { name: 'Menu' }));
84
+ const backdrop = screen.getByTestId('bds-dropdownmenu-backdrop');
85
+ expect(backdrop).not.toHaveAttribute('role');
86
+ fireEvent.click(backdrop);
87
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
88
+ });
89
+
90
+ it('has no full-screen "Close menu" button; "Done" closes the menu', () => {
91
+ renderWithTheme(
92
+ <DropdownMenu trigger={<Trigger>Menu</Trigger>} items={items} title="Menu" />,
93
+ );
94
+ fireEvent.click(screen.getByRole('button', { name: 'Menu' }));
95
+ expect(
96
+ screen.queryByRole('button', { name: 'Close menu' }),
97
+ ).not.toBeInTheDocument();
98
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
99
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
100
+ });
101
+
102
+ it('exposes each item as its own menuitem, pressable independently', () => {
103
+ const onEdit = vi.fn();
104
+ const onDelete = vi.fn();
105
+ const menuItems = [
106
+ { id: 'edit', label: 'Edit', onPress: onEdit },
107
+ { id: 'delete', label: 'Delete', onPress: onDelete },
108
+ ];
109
+ renderWithTheme(
110
+ <DropdownMenu trigger={<Trigger>Actions</Trigger>} items={menuItems} title="Actions" />,
111
+ );
112
+ fireEvent.click(screen.getByRole('button', { name: 'Actions' }));
113
+
114
+ const menuitems = screen.getAllByRole('menuitem');
115
+ expect(menuitems).toHaveLength(2);
116
+ expect(menuitems.map((el) => el.textContent)).toEqual(['Edit', 'Delete']);
117
+
118
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Edit' }));
119
+ expect(onEdit).toHaveBeenCalledTimes(1);
120
+ expect(onDelete).not.toHaveBeenCalled();
121
+ expect(screen.queryByText('Delete')).not.toBeInTheDocument();
122
+ });
123
+
124
+ // On iOS a Pressable is `accessible` by default, so a no-op Pressable
125
+ // wrapped around the sheet's content used to make VoiceOver treat the
126
+ // whole sheet (all items included) as a single element. jsdom has no iOS
127
+ // grouping, but react-native-web's Pressable always renders with a
128
+ // `tabindex` attribute (0, or -1 disabled) on its own DOM node, wrapper
129
+ // or not. So a stray wrapper is detectable here as a `tabindex`-bearing
130
+ // ancestor between the menuitem and the sheet — the menuitem should have
131
+ // none.
132
+ it('renders each item with no tabindex-bearing wrapper between it and the sheet', () => {
133
+ renderWithTheme(
134
+ <DropdownMenu trigger={<Trigger>Menu</Trigger>} items={items} title="Menu" />,
135
+ );
136
+ fireEvent.click(screen.getByRole('button', { name: 'Menu' }));
137
+ const item = screen.getByRole('menuitem', { name: 'Edit' });
138
+
139
+ let node: HTMLElement | null = item.parentElement;
140
+ let tabIndexAncestor: HTMLElement | null = null;
141
+ while (node) {
142
+ if (node.hasAttribute('tabindex')) {
143
+ tabIndexAncestor = node;
144
+ break;
145
+ }
146
+ node = node.parentElement;
147
+ }
148
+
149
+ expect(tabIndexAncestor).toBeNull();
150
+ });
78
151
  });