Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions src/app/services/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = {
Expand Down Expand Up @@ -188,7 +189,23 @@ export default function ServicesPage() {
getInitialSort
);

const serviceLinkRefs = useRef(new Map<string, HTMLAnchorElement>());
const [activeServiceId, setActiveServiceId] = useState<string | null>(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 } =
Expand Down Expand Up @@ -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<HTMLAnchorElement>,
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;

Expand Down Expand Up @@ -286,14 +332,27 @@ export default function ServicesPage() {
/>
)}
{!loading && services && services.length > 0 && (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{sortedServices?.map((s) => (
<ul
aria-label="Services"
className="divide-y divide-zinc-200 dark:divide-zinc-800"
>
{serviceRows.map((s, index) => (
<li
key={s.serviceId}
className="-mx-4 flex items-center justify-between rounded-lg px-4 py-3 transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<Link
ref={(node) => {
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"
>
<span
Expand Down
169 changes: 169 additions & 0 deletions src/app/services/roving-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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";

jest.mock("../../lib/apiClient", () => ({
apiGet: jest.fn(),
}));

const apiGetMock = apiGet as jest.MockedFunction<typeof apiGet>;

function service(serviceId: string, priceStroops: number) {
return { serviceId, priceStroops };
}

function renderPage() {
return render(
<ToastProvider>
<ServicesPage />
</ToastProvider>
);
}

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]);
});
});
34 changes: 34 additions & 0 deletions src/lib/rovingList.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 32 additions & 0 deletions src/lib/rovingList.ts
Original file line number Diff line number Diff line change
@@ -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 === " ";
}
Loading