Files
siftlode/frontend/src/components/ui/DraftSaveBar.tsx
T
peter 5280949667 feat(ui): migrate the canonical action-button surface onto <Button>
Sweep the rounded-lg solid/ghost/outline action-button family onto the shared primitive:
ConfirmProvider, ErrorDialog, DownloadDialog, DownloadCenter, downloadModals, downloadAdmin,
ProfileEditor, PersonalViewsModal, SettingsPanel, WatchPage, DraftSaveBar. Deliberately left
on their own styles per the agreed scope: login/onboarding rounded-xl CTAs (Welcome/Onboarding/
Setup/KeyGate), glass Plex surfaces, segmented pills/toggles/switches/chips, dropdown triggers,
data-driven Banner/tone maps, text-xs dense buttons, and tiny inline p-1/p-1.5 icon controls —
none map 1:1 to a variant+size without a visual change, and the repo has no tailwind-merge.
2026-08-28 03:12:29 +02:00

88 lines
2.9 KiB
TypeScript

import { createPortal } from "react-dom";
import { Check, RotateCcw, Save } from "lucide-react";
import { Button } from "./Button";
// 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 glass rounded-2xl 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;
}