@sproutsocial/seeds-react-modal 2.4.8 → 2.5.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 (38) hide show
  1. package/.turbo/turbo-build.log +28 -28
  2. package/CHANGELOG.md +70 -0
  3. package/dist/{Modal-ki8oiGbC.d.mts → Modal-DTeKLfEI.d.mts} +1 -1
  4. package/dist/{Modal-ki8oiGbC.d.ts → Modal-DTeKLfEI.d.ts} +1 -1
  5. package/dist/{ModalAction-BHG3Zbd9.d.mts → ModalExternalTrigger-BnbJk9zY.d.mts} +85 -3
  6. package/dist/{ModalAction-BHG3Zbd9.d.ts → ModalExternalTrigger-BnbJk9zY.d.ts} +85 -3
  7. package/dist/esm/{chunk-ZY6VJ7XT.js → chunk-62MRZAJV.js} +36 -10
  8. package/dist/esm/chunk-62MRZAJV.js.map +1 -0
  9. package/dist/esm/{chunk-IYDY4OPB.js → chunk-72GBDCA2.js} +17 -1
  10. package/dist/esm/chunk-72GBDCA2.js.map +1 -0
  11. package/dist/esm/index.js +10 -4
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/v1/index.js +1 -1
  14. package/dist/esm/v2/index.js +1 -1
  15. package/dist/index.d.mts +3 -3
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.js +85 -11
  18. package/dist/index.js.map +1 -1
  19. package/dist/v1/index.d.mts +2 -2
  20. package/dist/v1/index.d.ts +2 -2
  21. package/dist/v1/index.js +16 -0
  22. package/dist/v1/index.js.map +1 -1
  23. package/dist/v2/index.d.mts +4 -77
  24. package/dist/v2/index.d.ts +4 -77
  25. package/dist/v2/index.js +35 -9
  26. package/dist/v2/index.js.map +1 -1
  27. package/package.json +7 -7
  28. package/src/Modal.stories.tsx +64 -0
  29. package/src/__tests__/v1/Modal.test.tsx +146 -1
  30. package/src/__tests__/v2/Modal.test.tsx +182 -0
  31. package/src/index.ts +4 -0
  32. package/src/v1/Modal.tsx +30 -0
  33. package/src/v2/Modal.tsx +8 -0
  34. package/src/v2/ModalTypes.ts +9 -2
  35. package/src/v2/ModalV2.stories.tsx +476 -49
  36. package/src/v2/components/ModalFooter.tsx +15 -4
  37. package/dist/esm/chunk-IYDY4OPB.js.map +0 -1
  38. package/dist/esm/chunk-ZY6VJ7XT.js.map +0 -1
@@ -3,9 +3,14 @@ import type { Meta, StoryObj } from "@storybook/react";
3
3
  import { Box } from "@sproutsocial/seeds-react-box";
4
4
  import { Button } from "@sproutsocial/seeds-react-button";
5
5
  import Text from "@sproutsocial/seeds-react-text";
6
+ import { Popout } from "@sproutsocial/seeds-react-popout";
7
+ import { Popout as PopoutV2 } from "@sproutsocial/seeds-react-popout/v2";
8
+ import ModalV1 from "../v1/Modal";
6
9
  import { FormField } from "@sproutsocial/seeds-react-form-field";
7
10
  import {
11
+ ActionMenu,
8
12
  MenuContent,
13
+ MenuGroup,
9
14
  MenuItem,
10
15
  MenuToggleButton,
11
16
  SingleSelectMenu,
@@ -847,6 +852,61 @@ export const FooterWithLeftAction: Story = {
847
852
  },
848
853
  };
849
854
 
855
+ export const AsyncPrimaryAction: Story = {
856
+ render: () => {
857
+ const [isOpen, setIsOpen] = useState(false);
858
+ const [isSaving, setIsSaving] = useState(false);
859
+
860
+ const handleSave = async () => {
861
+ setIsSaving(true);
862
+ // Simulate async operation
863
+ await new Promise((resolve) => setTimeout(resolve, 1500));
864
+ setIsSaving(false);
865
+ setIsOpen(false);
866
+ };
867
+
868
+ return (
869
+ <>
870
+ <Button appearance="primary" onClick={() => setIsOpen(true)}>
871
+ Open Async Save Modal
872
+ </Button>
873
+ <Modal
874
+ open={isOpen}
875
+ onOpenChange={setIsOpen}
876
+ aria-label="Async Save Modal"
877
+ closeButtonAriaLabel="Close"
878
+ >
879
+ <ModalHeader
880
+ title="Save Changes"
881
+ subtitle="The modal stays open until the save completes."
882
+ />
883
+ <ModalBody>
884
+ <Text>
885
+ This modal uses <code>closeOnPrimaryAction=false</code> so the
886
+ primary button does not auto-close the modal. Instead, the
887
+ consumer controls when to close after the async operation
888
+ completes.
889
+ </Text>
890
+ </ModalBody>
891
+ <ModalFooter
892
+ closeOnPrimaryAction={false}
893
+ cancelButton={<Button>Cancel</Button>}
894
+ primaryButton={
895
+ <Button
896
+ appearance="primary"
897
+ onClick={handleSave}
898
+ disabled={isSaving}
899
+ >
900
+ {isSaving ? "Saving..." : "Save"}
901
+ </Button>
902
+ }
903
+ />
904
+ </Modal>
905
+ </>
906
+ );
907
+ },
908
+ };
909
+
850
910
  export const CustomFooterOverride: Story = {
851
911
  render: () => {
852
912
  return (
@@ -974,16 +1034,15 @@ export const PortaledComponentsInModal: Story = {
974
1034
  };
975
1035
 
976
1036
  /**
977
- * Example using the useModalExternalTrigger hook for external triggers.
978
- *
979
- * ⚠️ NOT RECOMMENDED - This is a last resort pattern. Prefer using modalTrigger prop.
1037
+ * The recommended pattern for controlled modals where the trigger lives outside
1038
+ * the Modal component tree (e.g., a button in a table row, page header, or list item).
980
1039
  *
981
- * The hook provides:
982
- * - triggerRef: Ref to attach to your trigger element
983
- * - triggerProps: Function that returns ARIA props (pass isOpen state)
984
- * - onCloseAutoFocus: Callback for Modal to restore focus
1040
+ * useModalExternalTrigger provides:
1041
+ * - triggerRef: Ref to attach to your trigger element for focus restoration
1042
+ * - triggerProps: Function returning ARIA attributes (aria-haspopup, aria-expanded, aria-controls)
1043
+ * - onCloseAutoFocus: Pass to Modal to automatically restore focus to the trigger on close
985
1044
  *
986
- * This eliminates manual ref creation and focus restoration boilerplate.
1045
+ * This is the standard pattern for most real-world controlled modals.
987
1046
  */
988
1047
  export const ExternalTriggerWithHook: Story = {
989
1048
  render: () => {
@@ -994,29 +1053,31 @@ export const ExternalTriggerWithHook: Story = {
994
1053
  return (
995
1054
  <Box>
996
1055
  <Text mb={400} fontWeight="semibold">
997
- ⚠️ LAST RESORT: useModalExternalTrigger Hook
1056
+ Controlled Modal: useModalExternalTrigger Hook
998
1057
  </Text>
999
1058
  <Text mb={400}>
1000
- Only use when the trigger absolutely cannot be near the Modal (e.g.,
1001
- trigger in header, modal at bottom of app tree).
1059
+ Use this pattern when the trigger and modal live in different parts of
1060
+ the component tree — for example, a button in a data table row, a list
1061
+ item, or a page header.
1002
1062
  </Text>
1003
1063
 
1004
1064
  <Text mb={400} fontWeight="semibold">
1005
- Benefits:
1065
+ What this hook provides:
1006
1066
  </Text>
1007
1067
  <Box as="ul" pl={400} mb={400}>
1008
1068
  <li>
1009
1069
  <Text>
1010
- Works with any trigger element (Button, custom components, etc.)
1070
+ Works with any trigger element (Button, link, custom component)
1011
1071
  </Text>
1012
1072
  </li>
1013
1073
  <li>
1014
1074
  <Text>
1015
- Automatic focus restoration via onCloseAutoFocus callback
1075
+ Correct ARIA attributes on the trigger (aria-haspopup,
1076
+ aria-expanded, aria-controls)
1016
1077
  </Text>
1017
1078
  </li>
1018
1079
  <li>
1019
- <Text>No manual ref creation needed</Text>
1080
+ <Text>Automatic focus restoration when the modal closes</Text>
1020
1081
  </li>
1021
1082
  </Box>
1022
1083
 
@@ -1037,31 +1098,34 @@ export const ExternalTriggerWithHook: Story = {
1037
1098
  closeButtonAriaLabel="Close Modal"
1038
1099
  >
1039
1100
  <ModalHeader
1040
- title="External Trigger with Hook"
1041
- subtitle="Using useModalExternalTrigger"
1101
+ title="Controlled Modal"
1102
+ subtitle="Trigger lives outside the Modal component tree"
1042
1103
  />
1043
1104
  <ModalBody>
1044
1105
  <Text mb={400}>
1045
- The useModalExternalTrigger hook simplifies external trigger setup
1046
- by:
1106
+ The trigger button used to open this modal is rendered separately
1107
+ from the Modal component. The useModalExternalTrigger hook
1108
+ handles:
1047
1109
  </Text>
1048
1110
  <Box as="ul" pl={400}>
1049
1111
  <li>
1050
- <Text>Managing the trigger ref internally</Text>
1112
+ <Text>
1113
+ A ref on the trigger for focus restoration when the modal
1114
+ closes
1115
+ </Text>
1051
1116
  </li>
1052
1117
  <li>
1053
- <Text>Providing ARIA props via triggerProps function</Text>
1118
+ <Text>
1119
+ ARIA attributes on the trigger (aria-haspopup, aria-expanded,
1120
+ aria-controls)
1121
+ </Text>
1054
1122
  </li>
1055
1123
  <li>
1056
1124
  <Text>
1057
- Handling focus restoration via onCloseAutoFocus callback
1125
+ The onCloseAutoFocus callback to return focus to the trigger
1058
1126
  </Text>
1059
1127
  </li>
1060
1128
  </Box>
1061
- <Text mt={400}>
1062
- This eliminates the boilerplate of manual ref management and
1063
- custom onCloseAutoFocus implementation.
1064
- </Text>
1065
1129
  </ModalBody>
1066
1130
  <ModalFooter
1067
1131
  cancelButton={
@@ -1082,14 +1146,15 @@ export const ExternalTriggerWithHook: Story = {
1082
1146
  };
1083
1147
 
1084
1148
  /**
1085
- * Example using the ModalExternalTrigger component for external triggers.
1086
- *
1087
- * ⚠️ NOT RECOMMENDED - This is a last resort pattern. Prefer using modalTrigger prop.
1149
+ * Alternative to useModalExternalTrigger when the trigger is specifically a Seeds Button.
1088
1150
  *
1089
- * ModalExternalTrigger is a Button variant with built-in ARIA attributes.
1090
- * Use it when you need an external trigger that is specifically a Button.
1151
+ * ModalExternalTrigger is a pre-configured Button component that automatically applies
1152
+ * the correct ARIA attributes (aria-haspopup, aria-expanded, aria-controls) for a modal
1153
+ * trigger. Use it when the trigger lives outside the Modal component tree and you want
1154
+ * to avoid spreading hook props manually.
1091
1155
  *
1092
- * Note: Focus restoration still requires manual onCloseAutoFocus implementation.
1156
+ * Note: Unlike useModalExternalTrigger, focus restoration still requires passing an
1157
+ * onCloseAutoFocus callback to the Modal manually.
1093
1158
  */
1094
1159
  export const ExternalTriggerComponent: Story = {
1095
1160
  render: () => {
@@ -1099,37 +1164,41 @@ export const ExternalTriggerComponent: Story = {
1099
1164
  return (
1100
1165
  <Box>
1101
1166
  <Text mb={400} fontWeight="semibold">
1102
- ⚠️ LAST RESORT: ModalExternalTrigger Component
1167
+ Controlled Modal: ModalExternalTrigger Component
1103
1168
  </Text>
1104
1169
  <Text mb={400}>
1105
- Only use when the trigger absolutely cannot be near the Modal AND
1106
- you're using a Seeds Button specifically.
1170
+ Use this component when the trigger is a Seeds Button and lives
1171
+ outside the Modal component tree. It applies the correct ARIA
1172
+ attributes automatically without needing to spread hook props.
1107
1173
  </Text>
1108
1174
 
1109
1175
  <Text mb={400} fontWeight="semibold">
1110
- Benefits:
1176
+ What this component provides:
1111
1177
  </Text>
1112
1178
  <Box as="ul" pl={400} mb={400}>
1113
1179
  <li>
1114
1180
  <Text>
1115
- Automatic ARIA attributes (aria-haspopup, aria-expanded, etc.)
1181
+ Automatic ARIA attributes (aria-haspopup, aria-expanded,
1182
+ aria-controls)
1116
1183
  </Text>
1117
1184
  </li>
1118
1185
  <li>
1119
1186
  <Text>
1120
- All Button props supported (appearance, size, disabled, etc.)
1187
+ All Seeds Button props supported (appearance, size, disabled,
1188
+ etc.)
1121
1189
  </Text>
1122
1190
  </li>
1123
1191
  <li>
1124
- <Text>Cleaner than hook for Button-only triggers</Text>
1192
+ <Text>Cleaner than the hook when using a Button trigger</Text>
1125
1193
  </li>
1126
1194
  </Box>
1127
1195
 
1128
1196
  <Text mb={400} fontWeight="semibold">
1129
- Limitation:
1197
+ Note:
1130
1198
  </Text>
1131
1199
  <Text mb={400}>
1132
- Focus restoration still requires manual onCloseAutoFocus handling.
1200
+ Pass a ref and implement onCloseAutoFocus on the Modal to restore
1201
+ focus when the modal closes.
1133
1202
  </Text>
1134
1203
 
1135
1204
  <ModalExternalTrigger
@@ -1153,28 +1222,31 @@ export const ExternalTriggerComponent: Story = {
1153
1222
  closeButtonAriaLabel="Close Modal"
1154
1223
  >
1155
1224
  <ModalHeader
1156
- title="External Trigger Component"
1157
- subtitle="Using ModalExternalTrigger"
1225
+ title="Controlled Modal"
1226
+ subtitle="Using ModalExternalTrigger component"
1158
1227
  />
1159
1228
  <ModalBody>
1160
1229
  <Text mb={400}>
1161
- ModalExternalTrigger extends Seeds Button with automatic ARIA
1162
- attributes:
1230
+ The trigger button for this modal is a ModalExternalTrigger
1231
+ component rendered outside the Modal tree. It automatically
1232
+ applies:
1163
1233
  </Text>
1164
1234
  <Box as="ul" pl={400}>
1165
1235
  <li>
1166
1236
  <Text>aria-haspopup="dialog"</Text>
1167
1237
  </li>
1168
1238
  <li>
1169
- <Text>aria-expanded based on isOpen prop</Text>
1239
+ <Text>aria-expanded reflecting the current open state</Text>
1170
1240
  </li>
1171
1241
  <li>
1172
- <Text>aria-controls (if modalId provided)</Text>
1242
+ <Text>
1243
+ aria-controls linking to the modal (if modalId provided)
1244
+ </Text>
1173
1245
  </li>
1174
1246
  </Box>
1175
1247
  <Text mt={400}>
1176
- Supports all Button props (appearance, size, disabled, etc.).
1177
- Focus restoration requires manual onCloseAutoFocus handling.
1248
+ Focus returns to the trigger on close via the onCloseAutoFocus
1249
+ callback passed to the Modal.
1178
1250
  </Text>
1179
1251
  </ModalBody>
1180
1252
  <ModalFooter
@@ -1194,3 +1266,358 @@ export const ExternalTriggerComponent: Story = {
1194
1266
  );
1195
1267
  },
1196
1268
  };
1269
+
1270
+ export const WithFormAndMenus: Story = {
1271
+ render: () => {
1272
+ const [selectedItem, setSelectedItem] =
1273
+ useState<TypeSingleSelectMenuProps["selectedItem"]>(null);
1274
+ const [name, setName] = useState("");
1275
+ const [submitCount, setSubmitCount] = useState(0);
1276
+
1277
+ const handleSubmit = (e: React.FormEvent) => {
1278
+ e.preventDefault();
1279
+ setSubmitCount((c) => c + 1);
1280
+ };
1281
+
1282
+ return (
1283
+ <Modal
1284
+ modalTrigger={
1285
+ <Button appearance="primary">Open Modal with Form</Button>
1286
+ }
1287
+ aria-label="Form Auto-Submit Bug Reproduction"
1288
+ closeButtonProps={{ "aria-label": "Close" }}
1289
+ >
1290
+ <ModalHeader
1291
+ title="Assign User Role"
1292
+ subtitle="Selecting a role should not submit the form"
1293
+ />
1294
+ <ModalBody>
1295
+ <form onSubmit={handleSubmit}>
1296
+ <Box p={400} display="flex" flexDirection="column" gap={400}>
1297
+ <FormField
1298
+ label="Name"
1299
+ error={null}
1300
+ helperText={null}
1301
+ id="name-field"
1302
+ qa={{}}
1303
+ required={false}
1304
+ >
1305
+ {(props) => (
1306
+ <input
1307
+ {...props}
1308
+ type="text"
1309
+ placeholder="Enter a name"
1310
+ value={name}
1311
+ onChange={(e) => setName(e.target.value)}
1312
+ style={{
1313
+ padding: "8px 12px",
1314
+ borderRadius: "4px",
1315
+ border: "1px solid #ccc",
1316
+ width: "100%",
1317
+ boxSizing: "border-box",
1318
+ fontSize: "14px",
1319
+ }}
1320
+ />
1321
+ )}
1322
+ </FormField>
1323
+
1324
+ <FormField
1325
+ label="Role"
1326
+ error={null}
1327
+ helperText={null}
1328
+ id="role-field"
1329
+ qa={{}}
1330
+ required={false}
1331
+ >
1332
+ {() => (
1333
+ <SingleSelectMenu
1334
+ selectedItem={selectedItem}
1335
+ onSelectedItemChange={({ selectedItem: item }) =>
1336
+ setSelectedItem(item)
1337
+ }
1338
+ menuToggleElement={
1339
+ <MenuToggleButton>
1340
+ {selectedItem?.id ?? "Select a role..."}
1341
+ </MenuToggleButton>
1342
+ }
1343
+ >
1344
+ <MenuContent>
1345
+ <MenuItem id="admin">Admin</MenuItem>
1346
+ <MenuItem id="editor">Editor</MenuItem>
1347
+ <MenuItem id="viewer">Viewer</MenuItem>
1348
+ </MenuContent>
1349
+ </SingleSelectMenu>
1350
+ )}
1351
+ </FormField>
1352
+
1353
+ {submitCount > 0 && (
1354
+ <Box
1355
+ p={400}
1356
+ bg="container.background.destructive"
1357
+ borderRadius="6px"
1358
+ border="1px solid"
1359
+ borderColor="container.border.destructive"
1360
+ >
1361
+ <Text fontWeight="bold">
1362
+ Form submitted {submitCount} time(s)!
1363
+ </Text>
1364
+ <Text fontSize={200}>
1365
+ If you only clicked a menu item, this is a bug.
1366
+ </Text>
1367
+ </Box>
1368
+ )}
1369
+ </Box>
1370
+
1371
+ <ModalFooter
1372
+ cancelButton={<Button>Cancel</Button>}
1373
+ primaryButton={
1374
+ <Button type="submit" appearance="primary">
1375
+ Save
1376
+ </Button>
1377
+ }
1378
+ />
1379
+ </form>
1380
+ </ModalBody>
1381
+ </Modal>
1382
+ );
1383
+ },
1384
+ };
1385
+
1386
+ export const WithPopout: Story = {
1387
+ render: (args) => {
1388
+ const [selectedItem, setSelectedItem] =
1389
+ useState<TypeSingleSelectMenuProps["selectedItem"]>(null);
1390
+
1391
+ return (
1392
+ <Modal
1393
+ {...args}
1394
+ closeButtonAriaLabel="Close Modal"
1395
+ modalTrigger={<Button appearance="primary">Open Modal</Button>}
1396
+ >
1397
+ <ModalHeader
1398
+ title="Popout Components"
1399
+ subtitle="Various popout and menu components inside a modal"
1400
+ />
1401
+ <ModalBody>
1402
+ <Box p={400} display="flex" flexDirection="column" gap={400}>
1403
+ <Box>
1404
+ <Text fontSize={200} fontWeight="semibold" mb={200}>
1405
+ Popout V1
1406
+ </Text>
1407
+ <Popout
1408
+ content={
1409
+ <Popout.Content>
1410
+ Look this is some popout content
1411
+ </Popout.Content>
1412
+ }
1413
+ >
1414
+ <Button>Open Popout</Button>
1415
+ </Popout>
1416
+ </Box>
1417
+ <Box>
1418
+ <Text fontSize={200} fontWeight="semibold" mb={200}>
1419
+ Popout V2
1420
+ </Text>
1421
+ <PopoutV2 content="Look this is some popout content">
1422
+ <Button>Open Popout V2</Button>
1423
+ </PopoutV2>
1424
+ </Box>
1425
+ <Box>
1426
+ <Text fontSize={200} fontWeight="semibold" mb={200}>
1427
+ Action Menu
1428
+ </Text>
1429
+ <ActionMenu
1430
+ menuToggleElement={<MenuToggleButton>Actions</MenuToggleButton>}
1431
+ >
1432
+ <MenuContent>
1433
+ <MenuGroup id="actions">
1434
+ <MenuItem id="edit">Edit</MenuItem>
1435
+ <MenuItem id="duplicate">Duplicate</MenuItem>
1436
+ <MenuItem id="delete">Delete</MenuItem>
1437
+ </MenuGroup>
1438
+ </MenuContent>
1439
+ </ActionMenu>
1440
+ </Box>
1441
+ <Box>
1442
+ <Text fontSize={200} fontWeight="semibold" mb={200}>
1443
+ Single Select Menu
1444
+ </Text>
1445
+ <SingleSelectMenu
1446
+ selectedItem={selectedItem}
1447
+ onSelectedItemChange={({ selectedItem: item }) =>
1448
+ setSelectedItem(item)
1449
+ }
1450
+ menuToggleElement={
1451
+ <MenuToggleButton>
1452
+ {selectedItem?.id ?? "Select..."}
1453
+ </MenuToggleButton>
1454
+ }
1455
+ >
1456
+ <MenuContent>
1457
+ <MenuItem id="option-1">Option 1</MenuItem>
1458
+ <MenuItem id="option-2">Option 2</MenuItem>
1459
+ <MenuItem id="option-3">Option 3</MenuItem>
1460
+ </MenuContent>
1461
+ </SingleSelectMenu>
1462
+ </Box>
1463
+ </Box>
1464
+ </ModalBody>
1465
+ <ModalFooter
1466
+ cancelButton={<Button>Cancel</Button>}
1467
+ primaryButton={<Button appearance="primary">Confirm</Button>}
1468
+ />
1469
+ </Modal>
1470
+ );
1471
+ },
1472
+ };
1473
+
1474
+ export const WithModalV1: Story = {
1475
+ render: () => {
1476
+ const [v1Open, setV1Open] = useState(false);
1477
+ return (
1478
+ <Modal
1479
+ closeButtonAriaLabel="Close Modal V2"
1480
+ modalTrigger={<Button appearance="primary">Open Modal V2</Button>}
1481
+ >
1482
+ <ModalHeader title="Modal V2" subtitle="Open a Modal V1 from inside" />
1483
+ <ModalBody>
1484
+ <Text as="p" mb={400}>
1485
+ Click the button below to open a Modal V1 on top of this Modal V2.
1486
+ The V1 modal should be fully interactive and closing it should not
1487
+ dismiss the V2 modal.
1488
+ </Text>
1489
+ <Button appearance="secondary" onClick={() => setV1Open(true)}>
1490
+ Open Modal V1
1491
+ </Button>
1492
+ <ModalV1
1493
+ isOpen={v1Open}
1494
+ onClose={() => setV1Open(false)}
1495
+ label="Modal V1 on top of V2"
1496
+ closeButtonLabel="Close Modal V1"
1497
+ zIndex={100}
1498
+ >
1499
+ <ModalV1.Header title="Modal V1" />
1500
+ <ModalV1.Content>
1501
+ <Box p={400}>
1502
+ <Text as="p">
1503
+ This Modal V1 is rendered on top of a Modal V2. It should be
1504
+ clickable and closing it should not dismiss the V2 behind it.
1505
+ </Text>
1506
+ </Box>
1507
+ </ModalV1.Content>
1508
+ </ModalV1>
1509
+ </ModalBody>
1510
+ <ModalFooter
1511
+ cancelButton={<Button>Cancel</Button>}
1512
+ primaryButton={<Button appearance="primary">Confirm</Button>}
1513
+ />
1514
+ </Modal>
1515
+ );
1516
+ },
1517
+ };
1518
+
1519
+ /**
1520
+ * Two Modal V2s stacked — a primary modal that opens a secondary confirmation modal.
1521
+ *
1522
+ * The secondary modal uses zIndex={8} so its overlay (8) and content (9) render
1523
+ * above the primary modal's overlay (6) and content (7). Without this, the second
1524
+ * modal's overlay would sit behind the first modal's content.
1525
+ */
1526
+ export const NestedModals: Story = {
1527
+ render: () => {
1528
+ const [primaryOpen, setPrimaryOpen] = useState(false);
1529
+ const [secondaryOpen, setSecondaryOpen] = useState(false);
1530
+ const secondaryTriggerRef = React.useRef<HTMLButtonElement>(null);
1531
+ const {
1532
+ triggerRef: primaryTriggerRef,
1533
+ triggerProps: primaryTriggerProps,
1534
+ onCloseAutoFocus: primaryOnCloseAutoFocus,
1535
+ } = useModalExternalTrigger();
1536
+
1537
+ return (
1538
+ <Box>
1539
+ <Button
1540
+ ref={primaryTriggerRef}
1541
+ {...primaryTriggerProps(primaryOpen)}
1542
+ appearance="primary"
1543
+ onClick={() => setPrimaryOpen(true)}
1544
+ >
1545
+ Open Primary Modal
1546
+ </Button>
1547
+
1548
+ {/* Primary modal — default z-index (overlay: 6, content: 7) */}
1549
+ <Modal
1550
+ open={primaryOpen}
1551
+ onOpenChange={setPrimaryOpen}
1552
+ onCloseAutoFocus={primaryOnCloseAutoFocus}
1553
+ aria-label="Primary Modal"
1554
+ closeButtonAriaLabel="Close Primary Modal"
1555
+ >
1556
+ <ModalHeader
1557
+ title="Primary Modal"
1558
+ subtitle="This is the first modal"
1559
+ />
1560
+ <ModalBody>
1561
+ <Text mb={400}>
1562
+ This is the primary modal. Click the button below to open a
1563
+ secondary confirmation modal on top of this one.
1564
+ </Text>
1565
+ <Button
1566
+ ref={secondaryTriggerRef}
1567
+ aria-haspopup="dialog"
1568
+ aria-expanded={secondaryOpen}
1569
+ appearance="destructive"
1570
+ onClick={() => setSecondaryOpen(true)}
1571
+ >
1572
+ Delete item
1573
+ </Button>
1574
+ </ModalBody>
1575
+ <ModalFooter
1576
+ cancelButton={<Button>Cancel</Button>}
1577
+ primaryButton={<Button appearance="primary">Save</Button>}
1578
+ />
1579
+ </Modal>
1580
+
1581
+ {/* Secondary modal — elevated z-index (overlay: 8, content: 9) */}
1582
+ <Modal
1583
+ open={secondaryOpen}
1584
+ onOpenChange={setSecondaryOpen}
1585
+ onCloseAutoFocus={(e) => {
1586
+ e.preventDefault();
1587
+ secondaryTriggerRef.current?.focus();
1588
+ }}
1589
+ zIndex={8}
1590
+ aria-label="Confirm Deletion"
1591
+ closeButtonAriaLabel="Close Confirmation"
1592
+ >
1593
+ <ModalHeader
1594
+ title="Are you sure?"
1595
+ subtitle="This action can't be undone."
1596
+ />
1597
+ <ModalBody>
1598
+ <Text>
1599
+ Deleting this item will remove it permanently. The primary modal
1600
+ behind this one remains open.
1601
+ </Text>
1602
+ </ModalBody>
1603
+ <ModalFooter
1604
+ cancelButton={
1605
+ <Button onClick={() => setSecondaryOpen(false)}>Cancel</Button>
1606
+ }
1607
+ primaryButton={
1608
+ <Button
1609
+ appearance="destructive"
1610
+ onClick={() => {
1611
+ setSecondaryOpen(false);
1612
+ setPrimaryOpen(false);
1613
+ }}
1614
+ >
1615
+ Delete
1616
+ </Button>
1617
+ }
1618
+ />
1619
+ </Modal>
1620
+ </Box>
1621
+ );
1622
+ },
1623
+ };