@cosmicdrift/kumiko-renderer-web 0.193.1 → 0.195.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.
Files changed (49) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/create-app.test.tsx +10 -2
  3. package/src/__tests__/entity-edit-redirect.test.tsx +143 -0
  4. package/src/__tests__/field-hide-label.test.tsx +166 -1
  5. package/src/__tests__/kumiko-screen.test.tsx +59 -0
  6. package/src/__tests__/nav-tree.test.tsx +43 -11
  7. package/src/__tests__/primitives.test.tsx +33 -2
  8. package/src/__tests__/render-edit.test.tsx +397 -9
  9. package/src/__tests__/test-utils.tsx +20 -0
  10. package/src/__tests__/wizard-form-validation.test.tsx +129 -1
  11. package/src/app/__tests__/merge-content-editors.test.ts +2 -2
  12. package/src/app/__tests__/plain-content-editor.test.tsx +28 -3
  13. package/src/app/__tests__/rich-content-editor.test.tsx +6 -2
  14. package/src/app/__tests__/tiptap-editor.test.tsx +102 -7
  15. package/src/app/client-plugin.tsx +3 -3
  16. package/src/app/create-app.tsx +40 -49
  17. package/src/app/plain-content-editor.tsx +9 -8
  18. package/src/app/tiptap-editor.tsx +40 -12
  19. package/src/index.ts +1 -0
  20. package/src/layout/__tests__/workspace-switcher.test.tsx +62 -2
  21. package/src/layout/sidebar-panel.tsx +1 -4
  22. package/src/layout/workspace-switcher.tsx +26 -13
  23. package/src/lib/__tests__/resize-image.test.ts +42 -3
  24. package/src/lib/clamp.ts +3 -0
  25. package/src/lib/resize-image.ts +6 -1
  26. package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
  27. package/src/primitives/__tests__/date-field.test.tsx +37 -0
  28. package/src/primitives/__tests__/date-parse.test.ts +14 -2
  29. package/src/primitives/__tests__/embedded-list-input.test.tsx +77 -2
  30. package/src/primitives/__tests__/file-upload.test.tsx +24 -0
  31. package/src/primitives/date-field.tsx +8 -0
  32. package/src/primitives/date-parse.ts +6 -1
  33. package/src/primitives/dropdown-menu.tsx +23 -0
  34. package/src/primitives/embedded-list-input.tsx +23 -13
  35. package/src/primitives/file-upload.tsx +3 -0
  36. package/src/primitives/index.tsx +114 -120
  37. package/src/primitives/money-input.tsx +4 -7
  38. package/src/primitives/use-narrow-viewport.ts +28 -0
  39. package/src/ui/sheet.tsx +3 -1
  40. package/src/widgets/__tests__/drawer.test.tsx +315 -0
  41. package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
  42. package/src/widgets/__tests__/upload-zone.test.tsx +117 -2
  43. package/src/widgets/__tests__/widgets.test.tsx +25 -0
  44. package/src/widgets/ai-text-field.tsx +3 -1
  45. package/src/widgets/drawer.tsx +62 -31
  46. package/src/widgets/infinity-list.tsx +18 -5
  47. package/src/widgets/progress-bar.tsx +3 -1
  48. package/src/widgets/step-bar.tsx +1 -1
  49. package/src/widgets/upload-zone.tsx +45 -3
@@ -6,14 +6,14 @@
6
6
  // callback. Radix opens on pointerdown → userEvent instead of
7
7
  // fireEvent.click, same as language-switcher.test.tsx.
8
8
 
9
- import { describe, expect, mock, test } from "bun:test";
9
+ import { describe, expect, mock, spyOn, test } from "bun:test";
10
10
  import {
11
11
  createStaticLocaleResolver,
12
12
  LocaleProvider,
13
13
  type WorkspaceSchema,
14
14
  } from "@cosmicdrift/kumiko-renderer";
15
15
  import userEvent from "@testing-library/user-event";
16
- import { renderWithSidebar, screen } from "../../__tests__/test-utils";
16
+ import { render, renderWithSidebar, screen } from "../../__tests__/test-utils";
17
17
  import { WorkspaceSwitcher } from "../workspace-switcher";
18
18
 
19
19
  function ws(id: string, label = id): WorkspaceSchema {
@@ -55,6 +55,66 @@ describe("WorkspaceSwitcher — Render", () => {
55
55
  expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-checked")).toBe("true");
56
56
  });
57
57
 
58
+ test("entries carry role=menuitemradio (exclusive choice), not menuitemcheckbox", async () => {
59
+ const user = userEvent.setup();
60
+ renderWithSidebar(
61
+ <WorkspaceSwitcher
62
+ workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
63
+ activeId="a"
64
+ onSelect={() => {}}
65
+ />,
66
+ );
67
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
68
+ expect(screen.getByTestId("workspace-tab-a").getAttribute("role")).toBe("menuitemradio");
69
+ expect(screen.getByTestId("workspace-tab-b").getAttribute("role")).toBe("menuitemradio");
70
+ });
71
+
72
+ test("trigger has an accessible name beyond the active workspace label", () => {
73
+ renderWithSidebar(
74
+ <WorkspaceSwitcher
75
+ workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
76
+ activeId="a"
77
+ onSelect={() => {}}
78
+ />,
79
+ );
80
+ expect(
81
+ screen.getByTestId("workspace-switcher-trigger").getAttribute("aria-label"),
82
+ ).toBeTruthy();
83
+ });
84
+
85
+ test("activeId zeigt auf keinen sichtbaren Workspace → Trigger zeigt Fallback-Label statt leerem span (fw#1816)", () => {
86
+ renderWithSidebar(
87
+ <WorkspaceSwitcher
88
+ workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
89
+ activeId="stale-id-not-in-list"
90
+ onSelect={() => {}}
91
+ testId="sw"
92
+ />,
93
+ );
94
+ expect(screen.getByTestId("sw").textContent).not.toBe("");
95
+ expect(screen.getByText("Select workspace")).toBeTruthy();
96
+ });
97
+
98
+ test("rendering outside a SidebarProvider throws — the JSDoc requirement is a real crash, not just documentation (fw#1816)", () => {
99
+ // SidebarMenuButton calls useSidebar() internally; without a
100
+ // SidebarProvider ancestor that throws synchronously during render.
101
+ // Silence the expected console.error noise React logs alongside it.
102
+ const consoleError = spyOn(console, "error").mockImplementation(() => {});
103
+ try {
104
+ expect(() =>
105
+ render(
106
+ <WorkspaceSwitcher
107
+ workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
108
+ activeId="a"
109
+ onSelect={() => {}}
110
+ />,
111
+ ),
112
+ ).toThrow(/useSidebar must be used within a SidebarProvider/);
113
+ } finally {
114
+ consoleError.mockRestore();
115
+ }
116
+ });
117
+
58
118
  test("Click auf einen Eintrag ruft onSelect mit der Workspace-id", async () => {
59
119
  const user = userEvent.setup();
60
120
  const onSelect = mock((_id: string) => {});
@@ -22,6 +22,7 @@ import {
22
22
  useState,
23
23
  } from "react";
24
24
  import { createPortal } from "react-dom";
25
+ import { clamp } from "../lib/clamp";
25
26
  import { cn } from "../lib/cn";
26
27
 
27
28
  type SlotElement = HTMLElement | null;
@@ -57,10 +58,6 @@ const DEFAULT_WIDTH = 340;
57
58
  const DEFAULT_MIN = 260;
58
59
  const DEFAULT_MAX = 640;
59
60
 
60
- function clamp(value: number, min: number, max: number): number {
61
- return Math.min(Math.max(value, min), max);
62
- }
63
-
64
61
  function readStoredWidth(key: string | undefined): number | null {
65
62
  if (key === undefined || typeof window === "undefined") return null;
66
63
  const raw = window.localStorage.getItem(key);
@@ -20,8 +20,9 @@ import { ChevronsUpDown } from "lucide-react";
20
20
  import type { ReactNode } from "react";
21
21
  import {
22
22
  DropdownMenu,
23
- DropdownMenuCheckboxItem,
24
23
  DropdownMenuContent,
24
+ DropdownMenuRadioGroup,
25
+ DropdownMenuRadioItem,
25
26
  DropdownMenuTrigger,
26
27
  } from "../primitives/dropdown-menu";
27
28
  import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
@@ -33,6 +34,12 @@ export type WorkspaceSwitcherProps = {
33
34
  readonly testId?: string;
34
35
  };
35
36
 
37
+ /**
38
+ * Requires an ancestor `SidebarProvider` (see `../ui/sidebar`) — this
39
+ * component renders `SidebarMenuButton`, which calls `useSidebar()`
40
+ * internally and throws ("useSidebar must be used within a
41
+ * SidebarProvider.") when rendered outside one.
42
+ */
36
43
  export function WorkspaceSwitcher({
37
44
  workspaces,
38
45
  activeId,
@@ -57,22 +64,28 @@ export function WorkspaceSwitcher({
57
64
  <SidebarMenuItem>
58
65
  <DropdownMenu>
59
66
  <DropdownMenuTrigger asChild>
60
- <SidebarMenuButton data-testid="workspace-switcher-trigger">
61
- <span className="truncate">{active !== undefined ? labelOf(active) : ""}</span>
67
+ <SidebarMenuButton
68
+ data-testid="workspace-switcher-trigger"
69
+ aria-label={t("kumiko.workspace.switch")}
70
+ >
71
+ <span className="truncate">
72
+ {active !== undefined ? labelOf(active) : t("kumiko.workspace.select")}
73
+ </span>
62
74
  <ChevronsUpDown className="ml-auto" />
63
75
  </SidebarMenuButton>
64
76
  </DropdownMenuTrigger>
65
77
  <DropdownMenuContent align="start" className="min-w-[10rem]">
66
- {workspaces.map((ws) => (
67
- <DropdownMenuCheckboxItem
68
- key={ws.definition.id}
69
- checked={ws.definition.id === activeId}
70
- data-testid={`workspace-tab-${ws.definition.id}`}
71
- onSelect={() => onSelect(ws.definition.id)}
72
- >
73
- <span className="truncate">{labelOf(ws)}</span>
74
- </DropdownMenuCheckboxItem>
75
- ))}
78
+ <DropdownMenuRadioGroup value={activeId} onValueChange={onSelect}>
79
+ {workspaces.map((ws) => (
80
+ <DropdownMenuRadioItem
81
+ key={ws.definition.id}
82
+ value={ws.definition.id}
83
+ data-testid={`workspace-tab-${ws.definition.id}`}
84
+ >
85
+ <span className="truncate">{labelOf(ws)}</span>
86
+ </DropdownMenuRadioItem>
87
+ ))}
88
+ </DropdownMenuRadioGroup>
76
89
  </DropdownMenuContent>
77
90
  </DropdownMenu>
78
91
  </SidebarMenuItem>
@@ -49,7 +49,9 @@ describe("resizeImageBeforeUpload", () => {
49
49
  globalThis.createImageBitmap = mock(async () => bitmap);
50
50
 
51
51
  try {
52
- const file = new File(["x"], "photo.jpg", { type: "image/jpeg" });
52
+ // Content sized well above the 1-byte re-encoded blob so the resize
53
+ // wins the size-guard and the assertions below observe its output.
54
+ const file = new File(["x".repeat(2000)], "photo.jpg", { type: "image/jpeg" });
53
55
  const result = await resizeImageBeforeUpload(file, 2560);
54
56
  expect(capturedWidth).toBe(2560);
55
57
  expect(capturedHeight).toBe(1280);
@@ -81,7 +83,9 @@ describe("resizeImageBeforeUpload", () => {
81
83
  }));
82
84
 
83
85
  try {
84
- const file = new File(["x"], "photo.heic", { type: "image/heic" });
86
+ // Content sized well above the 1-byte re-encoded blob so the resize
87
+ // wins the size-guard and the assertions below observe its output.
88
+ const file = new File(["x".repeat(2000)], "photo.heic", { type: "image/heic" });
85
89
  const result = await resizeImageBeforeUpload(file);
86
90
  expect(result.name).toBe("photo.jpg");
87
91
  expect(result.type).toBe("image/jpeg");
@@ -114,7 +118,9 @@ describe("resizeImageBeforeUpload", () => {
114
118
  }));
115
119
 
116
120
  try {
117
- const file = new File(["x"], "photo.webp", { type: "image/webp" });
121
+ // Content sized well above the 1-byte re-encoded blob so the resize
122
+ // wins the size-guard and the assertions below observe its output.
123
+ const file = new File(["x".repeat(2000)], "photo.webp", { type: "image/webp" });
118
124
  const result = await resizeImageBeforeUpload(file);
119
125
  expect(result.name).toBe("photo.png");
120
126
  expect(result.type).toBe("image/png");
@@ -126,6 +132,39 @@ describe("resizeImageBeforeUpload", () => {
126
132
  }
127
133
  });
128
134
 
135
+ test("belässt ein bereits optimiertes Bild unverändert, wenn der Re-Encode es vergrößern würde", async () => {
136
+ // A small in-spec PNG (e.g. palette-optimized) round-tripped through a
137
+ // canvas can come back as a bigger full-RGBA blob — the size guard must
138
+ // then keep serving the original bytes instead of the larger re-encode.
139
+ class FakeOffscreenCanvas {
140
+ getContext() {
141
+ return { drawImage: mock(() => {}) };
142
+ }
143
+ convertToBlob() {
144
+ return Promise.resolve(new Blob(["x".repeat(2000)], { type: "image/png" }));
145
+ }
146
+ }
147
+ // @ts-expect-error test stub for a browser-only API missing in jsdom
148
+ globalThis.OffscreenCanvas = FakeOffscreenCanvas;
149
+ globalThis.createImageBitmap = mock(async () => ({
150
+ close: mock(() => {}),
151
+ height: 100,
152
+ width: 100,
153
+ }));
154
+
155
+ try {
156
+ const file = new File(["x"], "photo.png", { type: "image/png" });
157
+ const result = await resizeImageBeforeUpload(file);
158
+ expect(result).toBe(file);
159
+ expect(result.size).toBeLessThanOrEqual(file.size);
160
+ } finally {
161
+ // @ts-expect-error restore missing-API baseline
162
+ globalThis.OffscreenCanvas = undefined;
163
+ // @ts-expect-error restore missing-API baseline
164
+ globalThis.createImageBitmap = undefined;
165
+ }
166
+ });
167
+
129
168
  test("fällt bei Decode-Fehlern auf die Originaldatei zurück", async () => {
130
169
  class FakeOffscreenCanvas {
131
170
  getContext() {
@@ -0,0 +1,3 @@
1
+ export function clamp(value: number, min: number, max: number): number {
2
+ return Math.min(Math.max(value, min), max);
3
+ }
@@ -54,7 +54,12 @@ export async function resizeImageBeforeUpload(
54
54
  const actualType = blob.type || file.type;
55
55
  const fileName =
56
56
  actualType === file.type ? file.name : withMatchingExtension(file.name, actualType);
57
- return new File([blob], fileName, { type: actualType });
57
+ const resized = new File([blob], fileName, { type: actualType });
58
+ // Only takes the re-encode if it actually shrank the file — a
59
+ // palette-optimized PNG or a small in-spec photo can come back larger
60
+ // after a full RGBA canvas round-trip. EXIF/GPS stripping is then
61
+ // best-effort: an original that's kept as-is keeps its metadata too.
62
+ return resized.size < file.size ? resized : file;
58
63
  } catch {
59
64
  return file;
60
65
  }
@@ -183,13 +183,25 @@ describe("defaultCellRender", () => {
183
183
  expect(result).not.toMatch(/[.,]\d\d$/);
184
184
  });
185
185
 
186
- test("money → rehydrated { amount major, amountMinor } uses amountMinor (not major×100 wrong)", () => {
187
- // rehydrateMoney (fw#1830): amount is major units, amountMinor is cents.
188
- // formatMoney expects cents — using amount would show 100× too small.
186
+ test("money → rehydrated { amount major, amountMinor } derives minor units from amount + currencyDecimals", () => {
187
+ // rehydrateMoney (fw#1830): amount is major units, amountMinor is a flat
188
+ // ×100 scale that disagrees with currencyDecimals for non-2-decimal
189
+ // currencies — defaultCellRender ignores amountMinor and derives minor
190
+ // units from `amount` instead (see index.tsx).
189
191
  const result = defaultCellRender({ amount: 119, currency: "EUR", amountMinor: 11900 }, "money");
190
192
  expect(result.replace(/[^0-9]/g, "")).toBe("11900");
191
193
  });
192
194
 
195
+ test("money → zero-decimal currency (JPY) ignores amountMinor's mismatched flat scale", () => {
196
+ const result = defaultCellRender({ amount: 500, currency: "JPY", amountMinor: 50000 }, "money");
197
+ expect(result.replace(/[^0-9]/g, "")).toBe("500");
198
+ });
199
+
200
+ test("money → three-decimal currency (BHD) ignores amountMinor's mismatched flat scale", () => {
201
+ const result = defaultCellRender({ amount: 1.5, currency: "BHD", amountMinor: 150 }, "money");
202
+ expect(result.replace(/[^0-9]/g, "")).toBe("1500");
203
+ });
204
+
193
205
  test("money → unerwarteter Value-Shape fällt auf String zurück", () => {
194
206
  expect(defaultCellRender(45000, "money")).toBe("45000");
195
207
  });
@@ -0,0 +1,37 @@
1
+ // DateField rendering tests — happy-dom + @testing-library/react. Pins
2
+ // that the input's `placeholder` actually comes from `t()` + resolvedLocale
3
+ // (date-parse.test.ts only covers the pure formatDatePlaceholder logic; a
4
+ // wrong/renamed i18n key or a missing LocaleProvider fallback would stay
5
+ // green there).
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import {
9
+ createStaticLocaleResolver,
10
+ kumikoDefaultTranslations,
11
+ LocaleProvider,
12
+ } from "@cosmicdrift/kumiko-renderer";
13
+ import { render, screen } from "@testing-library/react";
14
+ import { DateField } from "../date-field";
15
+
16
+ function renderWithLocale(locale: string) {
17
+ return render(
18
+ <LocaleProvider
19
+ resolver={createStaticLocaleResolver({ locale })}
20
+ fallbackBundles={[kumikoDefaultTranslations]}
21
+ >
22
+ <DateField id="date" name="date" value="" onChange={() => {}} locale={locale} />
23
+ </LocaleProvider>,
24
+ );
25
+ }
26
+
27
+ describe("DateField placeholder", () => {
28
+ test("de-DE → placeholder built from t() keys, not a hardcoded pattern", () => {
29
+ renderWithLocale("de-DE");
30
+ expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("TT.MM.JJJJ");
31
+ });
32
+
33
+ test("en-US → placeholder built from t() keys, not a hardcoded pattern", () => {
34
+ renderWithLocale("en-US");
35
+ expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("MM/DD/YYYY");
36
+ });
37
+ });
@@ -5,6 +5,7 @@
5
5
  // rejection.
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
+ import { kumikoDefaultTranslations } from "@cosmicdrift/kumiko-renderer";
8
9
  import { Temporal } from "temporal-polyfill";
9
10
  import {
10
11
  formatDateForInput,
@@ -14,8 +15,19 @@ import {
14
15
  toIso,
15
16
  } from "../date-parse";
16
17
 
17
- const dePlaceholderLetters = { year: "J", month: "M", day: "T" };
18
- const enPlaceholderLetters = { year: "Y", month: "M", day: "D" };
18
+ // Derived from kumikoDefaultTranslations instead of hardcoded so a drift in
19
+ // the i18n defaults (e.g. renaming "T" → "D") fails this test too.
20
+ function placeholderLetters(locale: "de" | "en"): { year: string; month: string; day: string } {
21
+ const bundle = kumikoDefaultTranslations[locale];
22
+ return {
23
+ year: bundle?.["kumiko.field.dateField.placeholderYear"] ?? "",
24
+ month: bundle?.["kumiko.field.dateField.placeholderMonth"] ?? "",
25
+ day: bundle?.["kumiko.field.dateField.placeholderDay"] ?? "",
26
+ };
27
+ }
28
+
29
+ const dePlaceholderLetters = placeholderLetters("de");
30
+ const enPlaceholderLetters = placeholderLetters("en");
19
31
 
20
32
  describe("parseIso", () => {
21
33
  test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
@@ -2,7 +2,7 @@
2
2
  // test interacts with the rendered DOM (click/type/paste) and asserts on
3
3
  // the outcome; none merely check that the component mounts.
4
4
 
5
- import { describe, expect, mock, test } from "bun:test";
5
+ import { describe, expect, mock, spyOn, test } from "bun:test";
6
6
  import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
7
7
  import type { EmbeddedListColumn, EmbeddedListInputProps } from "@cosmicdrift/kumiko-renderer";
8
8
  import {
@@ -11,9 +11,15 @@ import {
11
11
  LocaleProvider,
12
12
  } from "@cosmicdrift/kumiko-renderer";
13
13
  import { fireEvent, render, screen, within } from "@testing-library/react";
14
- import { type ReactElement, useState } from "react";
14
+ import { act, type ReactElement, useState } from "react";
15
15
  import { EmbeddedListInput } from "../embedded-list-input";
16
16
 
17
+ function setViewportWidth(width: number): void {
18
+ (
19
+ window as unknown as { happyDOM: { setInnerWidth: (n: number) => void } }
20
+ ).happyDOM.setInnerWidth(width);
21
+ }
22
+
17
23
  function renderWithLocale(ui: ReactElement) {
18
24
  return render(
19
25
  <LocaleProvider
@@ -118,12 +124,53 @@ describe("EmbeddedListInput — desktop/mobile are mutually exclusive mounts (#1
118
124
  });
119
125
 
120
126
  test("mobile viewport mounts only the card layout, not the table", () => {
127
+ const originalWidth = window.innerWidth;
128
+ setViewportWidth(500);
129
+ try {
130
+ renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
131
+ expect(screen.getByTestId("lines-mobile")).toBeTruthy();
132
+ expect(screen.queryByTestId("lines-desktop")).toBeNull();
133
+ expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
134
+ } finally {
135
+ setViewportWidth(originalWidth);
136
+ }
137
+ });
138
+
139
+ test("mobile viewport at mount never creates the desktop table — no mount-then-discard flicker (fw#1868)", () => {
140
+ // The old useIsMobile hook only set its value in a useEffect, so the
141
+ // very first render always mounted the desktop table (isMobile=false)
142
+ // before an effect swapped in the mobile cards — even on a phone. Since
143
+ // effects flush synchronously inside render()'s act(), a plain post-
144
+ // render DOM assertion can't tell the two implementations apart (both
145
+ // end up correct); spying on document.createElement across the whole
146
+ // render call is the only way to prove a <table> was never built at all.
121
147
  const originalWidth = window.innerWidth;
122
148
  window.innerWidth = 500;
149
+ const createElementSpy = spyOn(document, "createElement");
123
150
  try {
124
151
  renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
125
152
  expect(screen.getByTestId("lines-mobile")).toBeTruthy();
126
153
  expect(screen.queryByTestId("lines-desktop")).toBeNull();
154
+ expect(createElementSpy.mock.calls.some(([tag]) => tag === "table")).toBe(false);
155
+ } finally {
156
+ window.innerWidth = originalWidth;
157
+ createElementSpy.mockRestore();
158
+ }
159
+ });
160
+
161
+ test("resizing past the breakpoint after mount swaps the table for the card layout", async () => {
162
+ const originalWidth = window.innerWidth;
163
+ try {
164
+ renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
165
+ expect(screen.getByTestId("lines-desktop")).toBeTruthy();
166
+
167
+ await act(async () => {
168
+ window.innerWidth = 500;
169
+ window.dispatchEvent(new Event("resize"));
170
+ });
171
+
172
+ expect(screen.queryByTestId("lines-desktop")).toBeNull();
173
+ expect(screen.getByTestId("lines-mobile")).toBeTruthy();
127
174
  expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
128
175
  } finally {
129
176
  window.innerWidth = originalWidth;
@@ -626,6 +673,34 @@ describe("EmbeddedListInput — currency (#1839)", () => {
626
673
  });
627
674
  });
628
675
 
676
+ describe("EmbeddedListInput — totals follow the app locale, not the browser locale", () => {
677
+ test("a de-DE app locale formats both the money total and a plain number total with '.' thousands / ',' decimal", () => {
678
+ const rows = [{ description: "A", quantity: 1234.5, amount: 150099 }];
679
+ render(
680
+ <LocaleProvider
681
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
682
+ fallbackBundles={[kumikoDefaultTranslations]}
683
+ >
684
+ <EmbeddedListInput
685
+ {...baseProps({
686
+ rows,
687
+ currency: "EUR",
688
+ totals: [
689
+ { field: "amount", label: "Sum", value: 150099 },
690
+ { field: "quantity", label: "Qty total", value: 1234.5 },
691
+ ],
692
+ })}
693
+ />
694
+ </LocaleProvider>,
695
+ );
696
+ const totals = within(screen.getByTestId("lines-desktop")).getByTestId("lines-totals");
697
+ // de-DE: money "1.500,99 €", plain number "1.234,5" — both use "." as
698
+ // the thousands separator and "," as the decimal separator.
699
+ expect(totals.textContent).toContain("1.500,99");
700
+ expect(totals.textContent).toContain("1.234,5");
701
+ });
702
+ });
703
+
629
704
  describe("EmbeddedListInput — desktop table width (solon#107)", () => {
630
705
  test("the table keeps min-w-max so columns don't shrink below their width classes", () => {
631
706
  const rows = [{ description: "A", quantity: 1, amount: 100 }];
@@ -23,3 +23,27 @@ describe("FileUploadInput — image variant preview src (#1950)", () => {
23
23
  expect(container.querySelector("img")?.getAttribute("src")).toBe("/api/files/abc");
24
24
  });
25
25
  });
26
+
27
+ describe("FileUploadInput — camera capture (#1953)", () => {
28
+ test("with capture set, the <input type=file> carries the capture attribute", () => {
29
+ const { container } = render(
30
+ <FileUploadInput
31
+ kind="image"
32
+ id="avatar"
33
+ value={null}
34
+ onChange={noop}
35
+ capture="environment"
36
+ />,
37
+ );
38
+ expect(container.querySelector('input[type="file"]')?.getAttribute("capture")).toBe(
39
+ "environment",
40
+ );
41
+ });
42
+
43
+ test("without capture, the <input type=file> has no capture attribute", () => {
44
+ const { container } = render(
45
+ <FileUploadInput kind="image" id="avatar" value={null} onChange={noop} />,
46
+ );
47
+ expect(container.querySelector('input[type="file"]')?.hasAttribute("capture")).toBe(false);
48
+ });
49
+ });
@@ -105,6 +105,14 @@ export function DateField({
105
105
  disabled={disabled}
106
106
  required={required}
107
107
  aria-invalid={hasError === true ? true : undefined}
108
+ // Deliberate: slot order/separator follow resolvedLocale (the
109
+ // `locale` prop, or the browser locale), but the letters follow
110
+ // the app's UI locale via t() — a caller rendering a date in a
111
+ // locale that differs from the UI locale gets a mixed-locale
112
+ // placeholder (e.g. German UI + locale="en-US" → "MM/TT/JJJJ").
113
+ // Accepted for now (fw#1879): a static per-locale letter lookup
114
+ // would need its own locale-root resolution and tests for a
115
+ // placeholder-only, Low-severity mismatch.
108
116
  placeholder={formatDatePlaceholder(resolvedLocale, {
109
117
  year: t("kumiko.field.dateField.placeholderYear"),
110
118
  month: t("kumiko.field.dateField.placeholderMonth"),
@@ -80,7 +80,12 @@ function localeDateParts(locale: string): Intl.DateTimeFormatPart[] {
80
80
  const refEpochMillis = activeTemporal()
81
81
  .PlainDate.from({ year: 2026, month: 1, day: 2 })
82
82
  .toZonedDateTime("UTC").epochMilliseconds;
83
- return new Intl.DateTimeFormat(locale, { timeZone: "UTC" }).formatToParts(refEpochMillis);
83
+ return new Intl.DateTimeFormat(locale, {
84
+ timeZone: "UTC",
85
+ year: "numeric",
86
+ month: "2-digit",
87
+ day: "2-digit",
88
+ }).formatToParts(refEpochMillis);
84
89
  }
85
90
 
86
91
  // Field order of the numeric locale format. de → [d,m,y], en-US →
@@ -83,6 +83,29 @@ export function DropdownMenuCheckboxItem({
83
83
  );
84
84
  }
85
85
 
86
+ export const DropdownMenuRadioGroup = Primitive.RadioGroup;
87
+
88
+ // RadioItem with a dot indicator on the left — for exclusive single-choice
89
+ // selection (e.g. WorkspaceSwitcher). Unlike CheckboxItem, Radix renders
90
+ // role="menuitemradio" + aria-checked here instead of role="menuitemcheckbox",
91
+ // which announces "one of several" to screen readers instead of "on/off".
92
+ export function DropdownMenuRadioItem({
93
+ className,
94
+ children,
95
+ ...props
96
+ }: ComponentPropsWithoutRef<typeof Primitive.RadioItem>): ReactNode {
97
+ return (
98
+ <Primitive.RadioItem className={cn(itemClass, "pl-8 pr-2", className)} {...props}>
99
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
100
+ <Primitive.ItemIndicator>
101
+ <Check className="h-3.5 w-3.5" />
102
+ </Primitive.ItemIndicator>
103
+ </span>
104
+ {children}
105
+ </Primitive.RadioItem>
106
+ );
107
+ }
108
+
86
109
  export function DropdownMenuLabel({
87
110
  className,
88
111
  ...props