AppWindow
ui layoutshellapp-window
Das gemeinsame App-Fenster der Produktfamilie: Kopfzeile · Arbeitsbereich · optionale Statuszeile · optionale Icon-Tab-Leiste. Dazu AppWindowColumns — das 3-Spalten-Raster mit optional resizbaren Seitenspalten (über useColumnWidth) oder festen Breiten, plus opt-in Einspalten-Modus für schmale Fenster. Store-frei: alle Inhalte sind Slots, alle Zustände Props/Callbacks.
Abhängigkeiten
Barrierefreiheit
- Kopfzeile ist ein <header>, die Content-Mitte ein <main>, die Seitenspalten sind <aside>.
- Optionale Bereiche werden über die ANWESENHEIT der Prop gesteuert, nicht über Booleans — fehlt `tabs`, existiert die Rasterzeile nicht (keine leere 56px-Leiste).
- Der Einspalten-Modus (activePane) ist opt-in: ohne die Prop greifen die Media-Queries nicht. Apps ohne Mobile-Konzept erben so kein Layout, für das ihre Ansichten nicht gebaut sind.
Beispiele
presenter — vier Zeilen, resizbare Spalten
const links = useColumnWidth({ storageKey: 'mp.shell.left-width', cssVar: '--app-window-left', defaultWidth: APP_WINDOW_COL.default, min: APP_WINDOW_COL.min, max: APP_WINDOW_COL.max });
<AppWindow header={<Topbar />} statusBar={<ProgressBar />} tabs={<SectionNav />}>
<AppWindowColumns left={<Library />} right={<LiveRail />} resize={{ left: links, right: rechts }}>
<Stage />
</AppWindowColumns>
</AppWindow> worship — zwei Zeilen, feste Spalten, Mobile-Umschaltung
<AppWindow header={<Header />} fit="fill">
<AppWindowColumns
left={<Events />}
right={<Details />}
leftWidth="clamp(300px, 22vw, 380px)"
rightWidth="clamp(320px, 24vw, 400px)"
activePane={pane}
hideSides={vollbild}
>
<Render />
</AppWindowColumns>
</AppWindow> Genutzte Tokens · 7
--app-window-col-narrow--app-window-left--app-window-right--app-window-tabbar-height--bg--border--surface
Quelldateien
components/ui/AppWindow.tsx tsx
import type { CSSProperties, ReactNode } from 'react';
import { PanelResizer } from './PanelResizer.tsx';
import styles from './AppWindow.module.css';
/**
* Das gemeinsame App-Fenster der Produktfamilie (aus media-presenter
* `app/AppShell.tsx`, Header-Raster aus worship `app/WorshipHeader`).
*
* Vier Zeilen: Kopfzeile · Arbeitsbereich · optionale Statuszeile · optionale
* Icon-Tab-Leiste. Store-frei — alle Inhalte kommen als Slots, alle Zustände
* als Props/Callbacks („Softkey-Hooks", siehe EXTRACTION-CONTRACTS.md).
*
* Optionale Teile werden über die ANWESENHEIT der Prop gesteuert, nicht über
* Booleans: fehlt `tabs`, existiert die Rasterzeile gar nicht — sonst bekäme
* eine App ohne Tab-Leiste einen leeren 56px-Streifen.
*/
/** Standard-, Minimal- und Maximalbreite der Seitenspalten.
* Die Klemmung passiert in `useColumnWidth` (JS), deshalb stehen die Werte
* hier und nicht als CSS-Token — dort wären sie wirkungslos. */
export const APP_WINDOW_COL = { default: 340, min: 240, max: 800 } as const;
export interface AppWindowProps {
/** Kopfzeile — in der Regel `<AppWindowHeader>`. */
header: ReactNode;
/** Arbeitsbereich (eine Zeile, 1fr). */
children: ReactNode;
/** 100 % breiter Slot direkt über den Tabs, z. B. ein Fortschrittsbalken
* für laufende Hintergrundaufgaben. Fehlt die Prop, entfällt die Zeile. */
statusBar?: ReactNode;
/** Untere Icon-Tab-Leiste — in der Regel `<AppWindowTabs>`. Apps ohne
* Bereichs-Navigation (worship) lassen sie weg. */
tabs?: ReactNode;
/**
* `viewport` → `100dvh`: die App füllt das Browser-/Tauri-Fenster (presenter).
* `fill` → `100%`: die App sitzt in einem Container, der die Höhe vorgibt
* (worship, Router-Container) — und in Demos mit fester Höhe.
*/
fit?: 'viewport' | 'fill';
className?: string;
}
export function AppWindow({
header,
children,
statusBar,
tabs,
fit = 'viewport',
className,
}: AppWindowProps) {
const cls = [styles.app, fit === 'fill' ? styles.fitFill : styles.fitViewport, className]
.filter(Boolean)
.join(' ');
return (
<div className={cls} data-fit={fit}>
<header className={styles.header}>{header}</header>
<div className={styles.body}>{children}</div>
{statusBar ? <div className={styles.statusBar}>{statusBar}</div> : null}
{tabs ? <div className={styles.tabbar}>{tabs}</div> : null}
</div>
);
}
/** Rückgabe von `useColumnWidth` — als benannter Typ, damit ihn Aufrufer
* durchreichen können, ohne ihn nachzubauen. */
export interface ColumnWidth {
width: number;
defaultWidth: number;
commit: (next: number) => void;
reset: () => void;
style: CSSProperties;
}
export type AppWindowPane = 'left' | 'center' | 'right';
export interface AppWindowColumnsProps {
/** Linke Seitenspalte. Fehlt sie, entfällt der Track. */
left?: ReactNode;
/** Content-Mitte (immer da, `minmax(0, 1fr)`). */
children: ReactNode;
/** Rechte Seitenspalte. Fehlt sie, entfällt der Track. */
right?: ReactNode;
/**
* Resize je Seite EINSCHALTEN, indem das Ergebnis von `useColumnWidth`
* übergeben wird. Der Hook wird bewusst von der App gerufen, nicht hier
* gekapselt: der presenter teilt eine Breite über alle Bereiche hinweg
* (gemeinsame LocalStorage-Keys), andere Ansichten haben eigene Schlüssel.
* Ohne `resize` wird KEIN Griff gerendert.
*/
resize?: { left?: ColumnWidth; right?: ColumnWidth };
/** Feste Breite, wenn nicht resizable — beliebiger CSS-Track-Wert
* (worship: `clamp(300px, 22vw, 380px)`). Ignoriert, sobald `resize`
* für diese Seite gesetzt ist. */
leftWidth?: string;
rightWidth?: string;
/**
* Mobile-Umschaltung: setzt `data-pane` am Raster. Unterhalb des
* Umbruchpunkts ist genau diese Spalte sichtbar. OPT-IN — ohne die Prop
* greift die Media-Query ins Leere. Das ist Absicht: der presenter hat
* kein Einspalten-Layout und darf keins geerbt bekommen.
*/
activePane?: AppWindowPane;
/** Seitenspalten ausblenden (worship-Vollbild). */
hideSides?: boolean;
/** aria-labels der Resizer-Griffe. */
labels?: { left?: string; right?: string };
className?: string;
}
export function AppWindowColumns({
left,
children,
right,
resize,
leftWidth,
rightWidth,
activePane,
hideSides,
labels,
className,
}: AppWindowColumnsProps) {
const spur = (
seite: ColumnWidth | undefined,
fest: string | undefined,
vorhanden: boolean,
): string => {
if (!vorhanden) return '';
if (seite) return `${seite.width}px`;
return fest ?? 'var(--app-window-col)';
};
const style = {
'--app-window-left': spur(resize?.left, leftWidth, left !== undefined),
'--app-window-right': spur(resize?.right, rightWidth, right !== undefined),
} as CSSProperties;
const cls = [styles.columns, className].filter(Boolean).join(' ');
return (
<div
className={cls}
style={style}
{...(activePane ? { 'data-pane': activePane } : {})}
{...(hideSides ? { 'data-hide-sides': '' } : {})}
>
{left !== undefined ? <aside className={styles.left}>{left}</aside> : null}
<main className={styles.center}>{children}</main>
{right !== undefined ? <aside className={styles.right}>{right}</aside> : null}
{resize?.left ? (
<PanelResizer
edge="left"
offsetVar="--app-window-left"
width={resize.left.width}
defaultWidth={resize.left.defaultWidth}
onResize={resize.left.commit}
onReset={resize.left.reset}
label={labels?.left ?? 'Breite der linken Leiste ändern'}
/>
) : null}
{resize?.right ? (
<PanelResizer
edge="right"
offsetVar="--app-window-right"
width={resize.right.width}
defaultWidth={resize.right.defaultWidth}
onResize={resize.right.commit}
onReset={resize.right.reset}
label={labels?.right ?? 'Breite der rechten Leiste ändern'}
/>
) : null}
</div>
);
}
components/ui/AppWindow.module.css css
/* Fenster-Raster. Die Zeilen für Statuszeile und Tab-Leiste entstehen nur,
wenn die jeweiligen Slots gerendert werden — deshalb `grid-template-rows`
mit `auto` statt fester Areas: leere Zeilen belegen dann keine Höhe. */
.app {
display: grid;
grid-template-rows: auto 1fr auto auto;
}
.fitViewport {
height: 100vh;
height: 100dvh;
}
.fitFill {
height: 100%;
}
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.body {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--bg);
overflow: hidden;
}
.statusBar {
min-width: 0;
}
.tabbar {
background: var(--surface);
border-top: 1px solid var(--border);
min-height: var(--app-window-tabbar-height);
padding-bottom: env(safe-area-inset-bottom);
}
/* ── Spalten ─────────────────────────────────────────────────────────
`flex: 1` statt `height: 100%`, weil .body ein Flex-Container ist —
der Vertrag zwischen den beiden Komponenten. */
.columns {
flex: 1;
display: grid;
grid-template-columns:
var(--app-window-left, 0)
minmax(0, 1fr)
var(--app-window-right, 0);
min-height: 0;
/* Anker für die absolut positionierten Resizer-Griffe (Kinder des Rasters). */
position: relative;
}
.left {
border-right: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
min-height: 0;
}
.center {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--bg);
container-type: inline-size;
container-name: stage;
}
.right {
border-left: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
/* Vollbild: Seitenspalten weg, Mitte bleibt. */
.columns[data-hide-sides] {
grid-template-columns: minmax(0, 1fr);
}
.columns[data-hide-sides] .left,
.columns[data-hide-sides] .right {
display: none;
}
/* Schmale Fenster: engere Standardbreite links. Sobald der Nutzer gezogen hat,
gewinnt die per JS gesetzte Variable ohnehin — sie ist dann ein px-Wert. */
@media (max-width: 1180px) {
.columns {
grid-template-columns:
var(--app-window-left, var(--app-window-col-narrow))
minmax(0, 1fr)
var(--app-window-right, 0);
}
}
/* Einspalten-Modus. Greift NUR, wenn die App `activePane` setzt — ohne das
Attribut bleiben alle Regeln hier wirkungslos. */
@media (max-width: 1000px) {
.columns[data-pane] {
grid-template-columns: minmax(0, 1fr);
}
.columns[data-pane] .left,
.columns[data-pane] .center,
.columns[data-pane] .right {
display: none;
}
.columns[data-pane='left'] .left,
.columns[data-pane='center'] .center,
.columns[data-pane='right'] .right {
display: flex;
}
.columns[data-pane] :global([role='separator']) {
display: none;
}
}
Holen
# MCP (Claude Code / Cursor)
get_component({ name: "app-window" })
# Registry direkt
https://www.ekklesi.tools/design/registry/components/app-window.json