Initialer Import: HA-Panel, Design-System, Companion-App
Drei zusammengehörige Teile in einem Repository: - homeassistant/ Das fertige, im Einsatz befindliche Home-Assistant-Panel (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl, Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage fahrzeugprofil.example.json ist mit dabei. - design-system/ Eigenständige React-Komponentenbibliothek (@audi-dash/ui), die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude Design. War bis hierher ein eigenes Repository und ist in dieses eingeschmolzen worden. - companion-app/ Datenschicht der neuen App DataMetric360 (iOS/Android via Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche: REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur innerhalb des HA-Frontends existiert. Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels), COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App), AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche Bauauftrag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# design-sync notes — @audi-dash/ui
|
||||
|
||||
## Repo shape
|
||||
- Package shape (no Storybook). Build: `npm run build` (tsup) in the repo root produces `dist/index.js` (ESM), `dist/index.cjs` (CJS), `dist/index.d.ts`, `dist/styles.css`. The converter's `--entry` points at `dist/index.js`.
|
||||
- Converter deps are staged/installed under `.ds-sync/` (isolated npm project, gitignored) — never mixed into the repo's own `package.json`.
|
||||
- On Windows, `npm install` needs `npm approve-scripts esbuild` once per fresh `.ds-sync/`/`node_modules` (npm 11's allow-scripts gate blocks esbuild's postinstall that downloads its platform binary).
|
||||
|
||||
## Known render warns (triaged, safe to ignore on re-sync)
|
||||
- `[RENDER_THIN]` on `IconButton`: "mounts have no text and paint nothing" — false positive. IconButton is intentionally icon-only (no text content, `aria-label` only). Screenshot confirms both cells (Default, Disabled) render the icons correctly; the heuristic just measures text content, which is legitimately empty here.
|
||||
|
||||
## cfg.overrides
|
||||
- `Popup`: `cardMode: "single"` — it's a fixed-position overlay component that portals to `document.body`; grid mode collapsed/escaped its card.
|
||||
- `Accordion`, `Feld`, `TabBar`, `StatGrid`: `cardMode: "column"` — one or more of their authored stories render wider than a grid cell (multi-field forms / full-width nav bars); column mode gives them the full card width instead of being cropped.
|
||||
|
||||
## Preview authoring conventions
|
||||
- Every preview wraps its composition in `<div className="ads-root" data-theme="nacht">` (a local `Root` helper duplicated per file) — the library only applies text color/background via `.ads-root`, and without it previews render with browser-default black-on-white instead of the app's actual dark theme. **This is required for every future preview** — the components' color tokens are on `:root` (theme-independent) but `color`/`background` inheritance only kicks in under `.ads-root`.
|
||||
- `Popup`'s children portal to `document.body`, escaping any local `.ads-root` wrapper. This doesn't break styling because `Popup`'s own CSS (`.ads-popup`, `.ads-popup-item`) sets `background`/`color` directly from `var(--tile-2)`/`var(--fg)` rather than relying on inherited color — so it renders correctly even though the wrapper technically doesn't reach it.
|
||||
- `SwipeRow`'s `SwipedOpen` story shows content clipped at the left edge behind the revealed delete button — this is correct, faithful behavior (the row slides left to reveal the delete action), not a rendering defect.
|
||||
|
||||
## Re-sync risks
|
||||
- All 20 previews are hand-authored (no floor cards) — a future re-sync only needs to touch previews for components whose source or `.tsx` actually changed; everything else carries forward via the grade cache + `_ds_sync.json` anchor.
|
||||
- The `Root` wrapper pattern (data-theme="nacht") is duplicated per preview file rather than imported from a shared module — if a preview is added later without it, it will silently render unthemed (black text, no background). Check for `data-theme="nacht"` in any new preview before grading it.
|
||||
- Playwright/Chromium was freshly installed for this sync (`.ds-sync/node_modules/playwright`, chromium cached at `~/AppData/Local/ms-playwright`) — not committed, machine-local. A re-sync on a different machine needs to reinstall it (package-validate.mjs will prompt if missing).
|
||||
- No `docsDir`/docs were found in this repo (it's a fresh internal design system, no README-per-component docs yet) — all `.prompt.md` files are synthesized from `.d.ts` + the authored preview `.tsx`, not from hand-written docs. If per-component docs are added later, set `cfg.docsDir` to pick them up.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"projectId": "27d66fd4-ed86-43b2-b8bf-61e281a5a1a5",
|
||||
"shape": "package",
|
||||
"pkg": "@audi-dash/ui",
|
||||
"globalName": "AudiDashUI",
|
||||
"cssEntry": "./dist/styles.css",
|
||||
"readmeHeader": ".design-sync/conventions.md",
|
||||
"overrides": {
|
||||
"Popup": { "cardMode": "single", "viewport": "340x300" },
|
||||
"Accordion": { "cardMode": "column" },
|
||||
"Feld": { "cardMode": "column" },
|
||||
"TabBar": { "cardMode": "column" },
|
||||
"StatGrid": { "cardMode": "column" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
## Usage conventions
|
||||
|
||||
- **Always wrap the tree that uses these components in `<div className="ads-root" data-theme="nacht">` (or `data-theme="tag"` for the light theme).** Color tokens (`--fg`, `--canvas`, `--tile`, etc.) are defined on `:root` and work anywhere, but text `color` and page `background` are only applied via the `.ads-root` class — content rendered outside it falls back to browser-default black-on-white.
|
||||
- Default theme is **Nacht** (dark) — this is what `:root` sets without any `data-theme` override. Use `data-theme="tag"` for the light variant.
|
||||
- No Audi brand assets (font, rings, nameplate) are included in this bundle — it uses a neutral `Helvetica, Arial, sans-serif` fallback stack by design.
|
||||
- `Popup` renders via a React portal into `document.body` by default (`position: fixed`, centers or anchors relative to the viewport) — it doesn't need to sit inside `.ads-root` itself since its own CSS sets `background`/`color` explicitly rather than inheriting them.
|
||||
- `SwipeRow` is a controlled/uncontrolled swipe-to-reveal row: swiping (or setting `swiped`) slides content left to reveal a delete action on the right, clipping the row's left edge by design — this is the intended interaction, not a layout bug.
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { Accordion, LeafRow } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Open() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 300 }}>
|
||||
<Accordion title="Reifen" summary="4,2 mm" defaultOpen>
|
||||
<LeafRow title="Vorne" value="4,2 mm" />
|
||||
<LeafRow title="Hinten" value="3,8 mm" />
|
||||
</Accordion>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Closed() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 300 }}>
|
||||
<Accordion title="Versicherung" summary="248 €/Jahr">
|
||||
<LeafRow title="Haftpflicht" value="180 €" />
|
||||
<LeafRow title="Teilkasko" value="68 €" />
|
||||
</Accordion>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function NestedLevel2() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 300 }}>
|
||||
<Accordion title="Service" summary="Nächster Termin" defaultOpen>
|
||||
<Accordion title="Inspektion" level={2} defaultOpen>
|
||||
<LeafRow title="Fällig bei" value="45.000 km" />
|
||||
</Accordion>
|
||||
<Accordion title="Ölwechsel" level={2}>
|
||||
<LeafRow title="Fällig bei" value="47.500 km" />
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { ActionButton } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<ActionButton>Speichern</ActionButton>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Destructive() {
|
||||
return (
|
||||
<Root>
|
||||
<ActionButton variant="destructive">Fahrt löschen</ActionButton>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disabled() {
|
||||
return (
|
||||
<Root>
|
||||
<ActionButton disabled>Speichern</ActionButton>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Group() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<ActionButton>Übernehmen</ActionButton>
|
||||
<ActionButton variant="destructive">Verwerfen</ActionButton>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Feld, Switch } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadOnlyValue() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<Feld label="Kennzeichen" value="M-AB 1234" />
|
||||
<Feld label="Erstzulassung" value="03/2022" last />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithUnit() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<Feld label="Anzugsmoment" unit="Nm" last>
|
||||
<input type="number" defaultValue={120} style={{ width: 60, textAlign: "right" }} />
|
||||
</Feld>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithControl() {
|
||||
const [on, setOn] = React.useState(true);
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<Feld label="Benachrichtigungen" last>
|
||||
<Switch checked={on} onChange={setOn} aria-label="Benachrichtigungen" />
|
||||
</Feld>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { Fig } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<Fig value={128} unit="km" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function LargeHero() {
|
||||
return (
|
||||
<Root>
|
||||
<Fig value={64} unit="%" size={40} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Compact() {
|
||||
return (
|
||||
<Root>
|
||||
<Fig value={7.2} unit="l/100km" size={17} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoUnit() {
|
||||
return (
|
||||
<Root>
|
||||
<Fig value={2.4} size={28} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { IconButton } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GearIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M12 2.5v3M12 18.5v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2.5 12h3M18.5 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BackIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M15 5l-7 7 7 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloseIcon = (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
<IconButton aria-label="Zurück">{BackIcon}</IconButton>
|
||||
<IconButton aria-label="Einstellungen">{GearIcon}</IconButton>
|
||||
<IconButton aria-label="Schließen">{CloseIcon}</IconButton>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disabled() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
<IconButton aria-label="Einstellungen" disabled>
|
||||
{GearIcon}
|
||||
</IconButton>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { ImagePlaceholder } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty() {
|
||||
return (
|
||||
<Root>
|
||||
<ImagePlaceholder alt="Fahrzeugfoto" label="Kein Foto" hint="foto.jpg" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Mini() {
|
||||
return (
|
||||
<Root>
|
||||
<ImagePlaceholder alt="Vorschau" label="Kein Foto" size="mini" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Round() {
|
||||
return (
|
||||
<Root>
|
||||
<ImagePlaceholder alt="Profilbild" label="Kein Bild" size="round" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { LeafRow } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<LeafRow title="Sommerreifen" subTitle="Vorne" value="4,2 mm" onClick={() => {}} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function List() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<LeafRow title="Sommerreifen" subTitle="Vorne" value="4,2 mm" onClick={() => {}} />
|
||||
<LeafRow title="Sommerreifen" subTitle="Hinten" value="3,8 mm" onClick={() => {}} />
|
||||
<LeafRow title="Winterreifen" subTitle="Eingelagert" value="6,1 mm" subValue="seit Mai" onClick={() => {}} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { Pill } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Pill>Elektro</Pill>
|
||||
<Pill>Automatik</Pill>
|
||||
<Pill>5 Sitze</Pill>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Work() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Pill variant="work">Neu</Pill>
|
||||
<Pill variant="work">-15%</Pill>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Mixed() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<Pill>Diesel</Pill>
|
||||
<Pill variant="work">SmartDeal</Pill>
|
||||
<Pill>4x4</Pill>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import { Popup, PopupMenuItem } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children, height }: { children: React.ReactNode; height: number }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ position: "relative", width: "100%", height, padding: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Menu() {
|
||||
return (
|
||||
<Root height={260}>
|
||||
<Popup open onClose={() => {}} variant="menu" anchor="center">
|
||||
<PopupMenuItem onClick={() => {}}>Foto ändern</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => {}}>Foto entfernen</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => {}} destructive>
|
||||
Fahrzeug löschen
|
||||
</PopupMenuItem>
|
||||
</Popup>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Form() {
|
||||
return (
|
||||
<Root height={280}>
|
||||
<Popup open onClose={() => {}} variant="form" anchor="center">
|
||||
<label className="ads-label">Gültig bis</label>
|
||||
<input type="date" defaultValue="2026-12-31" />
|
||||
</Popup>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import { PopupMenuItem } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div
|
||||
className="ads-popup ads-popup--menu"
|
||||
style={{ position: "static", transform: "none", display: "inline-flex", flexDirection: "column" }}
|
||||
>
|
||||
<PopupMenuItem onClick={() => {}}>Foto ändern</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => {}}>Details anzeigen</PopupMenuItem>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Destructive() {
|
||||
return (
|
||||
<Root>
|
||||
<div
|
||||
className="ads-popup ads-popup--menu"
|
||||
style={{ position: "static", transform: "none", display: "inline-flex", flexDirection: "column" }}
|
||||
>
|
||||
<PopupMenuItem onClick={() => {}}>Bearbeiten</PopupMenuItem>
|
||||
<PopupMenuItem onClick={() => {}} destructive>
|
||||
Löschen
|
||||
</PopupMenuItem>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { ProgressBar } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleFill() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 260 }}>
|
||||
<ProgressBar percent={68} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedWithLegend() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 260 }}>
|
||||
<ProgressBar
|
||||
segments={[
|
||||
{ value: 4, color: "var(--ok)", label: "Kraftstoff" },
|
||||
{ value: 2, color: "var(--warn)", label: "Verschleiß" },
|
||||
{ value: 1, color: "var(--bad)", label: "Wartung" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleSegmentNoLegend() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 260 }}>
|
||||
<ProgressBar segments={[{ value: 1, color: "var(--fg)" }]} showLegend={false} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { ProgressRing } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<ProgressRing percent={64} unit="%" label="Ladestand" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomValue() {
|
||||
return (
|
||||
<Root>
|
||||
<ProgressRing percent={82} value="312" unit="km" label="Reichweite" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Low() {
|
||||
return (
|
||||
<Root>
|
||||
<ProgressRing percent={12} unit="%" label="Kraftstoff" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Small() {
|
||||
return (
|
||||
<Root>
|
||||
<ProgressRing percent={45} unit="%" size={90} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { RowList } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<RowList
|
||||
items={[
|
||||
{ key: "km", label: "Kilometerstand", value: "42.318 km" },
|
||||
{ key: "verbrauch", label: "Ø Verbrauch", value: "6,8 l/100km" },
|
||||
{ key: "letzte", label: "Letzte Fahrt", value: "12 km", subValue: "vor 2 Stunden" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithSubValues() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<RowList
|
||||
items={[
|
||||
{ key: "vorne", label: "Solldruck vorne", value: "2,3 bar", subValue: "kalt gemessen" },
|
||||
{ key: "hinten", label: "Solldruck hinten", value: "2,1 bar", subValue: "kalt gemessen" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { Seg } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoOptions() {
|
||||
const [value, setValue] = React.useState<"nacht" | "tag">("nacht");
|
||||
return (
|
||||
<Root>
|
||||
<Seg
|
||||
options={[
|
||||
{ value: "nacht", label: "Nacht" },
|
||||
{ value: "tag", label: "Tag" },
|
||||
]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreeOptions() {
|
||||
const [value, setValue] = React.useState("woche");
|
||||
return (
|
||||
<Root>
|
||||
<Seg
|
||||
options={[
|
||||
{ value: "tag", label: "Tag" },
|
||||
{ value: "woche", label: "Woche" },
|
||||
{ value: "monat", label: "Monat" },
|
||||
]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { StatGrid } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FourColumns() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 320 }}>
|
||||
<StatGrid
|
||||
items={[
|
||||
{ value: 42318, caption: "Kilometer" },
|
||||
{ value: 6.8, unit: "l", caption: "Ø Verbrauch" },
|
||||
{ value: 12, caption: "Fahrten" },
|
||||
{ value: 312, unit: "km", caption: "Reichweite" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoColumns() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 200 }}>
|
||||
<StatGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{ value: 2.3, unit: "bar", caption: "Vorne" },
|
||||
{ value: 2.1, unit: "bar", caption: "Hinten" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { StatusRow } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AllStatuses() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, width: 260 }}>
|
||||
<StatusRow status="ok" title="Reifendruck" subtitle="Alle vier in Ordnung" />
|
||||
<StatusRow status="warn" title="Ölstand" subtitle="Nächster Wechsel in 800 km" />
|
||||
<StatusRow status="bad" title="Batterie" subtitle="Spannung niedrig" />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Clickable() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 260 }}>
|
||||
<StatusRow status="warn" title="Service fällig" subtitle="Tippen für Details" onClick={() => {}} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { SwipeRow, LeafRow } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Closed() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<SwipeRow onDelete={() => {}} deleteLabel="Löschen">
|
||||
<LeafRow title="Fahrt nach Hause" subTitle="Heute, 08:12" value="12 km" />
|
||||
</SwipeRow>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwipedOpen() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 280 }}>
|
||||
<SwipeRow onDelete={() => {}} deleteLabel="Löschen" swiped>
|
||||
<LeafRow title="Fahrt zur Arbeit" subTitle="Gestern, 17:40" value="18 km" />
|
||||
</SwipeRow>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { Switch } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16, display: "inline-block" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function On() {
|
||||
const [checked, setChecked] = React.useState(true);
|
||||
return (
|
||||
<Root>
|
||||
<Switch checked={checked} onChange={setChecked} aria-label="Benachrichtigungen" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Off() {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
return (
|
||||
<Root>
|
||||
<Switch checked={checked} onChange={setChecked} aria-label="Benachrichtigungen" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disabled() {
|
||||
return (
|
||||
<Root>
|
||||
<Switch checked={true} onChange={() => {}} disabled aria-label="Benachrichtigungen" />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { TabBar } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HomeIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M4 11l8-7 8 7v9a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1v-9Z" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const ListIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
const CarIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M4 16v-3l2-5h12l2 5v3M4 16h16M4 16v2M20 16v2M7 19v-2M17 19v-2" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const GearIcon = (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M12 2.5v3M12 18.5v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2.5 12h3M18.5 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const items = [
|
||||
{ key: "home", label: "Übersicht", icon: HomeIcon },
|
||||
{ key: "trips", label: "Fahrten", icon: ListIcon },
|
||||
{ key: "car", label: "Fahrzeug", icon: CarIcon },
|
||||
{ key: "settings", label: "Einstellungen", icon: GearIcon },
|
||||
];
|
||||
|
||||
export function Default() {
|
||||
const [active, setActive] = React.useState("home");
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 340 }}>
|
||||
<TabBar items={items} activeKey={active} onChange={setActive} />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconOnly() {
|
||||
const [active, setActive] = React.useState("trips");
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 220 }}>
|
||||
<TabBar items={items} activeKey={active} onChange={setActive} iconOnly />
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { Tile, Fig } from "@audi-dash/ui";
|
||||
|
||||
function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht" style={{ padding: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Default() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 240 }}>
|
||||
<Tile>
|
||||
<div className="ads-label">Reichweite</div>
|
||||
<Fig value={312} unit="km" size={28} />
|
||||
</Tile>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Flat() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 240 }}>
|
||||
<Tile variant="flat">Zusatzinformation ohne Rahmenschatten</Tile>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonWithChevron() {
|
||||
return (
|
||||
<Root>
|
||||
<div style={{ width: 240 }}>
|
||||
<Tile variant="button" chevron onClick={() => {}}>
|
||||
Alle Fahrten anzeigen
|
||||
</Tile>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.ds-sync/
|
||||
ds-bundle/
|
||||
.design-sync/.cache/
|
||||
.design-sync/learnings/
|
||||
.design-sync/node_modules
|
||||
@@ -0,0 +1,66 @@
|
||||
# @audi-dash/ui
|
||||
|
||||
Generisches React-Komponenten-Set, das die visuelle Sprache des Audi-Dashboards (Kacheln,
|
||||
Formularzeilen, Toggle, Segmented Control, Popups, Statusanzeigen, Fortschrittsanzeigen …)
|
||||
nachbildet. Enthält **keine** Audi-Marken-Assets (Font "Audi Type", Ringe, Typenschild) - diese
|
||||
sind laut Lizenzvereinbarung ausschließlich für die private Home-Assistant-Installation
|
||||
freigegeben. Diese Bibliothek nutzt einen neutralen Fallback-Font-Stack (`Helvetica, Arial,
|
||||
sans-serif`).
|
||||
|
||||
## Verwendung
|
||||
|
||||
Konsumierende Apps legen die `.ads-root`-Klasse um den Baum, der die Bibliothek nutzt, und setzen
|
||||
`data-theme="nacht"` (Standard) oder `data-theme="tag"`:
|
||||
|
||||
```tsx
|
||||
import { Tile, Feld, Switch } from "@audi-dash/ui";
|
||||
import "@audi-dash/ui/styles.css";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="ads-root" data-theme="nacht">
|
||||
<Tile>
|
||||
<Feld label="Beispiel">
|
||||
<Switch checked={true} onChange={() => {}} />
|
||||
</Feld>
|
||||
</Tile>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Design-Tokens
|
||||
|
||||
Alle Farben/Radien/Abstände liegen als CSS-Custom-Properties in `src/tokens/tokens.css` vor -
|
||||
Komponenten referenzieren ausschließlich `var(--token)`, nie Literal-Hex-Werte. Siehe die Datei
|
||||
für die vollständige Liste (`--red`, `--canvas`, `--tile`, `--fg`/`--fg2`/`--fg3`, `--ok`/`--warn`/
|
||||
`--bad`, `--r-tile`/`--r-pill`/`--r-func`, `--sp-1`…`--sp-5`).
|
||||
|
||||
## Typografie-Utility-Klassen
|
||||
|
||||
Reine Textstil-Hooks ohne eigene Komponentenstruktur (in `tokens.css` definiert) - direkt als
|
||||
`className` anwenden:
|
||||
|
||||
| Klasse | Verwendung |
|
||||
|---|---|
|
||||
| `.ads-eyebrow` | Kleine Überschrift über einem Seitentitel (10px, uppercase, `--fg3`) |
|
||||
| `.ads-title` | Seitentitel (23px, einzeilig, ellipsis) |
|
||||
| `.ads-label` | Feld-/Kachel-Label (10px, uppercase, `--fg3`) |
|
||||
| `.ads-sport` | Kursiver Akzenttext |
|
||||
|
||||
Die einzige Ausnahme mit echter interner Struktur ist die `Fig`-Komponente (großer Zahlenwert +
|
||||
Einheit) - sie wird intern auch von `ProgressRing` und `StatGrid` genutzt.
|
||||
|
||||
## Komponenten
|
||||
|
||||
Pill, IconButton, Fig, StatusRow, ActionButton, Switch, Seg, RowList, LeafRow, Tile, Feld,
|
||||
ProgressRing, ProgressBar, StatGrid, Accordion, SwipeRow, TabBar, Popup (+PopupMenuItem),
|
||||
ImagePlaceholder. Props-Referenz: `.d.ts`-Dateien im Build bzw. die jeweilige `<Name>.tsx`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
Generated
+1632
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@audi-dash/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Generisches React-Komponenten-Set, das die visuelle Sprache des Audi-Dashboards nachbildet.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./styles.css": "./dist/styles.css"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsup",
|
||||
"smoke": "node scripts/smoke.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.27.7": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import * as UI from "../dist/index.js";
|
||||
|
||||
const h = React.createElement;
|
||||
const noop = () => {};
|
||||
|
||||
const cases = [
|
||||
{ name: "Pill", el: h(UI.Pill, {}, "Text") },
|
||||
{ name: "IconButton", el: h(UI.IconButton, { "aria-label": "x" }, "*") },
|
||||
{ name: "Fig", el: h(UI.Fig, { value: 42, unit: "km" }) },
|
||||
{ name: "StatusRow", el: h(UI.StatusRow, { status: "ok", title: "Titel", subtitle: "Sub" }) },
|
||||
{ name: "ActionButton", el: h(UI.ActionButton, {}, "Aktion") },
|
||||
{ name: "Switch", el: h(UI.Switch, { checked: true, onChange: noop }) },
|
||||
{
|
||||
name: "Seg",
|
||||
el: h(UI.Seg, {
|
||||
options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
|
||||
value: "a",
|
||||
onChange: noop,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "RowList",
|
||||
el: h(UI.RowList, { items: [{ key: 1, label: "L", value: "V", subValue: "SV" }] }),
|
||||
},
|
||||
{ name: "LeafRow", el: h(UI.LeafRow, { title: "T", value: "V" }) },
|
||||
{ name: "Tile", el: h(UI.Tile, {}, "Inhalt") },
|
||||
{ name: "Tile (button)", el: h(UI.Tile, { variant: "button", chevron: true, onClick: noop }, "Klick") },
|
||||
{ name: "Feld", el: h(UI.Feld, { label: "L", value: "V" }) },
|
||||
{ name: "ProgressRing", el: h(UI.ProgressRing, { percent: 42, unit: "%" }) },
|
||||
{ name: "ProgressBar (single)", el: h(UI.ProgressBar, { percent: 50 }) },
|
||||
{
|
||||
name: "ProgressBar (segments)",
|
||||
el: h(UI.ProgressBar, { segments: [{ value: 3, label: "A" }, { value: 1, label: "B" }] }),
|
||||
},
|
||||
{
|
||||
name: "StatGrid",
|
||||
el: h(UI.StatGrid, { items: [{ value: 1, caption: "C" }, { value: 2, caption: "C2" }] }),
|
||||
},
|
||||
{ name: "Accordion", el: h(UI.Accordion, { title: "T", defaultOpen: true }, "Body") },
|
||||
{ name: "SwipeRow", el: h(UI.SwipeRow, { onDelete: noop }, "Zeile") },
|
||||
{
|
||||
name: "TabBar",
|
||||
el: h(UI.TabBar, {
|
||||
items: [{ key: "a", label: "A", icon: h("svg") }],
|
||||
activeKey: "a",
|
||||
onChange: noop,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Popup",
|
||||
el: h(UI.Popup, { open: true, onClose: noop }, h(UI.PopupMenuItem, {}, "Item")),
|
||||
},
|
||||
{ name: "ImagePlaceholder", el: h(UI.ImagePlaceholder, { alt: "a", label: "Label" }) },
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const c of cases) {
|
||||
try {
|
||||
const html = renderToStaticMarkup(c.el);
|
||||
if (!html || html.length === 0) throw new Error("empty output");
|
||||
console.log(`PASS ${c.name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(`FAIL ${c.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${cases.length - failed}/${cases.length} passed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,55 @@
|
||||
.ads-acc-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ads-acc-h__k {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.ads-acc-h__v {
|
||||
font-size: 11.5px;
|
||||
color: var(--fg2);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ads-mark {
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border-right: 1.4px solid var(--fg3);
|
||||
border-bottom: 1.4px solid var(--fg3);
|
||||
transform: rotate(-45deg) scaleY(1);
|
||||
transition: transform .15s, border-color .15s;
|
||||
}
|
||||
|
||||
.ads-acc-h[aria-expanded="true"] .ads-mark {
|
||||
border-color: var(--red);
|
||||
transform: rotate(45deg) scaleY(1);
|
||||
}
|
||||
|
||||
.ads-acc-b {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ads-acc-b--on {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ads-accordion--lvl2 {
|
||||
margin-left: 22px;
|
||||
}
|
||||
|
||||
.ads-accordion--lvl2 .ads-acc-h__k {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface AccordionProps {
|
||||
title: React.ReactNode;
|
||||
/** Right-aligned header value shown next to the title. */
|
||||
summary?: React.ReactNode;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** 2 = nested, indented sub-accordion with smaller header font. */
|
||||
level?: 1 | 2;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Accordion({
|
||||
title,
|
||||
summary,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
level = 1,
|
||||
children,
|
||||
className,
|
||||
}: AccordionProps) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const isOpen = isControlled ? open : internalOpen;
|
||||
|
||||
const toggle = () => {
|
||||
const next = !isOpen;
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const classes = ["ads-accordion"];
|
||||
if (level === 2) classes.push("ads-accordion--lvl2");
|
||||
if (className) classes.push(className);
|
||||
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
<button type="button" className="ads-acc-h" onClick={toggle} aria-expanded={isOpen}>
|
||||
<span className="ads-acc-h__k">{title}</span>
|
||||
{summary !== undefined && <span className="ads-acc-h__v">{summary}</span>}
|
||||
<span className="ads-mark" aria-hidden />
|
||||
</button>
|
||||
<div className={isOpen ? "ads-acc-b ads-acc-b--on" : "ads-acc-b"}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.ads-leaf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
padding: 12px 0 12px 44px;
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
button.ads-leaf {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ads-leaf__k {
|
||||
font-size: 13.5px;
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ads-leaf__k small {
|
||||
font-size: 11px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.ads-leaf__v {
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ads-leaf__v small {
|
||||
font-size: 11px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.ads-chev {
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
color: var(--fg3);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface LeafRowProps {
|
||||
title: React.ReactNode;
|
||||
subTitle?: React.ReactNode;
|
||||
value?: React.ReactNode;
|
||||
subValue?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LeafRow({ title, subTitle, value, subValue, onClick, className }: LeafRowProps) {
|
||||
const classes = ["ads-leaf"];
|
||||
if (className) classes.push(className);
|
||||
const Tag: React.ElementType = onClick ? "button" : "div";
|
||||
return (
|
||||
<Tag type={onClick ? "button" : undefined} className={classes.join(" ")} onClick={onClick}>
|
||||
<span className="ads-leaf__k">
|
||||
{title}
|
||||
{subTitle !== undefined && <small>{subTitle}</small>}
|
||||
</span>
|
||||
<span className="ads-leaf__v">
|
||||
{value}
|
||||
{subValue !== undefined && <small>{subValue}</small>}
|
||||
</span>
|
||||
<svg className="ads-chev" viewBox="0 0 6 10" aria-hidden>
|
||||
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Accordion } from "./Accordion";
|
||||
export type { AccordionProps } from "./Accordion";
|
||||
export { LeafRow } from "./LeafRow";
|
||||
export type { LeafRowProps } from "./LeafRow";
|
||||
@@ -0,0 +1,34 @@
|
||||
.ads-action {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg);
|
||||
background: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ads-action:hover:not(:disabled) {
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.ads-action:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ads-action--destructive {
|
||||
color: var(--bad);
|
||||
border-color: var(--bad);
|
||||
}
|
||||
|
||||
.ads-action--destructive:hover:not(:disabled) {
|
||||
background: var(--bad);
|
||||
color: var(--fg);
|
||||
border-color: var(--bad);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ActionButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: "default" | "destructive";
|
||||
disabled?: boolean;
|
||||
type?: "button" | "submit";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionButton({
|
||||
children,
|
||||
onClick,
|
||||
variant = "default",
|
||||
disabled,
|
||||
type = "button",
|
||||
className,
|
||||
}: ActionButtonProps) {
|
||||
const classes = ["ads-action"];
|
||||
if (variant === "destructive") classes.push("ads-action--destructive");
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<button type={type} className={classes.join(" ")} onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ActionButton } from "./ActionButton";
|
||||
export type { ActionButtonProps } from "./ActionButton";
|
||||
@@ -0,0 +1,46 @@
|
||||
.ads-feld {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ads-feld--last {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ads-feld label {
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.ads-feld input,
|
||||
.ads-feld select {
|
||||
background: var(--tile-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
padding: 8px 11px;
|
||||
border-radius: var(--r-func);
|
||||
text-align: right;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.ads-mit-einheit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.ads-mit-einheit input {
|
||||
min-width: 92px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ads-mit-einheit span:last-child {
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface FeldProps {
|
||||
label: React.ReactNode;
|
||||
/** Read-only display mode - mutually exclusive with `children`. */
|
||||
value?: React.ReactNode;
|
||||
/** Interactive control mode (input/select/Switch/etc.) - mutually exclusive with `value`. */
|
||||
children?: React.ReactNode;
|
||||
/** Wraps `children` with a trailing unit label, e.g. "Minuten" (the "MitEinheit" pattern). */
|
||||
unit?: string;
|
||||
/** Omit the bottom hairline - use on the last field in a group. */
|
||||
last?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Feld({ label, value, children, unit, last, className }: FeldProps) {
|
||||
const classes = ["ads-feld"];
|
||||
if (last) classes.push("ads-feld--last");
|
||||
if (className) classes.push(className);
|
||||
|
||||
const control = children ? (
|
||||
unit ? (
|
||||
<span className="ads-mit-einheit">
|
||||
{children}
|
||||
<span>{unit}</span>
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
<label>{label}</label>
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Feld } from "./Feld";
|
||||
export type { FeldProps } from "./Feld";
|
||||
@@ -0,0 +1,14 @@
|
||||
.ads-fig {
|
||||
font-weight: 300;
|
||||
letter-spacing: -.02em;
|
||||
line-height: .95;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.ads-fig__unit {
|
||||
font-size: .32em;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
color: var(--fg2);
|
||||
margin-left: .32em;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface FigProps {
|
||||
value: React.ReactNode;
|
||||
unit?: React.ReactNode;
|
||||
/** Font-size override in px, e.g. 40 for a hero readout, smaller for compact contexts. */
|
||||
size?: number | string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Fig({ value, unit, size, className }: FigProps) {
|
||||
const classes = ["ads-fig"];
|
||||
if (className) classes.push(className);
|
||||
const style: React.CSSProperties | undefined =
|
||||
size !== undefined ? { fontSize: typeof size === "number" ? `${size}px` : size } : undefined;
|
||||
return (
|
||||
<span className={classes.join(" ")} style={style}>
|
||||
{value}
|
||||
{unit !== undefined && <span className="ads-fig__unit">{unit}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Fig } from "./Fig";
|
||||
export type { FigProps } from "./Fig";
|
||||
@@ -0,0 +1,28 @@
|
||||
.ads-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--fg3);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ads-icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.ads-icon-button:hover:not(:disabled) {
|
||||
background: var(--tile-2);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.ads-icon-button:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface IconButtonProps {
|
||||
onClick?: () => void;
|
||||
/** SVG icon element rendered inside the circular hit area. */
|
||||
children: React.ReactNode;
|
||||
/** Required - this is an icon-only control with no visible text label. */
|
||||
"aria-label": string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
onClick,
|
||||
children,
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}: IconButtonProps) {
|
||||
const classes = ["ads-icon-button"];
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.join(" ")}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { IconButton } from "./IconButton";
|
||||
export type { IconButtonProps } from "./IconButton";
|
||||
@@ -0,0 +1,58 @@
|
||||
.ads-bildbox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ads-bildbox img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ads-bildbox--mini {
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
aspect-ratio: auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ads-bildbox--round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ads-platzhalter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: inherit;
|
||||
color: var(--fg3);
|
||||
text-align: center;
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.ads-platzhalter__icon svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.ads-platzhalter__label {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: .02em;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
.ads-platzhalter__hint {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 9px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ImagePlaceholderProps {
|
||||
src?: string;
|
||||
alt: string;
|
||||
/** Caption shown on the load-error placeholder. */
|
||||
label: React.ReactNode;
|
||||
/** Small monospace hint under the label, e.g. an expected filename. */
|
||||
hint?: React.ReactNode;
|
||||
/** Placeholder icon override - defaults to a simple image glyph. */
|
||||
icon?: React.ReactNode;
|
||||
size?: "default" | "mini" | "round";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ICON = (
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5Zm2 0v14h12V5H6Zm3 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm-1 8 3-3 2 2 3-4 3 5H8Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function ImagePlaceholder({
|
||||
src,
|
||||
alt,
|
||||
label,
|
||||
hint,
|
||||
icon,
|
||||
size = "default",
|
||||
className,
|
||||
}: ImagePlaceholderProps) {
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
const classes = ["ads-bildbox"];
|
||||
if (size === "mini") classes.push("ads-bildbox--mini");
|
||||
if (size === "round") classes.push("ads-bildbox--round");
|
||||
if (className) classes.push(className);
|
||||
|
||||
const showPlaceholder = !src || failed;
|
||||
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
{!showPlaceholder && (
|
||||
<img src={src} alt={alt} onError={() => setFailed(true)} />
|
||||
)}
|
||||
{showPlaceholder && (
|
||||
<div className="ads-platzhalter">
|
||||
<span className="ads-platzhalter__icon">{icon ?? DEFAULT_ICON}</span>
|
||||
<span className="ads-platzhalter__label">{label}</span>
|
||||
{hint !== undefined && <span className="ads-platzhalter__hint">{hint}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ImagePlaceholder } from "./ImagePlaceholder";
|
||||
export type { ImagePlaceholderProps } from "./ImagePlaceholder";
|
||||
@@ -0,0 +1,16 @@
|
||||
.ads-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.ads-pill--work {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface PillProps {
|
||||
children: React.ReactNode;
|
||||
/** "work" recolors border+text to the accent red - use for a highlighted tag. */
|
||||
variant?: "default" | "work";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Pill({ children, variant = "default", className }: PillProps) {
|
||||
const classes = ["ads-pill"];
|
||||
if (variant === "work") classes.push("ads-pill--work");
|
||||
if (className) classes.push(className);
|
||||
return <span className={classes.join(" ")}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Pill } from "./Pill";
|
||||
export type { PillProps } from "./Pill";
|
||||
@@ -0,0 +1,42 @@
|
||||
.ads-popup-catcher {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ads-popup {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
background: var(--tile-2);
|
||||
border-radius: 14px;
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.ads-popup--menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.ads-popup--form {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.ads-popup-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
padding: 10px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ads-popup-item--destructive {
|
||||
color: var(--bad);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface PopupAnchor {
|
||||
top?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
}
|
||||
|
||||
export interface PopupProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** "menu" = compact button-list card; "form" = wider inline form card. */
|
||||
variant?: "menu" | "form";
|
||||
anchor?: PopupAnchor | "center";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Portal target - defaults to document.body. Override for iframe/canvas embedding. */
|
||||
portalTarget?: Element;
|
||||
}
|
||||
|
||||
export function Popup({
|
||||
open,
|
||||
onClose,
|
||||
variant = "menu",
|
||||
anchor = "center",
|
||||
children,
|
||||
className,
|
||||
portalTarget,
|
||||
}: PopupProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const classes = ["ads-popup", `ads-popup--${variant}`];
|
||||
if (className) classes.push(className);
|
||||
|
||||
const style: React.CSSProperties =
|
||||
anchor === "center"
|
||||
? { top: "50%", left: "50%", transform: "translate(-50%, -50%)" }
|
||||
: { ...anchor };
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="ads-popup-catcher" onClick={onClose} />
|
||||
<div className={classes.join(" ")} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const target = portalTarget ?? (typeof document !== "undefined" ? document.body : null);
|
||||
return target ? createPortal(content, target) : content;
|
||||
}
|
||||
|
||||
export interface PopupMenuItemProps {
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PopupMenuItem({ onClick, destructive, children }: PopupMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={destructive ? "ads-popup-item ads-popup-item--destructive" : "ads-popup-item"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Popup, PopupMenuItem } from "./Popup";
|
||||
export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./Popup";
|
||||
@@ -0,0 +1,48 @@
|
||||
.ads-balken {
|
||||
height: 4px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ads-balken i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--fg);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
.ads-bar {
|
||||
display: flex;
|
||||
height: 4px;
|
||||
border-radius: var(--r-pill);
|
||||
overflow: hidden;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ads-bar i {
|
||||
display: block;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
.ads-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
font-size: 11px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.ads-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ads-legend i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ProgressBarSegment {
|
||||
value: number;
|
||||
color?: string;
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** Simple single-fill mode (uses --fg). Mutually exclusive with `segments`. */
|
||||
percent?: number;
|
||||
/** Stacked multi-fill mode with an optional legend row. */
|
||||
segments?: ProgressBarSegment[];
|
||||
/** Defaults to true when more than one segment is given. */
|
||||
showLegend?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProgressBar({ percent, segments, showLegend, className }: ProgressBarProps) {
|
||||
const classes = ["ads-progress-bar"];
|
||||
if (className) classes.push(className);
|
||||
|
||||
if (segments && segments.length > 0) {
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0) || 1;
|
||||
const legend = showLegend ?? segments.length > 1;
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
<div className="ads-bar">
|
||||
{segments.map((seg, i) => (
|
||||
<i
|
||||
key={i}
|
||||
style={{ flex: seg.value / total, background: seg.color ?? "var(--fg)" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{legend && (
|
||||
<div className="ads-legend">
|
||||
{segments.map((seg, i) => (
|
||||
<span key={i}>
|
||||
<i style={{ background: seg.color ?? "var(--fg)" }} />
|
||||
{seg.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, percent ?? 0));
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
<div className="ads-balken">
|
||||
<i style={{ width: `${clamped}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export type { ProgressBarProps, ProgressBarSegment } from "./ProgressBar";
|
||||
@@ -0,0 +1,32 @@
|
||||
.ads-ring {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ads-ring svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ads-ring__track {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 10;
|
||||
}
|
||||
|
||||
.ads-ring__arc {
|
||||
fill: none;
|
||||
stroke: var(--red);
|
||||
stroke-width: 10;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset .3s;
|
||||
}
|
||||
|
||||
.ads-ring__mid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { Fig } from "../Fig/Fig";
|
||||
|
||||
export interface ProgressRingProps {
|
||||
/** 0-100 */
|
||||
percent: number;
|
||||
/** Center figure - defaults to the rounded percent value. */
|
||||
value?: React.ReactNode;
|
||||
unit?: string;
|
||||
label?: React.ReactNode;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RADIUS = 64;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
export function ProgressRing({
|
||||
percent,
|
||||
value,
|
||||
unit,
|
||||
label,
|
||||
size = 150,
|
||||
className,
|
||||
}: ProgressRingProps) {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const offset = CIRCUMFERENCE * (1 - clamped / 100);
|
||||
const classes = ["ads-ring"];
|
||||
if (className) classes.push(className);
|
||||
|
||||
return (
|
||||
<div className={classes.join(" ")} style={{ width: size, height: size }}>
|
||||
<svg viewBox="0 0 150 150" width={size} height={size}>
|
||||
<circle cx="75" cy="75" r={RADIUS} className="ads-ring__track" />
|
||||
<circle
|
||||
cx="75"
|
||||
cy="75"
|
||||
r={RADIUS}
|
||||
className="ads-ring__arc"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={offset}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ads-ring__mid">
|
||||
<Fig value={value ?? Math.round(clamped)} unit={unit} />
|
||||
{label !== undefined && <span className="ads-label">{label}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProgressRing } from "./ProgressRing";
|
||||
export type { ProgressRingProps } from "./ProgressRing";
|
||||
@@ -0,0 +1,34 @@
|
||||
.ads-rows {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ads-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-3);
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ads-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ads-row dt {
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
|
||||
.ads-row dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--fg);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ads-row dd small {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface RowListItem {
|
||||
key: React.Key;
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
subValue?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface RowListProps {
|
||||
items: RowListItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RowList({ items, className }: RowListProps) {
|
||||
const classes = ["ads-rows"];
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<dl className={classes.join(" ")}>
|
||||
{items.map((item) => (
|
||||
<div className="ads-row" key={item.key}>
|
||||
<dt>{item.label}</dt>
|
||||
<dd>
|
||||
{item.value}
|
||||
{item.subValue !== undefined && <small>{item.subValue}</small>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RowList } from "./RowList";
|
||||
export type { RowListProps, RowListItem } from "./RowList";
|
||||
@@ -0,0 +1,23 @@
|
||||
.ads-seg {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.ads-seg button {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: none;
|
||||
color: var(--fg2);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
padding: 11px 8px;
|
||||
border-radius: var(--r-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ads-seg button.ads-seg__on {
|
||||
border-color: var(--red);
|
||||
color: var(--fg);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface SegOption<T extends string = string> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface SegProps<T extends string = string> {
|
||||
options: SegOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Seg<T extends string = string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: SegProps<T>) {
|
||||
const classes = ["ads-seg"];
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={opt.value === value ? "ads-seg__on" : undefined}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Seg } from "./Seg";
|
||||
export type { SegProps, SegOption } from "./Seg";
|
||||
@@ -0,0 +1,17 @@
|
||||
.ads-quad {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.ads-quad__n {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ads-quad__l {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 9px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg3);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import { Fig } from "../Fig/Fig";
|
||||
|
||||
export interface StatGridItem {
|
||||
value: React.ReactNode;
|
||||
unit?: string;
|
||||
caption: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface StatGridProps {
|
||||
items: StatGridItem[];
|
||||
columns?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatGrid({ items, columns = 4, className }: StatGridProps) {
|
||||
const classes = ["ads-quad"];
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<div className={classes.join(" ")} style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i}>
|
||||
<Fig className="ads-quad__n" value={item.value} unit={item.unit} size={17} />
|
||||
<span className="ads-quad__l">{item.caption}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StatGrid } from "./StatGrid";
|
||||
export type { StatGridProps, StatGridItem } from "./StatGrid";
|
||||
@@ -0,0 +1,42 @@
|
||||
.ads-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.ads-status {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ads-dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ads-dot--ok { background: var(--ok); }
|
||||
.ads-dot--warn { background: var(--warn); }
|
||||
.ads-dot--bad { background: var(--bad); }
|
||||
|
||||
.ads-status__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ads-status__t {
|
||||
font-size: 14px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.ads-status__s {
|
||||
font-size: 11px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface StatusRowProps {
|
||||
status: "ok" | "warn" | "bad";
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusRow({ status, title, subtitle, onClick, className }: StatusRowProps) {
|
||||
const classes = ["ads-status"];
|
||||
if (className) classes.push(className);
|
||||
const Tag: React.ElementType = onClick ? "button" : "div";
|
||||
return (
|
||||
<Tag
|
||||
type={onClick ? "button" : undefined}
|
||||
className={classes.join(" ")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={`ads-dot ads-dot--${status}`} aria-hidden />
|
||||
<span className="ads-status__text">
|
||||
<span className="ads-status__t">{title}</span>
|
||||
{subtitle !== undefined && <span className="ads-status__s">{subtitle}</span>}
|
||||
</span>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StatusRow } from "./StatusRow";
|
||||
export type { StatusRowProps } from "./StatusRow";
|
||||
@@ -0,0 +1,25 @@
|
||||
.ads-swipe-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ads-swipe-delete {
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
width: 84px;
|
||||
background: var(--bad);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ads-swipe-content {
|
||||
position: relative;
|
||||
background: var(--tile);
|
||||
touch-action: pan-y;
|
||||
transition: transform .18s;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
|
||||
const SWIPE_THRESHOLD = -40;
|
||||
const SWIPE_OPEN = -84;
|
||||
|
||||
/** Module-wide pub/sub so opening one row closes every other open row (mirrors the source app's schliesseAlleSwipes()). */
|
||||
const listeners = new Set<() => void>();
|
||||
function closeAllExcept(keep: () => void) {
|
||||
for (const close of listeners) {
|
||||
if (close !== keep) close();
|
||||
}
|
||||
}
|
||||
|
||||
export interface SwipeRowProps {
|
||||
children: React.ReactNode;
|
||||
onDelete: () => void;
|
||||
deleteLabel?: string;
|
||||
/** Controlled swiped-open state. */
|
||||
swiped?: boolean;
|
||||
onSwipedChange?: (swiped: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SwipeRow({
|
||||
children,
|
||||
onDelete,
|
||||
deleteLabel = "Delete",
|
||||
swiped,
|
||||
onSwipedChange,
|
||||
className,
|
||||
}: SwipeRowProps) {
|
||||
const [internalSwiped, setInternalSwiped] = React.useState(false);
|
||||
const isControlled = swiped !== undefined;
|
||||
const isSwiped = isControlled ? swiped : internalSwiped;
|
||||
|
||||
const setSwiped = React.useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalSwiped(next);
|
||||
onSwipedChange?.(next);
|
||||
},
|
||||
[isControlled, onSwipedChange]
|
||||
);
|
||||
|
||||
const close = React.useCallback(() => setSwiped(false), [setSwiped]);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.add(close);
|
||||
return () => {
|
||||
listeners.delete(close);
|
||||
};
|
||||
}, [close]);
|
||||
|
||||
const dragStartX = React.useRef<number | null>(null);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
dragStartX.current = e.clientX;
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (dragStartX.current === null) return;
|
||||
const delta = e.clientX - dragStartX.current;
|
||||
if (delta < SWIPE_THRESHOLD) {
|
||||
closeAllExcept(close);
|
||||
setSwiped(true);
|
||||
dragStartX.current = null;
|
||||
} else if (delta > -SWIPE_THRESHOLD) {
|
||||
setSwiped(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
dragStartX.current = null;
|
||||
};
|
||||
|
||||
const classes = ["ads-swipe-wrap"];
|
||||
if (className) classes.push(className);
|
||||
|
||||
return (
|
||||
<div className={classes.join(" ")}>
|
||||
<button type="button" className="ads-swipe-delete" onClick={onDelete}>
|
||||
{deleteLabel}
|
||||
</button>
|
||||
<div
|
||||
className={isSwiped ? "ads-swipe-content ads-swiped" : "ads-swipe-content"}
|
||||
style={{ transform: isSwiped ? `translateX(${SWIPE_OPEN}px)` : undefined }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SwipeRow } from "./SwipeRow";
|
||||
export type { SwipeRowProps } from "./SwipeRow";
|
||||
@@ -0,0 +1,51 @@
|
||||
.ads-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
.ads-switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ads-switch__track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--tile-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-pill);
|
||||
transition: background .18s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ads-switch__track::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 1px;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform .18s;
|
||||
}
|
||||
|
||||
.ads-switch input:checked + .ads-switch__track {
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.ads-switch input:checked + .ads-switch__track::before {
|
||||
transform: translateX(19px);
|
||||
}
|
||||
|
||||
.ads-switch input:disabled + .ads-switch__track {
|
||||
opacity: .4;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface SwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
"aria-label"?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
id,
|
||||
className,
|
||||
...rest
|
||||
}: SwitchProps) {
|
||||
const classes = ["ads-switch"];
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<span className={classes.join(" ")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id={id}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
{...rest}
|
||||
/>
|
||||
<span className="ads-switch__track" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Switch } from "./Switch";
|
||||
export type { SwitchProps } from "./Switch";
|
||||
@@ -0,0 +1,40 @@
|
||||
.ads-tabbar {
|
||||
display: grid;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ads-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 0 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 2px solid transparent;
|
||||
margin-top: -1px;
|
||||
color: var(--fg3);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ads-tab--on {
|
||||
border-top-color: var(--red);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.ads-tab__icon svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.ads-tabbar--ohne .ads-tab__icon svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.ads-tab__label {
|
||||
font-size: 9px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface TabBarItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TabBarProps {
|
||||
items: TabBarItem[];
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
/** Hide labels, enlarge icons (the source app's ".ohne" variant). */
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabBar({ items, activeKey, onChange, iconOnly, className }: TabBarProps) {
|
||||
const classes = ["ads-tabbar"];
|
||||
if (iconOnly) classes.push("ads-tabbar--ohne");
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<nav
|
||||
className={classes.join(" ")}
|
||||
style={{ gridTemplateColumns: `repeat(${items.length}, 1fr)` }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={item.key === activeKey ? "ads-tab ads-tab--on" : "ads-tab"}
|
||||
onClick={() => onChange(item.key)}
|
||||
>
|
||||
<span className="ads-tab__icon">{item.icon}</span>
|
||||
{!iconOnly && <span className="ads-tab__label">{item.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TabBar } from "./TabBar";
|
||||
export type { TabBarProps, TabBarItem } from "./TabBar";
|
||||
@@ -0,0 +1,39 @@
|
||||
.ads-tile {
|
||||
background: var(--tile);
|
||||
border-radius: var(--r-tile);
|
||||
padding: var(--sp-5);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.ads-tile--flat {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button.ads-tile--button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button.ads-tile--button:hover {
|
||||
background: var(--tile-2);
|
||||
}
|
||||
|
||||
.ads-tile__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ads-tile__go {
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
color: var(--fg3);
|
||||
flex: 0 0 auto;
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface TileProps {
|
||||
variant?: "default" | "flat" | "button";
|
||||
/** Trailing chevron affordance - only meaningful with variant="button". */
|
||||
chevron?: boolean;
|
||||
/** Required when variant="button". */
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tile({ variant = "default", chevron, onClick, className, children }: TileProps) {
|
||||
const classes = ["ads-tile"];
|
||||
if (variant === "flat") classes.push("ads-tile--flat");
|
||||
if (variant === "button") classes.push("ads-tile--button");
|
||||
if (className) classes.push(className);
|
||||
|
||||
if (variant === "button") {
|
||||
return (
|
||||
<button type="button" className={classes.join(" ")} onClick={onClick}>
|
||||
<span className="ads-tile__content">{children}</span>
|
||||
{chevron && (
|
||||
<svg className="ads-tile__go" viewBox="0 0 6 10" aria-hidden>
|
||||
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classes.join(" ")}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Tile } from "./Tile";
|
||||
export type { TileProps } from "./Tile";
|
||||
@@ -0,0 +1,53 @@
|
||||
export { Pill } from "./components/Pill";
|
||||
export type { PillProps } from "./components/Pill";
|
||||
|
||||
export { IconButton } from "./components/IconButton";
|
||||
export type { IconButtonProps } from "./components/IconButton";
|
||||
|
||||
export { Fig } from "./components/Fig";
|
||||
export type { FigProps } from "./components/Fig";
|
||||
|
||||
export { StatusRow } from "./components/StatusRow";
|
||||
export type { StatusRowProps } from "./components/StatusRow";
|
||||
|
||||
export { ActionButton } from "./components/ActionButton";
|
||||
export type { ActionButtonProps } from "./components/ActionButton";
|
||||
|
||||
export { Switch } from "./components/Switch";
|
||||
export type { SwitchProps } from "./components/Switch";
|
||||
|
||||
export { Seg } from "./components/Seg";
|
||||
export type { SegProps, SegOption } from "./components/Seg";
|
||||
|
||||
export { RowList } from "./components/RowList";
|
||||
export type { RowListProps, RowListItem } from "./components/RowList";
|
||||
|
||||
export { Tile } from "./components/Tile";
|
||||
export type { TileProps } from "./components/Tile";
|
||||
|
||||
export { Feld } from "./components/Feld";
|
||||
export type { FeldProps } from "./components/Feld";
|
||||
|
||||
export { ProgressRing } from "./components/ProgressRing";
|
||||
export type { ProgressRingProps } from "./components/ProgressRing";
|
||||
|
||||
export { ProgressBar } from "./components/ProgressBar";
|
||||
export type { ProgressBarProps, ProgressBarSegment } from "./components/ProgressBar";
|
||||
|
||||
export { StatGrid } from "./components/StatGrid";
|
||||
export type { StatGridProps, StatGridItem } from "./components/StatGrid";
|
||||
|
||||
export { Accordion, LeafRow } from "./components/Accordion";
|
||||
export type { AccordionProps, LeafRowProps } from "./components/Accordion";
|
||||
|
||||
export { SwipeRow } from "./components/SwipeRow";
|
||||
export type { SwipeRowProps } from "./components/SwipeRow";
|
||||
|
||||
export { TabBar } from "./components/TabBar";
|
||||
export type { TabBarProps, TabBarItem } from "./components/TabBar";
|
||||
|
||||
export { Popup, PopupMenuItem } from "./components/Popup";
|
||||
export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./components/Popup";
|
||||
|
||||
export { ImagePlaceholder } from "./components/ImagePlaceholder";
|
||||
export type { ImagePlaceholderProps } from "./components/ImagePlaceholder";
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Einstiegspunkt: Tokens zuerst, danach jede Komponenten-CSS-Datei. */
|
||||
@import "./tokens/tokens.css";
|
||||
|
||||
@import "./components/Pill/Pill.css";
|
||||
@import "./components/IconButton/IconButton.css";
|
||||
@import "./components/Fig/Fig.css";
|
||||
@import "./components/StatusRow/StatusRow.css";
|
||||
@import "./components/ActionButton/ActionButton.css";
|
||||
@import "./components/Switch/Switch.css";
|
||||
@import "./components/Seg/Seg.css";
|
||||
@import "./components/RowList/RowList.css";
|
||||
@import "./components/Accordion/LeafRow.css";
|
||||
@import "./components/Tile/Tile.css";
|
||||
@import "./components/Feld/Feld.css";
|
||||
@import "./components/ProgressRing/ProgressRing.css";
|
||||
@import "./components/ProgressBar/ProgressBar.css";
|
||||
@import "./components/StatGrid/StatGrid.css";
|
||||
@import "./components/Accordion/Accordion.css";
|
||||
@import "./components/SwipeRow/SwipeRow.css";
|
||||
@import "./components/TabBar/TabBar.css";
|
||||
@import "./components/Popup/Popup.css";
|
||||
@import "./components/ImagePlaceholder/ImagePlaceholder.css";
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Design-Tokens, aus dem Audi-Dashboard (audi-dashboard.css) übernommen.
|
||||
* Bewusst OHNE die dort eingebundene "Audi Type"-Schriftfamilie und ohne
|
||||
* Audi-Marken-Assets (Ringe/Typenschild) - laut bauauftrag.md §"Rechtlich"
|
||||
* ausschließlich für die private, nicht veröffentlichte HA-Installation
|
||||
* freigegeben. Hier gilt ein neutraler Fallback-Font-Stack.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Markenunabhängig */
|
||||
--red: #F50537;
|
||||
--r-tile: 20px;
|
||||
--r-pill: 999px;
|
||||
--r-func: 8px;
|
||||
--rahmen-hg: #111;
|
||||
|
||||
/* Abstandsskala (aus den im Original wiederkehrenden Literalwerten abgeleitet) */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 22px;
|
||||
|
||||
/* Nacht (Default-Theme, entspricht dem :root-Block des Originals) */
|
||||
--canvas: #161b23;
|
||||
--tile: #1f2733;
|
||||
--tile-2: #2a3341;
|
||||
--line: rgba(255, 255, 255, .10);
|
||||
--line-strong: rgba(255, 255, 255, .20);
|
||||
--fg: #FFFFFF;
|
||||
--fg2: #9aa1ad;
|
||||
--fg3: #657081;
|
||||
--ok: #15da15;
|
||||
--warn: #ffaa00;
|
||||
--bad: #fd2c4e;
|
||||
--shade: rgba(255, 255, 255, .05);
|
||||
|
||||
--font-stack: Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="tag"] {
|
||||
--canvas: #FFFFFF;
|
||||
--tile: #f2f2f2;
|
||||
--tile-2: #e5e5e5;
|
||||
--line: rgba(0, 0, 0, .10);
|
||||
--line-strong: rgba(0, 0, 0, .22);
|
||||
--fg: #000000;
|
||||
--fg2: #4c4c4c;
|
||||
--fg3: #666666;
|
||||
--ok: #0DA20D;
|
||||
--warn: #ffaa00;
|
||||
--bad: #eb0d3f;
|
||||
--shade: rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
/*
|
||||
* Wurzel-Klasse: von konsumierenden Apps um den Baum zu legen, der die
|
||||
* Bibliothek nutzt (ersetzt das :host-Scoping, das das Original per
|
||||
* Shadow DOM kostenlos bekam). Setzt Schriftstack + Zahlendarstellung.
|
||||
*/
|
||||
.ads-root {
|
||||
font-family: var(--font-stack);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
/* Reine Typografie-Utility-Klassen (siehe README) - keine eigenen Komponenten */
|
||||
.ads-eyebrow {
|
||||
font-size: 10px;
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg3);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ads-title {
|
||||
font-size: 23px;
|
||||
letter-spacing: -.015em;
|
||||
line-height: 1.15;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ads-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg3);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ads-sport {
|
||||
font-style: italic;
|
||||
letter-spacing: .005em;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts", styles: "src/styles.css" },
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
external: ["react", "react-dom"],
|
||||
});
|
||||
Reference in New Issue
Block a user