Files
Jyotisha/frontend/src/components/account-dialog-overlay.tsx
T
jesse-uxandClaude Code eb8f5147f4
Independent Staging Quality Gate / validate (push) Failing after 8m47s
Independent Staging Quality Gate / publish (push) Skipped
fix(frontend): refine settings dialog layout
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 11:32:27 +08:00

122 lines
4.4 KiB
TypeScript

"use client";
import { Settings, UserRound, Users, WalletCards, X } from "lucide-react";
import { memo, type MutableRefObject, type ReactNode } from "react";
import { createPortal } from "react-dom";
export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general";
export type AccountOverlayModel = Readonly<{
title: string;
dialogClass: string;
signingOut: boolean;
close: () => void;
navigate: (dialog: AccountSettingsDialog) => void;
overlayRef: MutableRefObject<HTMLElement | null>;
closeButtonRef: MutableRefObject<HTMLButtonElement | null>;
renderProfile: () => ReactNode;
renderChartLibrary: () => ReactNode;
renderBilling: () => ReactNode;
renderGeneral: () => ReactNode;
renderLogout: () => ReactNode;
}>;
const settingsItems: ReadonlyArray<{
dialog: AccountSettingsDialog;
label: string;
icon: typeof UserRound;
}> = [
{ dialog: "profile", label: "个人资料", icon: UserRound },
{ dialog: "chart-library", label: "星盘资料", icon: Users },
{ dialog: "billing", label: "账户与点数", icon: WalletCards },
{ dialog: "general", label: "通用设置", icon: Settings },
];
/**
* Panes whose body is a single column of form fields. They get a reading-width cap
* inside the content box; the list panes (星盘资料 / 账户与点数) stay full-bleed.
* The cap is purely inside `.settings-dialog-content`: every pane still renders under
* the same `.settings-modal` box, so the dialog cannot change size between panes
* (BUG-554 / BUG-698).
*/
const formPanes: ReadonlyArray<AccountSettingsDialog> = ["profile", "general"];
export const AccountDialogOverlay = memo(function AccountDialogOverlay({
open,
dialog,
model,
}: Readonly<{
open: boolean;
dialog: "profile" | "chart-library" | "billing" | "general" | "logout" | null;
model: AccountOverlayModel | null;
}>) {
if (!open || dialog === null || model === null) return null;
const isSettingsDialog = dialog !== "logout";
const isFormPane = dialog !== "logout" && formPanes.includes(dialog);
const renderSettingsContent = () => {
if (dialog === "profile") return model.renderProfile();
if (dialog === "chart-library") return model.renderChartLibrary();
if (dialog === "billing") return model.renderBilling();
return model.renderGeneral();
};
const overlay = (
<div className="account-modal-overlay" onMouseDown={model.close}>
<section
className={`account-modal ${model.dialogClass}`}
ref={model.overlayRef}
role="dialog"
aria-modal="true"
aria-labelledby="account-dialog-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header className="account-modal-header">
<div className="account-modal-heading">
{isSettingsDialog ? <span className="account-modal-eyebrow">设置</span> : null}
<h2 id="account-dialog-title">{model.title}</h2>
</div>
<button
className="dialog-close"
ref={model.closeButtonRef}
aria-label="关闭"
type="button"
onClick={model.close}
disabled={model.signingOut}
>
<X aria-hidden="true" />
</button>
</header>
{isSettingsDialog ? (
<div className="account-settings-shell">
<nav className="settings-dialog-nav" aria-label="设置分区">
{settingsItems.map(({ dialog: itemDialog, label, icon: Icon }) => (
<button
className="settings-dialog-nav-item"
key={itemDialog}
type="button"
aria-current={dialog === itemDialog ? "page" : undefined}
onClick={() => model.navigate(itemDialog)}
>
<Icon aria-hidden="true" />
<span>{label}</span>
</button>
))}
</nav>
<div
className={`settings-dialog-content${isFormPane ? " settings-dialog-content--form" : ""}`}
>
{renderSettingsContent()}
</div>
</div>
) : model.renderLogout()}
</section>
</div>
);
// BUG-968: SidebarInset is inert while the dialog is open. Portal to body so
// the dialog is not inside that subtree. SSR keeps the in-tree markup so
// renderToString contracts still see the dialog.
if (typeof document === "undefined") return overlay;
return createPortal(overlay, document.body);
});