@cosmicdrift/kumiko-renderer-web 0.167.0 → 0.168.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.167.0",
3
+ "version": "0.168.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.167.0",
20
- "@cosmicdrift/kumiko-headless": "0.167.0",
21
- "@cosmicdrift/kumiko-renderer": "0.167.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.168.0",
20
+ "@cosmicdrift/kumiko-headless": "0.168.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.168.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",
@@ -36,7 +36,8 @@
36
36
  "react": "^19.2.6",
37
37
  "react-day-picker": "^10.0.0",
38
38
  "react-dom": "^19.2.6",
39
- "tailwind-merge": "^3.6.0"
39
+ "tailwind-merge": "^3.6.0",
40
+ "temporal-polyfill": "^0.3.2"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "@tailwindcss/cli": "^4.3.0",
@@ -1,44 +1,50 @@
1
- // date-parse Pure-Logik Tests. parseIso pinnt das non-obvious Timezone-
2
- // Verhalten (baut ein LOKALES Date, nicht UTC, damit "2026-04-25" im
3
- // Calendar nicht je nach Zeitzone auf den 24. kippt). parseTypedDate deckt
4
- // die getippte Eingabe ab (#369): locale-Reihenfolge, Trenner-Toleranz,
5
- // Überlauf-Abweisung.
1
+ // date-parse pure logic tests. parseIso pins the non-obvious timezone
2
+ // behavior (PlainDate has no TZ conversion, so "2026-04-25" doesn't shift
3
+ // to the 24th in the calendar depending on the zone). parseTypedDate
4
+ // covers typed input (#369): locale order, separator tolerance, overflow
5
+ // rejection.
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
+ import { Temporal } from "temporal-polyfill";
8
9
  import { formatDateForInput, parseIso, parseTypedDate, toIso } from "../date-parse";
9
10
 
10
11
  describe("parseIso", () => {
11
- test("gültiges yyyy-mm-dd → lokales Date (kein UTC-Shift)", () => {
12
+ test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
12
13
  const d = parseIso("2026-04-25");
13
- expect(d).toBeInstanceOf(Date);
14
- expect(d?.getFullYear()).toBe(2026);
15
- expect(d?.getMonth()).toBe(3); // 0-based: April
16
- expect(d?.getDate()).toBe(25);
14
+ expect(d).toBeInstanceOf(Temporal.PlainDate);
15
+ expect(d?.year).toBe(2026);
16
+ expect(d?.month).toBe(4);
17
+ expect(d?.day).toBe(25);
17
18
  });
18
19
 
19
- test("leerer String → undefined", () => {
20
+ test("empty string → undefined", () => {
20
21
  expect(parseIso("")).toBeUndefined();
21
22
  });
22
23
 
23
- test("falsche Teil-Anzahl oder nicht-numerische Teile → undefined", () => {
24
+ test("wrong part count or non-numeric parts → undefined", () => {
24
25
  expect(parseIso("2026-04")).toBeUndefined();
25
26
  expect(parseIso("2026/04/25")).toBeUndefined();
26
27
  expect(parseIso("abc-de-fg")).toBeUndefined();
27
28
  });
28
29
 
29
- test("ungültiger Kalendertag (Überlauf) → undefined", () => {
30
+ test("invalid calendar day (overflow) → undefined", () => {
30
31
  expect(parseIso("2026-02-31")).toBeUndefined();
31
32
  expect(parseIso("2026-13-01")).toBeUndefined();
32
33
  });
34
+
35
+ test("year below 100 → undefined (no 1900+y fallback)", () => {
36
+ expect(parseIso("0026-04-25")).toBeUndefined();
37
+ expect(parseIso("26-04-25")).toBeUndefined();
38
+ });
33
39
  });
34
40
 
35
41
  describe("toIso", () => {
36
- test("Date → yyyy-mm-dd mit Zero-Padding", () => {
37
- expect(toIso(new Date(2026, 3, 5))).toBe("2026-04-05");
38
- expect(toIso(new Date(2026, 11, 25))).toBe("2026-12-25");
42
+ test("PlainDate → yyyy-mm-dd with zero padding", () => {
43
+ expect(toIso(Temporal.PlainDate.from({ year: 2026, month: 4, day: 5 }))).toBe("2026-04-05");
44
+ expect(toIso(Temporal.PlainDate.from({ year: 2026, month: 12, day: 25 }))).toBe("2026-12-25");
39
45
  });
40
46
 
41
- test("Roundtrip parseIso → toIso ist stabil", () => {
47
+ test("roundtrip parseIso → toIso is stable", () => {
42
48
  const d = parseIso("2026-04-25");
43
49
  expect(d).toBeDefined();
44
50
  if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
@@ -46,35 +52,35 @@ describe("toIso", () => {
46
52
  });
47
53
 
48
54
  describe("parseTypedDate", () => {
49
- test("ISO direkt getipptDate", () => {
50
- expect(toIso(parseTypedDate("2026-04-25", "de-DE") as Date)).toBe("2026-04-25");
55
+ test("ISO typed directlyPlainDate", () => {
56
+ expect(toIso(parseTypedDate("2026-04-25", "de-DE") as Temporal.PlainDate)).toBe("2026-04-25");
51
57
  });
52
58
 
53
- test("de-DE Reihenfolge d.m.y", () => {
59
+ test("de-DE order d.m.y", () => {
54
60
  const d = parseTypedDate("25.04.2026", "de-DE");
55
61
  expect(d).toBeDefined();
56
62
  if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
57
63
  });
58
64
 
59
- test("en-US Reihenfolge m/d/y", () => {
65
+ test("en-US order m/d/y", () => {
60
66
  const d = parseTypedDate("04/25/2026", "en-US");
61
67
  expect(d).toBeDefined();
62
68
  if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
63
69
  });
64
70
 
65
- test("Trenner-Toleranz (gemischte Nicht-Ziffern)", () => {
71
+ test("separator tolerance (mixed non-digits)", () => {
66
72
  const d = parseTypedDate("25 4 2026", "de-DE");
67
73
  expect(d).toBeDefined();
68
74
  if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
69
75
  });
70
76
 
71
- test("zweistelliges Jahr2000er", () => {
77
+ test("two-digit year2000s", () => {
72
78
  const d = parseTypedDate("25.04.26", "de-DE");
73
79
  expect(d).toBeDefined();
74
80
  if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
75
81
  });
76
82
 
77
- test("Teil-/Fehl-Eingabe → undefined", () => {
83
+ test("partial/invalid input → undefined", () => {
78
84
  expect(parseTypedDate("", "de-DE")).toBeUndefined();
79
85
  expect(parseTypedDate("25.04", "de-DE")).toBeUndefined();
80
86
  expect(parseTypedDate("foo", "de-DE")).toBeUndefined();
@@ -83,10 +89,23 @@ describe("parseTypedDate", () => {
83
89
  });
84
90
 
85
91
  describe("formatDateForInput", () => {
86
- test("numerisch, locale-spezifisch, wieder parsebar", () => {
87
- const formatted = formatDateForInput(new Date(2026, 3, 25), "de-DE");
92
+ test("numeric, locale-specific, re-parseable", () => {
93
+ const formatted = formatDateForInput(
94
+ Temporal.PlainDate.from({ year: 2026, month: 4, day: 25 }),
95
+ "de-DE",
96
+ );
88
97
  const roundtrip = parseTypedDate(formatted, "de-DE");
89
98
  expect(roundtrip).toBeDefined();
90
99
  if (roundtrip !== undefined) expect(toIso(roundtrip)).toBe("2026-04-25");
91
100
  });
101
+
102
+ test("en-US locale, re-parseable", () => {
103
+ const formatted = formatDateForInput(
104
+ Temporal.PlainDate.from({ year: 2026, month: 4, day: 25 }),
105
+ "en-US",
106
+ );
107
+ const roundtrip = parseTypedDate(formatted, "en-US");
108
+ expect(roundtrip).toBeDefined();
109
+ if (roundtrip !== undefined) expect(toIso(roundtrip)).toBe("2026-04-25");
110
+ });
92
111
  });
@@ -1,18 +1,34 @@
1
- // DateField — die gemeinsame tippbare Datums-Eingabe: ein Text-Input
2
- // (locale-aware Parse, Teil-Eingaben tolerant) plus CalendarPopover mit
3
- // Jahres-/Dekaden-Dropdown. Underlying-Wert ist ISO `yyyy-mm-dd`.
1
+ // DateField — the shared typable date input: a text input (locale-aware
2
+ // parse, tolerant of partial input) plus a CalendarPopover with year/
3
+ // decade dropdown. Underlying value is ISO `yyyy-mm-dd`.
4
4
  //
5
- // Eine Quelle für beide Date-Primitives: DateInput (kind:"date") ist ein
6
- // dünner Re-Export hiervon, TimestampInput (kind:"timestamp") nutzt es als
7
- // Datums-Teil neben dem Uhrzeit-Input. So teilen `date` und `timestamp`
8
- // dieselbe Tipp-/Navigations-UX statt zweier divergenter Primitives (#369).
5
+ // One source for both date primitives: DateInput (kind:"date") is a thin
6
+ // re-export of this, TimestampInput (kind:"timestamp") uses it as the
7
+ // date part next to the time input. So `date` and `timestamp` share the
8
+ // same typing/navigation UX instead of two diverging primitives (#369).
9
9
 
10
10
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
11
11
  import { type ReactNode, useState } from "react";
12
+ import { Temporal } from "temporal-polyfill";
12
13
  import { cn } from "../lib/cn";
13
14
  import { CalendarPopover } from "./calendar-popover";
14
15
  import { formatDateForInput, guessLocale, parseIso, parseTypedDate, toIso } from "./date-parse";
15
16
 
17
+ // CalendarPopover wraps react-day-picker, which only accepts native Date
18
+ // objects — the PlainDate↔Date boundary conversion stays confined to
19
+ // these two functions instead of spreading through the rest of the field.
20
+ function toNativeDate(pd: Temporal.PlainDate): Date {
21
+ return new Date(pd.year, pd.month - 1, pd.day);
22
+ }
23
+
24
+ function fromNativeDate(d: Date): Temporal.PlainDate {
25
+ return Temporal.PlainDate.from({
26
+ year: d.getFullYear(),
27
+ month: d.getMonth() + 1,
28
+ day: d.getDate(),
29
+ });
30
+ }
31
+
16
32
  export type DateFieldProps = {
17
33
  readonly id: string;
18
34
  readonly name: string;
@@ -22,9 +38,9 @@ export type DateFieldProps = {
22
38
  readonly required?: boolean;
23
39
  readonly hasError?: boolean;
24
40
  readonly locale?: string;
25
- /** Untere/obere Grenze als ISO `yyyy-mm-dd`. Begrenzt den Kalender
26
- * (Jahres-Dropdown-Range + ausgegraute Tage). Server-Validierung läuft
27
- * separat über die Zod-Schemas. */
41
+ /** Lower/upper bound as ISO `yyyy-mm-dd`. Limits the calendar
42
+ * (year-dropdown range + greyed-out days). Server-side validation runs
43
+ * separately via the Zod schemas. */
28
44
  readonly min?: string;
29
45
  readonly max?: string;
30
46
  };
@@ -52,9 +68,9 @@ export function DateField({
52
68
  const resolvedLocale = locale ?? guessLocale();
53
69
  const selected = parseIso(value);
54
70
 
55
- // draft === null → zeige den kanonisch formatierten Wert. Sobald der User
56
- // tippt, hält draft den Roh-Text, damit die Eingabe nicht bei jedem
57
- // Tastendruck umformatiert wird. onBlur setzt zurück auf null.
71
+ // draft === null → show the canonically formatted value. Once the user
72
+ // types, draft holds the raw text so input isn't reformatted on every
73
+ // keystroke. onBlur resets it back to null.
58
74
  const [draft, setDraft] = useState<string | null>(null);
59
75
  const display =
60
76
  draft ?? (selected !== undefined ? formatDateForInput(selected, resolvedLocale) : "");
@@ -82,7 +98,10 @@ export function DateField({
82
98
  disabled={disabled}
83
99
  required={required}
84
100
  aria-invalid={hasError === true ? true : undefined}
85
- placeholder={formatDateForInput(new Date(2026, 11, 31), resolvedLocale)}
101
+ placeholder={formatDateForInput(
102
+ Temporal.PlainDate.from({ year: 2026, month: 12, day: 31 }),
103
+ resolvedLocale,
104
+ )}
86
105
  onChange={(e) => {
87
106
  setDraft(e.target.value);
88
107
  commitTyped(e.target.value);
@@ -94,13 +113,13 @@ export function DateField({
94
113
  )}
95
114
  />
96
115
  <CalendarPopover
97
- selected={selected}
116
+ selected={selected !== undefined ? toNativeDate(selected) : undefined}
98
117
  onSelect={(d) => {
99
- onChange(d !== undefined ? toIso(d) : undefined);
118
+ onChange(d !== undefined ? toIso(fromNativeDate(d)) : undefined);
100
119
  setDraft(null);
101
120
  }}
102
- {...(minDate !== undefined && { min: minDate })}
103
- {...(maxDate !== undefined && { max: maxDate })}
121
+ {...(minDate !== undefined && { min: toNativeDate(minDate) })}
122
+ {...(maxDate !== undefined && { max: toNativeDate(maxDate) })}
104
123
  {...(disabled !== undefined && { disabled })}
105
124
  {...(hasError !== undefined && { hasError })}
106
125
  triggerLabel={t("kumiko.field.open-calendar")}
@@ -1,32 +1,29 @@
1
- // Gemeinsame Datums-Parse/Format-Utils für die Web-Date-Primitives
2
- // (DateInput, TimestampInput). PlainDate-Semantik: lokale Date-Objekte
3
- // ohne Timezone-Konvertierung — "2026-04-25" bleibt der 25., egal in
4
- // welcher Zone der Browser läuft. Die TZ-/Wall-Clock-Konvertierung für
5
- // timestamp-Felder lebt bewusst weiter in timestamp-input.tsx (Wire-
6
- // Boundary, eigener Test); hier ist reines Kalender-Datum.
1
+ // Shared date parse/format utils for the web date primitives (DateInput,
2
+ // TimestampInput). PlainDate semantics: pure calendar date, no timezone
3
+ // conversion — "2026-04-25" stays the 25th regardless of the browser's
4
+ // zone. TZ/wall-clock conversion for timestamp fields deliberately stays
5
+ // in timestamp-input.tsx (wire boundary, own test); this file is pure
6
+ // calendar date.
7
+
8
+ import { Temporal } from "temporal-polyfill";
7
9
 
8
10
  export function guessLocale(): string {
9
11
  if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
10
12
  return "en-US";
11
13
  }
12
14
 
13
- function pad(n: number): string {
14
- return String(n).padStart(2, "0");
15
- }
16
-
17
- // new Date(y, m-1, d) akzeptiert Überläufe (31. Feb → 3. März) und
18
- // interpretiert 0–99 als 1900+y. Beides hier als ungültig abweisen, damit
19
- // getippte Datümer nicht still in ein anderes Datum kippen.
20
- function makeLocalDate(y: number, m: number, d: number): Date | undefined {
21
- if (m < 1 || m > 12 || d < 1 || d > 31) return undefined;
22
- const date = new Date(y, m - 1, d);
23
- if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
15
+ // overflow:"reject" throws on overflow (Feb 31 → RangeError) instead of
16
+ // silently rolling it forward — typed dates should not silently shift to
17
+ // a different date.
18
+ function makePlainDate(y: number, m: number, d: number): Temporal.PlainDate | undefined {
19
+ try {
20
+ return Temporal.PlainDate.from({ year: y, month: m, day: d }, { overflow: "reject" });
21
+ } catch {
24
22
  return undefined;
25
23
  }
26
- return date;
27
24
  }
28
25
 
29
- export function parseIso(v: string): Date | undefined {
26
+ export function parseIso(v: string): Temporal.PlainDate | undefined {
30
27
  if (v === "") return undefined;
31
28
  const parts = v.split("-");
32
29
  if (parts.length !== 3) return undefined;
@@ -37,32 +34,42 @@ export function parseIso(v: string): Date | undefined {
37
34
  d === undefined ||
38
35
  Number.isNaN(y) ||
39
36
  Number.isNaN(m) ||
40
- Number.isNaN(d)
37
+ Number.isNaN(d) ||
38
+ // Reject 2-digit years — matches the pre-PlainDate behavior where
39
+ // `new Date`'s 1900+y quirk made a round-trip check fail on them.
40
+ y < 100
41
41
  ) {
42
42
  return undefined;
43
43
  }
44
- return makeLocalDate(y, m, d);
44
+ return makePlainDate(y, m, d);
45
45
  }
46
46
 
47
- export function toIso(d: Date): string {
48
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
47
+ export function toIso(d: Temporal.PlainDate): string {
48
+ return d.toString();
49
49
  }
50
50
 
51
- // Editierbare, wieder-parsebare Anzeige (numerisches Locale-Format, z.B.
52
- // de "25.04.2026", en-US "04/25/2026"). Bewusst NICHT month:"long" — der
53
- // User soll den angezeigten Text direkt überschreiben können.
54
- export function formatDateForInput(d: Date, locale: string): string {
55
- return d.toLocaleDateString(locale, { year: "numeric", month: "2-digit", day: "2-digit" });
51
+ // Editable, re-parseable display (numeric locale format, e.g. de
52
+ // "25.04.2026", en-US "04/25/2026"). Deliberately NOT month:"long" — the
53
+ // user should be able to overwrite the displayed text directly.
54
+ export function formatDateForInput(d: Temporal.PlainDate, locale: string): string {
55
+ return d.toLocaleString(locale, { year: "numeric", month: "2-digit", day: "2-digit" });
56
56
  }
57
57
 
58
58
  type DateSlot = "y" | "m" | "d";
59
59
 
60
- // Feld-Reihenfolge des numerischen Locale-Formats. de → [d,m,y],
61
- // en-US → [m,d,y], ISO-ähnliche Locales → [y,m,d].
60
+ // Field order of the numeric locale format. de → [d,m,y], en-US →
61
+ // [m,d,y], ISO-like locales → [y,m,d]. formatToParts runs over an epoch-
62
+ // millis number instead of a Date object (guard-compliant) — timeZone:
63
+ // "UTC" keeps the reference from shifting to the 1st depending on the
64
+ // browser's TZ.
62
65
  function localeDateOrder(locale: string): readonly DateSlot[] {
63
- const ref = new Date(2026, 0, 2); // Tag 2, Monat 1 alle Felder eindeutig
66
+ const refEpochMillis = Temporal.PlainDate.from({ year: 2026, month: 1, day: 2 }).toZonedDateTime(
67
+ "UTC",
68
+ ).epochMilliseconds;
64
69
  const order: DateSlot[] = [];
65
- for (const part of new Intl.DateTimeFormat(locale).formatToParts(ref)) {
70
+ for (const part of new Intl.DateTimeFormat(locale, { timeZone: "UTC" }).formatToParts(
71
+ refEpochMillis,
72
+ )) {
66
73
  if (part.type === "year") order.push("y");
67
74
  else if (part.type === "month") order.push("m");
68
75
  else if (part.type === "day") order.push("d");
@@ -70,11 +77,11 @@ function localeDateOrder(locale: string): readonly DateSlot[] {
70
77
  return order;
71
78
  }
72
79
 
73
- // Getippte EingabeDate. Akzeptiert ISO (yyyy-mm-dd) direkt sowie drei
74
- // numerische Tokens in Locale-Reihenfolge mit beliebigem Trenner
75
- // (".", "/", "-", " "). Zweistellige Jahre2000er. Teil-/Fehl-Eingaben
76
- // undefined (Caller behält dann den Roh-Text, committet nichts).
77
- export function parseTypedDate(input: string, locale: string): Date | undefined {
80
+ // Typed inputPlainDate. Accepts ISO (yyyy-mm-dd) directly, plus three
81
+ // numeric tokens in locale order with any separator (".", "/", "-", " ").
82
+ // Two-digit years 2000s. Partial/invalid inputundefined (caller
83
+ // keeps the raw text then, commits nothing).
84
+ export function parseTypedDate(input: string, locale: string): Temporal.PlainDate | undefined {
78
85
  const trimmed = input.trim();
79
86
  if (trimmed === "") return undefined;
80
87
 
@@ -100,5 +107,5 @@ export function parseTypedDate(input: string, locale: string): Date | undefined
100
107
  });
101
108
  if (y < 100) y += 2000;
102
109
 
103
- return makeLocalDate(y, m, d);
110
+ return makePlainDate(y, m, d);
104
111
  }