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:
1 parent
5a0307c9d6
commit
822387e4f7
16 files changed
+130
-72
No files matched your search
@@ -38,7 +38,7 @@ export default function AutocompleteInput({ value, onChange, getSuggestions, id,
|
||||
<input
|
||||
type="text"
|
||||
id={id}
|
||||
className="form-control"
|
||||
className="input"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showList}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export default function Badge({ tone = 'muted', dot = false, children, className = '', style }) {
|
||||
return (
|
||||
<span className={`badge badge-${tone} ${className}`} style={style}>
|
||||
{dot && <span className="dot" />}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
import { badgeToneClass } from './tones'
|
||||
|
||||
// "dot" bleibt als Prop erlaubt und wird ignoriert
|
||||
export default function Badge({ tone = 'muted', dot, className = '', style, children }) {
|
||||
const cls = className ? `${badgeToneClass(tone)} ${className}` : badgeToneClass(tone)
|
||||
return <span className={cls} style={style}>{children}</span>
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
const VARIANTS = {
|
||||
default: 'ui-btn',
|
||||
primary: 'ui-btn ui-btn-primary',
|
||||
ghost: 'ui-btn ui-btn-ghost',
|
||||
danger: 'ui-btn ui-btn-danger',
|
||||
}
|
||||
import { BUTTON_CLASSES } from './tones'
|
||||
|
||||
// "size" bleibt als Prop erlaubt und wird ignoriert; ein Standard-"type" gibt es nicht
|
||||
export default function Button({
|
||||
variant = 'default',
|
||||
size,
|
||||
@@ -13,7 +9,8 @@ export default function Button({
|
||||
children,
|
||||
...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
|
||||
return (
|
||||
<Comp className={cls} {...props}>
|
||||
|
||||
+11
-15
@@ -1,18 +1,14 @@
|
||||
export default function Card({ title, actions, children, pad = true, className = '', bodyStyle, style }) {
|
||||
if (!title && !actions) {
|
||||
return (
|
||||
<div className={`ui-card ${pad ? 'pad' : ''} ${className}`} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// "pad", "bodyStyle" und "style" bleiben als Props erlaubt und werden ignoriert
|
||||
export default function Card({ title, actions, className, children }) {
|
||||
return (
|
||||
<div className={`ui-card ${className}`} style={style}>
|
||||
<div className="ui-card-head">
|
||||
<h3>{title}</h3>
|
||||
{actions && <div className="flex gap-2 items-center">{actions}</div>}
|
||||
</div>
|
||||
<div className="ui-card-body" style={bodyStyle}>{children}</div>
|
||||
</div>
|
||||
<section className={className || undefined}>
|
||||
{(title || actions) && (
|
||||
<h2 className="section">
|
||||
<span>{title}</span>
|
||||
{actions && <span className="actions">{actions}</span>}
|
||||
</h2>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function DateTimeInput({ id, date, time, onDateChange, onTimeChan
|
||||
<input
|
||||
id={id}
|
||||
type="date"
|
||||
className="form-control"
|
||||
className="input"
|
||||
min={min || undefined}
|
||||
required={required}
|
||||
value={date}
|
||||
@@ -24,7 +24,7 @@ export default function DateTimeInput({ id, date, time, onDateChange, onTimeChan
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
className="form-control"
|
||||
className="input"
|
||||
aria-label={timeLabel}
|
||||
required={timeRequired}
|
||||
value={time}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function DurationSelect({ id, value, onChange, disabled }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
className="form-control"
|
||||
className="select"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// "icon" bleibt als Prop erlaubt und wird ignoriert
|
||||
export default function EmptyState({ icon, title, hint, action }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
{icon && <div className="empty-icon">{icon}</div>}
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>{title}</div>
|
||||
{hint && <div className="faint" style={{ fontSize: 13 }}>{hint}</div>}
|
||||
{action && <div style={{ marginTop: 16 }}>{action}</div>}
|
||||
<strong>{title}</strong>
|
||||
{hint}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
export default function Field({ label, children, hint }) {
|
||||
export default function Field({ label, htmlFor, children, hint }) {
|
||||
return (
|
||||
<div className="field">
|
||||
{label && <label>{label}</label>}
|
||||
{label && <label htmlFor={htmlFor}>{label}</label>}
|
||||
{children}
|
||||
{hint && <div className="faint" style={{ fontSize: 12 }}>{hint}</div>}
|
||||
{hint && <div className="hint">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
export default function Kpi({ label, value, sub, icon, accent = 'var(--accent)' }) {
|
||||
return (
|
||||
<div className="kpi">
|
||||
<span className="kpi-accent" style={{ background: accent }} />
|
||||
<div className="kpi-label">{icon}{label}</div>
|
||||
<div className="kpi-value">{value}</div>
|
||||
{sub && <div className="kpi-sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
import Stat from './Stat'
|
||||
|
||||
// Altname fuer Stat (wird in P7 geloescht); "icon" wird ignoriert, die Warnfarbe wird zu 'neg'
|
||||
export default function Kpi({ label, value, sub, icon, accent }) {
|
||||
return <Stat label={label} value={value} sub={sub} tone={accent === 'var(--danger)' ? 'neg' : ''} />
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{subtitle && <div className="page-subtitle">{subtitle}</div>}
|
||||
{children}
|
||||
<>
|
||||
{crumb && crumb.length > 0 && (
|
||||
<nav className="crumb" aria-label="Brotkrumen">
|
||||
{crumb.map((c, i) => (
|
||||
<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>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
import { statusLabel, statusClass, isUrgent } from '../../lib/ticketStatus'
|
||||
|
||||
const pillBase = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}
|
||||
import { statusLabel, statusTone, isUrgent } from '../../lib/ticketStatus'
|
||||
|
||||
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
|
||||
export function UrgentPill({ priority }) {
|
||||
if (!isUrgent(priority)) return null
|
||||
return <span className="priority-critical" style={pillBase}>Dringend</span>
|
||||
return <span className="badge red">Dringend</span>
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
export default function Tabs({ tabs, active, onChange }) {
|
||||
return (
|
||||
<div className="ticket-tabs">
|
||||
<div className="tabs" role="tablist">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
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)}
|
||||
>
|
||||
{t.label}
|
||||
{t.count != null && <span className="count">{t.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ export { StatusPill, UrgentPill } from './StatusPill'
|
||||
export { default as Tabs } from './Tabs'
|
||||
export { default as EmptyState } from './EmptyState'
|
||||
export { default as Field } from './Field'
|
||||
export { default as Stat } from './Stat'
|
||||
export { default as Kpi } from './Kpi'
|
||||
export { default as AutocompleteInput } from './AutocompleteInput'
|
||||
export { default as DateTimeInput } from './DateTimeInput'
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
Reference in new issue
Block a user