@cosmicdrift/kumiko-renderer-web 0.233.0 → 0.235.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 +1 -1
- package/src/__tests__/kumiko-screen.test.tsx +293 -3
- package/src/__tests__/primitives.test.tsx +182 -3
- package/src/__tests__/projection-detail-record-layout.test.tsx +119 -0
- package/src/__tests__/render-edit.test.tsx +9 -10
- package/src/__tests__/secrets-edit.test.tsx +287 -0
- package/src/__tests__/workspace-shell.test.tsx +5 -4
- package/src/icons.tsx +84 -1
- package/src/layout/app-layout.tsx +7 -7
- package/src/layout/default-app-shell.tsx +3 -2
- package/src/layout/workspace-shell.tsx +3 -2
- package/src/primitives/__tests__/button.test.tsx +37 -0
- package/src/primitives/__tests__/select-segmented.test.tsx +84 -0
- package/src/primitives/index.tsx +210 -17
- package/src/primitives/metric.tsx +18 -10
- package/src/styles.css +21 -17
- package/src/ui/switch.tsx +35 -0
|
@@ -68,7 +68,8 @@ export type DefaultAppShellProps = {
|
|
|
68
68
|
* Plan-Banner. */
|
|
69
69
|
readonly sidebarFooter?: ReactNode;
|
|
70
70
|
/** Viewport-fit Shell. true → fixe Viewport-Höhe (`h-svh`), der Content
|
|
71
|
-
* scrollt INNEN statt der ganzen Seite. Default
|
|
71
|
+
* scrollt INNEN statt der ganzen Seite. Default true — `fill={false}`
|
|
72
|
+
* schaltet zurück auf Seiten-Scroll. */
|
|
72
73
|
readonly fill?: boolean;
|
|
73
74
|
/** Screen-Content der im SidebarInset gerendert wird. */
|
|
74
75
|
readonly children: ReactNode;
|
|
@@ -82,7 +83,7 @@ export function DefaultAppShell({
|
|
|
82
83
|
headerActions,
|
|
83
84
|
navBadges,
|
|
84
85
|
sidebarFooter,
|
|
85
|
-
fill,
|
|
86
|
+
fill = true,
|
|
86
87
|
children,
|
|
87
88
|
}: DefaultAppShellProps): ReactNode {
|
|
88
89
|
const fillCls = fillClasses(fill);
|
|
@@ -80,7 +80,8 @@ export type WorkspaceShellProps = {
|
|
|
80
80
|
/** Runtime badge slot per nav leaf (bare nav id), same as DefaultAppShell. */
|
|
81
81
|
readonly navBadges?: ReadonlyMap<string, ReactNode>;
|
|
82
82
|
/** Viewport-fit shell. true → fixed viewport height (`h-svh`), content
|
|
83
|
-
* scrolls INSIDE instead of the whole page. Default
|
|
83
|
+
* scrolls INSIDE instead of the whole page. Default true — set
|
|
84
|
+
* `fill={false}` to opt back into page scroll. */
|
|
84
85
|
readonly fill?: boolean;
|
|
85
86
|
/** Screen content. */
|
|
86
87
|
readonly children: ReactNode;
|
|
@@ -94,7 +95,7 @@ export function WorkspaceShell({
|
|
|
94
95
|
initialWorkspaceId,
|
|
95
96
|
sidebarFooter,
|
|
96
97
|
navBadges,
|
|
97
|
-
fill,
|
|
98
|
+
fill = true,
|
|
98
99
|
children,
|
|
99
100
|
}: WorkspaceShellProps): ReactNode {
|
|
100
101
|
const app = useMemo(() => toAppSchema(schema), [schema]);
|
|
@@ -25,3 +25,40 @@ describe("DefaultButton className/ref (fw#1831)", () => {
|
|
|
25
25
|
expect(ref.current).toBe(screen.getByTestId("btn"));
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
describe("DefaultButton icon (fw-ui-defaults)", () => {
|
|
30
|
+
test("icon prop renders the resolved icon left of the text", () => {
|
|
31
|
+
render(
|
|
32
|
+
<Button icon="trash" testId="btn">
|
|
33
|
+
Delete
|
|
34
|
+
</Button>,
|
|
35
|
+
);
|
|
36
|
+
const btn = screen.getByTestId("btn");
|
|
37
|
+
expect(btn.querySelector("svg")).not.toBeNull();
|
|
38
|
+
expect(btn.textContent).toBe("Delete");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("size='icon' + a resolved icon still renders icon, children and aria-label", () => {
|
|
42
|
+
render(
|
|
43
|
+
<Button icon="trash" size="icon" ariaLabel="Delete" testId="btn">
|
|
44
|
+
Delete
|
|
45
|
+
</Button>,
|
|
46
|
+
);
|
|
47
|
+
const btn = screen.getByTestId("btn");
|
|
48
|
+
expect(btn.textContent).toBe("Delete");
|
|
49
|
+
expect(btn.querySelector("svg")).not.toBeNull();
|
|
50
|
+
expect(btn.getAttribute("aria-label")).toBe("Delete");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("unknown icon key: no crash, falls back to rendering children only", () => {
|
|
54
|
+
render(
|
|
55
|
+
// @ts-expect-error — exercising the runtime fallback for a schema-supplied key outside the closed IconKey union
|
|
56
|
+
<Button icon="not-a-real-icon" testId="btn">
|
|
57
|
+
Delete
|
|
58
|
+
</Button>,
|
|
59
|
+
);
|
|
60
|
+
const btn = screen.getByTestId("btn");
|
|
61
|
+
expect(btn.querySelector("svg")).toBeNull();
|
|
62
|
+
expect(btn.textContent).toBe("Delete");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Segment control for `kind: "select"` with a small closed option set
|
|
2
|
+
// (edit-existing screenshot feedback): a Status field with 3 short values
|
|
3
|
+
// no longer stretches into a full-width dropdown. Threshold: ≤4 options,
|
|
4
|
+
// each label ≤14 chars — otherwise the unchanged ComboboxInput dropdown.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { fireEvent } from "@testing-library/react";
|
|
8
|
+
import { render, screen } from "../../__tests__/test-utils";
|
|
9
|
+
import { defaultPrimitives } from "../index";
|
|
10
|
+
|
|
11
|
+
const { Field, Input } = defaultPrimitives;
|
|
12
|
+
|
|
13
|
+
function renderSelect(
|
|
14
|
+
options: readonly string[],
|
|
15
|
+
overrides: { readonly value?: string; readonly disabled?: boolean } = {},
|
|
16
|
+
): string[] {
|
|
17
|
+
const changes: string[] = [];
|
|
18
|
+
render(
|
|
19
|
+
<Field id="status" label="Status" testId="field-status">
|
|
20
|
+
<Input
|
|
21
|
+
kind="select"
|
|
22
|
+
id="status"
|
|
23
|
+
name="status"
|
|
24
|
+
value={overrides.value ?? options[0] ?? ""}
|
|
25
|
+
onChange={(v) => changes.push(v)}
|
|
26
|
+
options={options}
|
|
27
|
+
{...(overrides.disabled !== undefined && { disabled: overrides.disabled })}
|
|
28
|
+
/>
|
|
29
|
+
</Field>,
|
|
30
|
+
);
|
|
31
|
+
return changes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("DefaultInput select → segmented control (edit-existing feedback)", () => {
|
|
35
|
+
test("≤4 short options render the segmented control, not the dropdown", () => {
|
|
36
|
+
renderSelect(["Draft", "Review", "Published"]);
|
|
37
|
+
expect(screen.queryByTestId("combobox-status")).toBeNull();
|
|
38
|
+
expect(screen.getByRole("radiogroup")).toBeTruthy();
|
|
39
|
+
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("4 options render the segmented control", () => {
|
|
43
|
+
renderSelect(["Draft", "Review", "Published", "Archived"]);
|
|
44
|
+
expect(screen.queryByTestId("combobox-status")).toBeNull();
|
|
45
|
+
expect(screen.getByRole("radiogroup")).toBeTruthy();
|
|
46
|
+
expect(screen.getAllByRole("radio")).toHaveLength(4);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("5 options keep the dropdown", () => {
|
|
50
|
+
renderSelect(["Draft", "Review", "Published", "Archived", "Deleted"]);
|
|
51
|
+
expect(screen.queryByRole("radiogroup")).toBeNull();
|
|
52
|
+
expect(screen.getByTestId("combobox-status")).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("3 options with one long label (>14 chars) keep the dropdown", () => {
|
|
56
|
+
renderSelect(["Draft", "Review", "Published Status"]);
|
|
57
|
+
expect(screen.queryByRole("radiogroup")).toBeNull();
|
|
58
|
+
expect(screen.getByTestId("combobox-status")).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("clicking a segment reports the same value the dropdown's onChange would give", () => {
|
|
62
|
+
const changes = renderSelect(["Draft", "Review", "Published"], { value: "Draft" });
|
|
63
|
+
fireEvent.click(screen.getByRole("radio", { name: "Review" }));
|
|
64
|
+
expect(changes).toEqual(["Review"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("group has an accessible name and the selected segment is aria-checked", () => {
|
|
68
|
+
renderSelect(["Draft", "Review", "Published"], { value: "Review" });
|
|
69
|
+
expect(screen.getByRole("radiogroup", { name: "Status" })).toBeTruthy();
|
|
70
|
+
expect(screen.getByRole("radio", { name: "Review" }).getAttribute("aria-checked")).toBe("true");
|
|
71
|
+
expect(screen.getByRole("radio", { name: "Draft" }).getAttribute("aria-checked")).toBe("false");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("disabled prevents selection", () => {
|
|
75
|
+
const changes = renderSelect(["Draft", "Review", "Published"], {
|
|
76
|
+
value: "Draft",
|
|
77
|
+
disabled: true,
|
|
78
|
+
});
|
|
79
|
+
const review = screen.getByRole("radio", { name: "Review" });
|
|
80
|
+
expect((review as HTMLButtonElement).disabled).toBe(true);
|
|
81
|
+
fireEvent.click(review);
|
|
82
|
+
expect(changes).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
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,
|
|
@@ -81,16 +83,18 @@ import {
|
|
|
81
83
|
useRef,
|
|
82
84
|
useState,
|
|
83
85
|
} from "react";
|
|
84
|
-
import { NAV_ICONS } from "../icons";
|
|
86
|
+
import { Icon, NAV_ICONS } from "../icons";
|
|
85
87
|
import { cn } from "../lib/cn";
|
|
86
88
|
import { Badge } from "../ui/badge";
|
|
87
89
|
import { buttonVariants, Button as UiButton } from "../ui/button";
|
|
88
90
|
import { Checkbox } from "../ui/checkbox";
|
|
89
91
|
import { Input as UiInput } from "../ui/input";
|
|
90
92
|
import { Label as UiLabel } from "../ui/label";
|
|
93
|
+
import { Switch } from "../ui/switch";
|
|
91
94
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
92
95
|
import { Textarea } from "../ui/textarea";
|
|
93
96
|
import { ProgressBar } from "../widgets/progress-bar";
|
|
97
|
+
import { StatusBadge } from "../widgets/status-badge";
|
|
94
98
|
import { StepBar } from "../widgets/step-bar";
|
|
95
99
|
import { ComboboxInput } from "./combobox";
|
|
96
100
|
import { DateInput } from "./date-input";
|
|
@@ -132,7 +136,10 @@ const cardSurface = cva(
|
|
|
132
136
|
);
|
|
133
137
|
// Wraps instead of running off-screen when the row outgrows its container (fw#2528).
|
|
134
138
|
const cardFooter = "flex flex-wrap items-center justify-end gap-2 px-[var(--card-padding)] py-4";
|
|
135
|
-
|
|
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";
|
|
136
143
|
|
|
137
144
|
// ---- Button (vendored shadcn ui/button) ----
|
|
138
145
|
|
|
@@ -153,6 +160,14 @@ const BUTTON_SIZE = {
|
|
|
153
160
|
icon: "icon",
|
|
154
161
|
} as const;
|
|
155
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
|
+
|
|
156
171
|
function DefaultButton({
|
|
157
172
|
type = "button",
|
|
158
173
|
onClick,
|
|
@@ -257,6 +272,13 @@ function fieldLabelId(id: string): string {
|
|
|
257
272
|
return `${id}-label`;
|
|
258
273
|
}
|
|
259
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
|
+
|
|
260
282
|
function DefaultField({
|
|
261
283
|
id,
|
|
262
284
|
label,
|
|
@@ -305,7 +327,7 @@ function DefaultField({
|
|
|
305
327
|
return (
|
|
306
328
|
<div data-testid={testId} className="flex flex-col gap-1.5">
|
|
307
329
|
<div className="flex items-center gap-2">
|
|
308
|
-
{children}
|
|
330
|
+
<FieldLayoutContext.Provider value="inline">{children}</FieldLayoutContext.Provider>
|
|
309
331
|
{labelEl}
|
|
310
332
|
{labelAppendix !== undefined && labelAppendix}
|
|
311
333
|
</div>
|
|
@@ -398,9 +420,116 @@ function withUnitSuffix(unit: string | undefined, input: ReactNode): ReactNode {
|
|
|
398
420
|
);
|
|
399
421
|
}
|
|
400
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
|
+
|
|
401
529
|
function DefaultInput(props: InputProps): ReactNode {
|
|
402
530
|
// Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
|
|
403
531
|
// selbst — kein manuelles border-destructive mehr nötig.
|
|
532
|
+
const booleanLayout = useContext(FieldLayoutContext);
|
|
404
533
|
const common = {
|
|
405
534
|
id: props.id,
|
|
406
535
|
name: props.name,
|
|
@@ -484,7 +613,10 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
484
613
|
/>
|
|
485
614
|
);
|
|
486
615
|
case "boolean":
|
|
487
|
-
|
|
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" ? (
|
|
488
620
|
<Checkbox
|
|
489
621
|
id={props.id}
|
|
490
622
|
name={props.name}
|
|
@@ -494,6 +626,17 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
494
626
|
checked={props.value}
|
|
495
627
|
onCheckedChange={(checked) => props.onChange(checked === true)}
|
|
496
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
|
+
/>
|
|
497
640
|
);
|
|
498
641
|
case "file":
|
|
499
642
|
case "image":
|
|
@@ -538,6 +681,20 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
538
681
|
const comboOptions = props.options.map((o) =>
|
|
539
682
|
typeof o === "string" ? { value: o, label: o } : o,
|
|
540
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
|
+
}
|
|
541
698
|
return (
|
|
542
699
|
<ComboboxInput
|
|
543
700
|
id={props.id}
|
|
@@ -1096,10 +1253,14 @@ function RowActionsCell({
|
|
|
1096
1253
|
const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
|
|
1097
1254
|
if (visible.length === 0) return null;
|
|
1098
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);
|
|
1099
1260
|
return (
|
|
1100
1261
|
<div className="flex w-full items-center gap-1 justify-start">
|
|
1101
1262
|
{visible.map((a) => (
|
|
1102
|
-
<RowActionButton key={a.id} row={row} action={a} />
|
|
1263
|
+
<RowActionButton key={a.id} row={row} action={a} iconOnly={iconOnly} />
|
|
1103
1264
|
))}
|
|
1104
1265
|
</div>
|
|
1105
1266
|
);
|
|
@@ -1155,9 +1316,13 @@ function useRowActionTrigger(row: ListRowViewModel) {
|
|
|
1155
1316
|
function RowActionButton({
|
|
1156
1317
|
row,
|
|
1157
1318
|
action,
|
|
1319
|
+
iconOnly = false,
|
|
1158
1320
|
}: {
|
|
1159
1321
|
readonly row: ListRowViewModel;
|
|
1160
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;
|
|
1161
1326
|
}): ReactNode {
|
|
1162
1327
|
const { busy, triggerNow } = useRowActionTrigger(row);
|
|
1163
1328
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
@@ -1169,12 +1334,16 @@ function RowActionButton({
|
|
|
1169
1334
|
? "text-primary hover:bg-primary/10"
|
|
1170
1335
|
: "text-foreground hover:bg-accent";
|
|
1171
1336
|
|
|
1337
|
+
const resolvedIcon = actionIconFor(action.icon);
|
|
1338
|
+
const showIconOnly = iconOnly && resolvedIcon !== undefined;
|
|
1339
|
+
|
|
1172
1340
|
return (
|
|
1173
1341
|
<>
|
|
1174
1342
|
<button
|
|
1175
1343
|
type="button"
|
|
1176
1344
|
data-testid={`row-${row.id}-action-${action.id}`}
|
|
1177
1345
|
disabled={busy}
|
|
1346
|
+
{...(showIconOnly && { "aria-label": action.label, title: action.label })}
|
|
1178
1347
|
onClick={(e) => {
|
|
1179
1348
|
e.stopPropagation();
|
|
1180
1349
|
if (needsConfirm(action)) {
|
|
@@ -1184,13 +1353,25 @@ function RowActionButton({
|
|
|
1184
1353
|
}
|
|
1185
1354
|
}}
|
|
1186
1355
|
className={cn(
|
|
1187
|
-
"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",
|
|
1188
1358
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
1189
1359
|
"disabled:opacity-50 disabled:pointer-events-none",
|
|
1190
1360
|
variantClass,
|
|
1191
1361
|
)}
|
|
1192
1362
|
>
|
|
1193
|
-
{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
|
+
)}
|
|
1194
1375
|
</button>
|
|
1195
1376
|
<DefaultDialog
|
|
1196
1377
|
open={confirmOpen}
|
|
@@ -1807,13 +1988,21 @@ function DataTableCell({
|
|
|
1807
1988
|
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
1808
1989
|
console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
|
|
1809
1990
|
}
|
|
1810
|
-
// select
|
|
1811
|
-
//
|
|
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.
|
|
1812
1996
|
if (type === "select" && value !== null && value !== undefined && value !== "") {
|
|
1813
|
-
|
|
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.
|
|
1814
2003
|
return (
|
|
1815
2004
|
<Badge variant="outline" className="px-1.5 text-muted-foreground">
|
|
1816
|
-
{
|
|
2005
|
+
{label}
|
|
1817
2006
|
</Badge>
|
|
1818
2007
|
);
|
|
1819
2008
|
}
|
|
@@ -1896,7 +2085,7 @@ function DefaultForm({
|
|
|
1896
2085
|
)}
|
|
1897
2086
|
<div className={cn(cardSurface(), "overflow-hidden")}>
|
|
1898
2087
|
{(title !== undefined || subtitle !== undefined) && (
|
|
1899
|
-
<div className="px-6 pb-
|
|
2088
|
+
<div className={cn(cardHeaderBorder, "px-6 pb-4 pt-5")}>
|
|
1900
2089
|
{title !== undefined && (
|
|
1901
2090
|
<h2
|
|
1902
2091
|
data-testid={testId !== undefined ? `${testId}-title` : undefined}
|
|
@@ -1986,7 +2175,7 @@ export function FormScreenShell({
|
|
|
1986
2175
|
children,
|
|
1987
2176
|
className,
|
|
1988
2177
|
testId,
|
|
1989
|
-
maxWidth = "
|
|
2178
|
+
maxWidth = "4xl",
|
|
1990
2179
|
}: {
|
|
1991
2180
|
readonly children: ReactNode;
|
|
1992
2181
|
readonly className?: string;
|
|
@@ -2010,20 +2199,24 @@ function DefaultSection({
|
|
|
2010
2199
|
actions,
|
|
2011
2200
|
variant = "default",
|
|
2012
2201
|
testId,
|
|
2202
|
+
icon,
|
|
2013
2203
|
}: SectionProps): ReactNode {
|
|
2014
2204
|
const insideForm = useContext(InsideFormContext);
|
|
2015
2205
|
|
|
2016
2206
|
// h3 statt CardTitle (= div): erhält die Heading-Semantik für
|
|
2017
2207
|
// Screenreader-Navigation. Subtitle fließt darunter (kein Divider —
|
|
2018
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).
|
|
2019
2211
|
const header =
|
|
2020
2212
|
title !== undefined || subtitle !== undefined ? (
|
|
2021
2213
|
<div className="flex flex-col gap-1">
|
|
2022
2214
|
{title !== undefined && (
|
|
2023
2215
|
<h3
|
|
2024
2216
|
data-testid={testId !== undefined ? `${testId}-title` : undefined}
|
|
2025
|
-
className="text-base font-semibold leading-none tracking-tight"
|
|
2217
|
+
className="flex items-center gap-2 text-base font-semibold leading-none tracking-tight"
|
|
2026
2218
|
>
|
|
2219
|
+
{icon !== undefined && <Icon name={icon} className="size-4 text-muted-foreground" />}
|
|
2027
2220
|
{title}
|
|
2028
2221
|
</h3>
|
|
2029
2222
|
)}
|
|
@@ -2068,8 +2261,8 @@ function DefaultSection({
|
|
|
2068
2261
|
);
|
|
2069
2262
|
}
|
|
2070
2263
|
|
|
2071
|
-
// Standalone:
|
|
2072
|
-
// actions =
|
|
2264
|
+
// Standalone: own card, header flows into the body (no divider).
|
|
2265
|
+
// actions = raised footer row (cardFooterBorder, same as DefaultForm).
|
|
2073
2266
|
// overflow-hidden clips the footer-corner radius correctly for portaled
|
|
2074
2267
|
// overlays (Combobox/Select/Tooltip escape to document.body, unaffected).
|
|
2075
2268
|
// A non-portaled overlay (e.g. a custom dropdown built directly into
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import type { MetricProps } from "@cosmicdrift/kumiko-renderer";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
import { MiniStat } from "../widgets/stat";
|
|
4
3
|
|
|
4
|
+
// Borderless cell in the metrics-band Grid — `first:border-l-0` drops the
|
|
5
|
+
// divider on the first cell purely from DOM position, so the tiles read as
|
|
6
|
+
// one row of vertical dividers instead of individual cards (fw record-
|
|
7
|
+
// screen-type polish). Typo matches StatCard's label/value rhythm.
|
|
5
8
|
export function DefaultMetric({ label, value, testId }: MetricProps): ReactNode {
|
|
6
9
|
return (
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
<div data-testid={testId} className="border-l first:border-l-0 px-4 py-3">
|
|
11
|
+
<div
|
|
12
|
+
className="text-xs text-muted-foreground"
|
|
13
|
+
data-testid={testId !== undefined ? `${testId}-label` : undefined}
|
|
14
|
+
>
|
|
15
|
+
{label}
|
|
16
|
+
</div>
|
|
17
|
+
<div
|
|
18
|
+
className="mt-0.5 text-xl font-semibold tabular-nums text-foreground"
|
|
19
|
+
data-testid={testId !== undefined ? `${testId}-value` : undefined}
|
|
20
|
+
>
|
|
21
|
+
{value}
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
16
24
|
);
|
|
17
25
|
}
|