@cosmicdrift/kumiko-renderer-web 0.52.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.52.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: Trigger zeigt formatiertes Datum (locale-aware),
3
- // Popover öffnet das DayPicker, Auswahl gibt ISO-yyyy-mm-dd zurück.
4
- // Wert-Roundtrip (ISO Date → ISO) muss tag-stable sein, sonst
5
- // zeigt der Calendar je nach Timezone den Vortag.
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("trigger zeigt formatiertes Datum (de-DE)", () => {
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("button").textContent).toContain("23. April 2026");
18
+ expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("23.04.2026");
18
19
  });
19
20
 
20
- test("trigger zeigt formatiertes Datum (en-US)", () => {
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("button").textContent).toContain("April 23, 2026");
25
+ expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("04/23/2026");
25
26
  });
26
27
 
27
- test('trigger zeigt "—" Placeholder bei leerem Wert', () => {
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("button").textContent).toContain("");
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": Trigger zeigt formatiertes Datum, kein nativer date-Input', () => {
152
- // Default-DateInput nutzt Radix-Popover + DayPicker statt native
153
- // <input type="date">. Trigger ist ein Button mit dem formatierten
154
- // Datum als sichtbarem Text.
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
- const trigger = screen.getByRole("button");
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
- test("rendert UTC-Wert als valides datetime-local und emittiert Z-Instant", () => {
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 emitted: (string | undefined)[] = [];
54
- const view = render(
66
+ const [v, setV] = useState("");
67
+ return (
55
68
  <Input
56
69
  kind="timestamp"
57
70
  id="ts"
58
71
  name="ts"
59
- value="2026-06-08T19:09:00Z"
60
- onChange={(v) => emitted.push(v)}
61
- />,
72
+ value={v}
73
+ {...(wallClock === true && { wallClock: true })}
74
+ onChange={(nv) => {
75
+ onEmit(nv);
76
+ setV(nv ?? "");
77
+ }}
78
+ />
62
79
  );
63
- const input = view.container.querySelector("input");
64
- if (!input) throw new Error("expected input");
65
- // datetime-local akzeptiert keine Z-Suffixe der angezeigte Wert
66
- // muss konvertiert sein, sonst zeigt der Browser ein leeres Feld.
67
- expect(input.value).toMatch(DATETIME_LOCAL);
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
- fireEvent.change(input, { target: { value: "2026-06-08T21:09" } });
70
- expect(emitted).toHaveLength(1);
71
- const value = emitted[0];
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
- const { Input } = defaultPrimitives;
78
- const emitted: (string | undefined)[] = [];
79
- const view = render(
80
- <Input
81
- kind="timestamp"
82
- id="ts"
83
- name="ts"
84
- value=""
85
- wallClock
86
- onChange={(v) => emitted.push(v)}
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
- stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key" } }),
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 human-relevant hub; system-internal keys stay hidden", () => {
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 ONLY because `ops` opted a human (SystemAdmin)
111
- // into write; its child ops-system shows, billing's system-internal key
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).not.toContain("config:nav:billing-system");
122
+ expect(names).toContain("config:nav:billing-system");
116
123
 
117
- // schema-level completeness: the hidden key IS generated (a system actor
118
- // could resolve it) it's gated at resolve-time, not omitted at build-time.
119
- expect(navMembersOf("settings").has("config:nav:billing-system")).toBe(true);
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 — Radix-Popover + react-day-picker. Trigger ist ein
2
- // Button im Input-Style der das formatierte Datum zeigt; Popover
3
- // öffnet eine Calendar-Sheet zur Auswahl. Underlying-Wert bleibt
4
- // ISO `yyyy-mm-dd` damit Server-/Wire-Serialisierung unverändert
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
- import * as PopoverPrimitive from "@radix-ui/react-popover";
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
+ }
@@ -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 — natives <input type="datetime-local"> mit
2
- // Wert-Konvertierung. Der Server validiert timestamp-Felder als
3
- // ISO-UTC mit `Z` (z.iso.datetime()) bzw. als Wall-Clock ohne Offset
4
- // bei locatedTimestamps (z.iso.datetime({ local: true })). Das native
5
- // datetime-local-Input spricht aber IMMER lokale Wall-Clock ohne
6
- // Offset ohne Konvertierung ging jeder UTC-Timestamp als
7
- // offset-loser String raus und der Server lehnte mit invalid_format
8
- // ab (Bug-Bash-2, 2026-06-08).
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 type { ChangeEvent, ReactNode } from "react";
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 className?: string;
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
- className,
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
- <input
72
- type="datetime-local"
73
- id={id}
74
- name={name}
75
- disabled={disabled}
76
- required={required}
77
- aria-invalid={hasError === true ? true : undefined}
78
- value={timestampToInputValue(value)}
79
- onChange={(e: ChangeEvent<HTMLInputElement>) =>
80
- onChange(inputValueToTimestamp(e.target.value, wallClock === true))
81
- }
82
- // Das `flex` der Input-Basisklasse macht die Shadow-DOM-Teile des
83
- // datetime-local zu Flex-Items — der Picker-Indicator klebt dann
84
- // direkt am Text statt am rechten Rand. ml-auto schiebt ihn zurück.
85
- className={cn("[&::-webkit-calendar-picker-indicator]:ml-auto", className)}
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
- });