Skip to content
Open
36 changes: 34 additions & 2 deletions src/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { FC, useEffect, useState } from "react";

import Button from "components/Button";
import Input from "components/Input";
import ContextualMenu from "components/ContextualMenu";
import Modal from "./Modal";

describe("Modal ", () => {
Expand Down Expand Up @@ -104,15 +105,19 @@ describe("Modal ", () => {
expect(closeButton).toHaveFocus();
});

it("should stop immediate Esc press propagation", async () => {
it("should stop immediate Esc press propagation to other document listeners", async () => {
// A sibling component that registers a raw document keydown listener.
// It should NOT fire when the Modal handles Escape, because the global
// escape stack calls stopImmediatePropagation() before any other
// document listeners run.
const MockEscEventComponent = ({
onEscPress,
}: {
onEscPress: () => void;
}): React.JSX.Element => {
useEffect(() => {
const handleEscPress = (e: KeyboardEvent) => {
if (e.code === "Escape") {
if (e.key === "Escape") {
onEscPress();
}
};
Expand Down Expand Up @@ -223,6 +228,33 @@ describe("Modal ", () => {
expect(input).toHaveValue("delete item1");
});

it("should allow Escape to close an open overlay inside the modal before closing the modal", async () => {
// Regression test for https://github.com/canonical/react-components/issues/1305
//
// Both Modal and ContextualMenu register on the global escape-key stack.
// The menu is opened after the modal, so its handler sits on top of the
// stack and must be called first (LIFO order).
const handleCloseModal = jest.fn();

render(
<Modal title="Test" close={handleCloseModal}>
<ContextualMenu
toggleLabel="Open menu"
links={[{ children: "Item 1" }]}
visible={true}
/>
</Modal>,
);

// First Escape — should dismiss the ContextualMenu, not the Modal
await userEvent.keyboard("{Escape}");
expect(handleCloseModal).not.toHaveBeenCalled();

// Second Escape — menu is gone, so the Modal should now close
await userEvent.keyboard("{Escape}");
expect(handleCloseModal).toHaveBeenCalledTimes(1);
});

it("updates focusable elements when an initially disabled button becomes enabled", async () => {
const user = userEvent.setup();

Expand Down
26 changes: 5 additions & 21 deletions src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import classNames from "classnames";
import React, { useId, useRef, useEffect } from "react";
import type { HTMLProps, ReactNode, RefObject } from "react";
import { ClassName, PropsWithSpread } from "types";
import { useOnEscapePressed } from "hooks";

export type Props = PropsWithSpread<
{
Expand Down Expand Up @@ -88,20 +89,7 @@ export const Modal = ({
}
};

const handleEscKey = (
event: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>,
) => {
if ("nativeEvent" in event && event.nativeEvent.stopImmediatePropagation) {
event.nativeEvent.stopImmediatePropagation();
} else if ("stopImmediatePropagation" in event) {
event.stopImmediatePropagation();
} else if (event.stopPropagation) {
event.stopPropagation();
}
if (close) {
close();
}
};
useOnEscapePressed(() => close?.(), { isEnabled: !!close });

useEffect(() => {
if (focusRef?.current) {
Expand All @@ -114,14 +102,10 @@ export const Modal = ({
}, [focusRef]);

useEffect(() => {
const keyListenersMap = new Map([
["Escape", handleEscKey],
["Tab", handleTabKey],
]);

const keyDown = (event: KeyboardEvent) => {
const listener = keyListenersMap.get(event.code);
return listener && listener(event);
if (event.code === "Tab") {
handleTabKey(event as unknown as React.KeyboardEvent<HTMLDivElement>);
}
};

document.addEventListener("keydown", keyDown, true);
Expand Down
21 changes: 10 additions & 11 deletions src/external/usePortal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
RefObject,
MouseEvent,
} from "react";
import { pushEscapeHandler } from "../hooks/useEscapeStack";
import { createPortal } from "react-dom";
import { useSSR } from "./useSSR";

Expand Down Expand Up @@ -126,14 +127,6 @@ export const usePortal = ({
[closePortal, openPortal],
);

const handleKeydown = useCallback(
(e: KeyboardEvent): void =>
e.key === "Escape" && closeOnEsc
? closePortal(e as unknown as SyntheticEvent<HTMLElement, Event>)
: undefined,
[closeOnEsc, closePortal],
);

const handleOutsideMouseClick = useCallback(
(e: MouseEvent): void => {
const containsTarget = (target: RefObject<HTMLElement>) =>
Expand Down Expand Up @@ -179,21 +172,27 @@ export const usePortal = ({
const node = portal.current;
elToMountTo.appendChild(portal.current);

document.addEventListener("keydown", handleKeydown);
document.addEventListener(
"mousedown",
handleMouseDown as unknown as EventListener,
);

return () => {
document.removeEventListener("keydown", handleKeydown);
document.removeEventListener(
"mousedown",
handleMouseDown as unknown as EventListener,
);
elToMountTo.removeChild(node);
};
}, [isServer, handleOutsideMouseClick, handleKeydown, elToMountTo, portal]);
}, [isServer, handleOutsideMouseClick, elToMountTo, portal]);

// Register on the global escape-key stack only while the portal is open.
// LIFO ordering ensures the most recently opened overlay always handles
// Escape first, regardless of component type or DOM structure.
useEffect(() => {
if (isServer || !closeOnEsc || !isOpen) return undefined;
return pushEscapeHandler(() => closePortal());
}, [isOpen, closeOnEsc, isServer, closePortal]);
Comment thread
koushik717 marked this conversation as resolved.
Outdated

const Portal = useCallback(
({ children }: { children: ReactNode }) => {
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { useOnClickOutside, useClickOutside } from "./useOnClickOutside";
export { pushEscapeHandler, useEscapeStack } from "./useEscapeStack";
export { useId } from "./useId";
export { useListener } from "./useListener";
export { useOnEscapePressed } from "./useOnEscapePressed";
Expand Down
68 changes: 68 additions & 0 deletions src/hooks/useEscapeStack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { pushEscapeHandler } from "./useEscapeStack";

const fireEscape = () =>
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));

afterEach(() => {
// Each test unregisters its own handlers via the returned cleanup functions,
// so nothing to do here — this guard is just for safety.
});

describe("pushEscapeHandler", () => {
it("calls the registered handler when Escape is pressed", () => {
const handler = jest.fn();
const unregister = pushEscapeHandler(handler);
fireEscape();
expect(handler).toHaveBeenCalledTimes(1);
unregister();
});

it("does not call the handler after it is unregistered", () => {
const handler = jest.fn();
const unregister = pushEscapeHandler(handler);
unregister();
fireEscape();
expect(handler).not.toHaveBeenCalled();
});

it("calls only the most recently pushed handler (LIFO order)", () => {
const first = jest.fn();
const second = jest.fn();
const unregisterFirst = pushEscapeHandler(first);
const unregisterSecond = pushEscapeHandler(second);

fireEscape();
expect(second).toHaveBeenCalledTimes(1);
expect(first).not.toHaveBeenCalled();

unregisterSecond();
unregisterFirst();
});

it("falls back to the previous handler once the top handler is removed", () => {
const first = jest.fn();
const second = jest.fn();
const unregisterFirst = pushEscapeHandler(first);
const unregisterSecond = pushEscapeHandler(second);

unregisterSecond();
fireEscape();
expect(first).toHaveBeenCalledTimes(1);
expect(second).not.toHaveBeenCalled();

unregisterFirst();
});

it("stops propagation so other document keydown listeners do not fire", () => {
const outsideListener = jest.fn();
// Register an external listener AFTER the stack listener would be added.
const unregister = pushEscapeHandler(jest.fn());
document.addEventListener("keydown", outsideListener);

fireEscape();
expect(outsideListener).not.toHaveBeenCalled();

document.removeEventListener("keydown", outsideListener);
unregister();
});
});
61 changes: 61 additions & 0 deletions src/hooks/useEscapeStack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* A module-level LIFO stack of Escape-key handlers.
*
* Any overlay component (Modal, ContextualMenu, Drawer, …) can register a
* handler here. Only the most recently registered handler fires when Escape
* is pressed, so nested overlays always dismiss in the correct order
* regardless of their DOM position or portal placement.
*/

import { useEffect } from "react";
Comment thread
koushik717 marked this conversation as resolved.
Outdated

const handlers: Array<() => void> = [];

function onKeyDown(e: KeyboardEvent): void {
if (e.key !== "Escape" || handlers.length === 0) return;
e.stopImmediatePropagation();
handlers[handlers.length - 1]();
}

/**
* Push a callback onto the global escape-key stack.
* Returns a cleanup function that removes the handler (suitable for
* use as a `useEffect` cleanup return).
* Handlers are invoked in LIFO order — the last one pushed runs first,
* mirroring the visual stacking of overlays.
*/
export function pushEscapeHandler(handler: () => void): () => void {
if (handlers.length === 0) {
document.addEventListener("keydown", onKeyDown);
}
handlers.push(handler);
return () => {
const idx = handlers.lastIndexOf(handler);
if (idx !== -1) handlers.splice(idx, 1);
if (handlers.length === 0) {
document.removeEventListener("keydown", onKeyDown);
}
};
}
Comment thread
koushik717 marked this conversation as resolved.
Outdated
Comment thread
koushik717 marked this conversation as resolved.

/**
* React hook that registers an Escape-key handler on the global LIFO stack
* for the lifetime of the component (or while `isActive` is true).
*
* The most recently registered handler always fires first, so nested overlays
* naturally dismiss in the correct order regardless of DOM structure.
*
* @param handler - Callback invoked when Escape is pressed and this handler
* is at the top of the stack.
* @param options.isActive - When `false` the handler is not registered
* (defaults to `true`).
*/
export const useEscapeStack = (
handler: () => void,
{ isActive } = { isActive: true },
Comment thread
koushik717 marked this conversation as resolved.
Outdated
): void => {
useEffect(() => {
if (!isActive) return undefined;
return pushEscapeHandler(handler);
}, [handler, isActive]);
};
Comment thread
koushik717 marked this conversation as resolved.
24 changes: 8 additions & 16 deletions src/hooks/useOnEscapePressed.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
import { useCallback, useEffect } from "react";
import { useEffect } from "react";
import { pushEscapeHandler } from "./useEscapeStack";

/**
* Handle the escape key pressed.
* Registers the callback on the global escape-key stack so that nested
* overlays (modals, dropdowns, drawers, …) always dismiss in the correct
* LIFO order, regardless of DOM position or portal placement.
*/
export const useOnEscapePressed = (
onEscape: () => void,
{ isEnabled } = { isEnabled: true },
) => {
const keyDown = useCallback(
(evt: KeyboardEvent) => {
if (evt.code === "Escape") {
onEscape();
}
},
[onEscape],
);
useEffect(() => {
if (isEnabled) {
document.addEventListener("keydown", keyDown);
}
return () => {
document.removeEventListener("keydown", keyDown);
};
}, [keyDown, isEnabled]);
if (!isEnabled) return undefined;
return pushEscapeHandler(onEscape);
}, [onEscape, isEnabled]);
};
Comment thread
koushik717 marked this conversation as resolved.
Outdated
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ export type {
} from "./components/CustomSelect";

export {
pushEscapeHandler,
useEscapeStack,
useOnClickOutside,
useClickOutside,
useId,
Expand Down
Loading