From 872c78cf41ef6409fc65af296ddbf45ec3e253e4 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:57:37 -0400 Subject: [PATCH 1/7] feat: add roving list navigation helpers --- src/lib/rovingList.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/lib/rovingList.ts 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 === " "; +} From c2b3798f03861e6cecc2194d80fb83deecbc0844 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:57:40 -0400 Subject: [PATCH 2/7] test: cover roving list navigation math --- src/lib/rovingList.test.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/lib/rovingList.test.ts 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); + }); +}); From 1be3362159cd55b2d8295be15b7fd82d20a0bacf Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:57:42 -0400 Subject: [PATCH 3/7] test: cover services roving keyboard behavior --- src/app/services/roving-navigation.test.tsx | 162 ++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/app/services/roving-navigation.test.tsx diff --git a/src/app/services/roving-navigation.test.tsx b/src/app/services/roving-navigation.test.tsx new file mode 100644 index 0000000..300e024 --- /dev/null +++ b/src/app/services/roving-navigation.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { apiGet } from "../../lib/apiClient"; +import ServicesPage from "./page"; +import { ToastProvider } from "../../components/ToastProvider"; + +jest.mock("../../lib/apiClient", () => ({ + 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("exposes exactly one primary service link as the list 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 }); + + const links = serviceLinks(); + expect(links.map((link) => link.tabIndex)).toEqual([0, -1, -1]); + expect(links[0].className).toContain("focus-visible:outline"); + }); + + 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]); + }); +}); From 9e417ba71eb8c9adca8252fe638b4874ab0ad878 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:58:19 -0400 Subject: [PATCH 4/7] feat: add roving keyboard navigation to services --- src/app/services/page.tsx | 66 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..382156a 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"; @@ -10,7 +10,7 @@ import { Pagination } from "@/components/Pagination"; import { Spinner } from "@/components/Spinner"; import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; -import { useClipboard } from "@/lib/useClipboard"; +import { useClipboard } from "@/lib/useClipboard";\nimport { getRovingTargetIndex, isRovingActivationKey } from "@/lib/rovingList"; type Service = { serviceId: string; priceStroops: number; createdAt?: number | string | null }; type ServicesResponse = { @@ -188,7 +188,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 +234,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 +331,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" > Date: Thu, 8 Oct 2026 05:59:13 -0400 Subject: [PATCH 5/7] fix: repair roving helper import newline --- src/app/services/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 382156a..e4bab21 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -10,7 +10,8 @@ import { Pagination } from "@/components/Pagination"; import { Spinner } from "@/components/Spinner"; import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; -import { useClipboard } from "@/lib/useClipboard";\nimport { getRovingTargetIndex, isRovingActivationKey } from "@/lib/rovingList"; +import { useClipboard } from "@/lib/useClipboard"; +import { getRovingTargetIndex, isRovingActivationKey } from "@/lib/rovingList"; type Service = { serviceId: string; priceStroops: number; createdAt?: number | string | null }; type ServicesResponse = { From edc32a067dad6b46692a523f7139c2323fd18061 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:03:01 -0400 Subject: [PATCH 6/7] test: exercise tab entry into roving services list --- src/app/services/roving-navigation.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/services/roving-navigation.test.tsx b/src/app/services/roving-navigation.test.tsx index 300e024..3fdb882 100644 --- a/src/app/services/roving-navigation.test.tsx +++ b/src/app/services/roving-navigation.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react";\nimport userEvent from "@testing-library/user-event"; import { apiGet } from "../../lib/apiClient"; import ServicesPage from "./page"; import { ToastProvider } from "../../components/ToastProvider"; @@ -33,7 +33,8 @@ describe("services roving keyboard navigation", () => { window.history.replaceState(null, "", "/services"); }); - it("exposes exactly one primary service link as the list tab stop", async () => { + it("tabs into exactly one primary service link", async () => { + const user = userEvent.setup(); apiGetMock.mockResolvedValueOnce({ services: [ service("svc-a", 10), @@ -50,6 +51,11 @@ describe("services roving keyboard navigation", () => { 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 () => { From b62d3013b061e8845de3da3edec6589103c82912 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:05:36 -0400 Subject: [PATCH 7/7] fix: repair user-event import newline --- src/app/services/roving-navigation.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/services/roving-navigation.test.tsx b/src/app/services/roving-navigation.test.tsx index 3fdb882..842f28a 100644 --- a/src/app/services/roving-navigation.test.tsx +++ b/src/app/services/roving-navigation.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react";\nimport userEvent from "@testing-library/user-event"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { apiGet } from "../../lib/apiClient"; import ServicesPage from "./page"; import { ToastProvider } from "../../components/ToastProvider";