account-menu
FC-0052account-menublockv1.0.0Paneel 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.
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; }