89 lines
3.0 KiB
TypeScript
89 lines
3.0 KiB
TypeScript
import { createPortal } from "react-dom";
|
|
import { Check, RotateCcw, Save } from "lucide-react";
|
|
import { Button } from "./Button";
|
|
import { SURFACE } from "./surfaces";
|
|
|
|
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
|
// admin Configuration). Two visual variants share one state machine + markup:
|
|
// - "inline": a bar pinned to the bottom of a card (Settings panel)
|
|
// - "floating": a centered floating pill over the page (Configuration page)
|
|
// Renders nothing while clean and idle; stays mounted through the fading saved/error message.
|
|
|
|
export type SaveState = "idle" | "saving" | "saved" | "error";
|
|
|
|
export interface DraftSaveBarLabels {
|
|
saved: string;
|
|
failed: string;
|
|
unsaved: string;
|
|
discard: string;
|
|
save: string;
|
|
saving: string;
|
|
}
|
|
|
|
export function DraftSaveBar({
|
|
dirty,
|
|
state,
|
|
onSave,
|
|
onDiscard,
|
|
labels,
|
|
variant = "inline",
|
|
}: {
|
|
dirty: boolean;
|
|
state: SaveState;
|
|
onSave: () => void;
|
|
onDiscard: () => void;
|
|
labels: DraftSaveBarLabels;
|
|
variant?: "inline" | "floating";
|
|
}) {
|
|
if (!dirty && state === "idle") return null;
|
|
const saving = state === "saving";
|
|
const floating = variant === "floating";
|
|
const wrap = floating
|
|
? `fixed bottom-4 left-1/2 -translate-x-1/2 z-rail ${SURFACE.panel} flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]`
|
|
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
|
|
// The floating variant is fixed to the viewport but renders from a page inside the page
|
|
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
|
|
// mask) scopes this z-rail and fades the bar's bottom edge. The inline variant is in normal flow
|
|
// and stays put.
|
|
const bar = (
|
|
<div data-testid="draft-save-bar" className={wrap}>
|
|
<span data-testid="draft-save-status" data-state={state} className="text-sm text-muted">
|
|
{state === "saved" ? (
|
|
<span className="text-success inline-flex items-center gap-1.5">
|
|
<Check className="w-4 h-4" />
|
|
{labels.saved}
|
|
</span>
|
|
) : state === "error" ? (
|
|
<span className="text-danger">{labels.failed}</span>
|
|
) : (
|
|
labels.unsaved
|
|
)}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
data-testid="draft-discard"
|
|
variant="glass"
|
|
size="sm"
|
|
icon={<RotateCcw className="w-4 h-4" />}
|
|
onClick={onDiscard}
|
|
disabled={saving || !dirty}
|
|
>
|
|
{labels.discard}
|
|
</Button>
|
|
<Button
|
|
data-testid="draft-save"
|
|
variant="primary"
|
|
size="sm"
|
|
icon={<Save className="w-4 h-4" />}
|
|
onClick={onSave}
|
|
disabled={saving || !dirty}
|
|
className="shadow-md shadow-accent/20"
|
|
>
|
|
{saving ? labels.saving : labels.save}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
return floating ? createPortal(bar, document.body) : bar;
|
|
}
|