AccountMenu
ui accountmenushell
Konto-Pille oben rechts: Name + Avatar, im Menü Konto-Block („Gerät freigeschaltet für“), Einstellungen, Theme-Zyklus und optionale App-Extras. Die Lizenzstufe zeigt die StatusPill der Kopfzeile — hier gibt es bewusst kein Plan-Badge. Rein präsentational: jede App verdrahtet ihre Stores über Props/Callbacks dahinter (`extraItems` nimmt app-spezifische Einträge auf, z. B. presenters Fernsteuerung).
Keine interaktive Live-Demo — siehe Code & Beispiele unten.
Abhängigkeiten
Barrierefreiheit
- Erbt Fokus-Handling, Escape und Klick-außerhalb vom DropdownMenu.
- Der Trigger trägt einen Namen auch dann, wenn kein Nutzername gesetzt ist.
Beispiele
In der Kopfzeile
<AccountMenu
displayName={name}
konto={konto}
onOpenSettings={() => openSettings()}
themeMode={theme}
onCycleTheme={cycleTheme}
/> Genutzte Tokens · 12
--bg--border--ease-standard--fg--focus-ring--motion-fast--muted--radius-pill--space-2--space-3--text-sm--text-xs
Quelldateien
components/ui/AccountMenu.tsx tsx
import type { ReactNode } from 'react';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from './DropdownMenu.tsx';
import { ProfileIcon, SettingsIcon } from './Icon.tsx';
import styles from './AccountMenu.module.css';
/**
* Login-/Konto-Pille oben rechts (aus media-presenter `features/topbar/
* ProfileMenu.tsx` + `ThemeMenuItem`). Rein präsentational: Name, Konto-Block,
* Einstellungen-Eintrag, Theme-Zyklus und optionale App-Extras kommen als
* Props/Callbacks — jede App verdrahtet ihre Stores dahinter. Die Lizenzstufe
* zeigt die Kopfzeile als eigene „Lizenz"-Pille — hier gibt es kein Plan-Badge.
*/
export type ThemeMode = 'auto' | 'light' | 'dark';
export interface AccountMenuProps {
/** Name links vom Avatar (Gemeinde/CT-Anzeigename); null → nur Avatar.
* Die Lizenzstufe zeigt die App-Kopfzeile als eigene „Lizenz"-Pille
* (StatusIndicators) — hier trägt die Pille bewusst KEIN Plan-Badge mehr. */
displayName?: string | null;
/** Konto-Anzeige-Daten (Gemeinde + E-Mail, aus dem Heartbeat) — als
* Info-Block oben im Menü. null/weggelassen → kein Block. */
konto?: { name: string; email: string } | null;
onOpenSettings: () => void;
/** Aktueller Theme-Modus (für den Menü-Eintrag). */
themeMode: ThemeMode;
/** Zykliert das Theme (auto→hell→dunkel); Menü bleibt offen. */
onCycleTheme: () => void;
/** App-spezifische Zusatz-Einträge (presenter: „Fernsteuerung"). */
extraItems?: ReactNode;
ariaLabel?: string;
}
const THEME_LABEL: Record<ThemeMode, string> = {
auto: 'Darstellung: Automatisch',
light: 'Darstellung: Hell',
dark: 'Darstellung: Dunkel',
};
const THEME_TITLE: Record<ThemeMode, string> = {
auto: 'Darstellung folgt dem System. Klicken, um Hell zu erzwingen.',
light: 'Helle Darstellung. Klicken für Dunkel.',
dark: 'Dunkle Darstellung. Klicken für Automatisch.',
};
export function AccountMenu({
displayName,
konto = null,
onOpenSettings,
themeMode,
onCycleTheme,
extraItems,
ariaLabel = 'Profilmenü',
}: AccountMenuProps) {
const name = displayName ?? null;
return (
<DropdownMenu
ariaLabel={ariaLabel}
minWidth={300}
trigger={({ open, toggle }) => (
<button
type="button"
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
aria-haspopup="menu"
aria-expanded={open}
aria-label={ariaLabel}
title={name ?? 'Konto'}
onClick={toggle}
>
{name && <span className={styles.name}>{name}</span>}
<span className={styles.avatar}>
<ProfileIcon size={20} />
</span>
</button>
)}
>
{konto && (
<>
{/* Das ist GERÄTE-Zugehörigkeit, kein Anmeldezustand: der Name kommt
aus dem Heartbeat (Lizenzschlüssel) und überlebt die Portal-
Abmeldung absichtlich — das Bühnengerät bleibt freigeschaltet,
wenn niemand angemeldet ist (ADR-03). Ohne die Beschriftung liest
sich Name + E-Mail wie „angemeldet als", und die Abmeldung in den
Einstellungen wirkt dann kaputt. */}
<div className={styles.accountInfo}>
<span className={styles.accountLabel}>Gerät freigeschaltet für</span>
<span className={styles.accountName}>{konto.name}</span>
<span className={styles.accountEmail}>{konto.email}</span>
</div>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={onOpenSettings}>
<SettingsIcon size={16} />
<span>Einstellungen</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCycleTheme} closeOnSelect={false} title={THEME_TITLE[themeMode]}>
<ThemeGlyph mode={themeMode} />
<span>{THEME_LABEL[themeMode]}</span>
</DropdownMenuItem>
{extraItems}
</DropdownMenu>
);
}
/** Theme-Glyph je Modus (Sonne/Mond/Auto) — inline aus presenter ThemeMenuItem. */
function ThemeGlyph({ mode }: { mode: ThemeMode }) {
const common = {
width: 16,
height: 16,
viewBox: '0 0 16 16',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.6,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
};
if (mode === 'light') {
return (
<svg {...common}>
<circle cx="8" cy="8" r="3" />
<line x1="8" y1="1.5" x2="8" y2="3" />
<line x1="8" y1="13" x2="8" y2="14.5" />
<line x1="1.5" y1="8" x2="3" y2="8" />
<line x1="13" y1="8" x2="14.5" y2="8" />
<line x1="3.3" y1="3.3" x2="4.4" y2="4.4" />
<line x1="11.6" y1="11.6" x2="12.7" y2="12.7" />
<line x1="3.3" y1="12.7" x2="4.4" y2="11.6" />
<line x1="11.6" y1="4.4" x2="12.7" y2="3.3" />
</svg>
);
}
if (mode === 'dark') {
return (
<svg {...common}>
<path d="M13.5 9.2A5.5 5.5 0 1 1 6.8 2.5a4.5 4.5 0 0 0 6.7 6.7Z" />
</svg>
);
}
return (
<svg {...common}>
<circle cx="8" cy="8" r="5.5" />
<path d="M8 2.5a5.5 5.5 0 0 0 0 11Z" fill="currentColor" stroke="none" />
</svg>
);
}
components/ui/AccountMenu.module.css css
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 32px;
padding: 0 2px 0 var(--space-3);
border-radius: var(--radius-pill);
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
cursor: pointer;
font: inherit;
transition:
border-color var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.trigger:hover {
border-color: color-mix(in oklab, var(--border), var(--fg) 25%);
color: var(--fg);
}
.trigger:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.triggerOpen {
border-color: color-mix(in oklab, var(--border), var(--fg) 25%);
color: var(--fg);
}
.name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14rem;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
flex: 0 0 auto;
}
/* Konto-Block oben im Menü: Gemeinde + E-Mail (nicht interaktiv). */
.accountInfo {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
max-width: 320px;
}
/* Kleines Etikett über dem Namen: macht aus „angemeldet als" die ehrliche
Aussage „dieses Gerät gehört zu …". */
.accountLabel {
color: var(--muted);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.accountName {
font-weight: 600;
overflow-wrap: anywhere;
}
.accountEmail {
color: var(--muted);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
Holen
# MCP (Claude Code / Cursor)
get_component({ name: "account-menu" })
# Registry direkt
https://www.ekklesi.tools/design/registry/components/account-menu.json