Three low-severity findings from the /code-review high self-pass: - Plex duration: the "Any" chip no longer false-highlights when durationMin/Max hold a set-but-off-bucket value (stale/version-skew state) — it selects nothing then, matching the pre-sweep behaviour, instead of colliding on the empty-string sentinel. - FilterChipGroup: count badge now guards on count > 0 (was `!= null`), matching the Tabs badge so a future count:0 can't render a stray "0" pill. - Extract the shared CountBadge component; Tabs and FilterChipGroup now render the count-badge token from one source (was two near-identical inline spans). gate green (tsc/eslint-0/vitest 136/knip/prettier). Duration self-E2E'd: bucket select + Any toggle intact. Branch not merged.
66 lines
2.5 KiB
TypeScript
66 lines
2.5 KiB
TypeScript
import { type LucideIcon } from "lucide-react";
|
|
import { useAccountPersistedState } from "../lib/storage";
|
|
import CountBadge from "./CountBadge";
|
|
|
|
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
|
|
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
|
|
// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted
|
|
// to localStorage so a reload keeps the user where they were.
|
|
|
|
export interface TabDef {
|
|
id: string;
|
|
label: string;
|
|
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
|
|
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
|
|
testId?: string; // optional data-testid, to preserve existing e2e hooks
|
|
}
|
|
|
|
/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
|
|
* carry into another in the same browser) — re-exported under its original name for call sites. */
|
|
export const usePersistedTab = useAccountPersistedState;
|
|
|
|
export default function Tabs({
|
|
tabs,
|
|
active,
|
|
onChange,
|
|
orientation = "horizontal",
|
|
className,
|
|
}: {
|
|
tabs: TabDef[];
|
|
active: string;
|
|
onChange: (id: string) => void;
|
|
// "vertical" gives the Settings-style side rail; "horizontal" (default) is the standard row.
|
|
orientation?: "horizontal" | "vertical";
|
|
// Override the row/rail wrapper — e.g. the channel manager embeds the rail in a justify-between
|
|
// header, or the settings rail adds a width + right border. Omit for the orientation default.
|
|
className?: string;
|
|
}) {
|
|
const vertical = orientation === "vertical";
|
|
const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
|
|
return (
|
|
<div className={wrapCls}>
|
|
{tabs.map((tb) => {
|
|
const on = tb.id === active;
|
|
const Icon = tb.icon;
|
|
return (
|
|
<button
|
|
key={tb.id}
|
|
data-testid={tb.testId}
|
|
onClick={() => onChange(tb.id)}
|
|
aria-pressed={on}
|
|
className={`pill items-center rounded-lg px-3 text-sm ${
|
|
vertical
|
|
? "flex w-full justify-start gap-2 py-2 text-left"
|
|
: "inline-flex gap-1.5 py-1.5"
|
|
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
|
|
>
|
|
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
|
{tb.label}
|
|
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|