@cosmicdrift/kumiko-renderer 0.244.0 → 0.245.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.244.0",
3
+ "version": "0.245.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.244.0",
19
- "@cosmicdrift/kumiko-headless": "0.244.0",
18
+ "@cosmicdrift/kumiko-framework": "0.245.0",
19
+ "@cosmicdrift/kumiko-headless": "0.245.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.244.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.245.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -0,0 +1,134 @@
1
+ // SelectFieldDef.display travels the whole declarative chain (#2711):
2
+ // EntityDefinition → computeEditViewModel → RenderField → InputProps. Without
3
+ // it an app can only hope its labels fall under the renderer's heuristic.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type {
7
+ EntityDefinition,
8
+ EntityEditScreenDefinition,
9
+ } from "@cosmicdrift/kumiko-framework/ui-types";
10
+ import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
11
+ import { render } from "@testing-library/react";
12
+ import type { ComponentType, ReactNode } from "react";
13
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
14
+ import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
15
+ import { RenderField } from "../render-field";
16
+
17
+ let captured: InputProps | undefined;
18
+ const captureInput: ComponentType<InputProps> = (props) => {
19
+ captured = props;
20
+ return null;
21
+ };
22
+ const noop = (): ReactNode => null;
23
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
24
+
25
+ const testPrimitives: CorePrimitives = {
26
+ Button: noop,
27
+ Banner: noop,
28
+ Field: passChildren,
29
+ Input: captureInput,
30
+ DataTable: noop,
31
+ Form: noop,
32
+ Section: noop,
33
+ Card: noop,
34
+ Grid: noop,
35
+ GridCell: noop,
36
+ Text: noop,
37
+ Heading: noop,
38
+ Dialog: noop,
39
+ Modal: noop,
40
+ Lightbox: noop,
41
+ ConfigSourceBadge: noop,
42
+ ConfigCascadeView: noop,
43
+ Link: noop,
44
+ };
45
+
46
+ const AREAS = ["jobs", "mail", "search", "events", "tenants"] as const;
47
+
48
+ function buildEntity(display: "radio" | "dropdown" | undefined): EntityDefinition {
49
+ return {
50
+ fields: {
51
+ area: {
52
+ type: "select",
53
+ required: false,
54
+ options: AREAS,
55
+ ...(display !== undefined && { display }),
56
+ },
57
+ tags: {
58
+ type: "multiSelect",
59
+ required: false,
60
+ options: AREAS,
61
+ display: "checkboxes",
62
+ },
63
+ },
64
+ } as EntityDefinition;
65
+ }
66
+
67
+ const editScreen: EntityEditScreenDefinition = {
68
+ id: "ticket-edit",
69
+ type: "entityEdit",
70
+ entity: "ticket",
71
+ layout: { sections: [{ columns: 1, fields: ["area", "tags"] }] },
72
+ } as EntityEditScreenDefinition;
73
+
74
+ function viewModelField(entity: EntityDefinition, fieldName: string): EditFieldViewModel {
75
+ const vm = computeEditViewModel({
76
+ screen: editScreen,
77
+ entity,
78
+ values: { area: "jobs", tags: [] },
79
+ translate: (key) => key,
80
+ featureName: "helpdesk",
81
+ });
82
+ const section = vm.sections[0];
83
+ if (section === undefined || section.kind !== "fields") {
84
+ throw new Error("expected a fields section");
85
+ }
86
+ const field = section.fields.find((f) => f.field === fieldName);
87
+ if (field === undefined) throw new Error(`expected a ${fieldName} field`);
88
+ return field;
89
+ }
90
+
91
+ function renderField(field: EditFieldViewModel): InputProps {
92
+ captured = undefined;
93
+ render(
94
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
95
+ <PrimitivesProvider value={testPrimitives}>
96
+ <RenderField field={field} onChange={() => {}} />
97
+ </PrimitivesProvider>
98
+ </LocaleProvider>,
99
+ );
100
+ if (captured === undefined) throw new Error("field did not render an Input");
101
+ return captured;
102
+ }
103
+
104
+ describe("RenderField — select display request", () => {
105
+ test('display: "radio" on the field definition reaches the Input', () => {
106
+ const field = viewModelField(buildEntity("radio"), "area");
107
+ expect(field.display).toBe("radio");
108
+ const input = renderField(field);
109
+ expect(input.kind).toBe("select");
110
+ if (input.kind !== "select") return;
111
+ expect(input.display).toBe("radio");
112
+ });
113
+
114
+ test('display: "dropdown" on the field definition reaches the Input', () => {
115
+ const input = renderField(viewModelField(buildEntity("dropdown"), "area"));
116
+ expect(input.kind).toBe("select");
117
+ if (input.kind !== "select") return;
118
+ expect(input.display).toBe("dropdown");
119
+ });
120
+
121
+ test("no display on the field definition leaves the Input to its own default", () => {
122
+ const field = viewModelField(buildEntity(undefined), "area");
123
+ expect(field.display).toBeUndefined();
124
+ const input = renderField(field);
125
+ expect(input.kind).toBe("select");
126
+ if (input.kind !== "select") return;
127
+ expect(input.display).toBeUndefined();
128
+ });
129
+
130
+ test('multiSelect keeps its own "checkboxes" display untouched', () => {
131
+ const field = viewModelField(buildEntity("radio"), "tags");
132
+ expect(field.display).toBe("checkboxes");
133
+ });
134
+ });
@@ -689,6 +689,10 @@ function renderInput({
689
689
  labels !== undefined
690
690
  ? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
691
691
  : rawOptions;
692
+ // `display` is shared with multiSelect's "checkboxes", which is not a
693
+ // select presentation — narrow to the two a select field can request.
694
+ const display =
695
+ field.display === "radio" || field.display === "dropdown" ? field.display : undefined;
692
696
  return (
693
697
  <Input
694
698
  kind="select"
@@ -696,6 +700,7 @@ function renderInput({
696
700
  value={stringValue(field.value)}
697
701
  onChange={(v) => onChange(v)}
698
702
  options={selectOptions}
703
+ {...(display !== undefined && { display })}
699
704
  />
700
705
  );
701
706
  }
@@ -322,6 +322,12 @@ export type InputProps =
322
322
  readonly disabled?: boolean;
323
323
  readonly required?: boolean;
324
324
  readonly hasError?: boolean;
325
+ /** Requested presentation. `"radio"` renders the options as a visible
326
+ * radio group (WAI-ARIA radiogroup, one click per choice),
327
+ * `"dropdown"` renders the combobox. Omitted = the implementation
328
+ * decides; the web impl keeps its short-option-set heuristic. Custom
329
+ * impls may ignore it — it is a request, not a contract. */
330
+ readonly display?: "radio" | "dropdown";
325
331
  }
326
332
  | ({
327
333
  // Tier 2.1c: Combobox / Searchable-Select. Single-Mode (multiple