Skip to content

Commit e091987

Browse files
authored
Merge pull request #345 from eccenca/fix/hotkeysDuringDialog-CMEM-6851
Support tracking of Modal open/close states
2 parents 3a74d9f + 9ec51f2 commit e091987

File tree

5 files changed

+218
-6
lines changed

5 files changed

+218
-6
lines changed

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ This is a major release, and it might be not compatible with your current usage
6060
- `toggler-micon`
6161
- `toggler-micoff`
6262
- `<Modal />`:
63-
- `preventReactFlowEvents`: Adds 'nopan', 'nowheel' and 'nodrag' classes to Modal's overlay classes in order to prevent react-flow to react to drag and pan actions in modals.
64-
This is enabled by default for <SimpleDialog />.
65-
63+
- Add `ModalContext` to track open/close state of all used application modals.
64+
- Add `modalId` property to give a modal a unique ID for tracking purposes.
65+
- `preventReactFlowEvents`: adds 'nopan', 'nowheel' and 'nodrag' classes to overlay classes in order to prevent react-flow to react to drag and pan actions in modals.
6666

6767
### Removed
6868

src/components/Dialog/Modal.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { CLASSPREFIX as eccgui } from "../../configuration/constants";
1111
import { TestableComponent } from "../interfaces";
1212

1313
import { Card } from "./../Card";
14+
import { ModalContext } from "./ModalContext";
1415

1516
export interface ModalProps extends TestableComponent, BlueprintOverlayProps {
1617
children: React.ReactNode | React.ReactNode[];
@@ -43,7 +44,13 @@ export interface ModalProps extends TestableComponent, BlueprintOverlayProps {
4344
* If this option is used inflationary then this could harm the visibility of other overlays.
4445
*/
4546
forceTopPosition?: boolean;
46-
/** Prevents that pan and zooming actions of an existing react-flow instance are triggered while this Modal is open. */
47+
/**
48+
* Modal ID that should be globally unique. If a ModalContext is provided this can be used to track opening/closing of this modal.
49+
*/
50+
modalId?: string;
51+
/**
52+
* Prevents that pan and zooming actions of an existing react-flow instance are triggered while this Modal is open.
53+
*/
4754
preventReactFlowEvents?: boolean;
4855
}
4956

@@ -70,9 +77,24 @@ export const Modal = ({
7077
onOpening,
7178
"data-test-id": dataTestId,
7279
"data-testid": dataTestid,
80+
modalId,
7381
preventReactFlowEvents = true,
7482
...otherProps
7583
}: ModalProps) => {
84+
const modalContext = React.useContext(ModalContext)
85+
const uniqueModalId = React.useRef<string>(modalId ?? Date.now().toString(36) + Math.random().toString(36).substring(2))
86+
87+
React.useEffect(() => {
88+
return () => {
89+
// Make sure to always remove flag when modal is removed
90+
modalContext.setModalOpen(uniqueModalId.current, false)
91+
}
92+
}, [])
93+
94+
React.useEffect(() => {
95+
modalContext.setModalOpen(uniqueModalId.current, otherProps.isOpen)
96+
}, [otherProps.isOpen])
97+
7698
const backdropProps: React.HTMLProps<HTMLDivElement> | undefined =
7799
!canOutsideClickClose && canEscapeKeyClose
78100
? {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from "react";
2+
3+
export interface ModalContextProps {
4+
/** Set that a specific modal is currently being open (or closed) */
5+
setModalOpen: (modalId: string, isOpen: boolean) => void;
6+
7+
/** The currently opened modals ordered by when they have been opened. Oldest coming first. */
8+
openModalStack: string[] | undefined;
9+
}
10+
11+
/** Can be provided in the application to react to modal related changes. */
12+
export const ModalContext = React.createContext<ModalContextProps>({
13+
setModalOpen: () => {},
14+
openModalStack: [],
15+
});
16+
17+
/** Default implementation for modal context props.
18+
* Tracks open modals in a stack representation.
19+
**/
20+
export const useModalContext = (): ModalContextProps => {
21+
// A stack of modal IDs. These should reflect a stacked opening of modals on top of each other.
22+
const [openModalStack, setOpenModalStack] = React.useState<string[]>([]);
23+
24+
const setModalOpen = React.useCallback((modalId: string, isOpen: boolean) => {
25+
setOpenModalStack((old) => {
26+
if (isOpen) {
27+
return [...old, modalId];
28+
} else {
29+
const idx = old.findIndex((id) => modalId === id);
30+
switch (idx) {
31+
case -1:
32+
// Trying to close modal that has not been registered as open!
33+
return old;
34+
case old.length - 1:
35+
return old.slice(0, idx);
36+
default:
37+
// Modal in between is closed. Consider all modals after it also as closed.
38+
return old.slice(0, idx);
39+
}
40+
}
41+
});
42+
}, []);
43+
44+
return {
45+
openModalStack: openModalStack.length ? [...openModalStack] : undefined,
46+
setModalOpen,
47+
};
48+
};

src/components/Dialog/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./Modal";
22
export * from "./SimpleDialog";
33
export * from "./AlertDialog";
4+
export * from "./ModalContext";

src/components/Dialog/stories/Modal.stories.tsx

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import React from "react";
2-
import { OverlaysProvider } from "@blueprintjs/core";
2+
import { Classes, OverlaysProvider } from "@blueprintjs/core";
33
import { Meta, StoryFn } from "@storybook/react";
44
import { fn } from "@storybook/test";
55

66
import { SimpleCard } from "../../Card/stories/Card.stories";
77

8-
import { Card, Modal } from "./../../../../index";
8+
import {
9+
Button,
10+
Card,
11+
CardContent,
12+
Modal,
13+
ModalContext,
14+
ModalSize,
15+
Spacing,
16+
useModalContext,
17+
} from "./../../../../index";
918

1019
export default {
1120
title: "Components/Dialog/Modal",
@@ -33,3 +42,135 @@ Default.args = {
3342
onOpening: fn(),
3443
onClosing: fn(),
3544
};
45+
46+
const ContextTemplate = ({ children }: React.HTMLAttributes<HTMLDivElement>) => {
47+
const { setModalOpen, openModalStack } = useModalContext();
48+
49+
return (
50+
<OverlaysProvider>
51+
<div style={{ height: "70vh", position: "relative" }} id={"modalPortal"}>
52+
<ModalContext.Provider value={{ setModalOpen, openModalStack }}>{children}</ModalContext.Provider>
53+
</div>
54+
</OverlaysProvider>
55+
);
56+
};
57+
58+
const ModalContent = ({ children }: React.HTMLAttributes<HTMLDivElement>) => {
59+
return (
60+
<Card style={{ height: "100%" }}>
61+
<CardContent>{children}</CardContent>
62+
</Card>
63+
);
64+
};
65+
66+
/** Component for nested modals. */
67+
const ExampleModal = ({
68+
id,
69+
size,
70+
children,
71+
}: {
72+
id?: string;
73+
size: ModalSize;
74+
children?: React.HTMLAttributes<HTMLDivElement>["children"];
75+
}) => {
76+
const [isOpen, setIsOpen] = React.useState(true);
77+
const [portalElement, setPortalElement] = React.useState<HTMLElement | undefined>();
78+
79+
React.useEffect(() => {
80+
setPortalElement(document.getElementById("modalPortal")!);
81+
}, []);
82+
83+
return (
84+
<Modal
85+
modalId={id}
86+
size={size}
87+
isOpen={isOpen}
88+
usePortal={true}
89+
portalContainer={portalElement}
90+
hasBackdrop={true}
91+
onOpened={() => {
92+
// workaround, Blueprint attach a class to body tht prevents scrolling, probably it is attached to the wrong portal
93+
document.body.classList.remove(Classes.OVERLAY_OPEN);
94+
}}
95+
>
96+
<ModalContent>
97+
Modal with constant modal ID "{id}".
98+
<Spacing />
99+
<TrackingContent />
100+
<Spacing />
101+
{children}
102+
<Spacing />
103+
<Button key={"close"} onClick={() => setIsOpen(false)}>
104+
Close
105+
</Button>
106+
</ModalContent>
107+
</Modal>
108+
);
109+
};
110+
111+
const InnerModal = () => {
112+
return <ExampleModal id="innerModal" size="small" />;
113+
};
114+
115+
const MiddleModal = () => {
116+
return (
117+
<ExampleModal id="middleModal" size="regular">
118+
<InnerModal />
119+
</ExampleModal>
120+
);
121+
};
122+
123+
/** Shows the current stack of open modals. */
124+
const TrackingContent = () => {
125+
const modalContext = React.useContext(ModalContext);
126+
127+
return (
128+
<ul>
129+
{(modalContext.openModalStack ?? []).map((modalId, idx) => (
130+
<li key={modalId}>
131+
{idx + 1}. {modalId}
132+
</li>
133+
))}
134+
</ul>
135+
);
136+
};
137+
138+
/**
139+
* `ModalContext` can be used as provider to track a stack of modals.
140+
*
141+
* ```(Javascript)
142+
* const ContextTemplate = () => {
143+
* const { setModalOpen, openModalStack } = useModalContext();
144+
* return (
145+
* <ModalContext.Provider value={{ setModalOpen, openModalStack }}>
146+
* <SimpleDialog size="large" isOpen>
147+
* <OtherModal />
148+
* </SimpleDialog>
149+
* </ModalContext.Provider>
150+
* );
151+
* };
152+
*
153+
* const OtherModal = () => {
154+
* const modalContext = React.useContext(ModalContext);
155+
* return (
156+
* <SimpleDialog size="small">
157+
* <ul>
158+
* {(modalContext.openModalStack ?? []).map((modalId, idx) => (
159+
* <li key={modalId}>
160+
* {idx + 1}. {modalId}
161+
* </li>
162+
* ))}
163+
* </ul>
164+
* </SimpleDialog>
165+
* );
166+
* };
167+
* ```
168+
*/
169+
export const NestedModalWithContext = ContextTemplate.bind({});
170+
NestedModalWithContext.args = {
171+
children: [
172+
<ExampleModal size="large">
173+
<MiddleModal />
174+
</ExampleModal>,
175+
],
176+
};

0 commit comments

Comments
 (0)