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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user