async-button
FC-0017action-feedbackblockv1.0.0Receptuur: 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.
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); }