@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.
- package/.turbo/turbo-build.log +28 -28
- package/CHANGELOG.md +70 -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-ZY6VJ7XT.js → chunk-62MRZAJV.js} +36 -10
- package/dist/esm/chunk-62MRZAJV.js.map +1 -0
- 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 +85 -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 +35 -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 +182 -0
- package/src/index.ts +4 -0
- package/src/v1/Modal.tsx +30 -0
- package/src/v2/Modal.tsx +8 -0
- package/src/v2/ModalTypes.ts +9 -2
- package/src/v2/ModalV2.stories.tsx +476 -49
- package/src/v2/components/ModalFooter.tsx +15 -4
- package/dist/esm/chunk-IYDY4OPB.js.map +0 -1
- package/dist/esm/chunk-ZY6VJ7XT.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutsocial/seeds-react-modal",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Seeds React Modal",
|
|
5
5
|
"author": "Sprout Social, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@radix-ui/react-dialog": "^1.1.14",
|
|
39
|
-
"@sproutsocial/seeds-react-box": "^1.1.
|
|
40
|
-
"@sproutsocial/seeds-react-button": "^1.
|
|
41
|
-
"@sproutsocial/seeds-react-icon": "^2.2.
|
|
39
|
+
"@sproutsocial/seeds-react-box": "^1.1.14",
|
|
40
|
+
"@sproutsocial/seeds-react-button": "^1.4.0",
|
|
41
|
+
"@sproutsocial/seeds-react-icon": "^2.2.5",
|
|
42
42
|
"@sproutsocial/seeds-react-portal": "^1.2.0",
|
|
43
43
|
"@sproutsocial/seeds-react-system-props": "^3.0.1",
|
|
44
44
|
"@sproutsocial/seeds-react-text": "^1.4.0",
|
|
45
|
-
"@sproutsocial/seeds-react-theme": "^3.
|
|
45
|
+
"@sproutsocial/seeds-react-theme": "^3.6.0",
|
|
46
46
|
"motion": "^12.6.3",
|
|
47
47
|
"react-dnd": "^16.0.1",
|
|
48
48
|
"react-dnd-html5-backend": "^16.0.1",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@sproutsocial/eslint-config-seeds": "*",
|
|
54
|
-
"@sproutsocial/seeds-react-form-field": "^1.1.
|
|
55
|
-
"@sproutsocial/seeds-react-input": "^1.5.
|
|
54
|
+
"@sproutsocial/seeds-react-form-field": "^1.1.8",
|
|
55
|
+
"@sproutsocial/seeds-react-input": "^1.5.7",
|
|
56
56
|
"@sproutsocial/seeds-react-testing-library": "*",
|
|
57
57
|
"@sproutsocial/seeds-testing": "*",
|
|
58
58
|
"@sproutsocial/seeds-tsconfig": "*",
|
package/src/Modal.stories.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { FormField } from "@sproutsocial/seeds-react-form-field";
|
|
|
6
6
|
import { Input } from "@sproutsocial/seeds-react-input";
|
|
7
7
|
import { Text } from "@sproutsocial/seeds-react-text";
|
|
8
8
|
import Modal from "./v1/Modal";
|
|
9
|
+
import { useModalTriggerProps } from "./v2";
|
|
9
10
|
|
|
10
11
|
interface StatefulStoryProps<T> {
|
|
11
12
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
@@ -415,3 +416,66 @@ export const CustomBackgroundColor: Story = {
|
|
|
415
416
|
</StatefulStory>
|
|
416
417
|
),
|
|
417
418
|
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Demonstrates adding accessible ARIA attributes to a V1 modal trigger
|
|
422
|
+
* using `useModalTriggerProps`. This adds `aria-haspopup="dialog"` and
|
|
423
|
+
* `aria-expanded` to the trigger button so screen readers can identify
|
|
424
|
+
* it as a dialog trigger.
|
|
425
|
+
*
|
|
426
|
+
* Focus restoration is already handled automatically by Modal V1 via
|
|
427
|
+
* react-modal — no ref or callback needed.
|
|
428
|
+
*/
|
|
429
|
+
export const AccessibleTrigger: Story = {
|
|
430
|
+
render: () => {
|
|
431
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
432
|
+
const triggerProps = useModalTriggerProps(isOpen);
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<div>
|
|
436
|
+
<Text as="p" mb={400} color="text.body">
|
|
437
|
+
Inspect the trigger button below — it has aria-haspopup="dialog" and
|
|
438
|
+
aria-expanded attributes applied via useModalTriggerProps.
|
|
439
|
+
</Text>
|
|
440
|
+
|
|
441
|
+
<Button
|
|
442
|
+
{...triggerProps}
|
|
443
|
+
appearance="primary"
|
|
444
|
+
onClick={() => setIsOpen(true)}
|
|
445
|
+
>
|
|
446
|
+
Open Modal
|
|
447
|
+
</Button>
|
|
448
|
+
|
|
449
|
+
<Modal
|
|
450
|
+
appElementSelector="#root"
|
|
451
|
+
isOpen={isOpen}
|
|
452
|
+
onClose={() => setIsOpen(false)}
|
|
453
|
+
closeButtonLabel="Close this dialog"
|
|
454
|
+
label="Accessible Trigger Example"
|
|
455
|
+
>
|
|
456
|
+
<Modal.Header
|
|
457
|
+
title="Accessible Trigger"
|
|
458
|
+
subtitle="The button that opened this modal has proper ARIA attributes"
|
|
459
|
+
/>
|
|
460
|
+
<Modal.Content>
|
|
461
|
+
<Text as="p">
|
|
462
|
+
The trigger button uses useModalTriggerProps to apply
|
|
463
|
+
aria-haspopup and aria-expanded. Focus will return to the trigger
|
|
464
|
+
automatically when this modal closes.
|
|
465
|
+
</Text>
|
|
466
|
+
</Modal.Content>
|
|
467
|
+
<Modal.Footer>
|
|
468
|
+
<Box display="flex" justifyContent="flex-end">
|
|
469
|
+
<Button onClick={() => setIsOpen(false)} mr={300}>
|
|
470
|
+
Cancel
|
|
471
|
+
</Button>
|
|
472
|
+
<Button appearance="primary" onClick={() => setIsOpen(false)}>
|
|
473
|
+
Confirm
|
|
474
|
+
</Button>
|
|
475
|
+
</Box>
|
|
476
|
+
</Modal.Footer>
|
|
477
|
+
</Modal>
|
|
478
|
+
</div>
|
|
479
|
+
);
|
|
480
|
+
},
|
|
481
|
+
};
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
} from "@sproutsocial/seeds-react-testing-library";
|
|
10
10
|
import Modal from "../../v1/Modal";
|
|
11
11
|
|
|
12
|
-
afterEach(() =>
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cleanup();
|
|
14
|
+
document.body.style.pointerEvents = "";
|
|
15
|
+
});
|
|
13
16
|
|
|
14
17
|
describe("Modal", () => {
|
|
15
18
|
it("renders a custom background color", () => {
|
|
@@ -90,6 +93,148 @@ describe("Modal", () => {
|
|
|
90
93
|
expect(onClose).not.toHaveBeenCalled();
|
|
91
94
|
});
|
|
92
95
|
|
|
96
|
+
describe("pointer-events when Modal V2 is open", () => {
|
|
97
|
+
it("should set pointer-events: auto on the react-modal portal", () => {
|
|
98
|
+
render(
|
|
99
|
+
<Modal
|
|
100
|
+
isOpen={true}
|
|
101
|
+
label="Label"
|
|
102
|
+
onClose={() => {}}
|
|
103
|
+
closeButtonLabel="Close"
|
|
104
|
+
>
|
|
105
|
+
Content
|
|
106
|
+
</Modal>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const dialog = screen.getByRole("dialog");
|
|
110
|
+
// DOM: body > portal > overlay > content
|
|
111
|
+
const portal = dialog.parentElement?.parentElement;
|
|
112
|
+
expect(portal).toBeTruthy();
|
|
113
|
+
expect(portal!.style.pointerEvents).toBe("auto");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should stop pointerdown propagation when body has pointer-events: none", () => {
|
|
117
|
+
const documentPointerDown = jest.fn();
|
|
118
|
+
|
|
119
|
+
render(
|
|
120
|
+
<Modal
|
|
121
|
+
isOpen={true}
|
|
122
|
+
label="Label"
|
|
123
|
+
onClose={() => {}}
|
|
124
|
+
closeButtonLabel="Close"
|
|
125
|
+
>
|
|
126
|
+
Content
|
|
127
|
+
</Modal>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Simulate Radix Dialog setting pointer-events: none on body
|
|
131
|
+
document.body.style.pointerEvents = "none";
|
|
132
|
+
|
|
133
|
+
document.addEventListener("pointerdown", documentPointerDown);
|
|
134
|
+
|
|
135
|
+
const dialog = screen.getByRole("dialog");
|
|
136
|
+
const portal = dialog.parentElement?.parentElement;
|
|
137
|
+
fireEvent.pointerDown(portal!);
|
|
138
|
+
|
|
139
|
+
expect(documentPointerDown).not.toHaveBeenCalled();
|
|
140
|
+
|
|
141
|
+
document.removeEventListener("pointerdown", documentPointerDown);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should NOT stop pointerdown propagation when no Modal V2 is open", () => {
|
|
145
|
+
const documentPointerDown = jest.fn();
|
|
146
|
+
|
|
147
|
+
render(
|
|
148
|
+
<Modal
|
|
149
|
+
isOpen={true}
|
|
150
|
+
label="Label"
|
|
151
|
+
onClose={() => {}}
|
|
152
|
+
closeButtonLabel="Close"
|
|
153
|
+
>
|
|
154
|
+
Content
|
|
155
|
+
</Modal>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// body has default pointer-events — no Radix Dialog active
|
|
159
|
+
document.addEventListener("pointerdown", documentPointerDown);
|
|
160
|
+
|
|
161
|
+
const dialog = screen.getByRole("dialog");
|
|
162
|
+
const portal = dialog.parentElement?.parentElement;
|
|
163
|
+
fireEvent.pointerDown(portal!);
|
|
164
|
+
|
|
165
|
+
expect(documentPointerDown).toHaveBeenCalled();
|
|
166
|
+
|
|
167
|
+
document.removeEventListener("pointerdown", documentPointerDown);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("should remain dismissable via overlay click when body has pointer-events: none", () => {
|
|
171
|
+
const onClose = jest.fn();
|
|
172
|
+
|
|
173
|
+
render(
|
|
174
|
+
<Modal
|
|
175
|
+
isOpen={true}
|
|
176
|
+
label="Label"
|
|
177
|
+
onClose={onClose}
|
|
178
|
+
closeButtonLabel="Close"
|
|
179
|
+
>
|
|
180
|
+
Content
|
|
181
|
+
</Modal>
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
document.body.style.pointerEvents = "none";
|
|
185
|
+
|
|
186
|
+
// Click the overlay (parent of the dialog content)
|
|
187
|
+
fireEvent.click(screen.getByRole("dialog").parentElement as HTMLElement);
|
|
188
|
+
expect(onClose).toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should remain dismissable via close button when body has pointer-events: none", () => {
|
|
192
|
+
const onClose = jest.fn();
|
|
193
|
+
|
|
194
|
+
render(
|
|
195
|
+
<Modal
|
|
196
|
+
isOpen={true}
|
|
197
|
+
label="Label"
|
|
198
|
+
onClose={onClose}
|
|
199
|
+
closeButtonLabel="Close this dialog"
|
|
200
|
+
>
|
|
201
|
+
<Modal.Header>
|
|
202
|
+
<Modal.CloseButton />
|
|
203
|
+
</Modal.Header>
|
|
204
|
+
Content
|
|
205
|
+
</Modal>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
document.body.style.pointerEvents = "none";
|
|
209
|
+
|
|
210
|
+
fireEvent.click(screen.getByLabelText("Close this dialog"));
|
|
211
|
+
expect(onClose).toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should remain dismissable via Escape when body has pointer-events: none", () => {
|
|
215
|
+
const onClose = jest.fn();
|
|
216
|
+
|
|
217
|
+
render(
|
|
218
|
+
<Modal
|
|
219
|
+
isOpen={true}
|
|
220
|
+
label="Label"
|
|
221
|
+
onClose={onClose}
|
|
222
|
+
closeButtonLabel="Close"
|
|
223
|
+
>
|
|
224
|
+
Content
|
|
225
|
+
</Modal>
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
document.body.style.pointerEvents = "none";
|
|
229
|
+
|
|
230
|
+
fireEvent.keyDown(screen.getByText("Content"), {
|
|
231
|
+
key: "Escape",
|
|
232
|
+
keyCode: 27,
|
|
233
|
+
});
|
|
234
|
+
expect(onClose).toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
93
238
|
describe("Modal.Header", () => {
|
|
94
239
|
it("should have an aria-label on the close button", () => {
|
|
95
240
|
render(
|
|
@@ -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";
|
|
@@ -763,6 +764,111 @@ describe("Modal V2 - Dialog.Content Event Handlers and Convenience Props", () =>
|
|
|
763
764
|
});
|
|
764
765
|
});
|
|
765
766
|
|
|
767
|
+
describe("Escape key with open V1 Popout", () => {
|
|
768
|
+
afterEach(() => {
|
|
769
|
+
// Clean up any popout elements added to the DOM
|
|
770
|
+
document.querySelectorAll("[data-qa-popout]").forEach((el) => el.remove());
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("should not close modal when a V1 Popout is open", () => {
|
|
774
|
+
const handleOpenChange = jest.fn();
|
|
775
|
+
|
|
776
|
+
// Simulate an open V1 Popout in the DOM
|
|
777
|
+
const popout = document.createElement("div");
|
|
778
|
+
popout.setAttribute("data-qa-popout", "");
|
|
779
|
+
document.body.appendChild(popout);
|
|
780
|
+
|
|
781
|
+
render(
|
|
782
|
+
<Modal
|
|
783
|
+
open={true}
|
|
784
|
+
onOpenChange={handleOpenChange}
|
|
785
|
+
title="Test Modal"
|
|
786
|
+
description="Test description"
|
|
787
|
+
closeButtonAriaLabel="Close"
|
|
788
|
+
>
|
|
789
|
+
<ModalBody>Content</ModalBody>
|
|
790
|
+
</Modal>
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const dialog = screen.getByRole("dialog");
|
|
794
|
+
fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
|
|
795
|
+
|
|
796
|
+
expect(handleOpenChange).not.toHaveBeenCalled();
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("should close modal when no V1 Popout is open", () => {
|
|
800
|
+
const handleOpenChange = jest.fn();
|
|
801
|
+
|
|
802
|
+
render(
|
|
803
|
+
<Modal
|
|
804
|
+
open={true}
|
|
805
|
+
onOpenChange={handleOpenChange}
|
|
806
|
+
title="Test Modal"
|
|
807
|
+
description="Test description"
|
|
808
|
+
closeButtonAriaLabel="Close"
|
|
809
|
+
>
|
|
810
|
+
<ModalBody>Content</ModalBody>
|
|
811
|
+
</Modal>
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
const dialog = screen.getByRole("dialog");
|
|
815
|
+
fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
|
|
816
|
+
|
|
817
|
+
expect(handleOpenChange).toHaveBeenCalledWith(false);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("should still call onEscapeKeyDown callback when popout blocks dismissal", () => {
|
|
821
|
+
const handleEscapeKeyDown = jest.fn();
|
|
822
|
+
const handleOpenChange = jest.fn();
|
|
823
|
+
|
|
824
|
+
const popout = document.createElement("div");
|
|
825
|
+
popout.setAttribute("data-qa-popout", "");
|
|
826
|
+
document.body.appendChild(popout);
|
|
827
|
+
|
|
828
|
+
render(
|
|
829
|
+
<Modal
|
|
830
|
+
open={true}
|
|
831
|
+
onOpenChange={handleOpenChange}
|
|
832
|
+
onEscapeKeyDown={handleEscapeKeyDown}
|
|
833
|
+
title="Test Modal"
|
|
834
|
+
description="Test description"
|
|
835
|
+
closeButtonAriaLabel="Close"
|
|
836
|
+
>
|
|
837
|
+
<ModalBody>Content</ModalBody>
|
|
838
|
+
</Modal>
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
const dialog = screen.getByRole("dialog");
|
|
842
|
+
fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
|
|
843
|
+
|
|
844
|
+
expect(handleEscapeKeyDown).toHaveBeenCalled();
|
|
845
|
+
expect(handleOpenChange).not.toHaveBeenCalled();
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("should work alongside disableEscapeKeyClose", () => {
|
|
849
|
+
const handleOpenChange = jest.fn();
|
|
850
|
+
|
|
851
|
+
render(
|
|
852
|
+
<Modal
|
|
853
|
+
open={true}
|
|
854
|
+
onOpenChange={handleOpenChange}
|
|
855
|
+
disableEscapeKeyClose
|
|
856
|
+
title="Test Modal"
|
|
857
|
+
description="Test description"
|
|
858
|
+
closeButtonAriaLabel="Close"
|
|
859
|
+
>
|
|
860
|
+
<ModalBody>Content</ModalBody>
|
|
861
|
+
</Modal>
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
const dialog = screen.getByRole("dialog");
|
|
865
|
+
fireEvent.keyDown(dialog, { key: "Escape", code: "Escape" });
|
|
866
|
+
|
|
867
|
+
// Should still be blocked by disableEscapeKeyClose even without a popout
|
|
868
|
+
expect(handleOpenChange).not.toHaveBeenCalled();
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
766
872
|
describe("useModalExternalTrigger hook", () => {
|
|
767
873
|
it("should return triggerRef, triggerProps function, and onCloseAutoFocus callback", () => {
|
|
768
874
|
const { result } = renderHook(() => useModalExternalTrigger());
|
|
@@ -1017,3 +1123,79 @@ describe("Portaled components in modal", () => {
|
|
|
1017
1123
|
expect(contextValue).toHaveTextContent("false");
|
|
1018
1124
|
});
|
|
1019
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/Modal.tsx
CHANGED
|
@@ -135,6 +135,14 @@ const Modal = (props: TypeModalProps) => {
|
|
|
135
135
|
|
|
136
136
|
const wrappedOnEscapeKeyDown = React.useCallback<EscapeKeyDownHandler>(
|
|
137
137
|
(e) => {
|
|
138
|
+
// If a V1 Popout is open, prevent the modal from closing.
|
|
139
|
+
// The Popout's own escape handler (on document.body capture phase)
|
|
140
|
+
// will fire next and close just the popout.
|
|
141
|
+
const hasOpenPopout = document.querySelector("[data-qa-popout]") !== null;
|
|
142
|
+
if (hasOpenPopout) {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
}
|
|
145
|
+
|
|
138
146
|
if (disableEscapeKeyClose) {
|
|
139
147
|
e.preventDefault();
|
|
140
148
|
}
|
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;
|