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.
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