@cosmicdrift/kumiko-renderer-web 0.167.1 → 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.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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("
|
|
12
|
+
test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
|
|
12
13
|
const d = parseIso("2026-04-25");
|
|
13
|
-
expect(d).toBeInstanceOf(
|
|
14
|
-
expect(d?.
|
|
15
|
-
expect(d?.
|
|
16
|
-
expect(d?.
|
|
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("
|
|
20
|
+
test("empty string → undefined", () => {
|
|
20
21
|
expect(parseIso("")).toBeUndefined();
|
|
21
22
|
});
|
|
22
23
|
|
|
23
|
-
test("
|
|
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("
|
|
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("
|
|
37
|
-
expect(toIso(
|
|
38
|
-
expect(toIso(
|
|
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("
|
|
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
|
|
50
|
-
expect(toIso(parseTypedDate("2026-04-25", "de-DE") as
|
|
55
|
+
test("ISO typed directly → PlainDate", () => {
|
|
56
|
+
expect(toIso(parseTypedDate("2026-04-25", "de-DE") as Temporal.PlainDate)).toBe("2026-04-25");
|
|
51
57
|
});
|
|
52
58
|
|
|
53
|
-
test("de-DE
|
|
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
|
|
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("
|
|
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("
|
|
77
|
+
test("two-digit year → 2000s", () => {
|
|
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("
|
|
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("
|
|
87
|
-
const formatted = formatDateForInput(
|
|
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 —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
/**
|
|
26
|
-
* (
|
|
27
|
-
*
|
|
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 →
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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):
|
|
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
|
|
44
|
+
return makePlainDate(y, m, d);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function toIso(d:
|
|
48
|
-
return
|
|
47
|
+
export function toIso(d: Temporal.PlainDate): string {
|
|
48
|
+
return d.toString();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
export function formatDateForInput(d:
|
|
55
|
-
return d.
|
|
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
|
-
//
|
|
61
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
export function parseTypedDate(input: string, locale: string):
|
|
80
|
+
// Typed input → PlainDate. Accepts ISO (yyyy-mm-dd) directly, plus three
|
|
81
|
+
// numeric tokens in locale order with any separator (".", "/", "-", " ").
|
|
82
|
+
// Two-digit years → 2000s. Partial/invalid input → undefined (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
|
|
110
|
+
return makePlainDate(y, m, d);
|
|
104
111
|
}
|