Trap focus in the presence override editor and assert exact warning copy

Move focus to the editor heading on open, keep Tab/Shift+Tab inside,
make the background (tab bar included) inert and aria-hidden while open,
cancel on Escape unless saving, and return focus to Add on close.
Tests assert the full consequence warning text and that a malformed
From date sends no consequence request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 23:11:18 -04:00
co-authored by Claude Opus 5.5
parent c1d9e99f36
commit 8528200e32
3 changed files with 229 additions and 14 deletions
+146 -9
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
import ScheduleTab from "./ScheduleTab";
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
import type { AdminChild } from "../api/definitions";
@@ -93,6 +94,10 @@ function headline(): string {
return screen.getByTestId("override-consequence-headline").textContent ?? "";
}
function warningBody(): string {
return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
}
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
}
@@ -162,10 +167,9 @@ describe("OverrideEditor", () => {
expect(consequenceBodies()).toEqual([
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
]);
const warning = screen.getByTestId("override-consequence");
expect(warning.textContent).toContain("Declan");
expect(warning.textContent).toContain("Sep 23");
expect(warning.textContent).toContain("Completed days are untouched.");
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
);
expect(saveButton().disabled).toBe(false);
consequence = () => jsonResponse({ removed: 9 });
@@ -177,7 +181,9 @@ describe("OverrideEditor", () => {
end_date: "2026-09-27",
is_present: false,
});
expect(warning.textContent).toContain("Sep 23 – 27");
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
);
consequence = () => jsonResponse({ removed: 2 });
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
@@ -188,12 +194,16 @@ describe("OverrideEditor", () => {
end_date: "2026-09-27",
is_present: false,
});
expect(warning.textContent).toContain("Maeve");
expect(warningBody()).toBe(
"Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
);
// A Home override removes nothing by definition: no request, count 0.
fireEvent.click(screen.getByRole("button", { name: "Home" }));
expect(headline()).toBe("This removes 0 upcoming tasks");
expect(warning.textContent).toContain("Completed days are untouched.");
expect(warningBody()).toBe(
"Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(3);
});
@@ -208,8 +218,8 @@ describe("OverrideEditor", () => {
await openEditor();
setDate("Through", "2026-09-25");
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
expect(screen.getByTestId("override-consequence").textContent).toContain(
"Completed days are untouched.",
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
);
});
@@ -261,6 +271,19 @@ describe("OverrideEditor", () => {
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("a malformed From date sends no consequence request and cannot be saved", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("From", "2026-02-30");
expect(headline()).toBe("Choose a valid date range");
expect(saveButton().disabled).toBe(true);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
fireEvent.click(saveButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
await openEditor();
setDate("From", "2026-09-24");
@@ -383,6 +406,120 @@ describe("OverrideEditor", () => {
});
});
describe("OverrideEditor focus", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
overrides = [...INITIAL_OVERRIDES];
consequence = () => jsonResponse({ removed: 4 });
createResponse = (body) => {
const created = { id: 50, note: null, ...body } as PresenceOverride;
overrides = [...overrides, created];
return jsonResponse(created, 201);
};
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function dialog(): HTMLElement {
return screen.getByRole("dialog", { name: "Presence override" });
}
function addButton(): HTMLElement {
return screen.getByRole("button", { name: "Add" });
}
it("moves focus into the editor on open", async () => {
await openEditor();
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" }));
expect(dialog().contains(document.activeElement)).toBe(true);
});
it("keeps Tab and Shift+Tab inside the editor", async () => {
await openEditor();
await waitFor(() => expect(saveButton().disabled).toBe(false));
const back = screen.getByRole("button", { name: "Back" });
saveButton().focus();
fireEvent.keyDown(saveButton(), { key: "Tab" });
expect(document.activeElement).toBe(back);
fireEvent.keyDown(back, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(saveButton());
// From the focused heading (not in the tab order) Shift+Tab wraps to the end.
const title = screen.getByRole("heading", { name: "Presence override" });
title.focus();
fireEvent.keyDown(title, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(saveButton());
});
it("makes the tab bar inert while open and restores it on close", async () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Schedule" }));
await screen.findByTestId("pattern-1");
fireEvent.click(addButton());
await screen.findByRole("heading", { name: "Presence override" });
const tabbar = document.querySelector(".admin-tabbar") as HTMLElement;
expect(tabbar.hasAttribute("inert")).toBe(true);
expect(tabbar.getAttribute("aria-hidden")).toBe("true");
expect(dialog().closest("[inert]")).toBeNull();
fireEvent.keyDown(dialog(), { key: "Escape" });
await screen.findByTestId("pattern-1");
expect(tabbar.hasAttribute("inert")).toBe(false);
expect(tabbar.hasAttribute("aria-hidden")).toBe(false);
});
it("Escape cancels without writing and returns focus to Add", async () => {
await openEditor();
fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" });
await screen.findByTestId("pattern-1");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(addButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Escape does nothing while saving; focus returns to Add after the save", async () => {
const gate = deferred();
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
const path = new URL(url, "http://x").pathname;
if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise;
return routeFetch(url, init);
});
await openEditor();
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy();
fireEvent.keyDown(dialog(), { key: "Escape" });
expect(screen.getByRole("dialog")).toBeTruthy();
gate.resolve(new Response(null));
await screen.findByTestId("override-50");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(addButton());
});
it("Back and Cancel return focus to Add", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await screen.findByTestId("pattern-1");
expect(document.activeElement).toBe(addButton());
fireEvent.click(addButton());
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await screen.findByTestId("pattern-1");
expect(document.activeElement).toBe(addButton());
});
});
describe("range validation", () => {
it("accepts only strict ISO calendar dates in order", () => {
expect(isIsoDate("2026-09-23")).toBe(true);
+67 -3
View File
@@ -6,7 +6,7 @@
* until that count is on screen. Saving only ever sends the create request:
* the API never removes an instance that already has a completion event (D-005).
*/
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, type AdminChild } from "../api/definitions";
import {
@@ -45,6 +45,32 @@ function errorMessage(error: unknown, fallback: string): string {
return fallback;
}
const FOCUSABLE =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
/**
* Make everything outside `root` inert (siblings of each ancestor up to
* <body>), so the tab bar behind the pushed screen cannot take focus.
* Returns the undo.
*/
function inertOutside(root: HTMLElement): () => void {
const made: HTMLElement[] = [];
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
for (const sibling of Array.from(node.parentElement.children)) {
if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue;
sibling.setAttribute("inert", "");
sibling.setAttribute("aria-hidden", "true");
made.push(sibling);
}
}
return () => {
for (const el of made) {
el.removeAttribute("inert");
el.removeAttribute("aria-hidden");
}
};
}
function tasksPhrase(count: number): string {
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
}
@@ -76,6 +102,37 @@ export default function OverrideEditor({
const [error, setError] = useState<string | null>(null);
// Every form change bumps the sequence; only the latest request may render.
const requestSeq = useRef(0);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
// Modal focus: move in on open and keep the background out of reach. The
// opener hands focus back to its Add control once this screen closes.
useEffect(() => {
titleRef.current?.focus();
return screenRef.current ? inertOutside(screenRef.current) : undefined;
}, []);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!saving) onCancel();
return;
}
if (event.key !== "Tab" || !screenRef.current) return;
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
const rangeValid = isValidRange(startDate, endDate);
@@ -155,13 +212,20 @@ export default function OverrideEditor({
}
return (
<div className="override-screen" role="dialog" aria-modal="true" aria-labelledby="override-title">
<div
ref={screenRef}
className="override-screen"
role="dialog"
aria-modal="true"
aria-labelledby="override-title"
onKeyDown={onKeyDown}
>
<header className="override-header">
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="override-title" id="override-title">
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
Presence override
</h2>
<p className="override-sub">Beats the custody pattern for these dates</p>
+16 -2
View File
@@ -4,7 +4,7 @@
* Every day's cycle position comes from the anchor date (./cycle.ts), never
* from ISO week numbers or week parity (D-004).
*/
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import {
@@ -274,6 +274,15 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
const [save, setSave] = useState<SaveState>({ kind: "idle" });
const [editorOpen, setEditorOpen] = useState(false);
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
const addRef = useRef<HTMLButtonElement>(null);
const editorWasOpen = useRef(false);
// The editor replaces this screen, so Add is remounted when it closes;
// hand focus back to it then.
useEffect(() => {
if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
editorWasOpen.current = editorOpen;
}, [editorOpen]);
/** Reload children, schedules and overrides in place (no loading flash). */
const refresh = () =>
@@ -498,7 +507,12 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
<div className="schedule-section-head">
<h3 className="schedule-section-label">Overrides</h3>
<button type="button" className="schedule-text-action" onClick={() => setEditorOpen(true)}>
<button
ref={addRef}
type="button"
className="schedule-text-action"
onClick={() => setEditorOpen(true)}
>
Add
</button>
</div>