02カンバンカード — ポインターY比率から対象カラムと挿入位置を逆算する計算
ポインターがどのカラムのgetBoundingClientRect内にあるかで対象カラムを見つけ、そのカラム内カードのY中点とポインターYを比較して挿入位置を逆算します。カラムが実際に変わった瞬間だけ配列のspliceを実行し、同じカラム内ではプレビューだけ移動します。
使いどころカンバンボード、課題トラッカー
pointermovegetBoundingClientRecttransition

<main class="stage">
<div class="kb">
<p class="kb__hint">ポインターYをカードの中点と比較して挿入位置を逆算し、カラムが変わる瞬間だけ移動します</p>
<div class="kb__board" id="kbBoard">
<section class="kb__col" id="kbColA" aria-label="未着手">
<h2 class="kb__title">未着手 <b class="kb__count" id="kbCountA">3</b></h2>
<ul class="kb__cards" id="kbListA">
<li class="kb__card"><span class="kb__tag kb__tag--b">設計</span><span class="kb__txt">リスト構造の設計</span></li>
<li class="kb__card"><span class="kb__tag kb__tag--b">設計</span><span class="kb__txt">トークン整理</span></li>
<li class="kb__card"><span class="kb__tag kb__tag--m">作業</span><span class="kb__txt">ドラッグの実装</span></li>
</ul>
</section>
<section class="kb__col" id="kbColB" aria-label="進行中">
<h2 class="kb__title">進行中 <b class="kb__count" id="kbCountB">2</b></h2>
<ul class="kb__cards" id="kbListB">
<li class="kb__card"><span class="kb__tag kb__tag--m">作業</span><span class="kb__txt">インデックス計算</span></li>
<li class="kb__card"><span class="kb__tag kb__tag--o">検収</span><span class="kb__txt">計測値の確認</span></li>
</ul>
</section>
</div>
<p class="kb__meta"><b class="kb__state" id="kbState">insert 2</b><span class="kb__unit" id="kbCol">未着手カラム</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 220ms;
$easing: $ease-spring;
$color: $subject-blue-deep;
// 파스텔 강조(도구/파스텔.py to_pastel 결과: 채도 .34 · 명도 +.11)
$p-pink: #f4b3c6;
$p-mint: #93d9c0;
$p-sky: #d6e2eb;
@include stage($stage-yellow);
.kb {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.kb__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
.kb__board {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
}
.kb__col {
display: flex;
flex-direction: column;
gap: $sp-1;
background: rgba(255, 255, 255, .72);
border-radius: $r-card;
padding: $sp-2;
}
.kb__title {
margin: 0;
display: flex;
align-items: center;
gap: $sp-1;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
color: $subject-ink;
}
.kb__count {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: $r-pill;
background: $subject-ink;
color: $stage-yellow;
font-weight: 700;
font-size: 10px;
}
.kb__cards {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
min-height: 96px;
overflow-y: auto;
}
.kb__card {
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-2;
border-radius: $r-sm;
background: $stage-paper;
box-shadow: $shadow-press;
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
cursor: grab;
touch-action: none;
transition: transform $duration $easing;
}
.kb__card:hover {
transform: translateY(-2px) scale(1.02);
transform-origin: center;
}
.kb__card--drag {
cursor: grabbing;
box-shadow: $shadow-raised;
}
.kb__tag {
flex: 0 0 auto;
padding: 0 $sp-1;
border-radius: $r-xs;
font-weight: 700;
font-size: 9px;
line-height: 1.6;
}
.kb__tag--b { background: $p-sky; color: $subject-ink; }
.kb__tag--m { background: $p-mint; color: $subject-ink; }
.kb__tag--o { background: $p-pink; color: $subject-ink; }
.kb__txt {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.kb__line {
height: 28px;
border-radius: $r-xs;
background: rgba($color, .4);
list-style: none;
transform: scaleX(.92);
transform-origin: center;
transition: transform $duration $easing;
}
.kb__meta {
margin: 0;
padding: 0 $sp-1;
display: flex;
align-items: baseline;
gap: $sp-1;
}
.kb__state {
font-weight: 700;
font-size: 12px;
color: $subject-ink;
}
.kb__unit {
font-weight: 400;
font-size: 11px;
color: rgba($subject-ink, .72);
}
@media (max-width: 340px) {
.kb { max-width: 280px; gap: $sp-1; }
.kb__hint { font-size: 10px; }
.kb__card { font-size: 10px; padding: $sp-1; }
.kb__cards { max-height: 66px; min-height: 0; }
}
@media (prefers-reduced-motion: reduce) {
.kb__card,
.kb__line {
transition: none !important;
transform: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kb {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 8px;
}
.kb__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.kb__board {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.kb__col {
display: flex;
flex-direction: column;
gap: 4px;
background: rgba(255, 255, 255, 0.72);
border-radius: 16px;
padding: 8px;
}
.kb__title {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
color: #17141a;
}
.kb__count {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-weight: 700;
font-size: 10px;
}
.kb__cards {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 96px;
overflow-y: auto;
}
.kb__card {
display: flex;
align-items: center;
gap: 4px;
padding: 8px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
cursor: grab;
touch-action: none;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.kb__card:hover {
transform: translateY(-2px) scale(1.02);
transform-origin: center;
}
.kb__card--drag {
cursor: grabbing;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.kb__tag {
flex: 0 0 auto;
padding: 0 4px;
border-radius: 4px;
font-weight: 700;
font-size: 9px;
line-height: 1.6;
}
.kb__tag--b {
background: #d6e2eb;
color: #17141a;
}
.kb__tag--m {
background: #93d9c0;
color: #17141a;
}
.kb__tag--o {
background: #f4b3c6;
color: #17141a;
}
.kb__txt {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.kb__line {
height: 28px;
border-radius: 4px;
background: rgba(10, 79, 232, 0.4);
list-style: none;
transform: scaleX(0.92);
transform-origin: center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.kb__meta {
margin: 0;
padding: 0 4px;
display: flex;
align-items: baseline;
gap: 4px;
}
.kb__state {
font-weight: 700;
font-size: 12px;
color: #17141a;
}
.kb__unit {
font-weight: 400;
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.kb {
max-width: 280px;
gap: 4px;
}
.kb__hint {
font-size: 10px;
}
.kb__card {
font-size: 10px;
padding: 4px;
}
.kb__cards {
max-height: 66px;
min-height: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.kb__card,
.kb__line {
transition: none !important;
transform: none !important;
}
}// 칸반 카드(02) — 포인터가 어느 컬럼의 getBoundingClientRect 안에 있는지로 대상 컬럼을 찾고(findColumn),
// 그 カラム 안 카드들의 y 중점과 포인터 y 를 비교해 삽입 인덱스를 역산한다(insertIndex). 컬럼이 실제로
// 바뀌는 순간에만 appendChild 로 카드를 옮기고, 같은 カラム 안에서는 미리보기 삽입선만 움직인다.
// 자동 시연은 고정 시나리오: 카드를 뽑아 다른 컬럼의 계산된 위치에 놓기를 2초 주기로 반복한다.
(function () {
var colA = document.getElementById('kbColA');
var colB = document.getElementById('kbColB');
var listA = document.getElementById('kbListA');
var listB = document.getElementById('kbListB');
var countA = document.getElementById('kbCountA');
var countB = document.getElementById('kbCountB');
var stateEl = document.getElementById('kbState');
var colEl = document.getElementById('kbCol');
if (!colA || !colB || !listA || !listB || !countA || !countB || !stateEl || !colEl) return;
var COLS = [colA, colB];
var NAMES = ['未着手', '進行中'];
// カラム rects 캐시 — 시연 시나리오마다 재측정
function findColumn(x) {
for (var i = 0; i < COLS.length; i++) {
var r = COLS[i].getBoundingClientRect();
if (x >= r.left && x <= r.right) return i;
}
return -1;
}
// 삽입 인덱스 역산 — 카드 y 중점과 포인터 y 비교
function insertIndex(colList, y) {
var cards = colList.children;
for (var i = 0; i < cards.length; i++) {
var r = cards[i].getBoundingClientRect();
if (y < r.top + r.height / 2) return i;
}
return cards.length;
}
function refreshCounts() {
countA.textContent = String(listA.children.length);
countB.textContent = String(listB.children.length);
}
// 실제 이동 — 컬럼이 바뀔 때만 호출. FLIP: 이동 전 모든 카드 좌표를 재고 이동 후 차를 되돌렸다가 푼다
function moveCard(card, fromList, toList, index) {
var all = Array.prototype.slice.call(document.querySelectorAll('.kb__card'));
var first = all.map(function (c) { return c.getBoundingClientRect(); });
var target = toList.children[index] || null;
toList.insertBefore(card, target);
refreshCounts();
all.forEach(function (c, i) {
var r = c.getBoundingClientRect();
var dx = first[i].left - r.left, dy = first[i].top - r.top;
if (dx !== 0 || dy !== 0) {
c.style.transition = 'none';
c.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
requestAnimationFrame(function () {
c.style.transition = 'transform 260ms cubic-bezier(.2,.8,.2,1)';
c.style.transform = 'translate(0,0)';
});
}
});
}
// 미리보기 삽입선 — 같은 カラム 안에서 포인터 y 가 가리키는 칸
function previewLine(card, y) {
var list = card.parentElement;
if (!list) return;
var idx = insertIndex(list, y);
stateEl.textContent = 'insert ' + idx;
colEl.textContent = (list === listA ? NAMES[0] : NAMES[1]) + ' カラム';
var old = list.querySelector('.kb__line');
if (old) old.parentElement.removeChild(old);
var line = document.createElement('li');
line.className = 'kb__line';
var target = list.children[idx] || null;
list.insertBefore(line, target);
}
// 사람 조작 — 실제 포인터 ドラッグ: pointerdown 으로 캡처, pointermove 로 삽입선을 따라가고,
// pointerup 에서 findColumn+insertIndex 로 역산한 자리에 moveCard 를 부른다(같은 계산 경로).
var dragCard = null;
function onPointerDown(e) {
var card = e.target.closest('.kb__card');
if (!card) return;
stop();
dragCard = card;
card.setPointerCapture(e.pointerId);
card.classList.add('kb__card--drag');
}
function onPointerMove(e) {
if (!dragCard) return;
var ci = findColumn(e.clientX);
var list = ci === 1 ? listB : listA;
previewLine(dragCard, e.clientY);
if (list !== dragCard.parentElement) {
var idx = insertIndex(list, e.clientY);
var target = list.children[idx] || null;
list.insertBefore(dragCard, target);
refreshCounts();
}
}
function onPointerUp(e) {
if (!dragCard) return;
var card = dragCard;
dragCard = null;
card.classList.remove('kb__card--drag');
var list = card.parentElement;
var idx = insertIndex(list, e.clientY);
moveCard(card, list, list, Math.min(idx, list.children.length - 1));
clearLines();
}
colA.addEventListener('pointerdown', onPointerDown);
colB.addEventListener('pointerdown', onPointerDown);
colA.addEventListener('pointermove', onPointerMove);
colB.addEventListener('pointermove', onPointerMove);
colA.addEventListener('pointerup', onPointerUp);
colB.addEventListener('pointerup', onPointerUp);
colA.addEventListener('pointercancel', onPointerUp);
colB.addEventListener('pointercancel', onPointerUp);
// 클릭(탭)도 반대 컬럼의 중점 위치로 옮기는 대체 경로로 남긴다(포인터 없는 환경 대비)
function onCardClick(e) {
if (dragCard) return;
stop();
var card = e.target.closest('.kb__card');
if (!card) return;
var r = card.getBoundingClientRect();
var toOther = card.parentElement === listA ? listB : listA;
var idx = insertIndex(toOther, r.top + r.height / 2);
moveCard(card, card.parentElement, toOther, idx);
}
colA.addEventListener('click', onCardClick);
colB.addEventListener('click', onCardClick);
// 자동 시연 — 시나리오: A2 를 뽑아 B 의 계산 인덱스에, B1 을 A 로, 되돌리기. 2초 루프.
var playing = true;
function stop() { playing = false; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function step1() {
var card = listA.children[1];
if (!card) return;
// カラム rect 를 다시 재서 목표 y 는 B カラム 세로 중간
var rb = colB.getBoundingClientRect();
var y = rb.top + rb.height / 2;
previewLine(card, y);
var idx = insertIndex(listB, y);
moveCard(card, listA, listB, idx);
}
function step2() {
var card = listB.children[0];
if (!card) return;
var ra = colA.getBoundingClientRect();
var y = ra.top + ra.height / 2;
previewLine(card, y);
var idx = insertIndex(listA, y);
moveCard(card, listB, listA, idx);
}
function clearLines() {
document.querySelectorAll('.kb__line').forEach(function (l) {
l.parentElement.removeChild(l);
});
}
if (reduce) {
step1();
clearLines();
} else {
var seq = [
{ at: 0, fn: step1 },
{ at: 660, fn: clearLines },
{ at: 1000, fn: step2 },
{ at: 1660, fn: clearLines }
];
var loopStart = performance.now();
(function tick(now) {
var t = (now - loopStart) % 2000;
seq.forEach(function (s) {
if (t >= s.at && t < s.at + 90 && !s.done) { s.fn(); s.done = true; }
if (t < s.at) s.done = false;
});
requestAnimationFrame(tick);
})(loopStart);
}
})();// DragReorderKanban — vanilla 판(run/336/demo/02-drag-reorder-kanban/index.html)
// 의 React 포트. 포인터가 어느 컬럼의 getBoundingClientRect 안에 있는지로 대상 컬럼을 찾고
// (findColumn), 그 컬럼 안 카드들의 y 중점과 포인터 y 를 비교해 삽입 인덱스를 역산한다
// (insertIndex). 컬럼이 실제로 바뀌는 순간에만 moveCard 가 전체 카드를 FLIP 으로 되돌린다.
import { useEffect, useRef } from "react";
import styles from "./DragReorderKanban.module.scss";
export type DragReorderKanbanProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "포인터 y 를 카드 중점들과 비교해 삽입 인덱스를 역산하고, 컬럼이 바뀌는 순간에만 옮깁니다";
const NAMES = ["할 일", "하는 중"];
type CardDef = { tag: string; tagClass: string; txt: string };
const CARDS_A: CardDef[] = [
{ tag: "설계", tagClass: "b", txt: "리스트 구조 잡기" },
{ tag: "설계", tagClass: "b", txt: "토큰 정리" },
{ tag: "작업", tagClass: "m", txt: "드래그 붙이기" }
];
const CARDS_B: CardDef[] = [
{ tag: "작업", tagClass: "m", txt: "인덱스 계산" },
{ tag: "검토", tagClass: "o", txt: "측정값 확인" }
];
export default function DragReorderKanban({
duration = "260ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8"
}: DragReorderKanbanProps) {
const colARef = useRef<HTMLElement | null>(null);
const colBRef = useRef<HTMLElement | null>(null);
const listARef = useRef<HTMLUListElement | null>(null);
const listBRef = useRef<HTMLUListElement | null>(null);
const countARef = useRef<HTMLElement | null>(null);
const countBRef = useRef<HTMLElement | null>(null);
const stateRef = useRef<HTMLElement | null>(null);
const colTxtRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const colA = colARef.current;
const colB = colBRef.current;
const listA = listARef.current;
const listB = listBRef.current;
const countA = countARef.current;
const countB = countBRef.current;
const stateEl = stateRef.current;
const colEl = colTxtRef.current;
if (!colA || !colB || !listA || !listB || !countA || !countB || !stateEl || !colEl) return;
const COLS = [colA, colB];
const lineClass = styles.line;
const cardClass = styles.card;
const dragClass = styles.cardDrag;
function findColumn(x: number): number {
for (let i = 0; i < COLS.length; i++) {
const r = COLS[i]!.getBoundingClientRect();
if (x >= r.left && x <= r.right) return i;
}
return -1;
}
function insertIndex(colList: HTMLElement, y: number): number {
const cards = colList.children;
for (let i = 0; i < cards.length; i++) {
const r = (cards[i] as HTMLElement).getBoundingClientRect();
if (y < r.top + r.height / 2) return i;
}
return cards.length;
}
function refreshCounts(): void {
countA!.textContent = String(listA!.children.length);
countB!.textContent = String(listB!.children.length);
}
function allCards(): HTMLElement[] {
return Array.prototype.slice.call(document.querySelectorAll("." + cardClass));
}
function moveCard(card: HTMLElement, toList: HTMLElement, index: number): void {
const all = allCards();
const first = all.map((c) => c.getBoundingClientRect());
const target = (toList.children[index] as HTMLElement) || null;
toList.insertBefore(card, target);
refreshCounts();
all.forEach((c, i) => {
const r = c.getBoundingClientRect();
const dx = first[i].left - r.left;
const dy = first[i].top - r.top;
if (dx !== 0 || dy !== 0) {
c.style.transition = "none";
c.style.transform = "translate(" + dx + "px," + dy + "px)";
requestAnimationFrame(() => {
c.style.transition = "transform " + duration + " " + easing;
c.style.transform = "translate(0,0)";
});
}
});
}
function previewLine(card: HTMLElement, y: number): void {
const list = card.parentElement;
if (!list) return;
const idx = insertIndex(list as HTMLElement, y);
stateEl!.textContent = "insert " + idx;
colEl!.textContent = (list === listA ? NAMES[0] : NAMES[1]) + " 컬럼";
const old = list.querySelector("." + lineClass);
if (old && old.parentElement) old.parentElement.removeChild(old);
const line = document.createElement("li");
line.className = lineClass;
const target = (list.children[idx] as HTMLElement) || null;
list.insertBefore(line, target);
}
function clearLines(): void {
document.querySelectorAll("." + lineClass).forEach((l) => {
if (l.parentElement) l.parentElement.removeChild(l);
});
}
let dragCard: HTMLElement | null = null;
function onPointerDown(e: PointerEvent): void {
const card = (e.target as HTMLElement).closest("." + cardClass) as HTMLElement | null;
if (!card) return;
stop();
dragCard = card;
card.setPointerCapture(e.pointerId);
card.classList.add(dragClass);
}
function onPointerMove(e: PointerEvent): void {
if (!dragCard) return;
const ci = findColumn(e.clientX);
const list = ci === 1 ? listB! : listA!;
previewLine(dragCard, e.clientY);
if (list !== dragCard.parentElement) {
const idx = insertIndex(list, e.clientY);
const target = (list.children[idx] as HTMLElement) || null;
list.insertBefore(dragCard, target);
refreshCounts();
}
}
function onPointerUp(e: PointerEvent): void {
if (!dragCard) return;
const card = dragCard;
dragCard = null;
card.classList.remove(dragClass);
const list = card.parentElement as HTMLElement;
const idx = insertIndex(list, e.clientY);
moveCard(card, list, Math.min(idx, list.children.length - 1));
clearLines();
}
function onCardClick(e: MouseEvent): void {
if (dragCard) return;
stop();
const card = (e.target as HTMLElement).closest("." + cardClass) as HTMLElement | null;
if (!card) return;
const r = card.getBoundingClientRect();
const toOther = card.parentElement === listA ? listB! : listA!;
const idx = insertIndex(toOther, r.top + r.height / 2);
moveCard(card, toOther, idx);
}
colA.addEventListener("pointerdown", onPointerDown as EventListener);
colB.addEventListener("pointerdown", onPointerDown as EventListener);
colA.addEventListener("pointermove", onPointerMove as EventListener);
colB.addEventListener("pointermove", onPointerMove as EventListener);
colA.addEventListener("pointerup", onPointerUp as EventListener);
colB.addEventListener("pointerup", onPointerUp as EventListener);
colA.addEventListener("pointercancel", onPointerUp as EventListener);
colB.addEventListener("pointercancel", onPointerUp as EventListener);
colA.addEventListener("click", onCardClick as EventListener);
colB.addEventListener("click", onCardClick as EventListener);
let playing = true;
function stop(): void {
playing = false;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function step1(): void {
const card = listA!.children[1] as HTMLElement;
if (!card) return;
const rb = colB!.getBoundingClientRect();
const y = rb.top + rb.height / 2;
previewLine(card, y);
const idx = insertIndex(listB!, y);
moveCard(card, listB!, idx);
}
function step2(): void {
const card = listB!.children[0] as HTMLElement;
if (!card) return;
const ra = colA!.getBoundingClientRect();
const y = ra.top + ra.height / 2;
previewLine(card, y);
const idx = insertIndex(listA!, y);
moveCard(card, listA!, idx);
}
let rafId = 0;
if (reduce) {
step1();
clearLines();
} else {
const seq = [
{ at: 0, fn: step1, done: false },
{ at: 660, fn: clearLines, done: false },
{ at: 1000, fn: step2, done: false },
{ at: 1660, fn: clearLines, done: false }
];
const loopStart = performance.now();
const tick = (now: number): void => {
const t = (now - loopStart) % 2000;
seq.forEach((s) => {
if (playing && t >= s.at && t < s.at + 90 && !s.done) {
s.fn();
s.done = true;
}
if (t < s.at) s.done = false;
});
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(rafId);
colA.removeEventListener("pointerdown", onPointerDown as EventListener);
colB.removeEventListener("pointerdown", onPointerDown as EventListener);
colA.removeEventListener("pointermove", onPointerMove as EventListener);
colB.removeEventListener("pointermove", onPointerMove as EventListener);
colA.removeEventListener("pointerup", onPointerUp as EventListener);
colB.removeEventListener("pointerup", onPointerUp as EventListener);
colA.removeEventListener("pointercancel", onPointerUp as EventListener);
colB.removeEventListener("pointercancel", onPointerUp as EventListener);
colA.removeEventListener("click", onCardClick as EventListener);
colB.removeEventListener("click", onCardClick as EventListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.kb}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.board}>
<section className={styles.col} ref={colARef} aria-label={NAMES[0]}>
<h2 className={styles.title}>{NAMES[0]} <b className={styles.count} ref={countARef}>{CARDS_A.length}</b></h2>
<ul className={styles.cards} ref={listARef}>
{CARDS_A.map((c, i) => (
<li key={i} className={styles.card}>
<span className={styles.tag + " " + (styles as Record<string, string>)["tag" + c.tagClass.toUpperCase()]}>{c.tag}</span>
<span className={styles.txt}>{c.txt}</span>
</li>
))}
</ul>
</section>
<section className={styles.col} ref={colBRef} aria-label={NAMES[1]}>
<h2 className={styles.title}>{NAMES[1]} <b className={styles.count} ref={countBRef}>{CARDS_B.length}</b></h2>
<ul className={styles.cards} ref={listBRef}>
{CARDS_B.map((c, i) => (
<li key={i} className={styles.card}>
<span className={styles.tag + " " + (styles as Record<string, string>)["tag" + c.tagClass.toUpperCase()]}>{c.tag}</span>
<span className={styles.txt}>{c.txt}</span>
</li>
))}
</ul>
</section>
</div>
<p className={styles.meta}>
<b className={styles.state} ref={stateRef} style={{ color }}>insert 2</b>
<span className={styles.unit} ref={colTxtRef}>{NAMES[0]} 컬럼</span>
</p>
</div>
</main>
);
}02 カンバンカード — ポインターY比率から対象カラムと挿入位置を逆算する計算 — 計測 累積面積 25.115% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種
このデモが属する記事
ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種ドラッグアンドドロップ 並び替えは、指を離すたびに座標を計算し直す一枚の層です。ポインター位置から挿入先インデックスを計算し、リスト・カンバン・グリッドが並び替わります。ロングプレス・Alt矢印・端での自動スクロールまで — ドラッグ並び替 この記事のデモをすべて見る


