04대기열 순서 값 재배치 — 아직 시작하지 않은 항목들의 순서 값이 사용자가 정한 차례로 바뀌는 방식
대기 중인 항목들의 순서 값이 사용자가 정한 차례로 바뀌면, 목록 안 위치 값도 그 순서를 따라 함께 바뀝니다.
쓰임여러 파일을 예약해 두고 순서를 바꾸는 다운로드 대기열, 대량 백업 작업 목록
.dqr__itemLERPROW_H

<main class="stage">
<div class="dqr is-demo" id="dqr">
<div class="dqr__item" data-order="0" id="dqrItem0">대기 A</div>
<div class="dqr__item" data-order="1" id="dqrItem1">대기 B</div>
<div class="dqr__item" data-order="2" id="dqrItem2">대기 C</div>
<div class="dqr__item" data-order="3" id="dqrItem3">대기 D</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 순서 값 → 목표 위치, 실제 위치는 매 프레임 남은 거리의 18%씩 다가가는 보간(lerp) — index.html tickLerp() 참조
@include stage($stage-ink);
.dqr {
position: relative;
width: 100%;
max-width: 220px;
height: 160px; // 4 × ROW_H(40px)
}
.dqr__item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 32px;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 $sp-3;
border-radius: $r-sm;
background: $subject-blue-light;
color: $stage-ink;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: grab;
touch-action: none;
user-select: none;
will-change: transform;
}
.dqr__item--drag {
cursor: grabbing;
background: $subject-cream;
box-shadow: $shadow-raised;
z-index: 2;
}
@media (max-width: 340px) {
.dqr { max-width: 190px; }
.dqr__item { height: 30px; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.dqr__item { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dqr {
position: relative;
width: 100%;
max-width: 220px;
height: 160px;
}
.dqr__item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 32px;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 12px;
border-radius: 8px;
background: #5b8cf8;
color: #17141a;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: grab;
touch-action: none;
user-select: none;
will-change: transform;
}
.dqr__item--drag {
cursor: grabbing;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 2;
}
@media (max-width: 340px) {
.dqr {
max-width: 190px;
}
.dqr__item {
height: 30px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dqr__item {
transition: none;
}
}(function () {
var root = document.getElementById('dqr');
var items = [document.getElementById('dqrItem0'), document.getElementById('dqrItem1'), document.getElementById('dqrItem2'), document.getElementById('dqrItem3')];
// 상수: 항목 높이(px, gap 포함) · 순서→위치 보간 속도(1프레임당 남은 거리의 비율)
var ROW_H = 40;
var LERP = 0.18;
var AUTO_SWAP_MS = 900;
var order = [0, 1, 2, 3]; // order[i] = i번째 칸에 있는 항목 index
var y = [0, 1, 2, 3].map(function (i) { return i * ROW_H; }); // 각 항목의 실제(보간 중) y
var dragging = -1;
var dragStartY = 0, dragStartTop = 0;
function targetY(itemIdx) {
var slot = order.indexOf(itemIdx);
return slot * ROW_H;
}
// 계산 겹: 순서 값이 바뀌면 목표 위치로 즉시 점프하지 않고 매 프레임 남은 거리의 LERP 비율만큼 다가간다
function tickLerp() {
for (var i = 0; i < items.length; i++) {
if (i === dragging) continue;
var t = targetY(i);
y[i] += (t - y[i]) * LERP;
items[i].style.transform = 'translateY(' + y[i].toFixed(2) + 'px)';
}
requestAnimationFrame(tickLerp);
}
requestAnimationFrame(tickLerp);
function swap(a, b) {
var sa = order.indexOf(a), sb = order.indexOf(b);
order[sa] = b; order[sb] = a;
}
// 자동 루프: 대기 중인 항목의 순서 값을 주기적으로 맞바꾼다
var autoSeq = [[0, 2], [1, 3], [0, 1], [2, 3], [0, 3]];
var autoI = 0;
function autoStep() {
if (root.classList.contains('is-demo')) {
var pair = autoSeq[autoI % autoSeq.length];
swap(pair[0], pair[1]);
autoI++;
}
setTimeout(autoStep, AUTO_SWAP_MS);
}
setTimeout(autoStep, AUTO_SWAP_MS);
// 실사용자 드래그: pointerdown/move/up 으로 순서 값을 직접 바꾼다
items.forEach(function (el, idx) {
el.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = idx;
dragStartY = e.clientY;
dragStartTop = y[idx];
el.setPointerCapture(e.pointerId);
el.classList.add('dqr__item--drag');
});
el.addEventListener('pointermove', function (e) {
if (dragging !== idx) return;
var ny = dragStartTop + (e.clientY - dragStartY);
y[idx] = ny;
el.style.transform = 'translateY(' + ny.toFixed(2) + 'px)';
var slot = Math.max(0, Math.min(items.length - 1, Math.round(ny / ROW_H)));
var curSlot = order.indexOf(idx);
if (slot !== curSlot) {
var other = order[slot];
order[curSlot] = other;
order[slot] = idx;
}
});
function end(e) {
if (dragging !== idx) return;
dragging = -1;
el.classList.remove('dqr__item--drag');
}
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
});
})();import { useEffect, useRef } from "react";
import styles from "./DownloadManagerQueueReorder.module.scss";
type Box<T> = { current: T };
export default function DownloadManagerQueueReorder() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const items = [0, 1, 2, 3].map((i) => root.querySelector(`.${styles["item" + i]}`) as HTMLElement | null);
if (items.some((el) => !el)) return;
// 상수: 항목 높이(px, gap 포함) · 순서→위치 보간 속도(1프레임당 남은 거리의 비율)
const ROW_H = 40;
const LERP = 0.18;
const AUTO_SWAP_MS = 900;
const order = [0, 1, 2, 3];
const y = [0, 1, 2, 3].map((i) => i * ROW_H);
let dragging = -1;
let dragStartY = 0, dragStartTop = 0;
let stopped = false;
let rafId = 0;
let autoTimer = 0;
function targetY(itemIdx: number) {
const slot = order.indexOf(itemIdx);
return slot * ROW_H;
}
// 계산 겹: 순서 값이 바뀌면 목표 위치로 즉시 점프하지 않고 매 프레임 남은 거리의 LERP 비율만큼 다가간다
function tickLerp() {
if (stopped) return;
for (let i = 0; i < items.length; i++) {
if (i === dragging) continue;
const t = targetY(i);
y[i] += (t - y[i]) * LERP;
items[i]!.style.transform = "translateY(" + y[i].toFixed(2) + "px)";
}
rafId = requestAnimationFrame(tickLerp);
}
rafId = requestAnimationFrame(tickLerp);
function swap(a: number, b: number) {
const sa = order.indexOf(a), sb = order.indexOf(b);
order[sa] = b; order[sb] = a;
}
const autoSeq = [[0, 2], [1, 3], [0, 1], [2, 3], [0, 3]];
let autoI = 0;
function autoStep() {
if (stopped) return;
if (root!.classList.contains(styles["is-demo"])) {
const pair = autoSeq[autoI % autoSeq.length];
swap(pair[0], pair[1]);
autoI++;
}
autoTimer = window.setTimeout(autoStep, AUTO_SWAP_MS);
}
autoTimer = window.setTimeout(autoStep, AUTO_SWAP_MS);
const cleanups: Array<() => void> = [];
items.forEach((el, idx) => {
function onDown(e: PointerEvent) {
root!.classList.remove(styles["is-demo"]);
dragging = idx;
dragStartY = e.clientY;
dragStartTop = y[idx];
el!.setPointerCapture(e.pointerId);
el!.classList.add(styles["item--drag"]);
}
function onMove(e: PointerEvent) {
if (dragging !== idx) return;
const ny = dragStartTop + (e.clientY - dragStartY);
y[idx] = ny;
el!.style.transform = "translateY(" + ny.toFixed(2) + "px)";
const slot = Math.max(0, Math.min(items.length - 1, Math.round(ny / ROW_H)));
const curSlot = order.indexOf(idx);
if (slot !== curSlot) {
const other = order[slot];
order[curSlot] = other;
order[slot] = idx;
}
}
function onEnd() {
if (dragging !== idx) return;
dragging = -1;
el!.classList.remove(styles["item--drag"]);
}
el!.addEventListener("pointerdown", onDown);
el!.addEventListener("pointermove", onMove);
el!.addEventListener("pointerup", onEnd);
el!.addEventListener("pointercancel", onEnd);
cleanups.push(() => {
el!.removeEventListener("pointerdown", onDown);
el!.removeEventListener("pointermove", onMove);
el!.removeEventListener("pointerup", onEnd);
el!.removeEventListener("pointercancel", onEnd);
});
});
return () => {
stopped = true;
cancelAnimationFrame(rafId);
window.clearTimeout(autoTimer);
cleanups.forEach((fn) => fn());
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.dqr} ${styles["is-demo"]}`}>
<div className={`${styles.item} ${styles.item0}`}>대기 A</div>
<div className={`${styles.item} ${styles.item1}`}>대기 B</div>
<div className={`${styles.item} ${styles.item2}`}>대기 C</div>
<div className={`${styles.item} ${styles.item3}`}>대기 D</div>
</div>
);
}04 대기열 순서 값 재배치 — 아직 시작하지 않은 항목들의 순서 값이 사용자가 정한 차례로 바뀌는 방식 — 측정 누적면적 20.906% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 다운로드 진행 상황 표시 9종 — 값이 퍼센트 단위로 갱신되는 아홉 가지 방식
이 데모가 속한 글
다운로드 진행 상황 표시 9종 — 값이 퍼센트 단위로 갱신되는 아홉 가지 방식다운로드 진행 상황 표시는 지금 받아지는 파일이 어느 상태에 있는지를 값 하나로 계산해 화면에 드러내는 인터랙션을 말하는데, 아홉 개 중 하나는 여러 파일이 동시에 받아질 때 각 파일의 진행 값이 목록에서 나란히 오 이 글의 데모 전부 보기


