AppWindowTabs
ui navigationshellapp-window
Untere Icon-Tab-Leiste für den Bereichswechsel. Generisch über die Id — kennt weder einen Store noch einen Router: `activeId` und `onSelect` verdrahtet die App. Einträge können deaktiviert sein.
Abhängigkeiten
Barrierefreiheit
- <nav> mit aria-label (Default „Bereich"), überschreibbar.
- Der aktive Eintrag trägt aria-pressed="true" — es sind Umschalter, keine Links, deshalb aria-pressed statt aria-current.
- Jeder Eintrag zeigt Label UND Icon; das Label ist sichtbarer Text, nicht nur ein aria-label.
Beispiele
Bereichswechsel
const TABS = [
{ id: 'event', label: 'Event', icon: <EventIcon size={20} /> },
{ id: 'songs', label: 'Songs', icon: <SongIcon size={20} /> },
];
<AppWindowTabs items={TABS} activeId={section} onSelect={setSection} /> Genutzte Tokens · 10
--accent--accent-fg--ease-standard--fg--focus-ring--font-body--motion-fast--muted--space-1--space-4
Quelldateien
components/ui/AppWindowTabs.tsx tsx
import type { ReactNode } from 'react';
import styles from './AppWindowTabs.module.css';
/**
* Untere Icon-Tab-Leiste des App-Fensters (aus media-presenter
* `features/sectionnav/SectionNav.tsx`).
*
* Bewusst generisch über `Id`: die Leiste kennt weder presenters `Section`-Typ
* noch einen Router. Welcher Eintrag aktiv ist und was ein Klick auslöst,
* entscheidet die App (`activeId` / `onSelect`) — Store oder Routing bleiben
* app-seitig.
*/
export interface AppWindowTab<Id extends string = string> {
id: Id;
label: string;
/** Fertig gerendertes Icon-Element, z. B. `<EventIcon size={20} />`.
* ReactNode statt Komponententyp, damit kein Icon-Typ in den Vertrag muss. */
icon: ReactNode;
disabled?: boolean;
}
export interface AppWindowTabsProps<Id extends string = string> {
items: readonly AppWindowTab<Id>[];
activeId: Id;
onSelect: (id: Id) => void;
/** Beschriftung der Navigation für Screenreader. */
ariaLabel?: string;
className?: string;
}
export function AppWindowTabs<Id extends string = string>({
items,
activeId,
onSelect,
ariaLabel = 'Bereich',
className,
}: AppWindowTabsProps<Id>) {
const cls = [styles.nav, className].filter(Boolean).join(' ');
return (
<nav className={cls} aria-label={ariaLabel}>
{items.map((eintrag) => {
const aktiv = eintrag.id === activeId;
return (
<button
key={eintrag.id}
type="button"
className={`${styles.button} ${aktiv ? styles.active : ''}`}
// aria-pressed statt aria-current: es sind Umschalter, keine Links.
aria-pressed={aktiv}
disabled={eintrag.disabled ?? false}
onClick={() => onSelect(eintrag.id)}
>
<span className={styles.icon}>{eintrag.icon}</span>
<span className={styles.label}>{eintrag.label}</span>
</button>
);
})}
</nav>
);
}
components/ui/AppWindowTabs.module.css css
.nav {
display: flex;
align-items: stretch;
justify-content: center;
gap: var(--space-1);
height: 100%;
padding: 0 var(--space-4);
}
.button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 84px;
padding: 6px 14px 8px;
background: transparent;
border: none;
border-top: 2px solid transparent;
color: var(--muted);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.02em;
cursor: pointer;
transition:
color var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard);
}
.button:hover:not(:disabled) {
color: var(--fg);
background: color-mix(in oklab, var(--fg), transparent 95%);
}
.button:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.button.active {
color: var(--accent-fg);
border-top-color: var(--accent);
}
.icon {
display: grid;
place-items: center;
width: 22px;
height: 22px;
}
.label {
line-height: 1.1;
}
Holen
# MCP (Claude Code / Cursor)
get_component({ name: "app-window-tabs" })
# Registry direkt
https://www.ekklesi.tools/design/registry/components/app-window-tabs.json