@cosmicdrift/kumiko-renderer 0.231.0 → 0.233.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 +4 -4
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +5 -2
- package/src/app/__tests__/projection-detail-actions.test.tsx +10 -6
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +3 -1
- package/src/components/__tests__/render-field-number-unit.test.tsx +191 -0
- package/src/components/render-edit.tsx +48 -28
- package/src/components/render-field.tsx +24 -6
- package/src/i18n-defaults.ts +2 -0
- package/src/primitives.tsx +17 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.233.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.233.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.233.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
27
|
"jsdom": "^29.1.1",
|
|
28
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
28
|
+
"@cosmicdrift/kumiko-locale-de": "0.233.0"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -292,10 +292,13 @@ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) =
|
|
|
292
292
|
</button>
|
|
293
293
|
);
|
|
294
294
|
|
|
295
|
-
const FormWithActions: ComponentType<FormProps> = ({ children, actions }) => (
|
|
295
|
+
const FormWithActions: ComponentType<FormProps> = ({ children, actions, secondaryActions }) => (
|
|
296
296
|
<>
|
|
297
297
|
<div data-testid="form-body">{children}</div>
|
|
298
|
-
<div data-testid="form-actions">
|
|
298
|
+
<div data-testid="form-actions">
|
|
299
|
+
{secondaryActions}
|
|
300
|
+
{actions}
|
|
301
|
+
</div>
|
|
299
302
|
</>
|
|
300
303
|
);
|
|
301
304
|
|
|
@@ -43,14 +43,18 @@ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) =
|
|
|
43
43
|
</button>
|
|
44
44
|
);
|
|
45
45
|
|
|
46
|
-
// Form's `actions`
|
|
47
|
-
// alone would drop
|
|
48
|
-
// assert a banner rendered as a Form CHILD
|
|
49
|
-
// rather than inside the actions row — the
|
|
50
|
-
|
|
46
|
+
// Form's `actions`/`secondaryActions` slots carry the header buttons under
|
|
47
|
+
// test — passChildren alone would drop them. Wrapped in distinct testid'd
|
|
48
|
+
// containers so a test can assert a banner rendered as a Form CHILD
|
|
49
|
+
// (formError/actionError region) rather than inside the actions row — the
|
|
50
|
+
// review finding this proves.
|
|
51
|
+
const FormWithActions: ComponentType<FormProps> = ({ children, actions, secondaryActions }) => (
|
|
51
52
|
<>
|
|
52
53
|
<div data-testid="form-body">{children}</div>
|
|
53
|
-
<div data-testid="form-actions">
|
|
54
|
+
<div data-testid="form-actions">
|
|
55
|
+
{secondaryActions}
|
|
56
|
+
{actions}
|
|
57
|
+
</div>
|
|
54
58
|
</>
|
|
55
59
|
);
|
|
56
60
|
|
|
@@ -44,8 +44,9 @@ const testButton: ComponentType<{
|
|
|
44
44
|
const testForm: ComponentType<{
|
|
45
45
|
children?: ReactNode;
|
|
46
46
|
actions?: ReactNode;
|
|
47
|
+
secondaryActions?: ReactNode;
|
|
47
48
|
onSubmit?: () => void;
|
|
48
|
-
}> = ({ children, actions, onSubmit }) => (
|
|
49
|
+
}> = ({ children, actions, secondaryActions, onSubmit }) => (
|
|
49
50
|
<form
|
|
50
51
|
onSubmit={(e) => {
|
|
51
52
|
e.preventDefault();
|
|
@@ -53,6 +54,7 @@ const testForm: ComponentType<{
|
|
|
53
54
|
}}
|
|
54
55
|
>
|
|
55
56
|
{children}
|
|
57
|
+
{secondaryActions}
|
|
56
58
|
{actions}
|
|
57
59
|
</form>
|
|
58
60
|
);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Editable number-field unit-of-measure suffix (static or sibling-field
|
|
2
|
+
// reference). Complements render-field-unit-format.test.tsx, which only
|
|
3
|
+
// covers the pre-existing read-only `format: "unit"` FieldRenderer path —
|
|
4
|
+
// this feature is display-only decoration on the editable Input widget,
|
|
5
|
+
// never a value conversion.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type {
|
|
9
|
+
EntityDefinition,
|
|
10
|
+
EntityEditScreenDefinition,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
12
|
+
import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
13
|
+
import { render } from "@testing-library/react";
|
|
14
|
+
import type { ComponentType, ReactNode } from "react";
|
|
15
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
16
|
+
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
17
|
+
import { RenderField } from "../render-field";
|
|
18
|
+
|
|
19
|
+
let captured: InputProps | undefined;
|
|
20
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
21
|
+
captured = props;
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
const noop = (): ReactNode => null;
|
|
25
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
26
|
+
|
|
27
|
+
const testPrimitives: CorePrimitives = {
|
|
28
|
+
Button: noop,
|
|
29
|
+
Banner: noop,
|
|
30
|
+
Field: passChildren,
|
|
31
|
+
Input: captureInput,
|
|
32
|
+
DataTable: noop,
|
|
33
|
+
Form: noop,
|
|
34
|
+
Section: noop,
|
|
35
|
+
Card: noop,
|
|
36
|
+
Grid: noop,
|
|
37
|
+
GridCell: noop,
|
|
38
|
+
Text: noop,
|
|
39
|
+
Heading: noop,
|
|
40
|
+
Dialog: noop,
|
|
41
|
+
Modal: noop,
|
|
42
|
+
Lightbox: noop,
|
|
43
|
+
ConfigSourceBadge: noop,
|
|
44
|
+
ConfigCascadeView: noop,
|
|
45
|
+
Link: noop,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function buildEntity(
|
|
49
|
+
mileageUnit: string | { readonly field: string } | undefined,
|
|
50
|
+
): EntityDefinition {
|
|
51
|
+
return {
|
|
52
|
+
fields: {
|
|
53
|
+
mileage: {
|
|
54
|
+
type: "number",
|
|
55
|
+
required: false,
|
|
56
|
+
...(mileageUnit !== undefined && { unit: mileageUnit }),
|
|
57
|
+
},
|
|
58
|
+
mileageUnit: { type: "text", required: false },
|
|
59
|
+
},
|
|
60
|
+
} as EntityDefinition;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildScreen(): EntityEditScreenDefinition {
|
|
64
|
+
return {
|
|
65
|
+
id: "vehicle-edit",
|
|
66
|
+
type: "entityEdit",
|
|
67
|
+
entity: "vehicle",
|
|
68
|
+
layout: { sections: [{ columns: 1, fields: ["mileage", "mileageUnit"] }] },
|
|
69
|
+
} as EntityEditScreenDefinition;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mileageField(
|
|
73
|
+
entity: EntityDefinition,
|
|
74
|
+
values: Record<string, unknown>,
|
|
75
|
+
): EditFieldViewModel {
|
|
76
|
+
const vm = computeEditViewModel({
|
|
77
|
+
screen: buildScreen(),
|
|
78
|
+
entity,
|
|
79
|
+
values,
|
|
80
|
+
translate: (key) => key,
|
|
81
|
+
featureName: "fleet",
|
|
82
|
+
});
|
|
83
|
+
const section = vm.sections[0];
|
|
84
|
+
if (section === undefined || section.kind !== "fields") {
|
|
85
|
+
throw new Error("expected a fields section");
|
|
86
|
+
}
|
|
87
|
+
const field = section.fields.find((f) => f.field === "mileage");
|
|
88
|
+
if (field === undefined) throw new Error("expected a mileage field");
|
|
89
|
+
return field;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function currentInput(): InputProps {
|
|
93
|
+
if (captured === undefined) throw new Error("expected an Input to be captured");
|
|
94
|
+
return captured;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderMileageField(
|
|
98
|
+
entity: EntityDefinition,
|
|
99
|
+
values: Record<string, unknown>,
|
|
100
|
+
row?: Record<string, unknown>,
|
|
101
|
+
): InputProps {
|
|
102
|
+
captured = undefined;
|
|
103
|
+
render(
|
|
104
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
|
|
105
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
106
|
+
<RenderField
|
|
107
|
+
field={mileageField(entity, values)}
|
|
108
|
+
onChange={() => {}}
|
|
109
|
+
{...(row !== undefined && { row })}
|
|
110
|
+
/>
|
|
111
|
+
</PrimitivesProvider>
|
|
112
|
+
</LocaleProvider>,
|
|
113
|
+
);
|
|
114
|
+
if (captured === undefined) throw new Error("mileage field did not render an Input");
|
|
115
|
+
return captured;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("RenderField — editable number unit suffix", () => {
|
|
119
|
+
test("static unit resolves onto Input.unit, value stays untouched", () => {
|
|
120
|
+
const field = renderMileageField(buildEntity("km"), { mileage: 58 });
|
|
121
|
+
expect(field.kind).toBe("number");
|
|
122
|
+
if (field.kind !== "number") return;
|
|
123
|
+
expect(field.unit).toBe("km");
|
|
124
|
+
expect(field.value).toBe(58);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("no unit configured: Input.unit is undefined", () => {
|
|
128
|
+
const field = renderMileageField(buildEntity(undefined), { mileage: 58 });
|
|
129
|
+
expect(field.kind).toBe("number");
|
|
130
|
+
if (field.kind !== "number") return;
|
|
131
|
+
expect(field.unit).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("sibling-field unit resolves from the live row and updates when the sibling changes", () => {
|
|
135
|
+
const entity = buildEntity({ field: "mileageUnit" });
|
|
136
|
+
captured = undefined;
|
|
137
|
+
const { rerender } = render(
|
|
138
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
|
|
139
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
140
|
+
<RenderField
|
|
141
|
+
field={mileageField(entity, { mileage: 58, mileageUnit: "mi" })}
|
|
142
|
+
onChange={() => {}}
|
|
143
|
+
row={{ mileage: 58, mileageUnit: "mi" }}
|
|
144
|
+
/>
|
|
145
|
+
</PrimitivesProvider>
|
|
146
|
+
</LocaleProvider>,
|
|
147
|
+
);
|
|
148
|
+
const first = currentInput();
|
|
149
|
+
if (first.kind !== "number") throw new Error("expected a number Input");
|
|
150
|
+
expect(first.unit).toBe("mi");
|
|
151
|
+
|
|
152
|
+
captured = undefined;
|
|
153
|
+
rerender(
|
|
154
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
|
|
155
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
156
|
+
<RenderField
|
|
157
|
+
field={mileageField(entity, { mileage: 58, mileageUnit: "km" })}
|
|
158
|
+
onChange={() => {}}
|
|
159
|
+
row={{ mileage: 58, mileageUnit: "km" }}
|
|
160
|
+
/>
|
|
161
|
+
</PrimitivesProvider>
|
|
162
|
+
</LocaleProvider>,
|
|
163
|
+
);
|
|
164
|
+
const second = currentInput();
|
|
165
|
+
if (second.kind !== "number") throw new Error("expected a number Input");
|
|
166
|
+
expect(second.unit).toBe("km");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("missing sibling field: no suffix, no crash, no guessed default", () => {
|
|
170
|
+
const entity = buildEntity({ field: "mileageUnit" });
|
|
171
|
+
const field = renderMileageField(entity, { mileage: 58 }, { mileage: 58 });
|
|
172
|
+
expect(field.kind).toBe("number");
|
|
173
|
+
if (field.kind !== "number") return;
|
|
174
|
+
expect(field.unit).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("empty-string sibling value: no suffix, no guessed default", () => {
|
|
178
|
+
const entity = buildEntity({ field: "mileageUnit" });
|
|
179
|
+
const field = renderMileageField(
|
|
180
|
+
entity,
|
|
181
|
+
{ mileage: 58, mileageUnit: "" },
|
|
182
|
+
{
|
|
183
|
+
mileage: 58,
|
|
184
|
+
mileageUnit: "",
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
expect(field.kind).toBe("number");
|
|
188
|
+
if (field.kind !== "number") return;
|
|
189
|
+
expect(field.unit).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -914,25 +914,33 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
914
914
|
}
|
|
915
915
|
handleSubmitRef.current = handleSubmit;
|
|
916
916
|
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
|
|
917
|
+
// Two groups — wizard navigation on the right/top, record actions on the
|
|
918
|
+
// left/below; destructive action sits outermost, farthest from the target
|
|
919
|
+
// action.
|
|
920
|
+
const hasSecondaryFormActions =
|
|
921
|
+
onDelete !== undefined ||
|
|
922
|
+
onCopyLink !== undefined ||
|
|
923
|
+
(actions !== undefined && actions.length > 0) ||
|
|
924
|
+
onCancel !== undefined;
|
|
925
|
+
const secondaryFormActions = (
|
|
922
926
|
<>
|
|
923
|
-
{
|
|
924
|
-
<
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
927
|
+
{onDelete !== undefined && (
|
|
928
|
+
<Button
|
|
929
|
+
type="button"
|
|
930
|
+
variant="danger-ghost"
|
|
931
|
+
icon="trash"
|
|
932
|
+
testId="render-edit-delete"
|
|
933
|
+
disabled={disabled}
|
|
934
|
+
onClick={() => setConfirmDeleteOpen(true)}
|
|
935
|
+
>
|
|
936
|
+
{translate("kumiko.actions.delete")}
|
|
937
|
+
</Button>
|
|
938
|
+
)}
|
|
932
939
|
{onCopyLink !== undefined && (
|
|
933
940
|
<Button
|
|
934
941
|
type="button"
|
|
935
|
-
variant="
|
|
942
|
+
variant="link"
|
|
943
|
+
icon={linkCopied ? "check" : "link"}
|
|
936
944
|
testId="render-edit-copy-link"
|
|
937
945
|
onClick={async () => {
|
|
938
946
|
await onCopyLink();
|
|
@@ -942,31 +950,35 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
942
950
|
{translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
|
|
943
951
|
</Button>
|
|
944
952
|
)}
|
|
945
|
-
{
|
|
946
|
-
<
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
</Button>
|
|
955
|
-
)}
|
|
953
|
+
{actions?.map((action) => (
|
|
954
|
+
<RenderEditActionButton
|
|
955
|
+
key={action.id}
|
|
956
|
+
action={action}
|
|
957
|
+
Button={Button}
|
|
958
|
+
Dialog={Dialog}
|
|
959
|
+
onError={setActionError}
|
|
960
|
+
/>
|
|
961
|
+
))}
|
|
956
962
|
{onCancel !== undefined && (
|
|
957
963
|
<Button
|
|
958
964
|
type="button"
|
|
959
|
-
variant="
|
|
965
|
+
variant="link"
|
|
966
|
+
icon="x"
|
|
960
967
|
onClick={() => onCancel()}
|
|
961
968
|
testId="render-edit-cancel"
|
|
962
969
|
>
|
|
963
970
|
{translate("kumiko.actions.cancel")}
|
|
964
971
|
</Button>
|
|
965
972
|
)}
|
|
973
|
+
</>
|
|
974
|
+
);
|
|
975
|
+
const formActions = (
|
|
976
|
+
<>
|
|
966
977
|
{isWizard && currentStep > 0 && (
|
|
967
978
|
<Button
|
|
968
979
|
type="button"
|
|
969
980
|
variant="secondary"
|
|
981
|
+
icon="arrow-left"
|
|
970
982
|
onClick={handleWizardBack}
|
|
971
983
|
testId="render-edit-wizard-back"
|
|
972
984
|
>
|
|
@@ -974,7 +986,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
974
986
|
</Button>
|
|
975
987
|
)}
|
|
976
988
|
{isWizard && !isLastWizardStep && (
|
|
977
|
-
<Button
|
|
989
|
+
<Button
|
|
990
|
+
type="submit"
|
|
991
|
+
variant="primary"
|
|
992
|
+
iconEnd="arrow-right"
|
|
993
|
+
testId="render-edit-wizard-next"
|
|
994
|
+
>
|
|
978
995
|
{translate("kumiko.actions.next")}
|
|
979
996
|
</Button>
|
|
980
997
|
)}
|
|
@@ -984,6 +1001,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
984
1001
|
disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
|
|
985
1002
|
loading={isSubmitting}
|
|
986
1003
|
variant="primary"
|
|
1004
|
+
icon="check"
|
|
987
1005
|
testId="render-edit-submit"
|
|
988
1006
|
>
|
|
989
1007
|
{translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
|
|
@@ -1022,6 +1040,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1022
1040
|
{...(hideSectionTitles !== true &&
|
|
1023
1041
|
formSubtitle !== undefined && { subtitle: formSubtitle })}
|
|
1024
1042
|
{...(hideActions !== true && { actions: formActions })}
|
|
1043
|
+
{...(hideActions !== true &&
|
|
1044
|
+
hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
|
|
1025
1045
|
testId="render-edit-form"
|
|
1026
1046
|
stickyActions={isWizard}
|
|
1027
1047
|
{...(screen.layout.width !== undefined && { width: screen.layout.width })}
|
|
@@ -61,11 +61,12 @@ export type RenderFieldProps = {
|
|
|
61
61
|
* `field.readOnly` field as plain text instead (projectionDetail's read
|
|
62
62
|
* view, fw#2245); editable fields are untouched by this prop either way. */
|
|
63
63
|
readonly valueDisplay?: "form" | "text";
|
|
64
|
-
/** Current form values, keyed by field name
|
|
64
|
+
/** Current form values, keyed by field name. Consulted when
|
|
65
65
|
* `field.renderer` resolves to a `{ react: { __component } }` registry
|
|
66
|
-
* component
|
|
67
|
-
* as list-column renderers, fw#2245)
|
|
68
|
-
*
|
|
66
|
+
* component (passed through as `ColumnRendererProps.row`, same contract
|
|
67
|
+
* as list-column renderers, fw#2245), and to resolve a `type: "number"`
|
|
68
|
+
* field's sibling-field `unit`. Omitted → falls back to a single-key
|
|
69
|
+
* `{ [field.field]: field.value }` row. */
|
|
69
70
|
readonly row?: Readonly<Record<string, unknown>>;
|
|
70
71
|
};
|
|
71
72
|
|
|
@@ -134,7 +135,7 @@ export function RenderField({
|
|
|
134
135
|
) : readOnlyText && !isComplexFieldType(field.type) ? (
|
|
135
136
|
<Text testId={`field-value-${field.field}`}>{readOnlyDisplayText(field, appLocale)}</Text>
|
|
136
137
|
) : (
|
|
137
|
-
renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t })
|
|
138
|
+
renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t, row })
|
|
138
139
|
);
|
|
139
140
|
|
|
140
141
|
return (
|
|
@@ -449,6 +450,7 @@ function renderInput({
|
|
|
449
450
|
Banner,
|
|
450
451
|
Text,
|
|
451
452
|
t,
|
|
453
|
+
row,
|
|
452
454
|
}: {
|
|
453
455
|
readonly field: EditFieldViewModel;
|
|
454
456
|
readonly id: string;
|
|
@@ -459,6 +461,7 @@ function renderInput({
|
|
|
459
461
|
readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
|
|
460
462
|
readonly Text: ReturnType<typeof usePrimitives>["Text"];
|
|
461
463
|
readonly t: ReturnType<typeof useTranslation>;
|
|
464
|
+
readonly row?: Readonly<Record<string, unknown>>;
|
|
462
465
|
}): ReactNode {
|
|
463
466
|
const common = {
|
|
464
467
|
id,
|
|
@@ -474,7 +477,8 @@ function renderInput({
|
|
|
474
477
|
// already emits `number | undefined`, so no extra coercion is needed
|
|
475
478
|
// beyond what "number" already does (#1925).
|
|
476
479
|
case "number":
|
|
477
|
-
case "bigInt":
|
|
480
|
+
case "bigInt": {
|
|
481
|
+
const unit = resolveNumberUnit(field.unit, row);
|
|
478
482
|
return (
|
|
479
483
|
<Input
|
|
480
484
|
kind="number"
|
|
@@ -482,8 +486,10 @@ function renderInput({
|
|
|
482
486
|
value={numberValue(field.value)}
|
|
483
487
|
onChange={(v) => onChange(v)}
|
|
484
488
|
{...(field.icon !== undefined && { icon: field.icon })}
|
|
489
|
+
{...(unit !== undefined && { unit })}
|
|
485
490
|
/>
|
|
486
491
|
);
|
|
492
|
+
}
|
|
487
493
|
case "decimal":
|
|
488
494
|
// step="any" disables the native stepMismatch constraint — without it
|
|
489
495
|
// <input type="number"> defaults to step=1 and blocks form submit on
|
|
@@ -721,6 +727,18 @@ function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined)
|
|
|
721
727
|
return fieldCurrency ?? "EUR";
|
|
722
728
|
}
|
|
723
729
|
|
|
730
|
+
// Static unit → used as-is. Sibling-field reference → read the live row
|
|
731
|
+
// value; missing/empty/non-string sibling means no suffix, never a guess.
|
|
732
|
+
function resolveNumberUnit(
|
|
733
|
+
unit: string | { readonly field: string } | undefined,
|
|
734
|
+
row: Readonly<Record<string, unknown>> | undefined,
|
|
735
|
+
): string | undefined {
|
|
736
|
+
if (unit === undefined) return undefined;
|
|
737
|
+
if (typeof unit === "string") return unit;
|
|
738
|
+
const sibling = row?.[unit.field];
|
|
739
|
+
return typeof sibling === "string" && sibling.length > 0 ? sibling : undefined;
|
|
740
|
+
}
|
|
741
|
+
|
|
724
742
|
function moneyMinorValue(v: unknown, currency: string): number | "" {
|
|
725
743
|
if (v === undefined || v === null || v === "") return "";
|
|
726
744
|
if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -62,6 +62,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
62
62
|
"kumiko.list.empty.hint": "Create the first one to get started.",
|
|
63
63
|
"kumiko.list.no-entries": "No entries.",
|
|
64
64
|
"kumiko.list.end-of-list": "— End of list —",
|
|
65
|
+
"kumiko.list.sort.label": "Sort",
|
|
66
|
+
"kumiko.list.sort.unsorted": "Unsorted",
|
|
65
67
|
|
|
66
68
|
"kumiko.pager.status": "{from}–{to} of {total}",
|
|
67
69
|
"kumiko.pager.previousPage": "Previous page",
|
package/src/primitives.tsx
CHANGED
|
@@ -35,7 +35,7 @@ import type {
|
|
|
35
35
|
ConfigScope,
|
|
36
36
|
ConfigValueSource,
|
|
37
37
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
38
|
-
import type { FieldIconKey, FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
38
|
+
import type { FieldIconKey, FormWidth, NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
39
39
|
import type {
|
|
40
40
|
FieldIssue,
|
|
41
41
|
ListColumnViewModel,
|
|
@@ -67,11 +67,12 @@ export type ButtonProps = {
|
|
|
67
67
|
* setzen wenn die Action blockiert bis das Loading abgeschlossen
|
|
68
68
|
* ist (verhindert Double-Submit). */
|
|
69
69
|
readonly loading?: boolean;
|
|
70
|
-
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
70
|
+
/** Semantic class — default="primary". Custom impls decide what this
|
|
71
|
+
* becomes visually; the renderers use "primary" for Save, "danger" for
|
|
72
|
+
* Delete, "secondary" for a Confirm state, "link" for inline actions in
|
|
73
|
+
* running text (no background, underline), "danger-ghost" for a
|
|
74
|
+
* destructive action as red text instead of a red fill. */
|
|
75
|
+
readonly variant?: "primary" | "secondary" | "danger" | "link" | "danger-ghost";
|
|
75
76
|
/** Größe — default="md". "sm" für kompakte Inline-Aktionen (Toolbar,
|
|
76
77
|
* Listen-Zeilen), "icon" für quadratische Icon-only-Buttons. */
|
|
77
78
|
readonly size?: "sm" | "md" | "icon";
|
|
@@ -91,6 +92,10 @@ export type ButtonProps = {
|
|
|
91
92
|
* a click (e.g. binding a drop-target handler). Web forwards it, native
|
|
92
93
|
* impls ignore it (no native equivalent). */
|
|
93
94
|
readonly ref?: Ref<HTMLButtonElement>;
|
|
95
|
+
/** Icon before the label. */
|
|
96
|
+
readonly icon?: NavIconKey;
|
|
97
|
+
/** Icon after the label (e.g. "Continue →"). */
|
|
98
|
+
readonly iconEnd?: NavIconKey;
|
|
94
99
|
};
|
|
95
100
|
|
|
96
101
|
/** Navigations-Link. `variant="button"` rendert die Button-Optik auf einem
|
|
@@ -227,6 +232,9 @@ export type InputProps =
|
|
|
227
232
|
/** `<input step>`. "any" disables the native stepMismatch constraint
|
|
228
233
|
* (needed for decimal fields — integer fields leave this unset). */
|
|
229
234
|
readonly step?: number | "any";
|
|
235
|
+
/** Resolved display suffix (static or from a sibling field) — never
|
|
236
|
+
* part of the numeric value. */
|
|
237
|
+
readonly unit?: string;
|
|
230
238
|
}
|
|
231
239
|
| {
|
|
232
240
|
readonly kind: "range";
|
|
@@ -696,6 +704,9 @@ export type FormProps = {
|
|
|
696
704
|
* your catalog") — gibt dem Form-Header Kontext statt nur ein Label. */
|
|
697
705
|
readonly subtitle?: ReactNode;
|
|
698
706
|
readonly actions?: ReactNode;
|
|
707
|
+
/** Secondary, record-related actions — rendered on the left on desktop,
|
|
708
|
+
* on their own row below the primary action on a narrow viewport. */
|
|
709
|
+
readonly secondaryActions?: ReactNode;
|
|
699
710
|
readonly testId?: string;
|
|
700
711
|
/** Max width of the form container. Default "full" — see FormWidth
|
|
701
712
|
* (`packages/types/src/screen.ts`, EditLayout.width). Native impls may
|