← alle entries

stacked-list

FC-0056stacked-listblockv1.0.0

Ledenkaart: actieve leden met zoekveld bovenin; onderin een balk met drie portretten en +N die op een tik de kaart vult met de volledige lijst en eigen zoekveld. Rijen komen schuin binnen. Componeert search-field.

zelfde component — andere tokens

Actieve leden 3

  • Olivier de BruinOnlineProjectleider
  • Leo GarciaOnlineOntwerper
  • Mia ThomassenOnlineProjectleider
Ledenlijst7 leden+4

Tik op de balk onderin: hij groeit tot de hele kaart met de volledige lijst.

blocks/stacked-list/stacked-list.tsx
/**
 * StackedList — een ledenkaart: bovenin de actieve leden met zoekveld,
 * onderin een balk 'Alle leden' met drie portretten en +N; tik erop en de
 * balk groeit tot hij de kaart vult, met de volledige lijst en een eigen
 * zoekveld. Rijen komen schuin binnen, één voor één.
 *
 * De bron animeerde height/width/bottom/left/borderRadius van de balk
 * (layout per frame) en had hugeicons + vier hex-badgekleuren. Hier: de
 * balk wisselt van class (dicht/open) en Framer's layout-animatie vertaalt
 * dat naar transforms; badges kleuren via een `roleColor` van het project
 * met --muted als terugval. Componeert search-field (FC-0048).
 */

"use client";

import { useMemo, useState } from "react";
import type * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { usePress } from "../../primitives/ui/press";
import { SearchField } from "../../primitives/ui/search-field";

export interface Member {
  id: string;
  name: string;
  /** "Online", "17 min geleden" — vrije tekst van het project. */
  status?: string;
  online?: boolean;
  role?: string;
  /** Kleur van het rolbadge (CSS-kleur); zonder = --muted. */
  roleColor?: string;
  roleIcon?: React.ReactNode;
  avatar?: string;
}

export interface StackedListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
  members: readonly Member[];
  labels?: { active?: string; directory?: string; registered?: string; search?: string; searchAll?: string; add?: string; close?: string };
  onAdd?: () => void;
  onSelect?: (member: Member) => void;
}

function Portret({ m }: { m: Member }) {
  return m.avatar ? (
    // eslint-disable-next-line @next/next/no-img-element
    <img className="fp-stack__portret" src={m.avatar} alt="" width={48} height={48} loading="lazy" />
  ) : (
    <span className="fp-stack__portret fp-stack__portret--letter" aria-hidden="true">{m.name.charAt(0)}</span>
  );
}

function Rij({ m, onSelect }: { m: Member; onSelect?: (m: Member) => void }) {
  const veer = useSpringTransition("snappy", { mass: "content" });
  const { pressProps } = usePress({ onPress: () => onSelect?.(m), disabled: !onSelect });
  return (
    <motion.li
      {...(onSelect ? pressProps : {})}
      className="fp-stack__rij"
      role={onSelect ? "button" : undefined}
      tabIndex={onSelect ? 0 : undefined}
      variants={{ dicht: { opacity: 0, x: 10, y: 15, rotate: 1 }, open: { opacity: 1, x: 0, y: 0, rotate: 0 } }}
      transition={veer}
      style={{ originX: 1, originY: 1 }}
    >
      <span className="fp-stack__portret-vak">
        <Portret m={m} />
        {m.online && <span aria-hidden="true" className="fp-stack__online" />}
      </span>
      <span className="fp-stack__wie">
        <span className="fp-stack__naam">{m.name}</span>
        {m.status && <span className={m.online ? "fp-stack__status fp-stack__status--online" : "fp-stack__status"}>{m.status}</span>}
      </span>
      {m.role && (
        <span className="fp-stack__badge" style={m.roleColor ? ({ ["--fp-stack-rol" as string]: m.roleColor } as React.CSSProperties) : undefined}>
          {m.roleIcon}
          <span>{m.role}</span>
        </span>
      )}
    </motion.li>
  );
}

function IconKnop({ label, onPress, children, className }: { label: string; onPress?: () => void; children: React.ReactNode; className?: string }) {
  const { pressed, pressProps } = usePress({ onPress });
  return (
    <button {...pressProps} type="button" className={["fp-stack__icoonknop", className].filter(Boolean).join(" ")} aria-label={label} data-pressed={pressed ? "" : undefined}>
      {children}
    </button>
  );
}

export function StackedList({ members, labels, onAdd, onSelect, className, ...rest }: StackedListProps) {
  const t = { active: "Actieve leden", directory: "Ledenlijst", registered: "{n} leden", search: "Zoek een collega…", searchAll: "Zoek een lid…", add: "Lid toevoegen", close: "Sluiten", ...labels };
  const reduced = useReducedMotion() ?? false;
  const [open, setOpen] = useState(false);
  const [zoek, setZoek] = useState("");
  const [zoekAlles, setZoekAlles] = useState("");
  const balk = useSpringTransition("smooth", { mass: "system" });
  const fade = useSpringTransition("default", { mass: "content" });
  const { pressProps: openProps } = usePress({ onPress: () => setOpen(true), disabled: open });

  const filter = (q: string) => members.filter((m) => `${m.name} ${m.role ?? ""}`.toLowerCase().includes(q.trim().toLowerCase()));
  const actief = useMemo(() => filter(zoek).filter((m) => m.online), [members, zoek]); // eslint-disable-line react-hooks/exhaustive-deps
  const alles = useMemo(() => filter(zoekAlles), [members, zoekAlles]); // eslint-disable-line react-hooks/exhaustive-deps
  const eerste = members.slice(0, 3);

  return (
    <div {...rest} className={["fp-stack", className].filter(Boolean).join(" ")}>
      <div className="fp-stack__boven">
        <div className="fp-stack__kop">
          <h2 className="fp-stack__titel">{t.active} <span className="fp-stack__teller">{members.filter((m) => m.online).length}</span></h2>
          {onAdd && <IconKnop label={t.add} onPress={onAdd}><svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg></IconKnop>}
        </div>
        <SearchField placeholder={t.search} shortcut="" value={zoek} onChange={(e) => setZoek(e.target.value)} />
        <motion.ul className="fp-stack__lijst" initial={false} animate="open" variants={{ open: { transition: { staggerChildren: 0.04 } } }}>
          {actief.map((m) => <Rij key={m.id} m={m} onSelect={onSelect} />)}
        </motion.ul>
      </div>

      {/* De balk: dicht onderaan, open over de hele kaart — via class + layout-FLIP. */}
      <motion.div
        layout={!reduced}
        transition={balk}
        className={open ? "fp-stack__balk fp-stack__balk--open" : "fp-stack__balk"}
        {...(open ? {} : openProps)}
        role={open ? undefined : "button"}
        tabIndex={open ? undefined : 0}
      >
        <div className="fp-stack__balk-kop">
          <span className="fp-stack__balk-icoon" aria-hidden="true">
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 4-6 8-6s8 2 8 6" /></svg>
          </span>
          <motion.span layout="position" className="fp-stack__balk-tekst">
            <span className="fp-stack__balk-titel">{t.directory}</span>
            <span className="fp-stack__balk-sub">{t.registered.replace("{n}", String(members.length))}</span>
          </motion.span>
          <span className="fp-stack__balk-rechts">
            {!open && (
              <span className="fp-stack__stapel">
                {eerste.map((m) => <span key={m.id} className="fp-stack__stapel-item"><Portret m={m} /></span>)}
                {members.length > 3 && <span className="fp-stack__stapel-item fp-stack__stapel-rest">+{members.length - 3}</span>}
              </span>
            )}
            {open && (
              <IconKnop label={t.close} onPress={() => setOpen(false)} className="fp-stack__sluit">
                <svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
              </IconKnop>
            )}
          </span>
        </div>
        <AnimatePresence>
          {open && (
            <motion.div className="fp-stack__balk-inhoud" initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={fade}>
              <SearchField placeholder={t.searchAll} shortcut="" value={zoekAlles} onChange={(e) => setZoekAlles(e.target.value)} />
              <motion.ul className="fp-stack__lijst" initial="dicht" animate="open" variants={{ open: { transition: { staggerChildren: 0.03, delayChildren: 0.1 } } }}>
                {alles.map((m) => <Rij key={m.id} m={m} onSelect={onSelect} />)}
              </motion.ul>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
    </div>
  );
}
blocks/stacked-list/stacked-list.css
/** StackedList — importeren in de app-root (registry-site: app/globals.css). */

.fp-stack {
  position: relative; display: flex; flex-direction: column; overflow: hidden;
  width: 100%; max-width: 27.5rem; height: 36rem;
  background: var(--card); color: var(--card-foreground, var(--foreground));
  border: 1px solid var(--border); border-radius: calc(var(--radius) * 3);
}
.fp-stack__boven { display: flex; flex-direction: column; gap: 1rem; padding: 2rem 2rem 6rem; overflow-y: auto; flex: 1; scrollbar-width: thin; }
.fp-stack__kop { display: flex; align-items: center; justify-content: space-between; }
.fp-stack__titel { margin: 0; display: flex; align-items: center; gap: 0.5rem; font: inherit; font-size: 1.125rem; font-weight: 600; letter-spacing: -0.01em; }
.fp-stack__teller { padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 400; color: var(--muted-foreground); background: var(--muted); border-radius: 999px; font-variant-numeric: tabular-nums; }
.fp-stack__icoonknop {
  display: flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem;
  appearance: none; cursor: pointer; background: transparent; color: var(--muted-foreground);
  border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); border-radius: 999px;
  transition: background-color var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-stack__icoonknop:hover { background: var(--muted); }
.fp-stack__icoonknop[data-pressed] { transform: scale(0.92); }
.fp-stack__icoonknop:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-stack__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.fp-stack__rij { display: flex; align-items: center; gap: 1rem; padding: 1rem 0; border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); }
.fp-stack__rij:last-child { border-bottom: 0; }
.fp-stack__rij[role="button"] { cursor: pointer; }
.fp-stack__rij:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; border-radius: var(--radius); }
.fp-stack__portret-vak { position: relative; flex-shrink: 0; }
.fp-stack__portret { display: flex; align-items: center; justify-content: center; width: 3rem; height: 3rem; border-radius: 50%; object-fit: cover; background: var(--muted); box-shadow: 0 0 0 2px var(--card); }
.fp-stack__portret--letter { font-weight: 600; color: var(--muted-foreground); }
.fp-stack__online { position: absolute; right: 0; bottom: 0; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--card); box-shadow: inset 0 0 0 0.1875rem var(--card), inset 0 0 0 0.5rem var(--success); }
.fp-stack__wie { display: flex; flex-direction: column; gap: 0.375rem; min-width: 0; flex: 1; }
.fp-stack__naam { font-weight: 600; letter-spacing: -0.01em; line-height: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-stack__status { font-size: 0.875rem; font-weight: 500; line-height: 1; color: var(--muted-foreground); }
.fp-stack__status--online { color: var(--success); }
.fp-stack__badge {
  --fp-stack-rol: var(--muted-foreground);
  display: inline-flex; align-items: center; gap: 0.375rem; flex-shrink: 0;
  padding: 0.25rem 0.625rem; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.02em; white-space: nowrap;
  color: var(--fp-stack-rol);
  background: color-mix(in srgb, var(--fp-stack-rol) 10%, var(--card));
  border: 1px solid color-mix(in srgb, var(--fp-stack-rol) 30%, transparent);
  border-radius: 999px;
}

/* De balk: dicht = onderaan met marge; open = de hele kaart. Alleen classes — Framer's layout doet de rest. */
.fp-stack__balk {
  position: absolute; z-index: 5; display: flex; flex-direction: column; overflow: hidden;
  left: 1.25rem; right: 1.25rem; bottom: 1.25rem; height: 4.25rem;
  background: var(--card); border: 1px solid var(--border); border-radius: calc(var(--radius) * 1.5);
  cursor: pointer;
}
.fp-stack__balk:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-stack__balk--open { left: 0.625rem; right: 0.625rem; bottom: 0.625rem; top: 0.625rem; height: auto; cursor: default; border-radius: calc(var(--radius) * 2.5); }
.fp-stack__balk-kop { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; height: 4.25rem; flex-shrink: 0; padding: 0 0.75rem; }
.fp-stack__balk--open .fp-stack__balk-kop { border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); }
.fp-stack__balk-icoon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; flex-shrink: 0; color: var(--muted-foreground); background: var(--background); border: 1px solid var(--border); border-radius: var(--radius); }
.fp-stack__balk-tekst { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; min-width: 0; }
.fp-stack__balk-titel { font-weight: 500; letter-spacing: -0.01em; line-height: 1; }
.fp-stack__balk-sub { font-size: 0.75rem; line-height: 1; color: var(--muted-foreground); }
.fp-stack__balk-rechts { display: flex; align-items: center; }
.fp-stack__stapel { display: flex; }
.fp-stack__stapel-item { display: flex; margin-left: -0.75rem; }
.fp-stack__stapel-item:first-child { margin-left: 0; }
.fp-stack__stapel-item .fp-stack__portret { width: 2.5rem; height: 2.5rem; box-shadow: 0 0 0 1px var(--card); }
.fp-stack__stapel-rest { align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; border-radius: 50%; background: var(--muted); font-size: 0.875rem; color: var(--muted-foreground); box-shadow: 0 0 0 1px var(--card); }
.fp-stack__sluit { border-color: transparent; background: color-mix(in srgb, var(--muted) 60%, transparent); border-radius: var(--radius); }
.fp-stack__balk-inhoud { display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem 1.5rem; flex: 1; overflow-y: auto; scrollbar-width: thin; }