diff --git a/admin/src/schedule/OverrideEditor.test.tsx b/admin/src/schedule/OverrideEditor.test.tsx index 0dc4176..0a2c4bb 100644 --- a/admin/src/schedule/OverrideEditor.test.tsx +++ b/admin/src/schedule/OverrideEditor.test.tsx @@ -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(); + 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); diff --git a/admin/src/schedule/OverrideEditor.tsx b/admin/src/schedule/OverrideEditor.tsx index 44a1cc5..964c6ad 100644 --- a/admin/src/schedule/OverrideEditor.tsx +++ b/admin/src/schedule/OverrideEditor.tsx @@ -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 + * ), 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(null); // Every form change bumps the sequence; only the latest request may render. const requestSeq = useRef(0); + const screenRef = useRef(null); + const titleRef = useRef(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) { + if (event.key === "Escape") { + event.preventDefault(); + if (!saving) onCancel(); + return; + } + if (event.key !== "Tab" || !screenRef.current) return; + const focusable = Array.from(screenRef.current.querySelectorAll(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 ( -
+
-

+

Presence override

Beats the custody pattern for these dates

diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx index f367d63..6bd5eb4 100644 --- a/admin/src/schedule/ScheduleTab.tsx +++ b/admin/src/schedule/ScheduleTab.tsx @@ -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({ kind: "idle" }); const [editorOpen, setEditorOpen] = useState(false); const [deletion, setDeletion] = useState({ kind: "idle" }); + const addRef = useRef(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

Overrides

-