@cosmicdrift/kumiko-renderer-web 0.53.0 → 0.55.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 +1 -1
- package/src/__tests__/date-input.test.tsx +71 -10
- package/src/__tests__/primitives.test.tsx +5 -6
- package/src/__tests__/timestamp-input.test.tsx +46 -33
- package/src/layout/__tests__/settings-hub-visibility.test.ts +20 -12
- package/src/primitives/__tests__/date-parse.test.ts +92 -0
- package/src/primitives/calendar-popover.tsx +128 -0
- package/src/primitives/date-field.tsx +108 -0
- package/src/primitives/date-input.tsx +5 -164
- package/src/primitives/date-parse.ts +104 -0
- package/src/primitives/index.tsx +5 -1
- package/src/primitives/timestamp-input.tsx +82 -27
- package/src/primitives/__tests__/date-input.test.ts +0 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.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>",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
//
|
|
2
|
-
// DateInput pinnt:
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// zeigt der Calendar je
|
|
2
|
+
// DateInput pinnt (seit #369): tippbares Text-Input zeigt das Datum
|
|
3
|
+
// locale-numerisch und akzeptiert Eingabe, ein Icon-Button öffnet das
|
|
4
|
+
// DayPicker, Auswahl gibt ISO-yyyy-mm-dd zurück. Wert-Roundtrip
|
|
5
|
+
// (ISO → Date → ISO) muss tag-stable sein, sonst zeigt der Calendar je
|
|
6
|
+
// nach Timezone den Vortag.
|
|
6
7
|
|
|
7
8
|
import { describe, expect, mock, test } from "bun:test";
|
|
8
9
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
@@ -10,23 +11,30 @@ import userEvent from "@testing-library/user-event";
|
|
|
10
11
|
import { DateInput } from "../primitives/date-input";
|
|
11
12
|
|
|
12
13
|
describe("DateInput", () => {
|
|
13
|
-
test("
|
|
14
|
+
test("Eingabefeld zeigt locale-numerisches Datum (de-DE)", () => {
|
|
14
15
|
render(
|
|
15
16
|
<DateInput id="d" name="d" value="2026-04-23" onChange={() => undefined} locale="de-DE" />,
|
|
16
17
|
);
|
|
17
|
-
expect(screen.getByRole("
|
|
18
|
+
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("23.04.2026");
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
test("
|
|
21
|
+
test("Eingabefeld zeigt locale-numerisches Datum (en-US)", () => {
|
|
21
22
|
render(
|
|
22
23
|
<DateInput id="d" name="d" value="2026-04-23" onChange={() => undefined} locale="en-US" />,
|
|
23
24
|
);
|
|
24
|
-
expect(screen.getByRole("
|
|
25
|
+
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("04/23/2026");
|
|
25
26
|
});
|
|
26
27
|
|
|
27
|
-
test(
|
|
28
|
+
test("Eingabefeld ist leer bei leerem Wert", () => {
|
|
28
29
|
render(<DateInput id="d" name="d" value="" onChange={() => undefined} locale="de-DE" />);
|
|
29
|
-
expect(screen.getByRole("
|
|
30
|
+
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("Datum tippen → onChange feuert ISO yyyy-mm-dd", () => {
|
|
34
|
+
const onChange = mock();
|
|
35
|
+
render(<DateInput id="d" name="d" value="" onChange={onChange} locale="de-DE" />);
|
|
36
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "23.04.2026" } });
|
|
37
|
+
expect(onChange).toHaveBeenCalledWith("2026-04-23");
|
|
30
38
|
});
|
|
31
39
|
|
|
32
40
|
test("kein nativer date-input im DOM (Radix-Popover-Pattern, nicht type=date)", () => {
|
|
@@ -87,4 +95,57 @@ describe("DateInput", () => {
|
|
|
87
95
|
fireEvent.click(day25.querySelector("button") as HTMLButtonElement);
|
|
88
96
|
expect(onChange).toHaveBeenCalledWith("2026-04-25");
|
|
89
97
|
});
|
|
98
|
+
|
|
99
|
+
// Headline-Feature #369: captionLayout="dropdown" rendert Monats-/Jahres-
|
|
100
|
+
// Selects statt nur Monats-Vor/Zurück. min/max begrenzen über
|
|
101
|
+
// startMonth/endMonth den Jahres-Selector.
|
|
102
|
+
test("Kalender zeigt Jahres-Dropdown, begrenzt durch min/max", async () => {
|
|
103
|
+
const user = userEvent.setup();
|
|
104
|
+
render(
|
|
105
|
+
<DateInput
|
|
106
|
+
id="d"
|
|
107
|
+
name="d"
|
|
108
|
+
value="2023-06-15"
|
|
109
|
+
onChange={() => undefined}
|
|
110
|
+
locale="de-DE"
|
|
111
|
+
min="2020-01-01"
|
|
112
|
+
max="2026-12-31"
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
await user.click(screen.getByRole("button"));
|
|
116
|
+
// rdp v9 rendert die Dropdowns als <select> (role=combobox). Mind. der
|
|
117
|
+
// Jahres-Selector muss da sein — sonst greift captionLayout nicht.
|
|
118
|
+
const combos = screen.getAllByRole("combobox");
|
|
119
|
+
expect(combos.length).toBeGreaterThanOrEqual(1);
|
|
120
|
+
const yearSelect = combos.find((c) =>
|
|
121
|
+
Array.from(c.querySelectorAll("option")).some((o) => o.textContent === "2023"),
|
|
122
|
+
) as HTMLSelectElement | undefined;
|
|
123
|
+
if (!yearSelect) throw new Error("expected a year dropdown");
|
|
124
|
+
const years = Array.from(yearSelect.querySelectorAll("option")).map((o) => o.textContent);
|
|
125
|
+
expect(years).toContain("2020");
|
|
126
|
+
expect(years).toContain("2026");
|
|
127
|
+
expect(years).not.toContain("2019");
|
|
128
|
+
expect(years).not.toContain("2027");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("min/max grauen Out-of-Range-Tage aus", async () => {
|
|
132
|
+
const user = userEvent.setup();
|
|
133
|
+
const onChange = mock();
|
|
134
|
+
// Range endet am 10. April 2026 — der 25. liegt außerhalb.
|
|
135
|
+
render(
|
|
136
|
+
<DateInput
|
|
137
|
+
id="d"
|
|
138
|
+
name="d"
|
|
139
|
+
value="2026-04-05"
|
|
140
|
+
onChange={onChange}
|
|
141
|
+
locale="en-US"
|
|
142
|
+
max="2026-04-10"
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
await user.click(screen.getByRole("button"));
|
|
146
|
+
const day25Button = screen
|
|
147
|
+
.getByRole("gridcell", { name: /^25$/ })
|
|
148
|
+
.querySelector("button") as HTMLButtonElement;
|
|
149
|
+
expect(day25Button.disabled).toBe(true);
|
|
150
|
+
});
|
|
90
151
|
});
|
|
@@ -148,17 +148,16 @@ describe("Input kind mapping", () => {
|
|
|
148
148
|
expect(onChange).toHaveBeenCalledWith(true);
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
test('kind="date":
|
|
152
|
-
// Default-DateInput nutzt
|
|
153
|
-
// <input type="date">.
|
|
154
|
-
//
|
|
151
|
+
test('kind="date": tippbares Text-Input mit locale-Datum, kein nativer date-Input', () => {
|
|
152
|
+
// Default-DateInput nutzt seit #369 ein tippbares Text-Input +
|
|
153
|
+
// DayPicker-Popover statt native <input type="date">. Das Datum steht
|
|
154
|
+
// locale-numerisch im Eingabefeld.
|
|
155
155
|
const onChange = mock();
|
|
156
156
|
render(
|
|
157
157
|
<Input id="i" name="i" kind="date" value="2026-04-23" onChange={onChange} locale="de-DE" />,
|
|
158
158
|
);
|
|
159
159
|
expect(document.querySelector('input[type="date"]')).toBeNull();
|
|
160
|
-
|
|
161
|
-
expect(trigger.textContent).toContain("23. April 2026");
|
|
160
|
+
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("23.04.2026");
|
|
162
161
|
});
|
|
163
162
|
|
|
164
163
|
test("hasError=true sets aria-invalid", () => {
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// Zeit ("2026-06-08T21:09") → jeder Save endete in 422 invalid_format.
|
|
5
5
|
// Die Assertions laufen gegen die ECHTEN Zod-Schemas aus
|
|
6
6
|
// schema-builder.ts (z.iso.datetime / z.iso.datetime({local:true})).
|
|
7
|
+
|
|
7
8
|
import { describe, expect, test } from "bun:test";
|
|
8
9
|
import { fireEvent } from "@testing-library/react";
|
|
10
|
+
import { useState } from "react";
|
|
9
11
|
import { z } from "zod";
|
|
10
12
|
import { defaultPrimitives } from "../primitives";
|
|
11
13
|
import { inputValueToTimestamp, timestampToInputValue } from "../primitives/timestamp-input";
|
|
@@ -48,48 +50,59 @@ describe("timestamp Konvertierung (Helpers)", () => {
|
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
describe("Input kind=timestamp (Primitive)", () => {
|
|
51
|
-
|
|
53
|
+
// Seit #369: getrennte Datums- (tippbar, ISO direkt akzeptiert) und
|
|
54
|
+
// Uhrzeit-Eingabe statt nativem datetime-local. inputs[0] = Datum,
|
|
55
|
+
// inputs[1] = type=time. Die Wire-Konvertierung (UTC↔Wall-Clock) ist
|
|
56
|
+
// unverändert und oben über die Helpers gepinnt. Kontrollierter Wrapper,
|
|
57
|
+
// damit `value` wie in einer echten Form zurückfließt.
|
|
58
|
+
function ControlledTimestamp({
|
|
59
|
+
wallClock,
|
|
60
|
+
onEmit,
|
|
61
|
+
}: {
|
|
62
|
+
readonly wallClock?: boolean;
|
|
63
|
+
readonly onEmit: (v: string | undefined) => void;
|
|
64
|
+
}) {
|
|
52
65
|
const { Input } = defaultPrimitives;
|
|
53
|
-
const
|
|
54
|
-
|
|
66
|
+
const [v, setV] = useState("");
|
|
67
|
+
return (
|
|
55
68
|
<Input
|
|
56
69
|
kind="timestamp"
|
|
57
70
|
id="ts"
|
|
58
71
|
name="ts"
|
|
59
|
-
value=
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
value={v}
|
|
73
|
+
{...(wallClock === true && { wallClock: true })}
|
|
74
|
+
onChange={(nv) => {
|
|
75
|
+
onEmit(nv);
|
|
76
|
+
setV(nv ?? "");
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
62
79
|
);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test("Datum + Uhrzeit tippen → valider Z-Instant (UTC-Variante)", () => {
|
|
83
|
+
let last: string | undefined;
|
|
84
|
+
const view = render(<ControlledTimestamp onEmit={(v) => (last = v)} />);
|
|
85
|
+
const [dateInput, timeInput] = view.container.querySelectorAll("input");
|
|
86
|
+
if (!dateInput || !timeInput) throw new Error("expected date + time input");
|
|
87
|
+
|
|
88
|
+
fireEvent.change(dateInput, { target: { value: "2026-06-08" } });
|
|
89
|
+
fireEvent.change(timeInput, { target: { value: "21:09" } });
|
|
68
90
|
|
|
69
|
-
|
|
70
|
-
expect(
|
|
71
|
-
|
|
72
|
-
if (value === undefined) throw new Error("expected emitted value");
|
|
73
|
-
expect(utcSchema.safeParse(value).success).toBe(true);
|
|
91
|
+
if (last === undefined) throw new Error("expected emitted value");
|
|
92
|
+
expect(last.endsWith("Z")).toBe(true);
|
|
93
|
+
expect(utcSchema.safeParse(last).success).toBe(true);
|
|
74
94
|
});
|
|
75
95
|
|
|
76
96
|
test("wallClock-Variante emittiert offset-lose Wall-Clock", () => {
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
/>,
|
|
88
|
-
);
|
|
89
|
-
const input = view.container.querySelector("input");
|
|
90
|
-
if (!input) throw new Error("expected input");
|
|
91
|
-
fireEvent.change(input, { target: { value: "2026-06-08T10:00" } });
|
|
92
|
-
expect(emitted).toEqual(["2026-06-08T10:00"]);
|
|
93
|
-
expect(wallClockSchema.safeParse(emitted[0]).success).toBe(true);
|
|
97
|
+
let last: string | undefined;
|
|
98
|
+
const view = render(<ControlledTimestamp wallClock onEmit={(v) => (last = v)} />);
|
|
99
|
+
const [dateInput, timeInput] = view.container.querySelectorAll("input");
|
|
100
|
+
if (!dateInput || !timeInput) throw new Error("expected date + time input");
|
|
101
|
+
|
|
102
|
+
fireEvent.change(dateInput, { target: { value: "2026-06-08" } });
|
|
103
|
+
fireEvent.change(timeInput, { target: { value: "10:00" } });
|
|
104
|
+
|
|
105
|
+
expect(last).toBe("2026-06-08T10:00");
|
|
106
|
+
expect(wallClockSchema.safeParse(last).success).toBe(true);
|
|
94
107
|
});
|
|
95
108
|
});
|
|
@@ -19,11 +19,19 @@ import { qualifyScreenId } from "@cosmicdrift/kumiko-renderer";
|
|
|
19
19
|
import { buildNavRegistrySliceForApp } from "../nav-tree";
|
|
20
20
|
|
|
21
21
|
const billing = defineFeature("billing", (r) => {
|
|
22
|
+
r.config({
|
|
23
|
+
// tenant-home with the admin write-set (∋ SystemAdmin): cascades up to a
|
|
24
|
+
// SystemAdmin-only Plattform default screen AND a tenant override screen.
|
|
25
|
+
keys: { stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key" } }) },
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
// A masked SYSTEM key keeping the default ["system"] (internal-actor) write: no
|
|
29
|
+
// human can set it, so it must NOT generate a hub nav at all — build-time
|
|
30
|
+
// exclusion, not just resolve-time hiding (else it renders as an unsaveable field).
|
|
31
|
+
const internal = defineFeature("internal", (r) => {
|
|
22
32
|
r.config({
|
|
23
33
|
keys: {
|
|
24
|
-
|
|
25
|
-
// system-scope default write = ["system"] (internal actor) → human-hidden
|
|
26
|
-
platformFee: createSystemConfig("number", { mask: { title: "billing.platform-fee" } }),
|
|
34
|
+
rebuildToken: createSystemConfig("text", { mask: { title: "internal.rebuild-token" } }),
|
|
27
35
|
},
|
|
28
36
|
});
|
|
29
37
|
});
|
|
@@ -52,7 +60,7 @@ const shell = defineFeature("shell", (r) => {
|
|
|
52
60
|
r.workspace({ id: "main", label: "Main", nav: ["shell:nav:home"] });
|
|
53
61
|
});
|
|
54
62
|
|
|
55
|
-
const app = buildAppSchema(createRegistry([shell, billing, notify, ops]));
|
|
63
|
+
const app = buildAppSchema(createRegistry([shell, billing, internal, notify, ops]));
|
|
56
64
|
|
|
57
65
|
function navMembersOf(workspaceId: string): ReadonlySet<string> {
|
|
58
66
|
const ws = app.workspaces?.find((w) => w.definition.id === workspaceId);
|
|
@@ -97,7 +105,7 @@ function screenRefsIn(tree: ReturnType<typeof resolveNavigation>): string[] {
|
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
describe("Settings-Hub visibility — full boot pipeline", () => {
|
|
100
|
-
test("privileged user sees the
|
|
108
|
+
test("privileged user sees the hub incl. cascaded Plattform defaults; machine-only keys never generate a nav", () => {
|
|
101
109
|
const slice = buildNavRegistrySliceForApp(app, navMembersOf("settings"));
|
|
102
110
|
const tree = resolveNavigation({
|
|
103
111
|
source: slice,
|
|
@@ -107,16 +115,16 @@ describe("Settings-Hub visibility — full boot pipeline", () => {
|
|
|
107
115
|
|
|
108
116
|
expect(names).toContain("config:nav:audience-tenant");
|
|
109
117
|
expect(names).toContain("config:nav:audience-user");
|
|
110
|
-
// system audience surfaces
|
|
111
|
-
//
|
|
112
|
-
// (write ["system"]) stays hidden from the same admin.
|
|
118
|
+
// system audience surfaces for `ops` (human-opted system key) AND for
|
|
119
|
+
// billing's tenant key cascading up to a SystemAdmin-only Plattform default.
|
|
113
120
|
expect(names).toContain("config:nav:audience-system");
|
|
114
121
|
expect(names).toContain("config:nav:ops-system");
|
|
115
|
-
expect(names).
|
|
122
|
+
expect(names).toContain("config:nav:billing-system");
|
|
116
123
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
expect(navMembersOf("settings").has("config:nav:
|
|
124
|
+
// the internal machine-only key (write ["system"]) generates NO nav at all —
|
|
125
|
+
// build-time exclusion, so it can never render as an unsaveable field.
|
|
126
|
+
expect(navMembersOf("settings").has("config:nav:internal-system")).toBe(false);
|
|
127
|
+
expect(names).not.toContain("config:nav:internal-system");
|
|
120
128
|
|
|
121
129
|
// hierarchy: the billing-tenant screen hangs under the tenant audience
|
|
122
130
|
const tenantAudience = tree.find((n) => n.qualifiedName === "config:nav:audience-tenant");
|
|
@@ -0,0 +1,92 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { formatDateForInput, parseIso, parseTypedDate, toIso } from "../date-parse";
|
|
9
|
+
|
|
10
|
+
describe("parseIso", () => {
|
|
11
|
+
test("gültiges yyyy-mm-dd → lokales Date (kein UTC-Shift)", () => {
|
|
12
|
+
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);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("leerer String → undefined", () => {
|
|
20
|
+
expect(parseIso("")).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("falsche Teil-Anzahl oder nicht-numerische Teile → undefined", () => {
|
|
24
|
+
expect(parseIso("2026-04")).toBeUndefined();
|
|
25
|
+
expect(parseIso("2026/04/25")).toBeUndefined();
|
|
26
|
+
expect(parseIso("abc-de-fg")).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ungültiger Kalendertag (Überlauf) → undefined", () => {
|
|
30
|
+
expect(parseIso("2026-02-31")).toBeUndefined();
|
|
31
|
+
expect(parseIso("2026-13-01")).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
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");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("Roundtrip parseIso → toIso ist stabil", () => {
|
|
42
|
+
const d = parseIso("2026-04-25");
|
|
43
|
+
expect(d).toBeDefined();
|
|
44
|
+
if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("parseTypedDate", () => {
|
|
49
|
+
test("ISO direkt getippt → Date", () => {
|
|
50
|
+
expect(toIso(parseTypedDate("2026-04-25", "de-DE") as Date)).toBe("2026-04-25");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("de-DE Reihenfolge d.m.y", () => {
|
|
54
|
+
const d = parseTypedDate("25.04.2026", "de-DE");
|
|
55
|
+
expect(d).toBeDefined();
|
|
56
|
+
if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("en-US Reihenfolge m/d/y", () => {
|
|
60
|
+
const d = parseTypedDate("04/25/2026", "en-US");
|
|
61
|
+
expect(d).toBeDefined();
|
|
62
|
+
if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("Trenner-Toleranz (gemischte Nicht-Ziffern)", () => {
|
|
66
|
+
const d = parseTypedDate("25 4 2026", "de-DE");
|
|
67
|
+
expect(d).toBeDefined();
|
|
68
|
+
if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("zweistelliges Jahr → 2000er", () => {
|
|
72
|
+
const d = parseTypedDate("25.04.26", "de-DE");
|
|
73
|
+
expect(d).toBeDefined();
|
|
74
|
+
if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("Teil-/Fehl-Eingabe → undefined", () => {
|
|
78
|
+
expect(parseTypedDate("", "de-DE")).toBeUndefined();
|
|
79
|
+
expect(parseTypedDate("25.04", "de-DE")).toBeUndefined();
|
|
80
|
+
expect(parseTypedDate("foo", "de-DE")).toBeUndefined();
|
|
81
|
+
expect(parseTypedDate("32.04.2026", "de-DE")).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("formatDateForInput", () => {
|
|
86
|
+
test("numerisch, locale-spezifisch, wieder parsebar", () => {
|
|
87
|
+
const formatted = formatDateForInput(new Date(2026, 3, 25), "de-DE");
|
|
88
|
+
const roundtrip = parseTypedDate(formatted, "de-DE");
|
|
89
|
+
expect(roundtrip).toBeDefined();
|
|
90
|
+
if (roundtrip !== undefined) expect(toIso(roundtrip)).toBe("2026-04-25");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// CalendarPopover — gemeinsamer Kalender-Trigger für DateInput und
|
|
2
|
+
// TimestampInput. Ein Icon-Button öffnet ein Radix-Popover mit
|
|
3
|
+
// react-day-picker (v9). captionLayout="dropdown" + startMonth/endMonth
|
|
4
|
+
// geben Monats- UND Jahres-Dropdown statt nur Monats-Vor/Zurück — der
|
|
5
|
+
// Grund für #369 (10 Jahre zurück war ~120 Klicks). min/max grauen
|
|
6
|
+
// Tage außerhalb des erlaubten Bereichs aus.
|
|
7
|
+
//
|
|
8
|
+
// Der Text-Input (Tippen) lebt bewusst NICHT hier, sondern im jeweiligen
|
|
9
|
+
// Primitive — date hat ein Feld, timestamp zwei (Datum + Uhrzeit). So
|
|
10
|
+
// teilen beide dieselbe Kalender-Mechanik ohne ihre Eingabe-Form zu teilen.
|
|
11
|
+
|
|
12
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
13
|
+
import { Calendar as CalendarIcon } from "lucide-react";
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import { DayPicker } from "react-day-picker";
|
|
16
|
+
import { cn } from "../lib/cn";
|
|
17
|
+
|
|
18
|
+
export type CalendarPopoverProps = {
|
|
19
|
+
readonly selected: Date | undefined;
|
|
20
|
+
readonly onSelect: (d: Date | undefined) => void;
|
|
21
|
+
readonly min?: Date;
|
|
22
|
+
readonly max?: Date;
|
|
23
|
+
readonly disabled?: boolean;
|
|
24
|
+
readonly hasError?: boolean;
|
|
25
|
+
/** a11y-Label für den Icon-Button (kein sichtbarer Text). */
|
|
26
|
+
readonly triggerLabel: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const triggerClass =
|
|
30
|
+
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-input " +
|
|
31
|
+
"bg-transparent text-muted-foreground shadow-sm transition-colors hover:bg-accent " +
|
|
32
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
|
33
|
+
"disabled:cursor-not-allowed disabled:opacity-50";
|
|
34
|
+
|
|
35
|
+
const popoverClass =
|
|
36
|
+
"z-50 rounded-md border bg-popover p-3 text-popover-foreground shadow-md " +
|
|
37
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out " +
|
|
38
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 " +
|
|
39
|
+
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95";
|
|
40
|
+
|
|
41
|
+
// Default-Range wenn das Feld kein min/max setzt — sonst wäre das
|
|
42
|
+
// Jahres-Dropdown leer/unbrauchbar. Zur Render-Zeit berechnet.
|
|
43
|
+
function defaultRange(): { start: Date; end: Date } {
|
|
44
|
+
const thisYear = new Date().getFullYear();
|
|
45
|
+
return { start: new Date(1900, 0, 1), end: new Date(thisYear + 10, 11, 31) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function CalendarPopover({
|
|
49
|
+
selected,
|
|
50
|
+
onSelect,
|
|
51
|
+
min,
|
|
52
|
+
max,
|
|
53
|
+
disabled,
|
|
54
|
+
hasError,
|
|
55
|
+
triggerLabel,
|
|
56
|
+
}: CalendarPopoverProps): ReactNode {
|
|
57
|
+
const range = defaultRange();
|
|
58
|
+
const startMonth = min ?? range.start;
|
|
59
|
+
const endMonth = max ?? range.end;
|
|
60
|
+
const disabledMatchers = [
|
|
61
|
+
...(min !== undefined ? [{ before: min }] : []),
|
|
62
|
+
...(max !== undefined ? [{ after: max }] : []),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<PopoverPrimitive.Root>
|
|
67
|
+
<PopoverPrimitive.Trigger asChild>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
aria-label={triggerLabel}
|
|
72
|
+
aria-invalid={hasError === true ? true : undefined}
|
|
73
|
+
className={cn(triggerClass, hasError === true && "border-destructive")}
|
|
74
|
+
>
|
|
75
|
+
<CalendarIcon className="size-4" aria-hidden="true" />
|
|
76
|
+
</button>
|
|
77
|
+
</PopoverPrimitive.Trigger>
|
|
78
|
+
<PopoverPrimitive.Portal>
|
|
79
|
+
<PopoverPrimitive.Content className={popoverClass} align="end" sideOffset={4}>
|
|
80
|
+
<DayPicker
|
|
81
|
+
mode="single"
|
|
82
|
+
captionLayout="dropdown"
|
|
83
|
+
startMonth={startMonth}
|
|
84
|
+
endMonth={endMonth}
|
|
85
|
+
selected={selected}
|
|
86
|
+
// Ohne defaultMonth zeigt DayPicker today statt selected —
|
|
87
|
+
// unintuitiv wenn der User schon ein Datum gewählt hat.
|
|
88
|
+
defaultMonth={selected}
|
|
89
|
+
{...(disabledMatchers.length > 0 && { disabled: disabledMatchers })}
|
|
90
|
+
onSelect={(d) => onSelect(d)}
|
|
91
|
+
classNames={dayPickerClasses}
|
|
92
|
+
/>
|
|
93
|
+
</PopoverPrimitive.Content>
|
|
94
|
+
</PopoverPrimitive.Portal>
|
|
95
|
+
</PopoverPrimitive.Root>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// classNames-Map für react-day-picker v9 — überschreibt die default-
|
|
100
|
+
// Klassen mit Tailwind/shadcn-Tokens. dropdowns/dropdown decken den
|
|
101
|
+
// captionLayout="dropdown"-Modus ab (Monat-/Jahr-Selects).
|
|
102
|
+
const dayPickerClasses = {
|
|
103
|
+
root: "rdp-root",
|
|
104
|
+
months: "flex flex-col gap-2",
|
|
105
|
+
month: "flex flex-col gap-2",
|
|
106
|
+
month_caption: "flex justify-center items-center h-7 text-sm font-medium",
|
|
107
|
+
caption_label: "text-sm font-medium",
|
|
108
|
+
dropdowns: "flex items-center gap-1 text-sm font-medium",
|
|
109
|
+
dropdown_root: "relative inline-flex items-center",
|
|
110
|
+
dropdown:
|
|
111
|
+
"rounded-sm border border-input bg-transparent px-1.5 py-0.5 text-sm " +
|
|
112
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
113
|
+
nav: "flex items-center gap-1 absolute right-1 top-2",
|
|
114
|
+
button_previous: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
|
|
115
|
+
button_next: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
|
|
116
|
+
weekdays: "flex",
|
|
117
|
+
weekday: "w-8 text-xs font-normal text-muted-foreground",
|
|
118
|
+
week: "flex mt-1",
|
|
119
|
+
day: "w-8 h-8 p-0 text-sm",
|
|
120
|
+
day_button:
|
|
121
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-sm hover:bg-accent " +
|
|
122
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
123
|
+
selected:
|
|
124
|
+
"[&_button]:bg-primary [&_button]:text-primary-foreground [&_button]:hover:bg-primary/90",
|
|
125
|
+
today: "[&_button]:underline",
|
|
126
|
+
outside: "text-muted-foreground/50",
|
|
127
|
+
disabled: "text-muted-foreground/30 pointer-events-none",
|
|
128
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
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`.
|
|
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).
|
|
9
|
+
|
|
10
|
+
import { type ReactNode, useState } from "react";
|
|
11
|
+
import { cn } from "../lib/cn";
|
|
12
|
+
import { CalendarPopover } from "./calendar-popover";
|
|
13
|
+
import { formatDateForInput, guessLocale, parseIso, parseTypedDate, toIso } from "./date-parse";
|
|
14
|
+
|
|
15
|
+
export type DateFieldProps = {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly value: string;
|
|
19
|
+
readonly onChange: (v: string | undefined) => void;
|
|
20
|
+
readonly disabled?: boolean;
|
|
21
|
+
readonly required?: boolean;
|
|
22
|
+
readonly hasError?: boolean;
|
|
23
|
+
readonly locale?: string;
|
|
24
|
+
/** Untere/obere Grenze als ISO `yyyy-mm-dd`. Begrenzt den Kalender
|
|
25
|
+
* (Jahres-Dropdown-Range + ausgegraute Tage). Server-Validierung läuft
|
|
26
|
+
* separat über die Zod-Schemas. */
|
|
27
|
+
readonly min?: string;
|
|
28
|
+
readonly max?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const inputClass =
|
|
32
|
+
"flex h-9 w-full items-center rounded-md border border-input bg-transparent " +
|
|
33
|
+
"px-3 py-1 text-sm shadow-sm transition-colors " +
|
|
34
|
+
"placeholder:text-muted-foreground " +
|
|
35
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
|
36
|
+
"disabled:cursor-not-allowed disabled:opacity-50";
|
|
37
|
+
|
|
38
|
+
export function DateField({
|
|
39
|
+
id,
|
|
40
|
+
name,
|
|
41
|
+
value,
|
|
42
|
+
onChange,
|
|
43
|
+
disabled,
|
|
44
|
+
required,
|
|
45
|
+
hasError,
|
|
46
|
+
locale,
|
|
47
|
+
min,
|
|
48
|
+
max,
|
|
49
|
+
}: DateFieldProps): ReactNode {
|
|
50
|
+
const resolvedLocale = locale ?? guessLocale();
|
|
51
|
+
const selected = parseIso(value);
|
|
52
|
+
|
|
53
|
+
// draft === null → zeige den kanonisch formatierten Wert. Sobald der User
|
|
54
|
+
// tippt, hält draft den Roh-Text, damit die Eingabe nicht bei jedem
|
|
55
|
+
// Tastendruck umformatiert wird. onBlur setzt zurück auf null.
|
|
56
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
57
|
+
const display =
|
|
58
|
+
draft ?? (selected !== undefined ? formatDateForInput(selected, resolvedLocale) : "");
|
|
59
|
+
|
|
60
|
+
function commitTyped(raw: string): void {
|
|
61
|
+
if (raw.trim() === "") {
|
|
62
|
+
onChange(undefined);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const parsed = parseTypedDate(raw, resolvedLocale);
|
|
66
|
+
if (parsed !== undefined) onChange(toIso(parsed));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const minDate = min !== undefined ? parseIso(min) : undefined;
|
|
70
|
+
const maxDate = max !== undefined ? parseIso(max) : undefined;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex items-center gap-1">
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
inputMode="numeric"
|
|
77
|
+
id={id}
|
|
78
|
+
name={name}
|
|
79
|
+
value={display}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
required={required}
|
|
82
|
+
aria-invalid={hasError === true ? true : undefined}
|
|
83
|
+
placeholder={formatDateForInput(new Date(2026, 11, 31), resolvedLocale)}
|
|
84
|
+
onChange={(e) => {
|
|
85
|
+
setDraft(e.target.value);
|
|
86
|
+
commitTyped(e.target.value);
|
|
87
|
+
}}
|
|
88
|
+
onBlur={() => setDraft(null)}
|
|
89
|
+
className={cn(
|
|
90
|
+
inputClass,
|
|
91
|
+
hasError === true && "border-destructive focus-visible:ring-destructive",
|
|
92
|
+
)}
|
|
93
|
+
/>
|
|
94
|
+
<CalendarPopover
|
|
95
|
+
selected={selected}
|
|
96
|
+
onSelect={(d) => {
|
|
97
|
+
onChange(d !== undefined ? toIso(d) : undefined);
|
|
98
|
+
setDraft(null);
|
|
99
|
+
}}
|
|
100
|
+
{...(minDate !== undefined && { min: minDate })}
|
|
101
|
+
{...(maxDate !== undefined && { max: maxDate })}
|
|
102
|
+
{...(disabled !== undefined && { disabled })}
|
|
103
|
+
{...(hasError !== undefined && { hasError })}
|
|
104
|
+
triggerLabel="Kalender öffnen"
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -1,165 +1,6 @@
|
|
|
1
|
-
// DateInput —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// funktioniert.
|
|
6
|
-
//
|
|
7
|
-
// Warum nicht type=date: Native date-Inputs sehen je nach Browser/OS
|
|
8
|
-
// völlig anders aus, der Picker-Overlay ist nicht stylebar, und der
|
|
9
|
-
// Format-Match ist Locale-spezifisch außerhalb unserer Kontrolle.
|
|
10
|
-
// Mit Popover + Calendar haben wir konsistentes Linear-Look-and-Feel.
|
|
1
|
+
// DateInput (kind:"date") — die tippbare Datums-Eingabe lebt in DateField,
|
|
2
|
+
// damit `date` und `timestamp` dieselbe Tipp-/Kalender-UX teilen (#369).
|
|
3
|
+
// Diese Datei hält nur das öffentliche Mapping; die Logik ist in
|
|
4
|
+
// date-field.tsx, die Date-Parse-Utils in date-parse.ts.
|
|
11
5
|
|
|
12
|
-
|
|
13
|
-
import { Calendar as CalendarIcon } from "lucide-react";
|
|
14
|
-
import { type ReactNode, useState } from "react";
|
|
15
|
-
import { DayPicker } from "react-day-picker";
|
|
16
|
-
import { cn } from "../lib/cn";
|
|
17
|
-
|
|
18
|
-
export type DateInputProps = {
|
|
19
|
-
readonly id: string;
|
|
20
|
-
readonly name: string;
|
|
21
|
-
readonly value: string;
|
|
22
|
-
readonly onChange: (v: string | undefined) => void;
|
|
23
|
-
readonly disabled?: boolean;
|
|
24
|
-
readonly required?: boolean;
|
|
25
|
-
readonly hasError?: boolean;
|
|
26
|
-
readonly locale?: string;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const triggerClass =
|
|
30
|
-
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent " +
|
|
31
|
-
"px-3 py-1 text-sm shadow-sm transition-colors text-left " +
|
|
32
|
-
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
|
|
33
|
-
"disabled:cursor-not-allowed disabled:opacity-50";
|
|
34
|
-
|
|
35
|
-
const popoverClass =
|
|
36
|
-
"z-50 rounded-md border bg-popover p-3 text-popover-foreground shadow-md " +
|
|
37
|
-
"data-[state=open]:animate-in data-[state=closed]:animate-out " +
|
|
38
|
-
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 " +
|
|
39
|
-
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95";
|
|
40
|
-
|
|
41
|
-
export function DateInput({
|
|
42
|
-
id,
|
|
43
|
-
name,
|
|
44
|
-
value,
|
|
45
|
-
onChange,
|
|
46
|
-
disabled,
|
|
47
|
-
required,
|
|
48
|
-
hasError,
|
|
49
|
-
locale,
|
|
50
|
-
}: DateInputProps): ReactNode {
|
|
51
|
-
const [open, setOpen] = useState(false);
|
|
52
|
-
const resolvedLocale = locale ?? guessLocale();
|
|
53
|
-
const selected = parseIso(value);
|
|
54
|
-
|
|
55
|
-
const display =
|
|
56
|
-
selected !== undefined
|
|
57
|
-
? selected.toLocaleDateString(resolvedLocale, {
|
|
58
|
-
year: "numeric",
|
|
59
|
-
month: "long",
|
|
60
|
-
day: "numeric",
|
|
61
|
-
})
|
|
62
|
-
: "";
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
66
|
-
<PopoverPrimitive.Trigger asChild>
|
|
67
|
-
<button
|
|
68
|
-
type="button"
|
|
69
|
-
id={id}
|
|
70
|
-
name={name}
|
|
71
|
-
disabled={disabled}
|
|
72
|
-
// aria-required wird auf <button> nicht unterstützt — stattdessen
|
|
73
|
-
// markiert <Field>-Label das Required mit "*" für Sehende und
|
|
74
|
-
// gibt den Status über aria-invalid (bei Fehler) durch.
|
|
75
|
-
data-required={required === true ? "true" : undefined}
|
|
76
|
-
aria-invalid={hasError === true ? true : undefined}
|
|
77
|
-
className={cn(
|
|
78
|
-
triggerClass,
|
|
79
|
-
hasError === true && "border-destructive focus-visible:ring-destructive",
|
|
80
|
-
)}
|
|
81
|
-
>
|
|
82
|
-
<span className={cn(display === "" && "text-muted-foreground")}>
|
|
83
|
-
{display === "" ? "—" : display}
|
|
84
|
-
</span>
|
|
85
|
-
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
|
86
|
-
</button>
|
|
87
|
-
</PopoverPrimitive.Trigger>
|
|
88
|
-
<PopoverPrimitive.Portal>
|
|
89
|
-
<PopoverPrimitive.Content className={popoverClass} align="start" sideOffset={4}>
|
|
90
|
-
<DayPicker
|
|
91
|
-
mode="single"
|
|
92
|
-
selected={selected}
|
|
93
|
-
// Ohne defaultMonth zeigt DayPicker today statt selected —
|
|
94
|
-
// unintuitiv wenn der User schon ein Datum gewählt hat und
|
|
95
|
-
// den Calendar nochmal öffnet.
|
|
96
|
-
defaultMonth={selected}
|
|
97
|
-
onSelect={(d) => {
|
|
98
|
-
onChange(d !== undefined ? toIso(d) : undefined);
|
|
99
|
-
setOpen(false);
|
|
100
|
-
}}
|
|
101
|
-
classNames={dayPickerClasses}
|
|
102
|
-
/>
|
|
103
|
-
</PopoverPrimitive.Content>
|
|
104
|
-
</PopoverPrimitive.Portal>
|
|
105
|
-
</PopoverPrimitive.Root>
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// classNames-Map für react-day-picker v9 — überschreibt die default-
|
|
110
|
-
// Klassen mit Tailwind/shadcn-Tokens. Nur die wichtigsten Slots; der
|
|
111
|
-
// Rest erbt die rdp-Default-Styles. Padding/Größen sind klein gehalten
|
|
112
|
-
// damit der Popover nicht das halbe Viewport einnimmt.
|
|
113
|
-
const dayPickerClasses = {
|
|
114
|
-
root: "rdp-root",
|
|
115
|
-
months: "flex flex-col gap-2",
|
|
116
|
-
month: "flex flex-col gap-2",
|
|
117
|
-
month_caption: "flex justify-center items-center h-7 text-sm font-medium",
|
|
118
|
-
caption_label: "text-sm font-medium",
|
|
119
|
-
nav: "flex items-center gap-1 absolute right-3 top-3",
|
|
120
|
-
button_previous: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
|
|
121
|
-
button_next: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
|
|
122
|
-
weekdays: "flex",
|
|
123
|
-
weekday: "w-8 text-xs font-normal text-muted-foreground",
|
|
124
|
-
week: "flex mt-1",
|
|
125
|
-
day: "w-8 h-8 p-0 text-sm",
|
|
126
|
-
day_button:
|
|
127
|
-
"inline-flex h-8 w-8 items-center justify-center rounded-sm hover:bg-accent " +
|
|
128
|
-
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
129
|
-
selected:
|
|
130
|
-
"[&_button]:bg-primary [&_button]:text-primary-foreground [&_button]:hover:bg-primary/90",
|
|
131
|
-
today: "[&_button]:underline",
|
|
132
|
-
outside: "text-muted-foreground/50",
|
|
133
|
-
disabled: "text-muted-foreground/30 pointer-events-none",
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
export function parseIso(v: string): Date | undefined {
|
|
137
|
-
if (v === "") return undefined;
|
|
138
|
-
// Date(yyyy-mm-dd) parses as UTC — wir wollen local damit "2026-04-25"
|
|
139
|
-
// im Calendar nicht je nach Timezone als 24. oder 25. erscheint.
|
|
140
|
-
const parts = v.split("-");
|
|
141
|
-
if (parts.length !== 3) return undefined;
|
|
142
|
-
const [y, m, d] = parts.map(Number);
|
|
143
|
-
if (
|
|
144
|
-
y === undefined ||
|
|
145
|
-
m === undefined ||
|
|
146
|
-
d === undefined ||
|
|
147
|
-
Number.isNaN(y) ||
|
|
148
|
-
Number.isNaN(m) ||
|
|
149
|
-
Number.isNaN(d)
|
|
150
|
-
)
|
|
151
|
-
return undefined;
|
|
152
|
-
return new Date(y, m - 1, d);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function toIso(d: Date): string {
|
|
156
|
-
const y = d.getFullYear();
|
|
157
|
-
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
158
|
-
const day = String(d.getDate()).padStart(2, "0");
|
|
159
|
-
return `${y}-${m}-${day}`;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function guessLocale(): string {
|
|
163
|
-
if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
|
|
164
|
-
return "en-US";
|
|
165
|
-
}
|
|
6
|
+
export { DateField as DateInput, type DateFieldProps as DateInputProps } from "./date-field";
|
|
@@ -0,0 +1,104 @@
|
|
|
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.
|
|
7
|
+
|
|
8
|
+
export function guessLocale(): string {
|
|
9
|
+
if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
|
|
10
|
+
return "en-US";
|
|
11
|
+
}
|
|
12
|
+
|
|
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) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return date;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseIso(v: string): Date | undefined {
|
|
30
|
+
if (v === "") return undefined;
|
|
31
|
+
const parts = v.split("-");
|
|
32
|
+
if (parts.length !== 3) return undefined;
|
|
33
|
+
const [y, m, d] = parts.map(Number);
|
|
34
|
+
if (
|
|
35
|
+
y === undefined ||
|
|
36
|
+
m === undefined ||
|
|
37
|
+
d === undefined ||
|
|
38
|
+
Number.isNaN(y) ||
|
|
39
|
+
Number.isNaN(m) ||
|
|
40
|
+
Number.isNaN(d)
|
|
41
|
+
) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return makeLocalDate(y, m, d);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function toIso(d: Date): string {
|
|
48
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
49
|
+
}
|
|
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" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type DateSlot = "y" | "m" | "d";
|
|
59
|
+
|
|
60
|
+
// Feld-Reihenfolge des numerischen Locale-Formats. de → [d,m,y],
|
|
61
|
+
// en-US → [m,d,y], ISO-ähnliche Locales → [y,m,d].
|
|
62
|
+
function localeDateOrder(locale: string): readonly DateSlot[] {
|
|
63
|
+
const ref = new Date(2026, 0, 2); // Tag 2, Monat 1 — alle Felder eindeutig
|
|
64
|
+
const order: DateSlot[] = [];
|
|
65
|
+
for (const part of new Intl.DateTimeFormat(locale).formatToParts(ref)) {
|
|
66
|
+
if (part.type === "year") order.push("y");
|
|
67
|
+
else if (part.type === "month") order.push("m");
|
|
68
|
+
else if (part.type === "day") order.push("d");
|
|
69
|
+
}
|
|
70
|
+
return order;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Getippte Eingabe → Date. Akzeptiert ISO (yyyy-mm-dd) direkt sowie drei
|
|
74
|
+
// numerische Tokens in Locale-Reihenfolge mit beliebigem Trenner
|
|
75
|
+
// (".", "/", "-", " "). Zweistellige Jahre → 2000er. Teil-/Fehl-Eingaben
|
|
76
|
+
// → undefined (Caller behält dann den Roh-Text, committet nichts).
|
|
77
|
+
export function parseTypedDate(input: string, locale: string): Date | undefined {
|
|
78
|
+
const trimmed = input.trim();
|
|
79
|
+
if (trimmed === "") return undefined;
|
|
80
|
+
|
|
81
|
+
const iso = parseIso(trimmed);
|
|
82
|
+
if (iso !== undefined) return iso;
|
|
83
|
+
|
|
84
|
+
const tokens = trimmed.split(/\D+/).filter((t) => t !== "");
|
|
85
|
+
if (tokens.length !== 3) return undefined;
|
|
86
|
+
const nums = tokens.map(Number);
|
|
87
|
+
if (nums.some(Number.isNaN)) return undefined;
|
|
88
|
+
|
|
89
|
+
const order = localeDateOrder(locale);
|
|
90
|
+
if (order.length !== 3) return undefined;
|
|
91
|
+
|
|
92
|
+
let y = 0;
|
|
93
|
+
let m = 0;
|
|
94
|
+
let d = 0;
|
|
95
|
+
order.forEach((slot, i) => {
|
|
96
|
+
const val = nums[i] ?? 0;
|
|
97
|
+
if (slot === "y") y = val;
|
|
98
|
+
else if (slot === "m") m = val;
|
|
99
|
+
else d = val;
|
|
100
|
+
});
|
|
101
|
+
if (y < 100) y += 2000;
|
|
102
|
+
|
|
103
|
+
return makeLocalDate(y, m, d);
|
|
104
|
+
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -270,6 +270,8 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
270
270
|
value={props.value}
|
|
271
271
|
onChange={props.onChange}
|
|
272
272
|
{...(props.locale !== undefined && { locale: props.locale })}
|
|
273
|
+
{...(props.min !== undefined && { min: props.min })}
|
|
274
|
+
{...(props.max !== undefined && { max: props.max })}
|
|
273
275
|
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
274
276
|
{...(props.required !== undefined && { required: props.required })}
|
|
275
277
|
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
@@ -348,10 +350,12 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
348
350
|
value={props.value}
|
|
349
351
|
onChange={props.onChange}
|
|
350
352
|
{...(props.wallClock !== undefined && { wallClock: props.wallClock })}
|
|
353
|
+
{...(props.locale !== undefined && { locale: props.locale })}
|
|
354
|
+
{...(props.min !== undefined && { min: props.min })}
|
|
355
|
+
{...(props.max !== undefined && { max: props.max })}
|
|
351
356
|
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
352
357
|
{...(props.required !== undefined && { required: props.required })}
|
|
353
358
|
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
354
|
-
className={cn(inputClassBase, errorClass)}
|
|
355
359
|
/>
|
|
356
360
|
);
|
|
357
361
|
case "textarea":
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
// TimestampInput —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// TimestampInput (kind:"timestamp") — gemeinsame Datums-Eingabe (DateField,
|
|
2
|
+
// Tippen + Jahres-Kalender) plus ein Uhrzeit-Input. Bis #369 war dies ein
|
|
3
|
+
// natives <input type="datetime-local"> — tippbar, aber mit nicht-stylebarem
|
|
4
|
+
// Browser-Picker ohne Jahres-Navigation und divergent zu DateInput.
|
|
5
|
+
//
|
|
6
|
+
// Die Wire-Konvertierung bleibt unverändert: der Server validiert timestamp
|
|
7
|
+
// als ISO-UTC mit `Z` (z.iso.datetime()) bzw. als Wall-Clock ohne Offset bei
|
|
8
|
+
// locatedTimestamps (z.iso.datetime({ local: true })). Intern rechnen wir
|
|
9
|
+
// über die datetime-local-Form `yyyy-MM-ddTHH:mm`; timestampToInputValue /
|
|
10
|
+
// inputValueToTimestamp kapseln die UTC↔Wall-Clock-Umrechnung (Bug-Bash-2,
|
|
11
|
+
// 2026-06-08) und sind separat getestet.
|
|
9
12
|
|
|
10
|
-
import
|
|
13
|
+
import { type ReactNode, useState } from "react";
|
|
11
14
|
import { cn } from "../lib/cn";
|
|
15
|
+
import { DateField } from "./date-field";
|
|
12
16
|
|
|
13
17
|
const LOCAL_MINUTES = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
14
18
|
const HAS_OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
|
|
@@ -53,9 +57,25 @@ export type TimestampInputProps = {
|
|
|
53
57
|
readonly disabled?: boolean;
|
|
54
58
|
readonly required?: boolean;
|
|
55
59
|
readonly hasError?: boolean;
|
|
56
|
-
readonly
|
|
60
|
+
readonly locale?: string;
|
|
61
|
+
/** Untere/obere Grenze als ISO-Datetime. Begrenzt den Kalender auf
|
|
62
|
+
* Tages-Granularität; die exakte Uhrzeit-Grenze setzt die Zod-Validierung
|
|
63
|
+
* durch. */
|
|
64
|
+
readonly min?: string;
|
|
65
|
+
readonly max?: string;
|
|
57
66
|
};
|
|
58
67
|
|
|
68
|
+
const timeInputClass =
|
|
69
|
+
"h-9 w-[7.5rem] shrink-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm " +
|
|
70
|
+
"shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 " +
|
|
71
|
+
"focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
|
|
72
|
+
|
|
73
|
+
function toDatePart(iso: string | undefined): string | undefined {
|
|
74
|
+
if (iso === undefined || iso === "") return undefined;
|
|
75
|
+
const v = timestampToInputValue(iso).slice(0, 10);
|
|
76
|
+
return v !== "" ? v : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
export function TimestampInput({
|
|
60
80
|
id,
|
|
61
81
|
name,
|
|
@@ -65,24 +85,59 @@ export function TimestampInput({
|
|
|
65
85
|
disabled,
|
|
66
86
|
required,
|
|
67
87
|
hasError,
|
|
68
|
-
|
|
88
|
+
locale,
|
|
89
|
+
min,
|
|
90
|
+
max,
|
|
69
91
|
}: TimestampInputProps): ReactNode {
|
|
92
|
+
const local = timestampToInputValue(value);
|
|
93
|
+
const isoDate = local !== "" ? local.slice(0, 10) : "";
|
|
94
|
+
// Uhrzeit ohne gesetztes Datum würde im Wire-Wert verschwinden — lokal
|
|
95
|
+
// halten, bis ein Datum gewählt ist. Sobald `value` ein Datum trägt, ist
|
|
96
|
+
// dessen Uhrzeit maßgeblich.
|
|
97
|
+
const [timeText, setTimeText] = useState("");
|
|
98
|
+
const effectiveTime = local !== "" ? local.slice(11, 16) : timeText;
|
|
99
|
+
|
|
100
|
+
function emit(nextIsoDate: string, nextTime: string): void {
|
|
101
|
+
if (nextIsoDate === "") {
|
|
102
|
+
onChange(undefined);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const localStr = `${nextIsoDate}T${nextTime === "" ? "00:00" : nextTime}`;
|
|
106
|
+
onChange(inputValueToTimestamp(localStr, wallClock === true));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const minPart = toDatePart(min);
|
|
110
|
+
const maxPart = toDatePart(max);
|
|
111
|
+
|
|
70
112
|
return (
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<DateField
|
|
115
|
+
id={id}
|
|
116
|
+
name={name}
|
|
117
|
+
value={isoDate}
|
|
118
|
+
onChange={(d) => emit(d ?? "", effectiveTime)}
|
|
119
|
+
{...(locale !== undefined && { locale })}
|
|
120
|
+
{...(minPart !== undefined && { min: minPart })}
|
|
121
|
+
{...(maxPart !== undefined && { max: maxPart })}
|
|
122
|
+
{...(disabled !== undefined && { disabled })}
|
|
123
|
+
{...(required !== undefined && { required })}
|
|
124
|
+
{...(hasError !== undefined && { hasError })}
|
|
125
|
+
/>
|
|
126
|
+
<input
|
|
127
|
+
type="time"
|
|
128
|
+
aria-label="Uhrzeit"
|
|
129
|
+
disabled={disabled}
|
|
130
|
+
aria-invalid={hasError === true ? true : undefined}
|
|
131
|
+
value={effectiveTime}
|
|
132
|
+
onChange={(e) => {
|
|
133
|
+
setTimeText(e.target.value);
|
|
134
|
+
if (isoDate !== "") emit(isoDate, e.target.value);
|
|
135
|
+
}}
|
|
136
|
+
className={cn(
|
|
137
|
+
timeInputClass,
|
|
138
|
+
hasError === true && "border-destructive focus-visible:ring-destructive",
|
|
139
|
+
)}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
87
142
|
);
|
|
88
143
|
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// date-input Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
|
|
2
|
-
//
|
|
3
|
-
// parseIso/toIso aus date-input.tsx (exportiert für Test). Pinst das
|
|
4
|
-
// non-obvious Timezone-Verhalten: parseIso baut ein LOKALES Date (nicht
|
|
5
|
-
// UTC), damit "2026-04-25" im Calendar nicht je nach Zeitzone auf den
|
|
6
|
-
// 24. kippt.
|
|
7
|
-
|
|
8
|
-
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import { parseIso, toIso } from "../date-input";
|
|
10
|
-
|
|
11
|
-
describe("parseIso", () => {
|
|
12
|
-
test("gültiges yyyy-mm-dd → lokales Date (kein UTC-Shift)", () => {
|
|
13
|
-
const d = parseIso("2026-04-25");
|
|
14
|
-
expect(d).toBeInstanceOf(Date);
|
|
15
|
-
expect(d?.getFullYear()).toBe(2026);
|
|
16
|
-
expect(d?.getMonth()).toBe(3); // 0-based: April
|
|
17
|
-
expect(d?.getDate()).toBe(25);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("leerer String → undefined", () => {
|
|
21
|
-
expect(parseIso("")).toBeUndefined();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("falsche Teil-Anzahl oder nicht-numerische Teile → undefined", () => {
|
|
25
|
-
expect(parseIso("2026-04")).toBeUndefined();
|
|
26
|
-
expect(parseIso("2026/04/25")).toBeUndefined();
|
|
27
|
-
expect(parseIso("abc-de-fg")).toBeUndefined();
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("toIso", () => {
|
|
32
|
-
test("Date → yyyy-mm-dd mit Zero-Padding", () => {
|
|
33
|
-
expect(toIso(new Date(2026, 3, 5))).toBe("2026-04-05");
|
|
34
|
-
expect(toIso(new Date(2026, 11, 25))).toBe("2026-12-25");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("Roundtrip parseIso → toIso ist stabil", () => {
|
|
38
|
-
const d = parseIso("2026-04-25");
|
|
39
|
-
expect(d).toBeDefined();
|
|
40
|
-
if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
|
|
41
|
-
});
|
|
42
|
-
});
|