"use client"; import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; /* * Profile Menu – an account card with the signed-in user, menu actions and profile setup progress, in a single self-contained file. * The styles are injected below, so no extra files are needed. */ export type ProfileMenuItem = { id: string; label: string; icon?: ReactNode; /** Small pill on the right, e.g. "New" */ badge?: string; onSelect?: () => void; }; export type ProfileSetup = { /** Steps finished so far */ done: number; total: number; /** The next open step, shown as a shortcut under the bar */ next?: string; onContinue?: () => void; }; export type ProfileMenuProps = { name: string; email: string; /** Replaces the default avatar */ avatar?: ReactNode; items?: ProfileMenuItem[]; /** Profile setup progress under the actions – leave empty to hide */ setup?: ProfileSetup; className?: string; }; // Counts get separators; anything that isn't a finite number reads as 0 const count = new Intl.NumberFormat("en"); const finite = (n: unknown) => (typeof n === "number" && Number.isFinite(n) ? n : 0); export function ProfileMenu({ name, email, avatar, items = defaultItems, setup, className, }: ProfileMenuProps) { const listRef = useRef(null); // Gaps in the list are skipped, an image that fails to load falls back to the default avatar const list = (Array.isArray(items) ? items : []).filter((item): item is ProfileMenuItem => !!item && typeof item === "object"); const [broken, setBroken] = useState(false); const shownName = typeof name === "string" ? name.trim() : ""; // The progress bar fills up once the card is on screen const [filled, setFilled] = useState(false); const rootRef = useRef(null); useEffect(() => { const el = rootRef.current; if (!el) return; const io = new IntersectionObserver(([e]) => e.isIntersecting && (setFilled(true), io.disconnect()), { threshold: 0.3, }); io.observe(el); return () => io.disconnect(); }, []); // Arrow keys move between the actions, like in a native menu const onKey = (e: KeyboardEvent) => { const buttons = [...(listRef.current?.querySelectorAll("button") ?? [])]; const i = buttons.indexOf(document.activeElement as HTMLButtonElement); const next = e.key === "ArrowDown" ? (i + 1) % buttons.length : e.key === "ArrowUp" ? (i - 1 + buttons.length) % buttons.length : e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : null; if (next === null) return; e.preventDefault(); buttons[next]?.focus(); }; return ( <>
setBroken(true)}> {avatar && !broken ? avatar : } {shownName && ( {shownName} )} {email}
{list.map((item, i) => ( ))}
{setup && }
); } /** Compact setup progress in the gray extension: count, bar and the next step on one line */ function SetupProgress({ done: rawDone, total: rawTotal, next, onContinue }: ProfileSetup) { const total = Math.max(0, finite(rawTotal)); const done = Math.min(total, Math.max(0, finite(rawDone))); const ratio = total > 0 ? done / total : 0; const percent = Math.round(ratio * 100); const open = next && done < total; return (
Profile setup {count.format(done)}/{count.format(total)}
{open && ( )}
); } /* ---------- Icons ---------- */ const Svg = ({ children }: { children: ReactNode }) => ( ); export const SettingsIcon = () => ( ); export const LanguageIcon = () => ( ); export const DocsIcon = () => ( ); export const AppsIcon = () => ( ); export const LogoutIcon = () => ( ); /** Default avatar: a person icon centered on a soft blue disc */ const DefaultAvatar = () => ( ); const ArrowIcon = () => ( ); const defaultItems: ProfileMenuItem[] = [ { id: "settings", label: "Settings", icon: }, { id: "docs", label: "Docs", icon: }, { id: "apps", label: "Apps", icon: , badge: "New" }, { id: "logout", label: "Logout", icon: }, ]; /* ---------- Styles ---------- */ const css = /* css */ ` .ui-pm { --ui-pm-card: #ffffff; --ui-pm-line: #ececec; --ui-pm-ink: #171717; --ui-pm-text: #404040; --ui-pm-muted: #8f8f8f; --ui-pm-icon: #737373; --ui-pm-hover: #f5f5f5; --ui-pm-off: #f0f0f0; --ui-pm-fill: #2f6bff; --ui-pm-badge: #e11d48; --ui-pm-badge-wash: #fff0f3; --ui-pm-ease: cubic-bezier(0.22, 1, 0.36, 1); --ui-pm-tray: #f0f0f0; box-sizing: border-box; width: 100%; max-width: 320px; /* Like the Chat Composer: the white menu card sits on a gray tray, the setup progress lives in the tray below */ background: var(--ui-pm-tray); /* The gray tray frames the white card on every side */ padding: 6px; border-radius: 22px; font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--ui-pm-ink); -webkit-font-smoothing: antialiased; } .ui-pm *, .ui-pm *::before, .ui-pm *::after { box-sizing: border-box; } .ui-pm__card { padding: 16px 8px 8px; background: var(--ui-pm-card); border: 1px solid #e5e5e5; border-radius: 16px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); } .ui-pm__user { display: flex; align-items: center; gap: 14px; padding: 0 10px 12px; } .ui-pm__avatar { flex: none; width: 42px; height: 42px; overflow: hidden; border-radius: 50%; color: var(--ui-pm-fill); } .ui-pm__avatar > * { display: block; width: 100%; height: 100%; object-fit: cover; } .ui-pm__who { display: grid; min-width: 0; } .ui-pm__name { overflow-wrap: anywhere; font-size: 15px; font-weight: 500; line-height: 22px; } .ui-pm__email { overflow: hidden; font-size: 13px; line-height: 20px; color: var(--ui-pm-muted); white-space: nowrap; text-overflow: ellipsis; } /* The column may shrink, so long labels shorten instead of widening the card; a long list scrolls */ .ui-pm__items { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; max-height: 380px; overflow-y: auto; overscroll-behavior: contain; } .ui-pm__item { display: flex; align-items: center; gap: 12px; width: 100%; height: 36px; padding: 0 12px; border-radius: 12px; } .ui-pm__item { margin: 0; border: 0; background: transparent; font: inherit; text-align: left; color: var(--ui-pm-text); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; } .ui-pm__item:hover, .ui-pm__item:focus-visible { background: var(--ui-pm-hover); color: var(--ui-pm-ink); outline: none; } .ui-pm__item:active { background: #efefef; } .ui-pm__icon { display: grid; place-items: center; flex: none; width: 20px; height: 20px; color: var(--ui-pm-icon); transition: color 160ms ease; } .ui-pm__icon svg { width: 20px; height: 20px; } .ui-pm__item:hover .ui-pm__icon, .ui-pm__item:focus-visible .ui-pm__icon { color: var(--ui-pm-ink); } .ui-pm__label { flex: 1; min-width: 0; overflow: hidden; font-size: 14px; line-height: 20px; white-space: nowrap; text-overflow: ellipsis; } .ui-pm__badge { flex: none; max-width: 50%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; padding: 2px 8px; border-radius: 999px; background: var(--ui-pm-badge-wash); font-size: 12px; font-weight: 500; line-height: 18px; color: var(--ui-pm-badge); } /* Profile setup in the gray extension – kept to two short lines and a bar */ .ui-pm__setup { padding: 10px 18px 12px; } .ui-pm__setup-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 13px; line-height: 18px; } .ui-pm__setup-title { color: var(--ui-pm-text); font-weight: 500; } .ui-pm__setup-count { font-variant-numeric: tabular-nums; color: var(--ui-pm-muted); } .ui-pm__track { display: block; height: 4px; margin-top: 7px; overflow: hidden; border-radius: 2px; background: rgba(0, 0, 0, 0.07); } .ui-pm__fill { display: block; height: 100%; border-radius: inherit; background: var(--ui-pm-fill); transform-origin: left center; scale: 0 1; transition: scale 900ms var(--ui-pm-ease) 150ms; } .ui-pm[data-filled] .ui-pm__fill { scale: 1 1; } .ui-pm__next { display: flex; align-items: center; gap: 8px; width: calc(100% + 16px); margin: 6px -8px 0; padding: 3px 8px; border: 0; border-radius: 8px; background: transparent; font: inherit; text-align: left; color: var(--ui-pm-ink); cursor: pointer; transition: background-color 160ms ease; } .ui-pm__next:hover, .ui-pm__next:focus-visible { background: rgba(0, 0, 0, 0.04); outline: none; } .ui-pm__next-label { flex: 1; min-width: 0; overflow: hidden; font-size: 13px; line-height: 20px; white-space: nowrap; text-overflow: ellipsis; } .ui-pm__next-hint { color: var(--ui-pm-muted); } .ui-pm__next svg { flex: none; width: 14px; height: 14px; color: var(--ui-pm-icon); transition: transform 200ms var(--ui-pm-ease), color 160ms ease; } .ui-pm__next:hover svg { transform: translateX(2px); color: var(--ui-pm-ink); } @media (prefers-reduced-motion: reduce) { .ui-pm__item, .ui-pm__icon, .ui-pm__fill, .ui-pm__next, .ui-pm__next svg { transition: none; } } /* Dark mode: follows a .dark class or data-theme="dark" on any ancestor, e.g. */ :where(.dark, [data-theme="dark"]) .ui-pm { --ui-pm-card: #232326; --ui-pm-line: rgba(255, 255, 255, 0.08); --ui-pm-ink: #ededed; --ui-pm-text: #c4c4c4; --ui-pm-muted: #8f8f8f; --ui-pm-icon: #8f8f8f; --ui-pm-hover: #2a2a2d; --ui-pm-off: #161618; --ui-pm-badge: #fb7185; --ui-pm-badge-wash: rgba(225, 29, 72, 0.16); --ui-pm-tray: #161618; } :where(.dark, [data-theme="dark"]) .ui-pm__card { border-color: rgba(255, 255, 255, 0.08); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); } :where(.dark, [data-theme="dark"]) .ui-pm__avatar { color: #6b95ff; } :where(.dark, [data-theme="dark"]) .ui-pm__item:active { background: #313135; } :where(.dark, [data-theme="dark"]) .ui-pm__track { background: rgba(255, 255, 255, 0.1); } :where(.dark, [data-theme="dark"]) .ui-pm__next:hover, :where(.dark, [data-theme="dark"]) .ui-pm__next:focus-visible { background: rgba(255, 255, 255, 0.06); } `;