diff --git a/client/src/console/App.tsx b/client/src/console/App.tsx index 3b177c15..b34e59e1 100644 --- a/client/src/console/App.tsx +++ b/client/src/console/App.tsx @@ -118,7 +118,9 @@ export function App({ inputMap }: { inputMap: InputMap }) {
show(id)} /> -
{section.render(ctx)}
+
+ {section.render(ctx)} +
{expired ? : null} diff --git a/client/src/console/home/Home.test.tsx b/client/src/console/home/Home.test.tsx index 52d5b9dc..769a5333 100644 --- a/client/src/console/home/Home.test.tsx +++ b/client/src/console/home/Home.test.tsx @@ -10,7 +10,16 @@ vi.mock("../api", async (importOriginal) => { import { ApiError, call } from "../api"; import { t } from "../strings"; import { ConfirmDialog, dialogNavHandler } from "../ui/ConfirmDialog"; -import { errorText, firstUsbVolume, HomeView, runAsk, type Ask, type HomeViewProps, type Volume } from "./Home"; +import { + errorText, + firstUsbVolume, + HomeView, + runAsk, + VolumeCard, + type Ask, + type HomeViewProps, + type Volume, +} from "./Home"; import { TaskRow } from "./TaskRow"; import { cancelJob, type Job } from "./useJobs"; @@ -73,6 +82,7 @@ function viewProps(over: Partial = {}): HomeViewProps { onConfirm: vi.fn(), onDismiss: vi.fn(), onBrowseUsb: null, + onOpenDrive: null, ...over, }; } @@ -193,6 +203,19 @@ describe("Home", () => { expect(html.split("data-volume=").length - 1).toBe(2); }); + it("F3: ✕ on a storage card opens Files at that drive's path", () => { + const open = vi.fn(); + const cards = elements( + HomeView(viewProps({ volumes: [vol("/data", "x"), vol("/mnt/usb0", "/dev/da0s1")], onOpenDrive: open })), + ).filter((e) => e.type === VolumeCard); + expect(cards).toHaveLength(2); + // ✕ clicks the focused card (nav.ts confirm): the card's li carries the onClick. + const li = VolumeCard(cards[1].props as Parameters[0]) as ReactElement>; + expect(li.props["data-volume"]).toBe("/mnt/usb0"); + (li.props.onClick as () => void)(); + expect(open).toHaveBeenCalledWith("/mnt/usb0"); + }); + it("errors become short translated messages", () => { expect(errorText(new ApiError(0, "offline"))).toBe(t("cw_home_err_offline")); expect(errorText(new ApiError(409, "busy"))).toBe(t("cw_home_err_busy")); diff --git a/client/src/console/home/Home.tsx b/client/src/console/home/Home.tsx index 423f8dc5..c4149560 100644 --- a/client/src/console/home/Home.tsx +++ b/client/src/console/home/Home.tsx @@ -150,13 +150,21 @@ export interface HomeViewProps { onDismiss: () => void; /** Browse USB, or null to hide it (no USB drive, or Files cannot open a path yet). */ onBrowseUsb: (() => void) | null; + /** ✕ on a storage card: Files at that drive's path, or null when Files cannot open a path. */ + onOpenDrive: ((path: string) => void) | null; } -function VolumeCard({ v }: { v: Volume }) { +/** One drive. Focusable; ✕ (a click) opens Files there when `onOpen` is given. */ +export function VolumeCard({ v, onOpen }: { v: Volume; onOpen: ((path: string) => void) | null }) { const used = v.total_bytes > 0 ? Math.max(0, Math.min(100, 100 - (v.free_bytes / v.total_bytes) * 100)) : 0; const Icon = isUsbStick(v) || isUsbExt(v) ? Usb : HardDrive; return ( -
  • +
  • onOpen(v.path) : undefined} + >
    {volumeLabel(v)} @@ -279,7 +287,7 @@ export function HomeView(p: HomeViewProps) { ) : (
      {drives.map((v) => ( - + ))}
    )} @@ -375,6 +383,7 @@ export function Home({ ctx }: { ctx: SectionContext }) { const usb = volumes ? firstUsbVolume(volumes) : null; const onBrowseUsb = usb && canOpen("files") ? () => openSection("files", { path: usb.path }) : null; + const onOpenDrive = canOpen("files") ? (path: string) => openSection("files", { path }) : null; return ( ); } diff --git a/client/src/console/nav.dom.test.ts b/client/src/console/nav.dom.test.ts index 4d15a36b..09d70431 100644 --- a/client/src/console/nav.dom.test.ts +++ b/client/src/console/nav.dom.test.ts @@ -3,6 +3,7 @@ import { createEchoGuard, DEFAULT_INPUT_MAP, dispatchNav, + focusElement, installNavigation, onNavAction, } from "./nav"; @@ -43,14 +44,20 @@ class FakeEl { } querySelectorAll(sel: string): FakeEl[] { if (sel === "[data-nav-modal]") return this.descendants().filter((e) => "data-nav-modal" in e.attrs); + if (sel === "[data-nav-region]") return this.descendants().filter((e) => "data-nav-region" in e.attrs); return this.descendants().filter((e) => e.tagName === "BUTTON"); // the FOCUSABLE selector } - closest(): null { - return null; // nothing is inert or aria-hidden in these fixtures + closest(sel: string): FakeEl | null { + if (sel !== "[data-nav-region]") return null; // nothing is inert or aria-hidden in these fixtures + if ("data-nav-region" in this.attrs) return this; + return this.parent ? this.parent.closest(sel) : null; } hasAttribute(a: string): boolean { return a in this.attrs; } + getAttribute(a: string): string | null { + return a in this.attrs ? this.attrs[a] : null; + } getBoundingClientRect(): DOMRect { const { left, top, width, height } = this.box; return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top } as DOMRect; @@ -191,6 +198,140 @@ describe("modal scope", () => { }); }); +/* + * The page's two regions, like App: the rail (left) and the content (right). + * Rail items stack down the left; the content's controls sit to the right. + */ +function regions() { + const rail = new FakeEl("rail", { "data-nav-region": "rail" }, { left: 0, top: 60, width: 120, height: 600 }); + const content = new FakeEl("content", { "data-nav-region": "content" }, { left: 130, top: 60, width: 900, height: 600 }); + const home = btn("home", 0, 100); + const files = new FakeEl("files", { "data-nav-entry": "" }, { left: 0, top: 160, width: 100, height: 40 }, "BUTTON"); + const settings = btn("settings", 0, 520); + rail.add(home, files, settings); + body.add(rail, content); + return { rail, content, home, files, settings }; +} + +describe("rail and content regions", () => { + it("F1: Left from the content enters the rail on the active section's item, not the nearest", () => { + const r = regions(); + const low = btn("low", 140, 520); // level with Settings + r.content.add(btn("top", 140, 100), low); + low.focus(); + dispatchNav("left"); + expect(fakeDoc.activeElement).toBe(r.files); + }); + + it("F1: Left inside the content still moves within the content when something is that way", () => { + const r = regions(); + const a = btn("a", 140, 300); + const b = btn("b", 260, 300); + r.content.add(a, b); + b.focus(); + dispatchNav("left"); + expect(fakeDoc.activeElement).toBe(a); + }); + + it("F1: a wide card's Left goes to the rail, not up to a narrower control above it", () => { + const r = regions(); + const quick = btn("quick", 140, 100); + const card = new FakeEl("card", {}, { left: 140, top: 300, width: 400, height: 120 }, "BUTTON"); + r.content.add(quick, card); + card.focus(); + dispatchNav("left"); + expect(fakeDoc.activeElement).toBe(r.files); + }); + + it("F2: Down on the last rail item stays put, never jumps into the content", () => { + const r = regions(); + r.content.add(btn("card", 140, 600)); + r.settings.focus(); + dispatchNav("down"); + expect(fakeDoc.activeElement).toBe(r.settings); + dispatchNav("up"); + expect(fakeDoc.activeElement).toBe(r.files); + }); + + it("F2: Down at the end of the content stays put, never jumps to a rail item", () => { + const r = regions(); + const first = btn("first", 140, 100); + const last = btn("last", 140, 300); + r.content.add(first, last); + last.focus(); + dispatchNav("down"); // Settings (rail, y=520) is below + expect(fakeDoc.activeElement).toBe(last); + dispatchNav("up"); + expect(fakeDoc.activeElement).toBe(first); + dispatchNav("up"); // Home (rail, y=100) is level; nothing above in the content + expect(fakeDoc.activeElement).toBe(first); + }); + + it("F2: Right from the rail enters the content on its first control, not the nearest", () => { + const r = regions(); + const first = btn("first", 140, 100); + const nearSettings = btn("near", 140, 520); + r.content.add(first, nearSettings); + r.settings.focus(); + dispatchNav("right"); + expect(fakeDoc.activeElement).toBe(first); + }); + + it("F2: Right from the rail returns to the control last focused in the content", () => { + const r = regions(); + const first = btn("first", 140, 100); + const second = btn("second", 140, 300); + r.content.add(first, second); + second.focus(); + dispatchNav("left"); + expect(fakeDoc.activeElement).toBe(r.files); + dispatchNav("down"); + expect(fakeDoc.activeElement).toBe(r.settings); + dispatchNav("right"); + expect(fakeDoc.activeElement).toBe(second); + }); + + it("F2: the remembered control is gone (section changed) → the content's first control", () => { + const r = regions(); + const first = btn("first", 140, 100); + const second = btn("second", 140, 300); + r.content.add(first, second); + second.focus(); + dispatchNav("left"); + second.remove(); + dispatchNav("right"); + expect(fakeDoc.activeElement).toBe(first); + }); + + it("○ to the rail (App's handler) also remembers where the content was", () => { + const r = regions(); + const first = btn("first", 140, 100); + const second = btn("second", 140, 300); + r.content.add(first, second); + second.focus(); + offs.push( + onNavAction((a) => { + if (a !== "back") return false; + focusElement(r.files as unknown as HTMLElement); + return true; + }), + ); + dispatchNav("back"); + expect(fakeDoc.activeElement).toBe(r.files); + dispatchNav("right"); + expect(fakeDoc.activeElement).toBe(second); + }); + + it("a modal inside the content keeps the D-pad in the modal, regions aside", () => { + const r = regions(); + const ok = btn("ok", 400, 300); + r.content.add(btn("behind", 140, 300), new FakeEl("dialog", { "data-nav-modal": "" }).add(ok)); + ok.focus(); + dispatchNav("left"); + expect(fakeDoc.activeElement).toBe(ok); + }); +}); + describe("R12 gamepad/keyboard echo", () => { it("drops an Arrow keydown within 100 ms after the gamepad moved the same way", () => { const g = createEchoGuard(); diff --git a/client/src/console/nav.ts b/client/src/console/nav.ts index 74f13f02..3fa57d88 100644 --- a/client/src/console/nav.ts +++ b/client/src/console/nav.ts @@ -280,12 +280,73 @@ function focusables(): FocusCandidate[] { return out; } +/* ---- regions ----------------------------------------------------------- */ + +/* + * The page is split into regions, [data-nav-region] (App: the rail and the + * content). Up/Down never leave the focused element's region. Left/Right move + * inside it while something lies straight that way; otherwise they enter the + * neighbouring region that way, at its entry: its [data-nav-entry] (the rail's + * active section), else the element last focused there if it is still shown, + * else its first control. Open modals ignore regions (focus stays in the modal). + */ + +const REGION = "[data-nav-region]"; + +/** Region element → the element last focused inside it. */ +const lastInRegion = new WeakMap(); + +function regionOf(el: Element | null): Element | null { + return el && typeof el.closest === "function" ? el.closest(REGION) : null; +} + +function remember(el: Element | null): void { + const region = regionOf(el); + if (region) lastInRegion.set(region, el as HTMLElement); +} + +/** Where focus lands when it enters `region` from another one. */ +function regionEntry(region: Element, all: FocusCandidate[]): HTMLElement | null { + const inside = all.filter((c) => region.contains(c.el)); + if (!inside.length) return null; + const marked = inside.find((c) => c.el.hasAttribute("data-nav-entry")); + if (marked) return marked.el; + const last = lastInRegion.get(region); + const kept = last ? inside.find((c) => c.el === last) : undefined; + return (kept ?? inside[0]).el; +} + /** Focus `el` and keep it on screen. */ export function focusElement(el: HTMLElement): void { + if (typeof document !== "undefined") remember(document.activeElement); el.focus(); + remember(el); if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "nearest", inline: "nearest" }); } +function inBeam(from: DOMRect, r: DOMRect, dir: Direction): boolean { + return crossAxis(from, r, dir).overlap > 0; +} + +/** The target of `dir` from `current` when it sits in `region` (no modal open). */ +function regionMove(current: FocusCandidate, region: Element, all: FocusCandidate[], dir: Direction): HTMLElement | null { + const others = all.filter((c) => c !== current); + const same = others.filter((c) => region.contains(c.el)); + if (dir === "up" || dir === "down") return nextFocus(current.rect, same, dir); + const aligned = same.filter((c) => isBeyond(current.rect, c.rect, dir) && inBeam(current.rect, c.rect, dir)); + if (aligned.length) return nextFocus(current.rect, aligned, dir); + // Nothing straight that way here: the neighbouring region that way, if any. + const regions: FocusCandidate[] = []; + document.querySelectorAll(REGION).forEach((r) => { + if (r === region || r.contains(region) || region.contains(r)) return; + if (!all.some((c) => r.contains(c.el))) return; + regions.push({ el: r, rect: r.getBoundingClientRect() }); + }); + const neighbour = nextFocus((region as HTMLElement).getBoundingClientRect(), regions, dir); + if (neighbour) return regionEntry(neighbour, all); + return nextFocus(current.rect, same, dir); +} + function move(dir: Direction): void { const all = focusables(); if (!all.length) return; @@ -296,11 +357,14 @@ function move(dir: Direction): void { focusElement(preferred.el); return; } - const next = nextFocus( - current.rect, - all.filter((c) => c !== current), - dir, - ); + const region = topModal() ? null : regionOf(current.el); + const next = region + ? regionMove(current, region, all, dir) + : nextFocus( + current.rect, + all.filter((c) => c !== current), + dir, + ); if (next) focusElement(next); } diff --git a/client/src/console/shell/Rail.tsx b/client/src/console/shell/Rail.tsx index 7286f4ca..2f744eaf 100644 --- a/client/src/console/shell/Rail.tsx +++ b/client/src/console/shell/Rail.tsx @@ -1,7 +1,10 @@ import type { Section } from "../sections"; import { t } from "../strings"; -/** The left icon rail. Real buttons: D-pad left from the content reaches it, ✕ opens a section. */ +/** + * The left icon rail, a nav region: D-pad left from the content enters it on + * the active section ([data-nav-entry]), ✕ opens a section. + */ export function Rail({ sections, active, @@ -12,7 +15,7 @@ export function Rail({ onSelect: (id: string) => void; }) { return ( -