SettingsShell

ui layoutsettingsshell

Einstellungen-Rahmen mit linker Tab-Sidebar auf Basis des geteilten Modal. Die Shell weiß nichts über Konto/ChurchTools — Tabs und Panel-Inhalte liefert die App.

Keine interaktive Live-Demo — siehe Code & Beispiele unten.

Abhängigkeiten

Barrierefreiheit

  • Sidebar ist role="tablist" mit aria-orientation="vertical", Einträge tragen aria-selected.
  • Der Inhaltsbereich ist role="tabpanel".

Beispiele

Mit zwei Tabs
<SettingsShell
  open={open}
  onClose={close}
  tabs={[{ id: "account", label: "Konto", icon: <ProfileIcon /> }]}
  activeTab={tab}
  onSelectTab={setTab}
>
  {tab === "account" && <AccountPanel />}
</SettingsShell>

Genutzte Tokens · 13

--accent--bg--border--ease-standard--fg--focus-ring--font-body--motion-fast--muted--radius-sm--space-2--space-3--space-6

Quelldateien

components/ui/SettingsShell.tsx tsx
import { Fragment, type ReactNode } from 'react';
import { Modal } from './Modal.tsx';
import { SectionLabel } from './SectionLabel.tsx';
import styles from './SettingsShell.module.css';

/**
 * Einstellungen-Rahmen mit linker Tab-Sidebar (aus media-presenter
 * `features/settings/SettingsOverlay.tsx`). Baut auf dem geteilten `Modal`;
 * die konkreten Tabs und Panel-Inhalte liefert die App — die Shell weiß nichts
 * über Konto/ChurchTools/etc. Vertrag: das Content-Padding gehört der Shell,
 * Panels bringen KEIN eigenes Außen-Padding mit.
 */
export interface SettingsTabEntry {
  id: string;
  label: string;
  icon: ReactNode;
  /** Optionale Gruppe: beim Wechsel der Gruppe zwischen aufeinanderfolgenden
   *  Tabs rendert die Sidebar eine Zwischenüberschrift (z. B. „Development"). */
  group?: string;
}

export interface SettingsShellProps {
  open: boolean;
  onClose: () => void;
  tabs: SettingsTabEntry[];
  activeTab: string;
  onTabChange: (id: string) => void;
  /** Inhalt des aktiven Tabs (die App rendert nach `activeTab`). */
  children: ReactNode;
  title?: string;
}

export function SettingsShell({
  open,
  onClose,
  tabs,
  activeTab,
  onTabChange,
  children,
  title = 'Einstellungen',
}: SettingsShellProps) {
  if (!open) return null;
  return (
    <Modal open onClose={onClose} title={title} size="lg" bodyClassName={styles.body}>
      <nav className={styles.sidebar} role="tablist" aria-orientation="vertical" aria-label={title}>
        {tabs.map((t, i) => (
          <Fragment key={t.id}>
            {t.group !== undefined && t.group !== tabs[i - 1]?.group && (
              <SectionLabel as="div" className={styles.sidebarSection}>
                {t.group}
              </SectionLabel>
            )}
            <button
              type="button"
              role="tab"
              aria-selected={activeTab === t.id}
              className={`${styles.tab} ${activeTab === t.id ? styles.active : ''}`}
              onClick={() => onTabChange(t.id)}
            >
              <span className={styles.icon}>{t.icon}</span>
              <span className={styles.label}>{t.label}</span>
            </button>
          </Fragment>
        ))}
      </nav>
      <div className={styles.content} role="tabpanel">
        {children}
      </div>
    </Modal>
  );
}
components/ui/SettingsShell.module.css css
/* Ersetzt Modals Default-Body: volle Breite, feste Höhe, Sidebar + Content.
 * Die feste Höhe verhindert, dass der Dialog beim Tab-Wechsel springt. */
.body {
  display: grid;
  grid-template-columns: 220px 1fr;
  height: min(680px, 78vh);
  overflow: hidden;
}

.sidebar {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--space-3);
  border-right: 1px solid var(--border);
  background: var(--bg);
  overflow-y: auto;
}

.tab {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: 8px 10px;
  background: transparent;
  border: none;
  border-radius: var(--radius-sm);
  color: var(--muted);
  font-family: var(--font-body);
  font-size: 0.875rem;
  text-align: left;
  cursor: pointer;
  transition:
    color var(--motion-fast) var(--ease-standard),
    background var(--motion-fast) var(--ease-standard);
}

.tab:hover {
  color: var(--fg);
  background: color-mix(in oklab, var(--fg), transparent 95%);
}

.tab:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
}

.tab.active {
  color: var(--fg);
  background: color-mix(in oklab, var(--accent), transparent 88%);
}

.icon {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  flex-shrink: 0;
}

.label {
  line-height: 1.2;
}

/* Zwischenüberschrift in der Sidebar (z. B. „Development"), trennt Gruppen ab. */
.sidebarSection {
  margin: var(--space-3) 0 4px;
  padding: 0 10px;
}

/* Das Padding gehört der Shell (Vertrag): Panels bringen kein Außen-Padding mit. */
.content {
  display: flex;
  flex-direction: column;
  gap: var(--space-6);
  min-width: 0;
  padding: var(--space-6);
  overflow-y: auto;
}

/* Schmale Screens (Mobil): Sidebar wird zur horizontalen Tab-Leiste oben,
 * Inhalt darunter — gleiche Struktur, keine App-Anpassung nötig. */
@media (max-width: 640px) {
  .body {
    grid-template-columns: 1fr;
    grid-template-rows: auto 1fr;
    height: min(680px, 85vh);
  }

  .sidebar {
    flex-direction: row;
    overflow-x: auto;
    overflow-y: hidden;
    border-right: none;
    border-bottom: 1px solid var(--border);
  }

  .tab {
    flex-shrink: 0;
  }
}

Holen

# MCP (Claude Code / Cursor)
get_component({ name: "settings-shell" })

# Registry direkt
https://www.ekklesi.tools/design/registry/components/settings-shell.json