console page: rail/content navigation regions, storage cards open Files

The rail and the content are nav regions ([data-nav-region]). Up/Down stay
in the focused region and stop at its ends. Left/Right move within the
region while something lies straight that way, else enter the neighbouring
region at its entry: [data-nav-entry] (the rail's active section), else the
control last focused there if it is still shown, else its first control.
Modals keep their own scoping. ✕ on a Home storage card opens Files at
that drive's path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
phantomptrandClaude Opus 5.5 committed 2026-10-17 06:42:46 -07:00
1 parent 81654f8793
commit 97345a6514
6 files changed
+258 -14

No files matched your search

+3 -1
View File
@@ -118,7 +118,9 @@ export function App({ inputMap }: { inputMap: InputMap }) {
<TopBar status={status} chipFallback={chipFallback} />
<div className="cw-body" aria-hidden={expired ? true : undefined}>
<Rail sections={SECTIONS} active={active} onSelect={(id) => show(id)} />
<main className="cw-content">{section.render(ctx)}</main>
<main className="cw-content" data-nav-region="content">
{section.render(ctx)}
</main>
</div>
<HintBar map={inputMap} />
{expired ? <ReloadBanner /> : null}
+24 -1
View File
@@ -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> = {}): 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<typeof VolumeCard>[0]) as ReactElement<Record<string, unknown>>;
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"));
+13 -3
View File
@@ -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 (
<li className="cw-focusable cw-volume" tabIndex={0} data-volume={v.path}>
<li
className="cw-focusable cw-volume"
tabIndex={0}
data-volume={v.path}
onClick={onOpen ? () => onOpen(v.path) : undefined}
>
<div className="cw-volume-head">
<Icon aria-hidden />
<span className="cw-volume-name">{volumeLabel(v)}</span>
@@ -279,7 +287,7 @@ export function HomeView(p: HomeViewProps) {
) : (
<ul className="cw-volumes">
{drives.map((v) => (
<VolumeCard key={v.path} v={v} />
<VolumeCard key={v.path} v={v} onOpen={p.onOpenDrive} />
))}
</ul>
)}
@@ -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 (
<HomeView
@@ -394,6 +403,7 @@ export function Home({ ctx }: { ctx: SectionContext }) {
onConfirm={onConfirm}
onDismiss={onDismiss}
onBrowseUsb={onBrowseUsb}
onOpenDrive={onOpenDrive}
/>
);
}
+143 -2
View File
@@ -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();
+69 -5
View File
@@ -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<Element, HTMLElement>();
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<HTMLElement>(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);
}
+6 -2
View File
@@ -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 (
<nav className="cw-rail" aria-label={t("cw_nav_sections")}>
<nav className="cw-rail" aria-label={t("cw_nav_sections")} data-nav-region="rail">
{sections.map((s) => {
const Icon = s.icon;
return (
@@ -22,6 +25,7 @@ export function Rail({
className="cw-focusable cw-rail-item"
data-rail-id={s.id}
aria-current={s.id === active ? "page" : undefined}
data-nav-entry={s.id === active ? "" : undefined}
onClick={() => onSelect(s.id)}
>
<Icon aria-hidden />