@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/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,
@@ -3782,46 +3786,48 @@ function Dialog({
3782
3786
  transparent: true,
3783
3787
  animationType: "fade",
3784
3788
  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
- )
3789
+ children: /* @__PURE__ */ jsxs29(View37, { style: styles40.overlay, children: [
3790
+ /* @__PURE__ */ jsx44(
3791
+ Pressable22,
3792
+ {
3793
+ testID: "bds-dialog-backdrop",
3794
+ style: StyleSheet40.absoluteFill,
3795
+ onPress: onClose,
3796
+ accessible: false,
3797
+ importantForAccessibility: "no"
3798
+ }
3799
+ ),
3800
+ /* @__PURE__ */ jsxs29(
3801
+ View37,
3802
+ {
3803
+ style: [styles40.panel, { backgroundColor: colors2.colorGreySubtle }, style],
3804
+ accessibilityViewIsModal: true,
3805
+ onAccessibilityEscape: onClose,
3806
+ children: [
3807
+ /* @__PURE__ */ jsx44(
3808
+ Pressable22,
3809
+ {
3810
+ style: styles40.closeButton,
3811
+ onPress: onClose,
3812
+ accessibilityRole: "button",
3813
+ accessibilityLabel: "Close dialog",
3814
+ hitSlop: 8,
3815
+ children: /* @__PURE__ */ jsx44(
3816
+ Text32,
3817
+ {
3818
+ style: [styles40.closeText, { color: colors2.colorOnBgSubtle }],
3819
+ "aria-hidden": true,
3820
+ children: "\u2715"
3821
+ }
3822
+ )
3823
+ }
3824
+ ),
3825
+ !!title && /* @__PURE__ */ jsx44(View37, { style: styles40.header, children: /* @__PURE__ */ jsx44(Text32, { style: [styles40.title, { color: colors2.colorOnGreySubtle }], children: title }) }),
3826
+ children
3827
+ ]
3828
+ }
3829
+ )
3830
+ ] })
3825
3831
  }
3826
3832
  );
3827
3833
  }
@@ -3947,8 +3953,9 @@ function SideSheet({
3947
3953
  {
3948
3954
  style: StyleSheet41.absoluteFill,
3949
3955
  onPress: onClose,
3950
- accessibilityRole: "button",
3951
- accessibilityLabel: "Close side sheet"
3956
+ testID: "bds-sidesheet-backdrop",
3957
+ accessible: false,
3958
+ importantForAccessibility: "no"
3952
3959
  }
3953
3960
  )
3954
3961
  }
@@ -3964,7 +3971,8 @@ function SideSheet({
3964
3971
  },
3965
3972
  style
3966
3973
  ],
3967
- accessibilityRole: "none",
3974
+ accessibilityViewIsModal: true,
3975
+ onAccessibilityEscape: onClose,
3968
3976
  children: [
3969
3977
  /* @__PURE__ */ jsxs30(
3970
3978
  View38,
@@ -4300,8 +4308,9 @@ function DropdownMenu({
4300
4308
  {
4301
4309
  style: StyleSheet43.absoluteFill,
4302
4310
  onPress: () => setOpen(false),
4303
- accessibilityRole: "button",
4304
- accessibilityLabel: "Close menu"
4311
+ testID: "bds-dropdownmenu-backdrop",
4312
+ accessible: false,
4313
+ importantForAccessibility: "no"
4305
4314
  }
4306
4315
  )
4307
4316
  }
@@ -4316,100 +4325,95 @@ function DropdownMenu({
4316
4325
  transform: [{ translateY: slideAnim }]
4317
4326
  }
4318
4327
  ],
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,
4328
+ accessibilityViewIsModal: true,
4329
+ onAccessibilityEscape: () => setOpen(false),
4330
+ children: /* @__PURE__ */ jsxs32(SafeAreaView2, { children: [
4331
+ /* @__PURE__ */ jsxs32(
4332
+ View40,
4333
+ {
4334
+ style: [
4335
+ styles43.sheetHeader,
4336
+ { borderBottomColor: colors2.colorBgSubtle }
4337
+ ],
4338
+ children: [
4339
+ /* @__PURE__ */ jsx47(Text35, { style: [styles43.sheetTitle, { color: colors2.colorOnBg }], children: title }),
4340
+ /* @__PURE__ */ jsx47(
4341
+ Pressable25,
4342
+ {
4343
+ accessibilityRole: "button",
4344
+ accessibilityLabel: "Close",
4345
+ onPress: () => setOpen(false),
4346
+ hitSlop: 8,
4347
+ children: /* @__PURE__ */ jsx47(
4348
+ Text35,
4364
4349
  {
4365
4350
  style: [
4366
- styles43.separator,
4367
- { backgroundColor: colors2.colorBgSubtle }
4351
+ styles43.closeText,
4352
+ { color: colors2.colorInteractive }
4368
4353
  ],
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
- ]
4354
+ children: "Done"
4405
4355
  }
4406
4356
  )
4407
- ] })
4408
- }
4409
- )
4410
- ] })
4411
- }
4412
- )
4357
+ }
4358
+ )
4359
+ ]
4360
+ }
4361
+ ),
4362
+ /* @__PURE__ */ jsx47(
4363
+ FlatList2,
4364
+ {
4365
+ data: items,
4366
+ keyExtractor: (item) => item.id,
4367
+ renderItem: ({ item }) => /* @__PURE__ */ jsxs32(Fragment2, { children: [
4368
+ item.separator && /* @__PURE__ */ jsx47(
4369
+ View40,
4370
+ {
4371
+ style: [
4372
+ styles43.separator,
4373
+ { backgroundColor: colors2.colorBgSubtle }
4374
+ ],
4375
+ accessibilityRole: "none"
4376
+ }
4377
+ ),
4378
+ /* @__PURE__ */ jsxs32(
4379
+ Pressable25,
4380
+ {
4381
+ accessibilityRole: "menuitem",
4382
+ accessibilityLabel: item.label,
4383
+ accessibilityState: { disabled: item.disabled },
4384
+ disabled: item.disabled,
4385
+ onPress: () => handleItemPress(item),
4386
+ style: ({ pressed }) => [
4387
+ styles43.item,
4388
+ pressed && { backgroundColor: colors2.colorBgSubtle },
4389
+ item.disabled && { opacity: 0.4 }
4390
+ ],
4391
+ children: [
4392
+ !!item.icon && /* @__PURE__ */ jsx47(
4393
+ View40,
4394
+ {
4395
+ importantForAccessibility: "no-hide-descendants",
4396
+ accessibilityElementsHidden: true,
4397
+ children: item.icon
4398
+ }
4399
+ ),
4400
+ /* @__PURE__ */ jsx47(
4401
+ Text35,
4402
+ {
4403
+ style: [
4404
+ styles43.itemText,
4405
+ { color: colors2.colorOnBg }
4406
+ ],
4407
+ children: item.label
4408
+ }
4409
+ )
4410
+ ]
4411
+ }
4412
+ )
4413
+ ] })
4414
+ }
4415
+ )
4416
+ ] })
4413
4417
  }
4414
4418
  )
4415
4419
  ] })
@@ -4458,6 +4462,7 @@ function Popover({
4458
4462
  }) {
4459
4463
  const { colors: colors2 } = useTheme();
4460
4464
  const [isOpen, setIsOpen] = useState15(false);
4465
+ const close = () => setIsOpen(false);
4461
4466
  return /* @__PURE__ */ jsxs33(View41, { style: [styles44.wrapper, style], children: [
4462
4467
  React2.cloneElement(children, {
4463
4468
  onPress: () => setIsOpen(true),
@@ -4470,27 +4475,28 @@ function Popover({
4470
4475
  visible: isOpen,
4471
4476
  transparent: true,
4472
4477
  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
- )
4478
+ onRequestClose: close,
4479
+ children: /* @__PURE__ */ jsxs33(View41, { style: styles44.overlay, onAccessibilityEscape: close, children: [
4480
+ /* @__PURE__ */ jsx48(
4481
+ Pressable26,
4482
+ {
4483
+ testID: "bds-popover-backdrop",
4484
+ style: StyleSheet44.absoluteFill,
4485
+ onPress: close,
4486
+ onAccessibilityTap: close,
4487
+ accessibilityRole: "button",
4488
+ accessibilityLabel: "Close popover"
4489
+ }
4490
+ ),
4491
+ /* @__PURE__ */ jsx48(
4492
+ View41,
4493
+ {
4494
+ style: [styles44.panel, { backgroundColor: colors2.colorBg }],
4495
+ accessibilityLabel,
4496
+ children: content
4497
+ }
4498
+ )
4499
+ ] })
4494
4500
  }
4495
4501
  )
4496
4502
  ] });
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.3.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
  });