Een rangschikbord: rijen met tegels die je erin sleept, herordent en terug naar de bak legt; rijen hernoemen, toevoegen en verwijderen; de bak vergroot tegels rond de muis als een dock. Eigen pointer-sleep met spookvak, toetsenbord met pijltjes. Rijkleuren van het project.
α
β
γ
Nog te plaatsen
δ
ε
ζ
η
θ
Sleep tegels tussen de rijen en de bak; beweeg over de bak voor het dock-effect; met focus verplaatsen de pijltjes.
blocks/tier-list/tier-list.tsx
/**
* TierList — een rangschikbord: rijen (S/A/B/C/D, of wat het project wil)
* met tegels die je erin sleept, herordent en terug naar de bak legt. Rijen
* hernoemen, toevoegen en verwijderen; de bak onderaan vergroot tegels rond
* de muis zoals een dock.
*
* Slepen is eigen pointer-werk (pointer-capture, geen dnd-bibliotheek): de
* tegel volgt de aanwijzer als losse laag, een spookvak toont waar hij
* landt. Toetsenbord: een tegel focussen en met pijltjes verplaatsen
* (links/rechts binnen de rij, omhoog/omlaag naar een andere rij of de bak).
* Rijkleuren komen van het project (`tier.color`, elke CSS-achtergrond);
* zonder kleur een mengsel van --primary en --muted, per rij een andere
* verhouding. Knoppen via button/usePress; springs uit de tokens.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type * as React from "react";
import { motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from "framer-motion";
import { Button } from "../../primitives/ui/button";
import { usePress } from "../../primitives/ui/press";
import { useSpringTransition } from "../../primitives/motion";
export interface TierItem {
id: string;
label?: string;
/** Tekst in de tegel (emoji, initialen) als er geen beeld is. */
glyph?: string;
image?: string;
}
export interface Tier {
id: string;
label: string;
/** Elke CSS-achtergrond; zonder: een mengsel van --primary en --muted. */
color?: string;
items: TierItem[];
}
export interface TierListValue {
tiers: Tier[];
pool: TierItem[];
}
export interface TierListLabels {
addTier: string;
removeTier: string;
tierName: string;
pool: string;
/** {label} */
grabHint: string;
}
export interface TierListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
value: TierListValue;
onChange: (next: TierListValue) => void;
/** Tegelmaat in px — structureel. */
tileSize?: number;
readOnly?: boolean;
labels?: Partial<TierListLabels>;
}
const STANDAARD: TierListLabels = { addTier: "Rij toevoegen", removeTier: "Rij verwijderen", tierName: "Naam van de rij", pool: "Nog te plaatsen", grabHint: "{label}: pijltjes verplaatsen" };
const POOL = "pool";
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const uid = () => (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2));
function Tegel({ item, size, dragging, onPointerDown, onKeyDown, hint, dockX }: {
item: TierItem; size: number; dragging: boolean; hint: string; dockX?: MotionValue<number>;
onPointerDown: (e: React.PointerEvent<HTMLElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const veer = useSpringTransition("pop");
// Dock: schaal op afstand tot de muis, alleen in de bak (dockX aanwezig).
// De terugval-MotionValue bestaat altijd, zodat de hook-volgorde vast is.
const stil = useMotionValue(Number.POSITIVE_INFINITY);
const afstand = useTransform(dockX ?? stil, (x) => {
const r = ref.current?.getBoundingClientRect();
return r ? x - (r.left + r.width / 2) : Number.POSITIVE_INFINITY;
});
const scale = useTransform(afstand, [-110, 0, 110], [1, 1.35, 1]);
return (
<motion.div
ref={ref}
data-tile-id={item.id}
role="listitem"
tabIndex={0}
aria-label={item.label ?? item.id}
title={hint}
className="fp-tier__tegel"
data-dragging={dragging ? "" : undefined}
style={{ ["--fp-tier-tegel" as string]: `${size}px`, scale: dockX ? scale : 1 } as React.CSSProperties & { scale: unknown }}
transition={veer}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
>
<TegelInhoud item={item} />
</motion.div>
);
}
function TegelInhoud({ item }: { item: TierItem }) {
return item.image ? (
/* eslint-disable-next-line @next/next/no-img-element -- url van het project */
<img src={item.image} alt="" draggable={false} />
) : (
<span className="fp-tier__glyph">{item.glyph ?? item.label?.slice(0, 2) ?? "?"}</span>
);
}
function RijKnoppen({ tier, onNaam, onWeg, L }: { tier: Tier; onNaam: (v: string) => void; onWeg: () => void; L: TierListLabels }) {
const weg = usePress({ onPress: onWeg });
return (
<>
<input className="fp-tier__naam" value={tier.label} aria-label={L.tierName} onChange={(e) => onNaam(e.target.value)} />
<button {...weg.pressProps} type="button" className="fp-tier__weg" data-pressed={weg.pressed ? "" : undefined} aria-label={`${L.removeTier}: ${tier.label}`}>×</button>
</>
);
}
export function TierList({ value, onChange, tileSize = 56, readOnly = false, labels: eigen, className, style, ...rest }: TierListProps) {
const L = { ...STANDAARD, ...eigen };
const reduced = useReducedMotion() ?? false;
const zones = useRef<Record<string, HTMLDivElement | null>>({});
const sleep = useRef<{ id: string; offX: number; offY: number } | null>(null);
const boven = useRef<{ zone: string; index: number } | null>(null);
const [dragId, setDragId] = useState<string | null>(null);
const [over, setOver] = useState<{ zone: string; index: number } | null>(null);
const px = useMotionValue(0);
const py = useMotionValue(0);
const dockX = useMotionValue(Number.POSITIVE_INFINITY);
const bakVeer = useSpringTransition("smooth");
const alle = useMemo(() => [...value.tiers.flatMap((t) => t.items), ...value.pool], [value]);
const slepend = dragId ? alle.find((i) => i.id === dragId) ?? null : null;
const zoneVolgorde = useMemo(() => [...value.tiers.map((t) => t.id), POOL], [value.tiers]);
const zoneOp = (x: number, y: number): string | null => {
for (const id of zoneVolgorde) {
const el = zones.current[id];
if (!el) continue;
const r = el.getBoundingClientRect();
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return id;
}
return null;
};
const indexIn = (zoneId: string, x: number, y: number, skip: string): number => {
const el = zones.current[zoneId];
if (!el) return 0;
const tegels = Array.from(el.querySelectorAll<HTMLElement>("[data-tile-id]")).filter((t) => t.getAttribute("data-tile-id") !== skip);
for (let i = 0; i < tegels.length; i += 1) {
const r = tegels[i]!.getBoundingClientRect();
if (y < r.top) return i;
if (y <= r.bottom && x < r.left + r.width / 2) return i;
}
return tegels.length;
};
const verplaats = useCallback((itemId: string, naarZone: string, index: number) => {
let verplaatst: TierItem | undefined;
const tiers = value.tiers.map((t) => {
const i = t.items.findIndex((x) => x.id === itemId);
if (i === -1) return t;
verplaatst = t.items[i];
return { ...t, items: t.items.filter((x) => x.id !== itemId) };
});
let pool = value.pool;
if (!verplaatst) {
const i = pool.findIndex((x) => x.id === itemId);
if (i !== -1) {
verplaatst = pool[i];
pool = pool.filter((x) => x.id !== itemId);
}
}
if (!verplaatst) return;
if (naarZone === POOL) {
const volgende = [...pool];
volgende.splice(clamp(index, 0, volgende.length), 0, verplaatst);
pool = volgende;
} else {
const ti = tiers.findIndex((t) => t.id === naarZone);
if (ti !== -1) {
const items = [...tiers[ti]!.items];
items.splice(clamp(index, 0, items.length), 0, verplaatst);
tiers[ti] = { ...tiers[ti]!, items };
}
}
onChange({ tiers, pool });
}, [value, onChange]);
const zetOver = (v: { zone: string; index: number } | null) => {
boven.current = v;
setOver(v);
};
const opTegelDown = (e: React.PointerEvent<HTMLElement>, itemId: string) => {
if (readOnly || e.button !== 0) return;
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
sleep.current = { id: itemId, offX: e.clientX - r.left, offY: e.clientY - r.top };
setDragId(itemId);
px.set(e.clientX - sleep.current.offX);
py.set(e.clientY - sleep.current.offY);
zetOver(null);
};
useEffect(() => {
if (!dragId) return;
const move = (e: PointerEvent) => {
const d = sleep.current;
if (!d) return;
px.set(e.clientX - d.offX);
py.set(e.clientY - d.offY);
const zone = zoneOp(e.clientX, e.clientY);
zetOver(zone ? { zone, index: indexIn(zone, e.clientX, e.clientY, d.id) } : null);
};
const up = () => {
const d = sleep.current;
const doel = boven.current;
if (d && doel) verplaats(d.id, doel.zone, doel.index);
sleep.current = null;
setDragId(null);
zetOver(null);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- alleen bij start/stop van een sleep
}, [dragId]);
// Toetsenbord: pijltjes verplaatsen binnen en tussen zones.
const opTegelKey = (e: React.KeyboardEvent<HTMLElement>, itemId: string, zoneId: string) => {
if (readOnly) return;
const zi = zoneVolgorde.indexOf(zoneId);
const items = zoneId === POOL ? value.pool : value.tiers.find((t) => t.id === zoneId)?.items ?? [];
const idx = items.findIndex((x) => x.id === itemId);
let doelZone = zoneId;
let doelIndex = idx;
if (e.key === "ArrowLeft") doelIndex = Math.max(0, idx - 1);
else if (e.key === "ArrowRight") doelIndex = Math.min(items.length - 1, idx + 1);
else if (e.key === "ArrowUp") { doelZone = zoneVolgorde[Math.max(0, zi - 1)] ?? zoneId; doelIndex = Number.MAX_SAFE_INTEGER; }
else if (e.key === "ArrowDown") { doelZone = zoneVolgorde[Math.min(zoneVolgorde.length - 1, zi + 1)] ?? zoneId; doelIndex = Number.MAX_SAFE_INTEGER; }
else return;
e.preventDefault();
if (doelZone === zoneId && doelIndex === idx) return;
verplaats(itemId, doelZone, doelIndex);
window.requestAnimationFrame(() => zones.current[doelZone]?.querySelector<HTMLElement>(`[data-tile-id="${itemId}"]`)?.focus());
};
const zetNaam = (tierId: string, label: string) => onChange({ ...value, tiers: value.tiers.map((t) => (t.id === tierId ? { ...t, label } : t)) });
const voegRijToe = () => onChange({ ...value, tiers: [...value.tiers, { id: uid(), label: "Nieuw", items: [] }] });
const verwijderRij = (tierId: string) => {
const rij = value.tiers.find((t) => t.id === tierId);
if (!rij) return;
onChange({ tiers: value.tiers.filter((t) => t.id !== tierId), pool: [...value.pool, ...rij.items] });
};
const renderZone = (zoneId: string, items: TierItem[], extra?: { dock?: boolean }) => (
<div
ref={(el) => { zones.current[zoneId] = el; }}
className="fp-tier__zone"
data-over={over?.zone === zoneId ? "" : undefined}
role="list"
onPointerMove={extra?.dock && !reduced ? (e) => dockX.set(e.clientX) : undefined}
onPointerLeave={extra?.dock ? () => dockX.set(Number.POSITIVE_INFINITY) : undefined}
>
{items.map((item, i) => (
<span key={item.id} className="fp-tier__slot" style={{ display: "contents" }}>
{over?.zone === zoneId && over.index === i && dragId !== item.id && <span className="fp-tier__spook" aria-hidden="true" />}
{dragId === item.id ? (
<span className="fp-tier__gat" aria-hidden="true" />
) : (
<Tegel item={item} size={tileSize} dragging={false} hint={L.grabHint.replace("{label}", item.label ?? item.id)} dockX={extra?.dock && !reduced ? dockX : undefined} onPointerDown={(e) => opTegelDown(e, item.id)} onKeyDown={(e) => opTegelKey(e, item.id, zoneId)} />
)}
</span>
))}
{over?.zone === zoneId && over.index >= items.filter((x) => x.id !== dragId).length && <span className="fp-tier__spook" aria-hidden="true" />}
</div>
);
return (
<div {...rest} className={["fp-tier", readOnly ? "fp-tier--readonly" : "", className].filter(Boolean).join(" ")} style={{ ...style, ["--fp-tier-tegel" as string]: `${tileSize}px` } as React.CSSProperties}>
<div className="fp-tier__rijen">
{value.tiers.map((tier, i) => (
<div key={tier.id} className="fp-tier__rij">
<div className="fp-tier__kop" style={{ background: tier.color ?? `color-mix(in srgb, var(--primary) ${Math.max(15, 85 - i * 14)}%, var(--muted))` }}>
{readOnly ? <span className="fp-tier__naam-vast">{tier.label}</span> : <RijKnoppen tier={tier} L={L} onNaam={(v) => zetNaam(tier.id, v)} onWeg={() => verwijderRij(tier.id)} />}
</div>
{renderZone(tier.id, tier.items)}
</div>
))}
</div>
{!readOnly && (
<div className="fp-tier__acties">
<Button variant="ghost" onPress={voegRijToe}>{L.addTier}</Button>
</div>
)}
<motion.div className="fp-tier__bak" layout transition={bakVeer}>
<span className="fp-tier__baklabel">{L.pool}</span>
{renderZone(POOL, value.pool, { dock: true })}
</motion.div>
{slepend && (
<motion.div className="fp-tier__zwever" style={{ x: px, y: py }} aria-hidden="true">
<TegelInhoud item={slepend} />
</motion.div>
)}
</div>
);
}
blocks/tier-list/tier-list.css
/**
* Tier-list-blocks: tier-list.
* Importeren in de app-root (de registry-site doet dat in app/globals.css).
*/
.fp-tier { position: relative; display: flex; flex-direction: column; gap: 0.75rem; width: 100%; color: var(--foreground); user-select: none; }
.fp-tier__rijen { display: flex; flex-direction: column; gap: 0.375rem; }
.fp-tier__rij { display: grid; grid-template-columns: 5.5rem minmax(0, 1fr); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--card); }
.fp-tier__kop { display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem; color: var(--primary-foreground); }
.fp-tier__naam { width: 100%; min-width: 0; border: 0; background: color-mix(in srgb, var(--background) 25%, transparent); border-radius: calc(var(--radius) * 0.5); padding: 0.25rem 0.375rem; font: inherit; font-weight: 700; color: inherit; text-align: center; outline: none; }
.fp-tier__naam:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
.fp-tier__naam-vast { flex: 1; text-align: center; font-weight: 700; }
.fp-tier__weg { flex-shrink: 0; width: 1.25rem; height: 1.25rem; border: 0; border-radius: 50%; background: color-mix(in srgb, var(--background) 25%, transparent); color: inherit; cursor: pointer; font: inherit; line-height: 1; opacity: 0.8; }
.fp-tier__weg:hover, .fp-tier__weg[data-pressed] { opacity: 1; }
.fp-tier__zone { display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; min-height: calc(var(--fp-tier-tegel) + 0.75rem); padding: 0.375rem; transition: background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-tier__zone[data-over] { background: color-mix(in srgb, var(--primary) 6%, transparent); }
/* Tegel: beeld of glyph; alleen schaduw/opacity zijn transitions — transform is van framer (dock) */
.fp-tier__tegel {
display: flex; align-items: center; justify-content: center; width: var(--fp-tier-tegel); height: var(--fp-tier-tegel); overflow: hidden; flex-shrink: 0;
border-radius: calc(var(--radius) * 1.5); background: var(--muted); color: var(--foreground); cursor: grab; outline: none; touch-action: none;
box-shadow: 0 1px 2px var(--shadow-overlay);
transition: box-shadow var(--motion-instant-duration) var(--motion-instant);
}
.fp-tier--readonly .fp-tier__tegel { cursor: default; }
.fp-tier__tegel:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
.fp-tier__tegel img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.fp-tier__glyph { font-size: calc(var(--fp-tier-tegel) * 0.4); font-weight: 600; line-height: 1; }
.fp-tier__gat { display: inline-block; width: var(--fp-tier-tegel); height: var(--fp-tier-tegel); border-radius: calc(var(--radius) * 1.5); background: color-mix(in srgb, var(--muted) 50%, transparent); }
.fp-tier__spook { display: inline-block; width: var(--fp-tier-tegel); height: var(--fp-tier-tegel); border-radius: calc(var(--radius) * 1.5); border: 2px dashed var(--primary); box-sizing: border-box; }
.fp-tier__acties { display: flex; }
/* De bak: dock met vergroting rond de muis */
.fp-tier__bak { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem; border: 1px dashed var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--muted) 40%, transparent); }
.fp-tier__bak .fp-tier__zone { min-height: calc(var(--fp-tier-tegel) * 1.5); align-items: flex-end; }
.fp-tier__bak .fp-tier__tegel { transform-origin: bottom center; }
.fp-tier__baklabel { font-size: 0.75rem; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-foreground); }
/* De slepende tegel: losse laag, volgt de aanwijzer via x/y (framer) */
.fp-tier__zwever {
position: fixed; top: 0; left: 0; z-index: 50; display: flex; align-items: center; justify-content: center; width: var(--fp-tier-tegel); height: var(--fp-tier-tegel); overflow: hidden;
border-radius: calc(var(--radius) * 1.5); background: var(--muted); color: var(--foreground); pointer-events: none; box-shadow: 0 12px 32px -8px var(--shadow-overlay); transform: scale(1.06);
}
.fp-tier__zwever img { width: 100%; height: 100%; object-fit: cover; }