← alle entries

account-menu

FC-0052account-menublockv1.0.0

Paneel achter je naam: portret, naam en e-mail, koppelingen met een pijltje op hover, en uitloggen in de statuskleur; alles schuift bij het openen één voor één van links in.

zelfde component — andere tokens

Rijen schuiven één voor één in; het pijltje verschijnt op hover.

blocks/account-menu/account-menu.tsx
/**
 * AccountMenu — het paneel achter je naam: portret, naam en e-mail, een
 * lijst koppelingen (bestellingen, adressen, instellingen) met een pijltje
 * dat op hover verschijnt, en onderaan uitloggen in de statuskleur. Alles
 * schuift bij het openen van links in, één voor één.
 *
 * De bron (UserProfileSidebar) had lucide-iconen en een losse spring
 * (stiffness 100/damping 15); hier: icon-slots, smooth-token met stagger,
 * uitloggen op usePress. Links renderen via `linkComponent` (a, of Next
 * Link) zodat het project zijn router houdt.
 */

"use client";

import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { usePress } from "../../primitives/ui/press";

export interface AccountMenuUser {
  name: string;
  email?: string;
  avatar?: string;
}

export interface AccountMenuItem {
  id: string;
  label: string;
  href: string;
  icon?: React.ReactNode;
  /** Extra ruimte boven dit item (groep-scheiding). */
  gapBefore?: boolean;
}

export interface AccountMenuProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  user: AccountMenuUser;
  items: readonly AccountMenuItem[];
  logout?: { label: string; icon?: React.ReactNode; onPress: () => void };
  /** Component voor links (default "a"; geef Next's Link mee in een Next-project). */
  linkComponent?: React.ElementType;
}

function Pijl() {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M9 6l6 6-6 6" /></svg>
  );
}

function Uitloggen({ label, icon, onPress }: { label: string; icon?: React.ReactNode; onPress: () => void }) {
  const { pressed, pressProps } = usePress({ onPress });
  return (
    <button {...pressProps} type="button" className="fp-acct__uit" data-pressed={pressed ? "" : undefined}>
      {icon && <span className="fp-acct__icoon">{icon}</span>}
      <span>{label}</span>
    </button>
  );
}

export function AccountMenu({ user, items, logout, linkComponent: Link = "a", className, ...rest }: AccountMenuProps) {
  const reduced = useReducedMotion() ?? false;
  const veer = useSpringTransition("smooth", { mass: "content" });
  let stap = 0;
  const entree = () => {
    const i = stap++;
    return {
      initial: reduced ? false : { opacity: 0, x: -20 },
      animate: { opacity: 1, x: 0 },
      transition: { ...veer, delay: reduced ? 0 : i * 0.08 },
    } as const;
  };

  return (
    <aside {...rest} aria-label="Account" className={["fp-acct", className].filter(Boolean).join(" ")}>
      <motion.div className="fp-acct__kop" {...entree()}>
        {user.avatar ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img className="fp-acct__portret" src={user.avatar} alt="" width={48} height={48} />
        ) : (
          <span className="fp-acct__portret fp-acct__portret--letter" aria-hidden="true">{user.name.charAt(0)}</span>
        )}
        <span className="fp-acct__wie">
          <span className="fp-acct__naam">{user.name}</span>
          {user.email && <span className="fp-acct__mail">{user.email}</span>}
        </span>
      </motion.div>
      <motion.hr className="fp-acct__lijn" {...entree()} />
      <nav className="fp-acct__nav" aria-label="Accountmenu">
        {items.map((item) => (
          <motion.div key={item.id} className={item.gapBefore ? "fp-acct__rij fp-acct__rij--ruimte" : "fp-acct__rij"} {...entree()}>
            <Link href={item.href} className="fp-acct__link">
              {item.icon && <span className="fp-acct__icoon">{item.icon}</span>}
              <span>{item.label}</span>
              <span className="fp-acct__pijl"><Pijl /></span>
            </Link>
          </motion.div>
        ))}
      </nav>
      {logout && (
        <motion.div className="fp-acct__voet" {...entree()}>
          <Uitloggen {...logout} />
        </motion.div>
      )}
    </aside>
  );
}
blocks/account-menu/account-menu.css
/** AccountMenu — importeren in de app-root (registry-site: app/globals.css). */

.fp-acct {
  display: flex; flex-direction: column; width: 100%; max-width: 20rem; padding: 1rem;
  background: var(--card); color: var(--card-foreground, var(--foreground));
  border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow-overlay);
}
.fp-acct__kop { display: flex; align-items: center; gap: 1rem; padding: 0.5rem; }
.fp-acct__portret { width: 3rem; height: 3rem; flex-shrink: 0; border-radius: 50%; object-fit: cover; background: var(--muted); }
.fp-acct__portret--letter { display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 1.125rem; color: var(--muted-foreground); }
.fp-acct__wie { display: flex; flex-direction: column; min-width: 0; }
.fp-acct__naam { font-weight: 600; font-size: 1.125rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-acct__mail { font-size: 0.875rem; color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-acct__lijn { margin: 1rem 0; border: 0; border-top: 1px solid var(--border); }
.fp-acct__nav { display: flex; flex-direction: column; gap: 0.25rem; }
.fp-acct__rij--ruimte { margin-top: 1.5rem; }
.fp-acct__link {
  display: flex; align-items: center; gap: 0.75rem;
  padding: 0.625rem 0.75rem;
  font-size: 0.875rem; font-weight: 500; color: var(--muted-foreground); text-decoration: none;
  border-radius: calc(var(--radius) * 0.75);
  transition: background-color var(--motion-instant-duration) var(--motion-instant), color var(--motion-instant-duration) var(--motion-instant);
}
.fp-acct__link:hover, .fp-acct__link:focus-visible { background: var(--muted); color: var(--foreground); outline: none; }
.fp-acct__icoon { display: flex; width: 1.25rem; height: 1.25rem; flex-shrink: 0; }
.fp-acct__pijl { display: flex; margin-left: auto; opacity: 0; transition: opacity var(--motion-instant-duration) var(--motion-instant); }
.fp-acct__link:hover .fp-acct__pijl, .fp-acct__link:focus-visible .fp-acct__pijl { opacity: 1; }
.fp-acct__voet { margin-top: 1rem; }
.fp-acct__uit {
  display: flex; align-items: center; gap: 0.75rem; width: 100%;
  padding: 0.625rem 0.75rem;
  appearance: none; border: 0; background: transparent; cursor: pointer; text-align: left;
  font: inherit; font-size: 0.875rem; font-weight: 500; color: var(--danger);
  border-radius: calc(var(--radius) * 0.75);
  transition: background-color var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-acct__uit:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); }
.fp-acct__uit[data-pressed] { transform: scale(0.98); }
.fp-acct__uit:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }