fix(design): the saved-view chip on one line, one Escape per layer, the /watch action row (E28 S3d)
- SelectTrigger lays its toolbar value out as a row; the saved-view chip passes its glyph and modified dot through the icon/trailing slots (an <svg> is display:block and broke the chip in two). - One Escape in a dialog's open list closed the list AND the dialog: the list's close flushes synchronously and pops its layer before the dialog's window listener runs. takeEscape() is the one close-on-Escape test (topmost AND not yet taken); Modal, the Welcome lightbox and useLayer use it, and keymap.dispatch ignores a taken Escape. - /watch: the Download sits on the toolbar rung, level with the Repeat chip.
This commit is contained in:
@@ -22,7 +22,8 @@ vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||
|
||||
import Modal from "./Modal";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
// Fire a keydown on window (where Modal installs its handler), inside act.
|
||||
const press = (key: string, shiftKey = false) =>
|
||||
@@ -364,3 +365,43 @@ describe("Modal — toasts raised while it is open", () => {
|
||||
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
|
||||
});
|
||||
});
|
||||
|
||||
// E28: one Escape in a dialog's open list closed the list AND the dialog (the share dialog's user
|
||||
// picker). The list's `document` listener closes it with a synchronous flush, which pops its layer
|
||||
// before the dialog's `window` listener runs — so "am I topmost?" reads yes. The dialog must honour
|
||||
// the list's stamp, as the players already do (UX-8).
|
||||
describe("Modal — an Escape a layer above it already took", () => {
|
||||
// Opened by a click after the dialog is up, as a user opens it: a child's effect runs before its
|
||||
// parent's, so a list open from the first render would sit BELOW the dialog on the stack.
|
||||
function List({ onClosed }: { onClosed: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useLayer(open, () => {
|
||||
flushSync(() => setOpen(false));
|
||||
onClosed();
|
||||
});
|
||||
return open ? (
|
||||
<div data-testid="list" />
|
||||
) : (
|
||||
<button onClick={() => setOpen(true)}>open list</button>
|
||||
);
|
||||
}
|
||||
|
||||
it("closes the list only; the next Escape closes the dialog", () => {
|
||||
const onClose = vi.fn();
|
||||
const listClosed = vi.fn();
|
||||
render(
|
||||
<Modal title="Share" onClose={onClose}>
|
||||
<List onClosed={listClosed} />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => screen.getByRole("button", { name: "open list" }).click());
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
expect(listClosed).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId("list")).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import { anyBlockingLayerAbove, pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
||||
import { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../../lib/layerStack";
|
||||
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
import { trapTab, useDialogChrome } from "./dialogFocus";
|
||||
import { IconButton } from "./IconButton";
|
||||
@@ -63,7 +63,7 @@ export default function Modal({
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
if (takeEscape(e, id)) {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
|
||||
@@ -39,7 +39,17 @@ export const SelectTrigger = forwardRef<
|
||||
>
|
||||
{icon}
|
||||
{label != null && <span className="text-muted">{label}:</span>}
|
||||
<span className={clsx("text-fg", context === "form" && "flex-1 truncate")}>{children}</span>
|
||||
{/* The toolbar value is a row, whatever the caller puts in it: an <svg> is display:block under the
|
||||
preflight, and in a plain span it broke the value onto its own line (the saved-view chip,
|
||||
E28). The form value is text that truncates, which a flex box would not do. */}
|
||||
<span
|
||||
className={clsx(
|
||||
"text-fg",
|
||||
context === "form" ? "flex-1 truncate" : "flex min-w-0 items-center gap-1.5",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{trailing}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
|
||||
Reference in New Issue
Block a user