@cosmicdrift/kumiko-renderer 0.222.0 → 0.224.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.224.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.224.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.224.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.224.0"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// display: "checkboxes" on a multiSelect field renders a checkbox grid
|
|
2
|
+
// (MultiSelectCheckboxes) instead of the combobox dropdown. Style follows
|
|
3
|
+
// render-field-app-locale.test.tsx: mount RenderField under real Locale +
|
|
4
|
+
// Primitives providers with capturing stubs, then invoke the captured
|
|
5
|
+
// callbacks the way a real primitive implementation would.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
9
|
+
import { render } from "@testing-library/react";
|
|
10
|
+
import type { ComponentType, ReactNode } from "react";
|
|
11
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
12
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
13
|
+
import {
|
|
14
|
+
type ButtonProps,
|
|
15
|
+
type CorePrimitives,
|
|
16
|
+
type GridProps,
|
|
17
|
+
type InputProps,
|
|
18
|
+
PrimitivesProvider,
|
|
19
|
+
} from "../../primitives";
|
|
20
|
+
import { RenderField } from "../render-field";
|
|
21
|
+
|
|
22
|
+
let capturedInputs: InputProps[] = [];
|
|
23
|
+
let capturedButton: ButtonProps | undefined;
|
|
24
|
+
let capturedGrid: GridProps | undefined;
|
|
25
|
+
|
|
26
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
27
|
+
capturedInputs.push(props);
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
const captureButton: ComponentType<ButtonProps> = (props) => {
|
|
31
|
+
capturedButton = props;
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
const captureGrid: ComponentType<GridProps> = (props) => {
|
|
35
|
+
capturedGrid = props;
|
|
36
|
+
return <>{props.children}</>;
|
|
37
|
+
};
|
|
38
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
39
|
+
const noop = (): ReactNode => null;
|
|
40
|
+
|
|
41
|
+
const testPrimitives: CorePrimitives = {
|
|
42
|
+
Button: captureButton,
|
|
43
|
+
Banner: noop,
|
|
44
|
+
Field: passChildren,
|
|
45
|
+
Input: captureInput,
|
|
46
|
+
DataTable: noop,
|
|
47
|
+
Form: noop,
|
|
48
|
+
Section: noop,
|
|
49
|
+
Card: noop,
|
|
50
|
+
Grid: captureGrid,
|
|
51
|
+
GridCell: passChildren,
|
|
52
|
+
Text: noop,
|
|
53
|
+
Heading: noop,
|
|
54
|
+
Dialog: noop,
|
|
55
|
+
Modal: noop,
|
|
56
|
+
Lightbox: noop,
|
|
57
|
+
ConfigSourceBadge: noop,
|
|
58
|
+
ConfigCascadeView: noop,
|
|
59
|
+
Link: noop,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function languagesField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
|
|
63
|
+
return {
|
|
64
|
+
field: "languages",
|
|
65
|
+
label: "Languages",
|
|
66
|
+
type: "multiSelect",
|
|
67
|
+
value: [],
|
|
68
|
+
visible: true,
|
|
69
|
+
readOnly: false,
|
|
70
|
+
required: false,
|
|
71
|
+
options: ["en", "de", "es"],
|
|
72
|
+
optionLabels: { en: "English", de: "German", es: "Spanish" },
|
|
73
|
+
display: "checkboxes",
|
|
74
|
+
...overrides,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let lastOnChange: unknown;
|
|
79
|
+
function renderField(field: EditFieldViewModel): void {
|
|
80
|
+
capturedInputs = [];
|
|
81
|
+
capturedButton = undefined;
|
|
82
|
+
capturedGrid = undefined;
|
|
83
|
+
lastOnChange = undefined;
|
|
84
|
+
render(
|
|
85
|
+
<LocaleProvider
|
|
86
|
+
resolver={createStaticLocaleResolver()}
|
|
87
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
88
|
+
>
|
|
89
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
90
|
+
<RenderField field={field} onChange={(v) => (lastOnChange = v)} />
|
|
91
|
+
</PrimitivesProvider>
|
|
92
|
+
</LocaleProvider>,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("RenderField — multiSelect display: checkboxes", () => {
|
|
97
|
+
test("renders one boolean checkbox per option, no combobox", () => {
|
|
98
|
+
renderField(languagesField());
|
|
99
|
+
expect(capturedInputs).toHaveLength(3);
|
|
100
|
+
for (const input of capturedInputs) {
|
|
101
|
+
expect(input.kind).toBe("boolean");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("without display, the field still renders the combobox (back-compat)", () => {
|
|
106
|
+
renderField(languagesField({ display: undefined }));
|
|
107
|
+
expect(capturedInputs).toHaveLength(1);
|
|
108
|
+
expect(capturedInputs[0]?.kind).toBe("combobox");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("clicking one checkbox emits the full array including previously-set values, in options order", () => {
|
|
112
|
+
renderField(languagesField({ value: ["en"] }));
|
|
113
|
+
const deInput = capturedInputs.find(
|
|
114
|
+
(i) => i.kind === "boolean" && i.id === "kumiko-edit-languages-de",
|
|
115
|
+
);
|
|
116
|
+
expect(deInput?.kind).toBe("boolean");
|
|
117
|
+
if (deInput?.kind === "boolean") deInput.onChange(true);
|
|
118
|
+
expect(lastOnChange).toEqual(["en", "de"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("unchecking a checkbox drops only that value, keeping options order", () => {
|
|
122
|
+
renderField(languagesField({ value: ["en", "de", "es"] }));
|
|
123
|
+
const deInput = capturedInputs.find(
|
|
124
|
+
(i) => i.kind === "boolean" && i.id === "kumiko-edit-languages-de",
|
|
125
|
+
);
|
|
126
|
+
if (deInput?.kind === "boolean") deInput.onChange(false);
|
|
127
|
+
expect(lastOnChange).toEqual(["en", "es"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("select-all toggle shows 'Select all' when nothing is selected, and selects everything in options order", () => {
|
|
131
|
+
renderField(languagesField({ value: [] }));
|
|
132
|
+
expect(capturedButton?.children).toBe("Select all");
|
|
133
|
+
capturedButton?.onClick?.();
|
|
134
|
+
expect(lastOnChange).toEqual(["en", "de", "es"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("select-all toggle flips to 'Deselect all' once everything is selected, and clears on click", () => {
|
|
138
|
+
renderField(languagesField({ value: ["en", "de", "es"] }));
|
|
139
|
+
expect(capturedButton?.children).toBe("Deselect all");
|
|
140
|
+
capturedButton?.onClick?.();
|
|
141
|
+
expect(lastOnChange).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("disabled (readOnly) disables every checkbox and the select-all toggle", () => {
|
|
145
|
+
renderField(languagesField({ readOnly: true }));
|
|
146
|
+
expect(capturedButton?.disabled).toBe(true);
|
|
147
|
+
for (const input of capturedInputs) {
|
|
148
|
+
expect(input.disabled).toBe(true);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("columns and maxRows pass through to the Grid primitive", () => {
|
|
153
|
+
renderField(languagesField({ columns: 2, maxRows: 3 }));
|
|
154
|
+
expect(capturedGrid?.columns).toBe(2);
|
|
155
|
+
expect(capturedGrid?.maxRows).toBe(3);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("omitting columns/maxRows keeps the default layout (no maxRows on the Grid)", () => {
|
|
159
|
+
renderField(languagesField());
|
|
160
|
+
expect(capturedGrid?.maxRows).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { useTranslation } from "../i18n";
|
|
4
|
+
import { usePrimitives } from "../primitives";
|
|
5
|
+
|
|
6
|
+
export type MultiSelectCheckboxOption = {
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly label: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type MultiSelectCheckboxesProps = {
|
|
12
|
+
readonly field: EditFieldViewModel;
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly options: readonly MultiSelectCheckboxOption[];
|
|
15
|
+
readonly value: readonly string[];
|
|
16
|
+
readonly onChange: (value: readonly string[]) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Renders MultiSelectFieldDef's `display: "checkboxes"` mode — every option
|
|
20
|
+
// as a visible checkbox plus a select-all toggle, built purely from the
|
|
21
|
+
// platform-neutral Primitives contract (Grid/GridCell/Field/Input kind
|
|
22
|
+
// "boolean", the same primitive `case "boolean"` uses in render-field.tsx).
|
|
23
|
+
// No renderer-web import here — this file must stay usable from Expo too.
|
|
24
|
+
export function MultiSelectCheckboxes({
|
|
25
|
+
field,
|
|
26
|
+
id,
|
|
27
|
+
options,
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
}: MultiSelectCheckboxesProps): ReactNode {
|
|
31
|
+
const { Grid, GridCell, Field, Input, Button } = usePrimitives();
|
|
32
|
+
const t = useTranslation();
|
|
33
|
+
const disabled = field.readOnly;
|
|
34
|
+
const selected = new Set(value);
|
|
35
|
+
const allSelected = options.length > 0 && options.every((opt) => selected.has(opt.value));
|
|
36
|
+
|
|
37
|
+
// Always emits in `field.options` order (MultiSelectFieldDef's documented
|
|
38
|
+
// ordering guarantee), never in click/selection order.
|
|
39
|
+
const emitOrdered = (next: ReadonlySet<string>): void => {
|
|
40
|
+
onChange(options.filter((opt) => next.has(opt.value)).map((opt) => opt.value));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const toggleOption = (optionValue: string, checked: boolean): void => {
|
|
44
|
+
const next = new Set(selected);
|
|
45
|
+
if (checked) {
|
|
46
|
+
next.add(optionValue);
|
|
47
|
+
} else {
|
|
48
|
+
next.delete(optionValue);
|
|
49
|
+
}
|
|
50
|
+
emitOrdered(next);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const toggleAll = (): void => {
|
|
54
|
+
emitOrdered(allSelected ? new Set() : new Set(options.map((opt) => opt.value)));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="secondary"
|
|
62
|
+
size="sm"
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
onClick={toggleAll}
|
|
65
|
+
testId={`${id}-select-all`}
|
|
66
|
+
>
|
|
67
|
+
{allSelected
|
|
68
|
+
? t("kumiko.field.multiSelect.deselect-all")
|
|
69
|
+
: t("kumiko.field.multiSelect.select-all")}
|
|
70
|
+
</Button>
|
|
71
|
+
<Grid
|
|
72
|
+
columns={field.columns ?? 2}
|
|
73
|
+
testId={`${id}-checkboxes`}
|
|
74
|
+
{...(field.maxRows !== undefined && { maxRows: field.maxRows })}
|
|
75
|
+
>
|
|
76
|
+
{options.map((opt) => (
|
|
77
|
+
<GridCell key={opt.value}>
|
|
78
|
+
<Field id={`${id}-${opt.value}`} label={opt.label} layout="inline">
|
|
79
|
+
<Input
|
|
80
|
+
kind="boolean"
|
|
81
|
+
id={`${id}-${opt.value}`}
|
|
82
|
+
name={`${id}-${opt.value}`}
|
|
83
|
+
value={selected.has(opt.value)}
|
|
84
|
+
onChange={(checked) => toggleOption(opt.value, checked)}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
/>
|
|
87
|
+
</Field>
|
|
88
|
+
</GridCell>
|
|
89
|
+
))}
|
|
90
|
+
</Grid>
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -21,6 +21,7 @@ import { useQuery } from "../hooks/use-query";
|
|
|
21
21
|
import { useLocale, useTranslation } from "../i18n";
|
|
22
22
|
import { usePrimitives } from "../primitives";
|
|
23
23
|
import { EmbeddedListField } from "./embedded-list-field";
|
|
24
|
+
import { MultiSelectCheckboxes } from "./multi-select-checkboxes";
|
|
24
25
|
import { ReferenceCreateDialog } from "./reference-create-dialog";
|
|
25
26
|
|
|
26
27
|
// RenderField übersetzt ein EditFieldViewModel → Primitives-Baum.
|
|
@@ -514,6 +515,17 @@ function renderInput({
|
|
|
514
515
|
? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
|
|
515
516
|
: rawOptions.map((value: string) => ({ value, label: value }));
|
|
516
517
|
const arrayValue = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
|
|
518
|
+
if (field.display === "checkboxes") {
|
|
519
|
+
return (
|
|
520
|
+
<MultiSelectCheckboxes
|
|
521
|
+
field={field}
|
|
522
|
+
id={id}
|
|
523
|
+
options={multiSelectOptions}
|
|
524
|
+
value={arrayValue}
|
|
525
|
+
onChange={(v) => onChange(v)}
|
|
526
|
+
/>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
517
529
|
return (
|
|
518
530
|
<Input
|
|
519
531
|
kind="combobox"
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -54,6 +54,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
54
54
|
"{count} pasted row(s) were dropped (max row count reached).",
|
|
55
55
|
"kumiko.field.embedded-list.paste-cells-unmatched":
|
|
56
56
|
"{count} cell(s) had no matching option and were left unchanged.",
|
|
57
|
+
"kumiko.field.multiSelect.select-all": "Select all",
|
|
58
|
+
"kumiko.field.multiSelect.deselect-all": "Deselect all",
|
|
57
59
|
|
|
58
60
|
"kumiko.list.search-placeholder": "Search…",
|
|
59
61
|
"kumiko.list.empty.title": "No entries yet.",
|
package/src/primitives.tsx
CHANGED
|
@@ -739,6 +739,9 @@ export type GridProps = {
|
|
|
739
739
|
readonly columns: number;
|
|
740
740
|
readonly children: ReactNode;
|
|
741
741
|
readonly testId?: string;
|
|
742
|
+
/** Rows visible before the grid becomes vertically scrollable. Omitted =
|
|
743
|
+
* the grid grows with its content and never scrolls. */
|
|
744
|
+
readonly maxRows?: number;
|
|
742
745
|
};
|
|
743
746
|
|
|
744
747
|
/** Span-Wrapper für ein Kind innerhalb eines Grid. Web: `style={{gridColumn: span N}}`,
|