← alle entries

async-button

FC-0017action-feedbackblockv1.0.0

Receptuur: de bestaande knop met de async-toestandsmachine erin en vier gezichten die over elkaar wisselen — rust, bezig, gelukt, mislukt. De maat blijft staan tijdens het wisselen.

zelfde component — andere tokens
blocks/async-button/async-button.tsx
/**
 * AsyncButton — receptuur: de bestaande Button (FC-0003) met de
 * toestandsmachine van useAsyncAction (FC-0016) erin, en vier gezichten die
 * over elkaar heen wisselen: rust, bezig, gelukt, mislukt.
 *
 * Waarom een block en geen tweede knop-primitive: de knop bestond al. Dit is
 * samenstelling, geen kopie — het uiterlijk, de hit-marge en het
 * touch-down-gedrag komen onveranderd uit Button en usePress.
 *
 * De maat blijft staan tijdens het wisselen: alle gezichten liggen in
 * hetzelfde grid-vak, dus de knop springt niet als "Publiceren" verandert in
 * "Gepubliceerd".
 */

"use client";

import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { Button } from "../../primitives/ui/button";
import { useAsyncAction, type AsyncActionStatus } from "../../primitives/ui/use-async-action";
import { useSpringTransition } from "../../primitives/motion";

export interface AsyncButtonProps {
  /** De actie; mag een promise teruggeven. Gooit hij, dan toont de knop 'mislukt'. */
  onAction: () => unknown;
  children: React.ReactNode;
  bezigLabel?: React.ReactNode;
  geluktLabel?: React.ReactNode;
  misluktLabel?: React.ReactNode;
  resetAfter?: number;
  disabled?: boolean;
  variant?: "primary" | "ghost";
  onError?: (error: unknown) => void;
  className?: string;
}

function Spinner({ stil }: { stil: boolean }) {
  return (
    <motion.svg
      width="12"
      height="12"
      viewBox="0 0 12 12"
      fill="none"
      aria-hidden="true"
      animate={stil ? undefined : { rotate: 360 }}
      transition={stil ? undefined : { duration: 0.85, repeat: Infinity, ease: "linear" }}
    >
      <circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.22" />
      <path d="M10.5 6A4.5 4.5 0 0 0 6 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    </motion.svg>
  );
}

const Vinkje = (
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
    <path d="M2.6 6.3 4.9 8.6 9.4 3.6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
  </svg>
);

const Uitroep = (
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
    <path d="M6 2.9v3.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
    <path d="M6 9.05h.01" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
  </svg>
);

export function AsyncButton({
  onAction,
  children,
  bezigLabel,
  geluktLabel = "Klaar",
  misluktLabel = "Opnieuw proberen",
  resetAfter = 1400,
  disabled = false,
  variant = "primary",
  onError,
  className,
}: AsyncButtonProps) {
  const reduced = useReducedMotion();
  const { status, bezig, run } = useAsyncAction({ action: onAction, resetAfter, onError });
  const wissel = useSpringTransition("smooth", { mass: "content" });

  const gezichten: { key: AsyncActionStatus; inhoud: React.ReactNode; icoon: React.ReactNode }[] = [
    { key: "rust", inhoud: children, icoon: null },
    { key: "bezig", inhoud: bezigLabel ?? children, icoon: <Spinner stil={reduced === true || status !== "bezig"} /> },
    { key: "gelukt", inhoud: geluktLabel, icoon: Vinkje },
    { key: "mislukt", inhoud: misluktLabel, icoon: Uitroep },
  ];

  /* Het huidige gezicht is de toegankelijke naam; de rest is decor. */
  const huidig = gezichten.find((g) => g.key === status);

  return (
    <>
      <Button
        variant={variant}
        disabled={disabled}
        aria-busy={bezig || undefined}
        data-status={status}
        onPress={() => {
          if (!bezig) run();
        }}
        className={["fp-async", className].filter(Boolean).join(" ")}
      >
        <span aria-hidden="true" className="fp-async__stack">
          {gezichten.map((gezicht) => (
            <motion.span
              key={gezicht.key}
              className="fp-async__face"
              initial={false}
              animate={
                gezicht.key === status
                  ? { opacity: 1, y: 0, filter: "blur(0px)" }
                  : { opacity: 0, y: 3, filter: "blur(3px)" }
              }
              transition={wissel}
            >
              {gezicht.icoon}
              {gezicht.inhoud}
            </motion.span>
          ))}
        </span>
        {/* Onzichtbaar, maar wél de naam die een schermlezer voorleest. */}
        <span className="fp-sr-only">{huidig?.inhoud}</span>
      </Button>
      <span role="status" aria-live="polite" className="fp-sr-only">
        {status === "gelukt" ? geluktLabel : status === "mislukt" ? misluktLabel : ""}
      </span>
    </>
  );
}
blocks/async-button/async-button.css
/**
 * AsyncButton — stylesheet.
 * Uitsluitend tokens uit tokens/CONTRACT.md (contract v1).
 * Vereist primitives/ui/primitives.css (de knop zelf) ernaast.
 */

/* ── AsyncButton (block, maar leunt op de knop-primitive) ───────────────── */

.fp-async__stack { display: grid; place-items: center; }
.fp-async__face {
  grid-area: 1 / 1;
  display: flex; align-items: center; justify-content: center; gap: 0.375rem;
  white-space: nowrap;
}
.fp-btn[data-status="gelukt"] { color: var(--success); }
.fp-btn[data-status="mislukt"] { color: var(--danger); }
.fp-btn[data-status="bezig"] { color: var(--muted-foreground); }