feat(ui): E31 S4a — rail rows are links, the channel page opens on unwatched, users and audit tidied
- Nav rail page rows are real links (/?page=<id>): a middle-click or Ctrl-click opens the page in a new tab; a plain click stays in-app. - A fresh channel page shows the unwatched videos; a picked watch state is a remembered preference (channelShow). - Users: the role, suspend and delete move into the row's one menu (the "…", right-click, Shift+F10); locked ones keep their reason. - Audit log: back-to-back successful runs of one job fold into one row that opens to the earlier ones; a failed run reads in the danger colour. - DESIGN.md decision log: the E31 picks.
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { NavRailAction, NavRailItem } from "./NavRail";
|
||||
|
||||
@@ -24,10 +24,11 @@ describe("NavRailItem", () => {
|
||||
showLabel={false}
|
||||
active
|
||||
badge={3}
|
||||
href="/?page=notifications"
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("button", { name: "Notifications (3)" });
|
||||
const row = screen.getByRole("link", { name: "Notifications (3)" });
|
||||
expect(row.getAttribute("aria-current")).toBe("page");
|
||||
expect(row.className).toContain("bg-accent/15");
|
||||
expect(row.textContent).not.toContain("Alerts");
|
||||
@@ -40,10 +41,11 @@ describe("NavRailItem", () => {
|
||||
label="Notifications"
|
||||
shortLabel="Alerts"
|
||||
showLabel
|
||||
href="/?page=notifications"
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("button", { name: "Alerts" });
|
||||
const row = screen.getByRole("link", { name: "Alerts" });
|
||||
expect(row.getAttribute("aria-current")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -57,10 +59,51 @@ describe("NavRailItem", () => {
|
||||
shortLabel="Alerts"
|
||||
showLabel
|
||||
badge={20}
|
||||
href="/?page=notifications"
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Alerts (20)" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Alerts (20)" })).toBeTruthy();
|
||||
});
|
||||
|
||||
// E31 (the owner): a middle-click on a rail row opens the page in a new tab. The row is a real link
|
||||
// to the page, so the browser's own gestures work; only a plain left click navigates in-app.
|
||||
it("is a link to its page, navigating in-app only on a plain left click", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<NavRailItem
|
||||
icon={Bell}
|
||||
label="Notifications"
|
||||
shortLabel="Alerts"
|
||||
showLabel={false}
|
||||
href="/?page=notifications"
|
||||
onClick={onClick}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("link", { name: "Notifications" });
|
||||
expect(row.getAttribute("href")).toBe("/?page=notifications");
|
||||
// Whether the row left the click to the browser, read after the app's own handler ran; then the
|
||||
// click is stopped here, as jsdom cannot follow a link.
|
||||
const leftToBrowser: boolean[] = [];
|
||||
const record = (e: Event) => {
|
||||
leftToBrowser.push(!e.defaultPrevented);
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("click", record);
|
||||
try {
|
||||
fireEvent.click(row, { button: 0 });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
for (const init of [
|
||||
{ button: 1 },
|
||||
{ button: 0, ctrlKey: true },
|
||||
{ button: 0, metaKey: true },
|
||||
])
|
||||
fireEvent.click(row, init);
|
||||
} finally {
|
||||
window.removeEventListener("click", record);
|
||||
}
|
||||
expect(leftToBrowser).toEqual([false, true, true, true]);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { SURFACE } from "./surfaces";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { isPlainLeftClick } from "../../lib/nav";
|
||||
|
||||
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
|
||||
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
|
||||
@@ -76,6 +77,7 @@ export function NavRailItem({
|
||||
showLabel,
|
||||
active = false,
|
||||
badge,
|
||||
href,
|
||||
onClick,
|
||||
testId,
|
||||
}: {
|
||||
@@ -88,15 +90,24 @@ export function NavRailItem({
|
||||
active?: boolean;
|
||||
/** An unread / in-progress count on the icon's corner (0 hides it). */
|
||||
badge?: number;
|
||||
/** The page's own address. A page row is a real link (the owner, E31): a middle-click, a
|
||||
* Ctrl/Cmd-click or the browser's "Open in new tab" opens the page in a tab of its own. */
|
||||
href: string;
|
||||
/** The in-app navigation, run only for a plain left click. */
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
const shown = !!badge && badge > 0;
|
||||
return (
|
||||
<Tooltip hint={label} side="right" always openDelay={350}>
|
||||
<Pressable
|
||||
<a
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
if (!isPlainLeftClick(e)) return; // the browser opens its tab or window
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
// With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name
|
||||
// contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the
|
||||
// name. The exact count rides in the name either way, so "Messages (3)" is heard and not only
|
||||
@@ -131,7 +142,7 @@ export function NavRailItem({
|
||||
{shortLabel}
|
||||
</span>
|
||||
)}
|
||||
</Pressable>
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"ui/ActionMenu.tsx": [1, "a menu row"],
|
||||
"ui/BackToTop.tsx": [1, "a control"],
|
||||
"ui/CommandPalette.tsx": [1, "a command row"],
|
||||
"ui/NavRail.tsx": [2, "nav items"],
|
||||
"ui/NavRail.tsx": [1, "the action rows (About, Sign out); a page row is a link (E31)"],
|
||||
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
||||
"ui/SidePanel.tsx": [2, "panel controls"],
|
||||
"ui/tableHeader.tsx": [1, "a column's sort header"],
|
||||
|
||||
Reference in New Issue
Block a user