@cosmicdrift/kumiko-renderer-web 0.193.0 → 0.194.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/create-app.test.tsx +1 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
- package/src/__tests__/field-hide-label.test.tsx +166 -1
- package/src/__tests__/kumiko-screen.test.tsx +59 -0
- package/src/__tests__/nav-tree.test.tsx +43 -11
- package/src/__tests__/primitives.test.tsx +33 -2
- package/src/__tests__/render-edit.test.tsx +344 -9
- package/src/__tests__/test-utils.tsx +20 -0
- package/src/__tests__/wizard-form-validation.test.tsx +129 -1
- package/src/app/__tests__/merge-content-editors.test.ts +2 -2
- package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
- package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/tiptap-editor.tsx +10 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +20 -13
- package/src/lib/__tests__/resize-image.test.ts +152 -0
- package/src/lib/clamp.ts +3 -0
- package/src/lib/resize-image.ts +61 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
- package/src/primitives/__tests__/date-field.test.tsx +37 -0
- package/src/primitives/__tests__/date-parse.test.ts +14 -2
- package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
- package/src/primitives/__tests__/file-upload.test.tsx +24 -0
- package/src/primitives/date-field.tsx +8 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/dropdown-menu.tsx +23 -0
- package/src/primitives/embedded-list-input.tsx +14 -5
- package/src/primitives/file-upload.tsx +6 -1
- package/src/primitives/index.tsx +114 -120
- package/src/primitives/money-input.tsx +4 -7
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +293 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +125 -1
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +54 -29
- package/src/widgets/infinity-list.tsx +18 -5
- package/src/widgets/progress-bar.tsx +3 -1
- package/src/widgets/step-bar.tsx +1 -1
- package/src/widgets/upload-zone.tsx +42 -3
package/src/widgets/drawer.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
|
2
2
|
import { type ReactNode, useRef, useState } from "react";
|
|
3
|
+
import { clamp } from "../lib/clamp";
|
|
3
4
|
import { cn } from "../lib/cn";
|
|
4
5
|
import {
|
|
5
6
|
Sheet,
|
|
@@ -20,39 +21,49 @@ export type DrawerProps = {
|
|
|
20
21
|
readonly children: ReactNode;
|
|
21
22
|
readonly testId?: string;
|
|
22
23
|
/** Opt-in drag-to-resize + maximize toggle (left/right sides only). */
|
|
23
|
-
readonly
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
readonly resize?: {
|
|
25
|
+
readonly defaultWidthPx?: number;
|
|
26
|
+
readonly minWidthPx?: number;
|
|
27
|
+
readonly maxWidthPx?: number;
|
|
28
|
+
};
|
|
29
|
+
/** Backdrop behind the panel. A drawer exists so the content behind it
|
|
30
|
+
* stays readable, so the blur is off by default. */
|
|
31
|
+
readonly backdrop?: {
|
|
32
|
+
readonly blurPx?: number;
|
|
33
|
+
readonly dimPercent?: number;
|
|
34
|
+
};
|
|
27
35
|
};
|
|
28
36
|
|
|
29
|
-
const DEFAULT_WIDTH_PX = 420;
|
|
30
37
|
const MIN_WIDTH_PX = 320;
|
|
31
|
-
const MAX_WIDTH_PX =
|
|
38
|
+
const MAX_WIDTH_PX = 1000;
|
|
39
|
+
// Must match the static `max(520px,25vw)` in floatingSideClass — Tailwind's
|
|
40
|
+
// JIT can't read these at runtime, so the two are kept in sync by hand.
|
|
41
|
+
const DEFAULT_WIDTH_MIN_PX = 520;
|
|
42
|
+
const DEFAULT_WIDTH_VIEWPORT_RATIO = 0.25;
|
|
43
|
+
const DEFAULT_BLUR_PX = 0;
|
|
44
|
+
const DEFAULT_DIM_PERCENT = 20;
|
|
45
|
+
|
|
46
|
+
function defaultWidthFromViewport(): number {
|
|
47
|
+
return typeof window === "undefined"
|
|
48
|
+
? DEFAULT_WIDTH_MIN_PX
|
|
49
|
+
: Math.max(DEFAULT_WIDTH_MIN_PX, Math.round(window.innerWidth * DEFAULT_WIDTH_VIEWPORT_RATIO));
|
|
50
|
+
}
|
|
32
51
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// per-side classes. twMerge resolves each utility group against the base
|
|
36
|
-
// (inset/width/height/border/rounding), so this fully overrides rather than
|
|
37
|
-
// stacking with it. 32px margin + 32px radius so the detachment from the
|
|
38
|
-
// viewport edge reads clearly at a glance, not just on close 1:1 inspection.
|
|
52
|
+
// 32px margin + 32px radius so the panel reads as detached from the
|
|
53
|
+
// viewport edge, unlike the sheet primitive's flush-edge default.
|
|
39
54
|
function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
|
|
40
55
|
switch (side) {
|
|
41
56
|
case "left":
|
|
42
|
-
return "inset-y-8 left-8 h-auto w-[
|
|
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";
|
|
43
58
|
case "top":
|
|
44
59
|
return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
45
60
|
case "bottom":
|
|
46
61
|
return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
47
62
|
default:
|
|
48
|
-
return "inset-y-8 right-8 h-auto w-[
|
|
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";
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
function clamp(value: number, min: number, max: number): number {
|
|
53
|
-
return Math.min(Math.max(value, min), max);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
67
|
/** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
|
|
57
68
|
* thin wrapper over the Sheet primitive with header/body/footer slots
|
|
58
69
|
* so screens skip per-route Radix boilerplate. */
|
|
@@ -65,20 +76,28 @@ export function Drawer({
|
|
|
65
76
|
footer,
|
|
66
77
|
children,
|
|
67
78
|
testId,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
minWidthPx = MIN_WIDTH_PX,
|
|
71
|
-
maxWidthPx = MAX_WIDTH_PX,
|
|
79
|
+
resize,
|
|
80
|
+
backdrop,
|
|
72
81
|
}: DrawerProps): ReactNode {
|
|
73
|
-
const canResize =
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
77
|
-
|
|
82
|
+
const canResize = resize !== undefined && (side === "left" || side === "right");
|
|
83
|
+
const minWidthPx = resize?.minWidthPx ?? MIN_WIDTH_PX;
|
|
84
|
+
const maxWidthPx = resize?.maxWidthPx ?? MAX_WIDTH_PX;
|
|
78
85
|
const effectiveMaxWidthPx = () =>
|
|
79
86
|
typeof window === "undefined"
|
|
80
87
|
? maxWidthPx
|
|
81
88
|
: Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
|
|
89
|
+
const [width, setWidth] = useState(() =>
|
|
90
|
+
clamp(resize?.defaultWidthPx ?? defaultWidthFromViewport(), minWidthPx, effectiveMaxWidthPx()),
|
|
91
|
+
);
|
|
92
|
+
const [maximized, setMaximized] = useState(false);
|
|
93
|
+
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
94
|
+
const blurPx = backdrop?.blurPx ?? DEFAULT_BLUR_PX;
|
|
95
|
+
const dimPercent = backdrop?.dimPercent ?? DEFAULT_DIM_PERCENT;
|
|
96
|
+
const overlayStyle: React.CSSProperties = {
|
|
97
|
+
backgroundColor: `rgb(0 0 0 / ${dimPercent}%)`,
|
|
98
|
+
...(blurPx > 0 ? { backdropFilter: `blur(${blurPx}px)` } : {}),
|
|
99
|
+
};
|
|
100
|
+
|
|
82
101
|
const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
|
|
83
102
|
|
|
84
103
|
const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
@@ -104,7 +123,13 @@ export function Drawer({
|
|
|
104
123
|
event.preventDefault();
|
|
105
124
|
setMaximized(false);
|
|
106
125
|
const delta = event.key === grow ? step : -step;
|
|
107
|
-
|
|
126
|
+
// Seed from the currently visible width, not the stale `width` state —
|
|
127
|
+
// while maximized, `width` still holds the pre-maximize value, so a key
|
|
128
|
+
// press would otherwise jump the drawer back to that old size instead
|
|
129
|
+
// of resizing relative to what's on screen.
|
|
130
|
+
setWidth((current) =>
|
|
131
|
+
clamp((maximized ? effectiveWidthPx : current) + delta, minWidthPx, effectiveMaxWidthPx()),
|
|
132
|
+
);
|
|
108
133
|
};
|
|
109
134
|
|
|
110
135
|
return (
|
|
@@ -112,7 +137,7 @@ export function Drawer({
|
|
|
112
137
|
<SheetContent
|
|
113
138
|
side={side}
|
|
114
139
|
data-testid={testId}
|
|
115
|
-
|
|
140
|
+
overlayStyle={overlayStyle}
|
|
116
141
|
className={floatingSideClass(side)}
|
|
117
142
|
style={canResize ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
|
|
118
143
|
>
|
|
@@ -106,11 +106,24 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
106
106
|
nextRows.map((row, index) => rowIdRef.current(row, index)),
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
|
-
setState((prev) =>
|
|
110
|
-
kind
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
setState((prev) => {
|
|
110
|
+
if (cursor === null || prev.kind !== "ready") {
|
|
111
|
+
return { kind: "ready", rows: nextRows, cursor: nextCursorRef.current(res.data) };
|
|
112
|
+
}
|
|
113
|
+
// The live-merge above can re-sort a row to the front of page 1; an
|
|
114
|
+
// offset-based cursor then re-serves that same row in a later page.
|
|
115
|
+
// Dedupe on append so it doesn't land twice under duplicate React
|
|
116
|
+
// keys.
|
|
117
|
+
const existingIds = new Set(prev.rows.map((row, index) => rowIdRef.current(row, index)));
|
|
118
|
+
const newRows = nextRows.filter(
|
|
119
|
+
(row, index) => !existingIds.has(rowIdRef.current(row, index)),
|
|
120
|
+
);
|
|
121
|
+
return {
|
|
122
|
+
kind: "ready",
|
|
123
|
+
rows: [...prev.rows, ...newRows],
|
|
124
|
+
cursor: nextCursorRef.current(res.data),
|
|
125
|
+
};
|
|
126
|
+
});
|
|
114
127
|
},
|
|
115
128
|
[dispatcher, query, pageSize, payloadKey],
|
|
116
129
|
);
|
|
@@ -18,7 +18,9 @@ export function ProgressBar({
|
|
|
18
18
|
readonly className?: string;
|
|
19
19
|
readonly testId?: string;
|
|
20
20
|
}): ReactNode {
|
|
21
|
-
|
|
21
|
+
// `value` is typically `done / total`; total:0 produces NaN, which would
|
|
22
|
+
// otherwise leak into `width: "NaN%"` and an invalid aria-valuenow.
|
|
23
|
+
const pct = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
|
22
24
|
return (
|
|
23
25
|
<div className={cn("w-full", className)}>
|
|
24
26
|
<div
|
package/src/widgets/step-bar.tsx
CHANGED
|
@@ -49,7 +49,7 @@ export function StepBar({
|
|
|
49
49
|
{isDone ? (
|
|
50
50
|
<>
|
|
51
51
|
<Check aria-hidden="true" className="size-3.5" />
|
|
52
|
-
<span className="sr-only">{t("kumiko.widget.
|
|
52
|
+
<span className="sr-only">{t("kumiko.widget.step-bar.done")}</span>
|
|
53
53
|
</>
|
|
54
54
|
) : (
|
|
55
55
|
<span className="text-xs font-semibold">{i + 1}</span>
|
|
@@ -2,6 +2,7 @@ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
|
2
2
|
import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload } from "lucide-react";
|
|
3
3
|
import { type DragEvent, type ReactNode, useId, useRef, useState } from "react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
|
+
import { resizeImageBeforeUpload } from "../lib/resize-image";
|
|
5
6
|
|
|
6
7
|
type UploadRowStatus = "uploading" | "done" | "error";
|
|
7
8
|
|
|
@@ -44,6 +45,32 @@ function toAcceptAttr(accept?: readonly string[]): string | undefined {
|
|
|
44
45
|
return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// `accept` on the native <input> only filters the file-picker dialog — a
|
|
49
|
+
// drag&drop drop is never routed through it, so any file type lands in
|
|
50
|
+
// `onUpload` regardless of what `accept` promises. Same matching rules as
|
|
51
|
+
// the native attribute: MIME type (with an optional "type/*" wildcard) or
|
|
52
|
+
// file extension.
|
|
53
|
+
function matchesAccept(file: File, accept?: readonly string[]): boolean {
|
|
54
|
+
if (accept === undefined || accept.length === 0) return true;
|
|
55
|
+
const name = file.name.toLowerCase();
|
|
56
|
+
const type = file.type.toLowerCase();
|
|
57
|
+
return accept.some((raw) => {
|
|
58
|
+
const a = raw.toLowerCase();
|
|
59
|
+
if (a.includes("/")) return a.endsWith("/*") ? type.startsWith(a.slice(0, -1)) : type === a;
|
|
60
|
+
return name.endsWith(a.startsWith(".") ? a : `.${a}`);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function partitionByAccept(
|
|
65
|
+
files: readonly File[],
|
|
66
|
+
accept: readonly string[] | undefined,
|
|
67
|
+
): readonly [accepted: readonly File[], rejected: readonly File[]] {
|
|
68
|
+
const accepted: File[] = [];
|
|
69
|
+
const rejected: File[] = [];
|
|
70
|
+
for (const file of files) (matchesAccept(file, accept) ? accepted : rejected).push(file);
|
|
71
|
+
return [accepted, rejected];
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
/** Drop zone + multi-file picker with a per-file status row (uploading/done/
|
|
48
75
|
* error). For screens that accept several files and upload them
|
|
49
76
|
* independently — unlike `FileField` (single file, FileRef value inside a
|
|
@@ -68,10 +95,10 @@ export function UploadZone({
|
|
|
68
95
|
const rowId = crypto.randomUUID();
|
|
69
96
|
setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
|
|
70
97
|
try {
|
|
71
|
-
await onUpload(file);
|
|
98
|
+
await onUpload(await resizeImageBeforeUpload(file));
|
|
72
99
|
setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "done" } : row)));
|
|
73
100
|
} catch (cause) {
|
|
74
|
-
const message = cause instanceof Error ? cause.message : "
|
|
101
|
+
const message = cause instanceof Error ? cause.message : t("kumiko.widget.upload.error");
|
|
75
102
|
setRows((prev) =>
|
|
76
103
|
prev.map((row) => (row.id === rowId ? { ...row, status: "error", error: message } : row)),
|
|
77
104
|
);
|
|
@@ -81,7 +108,19 @@ export function UploadZone({
|
|
|
81
108
|
async function uploadFiles(files: FileList | null): Promise<void> {
|
|
82
109
|
if (files === null || files.length === 0) return;
|
|
83
110
|
const picked = multiple ? Array.from(files) : files[0] !== undefined ? [files[0]] : [];
|
|
84
|
-
|
|
111
|
+
const [accepted, rejected] = partitionByAccept(picked, accept);
|
|
112
|
+
for (const file of rejected) {
|
|
113
|
+
setRows((prev) => [
|
|
114
|
+
...prev,
|
|
115
|
+
{
|
|
116
|
+
id: crypto.randomUUID(),
|
|
117
|
+
fileName: file.name,
|
|
118
|
+
status: "error",
|
|
119
|
+
error: t("kumiko.widget.upload.rejected-type"),
|
|
120
|
+
},
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
await Promise.all(accepted.map((file) => uploadOne(file)));
|
|
85
124
|
// Reset so re-picking the SAME file still fires change — the browser
|
|
86
125
|
// suppresses the event otherwise.
|
|
87
126
|
if (inputRef.current) inputRef.current.value = "";
|