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 では動きを切ってすぐ移動します。カードと会社は架空の表示サンプル。
プレビュー
説明の全文を読む
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