@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.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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()}>
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
+
}
|