Design: UI-Bausteine rendern die neuen Klassen (Button, Badge, Card, Kopf, Reiter, Stat)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 06:02:54 +00:00
1 parent 5a0307c9d6
commit 822387e4f7
16 files changed
+130 -72

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ export default function AutocompleteInput({ value, onChange, getSuggestions, id,
<input <input
type="text" type="text"
id={id} id={id}
className="form-control" className="input"
role="combobox" role="combobox"
aria-autocomplete="list" aria-autocomplete="list"
aria-expanded={showList} aria-expanded={showList}
+6 -7
View File
@@ -1,8 +1,7 @@
export default function Badge({ tone = 'muted', dot = false, children, className = '', style }) { import { badgeToneClass } from './tones'
return (
<span className={`badge badge-${tone} ${className}`} style={style}> // "dot" bleibt als Prop erlaubt und wird ignoriert
{dot && <span className="dot" />} export default function Badge({ tone = 'muted', dot, className = '', style, children }) {
{children} const cls = className ? `${badgeToneClass(tone)} ${className}` : badgeToneClass(tone)
</span> return <span className={cls} style={style}>{children}</span>
)
} }
+4 -7
View File
@@ -1,10 +1,6 @@
const VARIANTS = { import { BUTTON_CLASSES } from './tones'
default: 'ui-btn',
primary: 'ui-btn ui-btn-primary',
ghost: 'ui-btn ui-btn-ghost',
danger: 'ui-btn ui-btn-danger',
}
// "size" bleibt als Prop erlaubt und wird ignoriert; ein Standard-"type" gibt es nicht
export default function Button({ export default function Button({
variant = 'default', variant = 'default',
size, size,
@@ -13,7 +9,8 @@ export default function Button({
children, children,
...props ...props
}) { }) {
const cls = `${VARIANTS[variant] || VARIANTS.default}${size === 'sm' ? ' ui-btn-sm' : ''} ${className}` const base = Object.prototype.hasOwnProperty.call(BUTTON_CLASSES, variant) ? BUTTON_CLASSES[variant] : BUTTON_CLASSES.default
const cls = className ? `${base} ${className}` : base
const Comp = as const Comp = as
return ( return (
<Comp className={cls} {...props}> <Comp className={cls} {...props}>
+11 -15
View File
@@ -1,18 +1,14 @@
export default function Card({ title, actions, children, pad = true, className = '', bodyStyle, style }) { // "pad", "bodyStyle" und "style" bleiben als Props erlaubt und werden ignoriert
if (!title && !actions) { export default function Card({ title, actions, className, children }) {
return (
<div className={`ui-card ${pad ? 'pad' : ''} ${className}`} style={style}>
{children}
</div>
)
}
return ( return (
<div className={`ui-card ${className}`} style={style}> <section className={className || undefined}>
<div className="ui-card-head"> {(title || actions) && (
<h3>{title}</h3> <h2 className="section">
{actions && <div className="flex gap-2 items-center">{actions}</div>} <span>{title}</span>
</div> {actions && <span className="actions">{actions}</span>}
<div className="ui-card-body" style={bodyStyle}>{children}</div> </h2>
</div> )}
{children}
</section>
) )
} }
+2 -2
View File
@@ -15,7 +15,7 @@ export default function DateTimeInput({ id, date, time, onDateChange, onTimeChan
<input <input
id={id} id={id}
type="date" type="date"
className="form-control" className="input"
min={min || undefined} min={min || undefined}
required={required} required={required}
value={date} value={date}
@@ -24,7 +24,7 @@ export default function DateTimeInput({ id, date, time, onDateChange, onTimeChan
/> />
<input <input
type="time" type="time"
className="form-control" className="input"
aria-label={timeLabel} aria-label={timeLabel}
required={timeRequired} required={timeRequired}
value={time} value={time}
+1 -1
View File
@@ -5,7 +5,7 @@ export default function DurationSelect({ id, value, onChange, disabled }) {
return ( return (
<select <select
id={id} id={id}
className="form-control" className="select"
value={value} value={value}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => onChange(Number(e.target.value))}
+4 -4
View File
@@ -1,10 +1,10 @@
// "icon" bleibt als Prop erlaubt und wird ignoriert
export default function EmptyState({ icon, title, hint, action }) { export default function EmptyState({ icon, title, hint, action }) {
return ( return (
<div className="empty"> <div className="empty">
{icon && <div className="empty-icon">{icon}</div>} <strong>{title}</strong>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{title}</div> {hint}
{hint && <div className="faint" style={{ fontSize: 13 }}>{hint}</div>} {action && <div className="mt-2">{action}</div>}
{action && <div style={{ marginTop: 16 }}>{action}</div>}
</div> </div>
) )
} }
+3 -3
View File
@@ -1,9 +1,9 @@
export default function Field({ label, children, hint }) { export default function Field({ label, htmlFor, children, hint }) {
return ( return (
<div className="field"> <div className="field">
{label && <label>{label}</label>} {label && <label htmlFor={htmlFor}>{label}</label>}
{children} {children}
{hint && <div className="faint" style={{ fontSize: 12 }}>{hint}</div>} {hint && <div className="hint">{hint}</div>}
</div> </div>
) )
} }
+5 -9
View File
@@ -1,10 +1,6 @@
export default function Kpi({ label, value, sub, icon, accent = 'var(--accent)' }) { import Stat from './Stat'
return (
<div className="kpi"> // Altname fuer Stat (wird in P7 geloescht); "icon" wird ignoriert, die Warnfarbe wird zu 'neg'
<span className="kpi-accent" style={{ background: accent }} /> export default function Kpi({ label, value, sub, icon, accent }) {
<div className="kpi-label">{icon}{label}</div> return <Stat label={label} value={value} sub={sub} tone={accent === 'var(--danger)' ? 'neg' : ''} />
<div className="kpi-value">{value}</div>
{sub && <div className="kpi-sub">{sub}</div>}
</div>
)
} }
+25 -8
View File
@@ -1,12 +1,29 @@
export default function PageHeader({ title, subtitle, actions, children }) { import { Fragment } from 'react'
import { Link } from 'react-router-dom'
// crumb: [{ label, to? }] steht vor dem Kopf; Eintraege mit "to" sind Links, die anderen Text
export default function PageHeader({ title, subtitle, actions, eyebrow, crumb, children }) {
return ( return (
<div className="page-header"> <>
<div> {crumb && crumb.length > 0 && (
<h1 className="page-title">{title}</h1> <nav className="crumb" aria-label="Brotkrumen">
{subtitle && <div className="page-subtitle">{subtitle}</div>} {crumb.map((c, i) => (
{children} <Fragment key={i}>
{i > 0 && <span className="faint">/</span>}
{c.to ? <Link to={c.to}>{c.label}</Link> : <span>{c.label}</span>}
</Fragment>
))}
</nav>
)}
<div className="page-head">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h1>{title}</h1>
{subtitle && <div className="sub">{subtitle}</div>}
{children}
</div>
{actions && <div className="actions">{actions}</div>}
</div> </div>
{actions && <div className="page-actions">{actions}</div>} </>
</div>
) )
} }
+10
View File
@@ -0,0 +1,10 @@
// Kennzahl: Beschriftung, Wert, Zusatzzeile; tone '' | 'warn' | 'neg'. Der Container .stats kommt vom Aufrufer.
export default function Stat({ label, value, sub, tone = '' }) {
return (
<div className="stat">
<div className="k">{label}</div>
<div className={tone ? `v ${tone}` : 'v'}>{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}
+4 -13
View File
@@ -1,21 +1,12 @@
import { statusLabel, statusClass, isUrgent } from '../../lib/ticketStatus' import { statusLabel, statusTone, isUrgent } from '../../lib/ticketStatus'
const pillBase = {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '3px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
}
export function StatusPill({ status }) { export function StatusPill({ status }) {
return <span className={statusClass(status)} style={pillBase}>{statusLabel(status)}</span> const tone = statusTone(status)
return <span className={tone ? `badge ${tone}` : 'badge'}>{statusLabel(status)}</span>
} }
// Nur dringende Tickets bekommen ein Abzeichen // Nur dringende Tickets bekommen ein Abzeichen
export function UrgentPill({ priority }) { export function UrgentPill({ priority }) {
if (!isUrgent(priority)) return null if (!isUrgent(priority)) return null
return <span className="priority-critical" style={pillBase}>Dringend</span> return <span className="badge red">Dringend</span>
} }
+5 -2
View File
@@ -1,14 +1,17 @@
export default function Tabs({ tabs, active, onChange }) { export default function Tabs({ tabs, active, onChange }) {
return ( return (
<div className="ticket-tabs"> <div className="tabs" role="tablist">
{tabs.map((t) => ( {tabs.map((t) => (
<button <button
key={t.id} key={t.id}
type="button" type="button"
className={`ticket-tab ${active === t.id ? 'ticket-tab-active' : ''}`} role="tab"
aria-selected={active === t.id}
className={active === t.id ? 'tab active' : 'tab'}
onClick={() => onChange(t.id)} onClick={() => onChange(t.id)}
> >
{t.label} {t.label}
{t.count != null && <span className="count">{t.count}</span>}
</button> </button>
))} ))}
</div> </div>
+1
View File
@@ -6,6 +6,7 @@ export { StatusPill, UrgentPill } from './StatusPill'
export { default as Tabs } from './Tabs' export { default as Tabs } from './Tabs'
export { default as EmptyState } from './EmptyState' export { default as EmptyState } from './EmptyState'
export { default as Field } from './Field' export { default as Field } from './Field'
export { default as Stat } from './Stat'
export { default as Kpi } from './Kpi' export { default as Kpi } from './Kpi'
export { default as AutocompleteInput } from './AutocompleteInput' export { default as AutocompleteInput } from './AutocompleteInput'
export { default as DateTimeInput } from './DateTimeInput' export { default as DateTimeInput } from './DateTimeInput'
+28
View File
@@ -0,0 +1,28 @@
/* Klassen der Bausteine: reine Zuordnung, ohne JSX (Test: tests/unit/uiTones.test.mjs) */
// Badge-Ton (alter und neuer Name) auf die Klasse; "muted"/"grey" und alles Unbekannte = neutral
const BADGE_TONES = {
info: 'blue',
blue: 'blue',
ok: 'green',
green: 'green',
warn: 'orange',
orange: 'orange',
danger: 'red',
red: 'red',
violet: 'violet',
plain: 'plain',
}
export function badgeToneClass(tone) {
const t = Object.prototype.hasOwnProperty.call(BADGE_TONES, tone) ? BADGE_TONES[tone] : ''
return t ? `badge ${t}` : 'badge'
}
export const BUTTON_CLASSES = {
default: 'btn',
ghost: 'btn',
primary: 'btn btn-primary',
quiet: 'btn btn-quiet',
danger: 'link-danger',
}
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { badgeToneClass, BUTTON_CLASSES } = await loadModule('src/components/ui/tones.js')
test('badgeToneClass', () => {
for (const t of ['muted', 'grey', undefined, 'unbekannt']) assert.equal(badgeToneClass(t), 'badge')
assert.equal(badgeToneClass('info'), 'badge blue')
assert.equal(badgeToneClass('blue'), 'badge blue')
assert.equal(badgeToneClass('ok'), 'badge green')
assert.equal(badgeToneClass('warn'), 'badge orange')
assert.equal(badgeToneClass('danger'), 'badge red')
assert.equal(badgeToneClass('violet'), 'badge violet')
assert.equal(badgeToneClass('plain'), 'badge plain')
})
test('Button-Varianten', () => {
assert.deepEqual(BUTTON_CLASSES, { default: 'btn', ghost: 'btn', primary: 'btn btn-primary', quiet: 'btn btn-quiet', danger: 'link-danger' })
})