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.

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