A

atlas-web

Producción · hace 2 h

Zona de peligro

React + MotionOverlays

Suppression par appui long

Une boîte de dialogue de suppression dont le bouton n'agit que si vous le maintenez jusqu'à ce qu'il se remplisse.

Réglages

1500ms

Code

import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type AnimationPlaybackControls,
} from "framer-motion";
import { Check, Trash2 } from "lucide-react";

const spring = { type: "spring" as const, stiffness: 380, damping: 34 };

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

// Modal accesible: enfoca el primer control (o el marcado con data-autofocus), atrapa Tab, cierra con Esc y devuelve el foco
function useDialog(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    if (!open) return;
    const previous = document.activeElement as HTMLElement | null;
    const node = ref.current;
    const first =
      node?.querySelector<HTMLElement>("[data-autofocus]") ?? node?.querySelector<HTMLElement>(FOCUSABLE);
    first?.focus({ preventScroll: true });
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close.current();
        return;
      }
      if (e.key !== "Tab" || !node) return;
      const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
      if (!items.length) return;
      const firstItem = items[0];
      const lastItem = items[items.length - 1];
      if (!firstItem || !lastItem) return;
      if (!node.contains(document.activeElement)) {
        e.preventDefault();
        firstItem.focus();
      } else if (e.shiftKey && document.activeElement === firstItem) {
        e.preventDefault();
        lastItem.focus();
      } else if (!e.shiftKey && document.activeElement === lastItem) {
        e.preventDefault();
        firstItem.focus();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      previous?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

interface HoldToConfirmDialogProps {
  holdMs?: number;
  color?: string;
  label?: string;
  onConfirm?: () => void;
}

export default function HoldToConfirmDialog({
  holdMs = 1500,
  color = "#e5484d",
  label = "Maintenir pour supprimer",
  onConfirm,
}: HoldToConfirmDialogProps) {
  const [open, setOpen] = useState(false);
  const [holding, setHolding] = useState(false);
  const [done, setDone] = useState(false);
  const [deleted, setDeleted] = useState(false);
  const panel = useRef<HTMLDivElement>(null);
  const titleId = useId();
  const descId = useId();
  const reduce = useReducedMotion();
  const progress = useMotionValue(0);
  const clip = useTransform(progress, (p) => "inset(0 " + (100 - p * 100) + "% 0 0 round 999px)");
  const anim = useRef<AnimationPlaybackControls | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useDialog(panel, open, () => setOpen(false));

  useEffect(() => {
    if (open) return;
    anim.current?.stop();
    progress.set(0);
    setHolding(false);
    setDone(false);
  }, [open, progress]);
  useEffect(() => () => clearTimeout(timer.current), []);

  const start = () => {
    if (done) return;
    setHolding(true);
    anim.current?.stop();
    anim.current = animate(progress, 1, {
      duration: (holdMs / 1000) * (1 - progress.get()),
      ease: "linear",
      onComplete: () => {
        setDone(true);
        setHolding(false);
        navigator.vibrate?.(30);
        onConfirm?.();
        timer.current = setTimeout(() => {
          setOpen(false);
          setDeleted(true);
        }, 800);
      },
    });
  };
  const cancel = () => {
    if (done) return;
    setHolding(false);
    anim.current?.stop();
    anim.current = animate(progress, 0, { type: "spring", stiffness: 260, damping: 30 });
  };
  const text = done ? "Supprimé" : holding ? "Maintenez…" : label;
  const Icon = done ? Check : Trash2;

  return (
    <div className="relative grid min-h-[420px] w-full place-items-center bg-[#f5f5f7] p-5 text-[#1d1d1f] dark:bg-[#0b0b0c] dark:text-[#f5f5f7]">
      <div className="w-full max-w-[340px] rounded-[24px] border border-black/[0.06] bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_40px_-12px_rgba(0,0,0,0.12)] dark:border-white/[0.08] dark:bg-[#161618] dark:shadow-none">
        <AnimatePresence mode="wait" initial={false}>
          {deleted ? (
            <motion.div
              key="deleted"
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -6 }}
              className="flex items-center justify-between gap-3"
            >
              <p className="text-[14px] text-[#86868b]">Projet supprimé</p>
              <button
                type="button"
                onClick={() => setDeleted(false)}
                className="h-9 rounded-full bg-black/5 px-4 text-[13px] font-medium transition hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
              >
                Annuler
              </button>
            </motion.div>
          ) : (
            <motion.div key="project" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}>
              <div className="flex items-center gap-3">
                <span className="grid h-11 w-11 place-items-center rounded-[14px] bg-gradient-to-br from-[#6e8bff] via-[#7c5cff] to-[#c056f5] text-[16px] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.3),0_6px_16px_-6px_rgba(124,92,255,0.7)]">
                  A
                </span>
                <div className="min-w-0">
                  <p className="text-[16px] font-semibold tracking-[-0.01em]">atlas-web</p>
                  <p className="text-[13px] text-[#86868b]">Production · il y a 2 h</p>
                </div>
              </div>
              <div className="mt-5 flex items-center justify-between gap-3 border-t border-black/[0.06] pt-4 dark:border-white/[0.08]">
                <p className="text-[13px] text-[#86868b]">Zone de danger</p>
                <button
                  type="button"
                  aria-haspopup="dialog"
                  onClick={() => setOpen(true)}
                  className="inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-[13px] font-medium transition active:scale-[0.97]"
                  style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 10%, transparent)" }}
                >
                  <Trash2 className="h-4 w-4" strokeWidth={1.5} /> Supprimer
                </button>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <AnimatePresence>
        {open && (
          // fixed: el diálogo bloquea toda la ventana
          <motion.div
            key="overlay"
            className="fixed inset-0 z-50 grid place-items-center p-4"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.2 }}
          >
            <div aria-hidden onClick={() => setOpen(false)} className="absolute inset-0 bg-black/25 backdrop-blur-[6px] dark:bg-black/60" />
            <motion.div
              ref={panel}
              role="alertdialog"
              aria-modal="true"
              aria-labelledby={titleId}
              aria-describedby={descId}
              initial={{ opacity: 0, scale: 0.94, y: 12 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.97, y: 6 }}
              transition={reduce ? { duration: 0 } : spring}
              className="relative w-full max-w-[360px] rounded-[28px] border border-black/[0.06] bg-white p-5 sm:p-6 text-[#1d1d1f] shadow-[0_10px_30px_rgba(0,0,0,0.12),0_40px_100px_-20px_rgba(0,0,0,0.4)] dark:border-white/10 dark:bg-[#1a1a1d] dark:text-[#f5f5f7]"
            >
              <div className="flex items-center gap-3">
                <span
                  className="grid h-10 w-10 shrink-0 place-items-center rounded-full"
                  style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 12%, transparent)" }}
                >
                  <Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
                </span>
                <h2 id={titleId} className="text-[18px] font-semibold tracking-[-0.02em]">
                  Supprimer atlas-web ?
                </h2>
              </div>
              <p id={descId} className="mt-3 text-[14px] leading-relaxed text-[#6e6e73] dark:text-[#a1a1a6]">
                Les déploiements, les domaines et l’historique seront supprimés. Cette action est irréversible.
              </p>
              <motion.button
                type="button"
                aria-label={label + " (maintenir appuyé)"}
                onPointerDown={(e) => {
                  if (e.button === 0) start();
                }}
                onPointerUp={cancel}
                onPointerLeave={cancel}
                onPointerCancel={cancel}
                onContextMenu={(e) => e.preventDefault()}
                onKeyDown={(e) => {
                  if ((e.key === " " || e.key === "Enter") && !e.repeat) {
                    e.preventDefault();
                    start();
                  }
                }}
                onKeyUp={(e) => {
                  if (e.key === " " || e.key === "Enter") cancel();
                }}
                animate={{ scale: holding ? 0.97 : 1 }}
                transition={spring}
                className="mt-6 relative flex h-12 w-full touch-none select-none items-center justify-center gap-2 overflow-hidden rounded-full text-[14px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-[#1a1a1d]"
                style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 10%, transparent)" }}
              >
                <Icon className="h-4 w-4" strokeWidth={1.5} />
                {text}
                <motion.span
                  aria-hidden
                  className="absolute inset-0 flex items-center justify-center gap-2 text-white"
                  style={{ backgroundColor: color, clipPath: clip }}
                >
                  <Icon className="h-4 w-4" strokeWidth={1.5} />
                  {text}
                </motion.span>
              </motion.button>
              <button
                type="button"
                data-autofocus
                onClick={() => setOpen(false)}
                className="mt-2 h-12 w-full rounded-full text-[14px] font-medium text-[#1d1d1f] transition hover:bg-black/5 dark:text-[#f5f5f7] dark:hover:bg-white/10"
              >
                Annuler
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Prompt pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Overlays

Où l’utiliser

Un bouton à maintenir pour confirmer a du sens pour les actions irréversibles : supprimer un projet dans un tableau de bord d'hébergement, fermer un compte ou vider un panier enregistré. Cette seconde et demie laisse le temps de réfléchir et évite le clic malheureux. Au clavier, il suffit de maintenir Espace ou Entrée.

Évitez-le pour les actions courantes, il deviendrait pénible. Une seconde et demie suffit ; au-delà de deux, on croit que rien ne se passe. Le projet et les textes sont des exemples, et la vraie suppression reste à brancher de votre côté.