@cosmicdrift/kumiko-renderer-web 0.188.0 → 0.189.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-web",
3
- "version": "0.188.0",
3
+ "version": "0.189.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.188.0",
20
- "@cosmicdrift/kumiko-headless": "0.188.0",
21
- "@cosmicdrift/kumiko-renderer": "0.188.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.189.0",
20
+ "@cosmicdrift/kumiko-headless": "0.189.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.189.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -0,0 +1,166 @@
1
+ // Issue #1942: entityEdit had no post-save redirect (only actionForm did) —
2
+ // RenderEdit only exposed onSubmit/onCancel, unreachable from a declarative
3
+ // screen. This exercises the real create/update submit path through
4
+ // KumikoScreen → EntityEditCreateBody/EntityEditUpdateForm, not just the
5
+ // type/boot-validator layer, proving a successful save actually calls
6
+ // nav.navigate(redirect) instead of the default "back to list", and that
7
+ // omitting redirect keeps the existing list-navigation behavior.
8
+ import { describe, expect, test } from "bun:test";
9
+ import type {
10
+ EntityDefinition,
11
+ FeatureSchema,
12
+ ScreenDefinition,
13
+ } from "@cosmicdrift/kumiko-framework/ui-types";
14
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
15
+ import type { NavTarget } from "@cosmicdrift/kumiko-renderer";
16
+ import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
17
+ import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
18
+
19
+ const productEntity: EntityDefinition = {
20
+ fields: { name: { type: "text", required: false, searchable: false, sortable: false } },
21
+ };
22
+
23
+ function buildSchema(redirect?: string): FeatureSchema {
24
+ const editScreen: ScreenDefinition = {
25
+ id: "product-edit",
26
+ type: "entityEdit",
27
+ entity: "product",
28
+ layout: { sections: [{ fields: ["name"] }] },
29
+ ...(redirect !== undefined && { redirect }),
30
+ };
31
+ const listScreen: ScreenDefinition = {
32
+ id: "product-list",
33
+ type: "entityList",
34
+ entity: "product",
35
+ columns: ["name"],
36
+ };
37
+ return {
38
+ featureName: "shop",
39
+ entities: { product: productEntity },
40
+ screens: [editScreen, listScreen],
41
+ } as FeatureSchema;
42
+ }
43
+
44
+ function fillNameAndSubmit(): void {
45
+ const nameInput = screen.getByTestId("field-name").querySelector("input") as HTMLInputElement;
46
+ fireEvent.change(nameInput, { target: { value: "Widget" } });
47
+ const form = screen.getByTestId("render-edit-form");
48
+ fireEvent.submit(form);
49
+ }
50
+
51
+ describe("entityEdit redirect (#1942)", () => {
52
+ test("create: successful save with redirect set navigates there, not to the list", async () => {
53
+ const navigated: NavTarget[] = [];
54
+ render(
55
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
56
+ <NavProvider
57
+ value={{
58
+ route: { screenId: "shop:screen:product-edit" },
59
+ navigate: (target) => navigated.push(target),
60
+ replace: () => {},
61
+ hrefFor: () => "",
62
+ searchParams: {},
63
+ setSearchParams: () => {},
64
+ }}
65
+ >
66
+ <KumikoScreen schema={buildSchema("product-detail")} qn="shop:screen:product-edit" />
67
+ </NavProvider>
68
+ </DispatcherProvider>,
69
+ );
70
+
71
+ fillNameAndSubmit();
72
+
73
+ await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
74
+ });
75
+
76
+ test("create: no redirect set falls back to the entity's list screen", async () => {
77
+ const navigated: NavTarget[] = [];
78
+ render(
79
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
80
+ <NavProvider
81
+ value={{
82
+ route: { screenId: "shop:screen:product-edit" },
83
+ navigate: (target) => navigated.push(target),
84
+ replace: () => {},
85
+ hrefFor: () => "",
86
+ searchParams: {},
87
+ setSearchParams: () => {},
88
+ }}
89
+ >
90
+ <KumikoScreen schema={buildSchema()} qn="shop:screen:product-edit" />
91
+ </NavProvider>
92
+ </DispatcherProvider>,
93
+ );
94
+
95
+ fillNameAndSubmit();
96
+
97
+ await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
98
+ });
99
+
100
+ test("create: redirect as fully-qualified cross-feature QN navigates via its short id (#1946)", async () => {
101
+ // redirect may name a screen in ANOTHER feature via `<feature>:screen:<id>`
102
+ // (boot-validator accepts the QN directly) — the renderer must strip it
103
+ // down to the short id before calling nav.navigate, since the runtime
104
+ // router resolves bare short ids app-wide, not raw QNs (see nav.tsx).
105
+ const navigated: NavTarget[] = [];
106
+ render(
107
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
108
+ <NavProvider
109
+ value={{
110
+ route: { screenId: "shop:screen:product-edit" },
111
+ navigate: (target) => navigated.push(target),
112
+ replace: () => {},
113
+ hrefFor: () => "",
114
+ searchParams: {},
115
+ setSearchParams: () => {},
116
+ }}
117
+ >
118
+ <KumikoScreen
119
+ schema={buildSchema("statements:screen:statement-upload-list")}
120
+ qn="shop:screen:product-edit"
121
+ />
122
+ </NavProvider>
123
+ </DispatcherProvider>,
124
+ );
125
+
126
+ fillNameAndSubmit();
127
+
128
+ await waitFor(() => expect(navigated).toEqual([{ screenId: "statement-upload-list" }]));
129
+ });
130
+
131
+ test("update: successful save with redirect set navigates there, not to the list", async () => {
132
+ const navigated: NavTarget[] = [];
133
+ const dispatcher = createMockDispatcher({
134
+ query: (async () => ({
135
+ isSuccess: true,
136
+ data: { id: "42", version: 1, name: "Existing" },
137
+ })) as unknown as Dispatcher["query"],
138
+ });
139
+
140
+ render(
141
+ <DispatcherProvider dispatcher={dispatcher}>
142
+ <NavProvider
143
+ value={{
144
+ route: { screenId: "shop:screen:product-edit", entityId: "42" },
145
+ navigate: (target) => navigated.push(target),
146
+ replace: () => {},
147
+ hrefFor: () => "",
148
+ searchParams: {},
149
+ setSearchParams: () => {},
150
+ }}
151
+ >
152
+ <KumikoScreen
153
+ schema={buildSchema("product-detail")}
154
+ qn="shop:screen:product-edit"
155
+ entityId="42"
156
+ />
157
+ </NavProvider>
158
+ </DispatcherProvider>,
159
+ );
160
+
161
+ await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
162
+ fillNameAndSubmit();
163
+
164
+ await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
165
+ });
166
+ });
@@ -1404,6 +1404,50 @@ describe("KumikoScreen", () => {
1404
1404
  expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
1405
1405
  });
1406
1406
 
1407
+ test("actionForm mit Cross-Feature-QN als redirect: navigiert per Short-Id (#1946)", async () => {
1408
+ const navigateCalls: { screenId: string }[] = [];
1409
+ const dispatcher = makeDispatcher({
1410
+ write: (async () => ({
1411
+ isSuccess: true,
1412
+ data: { id: "x" },
1413
+ })) as unknown as Dispatcher["write"],
1414
+ });
1415
+ const memoryNav = {
1416
+ route: { screenId: "quick-add" },
1417
+ navigate: (target: { screenId: string }) => navigateCalls.push(target),
1418
+ replace: () => undefined,
1419
+ hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
1420
+ searchParams: {},
1421
+ setSearchParams: () => undefined,
1422
+ };
1423
+ const actionScreen: ActionFormScreenDefinition = {
1424
+ id: "quick-add",
1425
+ type: "actionForm",
1426
+ handler: "tasks:write:task:quick-add",
1427
+ fields: { title: { type: "text", required: true } },
1428
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1429
+ redirect: "statements:screen:statement-upload-list",
1430
+ };
1431
+
1432
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
1433
+ render(
1434
+ <NavProvider value={memoryNav}>
1435
+ <DispatcherProvider dispatcher={dispatcher}>
1436
+ <KumikoScreen
1437
+ schema={{ ...schema, screens: [actionScreen, listScreen] }}
1438
+ qn="tasks:screen:quick-add"
1439
+ />
1440
+ </DispatcherProvider>
1441
+ </NavProvider>,
1442
+ );
1443
+
1444
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1445
+ fireEvent.change(titleInput, { target: { value: "go" } });
1446
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1447
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
1448
+ expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list" });
1449
+ });
1450
+
1407
1451
  // cancelTarget (Bug-Bash 2026-06-07, Bug 9): redirect erzeugte
1408
1452
  // automatisch einen Abbrechen-Button mit demselben Ziel wie der
1409
1453
  // Submit-Redirect — auf Single-Action-Screens ("Test-Mail senden")
@@ -1814,6 +1858,106 @@ describe("KumikoScreen: update-only entityEdit (allowCreate/allowDelete)", () =>
1814
1858
  });
1815
1859
  });
1816
1860
 
1861
+ // --- singleton entityEdit (fw#1941) ---
1862
+ // Singleton entities (exactly one record per tenant) have no nav path
1863
+ // that supplies an entityId. `singleton: true` resolves the existing
1864
+ // record via list(limit:1) before deciding create vs update.
1865
+ describe("KumikoScreen: singleton entityEdit", () => {
1866
+ const singletonEdit: EntityEditScreenDefinition = {
1867
+ id: "task-edit",
1868
+ type: "entityEdit",
1869
+ entity: "task",
1870
+ singleton: true,
1871
+ layout: { sections: [{ title: "Basics", fields: ["title"] }] },
1872
+ };
1873
+ const singletonSchema: FeatureSchema = {
1874
+ featureName: "tasks",
1875
+ entities: { task: taskEntity },
1876
+ screens: [singletonEdit],
1877
+ };
1878
+
1879
+ test("kein vorhandener Record → rendert leeres Create-Form", async () => {
1880
+ const queryCalls: { type: string; payload: unknown }[] = [];
1881
+ const dispatcher = makeDispatcher({
1882
+ query: (async (type: string, payload: unknown) => {
1883
+ queryCalls.push({ type, payload });
1884
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
1885
+ }) as unknown as Dispatcher["query"],
1886
+ });
1887
+ render(
1888
+ <DispatcherProvider dispatcher={dispatcher}>
1889
+ <KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
1890
+ </DispatcherProvider>,
1891
+ );
1892
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
1893
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
1894
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1895
+ expect(titleInput.value).toBe("");
1896
+ expect(queryCalls).toEqual([{ type: "tasks:query:task:list", payload: { limit: 1 } }]);
1897
+ });
1898
+
1899
+ test("vorhandener Record → lädt ihn (Update-Form, prefilled) statt Create", async () => {
1900
+ const queryCalls: { type: string; payload: unknown }[] = [];
1901
+ const dispatcher = makeDispatcher({
1902
+ query: (async (type: string, payload: unknown) => {
1903
+ queryCalls.push({ type, payload });
1904
+ if (type.endsWith(":list")) {
1905
+ return {
1906
+ isSuccess: true,
1907
+ data: { rows: [{ id: "task-1", title: "loaded-title" }], nextCursor: null },
1908
+ };
1909
+ }
1910
+ return {
1911
+ isSuccess: true,
1912
+ data: { id: "task-1", version: 1, title: "loaded-title", count: 0, done: false },
1913
+ };
1914
+ }) as unknown as Dispatcher["query"],
1915
+ });
1916
+ render(
1917
+ <DispatcherProvider dispatcher={dispatcher}>
1918
+ <KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
1919
+ </DispatcherProvider>,
1920
+ );
1921
+ // Two sequential loading cycles (list, then detail once the singleton
1922
+ // wrapper hands off to EntityEditUpdateBody) — default waitFor timeout
1923
+ // can be too tight under CI's shared-process test load.
1924
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull(), {
1925
+ timeout: 5000,
1926
+ });
1927
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
1928
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1929
+ expect(titleInput.value).toBe("loaded-title");
1930
+ // Anchor: the id resolved from list(limit:1) reaches the detail query —
1931
+ // otherwise the form would render empty/create-mode instead of loading it.
1932
+ expect(queryCalls).toContainEqual({
1933
+ type: "tasks:query:task:detail",
1934
+ payload: { id: "task-1" },
1935
+ });
1936
+ });
1937
+
1938
+ test("allowCreate:false + leere Tabelle → Fehler-Banner statt Create-Form", async () => {
1939
+ const disabledSchema: FeatureSchema = {
1940
+ featureName: "tasks",
1941
+ entities: { task: taskEntity },
1942
+ screens: [{ ...singletonEdit, allowCreate: false }],
1943
+ };
1944
+ const dispatcher = makeDispatcher({
1945
+ query: (async () => ({
1946
+ isSuccess: true,
1947
+ data: { rows: [], nextCursor: null },
1948
+ })) as unknown as Dispatcher["query"],
1949
+ });
1950
+ render(
1951
+ <DispatcherProvider dispatcher={dispatcher}>
1952
+ <KumikoScreen schema={disabledSchema} qn="tasks:screen:task-edit" />
1953
+ </DispatcherProvider>,
1954
+ );
1955
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
1956
+ expect(screen.getByTestId("kumiko-screen-create-disabled")).toBeTruthy();
1957
+ expect(screen.queryByTestId("render-edit-form")).toBeNull();
1958
+ });
1959
+ });
1960
+
1817
1961
  // --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
1818
1962
  // actionForm hat keinen record — Extension-Sections bekommen stattdessen
1819
1963
  // die initialen Form-Values (inkl. searchParams-Prefill) als initialValues.
@@ -1040,6 +1040,29 @@ describe("Form", () => {
1040
1040
  );
1041
1041
  expect(screen.queryByTestId("form-actions")).toBeNull();
1042
1042
  });
1043
+
1044
+ test("stickyActions: Footer bekommt mobile-fixed-Klassen (fw#1918)", () => {
1045
+ render(
1046
+ <Form
1047
+ onSubmit={() => undefined}
1048
+ actions={<button type="submit">Next</button>}
1049
+ testId="form"
1050
+ stickyActions
1051
+ >
1052
+ <div>content</div>
1053
+ </Form>,
1054
+ );
1055
+ expect(screen.getByTestId("form-actions").className).toContain("max-sm:fixed");
1056
+ });
1057
+
1058
+ test("ohne stickyActions: Footer bleibt im normalen Dokumentfluss", () => {
1059
+ render(
1060
+ <Form onSubmit={() => undefined} actions={<button type="submit">Save</button>} testId="form">
1061
+ <div>content</div>
1062
+ </Form>,
1063
+ );
1064
+ expect(screen.getByTestId("form-actions").className).not.toContain("max-sm:fixed");
1065
+ });
1043
1066
  });
1044
1067
 
1045
1068
  describe("Banner padded", () => {
@@ -1303,6 +1303,143 @@ describe("RenderEdit wizard draft", () => {
1303
1303
  expect(screen.getByTestId("field-count")).toBeTruthy();
1304
1304
  expect(calls.some((c) => c.startsWith("form-draft:"))).toBe(false);
1305
1305
  });
1306
+
1307
+ // Issue #1914: patch() (controlled mode / extension sections) previously
1308
+ // never triggered a draft save — only handleWizardNext/Back did. A patch()
1309
+ // on the last step (no further Next click coming) or on a step abandoned
1310
+ // without Next/Back was silently lost, forcing e.g. a repeat of a paid
1311
+ // VIN-decode round-trip on resume (#1908).
1312
+ test("controls.patch() on the last step persists into the draft blob (debounced)", async () => {
1313
+ const { dispatcher, store } = makeDraftDispatcher();
1314
+ let controls: RenderEditControls<TestValues> | undefined;
1315
+
1316
+ render(
1317
+ <DispatcherProvider dispatcher={dispatcher}>
1318
+ <RenderEdit<TestValues>
1319
+ screen={makeDraftWizardScreen(true)}
1320
+ entity={orderEntity}
1321
+ featureName="orders"
1322
+ initial={{ title: "", count: 0 }}
1323
+ writeCommand="order:create"
1324
+ onControlsReady={(c) => {
1325
+ controls = c;
1326
+ }}
1327
+ />
1328
+ </DispatcherProvider>,
1329
+ );
1330
+
1331
+ // Reach the last step first (handleWizardNext's own saveDraft mints the
1332
+ // draftId) — the bug is specifically about a patch() AFTER that, with no
1333
+ // further Next/Back to piggyback a save on.
1334
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1335
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1336
+
1337
+ act(() => {
1338
+ controls?.patch({ count: 7 });
1339
+ });
1340
+
1341
+ await waitFor(
1342
+ () => {
1343
+ const values = store.current?.values as { count?: number } | undefined;
1344
+ expect(values?.count).toBe(7);
1345
+ },
1346
+ { timeout: 3000 },
1347
+ );
1348
+ });
1349
+
1350
+ test("remount after patch() without a Next/Back click shows the patched value", async () => {
1351
+ const { dispatcher, store } = makeDraftDispatcher();
1352
+ const draftStorage = createFakeDraftStorage();
1353
+ let controls: RenderEditControls<TestValues> | undefined;
1354
+
1355
+ const first = render(
1356
+ <DispatcherProvider dispatcher={dispatcher}>
1357
+ <DraftStorageProvider value={draftStorage}>
1358
+ <RenderEdit<TestValues>
1359
+ screen={makeDraftWizardScreen(true)}
1360
+ entity={orderEntity}
1361
+ featureName="orders"
1362
+ initial={{ title: "", count: 0 }}
1363
+ writeCommand="order:create"
1364
+ onControlsReady={(c) => {
1365
+ controls = c;
1366
+ }}
1367
+ />
1368
+ </DraftStorageProvider>
1369
+ </DispatcherProvider>,
1370
+ );
1371
+
1372
+ // No Next/Back click anywhere in this test — patch() alone, on the
1373
+ // first step, must still mint a draftId and persist (proves there is
1374
+ // no second, blob-bypassing write path for patch()'d data either: this
1375
+ // writes exclusively via patch(), the remount below reads exclusively
1376
+ // via form-draft:query:get).
1377
+ act(() => {
1378
+ controls?.patch({ title: "Patched" });
1379
+ });
1380
+
1381
+ await waitFor(() => expect(store.current).not.toBeNull(), { timeout: 3000 });
1382
+
1383
+ first.unmount();
1384
+
1385
+ render(
1386
+ <DispatcherProvider dispatcher={dispatcher}>
1387
+ <DraftStorageProvider value={draftStorage}>
1388
+ <RenderEdit<TestValues>
1389
+ screen={makeDraftWizardScreen(true)}
1390
+ entity={orderEntity}
1391
+ featureName="orders"
1392
+ initial={{ title: "", count: 0 }}
1393
+ writeCommand="order:create"
1394
+ />
1395
+ </DraftStorageProvider>
1396
+ </DispatcherProvider>,
1397
+ );
1398
+
1399
+ await waitFor(
1400
+ () => {
1401
+ const titleInput = screen
1402
+ .getByTestId("field-title")
1403
+ .querySelector("input") as HTMLInputElement;
1404
+ expect(titleInput.value).toBe("Patched");
1405
+ },
1406
+ { timeout: 3000 },
1407
+ );
1408
+ });
1409
+
1410
+ test("a burst of patch() calls collapses into a single debounced draft save", async () => {
1411
+ const { dispatcher, calls } = makeDraftDispatcher();
1412
+ let controls: RenderEditControls<TestValues> | undefined;
1413
+
1414
+ render(
1415
+ <DispatcherProvider dispatcher={dispatcher}>
1416
+ <RenderEdit<TestValues>
1417
+ screen={makeDraftWizardScreen(true)}
1418
+ entity={orderEntity}
1419
+ featureName="orders"
1420
+ initial={{ title: "", count: 0 }}
1421
+ writeCommand="order:create"
1422
+ onControlsReady={(c) => {
1423
+ controls = c;
1424
+ }}
1425
+ />
1426
+ </DispatcherProvider>,
1427
+ );
1428
+
1429
+ const savesSoFar = () => calls.filter((c) => c === "form-draft:write:save").length;
1430
+ const before = savesSoFar();
1431
+
1432
+ // Each patch() resets the same debounce timer — three patches in one
1433
+ // burst must still land as exactly one form-draft:write:save, not three.
1434
+ act(() => {
1435
+ controls?.patch({ count: 1 });
1436
+ controls?.patch({ count: 2 });
1437
+ controls?.patch({ count: 3 });
1438
+ });
1439
+
1440
+ await waitFor(() => expect(savesSoFar()).toBe(before + 1), { timeout: 3000 });
1441
+ expect(savesSoFar()).toBe(before + 1);
1442
+ });
1306
1443
  });
1307
1444
 
1308
1445
  describe("RenderEdit create-mode draftId (issue #1913)", () => {
@@ -100,6 +100,7 @@ import { DefaultModal } from "./modal";
100
100
  import { formatMoney, MoneyInput } from "./money-input";
101
101
  import { TimestampInput } from "./timestamp-input";
102
102
  import { useToast } from "./toast";
103
+ import { TzInput } from "./tz-input";
103
104
 
104
105
  // ---- Card-Chrome (eine Definition für Form/Section/Card) ----
105
106
 
@@ -555,6 +556,18 @@ function DefaultInput(props: InputProps): ReactNode {
555
556
  className="resize-y"
556
557
  />
557
558
  );
559
+ case "tz":
560
+ return (
561
+ <TzInput
562
+ id={props.id}
563
+ name={props.name}
564
+ value={props.value}
565
+ onChange={props.onChange}
566
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
567
+ {...(props.required !== undefined && { required: props.required })}
568
+ {...(props.hasError !== undefined && { hasError: props.hasError })}
569
+ />
570
+ );
558
571
  }
559
572
  }
560
573
 
@@ -1558,6 +1571,7 @@ function DefaultForm({
1558
1571
  actions,
1559
1572
  testId,
1560
1573
  width,
1574
+ stickyActions,
1561
1575
  }: FormProps): ReactNode {
1562
1576
  // Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
1563
1577
  // der Container trägt Card/Titel selbst, sonst Card-in-Card.
@@ -1626,6 +1640,9 @@ function DefaultForm({
1626
1640
  "[&>section:not(:first-child)]:border-t",
1627
1641
  "[&>:not(section)]:px-6 [&>:not(section)]:py-3",
1628
1642
  "[&>:not(section):first-child]:pt-6 [&>:not(section):last-child]:pb-6",
1643
+ // ponytail: fixed footer height is a guess (button row + safe-area) —
1644
+ // widen if a wizard step's last field ever renders visibly clipped.
1645
+ stickyActions === true && "max-sm:pb-24",
1629
1646
  )}
1630
1647
  >
1631
1648
  <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
@@ -1633,7 +1650,17 @@ function DefaultForm({
1633
1650
  {actions !== undefined && (
1634
1651
  <div
1635
1652
  data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1636
- className={cn(cardFooter, cardFooterBorder)}
1653
+ className={cn(
1654
+ cardFooter,
1655
+ cardFooterBorder,
1656
+ // Below sm (640px): pin to the viewport bottom instead of normal
1657
+ // flow, so a virtual keyboard shrinking the viewport can't push
1658
+ // this out of reach (fw#1918). `fixed` escapes the card's
1659
+ // `overflow-hidden` (only transform/filter/contain ancestors trap
1660
+ // it, confirmed against AppLayout/SidebarInset — neither sets those).
1661
+ stickyActions === true &&
1662
+ "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-[max(1rem,env(safe-area-inset-bottom))]",
1663
+ )}
1637
1664
  >
1638
1665
  {actions}
1639
1666
  </div>
@@ -9,27 +9,7 @@ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
9
9
  import type { ReactNode } from "react";
10
10
  import { ComboboxInput } from "./combobox";
11
11
  import { TimestampInput } from "./timestamp-input";
12
-
13
- // Kuratierte Notliste falls die Runtime Intl.supportedValuesOf nicht kennt
14
- // (vor ES2022). Reicht für die häufigsten Zonen; moderne Browser + Bun liefern
15
- // die volle Liste.
16
- const FALLBACK_ZONES: readonly string[] = [
17
- "UTC",
18
- "Europe/Berlin",
19
- "Europe/London",
20
- "Europe/Paris",
21
- "Europe/Madrid",
22
- "America/New_York",
23
- "America/Los_Angeles",
24
- "America/Sao_Paulo",
25
- "Asia/Tokyo",
26
- "Asia/Singapore",
27
- "Australia/Sydney",
28
- ];
29
-
30
- const TZ_OPTIONS: readonly { readonly value: string; readonly label: string }[] = (
31
- typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : FALLBACK_ZONES
32
- ).map((zone) => ({ value: zone, label: zone }));
12
+ import { TZ_OPTIONS } from "./tz-options";
33
13
 
34
14
  export type LocatedTimestampValue = {
35
15
  readonly at: string;
@@ -0,0 +1,43 @@
1
+ // TzInput (kind:"tz") — standalone IANA-zone picker for `tz`-typed fields
2
+ // (#1925). Same searchable-combobox UX as the zone half of
3
+ // LocatedTimestampInput, shared TZ_OPTIONS source.
4
+
5
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
6
+ import type { ReactNode } from "react";
7
+ import { ComboboxInput } from "./combobox";
8
+ import { TZ_OPTIONS } from "./tz-options";
9
+
10
+ export type TzInputProps = {
11
+ readonly id: string;
12
+ readonly name: string;
13
+ readonly value: string;
14
+ readonly onChange: (v: string | undefined) => void;
15
+ readonly disabled?: boolean;
16
+ readonly required?: boolean;
17
+ readonly hasError?: boolean;
18
+ };
19
+
20
+ export function TzInput({
21
+ id,
22
+ name,
23
+ value,
24
+ onChange,
25
+ disabled,
26
+ required,
27
+ hasError,
28
+ }: TzInputProps): ReactNode {
29
+ const t = useTranslation();
30
+ return (
31
+ <ComboboxInput
32
+ id={id}
33
+ name={name}
34
+ options={TZ_OPTIONS}
35
+ value={value}
36
+ onChange={(v) => onChange(v === "" ? undefined : v)}
37
+ placeholder={t("kumiko.field.timezone")}
38
+ {...(disabled !== undefined && { disabled })}
39
+ {...(required !== undefined && { required })}
40
+ {...(hasError !== undefined && { hasError })}
41
+ />
42
+ );
43
+ }
@@ -0,0 +1,20 @@
1
+ // Curated fallback list for runtimes without Intl.supportedValuesOf (pre
2
+ // ES2022). Covers the most common zones; modern browsers + Bun return the
3
+ // full IANA list.
4
+ const FALLBACK_ZONES: readonly string[] = [
5
+ "UTC",
6
+ "Europe/Berlin",
7
+ "Europe/London",
8
+ "Europe/Paris",
9
+ "Europe/Madrid",
10
+ "America/New_York",
11
+ "America/Los_Angeles",
12
+ "America/Sao_Paulo",
13
+ "Asia/Tokyo",
14
+ "Asia/Singapore",
15
+ "Australia/Sydney",
16
+ ];
17
+
18
+ export const TZ_OPTIONS: readonly { readonly value: string; readonly label: string }[] = (
19
+ typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : FALLBACK_ZONES
20
+ ).map((zone) => ({ value: zone, label: zone }));