feat(keys): the conversation list walks with j/k; the cheat sheet names keys in the reader's language
E11 S5 — the HU/EN sweep and the keyboard-only pass over every module.
- The keyboard cheat sheet printed "Space" and "Menu" on a Hungarian sheet: a keycap that carries a
WORD (not a legend every layout prints alike, like Esc, Ctrl or Enter) is now named by the caller in
the reader's language (`formatChord(chord, words)`, `keys.cap.*`: Szóköz / Menü).
- The Plex player's ⌫/Esc line said "Stop & back to feed", but the player goes back to wherever it
was opened from (Home, a Plex page): "Stop & go back" / "Leállítás és vissza".
- Messages: the conversation list is a keyboard list (NavGrid + navItemAttrs, the name button is the
open element; the pop-out button keeps its own Enter). D1 covers every list whose rows open one
thing; this one was missed in S4. Notification cards have no single open action and stay out, like
the download queue.
Sweep, measured: every static `t("…")` key used in the source exists in both languages (the only
misses are template prefixes); the HU values equal to EN are proper nouns and loanwords (Admin, Plex,
Trakt, Shorts). Palette in Hungarian: "téma" makes "Téma: Midnight" the active row ahead of the channel
"Topik - mindig témában", live region "7 találat".
Keyboard-only pass in Chrome, Hungarian UI — 1024×768: every `g` chord lands on its module, `j` rings
the first item (solid 2px accent) on Home, Feed, Channels, Playlists, Plex and Downloads, `s` focuses
the search on Feed, Channels, Playlists and Plex, no page scrolls sideways. 3440×1440: `j` → → ↓
walks Home, Feed (8 columns: 0 → 1 → 9), Channels, Playlists, Plex and Downloads, no sideways scroll.
The help sheet lists every line translated; inside the Plex player "Szóköz / K" and "⌫ / Esc
Leállítás és vissza". Messages: a real `j` rings the row, Enter opens the thread.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The conversation list as a keyboard list (E11 S5): `j`/`k` walk the conversations and Enter opens
|
||||
// the one the row's name button opens — not the pop-out button beside it. The live query, the key
|
||||
// state and the thread are stubbed: the list itself is what is under test.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const conversation = (id: number, name: string) => ({
|
||||
partner: { id, name, avatar_url: null },
|
||||
last_message: { kind: "system", body: `hello from ${name}`, created_at: "2026-09-13T00:00:00Z" },
|
||||
unread: 0,
|
||||
});
|
||||
const items = [conversation(0, "Siftlode"), conversation(7, "Anna"), conversation(9, "Bence")];
|
||||
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
useLiveQuery: () => ({ data: { items }, isError: false, isLoading: false, refetch: () => {} }),
|
||||
}));
|
||||
vi.mock("../lib/messaging", () => ({
|
||||
SYSTEM_ID: 0,
|
||||
POLL_MS: 20000,
|
||||
partnerPub: async () => "",
|
||||
useKeyState: () => "ready",
|
||||
}));
|
||||
const openChat = vi.fn();
|
||||
vi.mock("../lib/chatDock", () => ({ openChat: (...a: unknown[]) => openChat(...a) }));
|
||||
vi.mock("./ChatThread", () => ({
|
||||
default: ({ partnerId }: { partnerId: number }) => <div data-testid="thread">{partnerId}</div>,
|
||||
}));
|
||||
|
||||
import Messages from "./Messages";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
history.replaceState(null, "");
|
||||
});
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Messages · the conversation list is a keyboard list", () => {
|
||||
it("walks the conversations with j/k and opens the focused one on Enter", () => {
|
||||
render(<Messages meId={1} />);
|
||||
const focusedRow = () => document.activeElement?.getAttribute("data-nav-index");
|
||||
press("j");
|
||||
press("j");
|
||||
press("j");
|
||||
expect(focusedRow()).toBe("2");
|
||||
expect(document.activeElement?.textContent).toContain("Bence");
|
||||
press("k");
|
||||
expect(focusedRow()).toBe("1");
|
||||
expect(document.activeElement?.textContent).toContain("Anna");
|
||||
press("Enter");
|
||||
expect(screen.getByTestId("thread").textContent).toBe("7");
|
||||
expect(openChat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import Avatar from "./ui/Avatar";
|
||||
import KeyGate from "./KeyGate";
|
||||
import { inputCls } from "./ui/form";
|
||||
import ChatThread from "./ChatThread";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -140,15 +142,19 @@ function ConversationList({
|
||||
) : items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((c) => {
|
||||
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
|
||||
// opens; the pop-out button beside it keeps its own Enter.
|
||||
<NavGrid className="flex flex-col gap-1.5">
|
||||
{items.map((c, i) => {
|
||||
const isSystem = c.partner.id === SYSTEM_ID;
|
||||
return (
|
||||
<div
|
||||
key={c.partner.id}
|
||||
{...navItemAttrs(i)}
|
||||
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
||||
>
|
||||
<button
|
||||
data-nav-open=""
|
||||
onClick={() => onOpen(c)}
|
||||
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
|
||||
>
|
||||
@@ -195,7 +201,7 @@ function ConversationList({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -915,7 +915,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
info: () => setInfoOpen((v) => !v),
|
||||
stats: () => setStatsOpen((v) => !v),
|
||||
back: (ev) => {
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
|
||||
// Back button (shared cascade).
|
||||
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
|
||||
// and its focused close button is not a typing target, so the key reaches us regardless.
|
||||
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function KeyboardHelp() {
|
||||
? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu")
|
||||
: scopes;
|
||||
const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
|
||||
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
|
||||
return (
|
||||
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md">
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
@@ -58,7 +59,7 @@ export default function KeyboardHelp() {
|
||||
key={chord}
|
||||
className="rounded-sm border border-border bg-surface px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{formatChord(chord)}
|
||||
{formatChord(chord, words)}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"home": "Go to Home (what should I watch?)",
|
||||
"info": "Media info",
|
||||
"stats": "Stats for nerds",
|
||||
"back": "Stop & back to feed",
|
||||
"back": "Stop & go back",
|
||||
"browseBack": "Back one level",
|
||||
"closePlayer": "Close the player",
|
||||
"palette": "Open the jump-to palette",
|
||||
@@ -34,5 +34,9 @@
|
||||
"search": "Jump to the search box",
|
||||
"listStep": "Next / previous item",
|
||||
"gridMove": "Move around the list",
|
||||
"open": "Open the selected item"
|
||||
"open": "Open the selected item",
|
||||
"cap": {
|
||||
"space": "Space",
|
||||
"menu": "Menu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"home": "Ugrás a kezdőlapra (mit nézzek?)",
|
||||
"info": "Média infó",
|
||||
"stats": "Statisztika (nerdöknek)",
|
||||
"back": "Leállítás és vissza a feedre",
|
||||
"back": "Leállítás és vissza",
|
||||
"browseBack": "Vissza egy szintet",
|
||||
"closePlayer": "Lejátszó bezárása",
|
||||
"palette": "Ugrás-paletta megnyitása",
|
||||
@@ -34,5 +34,9 @@
|
||||
"search": "Ugrás a keresőbe",
|
||||
"listStep": "Következő / előző elem",
|
||||
"gridMove": "Mozgás a listában",
|
||||
"open": "A kijelölt elem megnyitása"
|
||||
"open": "A kijelölt elem megnyitása",
|
||||
"cap": {
|
||||
"space": "Szóköz",
|
||||
"menu": "Menü"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +149,9 @@ describe("the page-level rows", () => {
|
||||
});
|
||||
|
||||
it("render as the keys to press, step by step", () => {
|
||||
expect(formatChord("g h")).toBe("G H");
|
||||
expect(formatChord("g ,")).toBe("G ,");
|
||||
const words = { Space: "Space", ContextMenu: "Menu" };
|
||||
expect(formatChord("g h", words)).toBe("G H");
|
||||
expect(formatChord("g ,", words)).toBe("G ,");
|
||||
});
|
||||
|
||||
// `g h` shares Home's line with Shift+W; leaving out an unreachable chord must not take that with it.
|
||||
|
||||
@@ -213,11 +213,19 @@ describe("helpLines", () => {
|
||||
});
|
||||
|
||||
describe("formatChord", () => {
|
||||
const words = { Space: "Szóköz", ContextMenu: "Menü" };
|
||||
|
||||
it("renders glyphs and upper-cases the letter", () => {
|
||||
expect(formatChord("ArrowLeft")).toBe("←");
|
||||
expect(formatChord("Backspace")).toBe("⌫");
|
||||
expect(formatChord("Shift+ArrowRight")).toBe("⇧ →");
|
||||
expect(formatChord("k")).toBe("K");
|
||||
expect(formatChord("Space")).toBe("Space");
|
||||
expect(formatChord("ArrowLeft", words)).toBe("←");
|
||||
expect(formatChord("Backspace", words)).toBe("⌫");
|
||||
expect(formatChord("Shift+ArrowRight", words)).toBe("⇧ →");
|
||||
expect(formatChord("k", words)).toBe("K");
|
||||
});
|
||||
|
||||
// A key whose cap carries a word is named in the reader's language: the Hungarian sheet said "Space".
|
||||
it("names a word key in the caller's language", () => {
|
||||
expect(formatChord("Space", words)).toBe("Szóköz");
|
||||
expect(formatChord("ContextMenu", words)).toBe("Menü");
|
||||
expect(formatChord("Shift+F10", words)).toBe("⇧ F10");
|
||||
});
|
||||
});
|
||||
|
||||
+19
-14
@@ -195,7 +195,7 @@ export const SHORTCUTS = [
|
||||
{ id: "stats", scope: "plexPlayer", keys: ["d"], labelKey: "keys.stats" },
|
||||
// Two rows, one help line: ⌫ is the plain "stop & out", Escape is the layer-aware cascade (defer to
|
||||
// anything above → cancel auto-skip → close the topmost player overlay → close). Sharing a labelKey
|
||||
// keeps them a single "⌫ Esc Stop & back to feed" row in the sheet.
|
||||
// keeps them a single "⌫ Esc Stop & go back" row in the sheet.
|
||||
{ id: "back", scope: "plexPlayer", keys: ["Backspace"], labelKey: "keys.back" },
|
||||
{
|
||||
id: "escape",
|
||||
@@ -558,28 +558,33 @@ const GLYPHS: Record<string, string> = {
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
Backspace: "⌫",
|
||||
ContextMenu: "Menu", // the dedicated menu key; "ContextMenu" is a DOM name, not a keycap
|
||||
Escape: "Esc",
|
||||
Shift: "⇧",
|
||||
};
|
||||
|
||||
/** The keys a keycap names with a WORD rather than a legend every layout prints alike (Esc, Ctrl,
|
||||
* Enter): the caller supplies them in the reader's language — a Hungarian sheet said "Space". */
|
||||
export type KeycapWords = Readonly<Record<"Space" | "ContextMenu", string>>;
|
||||
|
||||
const isMac = (): boolean =>
|
||||
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent);
|
||||
|
||||
/** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac), and a
|
||||
* sequence step by step: `g h` → `G H`. */
|
||||
export function formatChord(chord: string): string {
|
||||
return stepsOf(chord).map(formatStep).join(" ");
|
||||
}
|
||||
|
||||
function formatStep(chord: string): string {
|
||||
return chord
|
||||
.split("+")
|
||||
.map((part, i, all) => {
|
||||
if (part.toLowerCase() === "mod") return isMac() ? "⌘" : "Ctrl";
|
||||
if (i < all.length - 1) return GLYPHS[part] ?? part;
|
||||
return GLYPHS[part] ?? (part.length === 1 ? part.toUpperCase() : part);
|
||||
})
|
||||
export function formatChord(chord: string, words: KeycapWords): string {
|
||||
const name = (part: string): string | undefined =>
|
||||
GLYPHS[part] ?? words[part as keyof KeycapWords];
|
||||
return stepsOf(chord)
|
||||
.map((step) =>
|
||||
step
|
||||
.split("+")
|
||||
.map((part, i, all) => {
|
||||
if (part.toLowerCase() === "mod") return isMac() ? "⌘" : "Ctrl";
|
||||
if (i < all.length - 1) return name(part) ?? part;
|
||||
return name(part) ?? (part.length === 1 ? part.toUpperCase() : part);
|
||||
})
|
||||
.join(" "),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user