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:
1 parent
27d4ec3be3
commit
bac0d75451
10 files changed
+212
-55
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user