@cosmicdrift/kumiko-renderer-web 0.193.1 → 0.194.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 (44) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/create-app.test.tsx +1 -1
  3. package/src/__tests__/entity-edit-redirect.test.tsx +111 -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 +344 -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 +4 -0
  13. package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
  14. package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
  15. package/src/app/client-plugin.tsx +3 -3
  16. package/src/app/create-app.tsx +40 -49
  17. package/src/app/tiptap-editor.tsx +10 -0
  18. package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
  19. package/src/layout/sidebar-panel.tsx +1 -4
  20. package/src/layout/workspace-switcher.tsx +20 -13
  21. package/src/lib/clamp.ts +3 -0
  22. package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
  23. package/src/primitives/__tests__/date-field.test.tsx +37 -0
  24. package/src/primitives/__tests__/date-parse.test.ts +14 -2
  25. package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
  26. package/src/primitives/__tests__/file-upload.test.tsx +24 -0
  27. package/src/primitives/date-field.tsx +8 -0
  28. package/src/primitives/date-parse.ts +6 -1
  29. package/src/primitives/dropdown-menu.tsx +23 -0
  30. package/src/primitives/embedded-list-input.tsx +14 -5
  31. package/src/primitives/file-upload.tsx +3 -0
  32. package/src/primitives/index.tsx +114 -120
  33. package/src/primitives/money-input.tsx +4 -7
  34. package/src/ui/sheet.tsx +3 -1
  35. package/src/widgets/__tests__/drawer.test.tsx +293 -0
  36. package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
  37. package/src/widgets/__tests__/upload-zone.test.tsx +90 -1
  38. package/src/widgets/__tests__/widgets.test.tsx +25 -0
  39. package/src/widgets/ai-text-field.tsx +3 -1
  40. package/src/widgets/drawer.tsx +54 -29
  41. package/src/widgets/infinity-list.tsx +18 -5
  42. package/src/widgets/progress-bar.tsx +3 -1
  43. package/src/widgets/step-bar.tsx +1 -1
  44. package/src/widgets/upload-zone.tsx +40 -2
@@ -9,6 +9,18 @@ function Controlled({ initial }: { readonly initial: string }): ReactNode {
9
9
  return <TiptapEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />;
10
10
  }
11
11
 
12
+ function ReadOnlyToggle({ initial }: { readonly initial: string }): ReactNode {
13
+ const [readOnly, setReadOnly] = useState(false);
14
+ return (
15
+ <div>
16
+ <button type="button" onClick={() => setReadOnly((r) => !r)}>
17
+ toggle readOnly
18
+ </button>
19
+ <TiptapEditor value={initial} onChange={() => {}} variables={[]} readOnly={readOnly} />
20
+ </div>
21
+ );
22
+ }
23
+
12
24
  // Mirrors the real caller: TextBlockEditor mounts with value="" and only
13
25
  // gets the loaded content after its by-slug query resolves — value arrives
14
26
  // as a prop update, not at mount. A test that renders the final HTML
@@ -91,4 +103,42 @@ describe("TiptapEditor — jsdom smoke", () => {
91
103
  fireEvent.click(screen.getByText("{{name}}"));
92
104
  expect(await screen.findByText("{{name}}", { selector: "p" })).toBeTruthy();
93
105
  });
106
+
107
+ test("toggling readOnly flips the surface's contenteditable and disables the toolbar", async () => {
108
+ // @tiptap/react re-applies `editable` from options on every options
109
+ // refresh, overriding what `editable: !readOnly` set at creation — so
110
+ // this only stays correct across a readOnly prop change via the
111
+ // `editor.setEditable` effect. A test that only mounts with a fixed
112
+ // readOnly value can't catch that effect being removed.
113
+ render(<ReadOnlyToggle initial="<p>hello</p>" />);
114
+ const editable = await screen.findByText("hello");
115
+ expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
116
+ expect((screen.getByRole("button", { name: "Bold" }) as HTMLButtonElement).disabled).toBe(
117
+ false,
118
+ );
119
+
120
+ fireEvent.click(screen.getByText("toggle readOnly"));
121
+
122
+ expect(editable.closest('[contenteditable="false"]')).not.toBeNull();
123
+ expect((screen.getByRole("button", { name: "Bold" }) as HTMLButtonElement).disabled).toBe(true);
124
+ });
125
+
126
+ // Documents the ponytail-noted ceiling on useEditor: markup StarterKit's
127
+ // schema doesn't model (tables, raw divs/spans, style attrs) is dropped on
128
+ // first parse, invisible until the first onChange fires. Not a bug to fix
129
+ // here — a fixture for whoever hits the ceiling next.
130
+ test("markup outside StarterKit's schema (a <table>) is dropped on the first onChange", async () => {
131
+ const values: string[] = [];
132
+ render(
133
+ <TiptapEditor
134
+ value="<table><tbody><tr><td>cell</td></tr></tbody></table><p>hello</p>"
135
+ onChange={(html) => values.push(html)}
136
+ variables={[]}
137
+ readOnly={false}
138
+ />,
139
+ );
140
+ const editable = (await screen.findByText("hello")).closest('[contenteditable="true"]');
141
+ fireEvent.click(screen.getByRole("button", { name: "Bold" }));
142
+ expect(editable?.innerHTML).not.toContain("<table");
143
+ });
94
144
  });
@@ -90,12 +90,12 @@ export type ClientFeatureDefinition = {
90
90
  collections: readonly QualifiedContentCollection[],
91
91
  ) => CollectionNavProviders;
92
92
 
93
- /** Content-Editor-Components — Map `contentFormat` ("plain" | "rich" | "markdown") →
94
- * React-Component. `r.contentCollection()` declares which format an
93
+ /** Content editor components — map `contentFormat` ("plain" | "rich" | "markdown") →
94
+ * React component. `r.contentCollection()` declares which format an
95
95
  * editor edits; the collection's editor UI (template-resolver's
96
96
  * TextBlockEditor et al.) looks the component up here and falls back to
97
97
  * a plain textarea when no clientFeature registered one for that
98
- * format. Same Last-Wins-Semantik wie columnRenderers. Public component
98
+ * format. Same last-wins semantics as columnRenderers. Public component
99
99
  * contract: `{ value, onChange, variables, readOnly }`. */
100
100
  readonly contentEditors?: Readonly<Record<string, ContentEditorComponent>>;
101
101
 
@@ -9,7 +9,6 @@ import type {
9
9
  import {
10
10
  AppFeaturesProvider,
11
11
  type AppSchema,
12
- type ColumnRendererComponent,
13
12
  ColumnRenderersProvider,
14
13
  type ContentEditorComponent,
15
14
  ContentEditorsProvider,
@@ -18,7 +17,6 @@ import {
18
17
  DispatcherProvider,
19
18
  type DraftStorage,
20
19
  DraftStorageProvider,
21
- type ExtensionSectionComponent,
22
20
  ExtensionSectionsProvider,
23
21
  type FeatureSchema,
24
22
  KumikoScreen,
@@ -99,7 +97,7 @@ export function buildNavProviderMaps(
99
97
  for (const [navId, provider] of Object.entries(f.navProviders ?? {})) {
100
98
  const qn = qualifyNavProviderKey(f.name, navId);
101
99
  if (navProviders.has(qn) && !derivedQns.delete(qn)) {
102
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
100
+ // biome-ignore lint/suspicious/noConsole: dev warning for schema conflicts
103
101
  console.warn(
104
102
  `[kumiko] navProvider for "${qn}" defined by multiple clientFeatures — last wins.`,
105
103
  );
@@ -228,26 +226,39 @@ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string |
228
226
  return undefined;
229
227
  }
230
228
 
231
- // Merges the content-editor map — same last-wins semantics as
232
- // columnRenderers. No entry for a contentFormat → useContentEditor falls
233
- // back to the textarea on its own, no warning needed.
234
- export function mergeContentEditors(
229
+ // Last-wins merge for a clientFeature-contributed map (contentEditors,
230
+ // columnRenderers, extensionSectionComponents, ...): later features in
231
+ // `clientFeatures` override earlier ones on key collision, logging once
232
+ // per collision so an accidental override isn't silent.
233
+ function mergeByKey<T>(
235
234
  clientFeatures: readonly ClientFeatureDefinition[],
236
- ): Record<string, ContentEditorComponent> {
237
- const contentEditors: Record<string, ContentEditorComponent> = {};
235
+ pick: (f: ClientFeatureDefinition) => Record<string, T> | undefined,
236
+ label: string,
237
+ ): Record<string, T> {
238
+ const merged: Record<string, T> = {};
238
239
  for (const f of clientFeatures) {
239
- if (f.contentEditors === undefined) continue;
240
- for (const [key, value] of Object.entries(f.contentEditors)) {
241
- if (contentEditors[key] !== undefined) {
242
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
240
+ const entries = pick(f);
241
+ if (entries === undefined) continue;
242
+ for (const [key, value] of Object.entries(entries)) {
243
+ if (merged[key] !== undefined) {
244
+ // biome-ignore lint/suspicious/noConsole: dev warning for schema conflicts
243
245
  console.warn(
244
- `[kumiko] contentEditor "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
246
+ `[kumiko] ${label} "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
245
247
  );
246
248
  }
247
- contentEditors[key] = value;
249
+ merged[key] = value;
248
250
  }
249
251
  }
250
- return contentEditors;
252
+ return merged;
253
+ }
254
+
255
+ // Merges the content-editor map — same last-wins semantics as
256
+ // columnRenderers. No entry for a contentFormat → useContentEditor falls
257
+ // back to the textarea on its own, no warning needed.
258
+ export function mergeContentEditors(
259
+ clientFeatures: readonly ClientFeatureDefinition[],
260
+ ): Record<string, ContentEditorComponent> {
261
+ return mergeByKey(clientFeatures, (f) => f.contentEditors, "contentEditor");
251
262
  }
252
263
 
253
264
  export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonly root: Root } {
@@ -321,39 +332,19 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
321
332
  for (const f of clientFeatures) {
322
333
  if (f.components !== undefined) Object.assign(customScreens, f.components);
323
334
  }
324
- // Column-Renderer-Map mergen — gleiche Last-Wins-Semantik wie bei
325
- // customScreens. Doppelte Keys über Features sind selten gewollt;
326
- // wir warnen einmalig pro Kollision damit das nicht stillschweigend
327
- // den Renderer einer Library überschreibt.
328
- const columnRenderers: Record<string, ColumnRendererComponent> = {};
329
- for (const f of clientFeatures) {
330
- if (f.columnRenderers === undefined) continue;
331
- for (const [key, value] of Object.entries(f.columnRenderers)) {
332
- if (columnRenderers[key] !== undefined) {
333
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
334
- console.warn(
335
- `[kumiko] columnRenderer "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
336
- );
337
- }
338
- columnRenderers[key] = value;
339
- }
340
- }
341
- // Extension-Section-Components — same Last-Wins + Warn-Semantik wie
342
- // columnRenderers. Mountet sich am ExtensionSectionsProvider; RenderEdit
343
- // löst die Component aus dem `__component`-Marker der section.
344
- const extensionSectionComponents: Record<string, ExtensionSectionComponent> = {};
345
- for (const f of clientFeatures) {
346
- if (f.extensionSectionComponents === undefined) continue;
347
- for (const [key, value] of Object.entries(f.extensionSectionComponents)) {
348
- if (extensionSectionComponents[key] !== undefined) {
349
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
350
- console.warn(
351
- `[kumiko] extensionSectionComponent "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
352
- );
353
- }
354
- extensionSectionComponents[key] = value;
355
- }
356
- }
335
+ // Column-renderer map, same last-wins semantics as customScreens —
336
+ // duplicate keys across features are rarely intentional, so a
337
+ // collision logs once instead of silently overriding a library's
338
+ // renderer.
339
+ const columnRenderers = mergeByKey(clientFeatures, (f) => f.columnRenderers, "columnRenderer");
340
+ // Extension-section components — same last-wins + warn semantics as
341
+ // columnRenderers. Mounted on ExtensionSectionsProvider; RenderEdit
342
+ // resolves the component from the section's `__component` marker.
343
+ const extensionSectionComponents = mergeByKey(
344
+ clientFeatures,
345
+ (f) => f.extensionSectionComponents,
346
+ "extensionSectionComponent",
347
+ );
357
348
 
358
349
  const contentEditors = mergeContentEditors(clientFeatures);
359
350
 
@@ -68,6 +68,12 @@ export default function TiptapEditor({
68
68
  readOnly,
69
69
  }: ContentEditorProps): ReactNode {
70
70
  const t = useTranslation();
71
+ // ponytail: rich = StarterKit's schema (bold/italic/lists/headings/links).
72
+ // Anything the schema doesn't model — <table>, <div>/<span>, style attrs,
73
+ // <img>, classes — is dropped on first parse (setContent below, and the
74
+ // initial `content: value` here). Upgrade if that ceiling is hit: add the
75
+ // matching tiptap extensions, or a raw-HTML mode for markup StarterKit
76
+ // can't represent.
71
77
  const editor = useEditor({
72
78
  extensions: [StarterKit],
73
79
  content: value,
@@ -79,6 +85,10 @@ export default function TiptapEditor({
79
85
  // Same id the textarea fallback uses — the Field wrapping the editor
80
86
  // (TextBlockEditor) associates its label via this id; the editor
81
87
  // contract has no `id` prop of its own, see content-editors.tsx.
88
+ // ponytail: fixed id, not a prop — two simultaneously mounted rich
89
+ // editors (or a rich + a plain editor on one screen) collide on this
90
+ // DOM id, same ceiling plain-content-editor.tsx documents. Upgrade:
91
+ // thread `id` through ContentEditorProps as an optional override.
82
92
  editorProps: { attributes: { id: CONTENT_EDITOR_ELEMENT_ID } },
83
93
  onUpdate: ({ editor: e }) => onChange(e.getHTML()),
84
94
  });
@@ -55,6 +55,46 @@ 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
+
58
98
  test("Click auf einen Eintrag ruft onSelect mit der Workspace-id", async () => {
59
99
  const user = userEvent.setup();
60
100
  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";
@@ -57,22 +58,28 @@ export function WorkspaceSwitcher({
57
58
  <SidebarMenuItem>
58
59
  <DropdownMenu>
59
60
  <DropdownMenuTrigger asChild>
60
- <SidebarMenuButton data-testid="workspace-switcher-trigger">
61
- <span className="truncate">{active !== undefined ? labelOf(active) : ""}</span>
61
+ <SidebarMenuButton
62
+ data-testid="workspace-switcher-trigger"
63
+ aria-label={t("kumiko.workspace.switch")}
64
+ >
65
+ <span className="truncate">
66
+ {active !== undefined ? labelOf(active) : t("kumiko.workspace.select")}
67
+ </span>
62
68
  <ChevronsUpDown className="ml-auto" />
63
69
  </SidebarMenuButton>
64
70
  </DropdownMenuTrigger>
65
71
  <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
- ))}
72
+ <DropdownMenuRadioGroup value={activeId} onValueChange={onSelect}>
73
+ {workspaces.map((ws) => (
74
+ <DropdownMenuRadioItem
75
+ key={ws.definition.id}
76
+ value={ws.definition.id}
77
+ data-testid={`workspace-tab-${ws.definition.id}`}
78
+ >
79
+ <span className="truncate">{labelOf(ws)}</span>
80
+ </DropdownMenuRadioItem>
81
+ ))}
82
+ </DropdownMenuRadioGroup>
76
83
  </DropdownMenuContent>
77
84
  </DropdownMenu>
78
85
  </SidebarMenuItem>
@@ -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
+ }
@@ -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)", () => {
@@ -14,6 +14,12 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
14
14
  import { 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
@@ -119,14 +125,14 @@ describe("EmbeddedListInput — desktop/mobile are mutually exclusive mounts (#1
119
125
 
120
126
  test("mobile viewport mounts only the card layout, not the table", () => {
121
127
  const originalWidth = window.innerWidth;
122
- window.innerWidth = 500;
128
+ setViewportWidth(500);
123
129
  try {
124
130
  renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
125
131
  expect(screen.getByTestId("lines-mobile")).toBeTruthy();
126
132
  expect(screen.queryByTestId("lines-desktop")).toBeNull();
127
133
  expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
128
134
  } finally {
129
- window.innerWidth = originalWidth;
135
+ setViewportWidth(originalWidth);
130
136
  }
131
137
  });
132
138
  });
@@ -626,6 +632,34 @@ describe("EmbeddedListInput — currency (#1839)", () => {
626
632
  });
627
633
  });
628
634
 
635
+ describe("EmbeddedListInput — totals follow the app locale, not the browser locale", () => {
636
+ test("a de-DE app locale formats both the money total and a plain number total with '.' thousands / ',' decimal", () => {
637
+ const rows = [{ description: "A", quantity: 1234.5, amount: 150099 }];
638
+ render(
639
+ <LocaleProvider
640
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
641
+ fallbackBundles={[kumikoDefaultTranslations]}
642
+ >
643
+ <EmbeddedListInput
644
+ {...baseProps({
645
+ rows,
646
+ currency: "EUR",
647
+ totals: [
648
+ { field: "amount", label: "Sum", value: 150099 },
649
+ { field: "quantity", label: "Qty total", value: 1234.5 },
650
+ ],
651
+ })}
652
+ />
653
+ </LocaleProvider>,
654
+ );
655
+ const totals = within(screen.getByTestId("lines-desktop")).getByTestId("lines-totals");
656
+ // de-DE: money "1.500,99 €", plain number "1.234,5" — both use "." as
657
+ // the thousands separator and "," as the decimal separator.
658
+ expect(totals.textContent).toContain("1.500,99");
659
+ expect(totals.textContent).toContain("1.234,5");
660
+ });
661
+ });
662
+
629
663
  describe("EmbeddedListInput — desktop table width (solon#107)", () => {
630
664
  test("the table keeps min-w-max so columns don't shrink below their width classes", () => {
631
665
  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