@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.
- package/.turbo/turbo-build.log +28 -28
- package/CHANGELOG.md +63 -0
- package/dist/{Modal-ki8oiGbC.d.mts → Modal-DTeKLfEI.d.mts} +1 -1
- package/dist/{Modal-ki8oiGbC.d.ts → Modal-DTeKLfEI.d.ts} +1 -1
- package/dist/{ModalAction-BHG3Zbd9.d.mts → ModalExternalTrigger-BnbJk9zY.d.mts} +85 -3
- package/dist/{ModalAction-BHG3Zbd9.d.ts → ModalExternalTrigger-BnbJk9zY.d.ts} +85 -3
- package/dist/esm/{chunk-HOGEFGDN.js → chunk-62MRZAJV.js} +32 -10
- package/dist/esm/{chunk-HOGEFGDN.js.map → chunk-62MRZAJV.js.map} +1 -1
- package/dist/esm/{chunk-IYDY4OPB.js → chunk-72GBDCA2.js} +17 -1
- package/dist/esm/chunk-72GBDCA2.js.map +1 -0
- package/dist/esm/index.js +10 -4
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/v1/index.js +1 -1
- package/dist/esm/v2/index.js +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +81 -11
- package/dist/index.js.map +1 -1
- package/dist/v1/index.d.mts +2 -2
- package/dist/v1/index.d.ts +2 -2
- package/dist/v1/index.js +16 -0
- package/dist/v1/index.js.map +1 -1
- package/dist/v2/index.d.mts +4 -77
- package/dist/v2/index.d.ts +4 -77
- package/dist/v2/index.js +31 -9
- package/dist/v2/index.js.map +1 -1
- package/package.json +7 -7
- package/src/Modal.stories.tsx +64 -0
- package/src/__tests__/v1/Modal.test.tsx +146 -1
- package/src/__tests__/v2/Modal.test.tsx +77 -0
- package/src/index.ts +4 -0
- package/src/v1/Modal.tsx +30 -0
- package/src/v2/ModalTypes.ts +9 -2
- package/src/v2/ModalV2.stories.tsx +268 -49
- package/src/v2/components/ModalFooter.tsx +15 -4
- 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
|
package/src/v2/ModalTypes.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1056
|
+
Controlled Modal: useModalExternalTrigger Hook
|
|
1002
1057
|
</Text>
|
|
1003
1058
|
<Text mb={400}>
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
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
|
|
1070
|
+
Works with any trigger element (Button, link, custom component)
|
|
1015
1071
|
</Text>
|
|
1016
1072
|
</li>
|
|
1017
1073
|
<li>
|
|
1018
1074
|
<Text>
|
|
1019
|
-
|
|
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>
|
|
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="
|
|
1045
|
-
subtitle="
|
|
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
|
|
1050
|
-
|
|
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>
|
|
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>
|
|
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
|
-
|
|
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
|
-
*
|
|
1149
|
+
* Alternative to useModalExternalTrigger when the trigger is specifically a Seeds Button.
|
|
1090
1150
|
*
|
|
1091
|
-
*
|
|
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
|
-
*
|
|
1094
|
-
*
|
|
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
|
-
|
|
1167
|
+
Controlled Modal: ModalExternalTrigger Component
|
|
1107
1168
|
</Text>
|
|
1108
1169
|
<Text mb={400}>
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
1197
|
+
Note:
|
|
1134
1198
|
</Text>
|
|
1135
1199
|
<Text mb={400}>
|
|
1136
|
-
|
|
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="
|
|
1161
|
-
subtitle="Using ModalExternalTrigger"
|
|
1225
|
+
title="Controlled Modal"
|
|
1226
|
+
subtitle="Using ModalExternalTrigger component"
|
|
1162
1227
|
/>
|
|
1163
1228
|
<ModalBody>
|
|
1164
1229
|
<Text mb={400}>
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
|
1239
|
+
<Text>aria-expanded reflecting the current open state</Text>
|
|
1174
1240
|
</li>
|
|
1175
1241
|
<li>
|
|
1176
|
-
<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
|
-
|
|
1181
|
-
|
|
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 {
|
|
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
|
-
{
|
|
94
|
-
<ModalCloseWrapper>{primaryButton}</ModalCloseWrapper>
|
|
95
|
-
)}
|
|
106
|
+
{wrappedPrimaryButton}
|
|
96
107
|
</Box>
|
|
97
108
|
</ModalCustomFooter>
|
|
98
109
|
);
|