@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.
- package/package.json +4 -4
- package/src/__tests__/create-app.test.tsx +1 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
- package/src/__tests__/field-hide-label.test.tsx +166 -1
- package/src/__tests__/kumiko-screen.test.tsx +59 -0
- package/src/__tests__/nav-tree.test.tsx +43 -11
- package/src/__tests__/primitives.test.tsx +33 -2
- package/src/__tests__/render-edit.test.tsx +344 -9
- package/src/__tests__/test-utils.tsx +20 -0
- package/src/__tests__/wizard-form-validation.test.tsx +129 -1
- package/src/app/__tests__/merge-content-editors.test.ts +2 -2
- package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
- package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/tiptap-editor.tsx +10 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +20 -13
- package/src/lib/clamp.ts +3 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
- package/src/primitives/__tests__/date-field.test.tsx +37 -0
- package/src/primitives/__tests__/date-parse.test.ts +14 -2
- package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
- package/src/primitives/__tests__/file-upload.test.tsx +24 -0
- package/src/primitives/date-field.tsx +8 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/dropdown-menu.tsx +23 -0
- package/src/primitives/embedded-list-input.tsx +14 -5
- package/src/primitives/file-upload.tsx +3 -0
- package/src/primitives/index.tsx +114 -120
- package/src/primitives/money-input.tsx +4 -7
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +293 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +90 -1
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +54 -29
- package/src/widgets/infinity-list.tsx +18 -5
- package/src/widgets/progress-bar.tsx +3 -1
- package/src/widgets/step-bar.tsx +1 -1
- 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
|
|
94
|
-
* React
|
|
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
|
|
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
|
|
package/src/app/create-app.tsx
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
232
|
-
// columnRenderers
|
|
233
|
-
//
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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]
|
|
246
|
+
`[kumiko] ${label} "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
|
|
245
247
|
);
|
|
246
248
|
}
|
|
247
|
-
|
|
249
|
+
merged[key] = value;
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
|
-
return
|
|
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-
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
const columnRenderers
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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>
|
package/src/lib/clamp.ts
ADDED
|
@@ -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 }
|
|
187
|
-
// rehydrateMoney (fw#1830): amount is major units, amountMinor is
|
|
188
|
-
//
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|