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:
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import App from "../App";
|
||||||
import ScheduleTab from "./ScheduleTab";
|
import ScheduleTab from "./ScheduleTab";
|
||||||
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
|
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
|
||||||
import type { AdminChild } from "../api/definitions";
|
import type { AdminChild } from "../api/definitions";
|
||||||
@@ -93,6 +94,10 @@ function headline(): string {
|
|||||||
return screen.getByTestId("override-consequence-headline").textContent ?? "";
|
return screen.getByTestId("override-consequence-headline").textContent ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function warningBody(): string {
|
||||||
|
return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function saveButton(): HTMLButtonElement {
|
function saveButton(): HTMLButtonElement {
|
||||||
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
|
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
|
||||||
}
|
}
|
||||||
@@ -162,10 +167,9 @@ describe("OverrideEditor", () => {
|
|||||||
expect(consequenceBodies()).toEqual([
|
expect(consequenceBodies()).toEqual([
|
||||||
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
|
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
|
||||||
]);
|
]);
|
||||||
const warning = screen.getByTestId("override-consequence");
|
expect(warningBody()).toBe(
|
||||||
expect(warning.textContent).toContain("Declan");
|
"Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
|
||||||
expect(warning.textContent).toContain("Sep 23");
|
);
|
||||||
expect(warning.textContent).toContain("Completed days are untouched.");
|
|
||||||
expect(saveButton().disabled).toBe(false);
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
|
||||||
consequence = () => jsonResponse({ removed: 9 });
|
consequence = () => jsonResponse({ removed: 9 });
|
||||||
@@ -177,7 +181,9 @@ describe("OverrideEditor", () => {
|
|||||||
end_date: "2026-09-27",
|
end_date: "2026-09-27",
|
||||||
is_present: false,
|
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 });
|
consequence = () => jsonResponse({ removed: 2 });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||||
@@ -188,12 +194,16 @@ describe("OverrideEditor", () => {
|
|||||||
end_date: "2026-09-27",
|
end_date: "2026-09-27",
|
||||||
is_present: false,
|
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.
|
// A Home override removes nothing by definition: no request, count 0.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
||||||
expect(headline()).toBe("This removes 0 upcoming tasks");
|
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();
|
await pastDebounce();
|
||||||
expect(consequenceBodies()).toHaveLength(3);
|
expect(consequenceBodies()).toHaveLength(3);
|
||||||
});
|
});
|
||||||
@@ -208,8 +218,8 @@ describe("OverrideEditor", () => {
|
|||||||
await openEditor();
|
await openEditor();
|
||||||
setDate("Through", "2026-09-25");
|
setDate("Through", "2026-09-25");
|
||||||
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
|
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
|
||||||
expect(screen.getByTestId("override-consequence").textContent).toContain(
|
expect(warningBody()).toBe(
|
||||||
"Completed days are untouched.",
|
"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);
|
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 () => {
|
it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
|
||||||
await openEditor();
|
await openEditor();
|
||||||
setDate("From", "2026-09-24");
|
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", () => {
|
describe("range validation", () => {
|
||||||
it("accepts only strict ISO calendar dates in order", () => {
|
it("accepts only strict ISO calendar dates in order", () => {
|
||||||
expect(isIsoDate("2026-09-23")).toBe(true);
|
expect(isIsoDate("2026-09-23")).toBe(true);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* until that count is on screen. Saving only ever sends the create request:
|
* 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).
|
* 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 { ApiForbiddenError } from "../api/client";
|
||||||
import { ApiRequestError, type AdminChild } from "../api/definitions";
|
import { ApiRequestError, type AdminChild } from "../api/definitions";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +45,32 @@ function errorMessage(error: unknown, fallback: string): string {
|
|||||||
return fallback;
|
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 {
|
function tasksPhrase(count: number): string {
|
||||||
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
|
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
|
||||||
}
|
}
|
||||||
@@ -76,6 +102,37 @@ export default function OverrideEditor({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Every form change bumps the sequence; only the latest request may render.
|
// Every form change bumps the sequence; only the latest request may render.
|
||||||
const requestSeq = useRef(0);
|
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 child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
|
||||||
const rangeValid = isValidRange(startDate, endDate);
|
const rangeValid = isValidRange(startDate, endDate);
|
||||||
@@ -155,13 +212,20 @@ export default function OverrideEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<header className="override-header">
|
||||||
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
|
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
|
||||||
<ChevronLeftGlyph size={20} />
|
<ChevronLeftGlyph size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="override-title" id="override-title">
|
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
|
||||||
Presence override
|
Presence override
|
||||||
</h2>
|
</h2>
|
||||||
<p className="override-sub">Beats the custody pattern for these dates</p>
|
<p className="override-sub">Beats the custody pattern for these dates</p>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Every day's cycle position comes from the anchor date (./cycle.ts), never
|
* Every day's cycle position comes from the anchor date (./cycle.ts), never
|
||||||
* from ISO week numbers or week parity (D-004).
|
* 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 { ApiForbiddenError } from "../api/client";
|
||||||
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
||||||
import {
|
import {
|
||||||
@@ -274,6 +274,15 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
const [save, setSave] = useState<SaveState>({ kind: "idle" });
|
const [save, setSave] = useState<SaveState>({ kind: "idle" });
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
|
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). */
|
/** Reload children, schedules and overrides in place (no loading flash). */
|
||||||
const refresh = () =>
|
const refresh = () =>
|
||||||
@@ -498,7 +507,12 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
|
|
||||||
<div className="schedule-section-head">
|
<div className="schedule-section-head">
|
||||||
<h3 className="schedule-section-label">Overrides</h3>
|
<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
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user