v1.0

StatusSteps

Preview

Basic

Loading…

Preview

Code

ts
import StatusSteps from '@/components/data/StatusSteps';

src/components/data/StatusSteps.tsx

AI prompt

text
Build a compact lifecycle stepper (inline progress dots) component in React + TypeScript + Tailwind CSS. It sits in a table row beside a status badge: the badge says what the status is now, and this shows how far the item has got.

## Look
- An `<ol>`, `flex items-center gap-1 leading-tight`. Each step is `flex items-center gap-1`: a 6px round dot, then its label in 10px `whitespace-nowrap` text.
- Between steps: a 12px × 1px connector line, slate-200 / dark slate-700 when the step after it is upcoming, otherwise slate-300 / dark slate-600.
- Dot and label by step state:
  - done: dot slate-400 / dark slate-500, label slate-500 / dark slate-400.
  - current: dot and label take the step's tone, and the label is semibold. Tones: active = amber-500 dot with amber-700 / dark amber-400 text; good = emerald-500 with emerald-700 / dark emerald-400; bad = rose-500 with rose-700 / dark rose-400; neutral = slate-400 with slate-400 / dark slate-500.
  - upcoming: dot slate-200 / dark slate-700, label slate-300 / dark slate-600.
  - skipped: a hollow dot (transparent with a dashed slate-300 / dark slate-600 border), label slate-300 / dark slate-600 with a line through it.

## Behaviour
Always three steps: raised → sent → settled. Approved and Rejected are two outcomes of the same moment, so they share the last step and never appear as separate steps. Map the status like this:
- Pending, or any unknown status: **Pending** current (active), Submitted upcoming, Approved upcoming.
- Submitted: Pending done, **Submitted** current (active), Approved upcoming.
- Approved: Pending done, Submitted done, **Approved** current (good).
- Rejected or Failed: Pending done, Submitted done, **Rejected** / **Failed** current (bad).
- Duplicate: Pending done, Submitted SKIPPED (it was never sent), **Duplicate** current (bad).

## Accessibility
Colour is never the only signal: the current step is also the only bold label and the only filled coloured dot. Dots and connectors are `aria-hidden`, and the list has `aria-label="Progress: <current step label>"`.

## API
`status: string`.

## Demo
Stack six rows, one per status: Pending, Submitted, Approved, Rejected, Failed, Duplicate.

## 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
/* Origin: bonus-adjustment (96S2), verbatim. */
import { lifecycleSteps, type LifecycleStep } from '@/lib/status';

/**
 * Pending -> Submitted -> Approved/Rejected, with the current step highlighted.
 *
 * A stepper rather than a lone badge because the badge answers "what is it now"
 * and this answers "how far has it got" — the question an operator chasing a
 * payout is actually asking. The two are complementary, so the row keeps both.
 *
 * Colour is never the only signal: the current step is also the only BOLD one
 * and the only filled dot, so the indicator survives being read in greyscale or
 * by someone who cannot separate the emerald from the rose.
 */

const DOT: Record<LifecycleStep['state'], string> = {
  done: 'bg-slate-400 dark:bg-slate-500',
  current: '',            // tone decides — see TONE_DOT
  upcoming: 'bg-slate-200 dark:bg-slate-700',
  skipped: 'bg-transparent border border-dashed border-slate-300 dark:border-slate-600',
};

const TONE_DOT: Record<LifecycleStep['tone'], string> = {
  neutral: 'bg-slate-400 dark:bg-slate-500',
  active: 'bg-amber-500',
  good: 'bg-emerald-500',
  bad: 'bg-rose-500',
};

const TONE_TEXT: Record<LifecycleStep['tone'], string> = {
  neutral: 'text-slate-400 dark:text-slate-500',
  active: 'text-amber-700 dark:text-amber-400',
  good: 'text-emerald-700 dark:text-emerald-400',
  bad: 'text-rose-700 dark:text-rose-400',
};

export default function StatusSteps({ status }: { status: string }) {
  const steps = lifecycleSteps(status);
  const current = steps.find((s) => s.state === 'current');

  return (
    <ol
      className="flex items-center gap-1 leading-tight"
      aria-label={`Progress: ${current?.label ?? status}`}
    >
      {steps.map((s, i) => (
        <li key={s.key} className="flex items-center gap-1">
          {i > 0 && (
            <span
              aria-hidden
              className={`w-3 h-px shrink-0 ${
                s.state === 'upcoming'
                  ? 'bg-slate-200 dark:bg-slate-700'
                  : 'bg-slate-300 dark:bg-slate-600'
              }`}
            />
          )}
          <span
            aria-hidden
            className={`w-1.5 h-1.5 rounded-full shrink-0 ${
              s.state === 'current' ? TONE_DOT[s.tone] : DOT[s.state]
            }`}
          />
          <span
            className={`text-[10px] leading-tight whitespace-nowrap ${
              s.state === 'current'
                ? `font-semibold ${TONE_TEXT[s.tone]}`
                : s.state === 'done'
                  ? 'text-slate-500 dark:text-slate-400'
                  : s.state === 'skipped'
                    ? 'text-slate-300 dark:text-slate-600 line-through'
                    : 'text-slate-300 dark:text-slate-600'
            }`}
          >
            {s.label}
          </span>
        </li>
      ))}
    </ol>
  );
}

Props

PropTypeDefaultDescription
status*string—