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 { StaleBanner } from "./StaleBanner";
|
||||||
import { oneOf, readViewState, seedViewState, writeViewState } from "../shared/view-state";
|
import { oneOf, readViewState, seedViewState, writeViewState } from "../shared/view-state";
|
||||||
import { LeaveGuardContext } from "./leave-guard";
|
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. */}
|
{/* 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)} />}
|
{syncFailure && <StaleBanner failure={syncFailure} syncedAt={syncedAt} onRetry={() => void actions.reload().catch(() => undefined)} />}
|
||||||
{leaving && (
|
{leaving && (
|
||||||
<div className="modal-backdrop" role="presentation">
|
<Modal label={t("Leave this page?")} role="alertdialog" onEscape={() => setLeaving(null)}>
|
||||||
<div className="modal" role="alertdialog" aria-label={t("Leave this page?")}>
|
<div className="modal">
|
||||||
<h2 className="modal-title">{t("Leave this page?")}</h2>
|
<h2 className="modal-title">{t("Leave this page?")}</h2>
|
||||||
<p style={{ margin: 0 }}>{t("What you typed here is lost if you leave.")}</p>
|
<p style={{ margin: 0 }}>{t("What you typed here is lost if you leave.")}</p>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
@@ -143,7 +144,7 @@ export function App() {
|
|||||||
<Button tone="primary" onClick={() => { setLeaving(null); leaving.proceed(); }}>{t("Leave")}</Button>
|
<Button tone="primary" onClick={() => { setLeaving(null); leaving.proceed(); }}>{t("Leave")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</AvatarDirectoryContext.Provider>
|
</AvatarDirectoryContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "../shared/ui";
|
import { Button } from "../shared/ui";
|
||||||
import { t } from "../i18n/i18n";
|
import { t } from "../i18n/i18n";
|
||||||
import { formatTime } from "../pages/types";
|
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. */
|
/** 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 }) {
|
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 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 }}>
|
<div style={{ fontSize: 13, minWidth: 0 }}>
|
||||||
<strong>{t("Not up to date.")}</strong>{" "}
|
<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>
|
<span style={{ color: "var(--text-2)", wordBreak: "break-word" }}>{t("Updating failed: {reason} (attempt {count}).", { reason: failure.message, count: failure.attempts })}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button size="small" onClick={onRetry}>{t("Retry now")}</Button>
|
<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 { approvalKindLabel } from "../../i18n/labels";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, IconButton, Pill } from "../../shared/ui";
|
import { Button, IconButton, Pill } from "../../shared/ui";
|
||||||
@@ -37,8 +38,8 @@ export function ApprovalDetailDialog({
|
|||||||
const tone = RISK_TONES[approval.risk];
|
const tone = RISK_TONES[approval.risk];
|
||||||
if (!tone) throw new Error("Approval risk tone is missing.");
|
if (!tone) throw new Error("Approval risk tone is missing.");
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
<Modal label={approval.title} onEscape={onClose} onBackdrop={onClose}>
|
||||||
<div className="modal" role="dialog" aria-label={approval.title} onMouseDown={(event) => event.stopPropagation()}>
|
<div className="modal">
|
||||||
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
||||||
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
||||||
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
<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>
|
<Button tone="primary" onClick={() => submit("approved")} disabled={saving}>{t("Approve")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +106,8 @@ export function ApprovalRecordDialog({ approval, requester, decider, drift, onCl
|
|||||||
if (!approval) return null;
|
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");
|
const outcome = approval.state === APPROVAL_STATE.APPROVED ? t("Approved") : approval.state === APPROVAL_STATE.SUPERSEDED ? t("Replaced by an edit") : t("Declined");
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
<Modal label={t("Record of {title}", { title: approval.title })} onEscape={onClose} onBackdrop={onClose}>
|
||||||
<div className="modal" role="dialog" aria-label={t("Record of {title}", { title: approval.title })} onMouseDown={(event) => event.stopPropagation()}>
|
<div className="modal">
|
||||||
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
||||||
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
||||||
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
||||||
@@ -132,6 +133,6 @@ export function ApprovalRecordDialog({ approval, requester, decider, drift, onCl
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DiscardConfirm } from "../../shared/DiscardConfirm";
|
||||||
|
import { Modal } from "../../shared/Modal";
|
||||||
import { useUnsaved } from "../../shared/unsaved";
|
import { useUnsaved } from "../../shared/unsaved";
|
||||||
import { useBusySubmit } from "../../shared/useBusySubmit";
|
import { useBusySubmit } from "../../shared/useBusySubmit";
|
||||||
import { approvalStateLabel, runTriggerLabel, taskEventLabel } from "../../i18n/labels";
|
import { approvalStateLabel, runTriggerLabel, taskEventLabel } from "../../i18n/labels";
|
||||||
@@ -141,15 +143,9 @@ export function TaskDetailDialog({
|
|||||||
const requestClose = () => (editing && editDirty ? setConfirmClose(true) : onClose());
|
const requestClose = () => (editing && editDirty ? setConfirmClose(true) : onClose());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation" onMouseDown={requestClose}>
|
<Modal label={task.title} onEscape={requestClose} onBackdrop={requestClose}>
|
||||||
<div className="modal" style={{ maxWidth: 560 }} role="dialog" aria-label={task.title} onMouseDown={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 560 }}>
|
||||||
{confirmClose && (
|
{confirmClose && <DiscardConfirm onKeep={() => setConfirmClose(false)} onDiscard={onClose} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 8 }}>
|
<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 }} />}
|
{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>
|
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{task.title}</h2>
|
||||||
@@ -234,7 +230,7 @@ export function TaskDetailDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DiscardConfirm } from "./DiscardConfirm";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
import { useUnsaved } from "./unsaved";
|
import { useUnsaved } from "./unsaved";
|
||||||
import { useBusySubmit } from "./useBusySubmit";
|
import { useBusySubmit } from "./useBusySubmit";
|
||||||
@@ -25,24 +27,16 @@ function DialogBody({ title, children, submitLabel, onClose, onSubmit, hideSubmi
|
|||||||
useUnsaved(edited);
|
useUnsaved(edited);
|
||||||
const requestClose = () => (edited ? setConfirming(true) : onClose());
|
const requestClose = () => (edited ? setConfirming(true) : onClose());
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation" onMouseDown={requestClose}>
|
<Modal label={title} onEscape={requestClose} onBackdrop={requestClose}>
|
||||||
<form className="modal" aria-busy={busy} onMouseDown={(event) => event.stopPropagation()} onInput={() => setEdited(true)} onChange={() => setEdited(true)} onSubmit={submit}>
|
<form className="modal" aria-busy={busy} onInput={() => setEdited(true)} onChange={() => setEdited(true)} onSubmit={submit}>
|
||||||
<h2 className="modal-title">{title}</h2>
|
<h2 className="modal-title">{title}</h2>
|
||||||
<div style={{ display: "grid", gap: 12 }}>{children}</div>
|
<div style={{ display: "grid", gap: 12 }}>{children}</div>
|
||||||
{confirming ? (
|
<div className="modal-actions">
|
||||||
// Keyed rows remount, so a clicked plain button is never reused as the submit button (whose default action would then submit).
|
<Button tone="ghost" onClick={requestClose}>{hideSubmit ? t("Close") : t("Cancel")}</Button>
|
||||||
<div key="confirm" className="modal-actions" role="alertdialog" aria-label={t("Discard your changes?")}>
|
{!hideSubmit && <Button tone="primary" type="submit" disabled={busy}>{busy ? t("Working…") : submitLabel}</Button>}
|
||||||
<span style={{ marginRight: "auto", fontSize: 13 }}>{t("Discard your changes?")}</span>
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</form>
|
</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 { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { IconButton } from "./ui";
|
import { IconButton } from "./ui";
|
||||||
import { t } from "../i18n/i18n";
|
import { t } from "../i18n/i18n";
|
||||||
|
|
||||||
@@ -23,7 +24,8 @@ export function FailureToasts() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
if (failures.length === 0) return null;
|
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 }}>
|
<div style={{ position: "fixed", right: 16, bottom: 16, zIndex: 1000, display: "grid", gap: 8, maxWidth: 420 }}>
|
||||||
{failures.map((failure) => (
|
{failures.map((failure) => (
|
||||||
<div key={failure.id} role="alert" className="card" style={{ display: "flex", gap: 8, alignItems: "flex-start", borderColor: "var(--red)", padding: 12 }}>
|
<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))} />
|
<IconButton icon="x" label={t("Dismiss")} onClick={() => setFailures((current) => current.filter((item) => item.id !== failure.id))} />
|
||||||
</div>
|
</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 { withNextPage, type SearchAnswer } from "./search-pages";
|
||||||
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
|
||||||
import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../../shared/domain";
|
import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../../shared/domain";
|
||||||
@@ -87,14 +88,6 @@ export function SearchDialog({
|
|||||||
};
|
};
|
||||||
}, [employeeId, filter, open, orgId, trimmed]);
|
}, [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;
|
if (!open) return null;
|
||||||
// The next page joins the shown one only if the words are still the same.
|
// The next page joins the shown one only if the words are still the same.
|
||||||
@@ -124,13 +117,7 @@ export function SearchDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal label={t("Search")} onEscape={onClose} onBackdrop={onClose} backdropStyle={overlayStyle}>
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("Search")}
|
|
||||||
style={overlayStyle}
|
|
||||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
|
||||||
>
|
|
||||||
<form onSubmit={submit} style={panelStyle}>
|
<form onSubmit={submit} style={panelStyle}>
|
||||||
<div className="row" style={{ padding: "10px 12px", borderBottom: "1px solid var(--border-soft)" }}>
|
<div className="row" style={{ padding: "10px 12px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||||
<Icon name="search" size={14} color="var(--text-3)" />
|
<Icon name="search" size={14} color="var(--text-3)" />
|
||||||
@@ -184,7 +171,7 @@ export function SearchDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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