♻️(react) migrate Modals to react modal
We encoutered an issue where stacked modal backdrop were not rendered above the modal below. It was caused by the dialog element that is natively rendered on the top layer regardless where it is create in the DOM. So we decided to use react modal that provides hand crafted dialog, and implementing a11y features. Closes #314
This commit is contained in:
5
.changeset/heavy-coats-beam.md
Normal file
5
.changeset/heavy-coats-beam.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@openfun/cunningham-react": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
migrate Modals to react modal
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"@react-stately/calendar": "3.4.4",
|
"@react-stately/calendar": "3.4.4",
|
||||||
"@react-stately/datepicker": "3.9.2",
|
"@react-stately/datepicker": "3.9.2",
|
||||||
"@tanstack/react-table": "8.13.2",
|
"@tanstack/react-table": "8.13.2",
|
||||||
|
"@types/react-modal": "3.16.3",
|
||||||
"chromatic": "11.2.0",
|
"chromatic": "11.2.0",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"downshift": "8.4.0",
|
"downshift": "8.4.0",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"react-aria": "3.32.1",
|
"react-aria": "3.32.1",
|
||||||
"react-aria-components": "1.1.1",
|
"react-aria-components": "1.1.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"react-modal": "3.16.1",
|
||||||
"react-stately": "3.30.1"
|
"react-stately": "3.30.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import ReactModal from "react-modal";
|
||||||
import {
|
import {
|
||||||
DeleteConfirmationModal,
|
DeleteConfirmationModal,
|
||||||
DeleteConfirmationModalProps,
|
DeleteConfirmationModalProps,
|
||||||
@@ -104,6 +105,10 @@ type ModalMap = Map<string, ReactNode>;
|
|||||||
export const ModalProvider = ({ children }: PropsWithChildren) => {
|
export const ModalProvider = ({ children }: PropsWithChildren) => {
|
||||||
const [modals, setModals] = useState<ModalMap>({} as ModalMap);
|
const [modals, setModals] = useState<ModalMap>({} as ModalMap);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ReactModal.setAppElement(".c__app");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addModal = (
|
const addModal = (
|
||||||
component: FunctionComponent<DecisionModalProps>,
|
component: FunctionComponent<DecisionModalProps>,
|
||||||
props: Partial<DecisionModalProps>,
|
props: Partial<DecisionModalProps>,
|
||||||
@@ -150,28 +155,6 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const portalElement = document.getElementById("c__modals-portal")!;
|
|
||||||
// Create an observer instance linked to the callback function
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
const dialogs = portalElement.querySelectorAll("dialog");
|
|
||||||
if (dialogs.length > 0) {
|
|
||||||
document.querySelector("body")!.classList.add(NOSCROLL_CLASS);
|
|
||||||
} else {
|
|
||||||
document.querySelector("body")!.classList.remove(NOSCROLL_CLASS);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start observing the target node for configured mutations
|
|
||||||
observer.observe(portalElement, {
|
|
||||||
childList: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContext.Provider value={context}>
|
<ModalContext.Provider value={context}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -8,6 +8,17 @@
|
|||||||
box-shadow: var(--c--components--modal--box-shadow);
|
box-shadow: var(--c--components--modal--box-shadow);
|
||||||
color: var(--c--components--modal--color);
|
color: var(--c--components--modal--color);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-width: calc(100% - 2em - 6px);
|
||||||
|
max-height: calc(100% - 2em - 6px);
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
&::backdrop {
|
&::backdrop {
|
||||||
// ::backdrop does not inherit from its element so CSS vars does not work.
|
// ::backdrop does not inherit from its element so CSS vars does not work.
|
||||||
@@ -181,6 +192,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
transform: none;
|
||||||
|
|
||||||
.c__modal__content {
|
.c__modal__content {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ describe("<Modal/>", () => {
|
|||||||
render(<Wrapper />);
|
render(<Wrapper />);
|
||||||
expect(await screen.findByText("Modal Content")).toBeInTheDocument();
|
expect(await screen.findByText("Modal Content")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a modal when clicking on the button", async () => {
|
it("shows a modal when clicking on the button", async () => {
|
||||||
const Wrapper = () => {
|
const Wrapper = () => {
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
@@ -44,6 +43,32 @@ describe("<Modal/>", () => {
|
|||||||
await user.click(button);
|
await user.click(button);
|
||||||
expect(screen.getByText("Modal Content")).toBeInTheDocument();
|
expect(screen.getByText("Modal Content")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it("sets aria-hidden on app when a modal is opened", async () => {
|
||||||
|
const Wrapper = () => {
|
||||||
|
const modal = useModal();
|
||||||
|
return (
|
||||||
|
<CunninghamProvider>
|
||||||
|
<button onClick={modal.open}>Open Modal</button>
|
||||||
|
<Modal size={ModalSize.SMALL} {...modal}>
|
||||||
|
<div>Modal Content</div>
|
||||||
|
</Modal>
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Wrapper />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const button = screen.getByText("Open Modal");
|
||||||
|
const app = document.querySelector(".c__app");
|
||||||
|
|
||||||
|
expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
|
||||||
|
expect(app).not.toHaveAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText("Modal Content")).toBeInTheDocument();
|
||||||
|
expect(app).toHaveAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
it("closes the modal when clicking on the close button", async () => {
|
it("closes the modal when clicking on the close button", async () => {
|
||||||
const Wrapper = () => {
|
const Wrapper = () => {
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
@@ -118,16 +143,7 @@ describe("<Modal/>", () => {
|
|||||||
await user.click(button);
|
await user.click(button);
|
||||||
expect(screen.getByText("Modal Content")).toBeInTheDocument();
|
expect(screen.getByText("Modal Content")).toBeInTheDocument();
|
||||||
|
|
||||||
const modal = screen.getByRole("dialog");
|
await user.click(document.querySelector(".c__modal__backdrop")!);
|
||||||
// We move the pointer on the edge of the screen in order to simulate the click outside.
|
|
||||||
await user.pointer({
|
|
||||||
coords: {
|
|
||||||
x: 1,
|
|
||||||
y: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(modal);
|
|
||||||
|
|
||||||
expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
|
expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -292,7 +308,6 @@ describe("<Modal/>", () => {
|
|||||||
|
|
||||||
// Display the modal.
|
// Display the modal.
|
||||||
await user.click(button);
|
await user.click(button);
|
||||||
|
|
||||||
// Modal is open.
|
// Modal is open.
|
||||||
expect(screen.getByText("Are you sure?")).toBeInTheDocument();
|
expect(screen.getByText("Are you sure?")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -452,6 +467,7 @@ describe("<Modal/>", () => {
|
|||||||
|
|
||||||
expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeFalsy();
|
expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeFalsy();
|
||||||
await user.click(button);
|
await user.click(button);
|
||||||
|
|
||||||
expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy();
|
expect(document.body.classList.contains(NOSCROLL_CLASS)).toBeTruthy();
|
||||||
|
|
||||||
const closeButton = screen.getByRole("button", {
|
const closeButton = screen.getByRole("button", {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { PropsWithChildren, ReactNode, useEffect } from "react";
|
import React, { PropsWithChildren, ReactNode, useEffect } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { createPortal } from "react-dom";
|
import ReactModal from "react-modal";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
|
import { NOSCROLL_CLASS } from ":/components/Modal/ModalProvider";
|
||||||
|
|
||||||
export type ModalHandle = {};
|
export type ModalHandle = {};
|
||||||
|
|
||||||
|
export const MODAL_CLASS = "c__modal";
|
||||||
|
|
||||||
export enum ModalSize {
|
export enum ModalSize {
|
||||||
SMALL = "small",
|
SMALL = "small",
|
||||||
MEDIUM = "medium",
|
MEDIUM = "medium",
|
||||||
@@ -69,63 +72,25 @@ export const Modal = (props: ModalProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ModalInner = (props: ModalProps) => {
|
export const ModalInner = (props: ModalProps) => {
|
||||||
const ref = React.useRef<HTMLDialogElement>(null);
|
const { modalParentSelector } = useModals();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (props.isOpen) {
|
|
||||||
ref.current?.showModal();
|
|
||||||
} else {
|
|
||||||
ref.current?.close();
|
|
||||||
}
|
|
||||||
}, [props.isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
ref.current?.addEventListener("close", () => props.onClose(), {
|
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onClick = (event: MouseEvent) => {
|
|
||||||
const rect = ref.current!.getBoundingClientRect();
|
|
||||||
const isInDialog =
|
|
||||||
rect.top <= event.clientY &&
|
|
||||||
event.clientY <= rect.top + rect.height &&
|
|
||||||
rect.left <= event.clientX &&
|
|
||||||
event.clientX <= rect.left + rect.width;
|
|
||||||
if (!isInDialog) {
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (props.closeOnClickOutside) {
|
|
||||||
ref.current?.addEventListener("click", onClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preventClose = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (props.preventClose) {
|
|
||||||
ref.current?.addEventListener("cancel", preventClose);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ref.current?.removeEventListener("click", onClick);
|
|
||||||
ref.current?.removeEventListener("click", preventClose);
|
|
||||||
};
|
|
||||||
}, [props.isOpen]);
|
|
||||||
|
|
||||||
if (!props.isOpen) {
|
if (!props.isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ReactModal
|
||||||
{createPortal(
|
isOpen={props.isOpen}
|
||||||
<>
|
onRequestClose={() => {
|
||||||
<div aria-hidden={true} className="c__modal__backdrop" />
|
if (!props.preventClose) {
|
||||||
<dialog
|
props.onClose();
|
||||||
ref={ref}
|
}
|
||||||
className={classNames("c__modal", "c__modal--" + props.size)}
|
}}
|
||||||
|
parentSelector={modalParentSelector}
|
||||||
|
overlayClassName="c__modal__backdrop"
|
||||||
|
className={classNames(MODAL_CLASS, `${MODAL_CLASS}--${props.size}`)}
|
||||||
|
shouldCloseOnOverlayClick={!!props.closeOnClickOutside}
|
||||||
|
bodyOpenClassName={classNames("c__modals--opened", NOSCROLL_CLASS)}
|
||||||
>
|
>
|
||||||
{!props.hideCloseButton && !props.preventClose && (
|
{!props.hideCloseButton && !props.preventClose && (
|
||||||
<div className="c__modal__close">
|
<div className="c__modal__close">
|
||||||
@@ -140,20 +105,14 @@ export const ModalInner = (props: ModalProps) => {
|
|||||||
{props.titleIcon && (
|
{props.titleIcon && (
|
||||||
<div className="c__modal__title-icon">{props.titleIcon}</div>
|
<div className="c__modal__title-icon">{props.titleIcon}</div>
|
||||||
)}
|
)}
|
||||||
{props.title && (
|
{props.title && <div className="c__modal__title">{props.title}</div>}
|
||||||
<div className="c__modal__title">{props.title}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
||||||
<div className="c__modal__content" tabIndex={0}>
|
<div className="c__modal__content" tabIndex={0}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
<ModalFooter {...props} />
|
<ModalFooter {...props} />
|
||||||
</dialog>
|
</ReactModal>
|
||||||
</>,
|
|
||||||
document.getElementById("c__modals-portal")!,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ export const CunninghamProvider = ({
|
|||||||
return (
|
return (
|
||||||
<CunninghamContext.Provider value={context}>
|
<CunninghamContext.Provider value={context}>
|
||||||
<ModalProvider>
|
<ModalProvider>
|
||||||
|
<div className="c__app">
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</div>
|
||||||
</ModalProvider>
|
</ModalProvider>
|
||||||
</CunninghamContext.Provider>
|
</CunninghamContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
36
yarn.lock
36
yarn.lock
@@ -4346,6 +4346,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react-modal@^3.16.3":
|
||||||
|
version "3.16.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.16.3.tgz#250f32c07f1de28e2bcf9c3e84b56adaa6897013"
|
||||||
|
integrity sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react@*", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0":
|
"@types/react@*", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0":
|
||||||
version "18.2.66"
|
version "18.2.66"
|
||||||
resolved "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz"
|
resolved "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz"
|
||||||
@@ -6913,6 +6920,11 @@ execa@^8.0.1:
|
|||||||
signal-exit "^4.1.0"
|
signal-exit "^4.1.0"
|
||||||
strip-final-newline "^3.0.0"
|
strip-final-newline "^3.0.0"
|
||||||
|
|
||||||
|
exenv@^1.2.0:
|
||||||
|
version "1.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
|
||||||
|
integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==
|
||||||
|
|
||||||
exit@^0.1.2:
|
exit@^0.1.2:
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz"
|
resolved "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz"
|
||||||
@@ -8910,7 +8922,7 @@ longest-streak@^3.0.0:
|
|||||||
resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz"
|
resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz"
|
||||||
integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
|
integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
|
||||||
|
|
||||||
loose-envify@^1.1.0, loose-envify@^1.4.0:
|
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
|
||||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||||
@@ -10645,6 +10657,21 @@ react-is@^18.0.0, react-is@^18.2.0:
|
|||||||
resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz"
|
resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz"
|
||||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||||
|
|
||||||
|
react-lifecycles-compat@^3.0.0:
|
||||||
|
version "3.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||||
|
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
||||||
|
|
||||||
|
react-modal@^3.16.1:
|
||||||
|
version "3.16.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.16.1.tgz#34018528fc206561b1a5467fc3beeaddafb39b2b"
|
||||||
|
integrity sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==
|
||||||
|
dependencies:
|
||||||
|
exenv "^1.2.0"
|
||||||
|
prop-types "^15.7.2"
|
||||||
|
react-lifecycles-compat "^3.0.0"
|
||||||
|
warning "^4.0.3"
|
||||||
|
|
||||||
react-refresh@^0.14.0:
|
react-refresh@^0.14.0:
|
||||||
version "0.14.0"
|
version "0.14.0"
|
||||||
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz"
|
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz"
|
||||||
@@ -12470,6 +12497,13 @@ walker@^1.0.8:
|
|||||||
dependencies:
|
dependencies:
|
||||||
makeerror "1.0.12"
|
makeerror "1.0.12"
|
||||||
|
|
||||||
|
warning@^4.0.3:
|
||||||
|
version "4.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
|
||||||
|
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.0.0"
|
||||||
|
|
||||||
watchpack@^2.2.0:
|
watchpack@^2.2.0:
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz"
|
resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user