Q199: dialogs keep the keyboard inside, close on Escape, and give focus back

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 10:16:13 -04:00
1 parent 27d4ec3be3
commit bac0d75451
10 files changed
+212 -55

No files matched your search

+4 -3
View File
@@ -1,3 +1,4 @@
import { Modal } from "../shared/Modal";
import { StaleBanner } from "./StaleBanner";
import { oneOf, readViewState, seedViewState, writeViewState } from "../shared/view-state";
import { LeaveGuardContext } from "./leave-guard";
@@ -134,8 +135,8 @@ export function App() {
{/* A failed manual retry is already recorded in syncFailure, which the banner shows. */}
{syncFailure && <StaleBanner failure={syncFailure} syncedAt={syncedAt} onRetry={() => void actions.reload().catch(() => undefined)} />}
{leaving && (
<div className="modal-backdrop" role="presentation">
<div className="modal" role="alertdialog" aria-label={t("Leave this page?")}>
<Modal label={t("Leave this page?")} role="alertdialog" onEscape={() => setLeaving(null)}>
<div className="modal">
<h2 className="modal-title">{t("Leave this page?")}</h2>
<p style={{ margin: 0 }}>{t("What you typed here is lost if you leave.")}</p>
<div className="modal-actions">
@@ -143,7 +144,7 @@ export function App() {
<Button tone="primary" onClick={() => { setLeaving(null); leaving.proceed(); }}>{t("Leave")}</Button>
</div>
</div>
</div>
</Modal>
)}
</AvatarDirectoryContext.Provider>
);
+5 -2
View File
@@ -1,3 +1,4 @@
import { createPortal } from "react-dom";
import { Button } from "../shared/ui";
import { t } from "../i18n/i18n";
import { formatTime } from "../pages/types";
@@ -5,7 +6,8 @@ import type { SyncFailure } from "./useSnapshot";
/** Says the data on screen is no longer current: from when it is, why updating failed, and that it keeps retrying. */
export function StaleBanner({ failure, syncedAt, onRetry }: { failure: SyncFailure; syncedAt: string | null; onRetry: () => void }) {
return (
// Outside the app root, so it stays usable while a dialog makes the app inert.
return createPortal(
<div role="alert" className="card" style={{ position: "fixed", top: 8, left: "50%", transform: "translateX(-50%)", zIndex: 900, display: "flex", gap: 10, alignItems: "center", padding: "8px 12px", borderColor: "var(--amber, var(--red))", maxWidth: "min(720px, calc(100vw - 32px))" }}>
<div style={{ fontSize: 13, minWidth: 0 }}>
<strong>{t("Not up to date.")}</strong>{" "}
@@ -13,6 +15,7 @@ export function StaleBanner({ failure, syncedAt, onRetry }: { failure: SyncFailu
<span style={{ color: "var(--text-2)", wordBreak: "break-word" }}>{t("Updating failed: {reason} (attempt {count}).", { reason: failure.message, count: failure.attempts })}</span>
</div>
<Button size="small" onClick={onRetry}>{t("Retry now")}</Button>
</div>
</div>,
document.body,
);
}
@@ -1,3 +1,4 @@
import { Modal } from "../../shared/Modal";
import { approvalKindLabel } from "../../i18n/labels";
import { useState } from "react";
import { Button, IconButton, Pill } from "../../shared/ui";
@@ -37,8 +38,8 @@ export function ApprovalDetailDialog({
const tone = RISK_TONES[approval.risk];
if (!tone) throw new Error("Approval risk tone is missing.");
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal" role="dialog" aria-label={approval.title} onMouseDown={(event) => event.stopPropagation()}>
<Modal label={approval.title} onEscape={onClose} onBackdrop={onClose}>
<div className="modal">
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
<IconButton icon="x" label={t("Close")} onClick={onClose} />
@@ -84,7 +85,7 @@ export function ApprovalDetailDialog({
<Button tone="primary" onClick={() => submit("approved")} disabled={saving}>{t("Approve")}</Button>
</div>
</div>
</div>
</Modal>
);
}
@@ -105,8 +106,8 @@ export function ApprovalRecordDialog({ approval, requester, decider, drift, onCl
if (!approval) return null;
const outcome = approval.state === APPROVAL_STATE.APPROVED ? t("Approved") : approval.state === APPROVAL_STATE.SUPERSEDED ? t("Replaced by an edit") : t("Declined");
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal" role="dialog" aria-label={t("Record of {title}", { title: approval.title })} onMouseDown={(event) => event.stopPropagation()}>
<Modal label={t("Record of {title}", { title: approval.title })} onEscape={onClose} onBackdrop={onClose}>
<div className="modal">
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
<IconButton icon="x" label={t("Close")} onClick={onClose} />
@@ -132,6 +133,6 @@ export function ApprovalRecordDialog({ approval, requester, decider, drift, onCl
)}
</div>
</div>
</div>
</Modal>
);
}
@@ -1,3 +1,5 @@
import { DiscardConfirm } from "../../shared/DiscardConfirm";
import { Modal } from "../../shared/Modal";
import { useUnsaved } from "../../shared/unsaved";
import { useBusySubmit } from "../../shared/useBusySubmit";
import { approvalStateLabel, runTriggerLabel, taskEventLabel } from "../../i18n/labels";
@@ -141,15 +143,9 @@ export function TaskDetailDialog({
const requestClose = () => (editing && editDirty ? setConfirmClose(true) : onClose());
return (
<div className="modal-backdrop" role="presentation" onMouseDown={requestClose}>
<div className="modal" style={{ maxWidth: 560 }} role="dialog" aria-label={task.title} onMouseDown={(e) => e.stopPropagation()}>
{confirmClose && (
<div role="alertdialog" aria-label={t("Discard your changes?")} className="row" style={{ position: "absolute", left: 0, right: 0, top: 0, zIndex: 3, gap: 8, padding: 12, background: "var(--panel)", borderBottom: "1px solid var(--border)", borderRadius: "14px 14px 0 0" }}>
<span style={{ marginRight: "auto", fontSize: 13 }}>{t("Discard your changes?")}</span>
<Button tone="ghost" size="small" onClick={() => setConfirmClose(false)}>{t("Keep editing")}</Button>
<Button tone="primary" size="small" onClick={onClose}>{t("Discard")}</Button>
</div>
)}
<Modal label={task.title} onEscape={requestClose} onBackdrop={requestClose}>
<div className="modal" style={{ maxWidth: 560 }}>
{confirmClose && <DiscardConfirm onKeep={() => setConfirmClose(false)} onDiscard={onClose} />}
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 8 }}>
{task.priority === "high" && <span style={{ width: 3, height: 18, background: "var(--red)", borderRadius: 2, marginTop: 2, flexShrink: 0 }} />}
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{task.title}</h2>
@@ -234,7 +230,7 @@ export function TaskDetailDialog({
/>
)}
</div>
</div>
</Modal>
);
}
+10 -16
View File
@@ -1,3 +1,5 @@
import { DiscardConfirm } from "./DiscardConfirm";
import { Modal } from "./Modal";
import { useState, type FormEvent, type ReactNode } from "react";
import { useUnsaved } from "./unsaved";
import { useBusySubmit } from "./useBusySubmit";
@@ -25,24 +27,16 @@ function DialogBody({ title, children, submitLabel, onClose, onSubmit, hideSubmi
useUnsaved(edited);
const requestClose = () => (edited ? setConfirming(true) : onClose());
return (
<div className="modal-backdrop" role="presentation" onMouseDown={requestClose}>
<form className="modal" aria-busy={busy} onMouseDown={(event) => event.stopPropagation()} onInput={() => setEdited(true)} onChange={() => setEdited(true)} onSubmit={submit}>
<Modal label={title} onEscape={requestClose} onBackdrop={requestClose}>
<form className="modal" aria-busy={busy} onInput={() => setEdited(true)} onChange={() => setEdited(true)} onSubmit={submit}>
<h2 className="modal-title">{title}</h2>
<div style={{ display: "grid", gap: 12 }}>{children}</div>
{confirming ? (
// Keyed rows remount, so a clicked plain button is never reused as the submit button (whose default action would then submit).
<div key="confirm" className="modal-actions" role="alertdialog" aria-label={t("Discard your changes?")}>
<span style={{ marginRight: "auto", fontSize: 13 }}>{t("Discard your changes?")}</span>
<Button tone="ghost" onClick={() => setConfirming(false)}>{t("Keep editing")}</Button>
<Button tone="primary" onClick={onClose}>{t("Discard")}</Button>
</div>
) : (
<div key="actions" className="modal-actions">
<Button tone="ghost" onClick={requestClose}>{hideSubmit ? t("Close") : t("Cancel")}</Button>
{!hideSubmit && <Button tone="primary" type="submit" disabled={busy}>{busy ? t("Working…") : submitLabel}</Button>}
</div>
)}
<div className="modal-actions">
<Button tone="ghost" onClick={requestClose}>{hideSubmit ? t("Close") : t("Cancel")}</Button>
{!hideSubmit && <Button tone="primary" type="submit" disabled={busy}>{busy ? t("Working…") : submitLabel}</Button>}
</div>
</form>
</div>
{confirming && <DiscardConfirm onKeep={() => setConfirming(false)} onDiscard={onClose} />}
</Modal>
);
}
@@ -0,0 +1,18 @@
import { Modal } from "./Modal";
import { Button } from "./ui";
import { t } from "../i18n/i18n";
/** Asks before typed input is thrown away, as its own layer above the dialog; Escape keeps editing. */
export function DiscardConfirm({ onKeep, onDiscard }: { onKeep: () => void; onDiscard: () => void }) {
return (
<Modal label={t("Discard your changes?")} role="alertdialog" onEscape={onKeep} onBackdrop={onKeep}>
<div className="modal" style={{ maxWidth: 360 }}>
<p style={{ margin: "0 0 12px", fontSize: 14 }}>{t("Discard your changes?")}</p>
<div className="modal-actions">
<Button tone="ghost" onClick={onKeep}>{t("Keep editing")}</Button>
<Button tone="primary" onClick={onDiscard}>{t("Discard")}</Button>
</div>
</div>
</Modal>
);
}
+5 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { IconButton } from "./ui";
import { t } from "../i18n/i18n";
@@ -23,7 +24,8 @@ export function FailureToasts() {
};
}, []);
if (failures.length === 0) return null;
return (
// Outside the app root, so a failure can be read and dismissed while a dialog makes the app inert.
return createPortal(
<div style={{ position: "fixed", right: 16, bottom: 16, zIndex: 1000, display: "grid", gap: 8, maxWidth: 420 }}>
{failures.map((failure) => (
<div key={failure.id} role="alert" className="card" style={{ display: "flex", gap: 8, alignItems: "flex-start", borderColor: "var(--red)", padding: 12 }}>
@@ -34,6 +36,7 @@ export function FailureToasts() {
<IconButton icon="x" label={t("Dismiss")} onClick={() => setFailures((current) => current.filter((item) => item.id !== failure.id))} />
</div>
))}
</div>
</div>,
document.body,
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
const FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
/** Open modals, innermost last; everything outside the innermost one is inert. */
const stack: HTMLElement[] = [];
function syncInert(): void {
const root = document.getElementById("root");
const top = stack[stack.length - 1] ?? null;
if (root) root.inert = top !== null;
for (const layer of stack) layer.inert = layer !== top;
}
function focusables(node: HTMLElement): HTMLElement[] {
return [...node.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((element) => element.offsetParent !== null || element === document.activeElement);
}
/** A modal layer above an inert app: focus starts and stays inside, Escape asks it to close, focus returns on close. */
export function Modal({ label, onEscape, onBackdrop, role = "dialog", backdropStyle, children }: {
label: string;
onEscape: () => void;
/** A mouse-down on the backdrop itself, outside the content. */
onBackdrop?: () => void;
role?: "dialog" | "alertdialog";
backdropStyle?: CSSProperties;
children: ReactNode;
}) {
const layerRef = useRef<HTMLDivElement | null>(null);
// Read at the first render, before a field inside takes focus with autoFocus.
const [opener] = useState(() => (document.activeElement instanceof HTMLElement ? document.activeElement : null));
const escapeRef = useRef(onEscape);
useEffect(() => {
escapeRef.current = onEscape;
}, [onEscape]);
useEffect(() => {
const layer = layerRef.current;
if (!layer) return;
stack.push(layer);
syncInert();
if (!layer.contains(document.activeElement)) (focusables(layer)[0] ?? layer).focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
escapeRef.current();
return;
}
if (event.key !== "Tab") return;
const items = focusables(layer);
if (items.length === 0) {
event.preventDefault();
return;
}
const first = items[0]!;
const last = items[items.length - 1]!;
if (event.shiftKey && (document.activeElement === first || !layer.contains(document.activeElement))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
layer.addEventListener("keydown", onKeyDown);
return () => {
layer.removeEventListener("keydown", onKeyDown);
stack.splice(stack.indexOf(layer), 1);
syncInert();
// After every layer closing in this commit has left the stack, so the opener is no longer inert.
queueMicrotask(() => {
if (opener && document.contains(opener)) opener.focus();
});
};
}, [opener]);
return createPortal(
<div
ref={layerRef}
className="modal-backdrop"
role={role}
aria-modal="true"
aria-label={label}
tabIndex={-1}
style={backdropStyle}
onMouseDown={(event) => { if (event.target === event.currentTarget) onBackdrop?.(); }}
>
{children}
</div>,
document.body,
);
}
+3 -16
View File
@@ -1,3 +1,4 @@
import { Modal } from "./Modal";
import { withNextPage, type SearchAnswer } from "./search-pages";
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../../shared/domain";
@@ -87,14 +88,6 @@ export function SearchDialog({
};
}, [employeeId, filter, open, orgId, trimmed]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose, open]);
if (!open) return null;
// The next page joins the shown one only if the words are still the same.
@@ -124,13 +117,7 @@ export function SearchDialog({
};
return (
<div
role="dialog"
aria-modal="true"
aria-label={t("Search")}
style={overlayStyle}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<Modal label={t("Search")} onEscape={onClose} onBackdrop={onClose} backdropStyle={overlayStyle}>
<form onSubmit={submit} style={panelStyle}>
<div className="row" style={{ padding: "10px 12px", borderBottom: "1px solid var(--border-soft)" }}>
<Icon name="search" size={14} color="var(--text-3)" />
@@ -184,7 +171,7 @@ export function SearchDialog({
)}
</div>
</form>
</div>
</Modal>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { _electron as electron, expect, test } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
import { closeApp, onboard } from "./onboard";
test("a dialog keeps the keyboard inside it, closes on Escape, and gives focus back", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-keys-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-keys-builtin-${randomUUID()}`));
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir },
});
try {
const page = await app.firstWindow();
await onboard(page, "Keys Co");
await page.getByRole("button", { name: "Work" }).click();
const opener = page.getByRole("button", { name: "New task" });
await opener.click();
const dialog = page.getByRole("dialog", { name: "New task" });
await expect(dialog).toBeVisible();
const focusInDialog = () => page.evaluate(() => Boolean(document.activeElement?.closest("[aria-modal=true]")));
// Focus starts inside, and the app behind cannot be reached.
expect(await focusInDialog()).toBe(true);
expect(await page.evaluate(() => document.getElementById("root")!.inert)).toBe(true);
for (let press = 0; press < 25; press += 1) {
await page.keyboard.press("Tab");
expect(await focusInDialog()).toBe(true);
}
for (let press = 0; press < 25; press += 1) {
await page.keyboard.press("Shift+Tab");
expect(await focusInDialog()).toBe(true);
}
// Escape closes an untouched dialog and focus returns to what opened it.
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(opener).toBeFocused();
expect(await page.evaluate(() => document.getElementById("root")!.inert)).toBe(false);
// With something typed, Escape asks first.
await opener.click();
await dialog.getByLabel("Title").fill("Draft");
await page.keyboard.press("Escape");
// The question is its own layer: focus moves into it, and Escape there keeps editing.
const confirm = page.getByRole("alertdialog", { name: "Discard your changes?" });
await expect(confirm).toBeVisible();
expect(await page.evaluate(() => Boolean(document.activeElement?.closest("[role=alertdialog]")))).toBe(true);
await page.keyboard.press("Escape");
await expect(confirm).toHaveCount(0);
await expect(dialog.getByLabel("Title")).toBeFocused();
await page.keyboard.press("Escape");
await confirm.getByRole("button", { name: "Discard" }).click();
await expect(dialog).toHaveCount(0);
// Both layers closed at once; focus still returns to what opened the dialog.
await expect(opener).toBeFocused();
} finally {
await closeApp(app);
}
});