diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..e4bab21 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { apiGet } from "@/lib/apiClient"; import { ErrorMessage } from "@/components/ErrorMessage"; @@ -11,6 +11,7 @@ import { Spinner } from "@/components/Spinner"; import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; import { useClipboard } from "@/lib/useClipboard"; +import { getRovingTargetIndex, isRovingActivationKey } from "@/lib/rovingList"; type Service = { serviceId: string; priceStroops: number; createdAt?: number | string | null }; type ServicesResponse = { @@ -188,7 +189,23 @@ export default function ServicesPage() { getInitialSort ); + const serviceLinkRefs = useRef(new Map()); + const [activeServiceId, setActiveServiceId] = useState(null); const sortedServices = useSorted(services, sortKey, sortDir); + const serviceRows = sortedServices ?? []; + const resolvedActiveServiceId = + activeServiceId && + serviceRows.some((service) => service.serviceId === activeServiceId) + ? activeServiceId + : serviceRows[0]?.serviceId ?? null; + + // Keep one primary row link in the tab order, even when paging or sorting + // replaces/reorders the rendered list. + useEffect(() => { + if (activeServiceId !== resolvedActiveServiceId) { + setActiveServiceId(resolvedActiveServiceId); + } + }, [activeServiceId, resolvedActiveServiceId]); const handleSort = (key: SortKey) => { const next: { key: SortKey; dir: SortDir } = @@ -218,6 +235,35 @@ export default function ServicesPage() { setRequestedPage(nextPage); }; + const focusServiceAt = (index: number) => { + const serviceId = serviceRows[index]?.serviceId; + if (!serviceId) return; + + setActiveServiceId(serviceId); + serviceLinkRefs.current.get(serviceId)?.focus(); + }; + + const handleServiceKeyDown = ( + event: React.KeyboardEvent, + index: number + ) => { + if (isRovingActivationKey(event.key)) { + event.preventDefault(); + event.currentTarget.click(); + return; + } + + const nextIndex = getRovingTargetIndex( + index, + serviceRows.length, + event.key + ); + if (nextIndex === null) return; + + event.preventDefault(); + focusServiceAt(nextIndex); + }; + useEffect(() => { let cancelled = false; @@ -286,14 +332,27 @@ export default function ServicesPage() { /> )} {!loading && services && services.length > 0 && ( -
    - {sortedServices?.map((s) => ( +
      + {serviceRows.map((s, index) => (
    • { + if (node) { + serviceLinkRefs.current.set(s.serviceId, node); + } else { + serviceLinkRefs.current.delete(s.serviceId); + } + }} href={`/services/${encodeURIComponent(s.serviceId)}`} + tabIndex={s.serviceId === resolvedActiveServiceId ? 0 : -1} + onFocus={() => setActiveServiceId(s.serviceId)} + onKeyDown={(event) => handleServiceKeyDown(event, index)} className="flex flex-1 items-center justify-between rounded-lg hover:bg-zinc-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-zinc-900" > ({ + apiGet: jest.fn(), +})); + +const apiGetMock = apiGet as jest.MockedFunction; + +function service(serviceId: string, priceStroops: number) { + return { serviceId, priceStroops }; +} + +function renderPage() { + return render( + + + + ); +} + +function serviceLinks() { + return screen + .getAllByRole("link") + .filter((link) => link.getAttribute("href") !== "/services/new"); +} + +describe("services roving keyboard navigation", () => { + beforeEach(() => { + apiGetMock.mockReset(); + window.history.replaceState(null, "", "/services"); + }); + + it("tabs into exactly one primary service link", async () => { + const user = userEvent.setup(); + apiGetMock.mockResolvedValueOnce({ + services: [ + service("svc-a", 10), + service("svc-b", 20), + service("svc-c", 30), + ], + page: 1, + pageCount: 1, + } as never); + + renderPage(); + await screen.findByRole("link", { name: /svc-a/i }); + + const links = serviceLinks(); + expect(links.map((link) => link.tabIndex)).toEqual([0, -1, -1]); + expect(links[0].className).toContain("focus-visible:outline"); + + await user.tab(); + expect(screen.getByRole("link", { name: /new service/i })).toHaveFocus(); + await user.tab(); + expect(links[0]).toHaveFocus(); + }); + + it("moves focus with ArrowDown and ArrowUp while keeping one tab stop", async () => { + apiGetMock.mockResolvedValueOnce({ + services: [ + service("svc-a", 10), + service("svc-b", 20), + service("svc-c", 30), + ], + page: 1, + pageCount: 1, + } as never); + + renderPage(); + await screen.findByRole("link", { name: /svc-a/i }); + + let links = serviceLinks(); + links[0].focus(); + fireEvent.keyDown(links[0], { key: "ArrowDown" }); + + links = serviceLinks(); + expect(document.activeElement).toBe(links[1]); + expect(links.map((link) => link.tabIndex)).toEqual([-1, 0, -1]); + + fireEvent.keyDown(links[1], { key: "ArrowUp" }); + links = serviceLinks(); + expect(document.activeElement).toBe(links[0]); + expect(links.map((link) => link.tabIndex)).toEqual([0, -1, -1]); + }); + + it("uses Home and End to jump to the list boundaries", async () => { + apiGetMock.mockResolvedValueOnce({ + services: [ + service("svc-a", 10), + service("svc-b", 20), + service("svc-c", 30), + ], + page: 1, + pageCount: 1, + } as never); + + renderPage(); + await screen.findByRole("link", { name: /svc-a/i }); + + let links = serviceLinks(); + links[0].focus(); + fireEvent.keyDown(links[0], { key: "End" }); + links = serviceLinks(); + expect(document.activeElement).toBe(links[2]); + + fireEvent.keyDown(links[2], { key: "Home" }); + links = serviceLinks(); + expect(document.activeElement).toBe(links[0]); + }); + + it("activates the focused item with Enter or Space", async () => { + apiGetMock.mockResolvedValueOnce({ + services: [service("svc-a", 10)], + page: 1, + pageCount: 1, + } as never); + + renderPage(); + const link = await screen.findByRole("link", { name: /svc-a/i }); + const click = jest.fn(); + Object.defineProperty(link, "click", { + configurable: true, + value: click, + }); + + fireEvent.keyDown(link, { key: "Enter" }); + fireEvent.keyDown(link, { key: " " }); + + expect(click).toHaveBeenCalledTimes(2); + }); + + it("moves the tab stop to the first new item when the list is replaced", async () => { + apiGetMock + .mockResolvedValueOnce({ + services: [service("svc-a", 10), service("svc-b", 20)], + page: 1, + pageCount: 2, + } as never) + .mockResolvedValueOnce({ + services: [service("svc-c", 30), service("svc-d", 40)], + page: 2, + pageCount: 2, + } as never); + + renderPage(); + await screen.findByRole("link", { name: /svc-a/i }); + + let links = serviceLinks(); + links[0].focus(); + fireEvent.keyDown(links[0], { key: "ArrowDown" }); + expect(document.activeElement).toBe(serviceLinks()[1]); + + fireEvent.click(screen.getByRole("button", { name: /next/i })); + + await waitFor(() => { + expect(apiGetMock).toHaveBeenLastCalledWith( + "/api/v1/services?page=2&limit=25" + ); + }); + await screen.findByRole("link", { name: /svc-c/i }); + + links = serviceLinks(); + expect(links.map((link) => link.tabIndex)).toEqual([0, -1]); + }); +}); diff --git a/src/lib/rovingList.test.ts b/src/lib/rovingList.test.ts new file mode 100644 index 0000000..0caab1a --- /dev/null +++ b/src/lib/rovingList.test.ts @@ -0,0 +1,34 @@ +import { + getRovingTargetIndex, + isRovingActivationKey, +} from "./rovingList"; + +describe("getRovingTargetIndex", () => { + it("moves down and wraps from the last item", () => { + expect(getRovingTargetIndex(0, 3, "ArrowDown")).toBe(1); + expect(getRovingTargetIndex(2, 3, "ArrowDown")).toBe(0); + }); + + it("moves up and wraps from the first item", () => { + expect(getRovingTargetIndex(2, 3, "ArrowUp")).toBe(1); + expect(getRovingTargetIndex(0, 3, "ArrowUp")).toBe(2); + }); + + it("jumps to the first and last items", () => { + expect(getRovingTargetIndex(1, 4, "Home")).toBe(0); + expect(getRovingTargetIndex(1, 4, "End")).toBe(3); + }); + + it("ignores unrelated keys and empty lists", () => { + expect(getRovingTargetIndex(1, 3, "Tab")).toBeNull(); + expect(getRovingTargetIndex(0, 0, "ArrowDown")).toBeNull(); + }); +}); + +describe("isRovingActivationKey", () => { + it("accepts Enter and Space only", () => { + expect(isRovingActivationKey("Enter")).toBe(true); + expect(isRovingActivationKey(" ")).toBe(true); + expect(isRovingActivationKey("Spacebar")).toBe(false); + }); +}); diff --git a/src/lib/rovingList.ts b/src/lib/rovingList.ts new file mode 100644 index 0000000..2fe33fb --- /dev/null +++ b/src/lib/rovingList.ts @@ -0,0 +1,32 @@ +export type RovingNavigationKey = + | "ArrowDown" + | "ArrowUp" + | "Home" + | "End"; + +export function getRovingTargetIndex( + currentIndex: number, + itemCount: number, + key: string +): number | null { + if (itemCount <= 0) return null; + + const current = Math.min(Math.max(currentIndex, 0), itemCount - 1); + + switch (key) { + case "ArrowDown": + return (current + 1) % itemCount; + case "ArrowUp": + return (current - 1 + itemCount) % itemCount; + case "Home": + return 0; + case "End": + return itemCount - 1; + default: + return null; + } +} + +export function isRovingActivationKey(key: string): boolean { + return key === "Enter" || key === " "; +}