mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
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:
1 parent
81654f8793
commit
97345a6514
6 files changed
+254
-10
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,7 +357,10 @@ function move(dir: Direction): void {
|
||||
focusElement(preferred.el);
|
||||
return;
|
||||
}
|
||||
const next = nextFocus(
|
||||
const region = topModal() ? null : regionOf(current.el);
|
||||
const next = region
|
||||
? regionMove(current, region, all, dir)
|
||||
: nextFocus(
|
||||
current.rect,
|
||||
all.filter((c) => c !== current),
|
||||
dir,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in new issue
Block a user