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:
2026-09-25 01:40:45 +02:00
parent 1833f594cb
commit b4a6569d0d
12 changed files with 144 additions and 26 deletions
+42 -1
View File
@@ -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);
});
});