@cosmicdrift/kumiko-renderer-web 0.93.0 → 0.95.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.93.0",
3
+ "version": "0.95.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.93.0",
20
- "@cosmicdrift/kumiko-headless": "0.93.0",
21
- "@cosmicdrift/kumiko-renderer": "0.93.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.95.0",
20
+ "@cosmicdrift/kumiko-headless": "0.95.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.95.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,96 @@
1
+ // LocatedTimestampInput (kind:"locatedTimestamp") — Wall-Clock + IANA-Zone.
2
+ // Pinnt das emittierte `at` gegen das ECHTE Server-Schema (die
3
+ // locatedTimestamp-Write-Union aus schema-builder.ts nutzt für `at`
4
+ // z.iso.datetime({ local: true })). Ein located Timestamp ist reine
5
+ // Wall-Clock — KEINE UTC-Konvertierung im UI.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { fireEvent } from "@testing-library/react";
9
+ import { useState } from "react";
10
+ import { z } from "zod";
11
+ import { defaultPrimitives } from "../primitives";
12
+ import type { LocatedTimestampValue } from "../primitives/located-timestamp-input";
13
+ import { render } from "./test-utils";
14
+
15
+ const atSchema = z.iso.datetime({ local: true });
16
+
17
+ function ControlledLocated({
18
+ initial,
19
+ onEmit,
20
+ }: {
21
+ readonly initial: LocatedTimestampValue | "";
22
+ readonly onEmit: (v: { at: string; tz: string } | undefined) => void;
23
+ }) {
24
+ const { Input } = defaultPrimitives;
25
+ const [v, setV] = useState<LocatedTimestampValue | "">(initial);
26
+ return (
27
+ <Input
28
+ kind="locatedTimestamp"
29
+ id="lt"
30
+ name="lt"
31
+ value={v}
32
+ onChange={(nv) => {
33
+ onEmit(nv);
34
+ setV(nv ?? "");
35
+ }}
36
+ />
37
+ );
38
+ }
39
+
40
+ function inputs(container: Element): { date: HTMLInputElement; time: HTMLInputElement } {
41
+ const date = container.querySelector<HTMLInputElement>('input:not([type="time"])');
42
+ const time = container.querySelector<HTMLInputElement>('input[type="time"]');
43
+ if (!date || !time) throw new Error("expected date + time input");
44
+ return { date, time };
45
+ }
46
+
47
+ describe("Input kind=locatedTimestamp", () => {
48
+ test("Datum + Uhrzeit tippen → { at: Wall-Clock, tz }, passend zum local-Schema", () => {
49
+ let last: { at: string; tz: string } | undefined;
50
+ const view = render(
51
+ <ControlledLocated initial={{ at: "", tz: "Europe/Lisbon" }} onEmit={(v) => (last = v)} />,
52
+ );
53
+ const { date, time } = inputs(view.container);
54
+
55
+ fireEvent.change(date, { target: { value: "2026-04-03" } });
56
+ fireEvent.change(time, { target: { value: "10:00" } });
57
+
58
+ if (last === undefined) throw new Error("expected emitted value");
59
+ expect(last.tz).toBe("Europe/Lisbon");
60
+ expect(last.at).toBe("2026-04-03T10:00");
61
+ expect(atSchema.safeParse(last.at).success).toBe(true);
62
+ // Located ist reine Wall-Clock — kein Offset/Z.
63
+ expect(last.at).not.toContain("Z");
64
+ expect(last.at).not.toContain("+");
65
+ });
66
+
67
+ test("READ-Wert mit Sekunden wird angezeigt + offset-frei re-emittiert", () => {
68
+ let last: { at: string; tz: string } | undefined;
69
+ const view = render(
70
+ <ControlledLocated
71
+ initial={{ at: "2026-04-03T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-03T09:00:00Z" }}
72
+ onEmit={(v) => (last = v)}
73
+ />,
74
+ );
75
+ const { time } = inputs(view.container);
76
+ expect(time.value).toBe("10:00");
77
+
78
+ fireEvent.change(time, { target: { value: "11:30" } });
79
+ if (last === undefined) throw new Error("expected emitted value");
80
+ expect(last.at).toBe("2026-04-03T11:30");
81
+ expect(last.tz).toBe("Europe/Lisbon");
82
+ expect(atSchema.safeParse(last.at).success).toBe(true);
83
+ });
84
+
85
+ test("leeres Datum + Uhrzeit ohne Zone → undefined (Feld leer)", () => {
86
+ let last: { at: string; tz: string } | undefined | "sentinel" = "sentinel";
87
+ render(<ControlledLocated initial="" onEmit={(v) => (last = v)} />);
88
+ // Kein Input → noch kein onChange. Sanity: Startwert blieb Sentinel.
89
+ expect(last).toBe("sentinel");
90
+ });
91
+
92
+ test("sichtbarer Zonen-Hinweis", () => {
93
+ const view = render(<ControlledLocated initial="" onEmit={() => {}} />);
94
+ expect(view.container.textContent ?? "").toMatch(/lokal|local/i);
95
+ });
96
+ });
@@ -85,6 +85,47 @@ describe("RenderEdit", () => {
85
85
  expect(screen.queryByTestId("field-notes")).toBeNull();
86
86
  });
87
87
 
88
+ // End-to-end-Routing: ein `type:"locatedTimestamp"`-Entity-Feld muss durch
89
+ // computeEditViewModel → render-field → DefaultInput auf den Located-Picker
90
+ // laufen (Datum + Uhrzeit + Zone), NICHT auf den Klartext-Fallthrough. Vor
91
+ // Item 10 fehlte der `case "locatedTimestamp"` → das Feld rendrte als Text.
92
+ test("locatedTimestamp field renders the located picker (date + time + zone), not plain text", () => {
93
+ const entity = {
94
+ fields: { pickup: { type: "locatedTimestamp", required: true } },
95
+ } as unknown as EntityDefinition;
96
+ const screenDef: EntityEditScreenDefinition = {
97
+ id: "orders:screen:order-edit",
98
+ type: "entityEdit",
99
+ entity: "order",
100
+ layout: { sections: [{ title: "When", columns: 1, fields: ["pickup"] }] },
101
+ };
102
+ render(
103
+ <DispatcherProvider dispatcher={makeDispatcher()}>
104
+ <RenderEdit
105
+ screen={screenDef}
106
+ entity={entity}
107
+ featureName="orders"
108
+ initial={
109
+ {
110
+ pickup: {
111
+ at: "2026-04-03T10:00:00",
112
+ tz: "Europe/Lisbon",
113
+ utc: "2026-04-03T09:00:00Z",
114
+ },
115
+ } as never
116
+ }
117
+ writeCommand="order:create"
118
+ />
119
+ </DispatcherProvider>,
120
+ );
121
+
122
+ const fieldEl = screen.getByTestId("field-pickup");
123
+ const timeInput = fieldEl.querySelector<HTMLInputElement>('input[type="time"]');
124
+ expect(timeInput).toBeTruthy();
125
+ expect(timeInput?.value).toBe("10:00");
126
+ expect(fieldEl.textContent ?? "").toMatch(/lokal|local/i);
127
+ });
128
+
88
129
  test("typing in an input updates the form snapshot (controller + view-model round-trip)", () => {
89
130
  render(
90
131
  <DispatcherProvider dispatcher={makeDispatcher()}>
@@ -75,6 +75,7 @@ import {
75
75
  DropdownMenuTrigger,
76
76
  } from "./dropdown-menu";
77
77
  import { FileUploadInput } from "./file-upload";
78
+ import { LocatedTimestampInput } from "./located-timestamp-input";
78
79
  import { MoneyInput } from "./money-input";
79
80
  import { TimestampInput } from "./timestamp-input";
80
81
  import { useToast } from "./toast";
@@ -390,6 +391,21 @@ function DefaultInput(props: InputProps): ReactNode {
390
391
  {...(props.hasError !== undefined && { hasError: props.hasError })}
391
392
  />
392
393
  );
394
+ case "locatedTimestamp":
395
+ return (
396
+ <LocatedTimestampInput
397
+ id={props.id}
398
+ name={props.name}
399
+ value={props.value}
400
+ onChange={props.onChange}
401
+ {...(props.locale !== undefined && { locale: props.locale })}
402
+ {...(props.min !== undefined && { min: props.min })}
403
+ {...(props.max !== undefined && { max: props.max })}
404
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
405
+ {...(props.required !== undefined && { required: props.required })}
406
+ {...(props.hasError !== undefined && { hasError: props.hasError })}
407
+ />
408
+ );
393
409
  case "textarea":
394
410
  return (
395
411
  <Textarea
@@ -0,0 +1,114 @@
1
+ // LocatedTimestampInput (kind:"locatedTimestamp") — Wall-Clock-Eingabe + IANA-
2
+ // Zonen-Auswahl für locatedTimestamp-Felder. Anders als TimestampInput findet
3
+ // KEINE UTC-Konvertierung statt: ein located Timestamp IST Wall-Clock + Zone;
4
+ // der Server rechnet `utc` aus `{ at, tz }`. Datum/Uhrzeit laufen über
5
+ // TimestampInput im wallClock-Modus (reine String-Form, kein new Date()), die
6
+ // Zone über eine durchsuchbare Combobox der IANA-Zonen.
7
+
8
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
9
+ import type { ReactNode } from "react";
10
+ import { ComboboxInput } from "./combobox";
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 }));
33
+
34
+ export type LocatedTimestampValue = {
35
+ readonly at: string;
36
+ readonly tz: string;
37
+ readonly utc?: string;
38
+ };
39
+
40
+ export type LocatedTimestampInputProps = {
41
+ readonly id: string;
42
+ readonly name: string;
43
+ readonly value: LocatedTimestampValue | "";
44
+ readonly onChange: (v: { readonly at: string; readonly tz: string } | undefined) => void;
45
+ readonly locale?: string;
46
+ readonly min?: string;
47
+ readonly max?: string;
48
+ readonly disabled?: boolean;
49
+ readonly required?: boolean;
50
+ readonly hasError?: boolean;
51
+ };
52
+
53
+ export function LocatedTimestampInput({
54
+ id,
55
+ name,
56
+ value,
57
+ onChange,
58
+ locale,
59
+ min,
60
+ max,
61
+ disabled,
62
+ required,
63
+ hasError,
64
+ }: LocatedTimestampInputProps): ReactNode {
65
+ const t = useTranslation();
66
+ const at = value === "" ? "" : value.at;
67
+ const tz = value === "" ? "" : value.tz;
68
+
69
+ // `at` ist Minuten-Wallclock (z.iso.datetime({local:true}) akzeptiert das);
70
+ // tz ein IANA-Name. Solange beides leer ist gilt das Feld als leer; sobald
71
+ // eines gesetzt ist, fließt das Paar — die Vollständigkeit erzwingt die
72
+ // Required-/Zod-Validierung beim Submit (so bleibt eine halbe Eingabe sichtbar).
73
+ function emit(nextAt: string, nextTz: string): void {
74
+ if (nextAt === "" && nextTz === "") {
75
+ onChange(undefined);
76
+ return;
77
+ }
78
+ onChange({ at: nextAt, tz: nextTz });
79
+ }
80
+
81
+ return (
82
+ <div className="flex flex-col gap-1">
83
+ <div className="flex flex-wrap items-center gap-2">
84
+ <TimestampInput
85
+ id={id}
86
+ name={name}
87
+ value={at}
88
+ onChange={(v) => emit(v ?? "", tz)}
89
+ wallClock
90
+ {...(locale !== undefined && { locale })}
91
+ {...(min !== undefined && { min })}
92
+ {...(max !== undefined && { max })}
93
+ {...(disabled !== undefined && { disabled })}
94
+ {...(required !== undefined && { required })}
95
+ {...(hasError !== undefined && { hasError })}
96
+ />
97
+ <div className="min-w-[12rem]">
98
+ <ComboboxInput
99
+ id={`${id}-tz`}
100
+ name={`${name}-tz`}
101
+ options={TZ_OPTIONS}
102
+ value={tz}
103
+ onChange={(v) => emit(at, v)}
104
+ placeholder={t("kumiko.field.timezone")}
105
+ {...(disabled !== undefined && { disabled })}
106
+ {...(required !== undefined && { required })}
107
+ {...(hasError !== undefined && { hasError })}
108
+ />
109
+ </div>
110
+ </div>
111
+ <span className="text-xs text-muted-foreground">{t("kumiko.field.locatedTzHint")}</span>
112
+ </div>
113
+ );
114
+ }