Files
edgeever/apps/web/src/components/plugins/PluginPanelDialog.tsx
T
tianma-if e953faad87 feat(plugins): add host-rendered panel chrome
Let plugins describe header actions, search, tabs, selects, and empty states through mount context shell.set(), and render them with the host UI.
2026-09-09 23:51:00 +08:00

117 lines
5.0 KiB
TypeScript

import { useEffect, useState } from "react";
import type { PluginPanelChrome, PluginPanelCloseDecision, PluginPanelOpenOptions } from "@edgeever/plugin-api";
import { useTranslation } from "react-i18next";
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
import { PluginPanelEmpty, PluginPanelHeaderActions, PluginPanelToolbar } from "@/components/plugins/PluginPanelChrome";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { EdgeEverPluginHost, RegisteredPluginPanel } from "@/lib/plugins/plugin-host";
import { cn } from "@/lib/utils";
export const PluginPanelDialog = ({ host, panel, options, onClose }: {
host: EdgeEverPluginHost;
panel: RegisteredPluginPanel | null;
options?: PluginPanelOpenOptions;
onClose: () => void;
}) => {
const { t } = useTranslation();
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [chrome, setChrome] = useState<PluginPanelChrome>({});
const [chromeEnabled, setChromeEnabled] = useState(false);
const [mountError, setMountError] = useState<string | null>(null);
const [closeConfirmation, setCloseConfirmation] = useState<Exclude<PluginPanelCloseDecision, boolean> | null>(null);
const [checkingClose, setCheckingClose] = useState(false);
const panelPluginId = panel?.pluginId ?? null;
const panelId = panel?.id ?? null;
useEffect(() => {
setChrome({});
setChromeEnabled(false);
}, [panelPluginId, panelId]);
useEffect(() => {
// Container is reactive so delayed portal attachment mounts the plugin.
if (!container || !panelPluginId || !panelId) return;
let disposed = false;
let disposePanel: (() => void) | null = null;
setMountError(null);
container.replaceChildren();
void host.mountPanel(panelPluginId, panelId, container, options, requestClose, {
set(next) {
if (disposed) return;
setChromeEnabled(true);
setChrome(next);
},
}).then((dispose) => {
if (disposed) dispose();
else disposePanel = dispose;
}).catch((error: unknown) => {
if (!disposed) setMountError(error instanceof Error ? error.message : String(error));
});
return () => {
disposed = true;
disposePanel?.();
container.replaceChildren();
};
}, [container, host, options, panelId, panelPluginId]);
const requestClose = async () => {
if (!panelPluginId || !panelId || checkingClose) return;
setCheckingClose(true);
try {
const decision = await host.getPanelCloseDecision(panelPluginId, panelId);
if (decision === true) onClose();
else if (decision && typeof decision === "object") setCloseConfirmation(decision);
} catch (error) {
setMountError(error instanceof Error ? error.message : String(error));
} finally {
setCheckingClose(false);
}
};
const title = chrome.header?.title || panel?.title || t("plugins.panel");
const hideDescription = chromeEnabled && chrome.header?.description === null;
const description = chrome.header?.description || t("plugins.panelDescription");
return (
<>
<Dialog open={Boolean(panel)} onOpenChange={(open) => { if (!open) void requestClose(); }}>
<DialogContent className={cn(
panel?.presentation === "fullscreen"
? "flex h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-none flex-col overflow-hidden p-5"
: "max-h-[85vh] max-w-3xl overflow-y-auto",
)}>
<DialogHeader className={cn(chromeEnabled && "flex flex-row items-start justify-between gap-3 space-y-0")}>
<div className="min-w-0 space-y-1.5">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className={hideDescription ? "sr-only" : undefined}>{description}</DialogDescription>
</div>
{chromeEnabled ? <PluginPanelHeaderActions chrome={chrome} /> : null}
</DialogHeader>
{chromeEnabled ? <PluginPanelToolbar chrome={chrome} /> : null}
{mountError ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{mountError}</div> : null}
{chrome.empty ? <PluginPanelEmpty chrome={chrome} /> : null}
<div ref={setContainer} hidden={Boolean(chrome.empty)} className={cn(
chromeEnabled
? "min-h-40 text-sm text-slate-700"
: "min-h-40 rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700",
panel?.presentation === "fullscreen" && "min-h-0 flex-1 overflow-auto",
)} />
</DialogContent>
</Dialog>
{closeConfirmation ? (
<AppConfirmDialog
title={closeConfirmation.title}
description={closeConfirmation.message}
confirmLabel={closeConfirmation.confirmLabel ?? t("common.close")}
tone="neutral"
onCancel={() => setCloseConfirmation(null)}
onConfirm={() => {
setCloseConfirmation(null);
onClose();
}}
/>
) : null}
</>
);
};