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.

ekklesi:demo
Basis
Content-Mitte — Bereich „event"

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