BLK-109Blocks

React ドラッグ&ドロップUIで要望を3段に仕分ける問い合わせ部品|並べ替えアニメーションで相談文に

React のドラッグ&ドロップUIと並べ替えアニメーションで、要望カードを3段に仕分けると相談文になる問い合わせ部品です。「お知らせを自分で更新」「予約フォーム」など12枚のカードを、未仕分けのトレイから「必須」「できれば」「今回は不要」の3段へドラッグで移すと、「【必須】…」「【できれば】…」「【今回は不要】…」の3行の相談文がその場で書き換わり、コピーボタンとフォームの hidden 欄(name="requirements")にも同じ文が入ります。上部の3段の件数と相談文の先読みで、仕分ける前から行き先と文の形が見えます。ドラッグ中は周りのカードが FLIP で滑らかに避けて置き場所を空け、スマホは長押し200msで持ち上がる(それまではスクロール優先)。カードを押すと3段のボタンが出て1回で移せ(下に入らなければ上へ、上下とも無理なら画面中央のパネルで開く)、移した直後は元の位置に「元に戻す」つきの通知を出し、移動先の段と件数を一瞬光らせます。キーボードは Enter でつかんで矢印キーで段と順番を変え、移動先を読み上げます。要望は1行ずつ足せて上限20枚。依存ライブラリなしの React + Tailwind で、<PriorityTray items lanes name onChange> として自社フォームへ写せます。prefers-reduced-motion では動きを切ってすぐ移動します。カードと会社は架空の表示サンプル。

プレビュー

BLK-109React ドラッグ&ドロップUI・並べ替えアニメーション(架空の制作会社)

入れたい機能を3段に仕分けると、相談文になる

ドラッグ(スマホは長押し)か、押して段を選びます。キーボードは Enter と矢印キー

仕分け済み 0/12

  • 必須

    0件

  • できれば

    0件

  • 今回は不要

    0件

相談文はこの形で入ります

  • 【必須】カードを移すとここに並びます
  • 【できれば】カードを移すとここに並びます
  • 【今回は不要】カードを移すとここに並びます
  1. 未仕分け12

    ここから選ぶ

  2. 必須0

    公開時に必ず入れたい

    • ここへ移す
  3. できれば0

    予算が合えば入れたい

    • ここへ移す
  4. 今回は不要0

    今回は見送る

    • ここへ移す

相談文 0字が一緒に送られます

X でシェア

説明の全文を読む

React のドラッグ&ドロップUIと並べ替えアニメーションで、要望カードを3段に仕分けると相談文になる問い合わせ部品です。「お知らせを自分で更新」「予約フォーム」など12枚のカードを、未仕分けのトレイから「必須」「できれば」「今回は不要」の3段へドラッグで移すと、「【必須】…」「【できれば】…」「【今回は不要】…」の3行の相談文がその場で書き換わり、コピーボタンとフォームの hidden 欄(name="requirements")にも同じ文が入ります。上部の3段の件数と相談文の先読みで、仕分ける前から行き先と文の形が見えます。ドラッグ中は周りのカードが FLIP で滑らかに避けて置き場所を空け、スマホは長押し200msで持ち上がる(それまではスクロール優先)。カードを押すと3段のボタンが出て1回で移せ(下に入らなければ上へ、上下とも無理なら画面中央のパネルで開く)、移した直後は元の位置に「元に戻す」つきの通知を出し、移動先の段と件数を一瞬光らせます。キーボードは Enter でつかんで矢印キーで段と順番を変え、移動先を読み上げます。要望は1行ずつ足せて上限20枚。依存ライブラリなしの React + Tailwind で、<PriorityTray items lanes name onChange> として自社フォームへ写せます。prefers-reduced-motion では動きを切ってすぐ移動します。カードと会社は架空の表示サンプル。

追加日:
2026-10-31
依存:
なし
tags
#form #inquiry #drag-and-drop #sort-animation #sortable #react #priority #sorting #flip-animation #accessibility #keyboard #touch #no-dependency #no-image
"use client";

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";

/**
 * 要望を3段に仕分けて相談文にする問い合わせ部品 / Requirements Priority Tray
 * 既存の two-step-plan-cta は「1問ずつラジオで選んで1文を作る」型なので、本ブロックは
 * 12〜20枚の要望カードを段(必須・できれば・今回は不要)へドラッグで並べ替える多項目の仕分け型にした。
 * 操作(ポインタ・長押し・キーボードのつかみ)と情報量(並び順まで相談文に残る)が別物。
 *
 * 汎用技法メモ:
 * - FLIP: 状態を変える直前に全カードの getBoundingClientRect を控え(First)、描画後に transform を外して
 *   新しい位置を測り(Last)、差分を translate で戻して(Invert)から transition で 0 へ戻す(Play)。
 *   控える値は transform 込みの見た目の位置なので、動いている最中に次の移動が来ても途切れずにつながる。
 * - ドラッグ中は、つかんだカードの DOM を元の位置に残して見えなくし(幅0・透明)、代わりの「置き場所」を
 *   移動先へ差し込む。カードを別の段へ描き直すとタッチの発生元の要素が外れ、touchmove が届かなくなって
 *   スクロールを止められないため。段への移動は指を離したときに1回だけ確定する。
 * - タッチは長押し200msでドラッグを始め、それまでに8px動いたらスクロールに譲る。始めた後は、親に付けた
 *   passive: false の touchmove で preventDefault してページが動かないようにする。
 * - 挿入位置は offsetLeft / offsetTop(transform の影響を受けない配置上の位置)で判定する。
 *   FLIP の途中で測っても、動いている見た目の位置に引きずられて行き来しない。
 * - ドラッグを始めた時点の各段の高さを min-height で保つ。カードが抜けた段が縮むと下の段が指の下からずれる。
 * - タップで移すと、カードは画面外の段へ行きがち。元の位置に短い通知(元に戻すつき)を出し、移動先の段と
 *   上の件数を一瞬だけ色で光らせて「どこへ移り、何が変わったか」を追えるようにする。
 * - 移動先メニューは開く前に上下の空きを測り、下に3項目が入らなければ上へ、上下とも足りなければ
 *   画面の縦中央に固定した選択パネルにする(下端は他の固定ボタンと重なりやすい)(追加スクロールなしで3択を見比べられる)。
 * - prefers-reduced-motion では FLIP・光らせる演出・通知の出現を行わず、すぐ移動・すぐ表示にする。
 * - 掲載している要望カードと会社は架空の表示サンプル。
 */

export type TrayItem = { id: string; label: string };
export type TrayLane = { id: string; label: string; hint: string };
type Order = Record<string, string[]>;
type Drag = { id: string; lane: string; index: number; w: number; h: number; gx: number; gy: number };

const TRAY = "tray";
const TRAY_LABEL = "未仕分け";
const MAX_LABEL = 24;

export const DEFAULT_LANES: TrayLane[] = [
  { id: "must", label: "必須", hint: "公開時に必ず入れたい" },
  { id: "nice", label: "できれば", hint: "予算が合えば入れたい" },
  { id: "skip", label: "今回は不要", hint: "今回は見送る" },
];

export const DEFAULT_ITEMS: TrayItem[] = [
  { id: "news", label: "お知らせを自分で更新" },
  { id: "booking", label: "予約フォーム" },
  { id: "english", label: "英語ページ" },
  { id: "recruit", label: "採用ページ" },
  { id: "blog", label: "ブログ" },
  { id: "mobile", label: "スマホ対応の見直し" },
  { id: "works", label: "施工事例の一覧" },
  { id: "faq", label: "よくある質問" },
  { id: "access", label: "地図とアクセス" },
  { id: "autoreply", label: "問い合わせの自動返信" },
  { id: "speed", label: "表示速度の改善" },
  { id: "photo", label: "写真の撮り直し" },
];

/** 段の並びから相談文を作る。1段1行で、空の段は「なし」と書き、未仕分けのカードは含めない */
export function buildRequestText(order: Order, items: TrayItem[], lanes: TrayLane[]) {
  const label = new Map(items.map((it) => [it.id, it.label]));
  return lanes
    .map((l) => {
      const names = (order[l.id] ?? []).map((id) => label.get(id)).filter(Boolean);
      return `【${l.label}】${names.length ? names.join("、") : "なし"}`;
    })
    .join("\n");
}

/** id を lane の index 番目へ移す(同じ段の中でも、別の段へも) */
export function moveItem(order: Order, id: string, lane: string, index: number): Order {
  const next: Order = {};
  for (const k of Object.keys(order)) next[k] = order[k].filter((x) => x !== id);
  const list = next[lane] ?? [];
  const at = Math.max(0, Math.min(index, list.length));
  next[lane] = [...list.slice(0, at), id, ...list.slice(at)];
  return next;
}

const locate = (order: Order, id: string) => {
  for (const k of Object.keys(order)) {
    const i = order[k].indexOf(id);
    if (i >= 0) return { lane: k, index: i };
  }
  return { lane: TRAY, index: 0 };
};

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706]";
const MARK: Record<number, string> = { 0: "bg-[#d97706]", 1: "bg-amber-300", 2: "bg-stone-300" };
const CARD_EDGE: Record<number, string> = {
  0: "border-l-[3px] border-l-[#d97706]",
  1: "border-l-[3px] border-l-amber-300",
  2: "border-l-[3px] border-l-stone-300 text-stone-500",
};

function Grip() {
  return (
    <svg aria-hidden="true" viewBox="0 0 8 14" className="h-3.5 w-2 shrink-0 fill-stone-400">
      {[2, 7, 12].map((y) => (
        <g key={y}>
          <circle cx="2" cy={y} r="1.1" />
          <circle cx="6" cy={y} r="1.1" />
        </g>
      ))}
    </svg>
  );
}

export type PriorityTrayProps = {
  /** 最初のカード(すべて未仕分けから始まる) */
  items?: TrayItem[];
  /** 仕分け先の段。3段を想定 */
  lanes?: TrayLane[];
  /** 相談文を入れる hidden 欄の name */
  name?: string;
  /** 相談文が変わるたびに呼ぶ */
  onChange?: (text: string, groups: Record<string, string[]>) => void;
  /** カードの上限 */
  max?: number;
};

export function PriorityTray({
  items: initialItems = DEFAULT_ITEMS,
  lanes = DEFAULT_LANES,
  name = "requirements",
  onChange,
  max = 20,
}: PriorityTrayProps) {
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const laneIds = useMemo(() => [TRAY, ...lanes.map((l) => l.id)], [lanes]);
  const laneName = (id: string) => (id === TRAY ? TRAY_LABEL : (lanes.find((l) => l.id === id)?.label ?? id));
  const initialOrder = useMemo<Order>(
    () => Object.fromEntries([[TRAY, initialItems.map((it) => it.id)], ...lanes.map((l) => [l.id, [] as string[]])]),
    [initialItems, lanes],
  );

  const [items, setItems] = useState<TrayItem[]>(initialItems);
  const [order, setOrder] = useState<Order>(initialOrder);
  const [drag, setDrag] = useState<Drag | null>(null);
  const [pressing, setPressing] = useState<string | null>(null);
  const [grab, setGrab] = useState<{ id: string; before: Order } | null>(null);
  const [menu, setMenu] = useState<{ id: string; right: boolean; place: "down" | "up" | "sheet" } | null>(null);
  const [toast, setToast] = useState<{ n: number; id: string; text: string; top: number; left: number; before: Order } | null>(null);
  const [status, setStatus] = useState("");
  const [draft, setDraft] = useState("");
  const [addError, setAddError] = useState("");
  const [copied, setCopied] = useState<"ok" | "fail" | null>(null);
  const [seq, setSeq] = useState(1);

  const boardRef = useRef<HTMLDivElement>(null);
  const ghostRef = useRef<HTMLDivElement>(null);
  const textRef = useRef<HTMLTextAreaElement>(null);
  const nodes = useRef(new Map<string, HTMLElement>());
  const snap = useRef<Map<string, DOMRect> | null>(null);
  const focusId = useRef<string | null>(null);
  const focusScroll = useRef(true);
  const landing = useRef<{ lane: string; id: string } | null>(null);
  const sumRef = useRef<HTMLDivElement>(null);
  const toastRef = useRef<HTMLDivElement>(null);
  const toastTimer = useRef(0);
  const toastSeq = useRef(0);
  const suppressClick = useRef(false);
  const dragRef = useRef<Drag | null>(null);
  const press = useRef<{
    id: string;
    touch: boolean;
    x0: number;
    y0: number;
    x: number;
    y: number;
    offX: number;
    offY: number;
    w: number;
    h: number;
    started: boolean;
    timer: number;
    raf: number;
    move: (e: PointerEvent) => void;
    up: (e: PointerEvent) => void;
    cancel: () => void;
  } | null>(null);

  const label = useMemo(() => new Map(items.map((it) => [it.id, it.label])), [items]);
  const text = useMemo(() => buildRequestText(order, items, lanes), [order, items, lanes]);
  const groups = useMemo(
    () => Object.fromEntries(lanes.map((l) => [l.id, order[l.id].map((id) => label.get(id) ?? "")])),
    [order, lanes, label],
  );
  const sorted = lanes.reduce((n, l) => n + order[l.id].length, 0);

  useEffect(() => {
    onChange?.(text, groups);
  }, [text, groups, onChange]);

  // ---- FLIP ----
  const snapshot = () => {
    if (reducedMotion()) return;
    const m = new Map<string, DOMRect>();
    nodes.current.forEach((el, k) => m.set(k, el.getBoundingClientRect()));
    snap.current = m;
  };
  useLayoutEffect(() => {
    const prev = snap.current;
    if (prev) {
      snap.current = null;
      const els = [...nodes.current].filter(([k]) => prev.has(k));
      els.forEach(([, el]) => {
        el.style.transition = "none";
        el.style.transform = "";
      });
      const moves = els
        .map(([k, el]) => {
          const a = prev.get(k)!;
          const b = el.getBoundingClientRect();
          return { el, dx: a.left - b.left, dy: a.top - b.top };
        })
        .filter((m) => Math.abs(m.dx) > 0.5 || Math.abs(m.dy) > 0.5);
      moves.forEach(({ el, dx, dy }) => (el.style.transform = `translate(${dx}px, ${dy}px)`));
      if (moves.length) {
        void boardRef.current?.offsetWidth;
        moves.forEach(({ el }) => {
          el.style.transition = "transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1)";
          el.style.transform = "";
        });
      }
    }
    // 移った先の段・カード・上の件数を一瞬だけ光らせる(どこへ移ったかを追えるように)
    const land = landing.current;
    if (land) {
      landing.current = null;
      if (!reducedMotion()) {
        const ease = "cubic-bezier(0.2, 0.8, 0.2, 1)";
        boardRef.current
          ?.querySelector(`[data-lane="${land.lane}"]`)
          ?.animate([{ backgroundColor: "#fde68a" }, { backgroundColor: "rgba(254, 243, 199, 0)" }], { duration: 900, easing: "ease-out" });
        nodes.current
          .get(land.id)
          ?.querySelector("button")
          ?.animate([{ boxShadow: "0 0 0 3px #d97706" }, { boxShadow: "0 0 0 3px rgba(217, 119, 6, 0)" }], { duration: 900, delay: 160, easing: "ease-out", fill: "backwards" });
        sumRef.current
          ?.querySelector(`[data-sum="${land.lane}"] [data-count]`)
          ?.animate([{ transform: "scale(1.4)", color: "#d97706" }, { transform: "none" }], { duration: 420, easing: ease });
      }
    }
    if (focusId.current) {
      nodes.current.get(focusId.current)?.querySelector("button")?.focus({ preventScroll: !focusScroll.current });
      focusId.current = null;
      focusScroll.current = true;
    }
  });
  const track = (key: string, el: HTMLElement | null) => {
    if (!el) return;
    nodes.current.set(key, el);
    return () => {
      if (nodes.current.get(key) === el) nodes.current.delete(key);
    };
  };

  const placeText = (lane: string, index: number) => `${laneName(lane)}の${index + 1}番目`;

  // ---- ポインタのドラッグ ----
  const hitTest = (x: number, y: number) => {
    const board = boardRef.current;
    const p = press.current;
    const d = dragRef.current;
    if (!board || !p || !d) return;
    const lists = [...board.querySelectorAll<HTMLElement>("[data-lane-list]")];
    let best = lists[0];
    let dist = Infinity;
    for (const el of lists) {
      const r = (el.closest("[data-lane]") as HTMLElement).getBoundingClientRect();
      const dd = y < r.top ? r.top - y : y > r.bottom ? y - r.bottom : 0;
      if (dd < dist) {
        dist = dd;
        best = el;
      }
    }
    const lane = best.dataset.laneList!;
    const lr = best.getBoundingClientRect();
    const px = x - lr.left;
    const py = y - lr.top;
    const cards = [...best.querySelectorAll<HTMLElement>(":scope > [data-card]")];
    let index = cards.length;
    for (let i = 0; i < cards.length; i++) {
      const c = cards[i];
      if (py < c.offsetTop || (py <= c.offsetTop + c.offsetHeight && px < c.offsetLeft + c.offsetWidth / 2)) {
        index = i;
        break;
      }
    }
    if (lane === d.lane && index === d.index) return;
    snapshot();
    const next = { ...d, lane, index };
    dragRef.current = next;
    setDrag(next);
  };

  const placeGhost = (x: number, y: number) => {
    const p = press.current;
    const board = boardRef.current;
    if (!p || !board || !ghostRef.current) return;
    const b = board.getBoundingClientRect();
    ghostRef.current.style.transform = `translate3d(${x - b.left - p.offX}px, ${y - b.top - p.offY}px, 0) rotate(-1.5deg)`;
  };

  const endPress = (commit: boolean) => {
    const p = press.current;
    if (!p) return;
    window.clearTimeout(p.timer);
    cancelAnimationFrame(p.raf);
    window.removeEventListener("pointermove", p.move);
    window.removeEventListener("pointerup", p.up);
    window.removeEventListener("pointercancel", p.cancel);
    press.current = null;
    setPressing(null);
    boardRef.current?.querySelectorAll<HTMLElement>("[data-lane-list]").forEach((el) => (el.style.minHeight = ""));
    const d = dragRef.current;
    dragRef.current = null;
    if (!p.started || !d) return;
    window.setTimeout(() => (suppressClick.current = false), 0);
    snapshot();
    if (commit) {
      landing.current = { lane: d.lane, id: d.id };
      setOrder((o) => moveItem(o, d.id, d.lane, d.index));
      setStatus(`「${label.get(d.id)}」を${placeText(d.lane, d.index)}へ移しました`);
    } else {
      setStatus(`「${label.get(d.id)}」の移動を取り消しました`);
    }
    setDrag(null);
  };

  const begin = () => {
    const p = press.current;
    const board = boardRef.current;
    if (!p || !board) return;
    p.started = true;
    suppressClick.current = true;
    setMenu(null);
    setPressing(null);
    const at = locate(order, p.id);
    const b = board.getBoundingClientRect();
    // ドラッグ中は段の高さを縮めない(カードが抜けた段が縮むと、下の段が指の下からずれるため)
    board.querySelectorAll<HTMLElement>("[data-lane-list]").forEach((el) => (el.style.minHeight = `${el.offsetHeight}px`));
    const d: Drag = { id: p.id, lane: at.lane, index: at.index, w: p.w, h: p.h, gx: p.x - b.left - p.offX, gy: p.y - b.top - p.offY };
    snapshot();
    dragRef.current = d;
    setDrag(d);
    setStatus(`「${label.get(p.id)}」を移動しています。段の上で離すと置きます`);
    const tick = () => {
      const q = press.current;
      if (!q) return;
      const edge = 64;
      const dy = q.y < edge ? -Math.ceil((edge - q.y) / 6) : q.y > window.innerHeight - edge ? Math.ceil((q.y - window.innerHeight + edge) / 6) : 0;
      if (dy) {
        window.scrollBy(0, dy);
        placeGhost(q.x, q.y);
        hitTest(q.x, q.y);
      }
      q.raf = requestAnimationFrame(tick);
    };
    p.raf = requestAnimationFrame(tick);
  };

  const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, id: string) => {
    if (e.button !== 0 || grab || press.current) return;
    setToast(null);
    const li = nodes.current.get(id);
    if (!li) return;
    const r = li.getBoundingClientRect();
    const move = (ev: PointerEvent) => {
      const p = press.current;
      if (!p) return;
      p.x = ev.clientX;
      p.y = ev.clientY;
      const moved = Math.hypot(p.x - p.x0, p.y - p.y0);
      if (!p.started) {
        if (p.touch && moved > 8) endPress(false);
        else if (!p.touch && moved > 4) begin();
        return;
      }
      placeGhost(p.x, p.y);
      hitTest(p.x, p.y);
    };
    const up = () => endPress(true);
    const cancel = () => endPress(false);
    press.current = {
      id,
      touch: e.pointerType !== "mouse",
      x0: e.clientX,
      y0: e.clientY,
      x: e.clientX,
      y: e.clientY,
      offX: e.clientX - r.left,
      offY: e.clientY - r.top,
      w: r.width,
      h: r.height,
      started: false,
      timer: 0,
      raf: 0,
      move,
      up,
      cancel,
    };
    if (e.pointerType !== "mouse") {
      setPressing(id);
      press.current.timer = window.setTimeout(begin, 200);
    }
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", cancel);
  };

  // ドラッグを始めた後だけ、タッチのスクロールを止める(始める前はスクロールを優先する)
  useEffect(() => {
    const board = boardRef.current;
    if (!board) return;
    const stop = (e: TouchEvent) => {
      if (press.current?.started) e.preventDefault();
    };
    board.addEventListener("touchmove", stop, { passive: false });
    return () => board.removeEventListener("touchmove", stop);
  }, []);

  useEffect(() => {
    if (!drag) return;
    const esc = (e: KeyboardEvent) => e.key === "Escape" && endPress(false);
    window.addEventListener("keydown", esc);
    return () => window.removeEventListener("keydown", esc);
  });

  // ---- タップで段を選ぶ ----
  const toggleMenu = (id: string) => {
    if (suppressClick.current) return;
    if (menu?.id === id) return setMenu(null);
    const li = nodes.current.get(id);
    const b = boardRef.current?.getBoundingClientRect();
    const r = li?.getBoundingClientRect();
    // 開く前に上下の空きを測る。3項目(各44px+枠と余白)が下に入らなければ上へ、上下とも無理なら画面の縦中央に固定したパネルへ
    const need = lanes.length * 44 + 16;
    const vh = window.visualViewport?.height ?? window.innerHeight;
    const place = !r || vh - r.bottom >= need ? "down" : r.top >= need ? "up" : "sheet";
    setMenu({ id, right: !!(b && r && r.left - b.left + 224 > b.width), place });
  };
  useEffect(() => {
    if (!menu) return;
    const close = (e: PointerEvent) => {
      const li = nodes.current.get(menu.id);
      if (li && !li.contains(e.target as Node)) setMenu(null);
    };
    document.addEventListener("pointerdown", close);
    return () => document.removeEventListener("pointerdown", close);
  }, [menu]);

  const sendTo = (id: string, lane: string, byKeyboard: boolean) => {
    const index = order[lane].length;
    const b = boardRef.current?.getBoundingClientRect();
    const r = nodes.current.get(id)?.getBoundingClientRect();
    const before = order;
    snapshot();
    setOrder((o) => moveItem(o, id, lane, index));
    setMenu(null);
    focusId.current = id;
    // 指で選んだときは画面を飛ばさない(通知で行き先を伝える)。キーボードは移った先へフォーカスごと移る
    focusScroll.current = byKeyboard;
    landing.current = { lane, id };
    if (b && r) {
      // 元の位置の下に出す。画面の下端に近いカードなら上に出す(通知が画面外にならないように)
      const vh = window.visualViewport?.height ?? window.innerHeight;
      setToast({
        n: ++toastSeq.current,
        id,
        text: `「${label.get(id)}」を${laneName(lane)}へ移動しました`,
        top: vh - r.bottom < 72 ? r.top - b.top - 62 : r.bottom - b.top + 6,
        left: Math.max(8, Math.min(r.left - b.left, b.width - 288)),
        before,
      });
    }
    setStatus(`「${label.get(id)}」を${placeText(lane, index)}へ移しました`);
  };

  // 通知: 出すときだけ短く現れ(動きを減らす設定ではすぐ表示)、4秒で消える。指やフォーカスが乗っている間は消さない。
  // 下のカードを隠して押せなくしないよう、通知の本体は pointer-events-none にし「元に戻す」だけ押せる
  const armToast = () => {
    window.clearTimeout(toastTimer.current);
    toastTimer.current = window.setTimeout(() => setToast(null), 4000);
  };
  useEffect(() => {
    if (!toast) return;
    if (!reducedMotion()) {
      toastRef.current?.animate([{ opacity: 0, transform: "translateY(-6px)" }, { opacity: 1, transform: "none" }], { duration: 180, easing: "ease-out" });
    }
    armToast();
    // 幅が変わると元の位置がずれるので閉じる
    const close = () => setToast(null);
    window.addEventListener("resize", close);
    return () => {
      window.clearTimeout(toastTimer.current);
      window.removeEventListener("resize", close);
    };
  }, [toast]);
  const undo = () => {
    if (!toast) return;
    const back = locate(toast.before, toast.id);
    snapshot();
    setOrder(toast.before);
    landing.current = { lane: back.lane, id: toast.id };
    focusId.current = toast.id;
    setStatus(`「${label.get(toast.id)}」を${laneName(back.lane)}へ戻しました`);
    setToast(null);
  };

  // ---- キーボード ----
  const onCardKey = (e: ReactKeyboardEvent<HTMLButtonElement>, id: string) => {
    const k = e.key;
    if (grab?.id !== id) {
      if (k === "Enter" || k === " ") {
        e.preventDefault();
        setMenu(null);
        setToast(null);
        setGrab({ id, before: order });
        const at = locate(order, id);
        setStatus(`「${label.get(id)}」をつかみました(${placeText(at.lane, at.index)})。↑↓で段、←→で段の中を移動し、Enterで置きます`);
      } else if (k === "Escape" && menu) setMenu(null);
      return;
    }
    if (k === "Enter" || k === " ") {
      e.preventDefault();
      const at = locate(order, id);
      setGrab(null);
      setStatus(`「${label.get(id)}」を${placeText(at.lane, at.index)}に置きました`);
      return;
    }
    if (k === "Escape") {
      e.preventDefault();
      snapshot();
      setOrder(grab.before);
      setGrab(null);
      focusId.current = id;
      setStatus(`「${label.get(id)}」の移動を取り消しました`);
      return;
    }
    const at = locate(order, id);
    let lane = at.lane;
    let index = at.index;
    if (k === "ArrowUp" || k === "ArrowDown") {
      const li = laneIds.indexOf(at.lane) + (k === "ArrowUp" ? -1 : 1);
      if (li < 0 || li >= laneIds.length) return e.preventDefault();
      lane = laneIds[li];
      index = Math.min(at.index, order[lane].length);
    } else if (k === "ArrowLeft" || k === "ArrowRight") {
      index = at.index + (k === "ArrowLeft" ? -1 : 1);
      if (index < 0 || index >= order[lane].length) return e.preventDefault();
    } else return;
    e.preventDefault();
    snapshot();
    if (lane !== at.lane) landing.current = { lane, id };
    setOrder((o) => moveItem(o, id, lane, index));
    focusId.current = id;
    setStatus(`${placeText(lane, index)}`);
  };

  // ---- 追加・コピー・やり直し ----
  const total = items.length;
  const add = () => {
    const v = draft.normalize("NFKC").replace(/\s+/g, " ").trim();
    if (!v) return setAddError("要望を1行で入力してください");
    if (total >= max) return setAddError(`カードは${max}枚までです`);
    if ([...v].length > MAX_LABEL) return setAddError(`${MAX_LABEL}字以内にしてください(いま${[...v].length}字)`);
    if (items.some((it) => it.label === v)) return setAddError(`「${v}」は既にあります`);
    const id = `add-${seq}`;
    snapshot();
    setToast(null);
    setSeq((n) => n + 1);
    setItems((xs) => [...xs, { id, label: v }]);
    setOrder((o) => ({ ...o, [TRAY]: [...o[TRAY], id] }));
    setDraft("");
    setAddError("");
    setStatus(`「${v}」を未仕分けに追加しました(${total + 1}/${max}枚)`);
  };
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied("ok");
    } catch {
      setCopied("fail");
      textRef.current?.focus();
      textRef.current?.select();
    }
  };
  const reset = () => {
    snapshot();
    setItems(initialItems);
    setOrder(initialOrder);
    setGrab(null);
    setMenu(null);
    setToast(null);
    setCopied(null);
    setStatus("最初の並びに戻しました");
  };

  // ---- 描画 ----
  const card = (id: string, laneIdx: number) => {
    const hidden = drag?.id === id;
    const grabbed = grab?.id === id;
    const open = menu?.id === id;
    const cur = locate(order, id).lane;
    return (
      <li
        key={id}
        ref={(el) => track(id, el)}
        data-card={hidden ? undefined : id}
        className={hidden ? "pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0" : `relative ${open || grabbed ? "z-20" : ""}`}
      >
        <button
          type="button"
          data-id={id}
          aria-expanded={open}
          aria-describedby={`${uid}-how`}
          onPointerDown={(e) => onPointerDown(e, id)}
          onClick={() => toggleMenu(id)}
          onKeyDown={(e) => onCardKey(e, id)}
          onKeyUp={(e) => e.key === " " && e.preventDefault()}
          onBlur={(e) => {
            if (grabbed && e.relatedTarget && !nodes.current.get(id)?.contains(e.relatedTarget as Node)) setGrab(null);
          }}
          onContextMenu={(e) => e.preventDefault()}
          className={`card flex min-h-11 cursor-grab select-none items-center gap-2 border bg-white px-3 text-left text-[15px] font-medium leading-snug [-webkit-touch-callout:none] transition-[transform,border-color] duration-200 hover:border-stone-500 active:cursor-grabbing motion-reduce:transition-none ${
            laneIdx > 0 ? CARD_EDGE[laneIdx - 1] : ""
          } ${grabbed ? "border-[#d97706] bg-amber-50 shadow-[0_0_0_2px_#d97706]" : open ? "border-stone-900" : "border-stone-300"} ${
            pressing === id ? "scale-[0.96]" : ""
          } ${focusRing}`}
        >
          <Grip />
          <span>{label.get(id)}</span>
        </button>
        {open && (
          <div
            role="group"
            aria-label={`「${label.get(id)}」の移動先`}
            onKeyDown={(e) => {
              if (e.key === "Escape") {
                setMenu(null);
                focusId.current = id;
                setStatus("");
              }
            }}
            data-place={menu.place}
            className={`z-30 border border-stone-900 bg-white shadow-[0_10px_24px_-12px_rgba(0,0,0,0.35)] ${
              menu.place === "sheet"
                ? "fixed inset-x-3 top-1/2 z-50 -translate-y-1/2 shadow-[0_18px_40px_-12px_rgba(0,0,0,0.45)]"
                : `absolute w-56 ${menu.place === "up" ? "bottom-full mb-1.5" : "top-full mt-1.5"} ${menu.right ? "right-0" : "left-0"}`
            }`}
          >
            {menu.place === "sheet" && (
              <p className="border-b border-stone-200 px-3 py-2.5 text-[13px] text-stone-600">「{label.get(id)}」の移動先</p>
            )}
            {lanes.map((l, i) => (
              <button
                key={l.id}
                type="button"
                data-move={l.id}
                disabled={cur === l.id}
                onClick={(e) => sendTo(id, l.id, e.detail === 0)}
                className={`flex min-h-11 w-full items-center gap-2.5 border-b border-stone-200 px-3 text-left text-[15px] font-bold last:border-b-0 hover:bg-amber-50 disabled:cursor-default disabled:bg-stone-50 disabled:text-stone-400 ${focusRing} focus-visible:-outline-offset-2`}
              >
                <span aria-hidden="true" className={`h-2.5 w-2.5 shrink-0 ${MARK[i]}`} />
                {l.label}
                {cur === l.id && <span className="ml-auto text-xs font-medium">いまの段</span>}
              </button>
            ))}
          </div>
        )}
      </li>
    );
  };

  const placeholder = drag && (
    <li
      key="__ph"
      ref={(el) => track("__ph", el)}
      aria-hidden="true"
      className="border-2 border-dashed border-[#d97706] bg-amber-50/70"
      style={{ width: drag.w, height: drag.h }}
    />
  );

  const laneBody = (laneId: string, laneIdx: number) => {
    const list = order[laneId];
    const out: ReactNode[] = [];
    let v = 0;
    for (const id of list) {
      if (drag && id === drag.id) {
        out.push(card(id, laneIdx));
        continue;
      }
      if (drag && drag.lane === laneId && drag.index === v) out.push(placeholder);
      out.push(card(id, laneIdx));
      v++;
    }
    if (drag && drag.lane === laneId && drag.index >= v) out.push(placeholder);
    const empty = v === 0 && !(drag && drag.lane === laneId);
    return (
      <ul data-lane-list={laneId} aria-label={laneName(laneId)} className="relative flex min-h-11 flex-wrap content-start gap-2">
        {out}
        {empty && (
          <li className="flex min-h-11 flex-1 items-center border border-dashed border-stone-300 px-3 text-[13px] text-stone-500">
            {laneId === TRAY ? "すべて仕分けました" : "ここへ移す"}
          </li>
        )}
      </ul>
    );
  };

  const counts = lanes.map((l) => order[l.id].length);

  // 相談文の欄は内容の行数に合わせて伸ばす(欄内スクロールなしでコピー前に全文を確かめられる)
  useLayoutEffect(() => {
    const el = textRef.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = `${el.scrollHeight + 2}px`;
  }, [text]);

  return (
    <div className="@container text-stone-900">
      <p id={`${uid}-how`} className="px-5 pb-4 text-[14px] leading-relaxed text-stone-600 sm:px-8">
        ドラッグ(スマホは長押し)か、押して段を選びます。キーボードは Enter と矢印キー
      </p>

      {/* 内訳の帯(幅は件数の比)・3段の件数・相談文の先読み。未仕分けでも行き先と文の形が見える */}
      <div ref={sumRef} className="border-t border-stone-200 px-5 py-4 sm:px-8">
        <div className="flex items-center gap-4">
          <div aria-hidden="true" className="flex h-2 min-w-0 flex-1 bg-stone-100">
            {counts.map((c, i) => (
              <span key={lanes[i].id} className={MARK[i]} style={{ flexGrow: c, flexBasis: 0 }} />
            ))}
            <span style={{ flexGrow: order[TRAY].length, flexBasis: 0 }} />
          </div>
          <p className="font-display shrink-0 text-[13px] tabular-nums text-stone-600">
            仕分け済み <span className="font-bold text-stone-900">{sorted}</span>/{total}
          </p>
        </div>
        <div className="mt-3 grid gap-3 @3xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
          <ul aria-label="段ごとの件数" className="grid grid-cols-3 divide-x divide-stone-200 border border-stone-200 bg-white">
            {lanes.map((l, i) => (
              <li key={l.id} data-sum={l.id} className="min-w-0 px-3 py-2.5">
                <span aria-hidden="true" className={`block h-1 w-7 ${MARK[i]}`} />
                <p className="mt-2 truncate text-[13px] font-bold">{l.label}</p>
                <p className="mt-0.5 flex items-baseline gap-1">
                  <span data-count className="font-display inline-block text-[1.75rem] font-semibold leading-none tabular-nums">
                    {counts[i]}
                  </span>
                  <span className="text-[12px] text-stone-500">件</span>
                </p>
              </li>
            ))}
          </ul>
          <div className="min-w-0 border-l-[3px] border-stone-900 bg-stone-50 px-3 py-2.5">
            <p className="text-[12px] font-bold text-stone-500">相談文はこの形で入ります</p>
            <ul className="mt-1 space-y-0.5 text-[14px] leading-snug">
              {lanes.map((l) => (
                <li key={l.id} className="truncate">
                  <span className="font-bold">【{l.label}】</span>
                  {order[l.id].length ? (
                    order[l.id].map((id) => label.get(id)).join("、")
                  ) : (
                    <span className="text-stone-400">カードを移すとここに並びます</span>
                  )}
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>

      <div ref={boardRef} className="relative" data-dragging={drag ? "" : undefined}>
        <ol className="divide-y divide-stone-200 border-y border-stone-200">
          {laneIds.map((id, i) => {
            const lane = lanes[i - 1];
            const over = drag?.lane === id;
            return (
              <li
                key={id}
                data-lane={id}
                className={`grid gap-x-6 gap-y-2 px-5 py-4 @xl:grid-cols-[9.5rem_minmax(0,1fr)] sm:px-8 ${
                  over ? "bg-amber-50/80" : i === 0 ? "bg-stone-50/70" : "bg-white"
                }`}
              >
                <div className="flex items-baseline gap-2 @xl:block">
                  <p className="flex items-center gap-2 text-[15px] font-bold">
                    {i === 0 ? (
                      <span aria-hidden="true" className="h-2.5 w-2.5 shrink-0 border border-stone-400" />
                    ) : (
                      <span aria-hidden="true" className={`h-2.5 w-2.5 shrink-0 ${MARK[i - 1]}`} />
                    )}
                    {laneName(id)}
                    <span className="font-display text-[13px] font-semibold tabular-nums text-stone-500">{order[id].length}</span>
                  </p>
                  <p className="text-[13px] leading-snug text-stone-500 @xl:mt-1 @xl:pl-[1.125rem]">
                    {i === 0 ? "ここから選ぶ" : lane.hint}
                  </p>
                </div>
                {laneBody(id, i)}
              </li>
            );
          })}
        </ol>

        {drag && (
          <div
            ref={ghostRef}
            aria-hidden="true"
            className="pointer-events-none absolute left-0 top-0 z-40 flex items-center gap-2 border border-[#d97706] bg-white px-3 text-[15px] font-medium shadow-[0_14px_30px_-12px_rgba(0,0,0,0.4)]"
            style={{ width: drag.w, height: drag.h, transform: `translate3d(${drag.gx}px, ${drag.gy}px, 0) rotate(-1.5deg)` }}
          >
            <Grip />
            <span className="truncate">{label.get(drag.id)}</span>
          </div>
        )}

        {toast && (
          <div
            key={toast.n}
            ref={toastRef}
            data-toast
            onPointerEnter={() => window.clearTimeout(toastTimer.current)}
            onPointerLeave={armToast}
            onFocus={() => window.clearTimeout(toastTimer.current)}
            onBlur={armToast}
            className="pointer-events-none absolute z-30 flex w-[17.5rem] max-w-[calc(100%-1rem)] items-center gap-3 bg-stone-900 py-2 pl-3 pr-2 text-[13px] leading-snug text-white shadow-[0_12px_28px_-12px_rgba(0,0,0,0.5)]"
            style={{ top: toast.top, left: toast.left }}
          >
            <span aria-hidden="true" className="h-2 w-2 shrink-0 bg-[#f59e0b]" />
            <span className="min-w-0 flex-1">{toast.text}</span>
            <button
              type="button"
              onClick={undo}
              className={`undo-move pointer-events-auto min-h-9 shrink-0 px-2 font-bold text-amber-300 underline underline-offset-4 hover:text-amber-200 ${focusRing}`}
            >
              元に戻す
            </button>
          </div>
        )}
      </div>

      <p role="status" aria-live="polite" className="min-h-11 px-5 py-3 text-[14px] text-stone-700 sm:px-8">
        {status}
      </p>

      {/* カードの追加 */}
      <div className="border-t border-stone-200 px-5 py-5 sm:px-8">
        <label htmlFor={`${uid}-add`} className="text-[14px] font-bold">
          要望を足す
          <span className="font-display ml-2 font-semibold tabular-nums text-stone-500">
            {total}/{max}枚
          </span>
        </label>
        <div className="mt-2 flex gap-2">
          <input
            id={`${uid}-add`}
            value={draft}
            onChange={(e) => {
              setDraft(e.target.value);
              setAddError("");
            }}
            onKeyDown={(e) => {
              if (e.key === "Enter" && !e.nativeEvent.isComposing) {
                e.preventDefault();
                add();
              }
            }}
            placeholder="例: 会員向けページ"
            maxLength={40}
            disabled={total >= max}
            aria-invalid={!!addError}
            aria-describedby={`${uid}-add-msg`}
            className={`add-input h-11 min-w-0 flex-1 border border-stone-300 bg-white px-3 text-base placeholder:text-stone-400 disabled:bg-stone-50 ${focusRing}`}
          />
          <button
            type="button"
            onClick={add}
            disabled={total >= max}
            className={`add-card h-11 shrink-0 border border-stone-900 bg-white px-4 text-[15px] font-bold hover:bg-stone-50 disabled:border-stone-300 disabled:text-stone-400 ${focusRing}`}
          >
            追加
          </button>
        </div>
        <p id={`${uid}-add-msg`} className="mt-1.5 min-h-5 text-[13px] text-[#b45309]">
          {addError || (total >= max ? `上限の${max}枚に達しました` : "")}
        </p>
      </div>

      {/* 相談文 */}
      <div className="border-t border-stone-200 bg-stone-50/60 px-5 py-5 sm:px-8">
        <div className="flex flex-wrap items-baseline justify-between gap-2">
          <label htmlFor={`${uid}-text`} className="text-[14px] font-bold">
            相談文(問い合わせに入ります)
          </label>
          <button type="button" onClick={reset} className={`reset text-[13px] text-stone-600 underline underline-offset-4 hover:text-stone-900 ${focusRing}`}>
            最初の並びに戻す
          </button>
        </div>
        <div className="mt-2 flex flex-col gap-2 @lg:flex-row @lg:items-start">
          <textarea
            id={`${uid}-text`}
            ref={textRef}
            readOnly
            value={text}
            rows={3}
            className={`summary min-h-24 w-full resize-none @lg:flex-1 overflow-hidden border border-stone-300 bg-white p-3 text-[15px] leading-relaxed text-stone-900 ${focusRing}`}
          />
          <button
            type="button"
            onClick={copy}
            className={`copy-text min-h-11 shrink-0 bg-stone-900 px-6 text-[15px] font-bold text-white transition-colors hover:bg-amber-800 active:bg-amber-900 motion-reduce:transition-none @lg:w-28 ${focusRing}`}
          >
            {copied === "ok" ? "コピー済み" : "コピー"}
          </button>
        </div>
        <p aria-live="polite" className="mt-1.5 min-h-5 text-[13px] text-stone-600">
          {copied === "ok" && "相談文をコピーしました。メールやチャットにそのまま貼れます"}
          {copied === "fail" && "自動でコピーできませんでした。欄の文を選択したので、そのままコピーしてください"}
        </p>
        <input type="hidden" name={name} value={text} />
      </div>
    </div>
  );
}

// ---- 見本: 架空の制作会社の問い合わせ欄 ----
export default function TierRankingBoard({ headingTag }: { headingTag?: "h1" | "h2" | "p" }) {
  const Heading = headingTag ?? "h2";
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [sent, setSent] = useState("");
  const [len, setLen] = useState(0);
  const onChange = useMemo(() => (t: string) => setLen([...t].length), []);
  const field = `mt-2 h-11 w-full border border-stone-300 bg-white px-3 text-base ${focusRing}`;

  return (
    <section className="mx-auto w-full max-w-5xl border border-stone-200 bg-white">
      <form
        onSubmit={(e) => {
          e.preventDefault();
          const v = new FormData(e.currentTarget).get("requirements");
          setSent(`見本のため送信はしません。requirements 欄には相談文(${[...String(v ?? "")].length}字)が入っています`);
        }}
      >
        <header className="px-5 pb-2 pt-7 sm:px-8">
          <p className="flex flex-wrap gap-x-3 text-xs text-stone-500">
            <span className="font-display font-semibold tracking-[0.18em]">BLK-109</span>
            <span>React ドラッグ&ドロップUI・並べ替えアニメーション(架空の制作会社)</span>
          </p>
          <Heading className="mt-2 text-[clamp(1.25rem,2.6vw,1.7rem)] font-bold leading-[1.45] tracking-tight text-balance">
            入れたい機能を3段に仕分けると、相談文になる
          </Heading>
        </header>

        <PriorityTray onChange={onChange} />

        <div className="grid gap-5 border-t border-stone-200 px-5 py-6 sm:grid-cols-2 sm:px-8">
          <div>
            <label htmlFor={`${uid}-name`} className="text-[14px] font-bold">
              お名前
            </label>
            <input id={`${uid}-name`} name="name" autoComplete="name" className={field} />
          </div>
          <div>
            <label htmlFor={`${uid}-mail`} className="text-[14px] font-bold">
              メールアドレス
            </label>
            <input id={`${uid}-mail`} name="email" type="email" autoComplete="email" className={field} />
          </div>
          <div className="flex flex-wrap items-center gap-x-4 gap-y-2 sm:col-span-2">
            <button
              type="submit"
              className={`send min-h-12 bg-[#d97706] px-6 text-[15px] font-bold text-white transition-colors hover:bg-amber-700 active:bg-amber-800 motion-reduce:transition-none ${focusRing}`}
            >
              この内容で相談する
            </button>
            <p aria-live="polite" className="text-[13px] text-stone-600">
              {sent || `相談文 ${len}字が一緒に送られます`}
            </p>
          </div>
        </div>
      </form>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/tier-ranking-board.json