nuxt-ui-basekit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/app/assets/css/basekit.css +86 -0
  4. package/app/components/BaseKitBackLink.vue +41 -0
  5. package/app/components/BaseKitChoiceCard.vue +107 -0
  6. package/app/components/BaseKitConfirmModal.vue +55 -0
  7. package/app/components/BaseKitDataTable.vue +293 -0
  8. package/app/components/BaseKitEmptyState.vue +39 -0
  9. package/app/components/BaseKitFileUpload.vue +90 -0
  10. package/app/components/BaseKitIconPicker.vue +130 -0
  11. package/app/components/BaseKitMarkdownEditor.vue +112 -0
  12. package/app/components/BaseKitPending.vue +54 -0
  13. package/app/components/BaseKitRecordPicker.vue +154 -0
  14. package/app/components/BaseKitSettingRow.vue +66 -0
  15. package/app/components/BaseKitStatTile.vue +64 -0
  16. package/app/components/BaseKitTabs.vue +161 -0
  17. package/app/components/BaseKitViewLink.vue +53 -0
  18. package/app/components/charts/BaseKitChartBars.vue +87 -0
  19. package/app/components/charts/BaseKitChartColumns.vue +319 -0
  20. package/app/components/charts/BaseKitChartDonut.vue +175 -0
  21. package/app/components/charts/BaseKitChartFigure.vue +87 -0
  22. package/app/components/charts/BaseKitChartMeter.vue +57 -0
  23. package/app/composables/useBaseKit.ts +155 -0
  24. package/app/composables/useChartPalette.ts +89 -0
  25. package/app/composables/useChartWidth.ts +41 -0
  26. package/app/composables/useConfirm.ts +16 -0
  27. package/app/stores/confirm.ts +56 -0
  28. package/app/utils/html-to-markdown.ts +47 -0
  29. package/app/utils/markdown.ts +24 -0
  30. package/nuxt.config.ts +43 -0
  31. package/package.json +78 -0
@@ -0,0 +1,319 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Gestapelte Säulen über einer Zeitachse — „was ist je Tag entstanden".
4
+ *
5
+ * Warum gestapelt und nicht mehrere Linien: die Frage ist Anteil am Ganzen,
6
+ * nicht Verlauf einzelner Reihen. Und warum Säulen und keine Fläche: Tageswerte
7
+ * sind gezählte Ereignisse, keine stetige Größe — eine Fläche würde zwischen
8
+ * zwei Tagen interpolieren und behaupten, dass um 12 Uhr nachts etwas
9
+ * halb passiert ist.
10
+ *
11
+ * Trennung zwischen den Segmenten passiert über eine 2-px-Lücke in der
12
+ * Kartenfarbe, nicht über einen Rahmen: ein Rahmen ist Tinte, die keine Daten
13
+ * trägt, und bei dünnen Segmenten überdeckt er den Wert.
14
+ *
15
+ * Höchstens fünf Reihen — siehe `useChartPalette`.
16
+ */
17
+ import { computed, ref } from 'vue'
18
+ import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
19
+ import { useChartWidth } from '../../composables/useChartWidth'
20
+
21
+ interface ColumnSeries {
22
+ key: string
23
+ label: string
24
+ points: number[]
25
+ }
26
+
27
+ const props = withDefaults(defineProps<{
28
+ /** Beschriftung je Säule, z. B. ISO-Tage. */
29
+ categories: string[]
30
+ series: ColumnSeries[]
31
+ height?: number
32
+ /** Achsenbeschriftung (kurz) aus einer Kategorie. */
33
+ formatCategory?: (value: string) => string
34
+ /** Ausführliche Fassung für den Tooltip. */
35
+ formatCategoryLong?: (value: string) => string
36
+ ariaLabel?: string
37
+ }>(), {
38
+ height: 200,
39
+ formatCategory: (value: string) => value,
40
+ formatCategoryLong: undefined,
41
+ ariaLabel: undefined,
42
+ })
43
+
44
+ const { number } = useChartFormat()
45
+ const { el, width } = useChartWidth()
46
+
47
+ const PADDING = { top: 10, right: 8, bottom: 22, left: 40 }
48
+ const MAX_COLUMN = 14
49
+ const GAP = 2
50
+
51
+ const active = ref<number | null>(null)
52
+
53
+ const plot = computed(() => {
54
+ const innerWidth = Math.max(60, width.value - PADDING.left - PADDING.right)
55
+ const innerHeight = Math.max(40, props.height - PADDING.top - PADDING.bottom)
56
+ const count = Math.max(1, props.categories.length)
57
+ const band = innerWidth / count
58
+ return {
59
+ innerWidth,
60
+ innerHeight,
61
+ band,
62
+ columnWidth: Math.max(2, Math.min(MAX_COLUMN, band - 3)),
63
+ baseline: PADDING.top + innerHeight,
64
+ }
65
+ })
66
+
67
+ /** Tagessummen — sie bestimmen die Skala und stehen im Tooltip. */
68
+ const totals = computed(() =>
69
+ props.categories.map((_, index) =>
70
+ props.series.reduce((sum, s) => sum + (s.points[index] ?? 0), 0),
71
+ ),
72
+ )
73
+
74
+ /**
75
+ * Obere Achsengrenze auf eine runde Zahl aufgerundet. Ohne das steht die
76
+ * Beschriftung auf krummen Werten wie 37, und die Gitterlinien tragen keine
77
+ * ablesbare Auskunft mehr.
78
+ */
79
+ const scaleMax = computed(() => {
80
+ const peak = Math.max(0, ...totals.value)
81
+ if (peak === 0) return 1
82
+ const magnitude = 10 ** Math.floor(Math.log10(peak))
83
+ for (const step of [1, 2, 2.5, 5, 10]) {
84
+ const candidate = step * magnitude
85
+ if (candidate >= peak) return candidate
86
+ }
87
+ return 10 * magnitude
88
+ })
89
+
90
+ const ticks = computed(() => {
91
+ const max = scaleMax.value
92
+ return [0, max / 2, max].map(value => ({
93
+ value,
94
+ y: plot.value.baseline - (value / max) * plot.value.innerHeight,
95
+ label: number(Math.round(value)),
96
+ }))
97
+ })
98
+
99
+ function columnX(index: number): number {
100
+ return PADDING.left + plot.value.band * (index + 0.5) - plot.value.columnWidth / 2
101
+ }
102
+
103
+ /** Runde Kappe nur oben — unten sitzt die Säule auf der Grundlinie auf. */
104
+ function cappedPath(x: number, y: number, w: number, h: number): string {
105
+ const r = Math.min(4, w / 2, h)
106
+ if (h <= 0) return ''
107
+ return [
108
+ `M ${x} ${y + h}`,
109
+ `L ${x} ${y + r}`,
110
+ `Q ${x} ${y} ${x + r} ${y}`,
111
+ `L ${x + w - r} ${y}`,
112
+ `Q ${x + w} ${y} ${x + w} ${y + r}`,
113
+ `L ${x + w} ${y + h}`,
114
+ 'Z',
115
+ ].join(' ')
116
+ }
117
+
118
+ interface Segment {
119
+ key: string
120
+ path: string
121
+ color: string
122
+ }
123
+
124
+ const columns = computed(() => {
125
+ const { innerHeight, baseline, columnWidth } = plot.value
126
+ const max = scaleMax.value
127
+
128
+ return props.categories.map((category, index) => {
129
+ const segments: Segment[] = []
130
+ let stacked = 0
131
+
132
+ // Von unten nach oben stapeln; die oberste Reihe bekommt die Kappe.
133
+ const present = props.series
134
+ .map((s, order) => ({ s, order, value: s.points[index] ?? 0 }))
135
+ .filter(entry => entry.value > 0)
136
+
137
+ present.forEach((entry, position) => {
138
+ const bottom = baseline - (stacked / max) * innerHeight
139
+ stacked += entry.value
140
+ const top = baseline - (stacked / max) * innerHeight
141
+
142
+ const isTop = position === present.length - 1
143
+ // Lücke nach oben, außer beim obersten Segment — dort säße sie
144
+ // zwischen Säule und Luft und würde die Säule nur kürzen.
145
+ const height = Math.max(0, bottom - top - (isTop ? 0 : GAP))
146
+ if (height <= 0) return
147
+
148
+ segments.push({
149
+ key: entry.s.key,
150
+ color: baseKitChartColor(entry.order),
151
+ path: isTop
152
+ ? cappedPath(columnX(index), top, columnWidth, height)
153
+ : `M ${columnX(index)} ${top} h ${columnWidth} v ${height} h ${-columnWidth} Z`,
154
+ })
155
+ })
156
+
157
+ return { category, index, segments, total: totals.value[index] ?? 0 }
158
+ })
159
+ })
160
+
161
+ /**
162
+ * Beschriftung ausdünnen, bis sie nicht mehr kollidiert — und zwar durch
163
+ * Weglassen, nicht durch Verstecken: ein ausgeblendeter Text bleibt im
164
+ * Baum stehen und wird von Screenreadern trotzdem vorgelesen.
165
+ */
166
+ const axisLabels = computed(() => {
167
+ const perLabel = 56
168
+ const every = Math.max(1, Math.ceil(perLabel / Math.max(1, plot.value.band)))
169
+
170
+ return props.categories
171
+ .map((category, index) => ({ category, index }))
172
+ .filter(entry => entry.index % every === 0)
173
+ .map(entry => ({
174
+ category: entry.category,
175
+ x: PADDING.left + plot.value.band * (entry.index + 0.5),
176
+ text: props.formatCategory(entry.category),
177
+ }))
178
+ })
179
+
180
+ const tooltip = computed(() => {
181
+ if (active.value === null) return null
182
+ const index = active.value
183
+ const long = props.formatCategoryLong ?? props.formatCategory
184
+
185
+ return {
186
+ index,
187
+ title: long(props.categories[index] ?? ''),
188
+ total: totals.value[index] ?? 0,
189
+ rows: props.series
190
+ .map((s, order) => ({
191
+ key: s.key,
192
+ label: s.label,
193
+ color: baseKitChartColor(order),
194
+ value: s.points[index] ?? 0,
195
+ }))
196
+ .filter(row => row.value > 0),
197
+ // Am Rand kippt der Kasten nach innen, statt aus der Karte zu laufen.
198
+ anchor: PADDING.left + plot.value.band * (index + 0.5),
199
+ }
200
+ })
201
+
202
+ function readout(index: number): string {
203
+ const long = props.formatCategoryLong ?? props.formatCategory
204
+ const parts = props.series
205
+ .map(s => `${s.label}: ${number(s.points[index] ?? 0)}`)
206
+ .join(', ')
207
+ return `${long(props.categories[index] ?? '')} — ${parts}`
208
+ }
209
+ </script>
210
+
211
+ <template>
212
+ <div ref="el" class="relative w-full">
213
+ <svg
214
+ :width="width"
215
+ :height="props.height"
216
+ :viewBox="`0 0 ${width} ${props.height}`"
217
+ role="group"
218
+ :aria-label="props.ariaLabel"
219
+ class="block max-w-full overflow-visible"
220
+ >
221
+ <!-- Gitter: durchgezogene Haarlinien, eine Stufe von der Fläche weg. -->
222
+ <g>
223
+ <line
224
+ v-for="tick in ticks"
225
+ :key="`grid-${tick.value}`"
226
+ :x1="PADDING.left"
227
+ :x2="width - PADDING.right"
228
+ :y1="tick.y"
229
+ :y2="tick.y"
230
+ :stroke="tick.value === 0 ? 'var(--basekit-chart-axis)' : 'var(--basekit-chart-grid)'"
231
+ stroke-width="1"
232
+ shape-rendering="crispEdges"
233
+ />
234
+ <text
235
+ v-for="tick in ticks"
236
+ :key="`tick-${tick.value}`"
237
+ :x="PADDING.left - 8"
238
+ :y="tick.y + 3"
239
+ text-anchor="end"
240
+ fill="currentColor"
241
+ class="text-dimmed text-[10px] tabular-nums"
242
+ >{{ tick.label }}</text>
243
+ </g>
244
+
245
+ <!-- Datenmarken -->
246
+ <g
247
+ v-for="column in columns"
248
+ :key="column.category"
249
+ >
250
+ <path
251
+ v-for="segment in column.segments"
252
+ :key="segment.key"
253
+ :d="segment.path"
254
+ :fill="segment.color"
255
+ :opacity="active === null || active === column.index ? 1 : 0.45"
256
+ class="transition-opacity"
257
+ />
258
+ </g>
259
+
260
+ <!-- Trefferflächen: über die volle Höhe und über die ganze Bandbreite,
261
+ damit niemand eine 14 px schmale Säule treffen muss. -->
262
+ <g>
263
+ <rect
264
+ v-for="column in columns"
265
+ :key="`hit-${column.category}`"
266
+ :x="PADDING.left + plot.band * column.index"
267
+ :y="PADDING.top"
268
+ :width="plot.band"
269
+ :height="plot.innerHeight"
270
+ fill="transparent"
271
+ tabindex="0"
272
+ role="button"
273
+ :aria-label="readout(column.index)"
274
+ class="cursor-default outline-none"
275
+ @pointerenter="active = column.index"
276
+ @pointerleave="active = null"
277
+ @focus="active = column.index"
278
+ @blur="active = null"
279
+ />
280
+ </g>
281
+
282
+ <!-- Achsenbeschriftung -->
283
+ <text
284
+ v-for="label in axisLabels"
285
+ :key="`label-${label.category}`"
286
+ :x="label.x"
287
+ :y="props.height - 6"
288
+ text-anchor="middle"
289
+ fill="currentColor"
290
+ class="text-dimmed text-[10px]"
291
+ >{{ label.text }}</text>
292
+ </svg>
293
+
294
+ <!-- Tooltip: Wert führt, Reihenname folgt. -->
295
+ <div
296
+ v-if="tooltip"
297
+ class="pointer-events-none absolute top-0 z-10 min-w-40 -translate-x-1/2 rounded-md border border-default bg-default p-2 shadow-lg"
298
+ :style="{ left: `${Math.min(Math.max(tooltip.anchor, 90), width - 90)}px` }"
299
+ >
300
+ <p class="mb-1 text-[11px] font-medium text-muted">
301
+ {{ tooltip.title }}
302
+ </p>
303
+ <ul class="space-y-0.5">
304
+ <li
305
+ v-for="row in tooltip.rows"
306
+ :key="row.key"
307
+ class="flex items-center gap-2 text-xs"
308
+ >
309
+ <span class="h-0.5 w-3 shrink-0 rounded-full" :style="{ backgroundColor: row.color }" />
310
+ <span class="font-semibold tabular-nums text-highlighted">{{ number(row.value) }}</span>
311
+ <span class="truncate text-muted">{{ row.label }}</span>
312
+ </li>
313
+ <li v-if="!tooltip.rows.length" class="text-xs text-muted">
314
+ {{ number(0) }}
315
+ </li>
316
+ </ul>
317
+ </div>
318
+ </div>
319
+ </template>
@@ -0,0 +1,175 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Ring für Teil-vom-Ganzen bei wenigen Klassen.
4
+ *
5
+ * Bewusst eng eingesetzt: ein Ring beantwortet „wie verteilt sich das grob",
6
+ * nicht „welcher von zweien ist größer". Sobald zwei Segmente nahe beieinander
7
+ * liegen, ist ein Balken die ehrlichere Form. Höchstens vier Segmente, sonst
8
+ * verlieren die kleinen ihre Beschriftung.
9
+ *
10
+ * In der Mitte steht die Summe — das ist die Zahl, die zuerst gesucht wird,
11
+ * und sie füllt den Platz, den ein Ring ohnehin frei lässt.
12
+ */
13
+ import { computed, ref } from 'vue'
14
+ import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
15
+
16
+ interface DonutSlice {
17
+ key: string
18
+ label: string
19
+ value: number
20
+ }
21
+
22
+ const props = withDefaults(defineProps<{
23
+ slices: DonutSlice[]
24
+ /** Beschriftung unter der Summe in der Mitte. */
25
+ centerLabel?: string
26
+ size?: number
27
+ ariaLabel?: string
28
+ }>(), {
29
+ centerLabel: undefined,
30
+ size: 168,
31
+ ariaLabel: undefined,
32
+ })
33
+
34
+ const { number, percent } = useChartFormat()
35
+
36
+ const THICKNESS = 18
37
+ /** Lücke zwischen den Segmenten, in Grad — das Gegenstück zur 2-px-Lücke. */
38
+ const GAP_DEGREES = 2
39
+
40
+ const active = ref<string | null>(null)
41
+
42
+ const total = computed(() => props.slices.reduce((sum, slice) => sum + slice.value, 0))
43
+
44
+ const radius = computed(() => props.size / 2 - THICKNESS / 2 - 2)
45
+ const center = computed(() => props.size / 2)
46
+
47
+ function pointOnCircle(angle: number, r: number): [number, number] {
48
+ const radians = ((angle - 90) * Math.PI) / 180
49
+ return [center.value + r * Math.cos(radians), center.value + r * Math.sin(radians)]
50
+ }
51
+
52
+ const arcs = computed(() => {
53
+ if (total.value <= 0) return []
54
+
55
+ let cursor = 0
56
+ return props.slices
57
+ .map((slice, order) => ({ slice, order }))
58
+ .filter(entry => entry.slice.value > 0)
59
+ .map(({ slice, order }) => {
60
+ const sweep = (slice.value / total.value) * 360
61
+ const start = cursor
62
+ cursor += sweep
63
+
64
+ // Ein Segment, das den ganzen Kreis füllt, hätte identischen Start- und
65
+ // Endpunkt — der Bogen verschwände. Dann zeichnen wir den vollen Ring.
66
+ const full = sweep >= 359.9
67
+ const gap = full ? 0 : Math.min(GAP_DEGREES, sweep / 3)
68
+ const from = start + gap / 2
69
+ const to = start + sweep - gap / 2
70
+
71
+ const [x1, y1] = pointOnCircle(from, radius.value)
72
+ const [x2, y2] = pointOnCircle(to, radius.value)
73
+ const largeArc = to - from > 180 ? 1 : 0
74
+
75
+ return {
76
+ key: slice.key,
77
+ label: slice.label,
78
+ value: slice.value,
79
+ color: baseKitChartColor(order),
80
+ path: full
81
+ ? `M ${center.value} ${center.value - radius.value} `
82
+ + `a ${radius.value} ${radius.value} 0 1 1 -0.01 0`
83
+ : `M ${x1} ${y1} A ${radius.value} ${radius.value} 0 ${largeArc} 1 ${x2} ${y2}`,
84
+ }
85
+ })
86
+ })
87
+
88
+ const legend = computed(() =>
89
+ props.slices.map((slice, order) => ({
90
+ ...slice,
91
+ color: baseKitChartColor(order),
92
+ share: percent(slice.value, total.value),
93
+ })),
94
+ )
95
+ </script>
96
+
97
+ <template>
98
+ <!-- Legende unter dem Ring, nicht daneben: die Karte steht auf der
99
+ Übersicht in einer schmalen Spalte, und nebeneinander blieb für die
100
+ Beschriftungen so wenig übrig, dass „Ausgeschieden" auf ein „A"
101
+ zusammenschrumpfte. Untereinander hat jede Zeile die volle Breite. -->
102
+ <div class="flex flex-col items-center gap-5">
103
+ <svg
104
+ :width="props.size"
105
+ :height="props.size"
106
+ :viewBox="`0 0 ${props.size} ${props.size}`"
107
+ role="group"
108
+ :aria-label="props.ariaLabel"
109
+ class="shrink-0"
110
+ >
111
+ <!-- Leerer Ring, wenn nichts da ist: die Form bleibt erkennbar, statt
112
+ dass die Karte auf eine leere Fläche zusammenfällt. -->
113
+ <circle
114
+ v-if="!arcs.length"
115
+ :cx="center"
116
+ :cy="center"
117
+ :r="radius"
118
+ fill="none"
119
+ stroke="var(--basekit-chart-grid)"
120
+ :stroke-width="THICKNESS"
121
+ />
122
+
123
+ <path
124
+ v-for="arc in arcs"
125
+ :key="arc.key"
126
+ :d="arc.path"
127
+ fill="none"
128
+ :stroke="arc.color"
129
+ :stroke-width="THICKNESS"
130
+ stroke-linecap="butt"
131
+ :opacity="active === null || active === arc.key ? 1 : 0.4"
132
+ class="transition-opacity"
133
+ tabindex="0"
134
+ role="button"
135
+ :aria-label="`${arc.label}: ${number(arc.value)}`"
136
+ @pointerenter="active = arc.key"
137
+ @pointerleave="active = null"
138
+ @focus="active = arc.key"
139
+ @blur="active = null"
140
+ />
141
+
142
+ <text
143
+ :x="center"
144
+ :y="center - 2"
145
+ text-anchor="middle"
146
+ fill="currentColor"
147
+ class="text-highlighted text-2xl font-semibold"
148
+ >{{ number(total) }}</text>
149
+ <text
150
+ v-if="props.centerLabel"
151
+ :x="center"
152
+ :y="center + 16"
153
+ text-anchor="middle"
154
+ fill="currentColor"
155
+ class="text-dimmed text-[11px]"
156
+ >{{ props.centerLabel }}</text>
157
+ </svg>
158
+
159
+ <!-- Legende trägt die Identität, nicht die Farbe allein. -->
160
+ <ul class="w-full space-y-1.5">
161
+ <li
162
+ v-for="entry in legend"
163
+ :key="entry.key"
164
+ class="flex items-center gap-2 text-sm"
165
+ @pointerenter="active = entry.key"
166
+ @pointerleave="active = null"
167
+ >
168
+ <span class="size-2.5 shrink-0 rounded-sm" :style="{ backgroundColor: entry.color }" />
169
+ <span class="truncate text-muted">{{ entry.label }}</span>
170
+ <span class="ml-auto shrink-0 font-medium tabular-nums text-highlighted">{{ number(entry.value) }}</span>
171
+ <span class="w-12 shrink-0 text-right text-xs tabular-nums text-dimmed">{{ entry.share }}</span>
172
+ </li>
173
+ </ul>
174
+ </div>
175
+ </template>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Rahmen für ein Diagramm: Karte, Titel, Legende und der Umschalter auf die
4
+ * Tabellenansicht.
5
+ *
6
+ * Der Umschalter ist keine Zugabe. Ein Diagramm codiert Werte über Farbe und
7
+ * Länge; wer davon nichts hat — Farbfehlsichtigkeit, Screenreader, Ausdruck —
8
+ * braucht dieselben Zahlen in lesbarer Form. Deshalb bringt jede Karte hier
9
+ * ihre Tabelle mit, und deshalb ist der Umschalter sichtbar und nicht in einem
10
+ * Menü versteckt.
11
+ */
12
+ import { computed, ref } from 'vue'
13
+ import { useBaseKitLabels } from '../../composables/useBaseKit'
14
+
15
+ const props = withDefaults(defineProps<{
16
+ title: string
17
+ subtitle?: string
18
+ /** Wohin die Karte führt. Ohne Ziel bleibt der Titel ein Titel. */
19
+ href?: string
20
+ /** Diagramm ausgrauen, während im Hintergrund nachgeladen wird. */
21
+ stale?: boolean
22
+ /** Ohne Tabellen-Slot entfällt der Umschalter. */
23
+ tableAvailable?: boolean
24
+ }>(), {
25
+ subtitle: undefined,
26
+ href: undefined,
27
+ stale: false,
28
+ tableAvailable: true,
29
+ })
30
+
31
+ const labels = useBaseKitLabels()
32
+ const showTable = ref(false)
33
+
34
+ const toggleLabel = computed(() =>
35
+ showTable.value ? labels.value.chartAsChart : labels.value.chartAsTable,
36
+ )
37
+ const toggleIcon = computed(() =>
38
+ showTable.value ? 'i-lucide-chart-column' : 'i-lucide-table',
39
+ )
40
+ </script>
41
+
42
+ <template>
43
+ <UCard :ui="{ body: 'space-y-4' }">
44
+ <div class="flex items-start justify-between gap-3">
45
+ <div class="min-w-0">
46
+ <component
47
+ :is="props.href ? 'NuxtLink' : 'h2'"
48
+ :to="props.href"
49
+ class="text-sm font-semibold text-highlighted"
50
+ :class="props.href ? 'group inline-flex items-center gap-1 hover:text-primary' : 'block'"
51
+ >
52
+ {{ props.title }}
53
+ <UIcon
54
+ v-if="props.href"
55
+ name="i-lucide-arrow-right"
56
+ class="size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
57
+ />
58
+ </component>
59
+ <p v-if="props.subtitle" class="mt-0.5 text-xs text-muted">
60
+ {{ props.subtitle }}
61
+ </p>
62
+ </div>
63
+
64
+ <UButton
65
+ v-if="props.tableAvailable"
66
+ :icon="toggleIcon"
67
+ :aria-label="toggleLabel"
68
+ :title="toggleLabel"
69
+ color="neutral"
70
+ variant="ghost"
71
+ size="xs"
72
+ @click="showTable = !showTable"
73
+ />
74
+ </div>
75
+
76
+ <div :class="props.stale ? 'opacity-60 transition-opacity' : 'transition-opacity'">
77
+ <div v-show="!showTable">
78
+ <slot />
79
+ </div>
80
+ <div v-if="props.tableAvailable" v-show="showTable" class="overflow-x-auto">
81
+ <slot name="table" />
82
+ </div>
83
+ </div>
84
+
85
+ <slot name="footer" />
86
+ </UCard>
87
+ </template>
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Ein Verhältnis gegen eine bekannte Grenze — „so viele von so vielen".
4
+ *
5
+ * Ein Meter setzt voraus, dass es ein Ganzes gibt. Wo keins existiert (Bytes
6
+ * ohne Kontingent, Anzahl ohne Obergrenze), gehört die Zahl in eine Kachel und
7
+ * nicht hierher; ein voller Balken behauptete sonst eine erreichte Grenze.
8
+ */
9
+ import { computed } from 'vue'
10
+ import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
11
+
12
+ const props = withDefaults(defineProps<{
13
+ value: number
14
+ total: number
15
+ label?: string
16
+ hint?: string
17
+ }>(), {
18
+ label: undefined,
19
+ hint: undefined,
20
+ })
21
+
22
+ const { number, percent } = useChartFormat()
23
+
24
+ const ratio = computed(() => (props.total <= 0 ? 0 : Math.min(1, props.value / props.total)))
25
+ const color = baseKitChartColor(0)
26
+ </script>
27
+
28
+ <template>
29
+ <div class="space-y-2">
30
+ <div class="flex items-baseline justify-between gap-3">
31
+ <p class="text-2xl font-semibold text-highlighted">
32
+ {{ number(props.value) }}<span class="text-base font-normal text-muted"> / {{ number(props.total) }}</span>
33
+ </p>
34
+ <p class="text-sm tabular-nums text-muted">
35
+ {{ percent(props.value, props.total) }}
36
+ </p>
37
+ </div>
38
+
39
+ <div
40
+ class="h-2.5 w-full overflow-hidden rounded-full bg-elevated"
41
+ role="meter"
42
+ :aria-valuenow="props.value"
43
+ :aria-valuemin="0"
44
+ :aria-valuemax="props.total"
45
+ :aria-label="props.label"
46
+ >
47
+ <div
48
+ class="h-full rounded-full"
49
+ :style="{ width: `${ratio * 100}%`, backgroundColor: color }"
50
+ />
51
+ </div>
52
+
53
+ <p v-if="props.hint" class="text-xs text-dimmed">
54
+ {{ props.hint }}
55
+ </p>
56
+ </div>
57
+ </template>