@cosmicdrift/kumiko-renderer-web 0.194.0 → 0.196.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.
@@ -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) => {});
@@ -101,6 +101,7 @@ import {
101
101
  SidebarMenuSub,
102
102
  SidebarMenuSubButton,
103
103
  SidebarMenuSubItem,
104
+ useSidebar,
104
105
  } from "../ui/sidebar";
105
106
  import { useDispatchTarget } from "./target-resolver-stub";
106
107
  import { parseTargetFromSearchParams } from "./target-url";
@@ -224,7 +225,14 @@ export function NavTree({
224
225
  }, []);
225
226
 
226
227
  const t = useTranslation();
228
+ const { state: sidebarState } = useSidebar();
227
229
  const [filter, setFilter] = useState("");
230
+ // The search box is only CSS-hidden (`group-data-[collapsible=icon]:hidden`)
231
+ // when the sidebar collapses to icon rail, not unmounted — without this,
232
+ // a stale filter re-applies invisibly on the next expand (fw#1816).
233
+ useEffect(() => {
234
+ if (sidebarState === "collapsed") setFilter("");
235
+ }, [sidebarState]);
228
236
  const q = filter.trim().toLowerCase();
229
237
  const matches = useCallback(
230
238
  (raw: string): boolean => {
@@ -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,
@@ -9,11 +9,16 @@ describe("resizeImageBeforeUpload", () => {
9
9
  });
10
10
 
11
11
  test("fehlt OffscreenCanvas, bleibt das Bild unverändert", async () => {
12
+ const originalOffscreenCanvas = globalThis.OffscreenCanvas;
12
13
  // @ts-expect-error simulate a browser without OffscreenCanvas support
13
14
  globalThis.OffscreenCanvas = undefined;
14
- const file = new File(["hi"], "photo.jpg", { type: "image/jpeg" });
15
- const result = await resizeImageBeforeUpload(file);
16
- expect(result).toBe(file);
15
+ try {
16
+ const file = new File(["hi"], "photo.jpg", { type: "image/jpeg" });
17
+ const result = await resizeImageBeforeUpload(file);
18
+ expect(result).toBe(file);
19
+ } finally {
20
+ globalThis.OffscreenCanvas = originalOffscreenCanvas;
21
+ }
17
22
  });
18
23
 
19
24
  test("lässt SVGs unverändert (Vektor würde beim Re-Encode zerstört)", async () => {
@@ -49,7 +54,9 @@ describe("resizeImageBeforeUpload", () => {
49
54
  globalThis.createImageBitmap = mock(async () => bitmap);
50
55
 
51
56
  try {
52
- const file = new File(["x"], "photo.jpg", { type: "image/jpeg" });
57
+ // Content sized well above the 1-byte re-encoded blob so the resize
58
+ // wins the size-guard and the assertions below observe its output.
59
+ const file = new File(["x".repeat(2000)], "photo.jpg", { type: "image/jpeg" });
53
60
  const result = await resizeImageBeforeUpload(file, 2560);
54
61
  expect(capturedWidth).toBe(2560);
55
62
  expect(capturedHeight).toBe(1280);
@@ -81,7 +88,9 @@ describe("resizeImageBeforeUpload", () => {
81
88
  }));
82
89
 
83
90
  try {
84
- const file = new File(["x"], "photo.heic", { type: "image/heic" });
91
+ // Content sized well above the 1-byte re-encoded blob so the resize
92
+ // wins the size-guard and the assertions below observe its output.
93
+ const file = new File(["x".repeat(2000)], "photo.heic", { type: "image/heic" });
85
94
  const result = await resizeImageBeforeUpload(file);
86
95
  expect(result.name).toBe("photo.jpg");
87
96
  expect(result.type).toBe("image/jpeg");
@@ -114,7 +123,9 @@ describe("resizeImageBeforeUpload", () => {
114
123
  }));
115
124
 
116
125
  try {
117
- const file = new File(["x"], "photo.webp", { type: "image/webp" });
126
+ // Content sized well above the 1-byte re-encoded blob so the resize
127
+ // wins the size-guard and the assertions below observe its output.
128
+ const file = new File(["x".repeat(2000)], "photo.webp", { type: "image/webp" });
118
129
  const result = await resizeImageBeforeUpload(file);
119
130
  expect(result.name).toBe("photo.png");
120
131
  expect(result.type).toBe("image/png");
@@ -126,6 +137,39 @@ describe("resizeImageBeforeUpload", () => {
126
137
  }
127
138
  });
128
139
 
140
+ test("belässt ein bereits optimiertes Bild unverändert, wenn der Re-Encode es vergrößern würde", async () => {
141
+ // A small in-spec PNG (e.g. palette-optimized) round-tripped through a
142
+ // canvas can come back as a bigger full-RGBA blob — the size guard must
143
+ // then keep serving the original bytes instead of the larger re-encode.
144
+ class FakeOffscreenCanvas {
145
+ getContext() {
146
+ return { drawImage: mock(() => {}) };
147
+ }
148
+ convertToBlob() {
149
+ return Promise.resolve(new Blob(["x".repeat(2000)], { type: "image/png" }));
150
+ }
151
+ }
152
+ // @ts-expect-error test stub for a browser-only API missing in jsdom
153
+ globalThis.OffscreenCanvas = FakeOffscreenCanvas;
154
+ globalThis.createImageBitmap = mock(async () => ({
155
+ close: mock(() => {}),
156
+ height: 100,
157
+ width: 100,
158
+ }));
159
+
160
+ try {
161
+ const file = new File(["x"], "photo.png", { type: "image/png" });
162
+ const result = await resizeImageBeforeUpload(file);
163
+ expect(result).toBe(file);
164
+ expect(result.size).toBeLessThanOrEqual(file.size);
165
+ } finally {
166
+ // @ts-expect-error restore missing-API baseline
167
+ globalThis.OffscreenCanvas = undefined;
168
+ // @ts-expect-error restore missing-API baseline
169
+ globalThis.createImageBitmap = undefined;
170
+ }
171
+ });
172
+
129
173
  test("fällt bei Decode-Fehlern auf die Originaldatei zurück", async () => {
130
174
  class FakeOffscreenCanvas {
131
175
  getContext() {
@@ -0,0 +1,5 @@
1
+ // "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
2
+ export function toAcceptAttr(accept?: readonly string[]): string | undefined {
3
+ if (accept === undefined || accept.length === 0) return undefined;
4
+ return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
5
+ }
@@ -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
 
@@ -442,6 +443,18 @@ export function EmbeddedListInput({
442
443
  const isTabForward = event.key === "Tab" && !event.shiftKey;
443
444
  const isEnter = event.key === "Enter";
444
445
  if (!isTabForward && !isEnter) return;
446
+ // A select/reference last column renders its picker trigger as a
447
+ // <button> (or an open cmdk popover's own input) in the cell — Enter
448
+ // there is the browser's native button-activation, not "append a row".
449
+ // Returning before preventDefault() lets that activation proceed;
450
+ // Tab still falls through to the append-row behavior below.
451
+ if (
452
+ isEnter &&
453
+ event.target instanceof HTMLElement &&
454
+ event.target.closest('button, [role="combobox"], [cmdk-input]') !== null
455
+ ) {
456
+ return;
457
+ }
445
458
  if (maxItems !== undefined && rows.length >= maxItems) return;
446
459
  // Enter bubbles up from the cell control through this TableCell — an
447
460
  // ancestor of any <form> the caller wraps the whole field in.
@@ -6,6 +6,7 @@
6
6
  import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
7
7
  import { ImageIcon, Loader2, Upload } from "lucide-react";
8
8
  import { type ChangeEvent, type ReactNode, useRef, useState } from "react";
9
+ import { toAcceptAttr } from "../lib/accept-attr";
9
10
  import { resizeImageBeforeUpload } from "../lib/resize-image";
10
11
  import { Button as UiButton } from "../ui/button";
11
12
 
@@ -22,12 +23,6 @@ export type FileUploadInputProps = {
22
23
  readonly capture?: "environment" | "user";
23
24
  };
24
25
 
25
- // "jpg" → ".jpg", "image/png" bleibt. Leere Liste → kein accept-Attribut.
26
- function toAcceptAttr(accept?: readonly string[]): string | undefined {
27
- if (accept === undefined || accept.length === 0) return undefined;
28
- return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
29
- }
30
-
31
26
  export function FileUploadInput({
32
27
  kind,
33
28
  id,
@@ -69,6 +69,7 @@ import {
69
69
  type ChangeEvent,
70
70
  type CSSProperties,
71
71
  createContext,
72
+ type MouseEvent,
72
73
  type ReactNode,
73
74
  useContext,
74
75
  useEffect,
@@ -732,6 +733,12 @@ function DefaultDataTable({
732
733
  // if the sum of the columns gets too wide.
733
734
  className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
734
735
  title={cellTitle(row.values[col.field])}
736
+ // A click into the editable cell's widget must not also
737
+ // trigger the row's onClick (typically "Open Detail") —
738
+ // same reasoning as the actions cell below.
739
+ {...(onCellChange !== undefined && {
740
+ onClick: (e: MouseEvent) => e.stopPropagation(),
741
+ })}
735
742
  >
736
743
  <DataTableCell
737
744
  value={row.values[col.field]}