@cosmicdrift/kumiko-renderer-web 0.137.0 → 0.139.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.137.0",
3
+ "version": "0.139.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.137.0",
20
- "@cosmicdrift/kumiko-headless": "0.137.0",
21
- "@cosmicdrift/kumiko-renderer": "0.137.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.139.0",
20
+ "@cosmicdrift/kumiko-headless": "0.139.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.139.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
package/src/index.ts CHANGED
@@ -155,24 +155,36 @@ export {
155
155
  } from "./tokens";
156
156
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
157
157
  export type {
158
+ BooleanFieldProps,
159
+ ComparisonMetric,
160
+ DateFieldProps,
158
161
  FeedRow,
162
+ FileFieldProps,
159
163
  NumberFieldProps,
160
164
  ProgressListRow,
161
165
  QueryTableColumn,
162
166
  QueryTableProps,
167
+ RangeFieldProps,
163
168
  ResultColumn,
169
+ SelectFieldProps,
164
170
  StatDelta,
165
171
  StatTone,
166
172
  StatusBarEntry,
167
173
  StatusTone,
174
+ TextareaFieldProps,
175
+ TextFieldProps,
168
176
  TimeseriesPoint,
169
177
  } from "./widgets";
170
178
  export {
179
+ BooleanField,
171
180
  CollapsibleSection,
181
+ ComparisonTable,
182
+ DateField,
172
183
  DetailList,
173
184
  EmptyState,
174
185
  ErrorState,
175
186
  FeedList,
187
+ FileField,
176
188
  LoadingState,
177
189
  MiniStat,
178
190
  ModeSwitch,
@@ -182,15 +194,19 @@ export {
182
194
  ProgressBar,
183
195
  ProgressList,
184
196
  QueryTable,
197
+ RangeField,
185
198
  ResultPanel,
186
199
  ResultTable,
187
200
  SectionCard,
201
+ SelectField,
188
202
  Sparkline,
189
203
  STATUS_TONE_TEXT,
190
204
  StatCard,
191
205
  StatusBadge,
192
206
  StatusBarChart,
193
207
  smoothPath,
208
+ TextareaField,
209
+ TextField,
194
210
  TimeseriesChart,
195
211
  useDraft,
196
212
  } from "./widgets";
@@ -111,12 +111,19 @@ const BUTTON_VARIANT = {
111
111
  link: "link",
112
112
  } as const;
113
113
 
114
+ const BUTTON_SIZE = {
115
+ sm: "sm",
116
+ md: "default",
117
+ icon: "icon",
118
+ } as const;
119
+
114
120
  function DefaultButton({
115
121
  type = "button",
116
122
  onClick,
117
123
  disabled,
118
124
  loading,
119
125
  variant = "primary",
126
+ size = "md",
120
127
  children,
121
128
  testId,
122
129
  }: ButtonProps): ReactNode {
@@ -128,6 +135,7 @@ function DefaultButton({
128
135
  data-testid={testId}
129
136
  data-loading={loading === true ? "true" : undefined}
130
137
  variant={BUTTON_VARIANT[variant]}
138
+ size={BUTTON_SIZE[size]}
131
139
  // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner),
132
140
  // nicht als gepolsterte Buttonfläche.
133
141
  className={variant === "link" ? "h-auto px-0 py-0" : undefined}
@@ -292,6 +300,19 @@ function DefaultInput(props: InputProps): ReactNode {
292
300
  className="text-right tabular-nums"
293
301
  />
294
302
  );
303
+ case "range":
304
+ return (
305
+ <input
306
+ type="range"
307
+ {...common}
308
+ min={props.min}
309
+ max={props.max}
310
+ step={props.step ?? 1}
311
+ value={props.value}
312
+ onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(Number(e.target.value))}
313
+ className="w-full accent-primary"
314
+ />
315
+ );
295
316
  case "boolean":
296
317
  return (
297
318
  <Checkbox
@@ -1,8 +1,21 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
2
4
  import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
3
5
  import { DetailList } from "../detail-list";
4
- import { MoneyField, NumberField, PercentField } from "../form-fields";
5
- import { ResultPanel, ResultTable } from "../result-panel";
6
+ import {
7
+ BooleanField,
8
+ DateField,
9
+ FileField,
10
+ MoneyField,
11
+ NumberField,
12
+ PercentField,
13
+ RangeField,
14
+ SelectField,
15
+ TextareaField,
16
+ TextField,
17
+ } from "../form-fields";
18
+ import { ComparisonTable, ResultPanel, ResultTable } from "../result-panel";
6
19
  import { useDraft } from "../use-draft";
7
20
 
8
21
  describe("useDraft", () => {
@@ -103,3 +116,150 @@ describe("ResultTable", () => {
103
116
  expect(screen.getByText("300.000 €").className).toContain("tabular-nums");
104
117
  });
105
118
  });
119
+
120
+ describe("Feld-Widgets (Select/Date/Text/Boolean/Textarea)", () => {
121
+ const noopStr = (): void => {};
122
+
123
+ test("SelectField rendert Label und den aktuell gewählten Wert", () => {
124
+ render(
125
+ <SelectField
126
+ label="Land"
127
+ id="l"
128
+ name="l"
129
+ value="NW"
130
+ onChange={noopStr}
131
+ options={[
132
+ { value: "NW", label: "Nordrhein-Westfalen" },
133
+ { value: "BY", label: "Bayern" },
134
+ ]}
135
+ />,
136
+ );
137
+ expect(screen.getByText("Land")).toBeTruthy();
138
+ expect(screen.getByText("Nordrhein-Westfalen")).toBeTruthy();
139
+ });
140
+
141
+ test("TextField meldet Eingabe", () => {
142
+ const onChange = mock();
143
+ render(<TextField label="Name" id="n" name="n" value="A" onChange={onChange} />);
144
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "Neu" } });
145
+ expect(onChange).toHaveBeenCalledWith("Neu");
146
+ });
147
+
148
+ test("DateField rendert mit Wert", () => {
149
+ render(
150
+ <DateField
151
+ label="Datum"
152
+ id="d"
153
+ name="d"
154
+ value="2026-07-10"
155
+ onChange={noopStr}
156
+ max="2030-01-01"
157
+ />,
158
+ );
159
+ expect(screen.getByText("Datum")).toBeTruthy();
160
+ });
161
+
162
+ test("BooleanField meldet Umschalten", () => {
163
+ const onChange = mock();
164
+ render(<BooleanField label="Aktiv" id="b" name="b" value={false} onChange={onChange} />);
165
+ fireEvent.click(screen.getByRole("checkbox"));
166
+ expect(onChange).toHaveBeenCalledWith(true);
167
+ });
168
+
169
+ test("TextareaField meldet Eingabe", () => {
170
+ const onChange = mock();
171
+ render(<TextareaField label="Notiz" id="t" name="t" value="" onChange={onChange} rows={3} />);
172
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "Text" } });
173
+ expect(onChange).toHaveBeenCalledWith("Text");
174
+ });
175
+ });
176
+
177
+ describe("ResultPanel footer", () => {
178
+ test("rendert den footer-Slot", () => {
179
+ render(
180
+ <ResultPanel
181
+ title="R"
182
+ rows={[{ label: "X", value: "1" }]}
183
+ footer={<button type="button">Los</button>}
184
+ >
185
+ <span>body</span>
186
+ </ResultPanel>,
187
+ );
188
+ expect(screen.getByRole("button", { name: "Los" })).toBeTruthy();
189
+ });
190
+ });
191
+
192
+ describe("RangeField", () => {
193
+ test("meldet den Slider-Wert als Zahl", () => {
194
+ const onChange = mock();
195
+ render(
196
+ <RangeField
197
+ label="Abruf"
198
+ id="r"
199
+ name="r"
200
+ value={20}
201
+ min={0}
202
+ max={100}
203
+ step={5}
204
+ onChange={onChange}
205
+ />,
206
+ );
207
+ expect(screen.getByText("Abruf")).toBeTruthy();
208
+ fireEvent.change(screen.getByRole("slider"), { target: { value: "45" } });
209
+ expect(onChange).toHaveBeenCalledWith(45);
210
+ });
211
+ });
212
+
213
+ describe("FileField", () => {
214
+ test("rendert Label", () => {
215
+ render(
216
+ <FileField label="Bild" id="f" name="f" value={null} onChange={() => {}} variant="image" />,
217
+ );
218
+ expect(screen.getByText("Bild")).toBeTruthy();
219
+ });
220
+ });
221
+
222
+ describe("Button size", () => {
223
+ function SizedButton({ size }: { readonly size: "sm" | "md" | "icon" }): ReactNode {
224
+ const { Button } = usePrimitives();
225
+ return (
226
+ <Button size={size} onClick={() => {}}>
227
+ X
228
+ </Button>
229
+ );
230
+ }
231
+ test("size=sm rendert die kompakte shadcn-Größe (h-8)", () => {
232
+ render(<SizedButton size="sm" />);
233
+ expect(screen.getByRole("button", { name: "X" }).className).toContain("h-8");
234
+ });
235
+ });
236
+
237
+ describe("ComparisonTable", () => {
238
+ test("transponierte Matrix mit Best-Highlight je Kennzahl", () => {
239
+ const cols = [
240
+ { name: "A", rate: 900 },
241
+ { name: "B", rate: 850 },
242
+ ];
243
+ render(
244
+ <ComparisonTable
245
+ testId="cmp"
246
+ columns={cols}
247
+ columnHeader={(c) => c.name}
248
+ columnKey={(c) => c.name}
249
+ metricLabel="Kennzahl"
250
+ metrics={[
251
+ {
252
+ label: "Rate",
253
+ value: (c: { name: string; rate: number }) => `${c.rate} €`,
254
+ bestIndex: (cs) => ((cs[0]?.rate ?? 0) <= (cs[1]?.rate ?? 0) ? 0 : 1),
255
+ },
256
+ ]}
257
+ />,
258
+ );
259
+ expect(screen.getByText("Kennzahl")).toBeTruthy();
260
+ expect(screen.getByText("A")).toBeTruthy();
261
+ // B (850) ist günstiger → hervorgehoben (font-semibold text-primary span)
262
+ expect(screen.getByText("850 €").className).toContain("text-primary");
263
+ expect(screen.getByText("900 €").className).not.toContain("text-primary");
264
+ });
265
+ });
@@ -52,3 +52,272 @@ export function MoneyField(props: NumberFieldProps): ReactNode {
52
52
  export function PercentField(props: NumberFieldProps): ReactNode {
53
53
  return <NumberField {...props} />;
54
54
  }
55
+
56
+ interface FieldBase {
57
+ readonly label: string;
58
+ readonly id: string;
59
+ readonly name: string;
60
+ readonly required?: boolean;
61
+ readonly disabled?: boolean;
62
+ readonly testId?: string;
63
+ }
64
+
65
+ export interface TextFieldProps extends FieldBase {
66
+ readonly value: string;
67
+ readonly onChange: (v: string) => void;
68
+ readonly placeholder?: string;
69
+ readonly autoComplete?: string;
70
+ }
71
+
72
+ /** Textfeld = Field + Input(kind:"text"). */
73
+ export function TextField({
74
+ label,
75
+ id,
76
+ name,
77
+ value,
78
+ onChange,
79
+ required,
80
+ disabled,
81
+ placeholder,
82
+ autoComplete,
83
+ testId,
84
+ }: TextFieldProps): ReactNode {
85
+ const { Field, Input } = usePrimitives();
86
+ return (
87
+ <Field id={id} label={label} required={required} testId={testId}>
88
+ <Input
89
+ kind="text"
90
+ id={id}
91
+ name={name}
92
+ value={value}
93
+ onChange={onChange}
94
+ required={required}
95
+ disabled={disabled}
96
+ {...(placeholder !== undefined && { placeholder })}
97
+ {...(autoComplete !== undefined && { autoComplete })}
98
+ />
99
+ </Field>
100
+ );
101
+ }
102
+
103
+ export interface SelectFieldProps extends FieldBase {
104
+ readonly value: string;
105
+ readonly onChange: (v: string) => void;
106
+ readonly options:
107
+ | readonly string[]
108
+ | readonly { readonly value: string; readonly label: string }[];
109
+ }
110
+
111
+ /** Auswahlfeld = Field + Input(kind:"select"). */
112
+ export function SelectField({
113
+ label,
114
+ id,
115
+ name,
116
+ value,
117
+ onChange,
118
+ options,
119
+ required,
120
+ disabled,
121
+ testId,
122
+ }: SelectFieldProps): ReactNode {
123
+ const { Field, Input } = usePrimitives();
124
+ return (
125
+ <Field id={id} label={label} required={required} testId={testId}>
126
+ <Input
127
+ kind="select"
128
+ id={id}
129
+ name={name}
130
+ value={value}
131
+ onChange={onChange}
132
+ options={options}
133
+ required={required}
134
+ disabled={disabled}
135
+ />
136
+ </Field>
137
+ );
138
+ }
139
+
140
+ export interface DateFieldProps extends FieldBase {
141
+ readonly value: string;
142
+ readonly onChange: (v: string | undefined) => void;
143
+ readonly min?: string;
144
+ readonly max?: string;
145
+ }
146
+
147
+ /** Datumsfeld = Field + Input(kind:"date"), ISO yyyy-mm-dd. */
148
+ export function DateField({
149
+ label,
150
+ id,
151
+ name,
152
+ value,
153
+ onChange,
154
+ min,
155
+ max,
156
+ required,
157
+ disabled,
158
+ testId,
159
+ }: DateFieldProps): ReactNode {
160
+ const { Field, Input } = usePrimitives();
161
+ return (
162
+ <Field id={id} label={label} required={required} testId={testId}>
163
+ <Input
164
+ kind="date"
165
+ id={id}
166
+ name={name}
167
+ value={value}
168
+ onChange={onChange}
169
+ required={required}
170
+ disabled={disabled}
171
+ {...(min !== undefined && { min })}
172
+ {...(max !== undefined && { max })}
173
+ />
174
+ </Field>
175
+ );
176
+ }
177
+
178
+ export interface BooleanFieldProps extends FieldBase {
179
+ readonly value: boolean;
180
+ readonly onChange: (v: boolean) => void;
181
+ }
182
+
183
+ /** Checkbox/Switch = Field(layout:"inline") + Input(kind:"boolean"). */
184
+ export function BooleanField({
185
+ label,
186
+ id,
187
+ name,
188
+ value,
189
+ onChange,
190
+ required,
191
+ disabled,
192
+ testId,
193
+ }: BooleanFieldProps): ReactNode {
194
+ const { Field, Input } = usePrimitives();
195
+ return (
196
+ <Field id={id} label={label} required={required} layout="inline" testId={testId}>
197
+ <Input
198
+ kind="boolean"
199
+ id={id}
200
+ name={name}
201
+ value={value}
202
+ onChange={onChange}
203
+ required={required}
204
+ disabled={disabled}
205
+ />
206
+ </Field>
207
+ );
208
+ }
209
+
210
+ export interface TextareaFieldProps extends FieldBase {
211
+ readonly value: string;
212
+ readonly onChange: (v: string) => void;
213
+ readonly rows?: number;
214
+ }
215
+
216
+ /** Mehrzeiliges Textfeld = Field + Input(kind:"textarea"). */
217
+ export function TextareaField({
218
+ label,
219
+ id,
220
+ name,
221
+ value,
222
+ onChange,
223
+ rows,
224
+ required,
225
+ disabled,
226
+ testId,
227
+ }: TextareaFieldProps): ReactNode {
228
+ const { Field, Input } = usePrimitives();
229
+ return (
230
+ <Field id={id} label={label} required={required} testId={testId}>
231
+ <Input
232
+ kind="textarea"
233
+ id={id}
234
+ name={name}
235
+ value={value}
236
+ onChange={onChange}
237
+ required={required}
238
+ disabled={disabled}
239
+ {...(rows !== undefined && { rows })}
240
+ />
241
+ </Field>
242
+ );
243
+ }
244
+
245
+ export interface RangeFieldProps extends FieldBase {
246
+ readonly value: number;
247
+ readonly onChange: (v: number) => void;
248
+ readonly min: number;
249
+ readonly max: number;
250
+ readonly step?: number;
251
+ }
252
+
253
+ /** Schieberegler = Field + Input(kind:"range"). */
254
+ export function RangeField({
255
+ label,
256
+ id,
257
+ name,
258
+ value,
259
+ onChange,
260
+ min,
261
+ max,
262
+ step,
263
+ required,
264
+ disabled,
265
+ testId,
266
+ }: RangeFieldProps): ReactNode {
267
+ const { Field, Input } = usePrimitives();
268
+ return (
269
+ <Field id={id} label={label} required={required} testId={testId}>
270
+ <Input
271
+ kind="range"
272
+ id={id}
273
+ name={name}
274
+ value={value}
275
+ onChange={onChange}
276
+ min={min}
277
+ max={max}
278
+ required={required}
279
+ disabled={disabled}
280
+ {...(step !== undefined && { step })}
281
+ />
282
+ </Field>
283
+ );
284
+ }
285
+
286
+ export interface FileFieldProps extends FieldBase {
287
+ /** FileRef-UUID der gespeicherten Datei, oder null. */
288
+ readonly value: string | null;
289
+ readonly onChange: (fileId: string | null) => void;
290
+ readonly accept?: readonly string[];
291
+ /** "image" zeigt eine Vorschau, "file" nur den Dateinamen. Default "file". */
292
+ readonly variant?: "file" | "image";
293
+ }
294
+
295
+ /** Datei-Upload = Field + Input(kind:"file"|"image") — FileRef-basiert. */
296
+ export function FileField({
297
+ label,
298
+ id,
299
+ name,
300
+ value,
301
+ onChange,
302
+ accept,
303
+ variant = "file",
304
+ required,
305
+ disabled,
306
+ testId,
307
+ }: FileFieldProps): ReactNode {
308
+ const { Field, Input } = usePrimitives();
309
+ return (
310
+ <Field id={id} label={label} required={required} testId={testId}>
311
+ <Input
312
+ kind={variant}
313
+ id={id}
314
+ name={name}
315
+ value={value}
316
+ onChange={onChange}
317
+ required={required}
318
+ disabled={disabled}
319
+ {...(accept !== undefined && { accept })}
320
+ />
321
+ </Field>
322
+ );
323
+ }
@@ -13,12 +13,37 @@ export {
13
13
  export { CollapsibleSection } from "./collapsible-section";
14
14
  export { DetailList } from "./detail-list";
15
15
  export { FeedList, type FeedRow } from "./feed-list";
16
- export { MoneyField, NumberField, type NumberFieldProps, PercentField } from "./form-fields";
16
+ export {
17
+ BooleanField,
18
+ type BooleanFieldProps,
19
+ DateField,
20
+ type DateFieldProps,
21
+ FileField,
22
+ type FileFieldProps,
23
+ MoneyField,
24
+ NumberField,
25
+ type NumberFieldProps,
26
+ PercentField,
27
+ RangeField,
28
+ type RangeFieldProps,
29
+ SelectField,
30
+ type SelectFieldProps,
31
+ TextareaField,
32
+ type TextareaFieldProps,
33
+ TextField,
34
+ type TextFieldProps,
35
+ } from "./form-fields";
17
36
  export { ModeSwitch } from "./mode-switch";
18
37
  export { ProgressBar } from "./progress-bar";
19
38
  export { ProgressList, type ProgressListRow } from "./progress-list";
20
39
  export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
21
- export { type ResultColumn, ResultPanel, ResultTable } from "./result-panel";
40
+ export {
41
+ type ComparisonMetric,
42
+ ComparisonTable,
43
+ type ResultColumn,
44
+ ResultPanel,
45
+ ResultTable,
46
+ } from "./result-panel";
22
47
  export { SectionCard } from "./section-card";
23
48
  export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
24
49
  export { EmptyState, ErrorState, LoadingState } from "./states";
@@ -13,6 +13,7 @@ export function ResultPanel({
13
13
  empty,
14
14
  emptyText,
15
15
  rows,
16
+ footer,
16
17
  children,
17
18
  testId,
18
19
  }: {
@@ -25,12 +26,14 @@ export function ResultPanel({
25
26
  readonly value: ReactNode;
26
27
  readonly emphasize?: boolean;
27
28
  }[];
29
+ /** Action-Slot am Karten-Fuß (z.B. „In Finanzierung übernehmen"). */
30
+ readonly footer?: ReactNode;
28
31
  readonly children?: ReactNode;
29
32
  readonly testId?: string;
30
33
  }): ReactNode {
31
34
  const { Banner } = usePrimitives();
32
35
  return (
33
- <SectionCard title={title} subtitle={subtitle} testId={testId}>
36
+ <SectionCard title={title} subtitle={subtitle} footer={footer} testId={testId}>
34
37
  {empty === true ? (
35
38
  <Banner variant="info" padded>
36
39
  {emptyText}
@@ -99,3 +102,67 @@ export function ResultTable<Row>({
99
102
  </div>
100
103
  );
101
104
  }
105
+
106
+ export interface ComparisonMetric<Col> {
107
+ readonly label: string;
108
+ readonly value: (col: Col, index: number) => ReactNode;
109
+ /** Index der besten Spalte für diese Zeile (hervorgehoben); -1 = keine. */
110
+ readonly bestIndex?: (cols: readonly Col[]) => number;
111
+ }
112
+
113
+ /** Transponierte Vergleichstabelle: Zeile = Kennzahl, Spalte = Variante, je
114
+ * Kennzahl optional die beste Spalte hervorgehoben. Für Szenario-/Angebots-
115
+ * Vergleiche, wo ResultTable (Zeile=Datensatz) nicht passt. */
116
+ export function ComparisonTable<Col>({
117
+ columns,
118
+ columnHeader,
119
+ columnKey,
120
+ metrics,
121
+ metricLabel,
122
+ testId,
123
+ }: {
124
+ readonly columns: readonly Col[];
125
+ readonly columnHeader: (col: Col, index: number) => string;
126
+ readonly columnKey: (col: Col, index: number) => string;
127
+ readonly metrics: readonly ComparisonMetric<Col>[];
128
+ readonly metricLabel: string;
129
+ readonly testId?: string;
130
+ }): ReactNode {
131
+ return (
132
+ <div className="overflow-x-auto">
133
+ <table data-testid={testId} className="w-full min-w-[24rem] text-sm">
134
+ <thead>
135
+ <tr className="border-b text-left text-muted-foreground">
136
+ <th className="py-1.5 font-medium">{metricLabel}</th>
137
+ {columns.map((col, i) => (
138
+ <th key={columnKey(col, i)} className="py-1.5 text-right font-medium">
139
+ {columnHeader(col, i)}
140
+ </th>
141
+ ))}
142
+ </tr>
143
+ </thead>
144
+ <tbody>
145
+ {metrics.map((metric) => {
146
+ const best = metric.bestIndex !== undefined ? metric.bestIndex(columns) : -1;
147
+ return (
148
+ <tr key={metric.label} className="border-b last:border-0">
149
+ <td className="py-1.5 text-muted-foreground">{metric.label}</td>
150
+ {columns.map((col, i) => (
151
+ <td key={columnKey(col, i)} className="py-1.5 text-right tabular-nums">
152
+ {i === best ? (
153
+ <span className="inline-block rounded bg-primary/10 px-2 py-0.5 font-semibold text-primary">
154
+ {metric.value(col, i)}
155
+ </span>
156
+ ) : (
157
+ metric.value(col, i)
158
+ )}
159
+ </td>
160
+ ))}
161
+ </tr>
162
+ );
163
+ })}
164
+ </tbody>
165
+ </table>
166
+ </div>
167
+ );
168
+ }