@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-modal",
3
- "version": "2.4.8",
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.13",
40
- "@sproutsocial/seeds-react-button": "^1.3.18",
41
- "@sproutsocial/seeds-react-icon": "^2.2.4",
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.5.1",
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.6",
55
- "@sproutsocial/seeds-react-input": "^1.5.4",
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": "*",
@@ -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(() => cleanup());
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
  }
@@ -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;