@cosmicdrift/kumiko-renderer-web 0.194.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.194.0",
3
+ "version": "0.195.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>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.194.0",
20
- "@cosmicdrift/kumiko-headless": "0.194.0",
21
- "@cosmicdrift/kumiko-renderer": "0.194.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.195.0",
20
+ "@cosmicdrift/kumiko-headless": "0.195.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.195.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -327,7 +327,15 @@ describe("createKumikoApp", () => {
327
327
  }
328
328
  function EditorProbe(): ReactNode {
329
329
  const Editor = useContentEditor("rich");
330
- return <Editor value="hello" onChange={() => {}} variables={[]} readOnly={false} />;
330
+ return (
331
+ <Editor
332
+ id="ca-rich-editor-probe"
333
+ value="hello"
334
+ onChange={() => {}}
335
+ variables={[]}
336
+ readOnly={false}
337
+ />
338
+ );
331
339
  }
332
340
  const probeSchema: FeatureSchema = {
333
341
  featureName: "tasks",
@@ -74,6 +74,38 @@ describe("entityEdit redirect (#1942)", () => {
74
74
  await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
75
75
  });
76
76
 
77
+ test("create: successful save with redirect set carries the newly created entityId along (#1945)", async () => {
78
+ const navigated: NavTarget[] = [];
79
+ const dispatcher = createMockDispatcher({
80
+ write: (async () => ({
81
+ isSuccess: true,
82
+ data: { id: "new-99" },
83
+ })) as unknown as Dispatcher["write"],
84
+ });
85
+ render(
86
+ <DispatcherProvider dispatcher={dispatcher}>
87
+ <NavProvider
88
+ value={{
89
+ route: { screenId: "shop:screen:product-edit" },
90
+ navigate: (target) => navigated.push(target),
91
+ replace: () => {},
92
+ hrefFor: () => "",
93
+ searchParams: {},
94
+ setSearchParams: () => {},
95
+ }}
96
+ >
97
+ <KumikoScreen schema={buildSchema("product-detail")} qn="shop:screen:product-edit" />
98
+ </NavProvider>
99
+ </DispatcherProvider>,
100
+ );
101
+
102
+ fillNameAndSubmit();
103
+
104
+ await waitFor(() =>
105
+ expect(navigated).toEqual([{ screenId: "product-detail", entityId: "new-99" }]),
106
+ );
107
+ });
108
+
77
109
  test("create: no redirect set falls back to the entity's list screen", async () => {
78
110
  const navigated: NavTarget[] = [];
79
111
  render(
@@ -1517,6 +1517,59 @@ describe("RenderEdit wizard draft", () => {
1517
1517
  expect(titleInputAgain.value).toBe("Acme");
1518
1518
  });
1519
1519
 
1520
+ // fw#1929: bare crypto.randomUUID() breaks in non-secure contexts and
1521
+ // React Native/Hermes without a polyfill. With it deleted, minting a
1522
+ // draftId must still work through the mintDraftId() fallback instead of
1523
+ // throwing.
1524
+ test("draft id still mints when crypto.randomUUID is unavailable (fw#1929)", async () => {
1525
+ const cryptoRef = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
1526
+ const original = cryptoRef?.randomUUID;
1527
+ // `randomUUID` lives on crypto's prototype (non-configurable) — `delete`
1528
+ // is a silent no-op there, so shadow it with an own `undefined` instead.
1529
+ if (cryptoRef) cryptoRef.randomUUID = undefined;
1530
+
1531
+ try {
1532
+ const draftKeys: string[] = [];
1533
+ const dispatcher = createMockDispatcher({
1534
+ query: (async () => ({ isSuccess: true, data: {} })) as Dispatcher["query"],
1535
+ write: (async (type: string, payload: unknown) => {
1536
+ if (type === "form-draft:write:save") {
1537
+ draftKeys.push((payload as { draftKey: string }).draftKey);
1538
+ }
1539
+ return { isSuccess: true, data: { id: "1" } };
1540
+ }) as Dispatcher["write"],
1541
+ });
1542
+
1543
+ render(
1544
+ <DispatcherProvider dispatcher={dispatcher}>
1545
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1546
+ <RenderEdit<TestValues>
1547
+ screen={makeDraftWizardScreen(true)}
1548
+ entity={orderEntity}
1549
+ featureName="orders"
1550
+ initial={{ title: "", count: 0 }}
1551
+ writeCommand="order:create"
1552
+ />
1553
+ </DraftStorageProvider>
1554
+ </DispatcherProvider>,
1555
+ );
1556
+
1557
+ fireEvent.change(
1558
+ screen.getByTestId("field-title").querySelector("input") as HTMLInputElement,
1559
+ { target: { value: "Acme" } },
1560
+ );
1561
+ await act(async () => {
1562
+ fireEvent.submit(screen.getByTestId("render-edit-form"));
1563
+ await Promise.resolve();
1564
+ });
1565
+
1566
+ expect(draftKeys.length).toBeGreaterThan(0);
1567
+ expect(draftKeys[0]).toContain(":new:draft-");
1568
+ } finally {
1569
+ if (cryptoRef && original) cryptoRef.randomUUID = original;
1570
+ }
1571
+ });
1572
+
1520
1573
  test("a successful submit discards the draft", async () => {
1521
1574
  const { dispatcher, store, calls } = makeDraftDispatcher();
1522
1575
 
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
2
3
  import { type ReactNode, useState } from "react";
3
4
  import { fireEvent, render, screen } from "../../__tests__/test-utils";
4
5
  import { PlainContentEditor } from "../plain-content-editor";
@@ -6,21 +7,41 @@ import { PlainContentEditor } from "../plain-content-editor";
6
7
  function Controlled({ initial }: { readonly initial: string }): ReactNode {
7
8
  const [value, setValue] = useState(initial);
8
9
  return (
9
- <PlainContentEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />
10
+ <PlainContentEditor
11
+ id={CONTENT_EDITOR_ELEMENT_ID}
12
+ value={value}
13
+ onChange={setValue}
14
+ variables={["name"]}
15
+ readOnly={false}
16
+ />
10
17
  );
11
18
  }
12
19
 
13
20
  describe("PlainContentEditor", () => {
14
21
  test("renders the textarea plus one chip per variable", () => {
15
22
  render(
16
- <PlainContentEditor value="" onChange={() => {}} variables={["name"]} readOnly={false} />,
23
+ <PlainContentEditor
24
+ id={CONTENT_EDITOR_ELEMENT_ID}
25
+ value=""
26
+ onChange={() => {}}
27
+ variables={["name"]}
28
+ readOnly={false}
29
+ />,
17
30
  );
18
31
  expect(screen.getByRole("textbox")).toBeTruthy();
19
32
  expect(screen.getByText("{{name}}")).toBeTruthy();
20
33
  });
21
34
 
22
35
  test("no variables → no chips", () => {
23
- render(<PlainContentEditor value="" onChange={() => {}} variables={[]} readOnly={false} />);
36
+ render(
37
+ <PlainContentEditor
38
+ id={CONTENT_EDITOR_ELEMENT_ID}
39
+ value=""
40
+ onChange={() => {}}
41
+ variables={[]}
42
+ readOnly={false}
43
+ />,
44
+ );
24
45
  expect(screen.queryAllByRole("button")).toHaveLength(0);
25
46
  });
26
47
 
@@ -7,6 +7,7 @@ describe("RichContentEditor", () => {
7
7
  test("falls back to the plain textarea while the tiptap chunk loads, then swaps in the editor", async () => {
8
8
  render(
9
9
  <RichContentEditor
10
+ id={CONTENT_EDITOR_ELEMENT_ID}
10
11
  value="<p>hello</p>"
11
12
  onChange={() => {}}
12
13
  variables={[]}
@@ -6,7 +6,15 @@ import TiptapEditor from "../tiptap-editor";
6
6
 
7
7
  function Controlled({ initial }: { readonly initial: string }): ReactNode {
8
8
  const [value, setValue] = useState(initial);
9
- return <TiptapEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />;
9
+ return (
10
+ <TiptapEditor
11
+ id={CONTENT_EDITOR_ELEMENT_ID}
12
+ value={value}
13
+ onChange={setValue}
14
+ variables={["name"]}
15
+ readOnly={false}
16
+ />
17
+ );
10
18
  }
11
19
 
12
20
  function ReadOnlyToggle({ initial }: { readonly initial: string }): ReactNode {
@@ -16,7 +24,13 @@ function ReadOnlyToggle({ initial }: { readonly initial: string }): ReactNode {
16
24
  <button type="button" onClick={() => setReadOnly((r) => !r)}>
17
25
  toggle readOnly
18
26
  </button>
19
- <TiptapEditor value={initial} onChange={() => {}} variables={[]} readOnly={readOnly} />
27
+ <TiptapEditor
28
+ id={CONTENT_EDITOR_ELEMENT_ID}
29
+ value={initial}
30
+ onChange={() => {}}
31
+ variables={[]}
32
+ readOnly={readOnly}
33
+ />
20
34
  </div>
21
35
  );
22
36
  }
@@ -32,7 +46,13 @@ function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
32
46
  <button type="button" onClick={() => setValue(loaded)}>
33
47
  load
34
48
  </button>
35
- <TiptapEditor value={value} onChange={setValue} variables={[]} readOnly={false} />
49
+ <TiptapEditor
50
+ id={CONTENT_EDITOR_ELEMENT_ID}
51
+ value={value}
52
+ onChange={setValue}
53
+ variables={[]}
54
+ readOnly={false}
55
+ />
36
56
  </div>
37
57
  );
38
58
  }
@@ -40,22 +60,40 @@ function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
40
60
  describe("TiptapEditor — jsdom smoke", () => {
41
61
  test("mounts a contenteditable surface for the given HTML", async () => {
42
62
  render(
43
- <TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
63
+ <TiptapEditor
64
+ id={CONTENT_EDITOR_ELEMENT_ID}
65
+ value="<p>hello</p>"
66
+ onChange={() => {}}
67
+ variables={[]}
68
+ readOnly={false}
69
+ />,
44
70
  );
45
71
  const editable = await screen.findByText("hello");
46
72
  expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
47
73
  });
48
74
 
49
- test("the contenteditable surface carries CONTENT_EDITOR_ELEMENT_ID so the wrapping Field's label stays associated", async () => {
75
+ test("the editor's id prop lands on the contenteditable surface so the wrapping Field's label stays associated", async () => {
50
76
  render(
51
- <TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
77
+ <TiptapEditor
78
+ id={CONTENT_EDITOR_ELEMENT_ID}
79
+ value="<p>hello</p>"
80
+ onChange={() => {}}
81
+ variables={[]}
82
+ readOnly={false}
83
+ />,
52
84
  );
53
85
  expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
54
86
  });
55
87
 
56
88
  test("no variables → no chip bar", async () => {
57
89
  render(
58
- <TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
90
+ <TiptapEditor
91
+ id={CONTENT_EDITOR_ELEMENT_ID}
92
+ value="<p>hello</p>"
93
+ onChange={() => {}}
94
+ variables={[]}
95
+ readOnly={false}
96
+ />,
59
97
  );
60
98
  await screen.findByText("hello");
61
99
  expect(screen.queryAllByRole("button", { name: "Bold" })).toHaveLength(1);
@@ -64,7 +102,13 @@ describe("TiptapEditor — jsdom smoke", () => {
64
102
 
65
103
  test("every toolbar action resolves by its accessible name", async () => {
66
104
  render(
67
- <TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
105
+ <TiptapEditor
106
+ id={CONTENT_EDITOR_ELEMENT_ID}
107
+ value="<p>hello</p>"
108
+ onChange={() => {}}
109
+ variables={[]}
110
+ readOnly={false}
111
+ />,
68
112
  );
69
113
  await screen.findByText("hello");
70
114
  for (const name of [
@@ -131,6 +175,7 @@ describe("TiptapEditor — jsdom smoke", () => {
131
175
  const values: string[] = [];
132
176
  render(
133
177
  <TiptapEditor
178
+ id={CONTENT_EDITOR_ELEMENT_ID}
134
179
  value="<table><tbody><tr><td>cell</td></tr></tbody></table><p>hello</p>"
135
180
  onChange={(html) => values.push(html)}
136
181
  variables={[]}
@@ -5,14 +5,13 @@
5
5
  // variable-chip bar underneath. A chip click inserts `{{name}}` at the caret
6
6
  // instead of appending to the end.
7
7
  //
8
- // ponytail: looks the textarea up by CONTENT_EDITOR_ELEMENT_ID rather than a
9
- // ref threaded through the primitives Input contract — that contract is
10
- // cross-platform (RN has no DOM node), this file is `@runtime client`-only
11
- // and CONTENT_EDITOR_ELEMENT_ID exists exactly as this DOM hook. Upgrade to a
12
- // forwarded ref if a screen ever needs two plain editors mounted at once.
8
+ // ponytail: looks the textarea up via document.getElementById(id) rather
9
+ // than a ref threaded through the primitives Input contract — that contract
10
+ // is cross-platform (RN has no DOM node), this file is `@runtime client`-
11
+ // only. `id` is per-instance (caller passes a stable unique id via
12
+ // ContentEditorProps.id), so two plain editors mounted at once don't collide.
13
13
 
14
14
  import {
15
- CONTENT_EDITOR_ELEMENT_ID,
16
15
  type ContentEditorProps,
17
16
  TextareaContentEditor,
18
17
  VariableChips,
@@ -21,6 +20,7 @@ import type { ReactNode } from "react";
21
20
  import { useEffect, useState } from "react";
22
21
 
23
22
  export function PlainContentEditor({
23
+ id,
24
24
  value,
25
25
  onChange,
26
26
  variables,
@@ -31,7 +31,7 @@ export function PlainContentEditor({
31
31
  // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on value only — must fire once per committed insert, not on every caret write
32
32
  useEffect(() => {
33
33
  if (caret === null) return;
34
- const el = document.getElementById(CONTENT_EDITOR_ELEMENT_ID);
34
+ const el = document.getElementById(id);
35
35
  if (el instanceof HTMLTextAreaElement) {
36
36
  el.focus();
37
37
  el.setSelectionRange(caret, caret);
@@ -40,7 +40,7 @@ export function PlainContentEditor({
40
40
  }, [value]);
41
41
 
42
42
  const insertAtCaret = (name: string): void => {
43
- const el = document.getElementById(CONTENT_EDITOR_ELEMENT_ID);
43
+ const el = document.getElementById(id);
44
44
  const placeholder = `{{${name}}}`;
45
45
  if (!(el instanceof HTMLTextAreaElement)) {
46
46
  onChange(value + placeholder);
@@ -55,6 +55,7 @@ export function PlainContentEditor({
55
55
  return (
56
56
  <div>
57
57
  <TextareaContentEditor
58
+ id={id}
58
59
  value={value}
59
60
  onChange={onChange}
60
61
  variables={variables}
@@ -7,14 +7,13 @@
7
7
  // link button, so no URL-prompt UI to build or test.
8
8
 
9
9
  import {
10
- CONTENT_EDITOR_ELEMENT_ID,
11
10
  type ContentEditorProps,
12
11
  TextareaContentEditor,
13
12
  usePrimitives,
14
13
  useTranslation,
15
14
  VariableChips,
16
15
  } from "@cosmicdrift/kumiko-renderer";
17
- import { EditorContent, useEditor } from "@tiptap/react";
16
+ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
18
17
  import StarterKit from "@tiptap/starter-kit";
19
18
  import {
20
19
  Bold as BoldIcon,
@@ -62,6 +61,7 @@ function Toolbar({
62
61
  }
63
62
 
64
63
  export default function TiptapEditor({
64
+ id,
65
65
  value,
66
66
  onChange,
67
67
  variables,
@@ -82,17 +82,34 @@ export default function TiptapEditor({
82
82
  // this component before committing it; immediate render would create
83
83
  // an editor instance during that throwaway pass.
84
84
  immediatelyRender: false,
85
- // Same id the textarea fallback uses — the Field wrapping the editor
86
- // (TextBlockEditor) associates its label via this id; the editor
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.
92
- editorProps: { attributes: { id: CONTENT_EDITOR_ELEMENT_ID } },
85
+ // Caller-supplied id — the Field wrapping the editor (TextBlockEditor)
86
+ // associates its label via this id, see ContentEditorProps.id in
87
+ // content-editors.tsx.
88
+ editorProps: { attributes: { id } },
93
89
  onUpdate: ({ editor: e }) => onChange(e.getHTML()),
94
90
  });
95
91
 
92
+ // `useEditor` alone leaves `editor.isActive(...)` reads in the render body
93
+ // frozen on the last prop-driven render — @tiptap/react v3 defaults
94
+ // `shouldRerenderOnTransaction` to false, so a selection-only change (e.g.
95
+ // moving the cursor into bold text without typing) never triggers a
96
+ // re-render on its own. `useEditorState` subscribes to transactions itself
97
+ // and only re-renders when the selected slice of state actually changes.
98
+ const activeState = useEditorState({
99
+ editor,
100
+ selector: ({ editor: e }) =>
101
+ e === null
102
+ ? null
103
+ : {
104
+ bold: e.isActive("bold"),
105
+ italic: e.isActive("italic"),
106
+ heading1: e.isActive("heading", { level: 1 }),
107
+ heading2: e.isActive("heading", { level: 2 }),
108
+ bulletList: e.isActive("bulletList"),
109
+ orderedList: e.isActive("orderedList"),
110
+ },
111
+ });
112
+
96
113
  // TextBlockEditor loads the entry's content asynchronously (by-slug query
97
114
  // resolves after mount), so `value` arrives after useEditor already read
98
115
  // its initial (empty) content once. Sync it in — guarded against
@@ -110,6 +127,7 @@ export default function TiptapEditor({
110
127
  if (!editor)
111
128
  return (
112
129
  <TextareaContentEditor
130
+ id={id}
113
131
  value={value}
114
132
  onChange={onChange}
115
133
  variables={variables}
@@ -121,37 +139,37 @@ export default function TiptapEditor({
121
139
  {
122
140
  label: t("kumiko.contentEditor.bold"),
123
141
  icon: BoldIcon,
124
- isActive: editor.isActive("bold"),
142
+ isActive: activeState?.bold ?? false,
125
143
  onClick: () => editor.chain().focus().toggleBold().run(),
126
144
  },
127
145
  {
128
146
  label: t("kumiko.contentEditor.italic"),
129
147
  icon: ItalicIcon,
130
- isActive: editor.isActive("italic"),
148
+ isActive: activeState?.italic ?? false,
131
149
  onClick: () => editor.chain().focus().toggleItalic().run(),
132
150
  },
133
151
  {
134
152
  label: t("kumiko.contentEditor.heading1"),
135
153
  icon: Heading1,
136
- isActive: editor.isActive("heading", { level: 1 }),
154
+ isActive: activeState?.heading1 ?? false,
137
155
  onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
138
156
  },
139
157
  {
140
158
  label: t("kumiko.contentEditor.heading2"),
141
159
  icon: Heading2,
142
- isActive: editor.isActive("heading", { level: 2 }),
160
+ isActive: activeState?.heading2 ?? false,
143
161
  onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
144
162
  },
145
163
  {
146
164
  label: t("kumiko.contentEditor.bulletList"),
147
165
  icon: List,
148
- isActive: editor.isActive("bulletList"),
166
+ isActive: activeState?.bulletList ?? false,
149
167
  onClick: () => editor.chain().focus().toggleBulletList().run(),
150
168
  },
151
169
  {
152
170
  label: t("kumiko.contentEditor.orderedList"),
153
171
  icon: ListOrdered,
154
- isActive: editor.isActive("orderedList"),
172
+ isActive: activeState?.orderedList ?? false,
155
173
  onClick: () => editor.chain().focus().toggleOrderedList().run(),
156
174
  },
157
175
  ];
package/src/index.ts CHANGED
@@ -153,6 +153,7 @@ export {
153
153
  DropdownMenuTrigger,
154
154
  } from "./primitives/dropdown-menu";
155
155
  export { PageSection, Stack } from "./primitives/layout";
156
+ export { formatMoney } from "./primitives/money-input";
156
157
  export type { ToastOptions, ToastProviderProps, ToastVariant } from "./primitives/toast";
157
158
  export { ToastProvider, useToast } from "./primitives/toast";
158
159
  export type { CreateEventSourceLiveEventsOptions } from "./sse/live-events";
@@ -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 {
@@ -95,6 +95,26 @@ describe("WorkspaceSwitcher — Render", () => {
95
95
  expect(screen.getByText("Select workspace")).toBeTruthy();
96
96
  });
97
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
+
98
118
  test("Click auf einen Eintrag ruft onSelect mit der Workspace-id", async () => {
99
119
  const user = userEvent.setup();
100
120
  const onSelect = mock((_id: string) => {});
@@ -34,6 +34,12 @@ export type WorkspaceSwitcherProps = {
34
34
  readonly testId?: string;
35
35
  };
36
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
+ */
37
43
  export function WorkspaceSwitcher({
38
44
  workspaces,
39
45
  activeId,
@@ -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() {
@@ -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
  }
@@ -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,7 +11,7 @@ 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
17
  function setViewportWidth(width: number): void {
@@ -135,6 +135,47 @@ describe("EmbeddedListInput — desktop/mobile are mutually exclusive mounts (#1
135
135
  setViewportWidth(originalWidth);
136
136
  }
137
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.
147
+ const originalWidth = window.innerWidth;
148
+ window.innerWidth = 500;
149
+ const createElementSpy = spyOn(document, "createElement");
150
+ try {
151
+ renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
152
+ expect(screen.getByTestId("lines-mobile")).toBeTruthy();
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();
174
+ expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
175
+ } finally {
176
+ window.innerWidth = originalWidth;
177
+ }
178
+ });
138
179
  });
139
180
 
140
181
  describe("EmbeddedListInput — row mutation callbacks", () => {
@@ -5,12 +5,13 @@
5
5
  // paste).
6
6
  //
7
7
  // Only one of the two layouts (table for md+, cards below md) is mounted
8
- // at a time, picked via useIsMobile — mounting both and toggling with
9
- // `hidden`/`md:hidden` left two live inputs per cell sharing one DOM id
10
- // (#1854). useIsMobile reports `false` for the first render regardless of
11
- // viewport, so the `hidden md:block` / `md:hidden` classes stay on the
12
- // wrapper divs too: on a phone that first (wrong) render is desktop but
13
- // CSS-hidden, not a visible flash of the wrong layout.
8
+ // at a time, picked via useIsNarrowViewport — mounting both and toggling
9
+ // with `hidden`/`md:hidden` left two live inputs per cell sharing one DOM
10
+ // id (#1854). The `hidden md:block` / `md:hidden` classes stay on the
11
+ // wrapper divs as a defensive fallback (e.g. SSR/no-JS, where
12
+ // getServerSnapshot forces the desktop layout) even though
13
+ // useIsNarrowViewport unlike the vendored useIsMobile it replaced
14
+ // already reports the correct value on the very first client render.
14
15
  //
15
16
  // ponytail: crossing the mobile/desktop breakpoint unmounts the active
16
17
  // layout and mounts the other, so an in-progress cell draft (uncommitted
@@ -41,11 +42,11 @@ import { Button as UiButton } from "../ui/button";
41
42
  import { Checkbox } from "../ui/checkbox";
42
43
  import { Input as UiInput } from "../ui/input";
43
44
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
44
- import { useIsMobile } from "../ui/use-mobile";
45
45
  import { ComboboxInput } from "./combobox";
46
46
  import { DateInput } from "./date-input";
47
47
  import { formatMoney, MoneyInput } from "./money-input";
48
48
  import { TimestampInput } from "./timestamp-input";
49
+ import { useIsNarrowViewport } from "./use-narrow-viewport";
49
50
 
50
51
  // `input[type=hidden]` excluded — ComboboxInput renders one as a plain
51
52
  // name-carrier before its focusable trigger button.
@@ -383,7 +384,7 @@ export function EmbeddedListInput({
383
384
  // component always hardcoded.
384
385
  const effectiveCurrency = currency ?? "EUR";
385
386
  const resolvedLocale = useLocale().locale();
386
- const isMobile = useIsMobile();
387
+ const isMobile = useIsNarrowViewport();
387
388
  const containerRef = useRef<HTMLDivElement>(null);
388
389
  const [pendingFocusCellId, setPendingFocusCellId] = useState<string | undefined>(undefined);
389
390
 
@@ -0,0 +1,28 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ // Matches ui/use-mobile.ts's MOBILE_BREAKPOINT. Kept as a separate constant
4
+ // because that file is vendored shadcn (regenerated via scripts/sync-shadcn.ts)
5
+ // and cannot be imported from without risking a future overwrite.
6
+ const MOBILE_BREAKPOINT = 768;
7
+
8
+ function subscribe(callback: () => void): () => void {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10
+ mql.addEventListener("change", callback);
11
+ return () => mql.removeEventListener("change", callback);
12
+ }
13
+
14
+ function getSnapshot(): boolean {
15
+ return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
16
+ }
17
+
18
+ function getServerSnapshot(): boolean {
19
+ return false;
20
+ }
21
+
22
+ // Unlike the vendored `useIsMobile` (ui/use-mobile.ts), which only sets its
23
+ // result in a `useEffect` and therefore always reports `false` on the first
24
+ // render regardless of actual viewport, this reads the real value up front
25
+ // via `useSyncExternalStore` — no wrong-then-corrected first render.
26
+ export function useIsNarrowViewport(): boolean {
27
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
28
+ }
@@ -299,6 +299,28 @@ describe("Drawer", () => {
299
299
  expect(handle.getAttribute("aria-valuenow")).toBe("484");
300
300
  });
301
301
 
302
+ test("PointerDown on the handle locks text selection, PointerUp restores it (fw#1965)", () => {
303
+ render(
304
+ <Drawer
305
+ open={true}
306
+ onOpenChange={() => {}}
307
+ side="right"
308
+ testId="drawer"
309
+ resize={{ defaultWidthPx: 400, minWidthPx: 300, maxWidthPx: 500 }}
310
+ >
311
+ <div>Body</div>
312
+ </Drawer>,
313
+ );
314
+ const handle = screen.getByRole("separator");
315
+ expect(document.body.style.userSelect).toBe("");
316
+
317
+ fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100 });
318
+ expect(document.body.style.userSelect).toBe("none");
319
+
320
+ fireEvent.pointerUp(handle, { pointerId: 1, clientX: 100 });
321
+ expect(document.body.style.userSelect).toBe("");
322
+ });
323
+
302
324
  test("side='top' with resize set: no resize handle, no maximize button (vertical drawers can't resize)", () => {
303
325
  render(
304
326
  <Drawer
@@ -110,6 +110,30 @@ describe("UploadZone", () => {
110
110
  expect(screen.queryByText("upload_failed")).toBeNull();
111
111
  });
112
112
 
113
+ test("uploads multiple files with distinct, stable row keys even without crypto.randomUUID (insecure-context LAN preview)", async () => {
114
+ // `crypto.randomUUID` only exists in a secure context — an HTTP-over-LAN
115
+ // preview leaves it undefined, so calling it throws TypeError. Row ids
116
+ // must not depend on it.
117
+ const originalRandomUUID = crypto.randomUUID;
118
+ (crypto as unknown as { randomUUID?: () => string }).randomUUID = undefined;
119
+ try {
120
+ const onUpload = mock(async () => {});
121
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
122
+ const a = new File(["a"], "a.pdf");
123
+ const b = new File(["b"], "b.pdf");
124
+ pick(screen.getByTestId("zone-input"), [a, b]);
125
+
126
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(2));
127
+ expect(screen.getByText("a.pdf")).toBeTruthy();
128
+ expect(screen.getByText("b.pdf")).toBeTruthy();
129
+ // Two distinct rows in the DOM (not collapsed onto one shared/undefined
130
+ // React key) proves the ids stayed unique without crypto.randomUUID.
131
+ expect(screen.getAllByTestId("zone-row")).toHaveLength(2);
132
+ } finally {
133
+ crypto.randomUUID = originalRandomUUID;
134
+ }
135
+ });
136
+
113
137
  test("disabled unterdrückt den Drop", () => {
114
138
  const onUpload = mock(async () => {});
115
139
  render(<UploadZone title="Datei hochladen" onUpload={onUpload} disabled testId="zone" />);
@@ -164,7 +188,9 @@ describe("UploadZone", () => {
164
188
  try {
165
189
  const onUpload = mock(async (_uploaded: File) => {});
166
190
  render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
167
- const file = new File(["x"], "photo.jpg", { type: "image/jpeg" });
191
+ // Content sized well above the fake resize's 13 bytes — resizeImageBeforeUpload
192
+ // (framework#1979) only takes the re-encode when it's actually smaller.
193
+ const file = new File(["x".repeat(2000)], "photo.jpg", { type: "image/jpeg" });
168
194
  pick(screen.getByTestId("zone-input"), [file]);
169
195
 
170
196
  await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
@@ -54,13 +54,13 @@ function defaultWidthFromViewport(): number {
54
54
  function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
55
55
  switch (side) {
56
56
  case "left":
57
- return "inset-y-8 left-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl";
57
+ return "inset-y-8 left-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
58
58
  case "top":
59
- return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
59
+ return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
60
60
  case "bottom":
61
- return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
61
+ return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
62
62
  default:
63
- return "inset-y-8 right-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl";
63
+ return "inset-y-8 right-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
64
64
  }
65
65
  }
66
66
 
@@ -101,9 +101,14 @@ export function Drawer({
101
101
  const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
102
102
 
103
103
  const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
104
+ event.preventDefault();
104
105
  event.currentTarget.setPointerCapture(event.pointerId);
105
106
  dragRef.current = { startX: event.clientX, startWidth: effectiveWidthPx };
106
107
  setMaximized(false);
108
+ // Handle already carries `cursor-col-resize`, so only the text-selection
109
+ // lock is needed here — without it, a fast drag over the drawer content
110
+ // selects the text underneath instead of just resizing.
111
+ document.body.style.setProperty("user-select", "none");
107
112
  };
108
113
  const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
109
114
  if (dragRef.current === null) return;
@@ -114,6 +119,7 @@ export function Drawer({
114
119
  const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
115
120
  event.currentTarget.releasePointerCapture(event.pointerId);
116
121
  dragRef.current = null;
122
+ document.body.style.removeProperty("user-select");
117
123
  };
118
124
  const onHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
119
125
  const step = event.shiftKey ? 40 : 16;
@@ -90,9 +90,13 @@ export function UploadZone({
90
90
  const inputRef = useRef<HTMLInputElement>(null);
91
91
  const [rows, setRows] = useState<readonly UploadRow[]>([]);
92
92
  const [dragOver, setDragOver] = useState(false);
93
+ // `crypto.randomUUID` only exists in a secure context — a plain-HTTP LAN
94
+ // preview leaves it undefined. These ids are React keys/row ids, not
95
+ // globally unique identifiers, so a per-instance counter is enough.
96
+ const nextRowId = useRef(0);
93
97
 
94
98
  async function uploadOne(file: File): Promise<void> {
95
- const rowId = crypto.randomUUID();
99
+ const rowId = String(nextRowId.current++);
96
100
  setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
97
101
  try {
98
102
  await onUpload(await resizeImageBeforeUpload(file));
@@ -113,7 +117,7 @@ export function UploadZone({
113
117
  setRows((prev) => [
114
118
  ...prev,
115
119
  {
116
- id: crypto.randomUUID(),
120
+ id: String(nextRowId.current++),
117
121
  fileName: file.name,
118
122
  status: "error",
119
123
  error: t("kumiko.widget.upload.rejected-type"),