refactor(ui): fold Settings + Stats tabs into shared Tabs (R13 S2c step 6)
Tabs gains an `orientation` prop (+ optional per-tab `icon`/`testId`) so the two hand-rolled NAV tab-sets that duplicated its active treatment now use it: - SettingsPanel vertical rail (appearance/notifications/account) -> vertical Tabs with icons; settings-tab-* testIds preserved - Stats overview/system tabs -> Tabs (rounded-full+border -> the rounded-lg tab canonical, per the agreed tab shape) The underline ChannelPage tabs and the icon NavSidebar rail are intentionally left as distinct idioms. Self-E2E'd: settings rail switches + persists with icons, stats tabs toggle. gate green (tsc/eslint-0/vitest 136/knip/prettier). Branch not merged.
This commit is contained in:
@@ -14,6 +14,7 @@ import { type SaveState } from "./ui/DraftSaveBar";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
import { usePrefs } from "./PrefsProvider";
|
import { usePrefs } from "./PrefsProvider";
|
||||||
|
import Tabs from "./Tabs";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
||||||
@@ -61,25 +62,18 @@ export default function SettingsPanel() {
|
|||||||
<div className="glass rounded-2xl overflow-hidden">
|
<div className="glass rounded-2xl overflow-hidden">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
||||||
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
<Tabs
|
||||||
{TABS.map((tabItem) => {
|
orientation="vertical"
|
||||||
const active = tab === tabItem.id;
|
className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1"
|
||||||
return (
|
active={tab}
|
||||||
<button
|
onChange={setTab}
|
||||||
key={tabItem.id}
|
tabs={TABS.map((tabItem) => ({
|
||||||
data-testid={`settings-tab-${tabItem.id}`}
|
id: tabItem.id,
|
||||||
onClick={() => setTab(tabItem.id)}
|
label: t(`settings.tabs.${tabItem.id}`),
|
||||||
aria-pressed={active}
|
icon: tabItem.icon,
|
||||||
className={`pill flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left ${
|
testId: `settings-tab-${tabItem.id}`,
|
||||||
active ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"
|
}))}
|
||||||
}`}
|
/>
|
||||||
>
|
|
||||||
<tabItem.icon className="w-4 h-4 shrink-0" />
|
|
||||||
{t(`settings.tabs.${tabItem.id}`)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Render only the active tab so the panel sizes to its actual content. (Stacking
|
{/* Render only the active tab so the panel sizes to its actual content. (Stacking
|
||||||
every tab in one grid cell made the whole panel as tall as the tallest tab —
|
every tab in one grid cell made the whole panel as tall as the tallest tab —
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { LS, useAccountPersistedState } from "../lib/storage";
|
|||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import FilterChipGroup from "./FilterChipGroup";
|
import FilterChipGroup from "./FilterChipGroup";
|
||||||
|
import Tabs from "./Tabs";
|
||||||
import { Section, SettingRow } from "./ui/form";
|
import { Section, SettingRow } from "./ui/form";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
@@ -31,18 +32,14 @@ export default function Stats() {
|
|||||||
<div className="p-4 max-w-4xl mx-auto">
|
<div className="p-4 max-w-4xl mx-auto">
|
||||||
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
|
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-1 mb-4">
|
<Tabs
|
||||||
{(["overview", "system"] as StatsTab[]).map((id) => (
|
active={tab}
|
||||||
<button
|
onChange={setTab}
|
||||||
key={id}
|
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
|
||||||
onClick={() => setTab(id)}
|
id,
|
||||||
aria-pressed={tab === id}
|
label: t(`stats.tabs.${id}`),
|
||||||
className="pill px-3 py-1.5 rounded-full text-sm border"
|
}))}
|
||||||
>
|
/>
|
||||||
{t(`stats.tabs.${id}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
|
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import { type LucideIcon } from "lucide-react";
|
||||||
import { useAccountPersistedState } from "../lib/storage";
|
import { useAccountPersistedState } from "../lib/storage";
|
||||||
|
|
||||||
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
|
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
|
||||||
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
|
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
|
||||||
// per the app's "persist UI state across reload" convention.
|
// 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 {
|
export interface TabDef {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
|
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
|
/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
|
||||||
@@ -18,28 +22,38 @@ export default function Tabs({
|
|||||||
tabs,
|
tabs,
|
||||||
active,
|
active,
|
||||||
onChange,
|
onChange,
|
||||||
className = "flex flex-wrap gap-1 mb-4",
|
orientation = "horizontal",
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
tabs: TabDef[];
|
tabs: TabDef[];
|
||||||
active: string;
|
active: string;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
// Override the row wrapper — e.g. the channel manager embeds the rail in a justify-between header
|
// "vertical" gives the Settings-style side rail; "horizontal" (default) is the standard row.
|
||||||
// and drops the default bottom margin. Omit for the standalone-row default.
|
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;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const vertical = orientation === "vertical";
|
||||||
|
const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={wrapCls}>
|
||||||
{tabs.map((tb) => {
|
{tabs.map((tb) => {
|
||||||
const on = tb.id === active;
|
const on = tb.id === active;
|
||||||
|
const Icon = tb.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tb.id}
|
key={tb.id}
|
||||||
|
data-testid={tb.testId}
|
||||||
onClick={() => onChange(tb.id)}
|
onClick={() => onChange(tb.id)}
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
className={`pill inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm ${
|
className={`pill items-center rounded-lg px-3 text-sm ${
|
||||||
on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"
|
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.label}
|
||||||
{tb.badge != null && tb.badge > 0 && (
|
{tb.badge != null && tb.badge > 0 && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
Reference in New Issue
Block a user