@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.
- package/package.json +4 -4
- package/src/__tests__/create-app.test.tsx +9 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +32 -0
- package/src/__tests__/render-edit.test.tsx +394 -1
- package/src/app/__tests__/plain-content-editor.test.tsx +24 -3
- package/src/app/__tests__/rich-content-editor.test.tsx +1 -0
- package/src/app/__tests__/tiptap-editor.test.tsx +53 -8
- package/src/app/plain-content-editor.tsx +9 -8
- package/src/app/tiptap-editor.tsx +34 -16
- package/src/index.ts +1 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +22 -2
- package/src/layout/nav-tree.tsx +8 -0
- package/src/layout/workspace-switcher.tsx +6 -0
- package/src/lib/__tests__/resize-image.test.ts +50 -6
- package/src/lib/accept-attr.ts +5 -0
- package/src/lib/resize-image.ts +6 -1
- package/src/primitives/__tests__/embedded-list-input.test.tsx +43 -2
- package/src/primitives/embedded-list-input.tsx +21 -8
- package/src/primitives/file-upload.tsx +1 -6
- package/src/primitives/index.tsx +7 -0
- package/src/primitives/use-narrow-viewport.ts +28 -0
- package/src/widgets/__tests__/drawer.test.tsx +22 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +27 -1
- package/src/widgets/drawer.tsx +16 -6
- package/src/widgets/infinity-list.tsx +33 -5
- package/src/widgets/upload-zone.tsx +7 -8
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
9
|
-
// ref threaded through the primitives Input contract — that contract
|
|
10
|
-
// cross-platform (RN has no DOM node), this file is `@runtime client`-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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) => {});
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/resize-image.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
9
|
-
// `hidden`/`md:hidden` left two live inputs per cell sharing one DOM
|
|
10
|
-
// (#1854).
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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 =
|
|
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,
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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]}
|