← alle entries

tier-list

FC-0087Data & lijstentier-listblockv1.0.0

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.

zelfde component — andere tokens
α
β
γ
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; }