SaveAllBar
Preview
Basic
Loading…
Preview
Code
ts
import SaveAllBar from '@/components/table/SaveAllBar';src/components/table/SaveAllBar.tsx
AI prompt
text
Build a floating "unsaved changes" bar component in React + TypeScript + Tailwind CSS for inline-edit tables: it shows how many rows are dirty and offers Save all / Discard.
## Look
- A centred pill that sticks to the bottom of its scroll area: `sticky bottom-4 z-20 mx-auto w-fit flex items-center gap-3 pl-4 pr-2 py-2 rounded-full bg-white/90 dark:bg-slate-900/90 backdrop-blur border border-slate-200 dark:border-slate-700 shadow-xl`.
- Left: "**3** unsaved changes" — `text-xs` slate-700 / dark slate-200, nowrap, count semibold, "change" singular at 1.
- Then a primary button made round (`rounded-full px-4`) with a 14px Save icon, "Save all" (reads "Saving…" while busy), and a ghost button (`rounded-full px-3`) with a 14px X icon, "Discard". Both disabled while busy.
## Behaviour
- Renders nothing when `count` is 0, so the caller can mount it unconditionally.
- `onSaveAll` may return a promise; the bar awaits it and tracks its own busy state (reset in `finally`), which blocks a double-click from firing two saves.
- An external `saving` flag is OR-ed with the internal one, for a caller that already tracks a save in flight (e.g. several tables saved together).
## API
`count: number`, `onSaveAll(): void | Promise<void>`, `onDiscardAll(): void`, `saving?: boolean` (default false).
## Demo
The bar with 3 unsaved changes; Save all and Discard both reset the count to 0, and a ghost "Dirty 3 rows again" button brings it back.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useState } from 'react';
import { Save, X } from 'lucide-react';
/**
* The unsaved-changes bar for inline-edit tables. Renders nothing at zero, so
* the caller can mount it unconditionally.
*
* It tracks its own busy state while awaiting `onSaveAll`, which is what stops a
* double-click from firing two saves — worth having when a single save is one
* transaction over the whole menu tree.
*
* MERGED from two copies. The external `saving` prop came across from
* marketing-stats: a caller that already owns a save-in-flight flag (a page
* saving several tables at once, say) needs the bar to reflect THAT, not just
* its own await. The two are OR-ed, so passing nothing keeps the built-in
* behaviour exactly as it was.
*/
export default function SaveAllBar({
count,
onSaveAll,
onDiscardAll,
saving = false,
}: {
count: number;
onSaveAll: () => void | Promise<void>;
onDiscardAll: () => void;
/**
* Externally-owned busy flag, for a caller that already tracks a save in
* flight. OR-ed with the bar's own — it never needs to be passed for the
* built-in await to work.
*/
saving?: boolean;
}) {
const [internalSaving, setInternalSaving] = useState(false);
const busy = saving || internalSaving;
if (count === 0) return null;
const save = async () => {
setInternalSaving(true);
try {
await onSaveAll();
} finally {
setInternalSaving(false);
}
};
return (
<div className="sticky bottom-4 z-20 mx-auto w-fit flex items-center gap-3 pl-4 pr-2 py-2 rounded-full bg-white/90 dark:bg-slate-900/90 backdrop-blur border border-slate-200 dark:border-slate-700 shadow-xl">
<span className="text-xs text-slate-700 dark:text-slate-200 whitespace-nowrap">
<span className="font-semibold">{count}</span> unsaved change{count === 1 ? '' : 's'}
</span>
<button type="button" onClick={save} disabled={busy} className="btn-primary rounded-full px-4">
<Save className="w-3.5 h-3.5" />
{busy ? 'Saving…' : 'Save all'}
</button>
<button
type="button"
onClick={onDiscardAll}
disabled={busy}
className="btn-ghost rounded-full px-3"
>
<X className="w-3.5 h-3.5" />
Discard
</button>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
count* | number | — | |
onSaveAll* | () => void | Promise<void> | — | |
onDiscardAll* | () => void | — | |
saving | boolean | false | Externally-owned busy flag, for a caller that already tracks a save in flight. OR-ed with the bar's own — it never needs to be passed for the built-in await to work. |