v1.0

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

PropTypeDefaultDescription
count*number—
onSaveAll*() => void | Promise<void>—
onDiscardAll*() => void—
savingbooleanfalseExternally-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.