@cosmicdrift/kumiko-renderer-web 0.232.0 → 0.234.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 +5 -5
- package/src/__tests__/app-layout.test.tsx +13 -12
- package/src/__tests__/default-app-shell.test.tsx +33 -0
- package/src/__tests__/form-action-bar.test.tsx +53 -4
- package/src/__tests__/kumiko-screen.test.tsx +293 -3
- package/src/__tests__/primitives.test.tsx +245 -15
- package/src/__tests__/projection-detail-record-layout.test.tsx +91 -0
- package/src/__tests__/render-edit.test.tsx +29 -10
- package/src/__tests__/render-list-narrow-cards.test.tsx +187 -0
- package/src/__tests__/workspace-shell.test.tsx +5 -4
- package/src/icons.tsx +199 -0
- package/src/layout/app-layout.tsx +7 -7
- package/src/layout/default-app-shell.tsx +3 -2
- package/src/layout/nav-tree.tsx +25 -122
- package/src/layout/workspace-shell.tsx +3 -2
- package/src/primitives/__tests__/button.test.tsx +37 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +33 -0
- package/src/primitives/__tests__/select-segmented.test.tsx +84 -0
- package/src/primitives/index.tsx +475 -50
- package/src/primitives/metric.tsx +18 -10
- package/src/styles.css +21 -17
- package/src/ui/switch.tsx +35 -0
package/src/primitives/index.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// basierte Stile. Radix-UI-Unterbau für interaktive Elemente (Modal,
|
|
9
9
|
// Dropdown etc. kommen später).
|
|
10
10
|
|
|
11
|
-
import type { FieldIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
|
+
import type { FieldIconKey, IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
12
12
|
import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
|
|
13
13
|
import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
14
14
|
import type {
|
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
type ProgressProps,
|
|
36
36
|
type SectionProps,
|
|
37
37
|
type StepBarProps,
|
|
38
|
+
shouldRenderActionsIconOnly,
|
|
39
|
+
statusToneForValue,
|
|
38
40
|
type TextProps,
|
|
39
41
|
useColumnRenderer,
|
|
40
42
|
useOptionalLocale,
|
|
@@ -73,6 +75,7 @@ import {
|
|
|
73
75
|
Children,
|
|
74
76
|
type CSSProperties,
|
|
75
77
|
createContext,
|
|
78
|
+
type KeyboardEvent,
|
|
76
79
|
type MouseEvent,
|
|
77
80
|
type ReactNode,
|
|
78
81
|
useContext,
|
|
@@ -80,15 +83,18 @@ import {
|
|
|
80
83
|
useRef,
|
|
81
84
|
useState,
|
|
82
85
|
} from "react";
|
|
86
|
+
import { Icon, NAV_ICONS } from "../icons";
|
|
83
87
|
import { cn } from "../lib/cn";
|
|
84
88
|
import { Badge } from "../ui/badge";
|
|
85
89
|
import { buttonVariants, Button as UiButton } from "../ui/button";
|
|
86
90
|
import { Checkbox } from "../ui/checkbox";
|
|
87
91
|
import { Input as UiInput } from "../ui/input";
|
|
88
92
|
import { Label as UiLabel } from "../ui/label";
|
|
93
|
+
import { Switch } from "../ui/switch";
|
|
89
94
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
90
95
|
import { Textarea } from "../ui/textarea";
|
|
91
96
|
import { ProgressBar } from "../widgets/progress-bar";
|
|
97
|
+
import { StatusBadge } from "../widgets/status-badge";
|
|
92
98
|
import { StepBar } from "../widgets/step-bar";
|
|
93
99
|
import { ComboboxInput } from "./combobox";
|
|
94
100
|
import { DateInput } from "./date-input";
|
|
@@ -113,6 +119,7 @@ import { DefaultTabs } from "./tabs";
|
|
|
113
119
|
import { TimestampInput } from "./timestamp-input";
|
|
114
120
|
import { useToast } from "./toast";
|
|
115
121
|
import { TzInput } from "./tz-input";
|
|
122
|
+
import { useIsNarrowViewport } from "./use-narrow-viewport";
|
|
116
123
|
|
|
117
124
|
// ---- Card-Chrome (eine Definition für Form/Section/Card) ----
|
|
118
125
|
|
|
@@ -129,7 +136,10 @@ const cardSurface = cva(
|
|
|
129
136
|
);
|
|
130
137
|
// Wraps instead of running off-screen when the row outgrows its container (fw#2528).
|
|
131
138
|
const cardFooter = "flex flex-wrap items-center justify-end gap-2 px-[var(--card-padding)] py-4";
|
|
132
|
-
|
|
139
|
+
// /30 read as nearly invisible against the light-theme card (white card,
|
|
140
|
+
// muted at 94% lightness) — /50 keeps the same token, just a stronger step.
|
|
141
|
+
const cardFooterBorder = "border-t bg-muted/50";
|
|
142
|
+
const cardHeaderBorder = "border-b bg-muted/50";
|
|
133
143
|
|
|
134
144
|
// ---- Button (vendored shadcn ui/button) ----
|
|
135
145
|
|
|
@@ -141,6 +151,7 @@ const BUTTON_VARIANT = {
|
|
|
141
151
|
secondary: "outline",
|
|
142
152
|
danger: "destructive",
|
|
143
153
|
link: "link",
|
|
154
|
+
"danger-ghost": "ghost",
|
|
144
155
|
} as const;
|
|
145
156
|
|
|
146
157
|
const BUTTON_SIZE = {
|
|
@@ -149,6 +160,14 @@ const BUTTON_SIZE = {
|
|
|
149
160
|
icon: "icon",
|
|
150
161
|
} as const;
|
|
151
162
|
|
|
163
|
+
// Closed IconKey vocabulary, checked against the shared NAV_ICONS registry
|
|
164
|
+
// (icons.tsx) — an unknown key (schema data isn't statically typed against
|
|
165
|
+
// IconKey the way this prop is) renders no icon instead of crashing, same
|
|
166
|
+
// fallback shape as fieldIconFor above.
|
|
167
|
+
function actionIconFor(icon: IconKey | undefined): IconKey | undefined {
|
|
168
|
+
return icon !== undefined && Object.hasOwn(NAV_ICONS, icon) ? icon : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
function DefaultButton({
|
|
153
172
|
type = "button",
|
|
154
173
|
onClick,
|
|
@@ -162,14 +181,29 @@ function DefaultButton({
|
|
|
162
181
|
testId,
|
|
163
182
|
className,
|
|
164
183
|
ref,
|
|
184
|
+
icon,
|
|
185
|
+
iconEnd,
|
|
165
186
|
}: ButtonProps): ReactNode {
|
|
166
187
|
// link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
|
|
167
188
|
// gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
|
|
168
189
|
const resolvedClassName = cn(
|
|
169
190
|
variant === "link" ? "h-auto px-0 py-0" : "",
|
|
191
|
+
variant === "danger-ghost"
|
|
192
|
+
? "text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
193
|
+
: "",
|
|
170
194
|
width === "full" ? "w-full" : "",
|
|
171
195
|
className,
|
|
172
196
|
);
|
|
197
|
+
const IconStart = icon !== undefined ? NAV_ICONS[icon] : undefined;
|
|
198
|
+
const IconEnd = iconEnd !== undefined ? NAV_ICONS[iconEnd] : undefined;
|
|
199
|
+
// Loading swaps the leading icon slot for a spinner and keeps `children` —
|
|
200
|
+
// replacing the label would shift the button's width mid-submit.
|
|
201
|
+
const leading =
|
|
202
|
+
loading === true ? (
|
|
203
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
204
|
+
) : IconStart !== undefined ? (
|
|
205
|
+
<IconStart className="size-4" aria-hidden="true" />
|
|
206
|
+
) : null;
|
|
173
207
|
return (
|
|
174
208
|
<UiButton
|
|
175
209
|
ref={ref}
|
|
@@ -183,7 +217,9 @@ function DefaultButton({
|
|
|
183
217
|
aria-label={ariaLabel}
|
|
184
218
|
className={resolvedClassName}
|
|
185
219
|
>
|
|
186
|
-
{
|
|
220
|
+
{leading}
|
|
221
|
+
{children}
|
|
222
|
+
{IconEnd !== undefined && <IconEnd className="size-4" aria-hidden="true" />}
|
|
187
223
|
</UiButton>
|
|
188
224
|
);
|
|
189
225
|
}
|
|
@@ -236,6 +272,13 @@ function fieldLabelId(id: string): string {
|
|
|
236
272
|
return `${id}-label`;
|
|
237
273
|
}
|
|
238
274
|
|
|
275
|
+
// Boolean optics depend on the surrounding Field layout: layout="inline"
|
|
276
|
+
// (checkbox lists like MultiSelectCheckboxes, the BooleanField widget) keeps
|
|
277
|
+
// the checkbox, the standard form field (layout="stacked"/default) gets the
|
|
278
|
+
// new switch. Pure web-renderer rendering decision, no contract field needed
|
|
279
|
+
// — Field hands its resolved layout down to the nested Input via context.
|
|
280
|
+
const FieldLayoutContext = createContext<FieldProps["layout"]>("stacked");
|
|
281
|
+
|
|
239
282
|
function DefaultField({
|
|
240
283
|
id,
|
|
241
284
|
label,
|
|
@@ -284,7 +327,7 @@ function DefaultField({
|
|
|
284
327
|
return (
|
|
285
328
|
<div data-testid={testId} className="flex flex-col gap-1.5">
|
|
286
329
|
<div className="flex items-center gap-2">
|
|
287
|
-
{children}
|
|
330
|
+
<FieldLayoutContext.Provider value="inline">{children}</FieldLayoutContext.Provider>
|
|
288
331
|
{labelEl}
|
|
289
332
|
{labelAppendix !== undefined && labelAppendix}
|
|
290
333
|
</div>
|
|
@@ -359,9 +402,134 @@ function withFieldIcon(icon: string | undefined, input: ReactNode): ReactNode {
|
|
|
359
402
|
);
|
|
360
403
|
}
|
|
361
404
|
|
|
405
|
+
// Mirrors withFieldIcon on the right side: a muted, non-interactive unit
|
|
406
|
+
// suffix rendered inside the input's visual box. Pure decoration — never
|
|
407
|
+
// focusable, never touches the input's value.
|
|
408
|
+
function withUnitSuffix(unit: string | undefined, input: ReactNode): ReactNode {
|
|
409
|
+
if (unit === undefined) return input;
|
|
410
|
+
return (
|
|
411
|
+
<div className="relative">
|
|
412
|
+
{input}
|
|
413
|
+
<span
|
|
414
|
+
aria-hidden="true"
|
|
415
|
+
className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"
|
|
416
|
+
>
|
|
417
|
+
{unit}
|
|
418
|
+
</span>
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Segmented control for `kind: "select"` with a small closed option set —
|
|
424
|
+
// a 4-value Status field looked wrong stretched into a full-width dropdown
|
|
425
|
+
// (edit-existing screenshot feedback). Purely a rendering choice inside the
|
|
426
|
+
// "select" branch below; the primitives contract is untouched (still
|
|
427
|
+
// `options` + string value/onChange).
|
|
428
|
+
const SEGMENTED_SELECT_MAX_OPTIONS = 4;
|
|
429
|
+
const SEGMENTED_SELECT_MAX_LABEL_LENGTH = 14;
|
|
430
|
+
|
|
431
|
+
function isSegmentedSelectEligible(options: readonly { readonly label: string }[]): boolean {
|
|
432
|
+
return (
|
|
433
|
+
options.length > 0 &&
|
|
434
|
+
options.length <= SEGMENTED_SELECT_MAX_OPTIONS &&
|
|
435
|
+
options.every((o) => o.label.length <= SEGMENTED_SELECT_MAX_LABEL_LENGTH)
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// WAI-ARIA radiogroup pattern (role="radiogroup" + role="radio" children):
|
|
440
|
+
// arrow keys move focus AND selection in the same step, only the checked
|
|
441
|
+
// segment (or the first when none is checked) sits in the tab order.
|
|
442
|
+
function SegmentedSelect({
|
|
443
|
+
id,
|
|
444
|
+
name,
|
|
445
|
+
value,
|
|
446
|
+
onChange,
|
|
447
|
+
options,
|
|
448
|
+
disabled,
|
|
449
|
+
required,
|
|
450
|
+
hasError,
|
|
451
|
+
}: {
|
|
452
|
+
readonly id: string;
|
|
453
|
+
readonly name: string;
|
|
454
|
+
readonly value: string;
|
|
455
|
+
readonly onChange: (v: string) => void;
|
|
456
|
+
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
457
|
+
readonly disabled?: boolean;
|
|
458
|
+
readonly required?: boolean;
|
|
459
|
+
readonly hasError?: boolean;
|
|
460
|
+
}): ReactNode {
|
|
461
|
+
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
462
|
+
|
|
463
|
+
const selectAt = (index: number): void => {
|
|
464
|
+
const target = options[index];
|
|
465
|
+
if (target === undefined) return;
|
|
466
|
+
onChange(target.value);
|
|
467
|
+
buttonRefs.current[index]?.focus();
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number): void => {
|
|
471
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
selectAt((index + 1) % options.length);
|
|
474
|
+
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
selectAt((index - 1 + options.length) % options.length);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<div
|
|
482
|
+
role="radiogroup"
|
|
483
|
+
aria-labelledby={fieldLabelId(id)}
|
|
484
|
+
aria-required={required}
|
|
485
|
+
aria-invalid={hasError === true ? true : undefined}
|
|
486
|
+
data-testid={`segmented-${id}`}
|
|
487
|
+
className={cn(
|
|
488
|
+
"inline-flex w-fit flex-wrap divide-x overflow-hidden rounded-md border",
|
|
489
|
+
hasError === true
|
|
490
|
+
? "divide-destructive/50 border-destructive"
|
|
491
|
+
: "divide-border border-input",
|
|
492
|
+
)}
|
|
493
|
+
>
|
|
494
|
+
<input type="hidden" name={name} value={value} />
|
|
495
|
+
{options.map((opt, index) => {
|
|
496
|
+
const checked = opt.value === value;
|
|
497
|
+
return (
|
|
498
|
+
// biome-ignore lint/a11y/useSemanticElements: a native <input type="radio"> can't render the segment's label as content — button+role="radio" is the standard WAI-ARIA composite-widget substitute.
|
|
499
|
+
<button
|
|
500
|
+
key={opt.value}
|
|
501
|
+
ref={(node) => {
|
|
502
|
+
buttonRefs.current[index] = node;
|
|
503
|
+
}}
|
|
504
|
+
type="button"
|
|
505
|
+
role="radio"
|
|
506
|
+
aria-checked={checked}
|
|
507
|
+
tabIndex={checked || (value === "" && index === 0) ? 0 : -1}
|
|
508
|
+
disabled={disabled}
|
|
509
|
+
data-testid={`segmented-${id}-${opt.value}`}
|
|
510
|
+
onClick={() => onChange(opt.value)}
|
|
511
|
+
onKeyDown={(e) => handleKeyDown(e, index)}
|
|
512
|
+
className={cn(
|
|
513
|
+
"px-3 py-1.5 text-sm font-medium transition-colors",
|
|
514
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
515
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
516
|
+
checked
|
|
517
|
+
? "bg-primary text-primary-foreground"
|
|
518
|
+
: "bg-transparent text-foreground hover:bg-accent",
|
|
519
|
+
)}
|
|
520
|
+
>
|
|
521
|
+
{opt.label}
|
|
522
|
+
</button>
|
|
523
|
+
);
|
|
524
|
+
})}
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
362
529
|
function DefaultInput(props: InputProps): ReactNode {
|
|
363
530
|
// Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
|
|
364
531
|
// selbst — kein manuelles border-destructive mehr nötig.
|
|
532
|
+
const booleanLayout = useContext(FieldLayoutContext);
|
|
365
533
|
const common = {
|
|
366
534
|
id: props.id,
|
|
367
535
|
name: props.name,
|
|
@@ -409,23 +577,27 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
409
577
|
/>
|
|
410
578
|
);
|
|
411
579
|
case "number":
|
|
412
|
-
return
|
|
413
|
-
props.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
580
|
+
return withUnitSuffix(
|
|
581
|
+
props.unit,
|
|
582
|
+
withFieldIcon(
|
|
583
|
+
props.icon,
|
|
584
|
+
<UiInput
|
|
585
|
+
type="number"
|
|
586
|
+
{...common}
|
|
587
|
+
data-testid={props.testId}
|
|
588
|
+
value={props.value}
|
|
589
|
+
step={props.step}
|
|
590
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
591
|
+
const v = e.target.value;
|
|
592
|
+
props.onChange(v === "" ? undefined : Number(v));
|
|
593
|
+
}}
|
|
594
|
+
className={cn(
|
|
595
|
+
"text-right tabular-nums",
|
|
596
|
+
fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined,
|
|
597
|
+
props.unit !== undefined ? "pr-8" : undefined,
|
|
598
|
+
)}
|
|
599
|
+
/>,
|
|
600
|
+
),
|
|
429
601
|
);
|
|
430
602
|
case "range":
|
|
431
603
|
return (
|
|
@@ -441,7 +613,10 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
441
613
|
/>
|
|
442
614
|
);
|
|
443
615
|
case "boolean":
|
|
444
|
-
|
|
616
|
+
// layout="inline" (checkbox lists like MultiSelectCheckboxes, the
|
|
617
|
+
// BooleanField widget) keeps the checkbox optics; the standard form
|
|
618
|
+
// field (render-field.tsx, layout="stacked") gets the switch.
|
|
619
|
+
return booleanLayout === "inline" ? (
|
|
445
620
|
<Checkbox
|
|
446
621
|
id={props.id}
|
|
447
622
|
name={props.name}
|
|
@@ -451,6 +626,17 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
451
626
|
checked={props.value}
|
|
452
627
|
onCheckedChange={(checked) => props.onChange(checked === true)}
|
|
453
628
|
/>
|
|
629
|
+
) : (
|
|
630
|
+
<Switch
|
|
631
|
+
id={props.id}
|
|
632
|
+
name={props.name}
|
|
633
|
+
disabled={props.disabled}
|
|
634
|
+
aria-required={props.required}
|
|
635
|
+
aria-invalid={props.hasError === true ? true : undefined}
|
|
636
|
+
checked={props.value}
|
|
637
|
+
onCheckedChange={(checked) => props.onChange(checked === true)}
|
|
638
|
+
className="aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
|
|
639
|
+
/>
|
|
454
640
|
);
|
|
455
641
|
case "file":
|
|
456
642
|
case "image":
|
|
@@ -495,6 +681,20 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
495
681
|
const comboOptions = props.options.map((o) =>
|
|
496
682
|
typeof o === "string" ? { value: o, label: o } : o,
|
|
497
683
|
);
|
|
684
|
+
if (isSegmentedSelectEligible(comboOptions)) {
|
|
685
|
+
return (
|
|
686
|
+
<SegmentedSelect
|
|
687
|
+
id={props.id}
|
|
688
|
+
name={props.name}
|
|
689
|
+
value={props.value}
|
|
690
|
+
onChange={props.onChange}
|
|
691
|
+
options={comboOptions}
|
|
692
|
+
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
693
|
+
{...(props.required !== undefined && { required: props.required })}
|
|
694
|
+
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
695
|
+
/>
|
|
696
|
+
);
|
|
697
|
+
}
|
|
498
698
|
return (
|
|
499
699
|
<ComboboxInput
|
|
500
700
|
id={props.id}
|
|
@@ -682,20 +882,29 @@ function DefaultDataTable({
|
|
|
682
882
|
// Optional hooks: a bare DataTable outside LocaleProvider must not crash.
|
|
683
883
|
const tableTranslate = useOptionalTranslation();
|
|
684
884
|
const tableLocale = useOptionalLocale();
|
|
885
|
+
// Below 768px a table scrolls its columns out of reach with no visible
|
|
886
|
+
// affordance (fw#2159 fixed the desktop case; narrow viewports never had
|
|
887
|
+
// one). Cards replace the table entirely below the breakpoint — same
|
|
888
|
+
// single-mount pattern as EmbeddedListInput/embedded-list-input.tsx.
|
|
889
|
+
const isNarrow = useIsNarrowViewport();
|
|
685
890
|
// Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
|
|
686
891
|
// beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
|
|
687
892
|
// scrollen typischerweise mit dem Page-Container, nicht intern.
|
|
688
893
|
// Sticky würde mit der Topbar konkurrieren.
|
|
689
894
|
const hasTableActions = rowActions !== undefined && rowActions.length > 0;
|
|
895
|
+
const isEmpty = rows.length === 0;
|
|
896
|
+
const emptyBlock: ReactNode = (
|
|
897
|
+
<div
|
|
898
|
+
data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
|
|
899
|
+
className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
|
|
900
|
+
>
|
|
901
|
+
{emptyState ?? <span>No entries.</span>}
|
|
902
|
+
</div>
|
|
903
|
+
);
|
|
904
|
+
|
|
690
905
|
function tableInner(): ReactNode {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
|
|
694
|
-
className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
|
|
695
|
-
>
|
|
696
|
-
{emptyState ?? <span>No entries.</span>}
|
|
697
|
-
</div>
|
|
698
|
-
) : (
|
|
906
|
+
if (isEmpty) return emptyBlock;
|
|
907
|
+
return (
|
|
699
908
|
// dashboard-01-Muster: `rounded-lg border`-Rahmen, die Header-Zeile
|
|
700
909
|
// trägt den bg-muted-Grauton. `bg-card` (statt transparent) → die Liste
|
|
701
910
|
// sitzt auf derselben Card-Fläche wie Forms; auf Themes mit farbigem
|
|
@@ -794,7 +1003,147 @@ function DefaultDataTable({
|
|
|
794
1003
|
</div>
|
|
795
1004
|
);
|
|
796
1005
|
}
|
|
797
|
-
|
|
1006
|
+
|
|
1007
|
+
// No column headers below the breakpoint, so the click-to-sort affordance
|
|
1008
|
+
// on SortableHeader has nothing to attach to. A single native <select> is
|
|
1009
|
+
// the whole fix — no custom widget, no menu — fed straight from the
|
|
1010
|
+
// columns marked sortable in the ViewModel. Options carry the resolved
|
|
1011
|
+
// {field, dir} directly so onChange can look the pick up by value instead
|
|
1012
|
+
// of parsing/casting the option string back apart.
|
|
1013
|
+
const sortableColumns = columns.filter((col) => col.sortable);
|
|
1014
|
+
const sortOptions: readonly {
|
|
1015
|
+
readonly value: string;
|
|
1016
|
+
readonly field: string;
|
|
1017
|
+
readonly dir: DataTableSortDir;
|
|
1018
|
+
readonly label: string;
|
|
1019
|
+
}[] = sortableColumns.flatMap((col) => [
|
|
1020
|
+
{ value: `${col.field}:asc`, field: col.field, dir: "asc", label: `${col.label} ↑` },
|
|
1021
|
+
{ value: `${col.field}:desc`, field: col.field, dir: "desc", label: `${col.label} ↓` },
|
|
1022
|
+
]);
|
|
1023
|
+
|
|
1024
|
+
function renderCard(row: ListRowViewModel): ReactNode {
|
|
1025
|
+
const titleColumn = columns.find((col) => col.highlighted === true) ?? columns[0];
|
|
1026
|
+
const detailColumns = columns.filter((col) => col !== titleColumn);
|
|
1027
|
+
return (
|
|
1028
|
+
<div
|
|
1029
|
+
key={row.id}
|
|
1030
|
+
data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
|
|
1031
|
+
{...(onRowClick !== undefined && {
|
|
1032
|
+
role: "button" as const,
|
|
1033
|
+
tabIndex: 0,
|
|
1034
|
+
onClick: () => onRowClick(row),
|
|
1035
|
+
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
|
|
1036
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
1037
|
+
e.preventDefault();
|
|
1038
|
+
onRowClick(row);
|
|
1039
|
+
},
|
|
1040
|
+
})}
|
|
1041
|
+
className={cn(
|
|
1042
|
+
"flex flex-col gap-3 rounded-lg border bg-card p-4",
|
|
1043
|
+
onRowClick !== undefined && "cursor-pointer",
|
|
1044
|
+
)}
|
|
1045
|
+
>
|
|
1046
|
+
{titleColumn !== undefined && (
|
|
1047
|
+
<div
|
|
1048
|
+
data-testid={
|
|
1049
|
+
getCellTestId?.(row, titleColumn.field) ?? `cell-${row.id}-${titleColumn.field}`
|
|
1050
|
+
}
|
|
1051
|
+
className="text-base font-medium"
|
|
1052
|
+
>
|
|
1053
|
+
<DataTableCell
|
|
1054
|
+
value={row.values[titleColumn.field]}
|
|
1055
|
+
row={row.values}
|
|
1056
|
+
field={titleColumn.field}
|
|
1057
|
+
type={titleColumn.type}
|
|
1058
|
+
renderer={titleColumn.renderer}
|
|
1059
|
+
translate={tableTranslate}
|
|
1060
|
+
locale={tableLocale}
|
|
1061
|
+
{...(titleColumn.optionLabels !== undefined && {
|
|
1062
|
+
optionLabels: titleColumn.optionLabels,
|
|
1063
|
+
})}
|
|
1064
|
+
{...(onCellChange !== undefined && {
|
|
1065
|
+
onChange: (value: unknown) => onCellChange(row.id, titleColumn.field, value),
|
|
1066
|
+
})}
|
|
1067
|
+
/>
|
|
1068
|
+
</div>
|
|
1069
|
+
)}
|
|
1070
|
+
<div className="flex flex-col gap-2">
|
|
1071
|
+
{detailColumns.map((col) => (
|
|
1072
|
+
<div key={col.field} className="flex flex-col gap-0.5">
|
|
1073
|
+
<span className="text-xs text-muted-foreground">{col.label}</span>
|
|
1074
|
+
<span
|
|
1075
|
+
data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
|
|
1076
|
+
className="text-sm"
|
|
1077
|
+
>
|
|
1078
|
+
<DataTableCell
|
|
1079
|
+
value={row.values[col.field]}
|
|
1080
|
+
row={row.values}
|
|
1081
|
+
field={col.field}
|
|
1082
|
+
type={col.type}
|
|
1083
|
+
renderer={col.renderer}
|
|
1084
|
+
translate={tableTranslate}
|
|
1085
|
+
locale={tableLocale}
|
|
1086
|
+
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
1087
|
+
{...(onCellChange !== undefined && {
|
|
1088
|
+
onChange: (value: unknown) => onCellChange(row.id, col.field, value),
|
|
1089
|
+
})}
|
|
1090
|
+
/>
|
|
1091
|
+
</span>
|
|
1092
|
+
</div>
|
|
1093
|
+
))}
|
|
1094
|
+
</div>
|
|
1095
|
+
{hasTableActions && (
|
|
1096
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation only — not a control
|
|
1097
|
+
<div
|
|
1098
|
+
className="flex items-center justify-end gap-1 border-t pt-3"
|
|
1099
|
+
onClick={(e) => e.stopPropagation()}
|
|
1100
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
1101
|
+
>
|
|
1102
|
+
<RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
|
|
1103
|
+
</div>
|
|
1104
|
+
)}
|
|
1105
|
+
</div>
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function cardsInner(): ReactNode {
|
|
1110
|
+
if (isEmpty) return emptyBlock;
|
|
1111
|
+
return (
|
|
1112
|
+
<div
|
|
1113
|
+
data-testid={testId !== undefined ? `${testId}-cards` : "render-list-cards"}
|
|
1114
|
+
className="flex flex-col gap-3"
|
|
1115
|
+
>
|
|
1116
|
+
{onSortChange !== undefined && sortableColumns.length > 0 && (
|
|
1117
|
+
<select
|
|
1118
|
+
aria-label={tableTranslate?.("kumiko.list.sort.label") ?? "Sort"}
|
|
1119
|
+
data-testid={testId !== undefined ? `${testId}-sort` : "render-list-sort"}
|
|
1120
|
+
value={sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : ""}
|
|
1121
|
+
onChange={(e) => {
|
|
1122
|
+
const raw = e.target.value;
|
|
1123
|
+
if (raw === "") {
|
|
1124
|
+
onSortChange(null);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const picked = sortOptions.find((o) => o.value === raw);
|
|
1128
|
+
if (picked === undefined) return;
|
|
1129
|
+
onSortChange({ field: picked.field, dir: picked.dir });
|
|
1130
|
+
}}
|
|
1131
|
+
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
1132
|
+
>
|
|
1133
|
+
<option value="">{tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted"}</option>
|
|
1134
|
+
{sortOptions.map((o) => (
|
|
1135
|
+
<option key={o.value} value={o.value}>
|
|
1136
|
+
{o.label}
|
|
1137
|
+
</option>
|
|
1138
|
+
))}
|
|
1139
|
+
</select>
|
|
1140
|
+
)}
|
|
1141
|
+
{rows.map((row) => renderCard(row))}
|
|
1142
|
+
</div>
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const tableContent = isNarrow ? cardsInner() : tableInner();
|
|
798
1147
|
|
|
799
1148
|
// Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
|
|
800
1149
|
// damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
|
|
@@ -904,10 +1253,14 @@ function RowActionsCell({
|
|
|
904
1253
|
const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
|
|
905
1254
|
if (visible.length === 0) return null;
|
|
906
1255
|
if (mode === "inline") {
|
|
1256
|
+
// Teil C: >2 actions all carrying an icon collapse to icon-only —
|
|
1257
|
+
// otherwise "inline" is exactly the wall-to-wall text-button problem
|
|
1258
|
+
// this feature exists to fix.
|
|
1259
|
+
const iconOnly = shouldRenderActionsIconOnly(visible);
|
|
907
1260
|
return (
|
|
908
1261
|
<div className="flex w-full items-center gap-1 justify-start">
|
|
909
1262
|
{visible.map((a) => (
|
|
910
|
-
<RowActionButton key={a.id} row={row} action={a} />
|
|
1263
|
+
<RowActionButton key={a.id} row={row} action={a} iconOnly={iconOnly} />
|
|
911
1264
|
))}
|
|
912
1265
|
</div>
|
|
913
1266
|
);
|
|
@@ -963,9 +1316,13 @@ function useRowActionTrigger(row: ListRowViewModel) {
|
|
|
963
1316
|
function RowActionButton({
|
|
964
1317
|
row,
|
|
965
1318
|
action,
|
|
1319
|
+
iconOnly = false,
|
|
966
1320
|
}: {
|
|
967
1321
|
readonly row: ListRowViewModel;
|
|
968
1322
|
readonly action: DataTableRowAction;
|
|
1323
|
+
/** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
|
|
1324
|
+
* effect when this action actually resolved an icon. */
|
|
1325
|
+
readonly iconOnly?: boolean;
|
|
969
1326
|
}): ReactNode {
|
|
970
1327
|
const { busy, triggerNow } = useRowActionTrigger(row);
|
|
971
1328
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
@@ -977,12 +1334,16 @@ function RowActionButton({
|
|
|
977
1334
|
? "text-primary hover:bg-primary/10"
|
|
978
1335
|
: "text-foreground hover:bg-accent";
|
|
979
1336
|
|
|
1337
|
+
const resolvedIcon = actionIconFor(action.icon);
|
|
1338
|
+
const showIconOnly = iconOnly && resolvedIcon !== undefined;
|
|
1339
|
+
|
|
980
1340
|
return (
|
|
981
1341
|
<>
|
|
982
1342
|
<button
|
|
983
1343
|
type="button"
|
|
984
1344
|
data-testid={`row-${row.id}-action-${action.id}`}
|
|
985
1345
|
disabled={busy}
|
|
1346
|
+
{...(showIconOnly && { "aria-label": action.label, title: action.label })}
|
|
986
1347
|
onClick={(e) => {
|
|
987
1348
|
e.stopPropagation();
|
|
988
1349
|
if (needsConfirm(action)) {
|
|
@@ -992,13 +1353,25 @@ function RowActionButton({
|
|
|
992
1353
|
}
|
|
993
1354
|
}}
|
|
994
1355
|
className={cn(
|
|
995
|
-
"inline-flex h-8 items-center justify-center rounded-sm
|
|
1356
|
+
"inline-flex h-8 items-center justify-center gap-1.5 rounded-sm text-sm",
|
|
1357
|
+
showIconOnly ? "w-8" : "px-2",
|
|
996
1358
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
997
1359
|
"disabled:opacity-50 disabled:pointer-events-none",
|
|
998
1360
|
variantClass,
|
|
999
1361
|
)}
|
|
1000
1362
|
>
|
|
1001
|
-
{busy ?
|
|
1363
|
+
{busy ? (
|
|
1364
|
+
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
|
|
1365
|
+
) : resolvedIcon === undefined ? (
|
|
1366
|
+
action.label
|
|
1367
|
+
) : showIconOnly ? (
|
|
1368
|
+
<Icon name={resolvedIcon} className="size-4" />
|
|
1369
|
+
) : (
|
|
1370
|
+
<>
|
|
1371
|
+
<Icon name={resolvedIcon} className="size-4" />
|
|
1372
|
+
{action.label}
|
|
1373
|
+
</>
|
|
1374
|
+
)}
|
|
1002
1375
|
</button>
|
|
1003
1376
|
<DefaultDialog
|
|
1004
1377
|
open={confirmOpen}
|
|
@@ -1456,11 +1829,30 @@ function isMoneyValue(value: unknown): value is MoneyCellValue {
|
|
|
1456
1829
|
// - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
|
|
1457
1830
|
// - money → { amount, currency } formatted via Intl (not "[object Object]")
|
|
1458
1831
|
// - text/else → toString
|
|
1832
|
+
|
|
1833
|
+
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
|
|
1834
|
+
const warnedTimestampColumns = new Set<string>();
|
|
1835
|
+
|
|
1836
|
+
// A raw ISO string in a "text" column means the author forgot `renderer:
|
|
1837
|
+
// { format: "timestamp" }` — guessing a format here could be wrong, so we
|
|
1838
|
+
// warn and leave the value as-is instead of auto-formatting.
|
|
1839
|
+
function warnMissingTimestampFormat(columnKey: string | undefined): void {
|
|
1840
|
+
if (typeof process === "undefined" || process.env.NODE_ENV === "production") return;
|
|
1841
|
+
const key = columnKey ?? "<unknown column>";
|
|
1842
|
+
if (warnedTimestampColumns.has(key)) return;
|
|
1843
|
+
warnedTimestampColumns.add(key);
|
|
1844
|
+
// biome-ignore lint/suspicious/noConsole: dev-only assertion
|
|
1845
|
+
console.warn(
|
|
1846
|
+
`[kumiko] column "${key}" renders a raw ISO timestamp as text — add renderer: { format: "timestamp" } to its column definition.`,
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1459
1850
|
export function defaultCellRender(
|
|
1460
1851
|
value: unknown,
|
|
1461
1852
|
type: string,
|
|
1462
1853
|
optionLabels?: Readonly<Record<string, string>>,
|
|
1463
1854
|
locale?: string,
|
|
1855
|
+
columnKey?: string,
|
|
1464
1856
|
): string {
|
|
1465
1857
|
if (value === null || value === undefined || value === "") return "";
|
|
1466
1858
|
if (type === "boolean") return value === true ? "✓" : "";
|
|
@@ -1495,6 +1887,9 @@ export function defaultCellRender(
|
|
|
1495
1887
|
})
|
|
1496
1888
|
.join(", ");
|
|
1497
1889
|
}
|
|
1890
|
+
if (type === "text" && typeof value === "string" && ISO_DATETIME_RE.test(value)) {
|
|
1891
|
+
warnMissingTimestampFormat(columnKey);
|
|
1892
|
+
}
|
|
1498
1893
|
return typeof value === "string" ? value : String(value);
|
|
1499
1894
|
}
|
|
1500
1895
|
|
|
@@ -1593,17 +1988,25 @@ function DataTableCell({
|
|
|
1593
1988
|
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
1594
1989
|
console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
|
|
1595
1990
|
}
|
|
1596
|
-
// select
|
|
1597
|
-
//
|
|
1991
|
+
// A select value renders as a pill instead of plain text. When the raw
|
|
1992
|
+
// value is a known status word, it carries the same tone as the
|
|
1993
|
+
// projectionDetail header badge — a status column stayed grey while the
|
|
1994
|
+
// detail view of the same value was coloured (fw#2579). Unknown values
|
|
1995
|
+
// keep the neutral outline pill.
|
|
1598
1996
|
if (type === "select" && value !== null && value !== undefined && value !== "") {
|
|
1599
|
-
|
|
1997
|
+
const label = defaultCellRender(value, type, optionLabels, locale);
|
|
1998
|
+
const tone = typeof value === "string" ? statusToneForValue(value) : undefined;
|
|
1999
|
+
if (tone !== undefined) {
|
|
2000
|
+
return <StatusBadge tone={tone}>{label}</StatusBadge>;
|
|
2001
|
+
}
|
|
2002
|
+
// dashboard-01 pattern: outline badge + muted instead of a filled secondary.
|
|
1600
2003
|
return (
|
|
1601
2004
|
<Badge variant="outline" className="px-1.5 text-muted-foreground">
|
|
1602
|
-
{
|
|
2005
|
+
{label}
|
|
1603
2006
|
</Badge>
|
|
1604
2007
|
);
|
|
1605
2008
|
}
|
|
1606
|
-
return defaultCellRender(value, type, optionLabels, locale);
|
|
2009
|
+
return defaultCellRender(value, type, optionLabels, locale, field);
|
|
1607
2010
|
}
|
|
1608
2011
|
|
|
1609
2012
|
// ---- Form + Section + Grid + Text ----
|
|
@@ -1630,6 +2033,7 @@ function DefaultForm({
|
|
|
1630
2033
|
title,
|
|
1631
2034
|
subtitle,
|
|
1632
2035
|
actions,
|
|
2036
|
+
secondaryActions,
|
|
1633
2037
|
testId,
|
|
1634
2038
|
width,
|
|
1635
2039
|
stickyActions,
|
|
@@ -1653,8 +2057,11 @@ function DefaultForm({
|
|
|
1653
2057
|
)}
|
|
1654
2058
|
>
|
|
1655
2059
|
<InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
|
|
1656
|
-
{actions !== undefined && (
|
|
1657
|
-
<div className="flex items-center justify-end gap-2">
|
|
2060
|
+
{(secondaryActions !== undefined || actions !== undefined) && (
|
|
2061
|
+
<div className="flex items-center justify-end gap-2">
|
|
2062
|
+
{secondaryActions}
|
|
2063
|
+
{actions}
|
|
2064
|
+
</div>
|
|
1658
2065
|
)}
|
|
1659
2066
|
</form>
|
|
1660
2067
|
);
|
|
@@ -1678,7 +2085,7 @@ function DefaultForm({
|
|
|
1678
2085
|
)}
|
|
1679
2086
|
<div className={cn(cardSurface(), "overflow-hidden")}>
|
|
1680
2087
|
{(title !== undefined || subtitle !== undefined) && (
|
|
1681
|
-
<div className="px-6 pb-
|
|
2088
|
+
<div className={cn(cardHeaderBorder, "px-6 pb-4 pt-5")}>
|
|
1682
2089
|
{title !== undefined && (
|
|
1683
2090
|
<h2
|
|
1684
2091
|
data-testid={testId !== undefined ? `${testId}-title` : undefined}
|
|
@@ -1714,11 +2121,10 @@ function DefaultForm({
|
|
|
1714
2121
|
>
|
|
1715
2122
|
<InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
|
|
1716
2123
|
</div>
|
|
1717
|
-
{actions !== undefined && (
|
|
2124
|
+
{(secondaryActions !== undefined || actions !== undefined) && (
|
|
1718
2125
|
<div
|
|
1719
|
-
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
1720
2126
|
className={cn(
|
|
1721
|
-
|
|
2127
|
+
"flex flex-col-reverse gap-3 px-[var(--card-padding)] py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4",
|
|
1722
2128
|
cardFooterBorder,
|
|
1723
2129
|
// Below sm (640px): pin to the viewport bottom instead of normal
|
|
1724
2130
|
// flow, so a virtual keyboard shrinking the viewport can't push
|
|
@@ -1729,7 +2135,22 @@ function DefaultForm({
|
|
|
1729
2135
|
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-4",
|
|
1730
2136
|
)}
|
|
1731
2137
|
>
|
|
1732
|
-
{
|
|
2138
|
+
{secondaryActions !== undefined && (
|
|
2139
|
+
<div
|
|
2140
|
+
data-testid={testId !== undefined ? `${testId}-actions-secondary` : undefined}
|
|
2141
|
+
className="flex flex-wrap items-center gap-1 max-sm:[&_button]:text-xs"
|
|
2142
|
+
>
|
|
2143
|
+
{secondaryActions}
|
|
2144
|
+
</div>
|
|
2145
|
+
)}
|
|
2146
|
+
{actions !== undefined && (
|
|
2147
|
+
<div
|
|
2148
|
+
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
2149
|
+
className="flex items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
|
|
2150
|
+
>
|
|
2151
|
+
{actions}
|
|
2152
|
+
</div>
|
|
2153
|
+
)}
|
|
1733
2154
|
</div>
|
|
1734
2155
|
)}
|
|
1735
2156
|
</div>
|
|
@@ -1754,7 +2175,7 @@ export function FormScreenShell({
|
|
|
1754
2175
|
children,
|
|
1755
2176
|
className,
|
|
1756
2177
|
testId,
|
|
1757
|
-
maxWidth = "
|
|
2178
|
+
maxWidth = "4xl",
|
|
1758
2179
|
}: {
|
|
1759
2180
|
readonly children: ReactNode;
|
|
1760
2181
|
readonly className?: string;
|
|
@@ -1778,20 +2199,24 @@ function DefaultSection({
|
|
|
1778
2199
|
actions,
|
|
1779
2200
|
variant = "default",
|
|
1780
2201
|
testId,
|
|
2202
|
+
icon,
|
|
1781
2203
|
}: SectionProps): ReactNode {
|
|
1782
2204
|
const insideForm = useContext(InsideFormContext);
|
|
1783
2205
|
|
|
1784
2206
|
// h3 statt CardTitle (= div): erhält die Heading-Semantik für
|
|
1785
2207
|
// Screenreader-Navigation. Subtitle fließt darunter (kein Divider —
|
|
1786
2208
|
// shadcn CardTitle+CardDescription-Muster).
|
|
2209
|
+
// icon only renders alongside a title — a title-less section has nothing
|
|
2210
|
+
// for a lone icon to sit next to, so it stays as-is (no heading grows).
|
|
1787
2211
|
const header =
|
|
1788
2212
|
title !== undefined || subtitle !== undefined ? (
|
|
1789
2213
|
<div className="flex flex-col gap-1">
|
|
1790
2214
|
{title !== undefined && (
|
|
1791
2215
|
<h3
|
|
1792
2216
|
data-testid={testId !== undefined ? `${testId}-title` : undefined}
|
|
1793
|
-
className="text-base font-semibold leading-none tracking-tight"
|
|
2217
|
+
className="flex items-center gap-2 text-base font-semibold leading-none tracking-tight"
|
|
1794
2218
|
>
|
|
2219
|
+
{icon !== undefined && <Icon name={icon} className="size-4 text-muted-foreground" />}
|
|
1795
2220
|
{title}
|
|
1796
2221
|
</h3>
|
|
1797
2222
|
)}
|
|
@@ -1836,8 +2261,8 @@ function DefaultSection({
|
|
|
1836
2261
|
);
|
|
1837
2262
|
}
|
|
1838
2263
|
|
|
1839
|
-
// Standalone:
|
|
1840
|
-
// actions =
|
|
2264
|
+
// Standalone: own card, header flows into the body (no divider).
|
|
2265
|
+
// actions = raised footer row (cardFooterBorder, same as DefaultForm).
|
|
1841
2266
|
// overflow-hidden clips the footer-corner radius correctly for portaled
|
|
1842
2267
|
// overlays (Combobox/Select/Tooltip escape to document.body, unaffected).
|
|
1843
2268
|
// A non-portaled overlay (e.g. a custom dropdown built directly into
|