@sproutsocial/seeds-react-modal 2.4.9 → 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 (36) hide show
  1. package/.turbo/turbo-build.log +28 -28
  2. package/CHANGELOG.md +63 -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-HOGEFGDN.js → chunk-62MRZAJV.js} +32 -10
  8. package/dist/esm/{chunk-HOGEFGDN.js.map → chunk-62MRZAJV.js.map} +1 -1
  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 +81 -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 +31 -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 +77 -0
  31. package/src/index.ts +4 -0
  32. package/src/v1/Modal.tsx +30 -0
  33. package/src/v2/ModalTypes.ts +9 -2
  34. package/src/v2/ModalV2.stories.tsx +268 -49
  35. package/src/v2/components/ModalFooter.tsx +15 -4
  36. package/dist/esm/chunk-IYDY4OPB.js.map +0 -1
@@ -13,6 +13,7 @@ import userEvent from "@testing-library/user-event";
13
13
  import {
14
14
  Modal,
15
15
  ModalBody,
16
+ ModalFooter,
16
17
  useModalExternalTrigger,
17
18
  ModalExternalTrigger,
18
19
  } from "../../v2";
@@ -1122,3 +1123,79 @@ describe("Portaled components in modal", () => {
1122
1123
  expect(contextValue).toHaveTextContent("false");
1123
1124
  });
1124
1125
  });
1126
+
1127
+ describe("ModalFooter - closeOnPrimaryAction", () => {
1128
+ it("should close the modal when primary button is clicked by default", async () => {
1129
+ const onOpenChange = jest.fn();
1130
+
1131
+ render(
1132
+ <Modal
1133
+ open
1134
+ onOpenChange={onOpenChange}
1135
+ aria-label="Test Modal"
1136
+ closeButtonAriaLabel="Close"
1137
+ >
1138
+ <ModalBody>Content</ModalBody>
1139
+ <ModalFooter
1140
+ primaryButton={<Button appearance="primary">Save</Button>}
1141
+ cancelButton={<Button>Cancel</Button>}
1142
+ />
1143
+ </Modal>
1144
+ );
1145
+
1146
+ const saveButton = screen.getByRole("button", { name: "Save" });
1147
+ await userEvent.click(saveButton);
1148
+
1149
+ expect(onOpenChange).toHaveBeenCalledWith(false);
1150
+ });
1151
+
1152
+ it("should NOT close the modal when primary button is clicked and closeOnPrimaryAction is false", async () => {
1153
+ const onOpenChange = jest.fn();
1154
+
1155
+ render(
1156
+ <Modal
1157
+ open
1158
+ onOpenChange={onOpenChange}
1159
+ aria-label="Test Modal"
1160
+ closeButtonAriaLabel="Close"
1161
+ >
1162
+ <ModalBody>Content</ModalBody>
1163
+ <ModalFooter
1164
+ closeOnPrimaryAction={false}
1165
+ primaryButton={<Button appearance="primary">Save</Button>}
1166
+ cancelButton={<Button>Cancel</Button>}
1167
+ />
1168
+ </Modal>
1169
+ );
1170
+
1171
+ const saveButton = screen.getByRole("button", { name: "Save" });
1172
+ await userEvent.click(saveButton);
1173
+
1174
+ expect(onOpenChange).not.toHaveBeenCalled();
1175
+ });
1176
+
1177
+ it("should still close the modal when cancel button is clicked even if closeOnPrimaryAction is false", async () => {
1178
+ const onOpenChange = jest.fn();
1179
+
1180
+ render(
1181
+ <Modal
1182
+ open
1183
+ onOpenChange={onOpenChange}
1184
+ aria-label="Test Modal"
1185
+ closeButtonAriaLabel="Close"
1186
+ >
1187
+ <ModalBody>Content</ModalBody>
1188
+ <ModalFooter
1189
+ closeOnPrimaryAction={false}
1190
+ primaryButton={<Button appearance="primary">Save</Button>}
1191
+ cancelButton={<Button>Cancel</Button>}
1192
+ />
1193
+ </Modal>
1194
+ );
1195
+
1196
+ const cancelButton = screen.getByRole("button", { name: "Cancel" });
1197
+ await userEvent.click(cancelButton);
1198
+
1199
+ expect(onOpenChange).toHaveBeenCalledWith(false);
1200
+ });
1201
+ });
package/src/index.ts CHANGED
@@ -24,6 +24,9 @@ export {
24
24
  ModalAction,
25
25
  ModalCustomHeader,
26
26
  ModalCustomFooter,
27
+ ModalExternalTrigger,
28
+ useModalExternalTrigger,
29
+ useModalTriggerProps,
27
30
  } from "./v2";
28
31
  export type {
29
32
  TypeModalProps as TypeModalV2Props,
@@ -34,4 +37,5 @@ export type {
34
37
  TypeModalRailProps,
35
38
  TypeModalActionProps,
36
39
  ModalCloseWrapperProps,
40
+ ModalExternalTriggerProps,
37
41
  } from "./v2";
package/src/v1/Modal.tsx CHANGED
@@ -106,10 +106,40 @@ const Modal = (props: TypeModalProps) => {
106
106
  ? (document.querySelector(appElementSelector) as HTMLElement)
107
107
  : undefined;
108
108
 
109
+ /**
110
+ * When a Radix Dialog (Modal V2) is open it sets `pointer-events: none` on
111
+ * `document.body` and listens for `pointerdown` on the document to detect
112
+ * "outside" clicks. react-modal portals its overlay to body, so:
113
+ * 1. The overlay inherits `pointer-events: none` → unclickable
114
+ * 2. Clicks on the overlay bubble to document → Radix dismisses Modal V2
115
+ *
116
+ * We use `contentRef` to reach the portal DOM and fix both issues.
117
+ */
118
+ const handleContentRef = React.useCallback(
119
+ (contentEl: HTMLElement | null) => {
120
+ if (!contentEl) return;
121
+ // DOM structure: body > portal > overlay > content
122
+ const portal = contentEl.parentElement?.parentElement;
123
+ if (portal) {
124
+ portal.style.pointerEvents = "auto";
125
+ portal.addEventListener("pointerdown", (e) => {
126
+ // Only stop propagation when Radix Dialog has disabled body pointer events.
127
+ // This avoids interfering with document-level listeners (analytics, other
128
+ // click-outside patterns) when no Modal V2 is open.
129
+ if (document.body.style.pointerEvents === "none") {
130
+ e.stopPropagation();
131
+ }
132
+ });
133
+ }
134
+ },
135
+ []
136
+ );
137
+
109
138
  return (
110
139
  <Container
111
140
  appElement={appElement}
112
141
  ariaHideApp={!!appElement}
142
+ contentRef={handleContentRef}
113
143
  isOpen={isOpen}
114
144
  contentLabel={label}
115
145
  // eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -77,8 +77,15 @@ export interface TypeModalDescriptionProps extends TypeBoxProps {
77
77
  * Note: This component only supports slots (button props).
78
78
  * For custom footers, use ModalCustomFooter instead.
79
79
  */
80
- export type TypeModalFooterProps = TypeBoxProps &
81
- (
80
+ export type TypeModalFooterProps = TypeBoxProps & {
81
+ /**
82
+ * Whether clicking the primary button automatically closes the modal.
83
+ * Set to `false` when the primary action is async (e.g., saving data)
84
+ * and the modal should stay open until the operation completes.
85
+ * @default true
86
+ */
87
+ closeOnPrimaryAction?: boolean;
88
+ } & (
82
89
  | {
83
90
  /** Primary action button - automatically wrapped in ModalCloseWrapper */
84
91
  primaryButton: React.ReactNode;
@@ -5,6 +5,7 @@ import { Button } from "@sproutsocial/seeds-react-button";
5
5
  import Text from "@sproutsocial/seeds-react-text";
6
6
  import { Popout } from "@sproutsocial/seeds-react-popout";
7
7
  import { Popout as PopoutV2 } from "@sproutsocial/seeds-react-popout/v2";
8
+ import ModalV1 from "../v1/Modal";
8
9
  import { FormField } from "@sproutsocial/seeds-react-form-field";
9
10
  import {
10
11
  ActionMenu,
@@ -851,6 +852,61 @@ export const FooterWithLeftAction: Story = {
851
852
  },
852
853
  };
853
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
+
854
910
  export const CustomFooterOverride: Story = {
855
911
  render: () => {
856
912
  return (
@@ -978,16 +1034,15 @@ export const PortaledComponentsInModal: Story = {
978
1034
  };
979
1035
 
980
1036
  /**
981
- * Example using the useModalExternalTrigger hook for external triggers.
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).
982
1039
  *
983
- * ⚠️ NOT RECOMMENDED - This is a last resort pattern. Prefer using modalTrigger prop.
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
984
1044
  *
985
- * The hook provides:
986
- * - triggerRef: Ref to attach to your trigger element
987
- * - triggerProps: Function that returns ARIA props (pass isOpen state)
988
- * - onCloseAutoFocus: Callback for Modal to restore focus
989
- *
990
- * This eliminates manual ref creation and focus restoration boilerplate.
1045
+ * This is the standard pattern for most real-world controlled modals.
991
1046
  */
992
1047
  export const ExternalTriggerWithHook: Story = {
993
1048
  render: () => {
@@ -998,29 +1053,31 @@ export const ExternalTriggerWithHook: Story = {
998
1053
  return (
999
1054
  <Box>
1000
1055
  <Text mb={400} fontWeight="semibold">
1001
- ⚠️ LAST RESORT: useModalExternalTrigger Hook
1056
+ Controlled Modal: useModalExternalTrigger Hook
1002
1057
  </Text>
1003
1058
  <Text mb={400}>
1004
- Only use when the trigger absolutely cannot be near the Modal (e.g.,
1005
- 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.
1006
1062
  </Text>
1007
1063
 
1008
1064
  <Text mb={400} fontWeight="semibold">
1009
- Benefits:
1065
+ What this hook provides:
1010
1066
  </Text>
1011
1067
  <Box as="ul" pl={400} mb={400}>
1012
1068
  <li>
1013
1069
  <Text>
1014
- Works with any trigger element (Button, custom components, etc.)
1070
+ Works with any trigger element (Button, link, custom component)
1015
1071
  </Text>
1016
1072
  </li>
1017
1073
  <li>
1018
1074
  <Text>
1019
- Automatic focus restoration via onCloseAutoFocus callback
1075
+ Correct ARIA attributes on the trigger (aria-haspopup,
1076
+ aria-expanded, aria-controls)
1020
1077
  </Text>
1021
1078
  </li>
1022
1079
  <li>
1023
- <Text>No manual ref creation needed</Text>
1080
+ <Text>Automatic focus restoration when the modal closes</Text>
1024
1081
  </li>
1025
1082
  </Box>
1026
1083
 
@@ -1041,31 +1098,34 @@ export const ExternalTriggerWithHook: Story = {
1041
1098
  closeButtonAriaLabel="Close Modal"
1042
1099
  >
1043
1100
  <ModalHeader
1044
- title="External Trigger with Hook"
1045
- subtitle="Using useModalExternalTrigger"
1101
+ title="Controlled Modal"
1102
+ subtitle="Trigger lives outside the Modal component tree"
1046
1103
  />
1047
1104
  <ModalBody>
1048
1105
  <Text mb={400}>
1049
- The useModalExternalTrigger hook simplifies external trigger setup
1050
- by:
1106
+ The trigger button used to open this modal is rendered separately
1107
+ from the Modal component. The useModalExternalTrigger hook
1108
+ handles:
1051
1109
  </Text>
1052
1110
  <Box as="ul" pl={400}>
1053
1111
  <li>
1054
- <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>
1055
1116
  </li>
1056
1117
  <li>
1057
- <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>
1058
1122
  </li>
1059
1123
  <li>
1060
1124
  <Text>
1061
- Handling focus restoration via onCloseAutoFocus callback
1125
+ The onCloseAutoFocus callback to return focus to the trigger
1062
1126
  </Text>
1063
1127
  </li>
1064
1128
  </Box>
1065
- <Text mt={400}>
1066
- This eliminates the boilerplate of manual ref management and
1067
- custom onCloseAutoFocus implementation.
1068
- </Text>
1069
1129
  </ModalBody>
1070
1130
  <ModalFooter
1071
1131
  cancelButton={
@@ -1086,14 +1146,15 @@ export const ExternalTriggerWithHook: Story = {
1086
1146
  };
1087
1147
 
1088
1148
  /**
1089
- * Example using the ModalExternalTrigger component for external triggers.
1149
+ * Alternative to useModalExternalTrigger when the trigger is specifically a Seeds Button.
1090
1150
  *
1091
- * ⚠️ NOT RECOMMENDED - This is a last resort pattern. Prefer using modalTrigger prop.
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.
1092
1155
  *
1093
- * ModalExternalTrigger is a Button variant with built-in ARIA attributes.
1094
- * Use it when you need an external trigger that is specifically a Button.
1095
- *
1096
- * 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.
1097
1158
  */
1098
1159
  export const ExternalTriggerComponent: Story = {
1099
1160
  render: () => {
@@ -1103,37 +1164,41 @@ export const ExternalTriggerComponent: Story = {
1103
1164
  return (
1104
1165
  <Box>
1105
1166
  <Text mb={400} fontWeight="semibold">
1106
- ⚠️ LAST RESORT: ModalExternalTrigger Component
1167
+ Controlled Modal: ModalExternalTrigger Component
1107
1168
  </Text>
1108
1169
  <Text mb={400}>
1109
- Only use when the trigger absolutely cannot be near the Modal AND
1110
- 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.
1111
1173
  </Text>
1112
1174
 
1113
1175
  <Text mb={400} fontWeight="semibold">
1114
- Benefits:
1176
+ What this component provides:
1115
1177
  </Text>
1116
1178
  <Box as="ul" pl={400} mb={400}>
1117
1179
  <li>
1118
1180
  <Text>
1119
- Automatic ARIA attributes (aria-haspopup, aria-expanded, etc.)
1181
+ Automatic ARIA attributes (aria-haspopup, aria-expanded,
1182
+ aria-controls)
1120
1183
  </Text>
1121
1184
  </li>
1122
1185
  <li>
1123
1186
  <Text>
1124
- All Button props supported (appearance, size, disabled, etc.)
1187
+ All Seeds Button props supported (appearance, size, disabled,
1188
+ etc.)
1125
1189
  </Text>
1126
1190
  </li>
1127
1191
  <li>
1128
- <Text>Cleaner than hook for Button-only triggers</Text>
1192
+ <Text>Cleaner than the hook when using a Button trigger</Text>
1129
1193
  </li>
1130
1194
  </Box>
1131
1195
 
1132
1196
  <Text mb={400} fontWeight="semibold">
1133
- Limitation:
1197
+ Note:
1134
1198
  </Text>
1135
1199
  <Text mb={400}>
1136
- 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.
1137
1202
  </Text>
1138
1203
 
1139
1204
  <ModalExternalTrigger
@@ -1157,28 +1222,31 @@ export const ExternalTriggerComponent: Story = {
1157
1222
  closeButtonAriaLabel="Close Modal"
1158
1223
  >
1159
1224
  <ModalHeader
1160
- title="External Trigger Component"
1161
- subtitle="Using ModalExternalTrigger"
1225
+ title="Controlled Modal"
1226
+ subtitle="Using ModalExternalTrigger component"
1162
1227
  />
1163
1228
  <ModalBody>
1164
1229
  <Text mb={400}>
1165
- ModalExternalTrigger extends Seeds Button with automatic ARIA
1166
- attributes:
1230
+ The trigger button for this modal is a ModalExternalTrigger
1231
+ component rendered outside the Modal tree. It automatically
1232
+ applies:
1167
1233
  </Text>
1168
1234
  <Box as="ul" pl={400}>
1169
1235
  <li>
1170
1236
  <Text>aria-haspopup="dialog"</Text>
1171
1237
  </li>
1172
1238
  <li>
1173
- <Text>aria-expanded based on isOpen prop</Text>
1239
+ <Text>aria-expanded reflecting the current open state</Text>
1174
1240
  </li>
1175
1241
  <li>
1176
- <Text>aria-controls (if modalId provided)</Text>
1242
+ <Text>
1243
+ aria-controls linking to the modal (if modalId provided)
1244
+ </Text>
1177
1245
  </li>
1178
1246
  </Box>
1179
1247
  <Text mt={400}>
1180
- Supports all Button props (appearance, size, disabled, etc.).
1181
- Focus restoration requires manual onCloseAutoFocus handling.
1248
+ Focus returns to the trigger on close via the onCloseAutoFocus
1249
+ callback passed to the Modal.
1182
1250
  </Text>
1183
1251
  </ModalBody>
1184
1252
  <ModalFooter
@@ -1402,3 +1470,154 @@ export const WithPopout: Story = {
1402
1470
  );
1403
1471
  },
1404
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
+ };
@@ -74,13 +74,26 @@ ModalCustomFooter.displayName = "ModalCustomFooter";
74
74
  * />
75
75
  */
76
76
  export const ModalFooter = (props: TypeModalFooterProps) => {
77
- const { cancelButton, primaryButton, leftAction, ...rest } = props;
77
+ const {
78
+ cancelButton,
79
+ primaryButton,
80
+ leftAction,
81
+ closeOnPrimaryAction = true,
82
+ ...rest
83
+ } = props;
78
84
 
79
85
  // If no simplified props provided, return empty footer
80
86
  if (!cancelButton && !primaryButton && !leftAction) {
81
87
  return null;
82
88
  }
83
89
 
90
+ const wrappedPrimaryButton =
91
+ primaryButton && closeOnPrimaryAction ? (
92
+ <ModalCloseWrapper>{primaryButton}</ModalCloseWrapper>
93
+ ) : (
94
+ primaryButton
95
+ );
96
+
84
97
  // Build simplified API layout
85
98
  return (
86
99
  <ModalCustomFooter data-slot="modal-footer" data-qa-modal-footer {...rest}>
@@ -90,9 +103,7 @@ export const ModalFooter = (props: TypeModalFooterProps) => {
90
103
  {/* Right-aligned button group (Cancel + Primary) */}
91
104
  <Box display="flex" gap={300} marginLeft="auto">
92
105
  {cancelButton && <ModalCloseWrapper>{cancelButton}</ModalCloseWrapper>}
93
- {primaryButton && (
94
- <ModalCloseWrapper>{primaryButton}</ModalCloseWrapper>
95
- )}
106
+ {wrappedPrimaryButton}
96
107
  </Box>
97
108
  </ModalCustomFooter>
98
109
  );