@cosmicdrift/kumiko-renderer-web 0.193.1 → 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.
Files changed (49) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/create-app.test.tsx +10 -2
  3. package/src/__tests__/entity-edit-redirect.test.tsx +143 -0
  4. package/src/__tests__/field-hide-label.test.tsx +166 -1
  5. package/src/__tests__/kumiko-screen.test.tsx +59 -0
  6. package/src/__tests__/nav-tree.test.tsx +43 -11
  7. package/src/__tests__/primitives.test.tsx +33 -2
  8. package/src/__tests__/render-edit.test.tsx +397 -9
  9. package/src/__tests__/test-utils.tsx +20 -0
  10. package/src/__tests__/wizard-form-validation.test.tsx +129 -1
  11. package/src/app/__tests__/merge-content-editors.test.ts +2 -2
  12. package/src/app/__tests__/plain-content-editor.test.tsx +28 -3
  13. package/src/app/__tests__/rich-content-editor.test.tsx +6 -2
  14. package/src/app/__tests__/tiptap-editor.test.tsx +102 -7
  15. package/src/app/client-plugin.tsx +3 -3
  16. package/src/app/create-app.tsx +40 -49
  17. package/src/app/plain-content-editor.tsx +9 -8
  18. package/src/app/tiptap-editor.tsx +40 -12
  19. package/src/index.ts +1 -0
  20. package/src/layout/__tests__/workspace-switcher.test.tsx +62 -2
  21. package/src/layout/sidebar-panel.tsx +1 -4
  22. package/src/layout/workspace-switcher.tsx +26 -13
  23. package/src/lib/__tests__/resize-image.test.ts +42 -3
  24. package/src/lib/clamp.ts +3 -0
  25. package/src/lib/resize-image.ts +6 -1
  26. package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
  27. package/src/primitives/__tests__/date-field.test.tsx +37 -0
  28. package/src/primitives/__tests__/date-parse.test.ts +14 -2
  29. package/src/primitives/__tests__/embedded-list-input.test.tsx +77 -2
  30. package/src/primitives/__tests__/file-upload.test.tsx +24 -0
  31. package/src/primitives/date-field.tsx +8 -0
  32. package/src/primitives/date-parse.ts +6 -1
  33. package/src/primitives/dropdown-menu.tsx +23 -0
  34. package/src/primitives/embedded-list-input.tsx +23 -13
  35. package/src/primitives/file-upload.tsx +3 -0
  36. package/src/primitives/index.tsx +114 -120
  37. package/src/primitives/money-input.tsx +4 -7
  38. package/src/primitives/use-narrow-viewport.ts +28 -0
  39. package/src/ui/sheet.tsx +3 -1
  40. package/src/widgets/__tests__/drawer.test.tsx +315 -0
  41. package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
  42. package/src/widgets/__tests__/upload-zone.test.tsx +117 -2
  43. package/src/widgets/__tests__/widgets.test.tsx +25 -0
  44. package/src/widgets/ai-text-field.tsx +3 -1
  45. package/src/widgets/drawer.tsx +62 -31
  46. package/src/widgets/infinity-list.tsx +18 -5
  47. package/src/widgets/progress-bar.tsx +3 -1
  48. package/src/widgets/step-bar.tsx +1 -1
  49. package/src/widgets/upload-zone.tsx +45 -3
@@ -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 resizable?: boolean;
24
- readonly defaultWidthPx?: number;
25
- readonly minWidthPx?: number;
26
- readonly maxWidthPx?: number;
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 = 800;
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
- // Floating panel with a clearly visible margin on every edge, rounded on
34
- // all four corners — replaces the sheet primitive's flush-to-viewport-edge
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-[420px] max-w-[85vw] sm:max-w-[420px] 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";
43
58
  case "top":
44
- 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";
45
60
  case "bottom":
46
- 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";
47
62
  default:
48
- return "inset-y-8 right-8 h-auto w-[420px] max-w-[85vw] sm:max-w-[420px] 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";
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,26 +76,39 @@ export function Drawer({
65
76
  footer,
66
77
  children,
67
78
  testId,
68
- resizable = false,
69
- defaultWidthPx = DEFAULT_WIDTH_PX,
70
- minWidthPx = MIN_WIDTH_PX,
71
- maxWidthPx = MAX_WIDTH_PX,
79
+ resize,
80
+ backdrop,
72
81
  }: DrawerProps): ReactNode {
73
- const canResize = resizable && (side === "left" || side === "right");
74
- const [width, setWidth] = useState(defaultWidthPx);
75
- const [maximized, setMaximized] = useState(false);
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
+ event.preventDefault();
85
105
  event.currentTarget.setPointerCapture(event.pointerId);
86
106
  dragRef.current = { startX: event.clientX, startWidth: effectiveWidthPx };
87
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");
88
112
  };
89
113
  const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
90
114
  if (dragRef.current === null) return;
@@ -95,6 +119,7 @@ export function Drawer({
95
119
  const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
96
120
  event.currentTarget.releasePointerCapture(event.pointerId);
97
121
  dragRef.current = null;
122
+ document.body.style.removeProperty("user-select");
98
123
  };
99
124
  const onHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
100
125
  const step = event.shiftKey ? 40 : 16;
@@ -104,7 +129,13 @@ export function Drawer({
104
129
  event.preventDefault();
105
130
  setMaximized(false);
106
131
  const delta = event.key === grow ? step : -step;
107
- setWidth((current) => clamp(current + delta, minWidthPx, effectiveMaxWidthPx()));
132
+ // Seed from the currently visible width, not the stale `width` state —
133
+ // while maximized, `width` still holds the pre-maximize value, so a key
134
+ // press would otherwise jump the drawer back to that old size instead
135
+ // of resizing relative to what's on screen.
136
+ setWidth((current) =>
137
+ clamp((maximized ? effectiveWidthPx : current) + delta, minWidthPx, effectiveMaxWidthPx()),
138
+ );
108
139
  };
109
140
 
110
141
  return (
@@ -112,7 +143,7 @@ export function Drawer({
112
143
  <SheetContent
113
144
  side={side}
114
145
  data-testid={testId}
115
- overlayClassName="bg-black/20 backdrop-blur-[2px]"
146
+ overlayStyle={overlayStyle}
116
147
  className={floatingSideClass(side)}
117
148
  style={canResize ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
118
149
  >
@@ -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: "ready",
111
- rows: cursor === null || prev.kind !== "ready" ? nextRows : [...prev.rows, ...nextRows],
112
- cursor: nextCursorRef.current(res.data),
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
- const pct = Math.max(0, Math.min(1, value));
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
@@ -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.stepBar.done")}</span>
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>
@@ -45,6 +45,32 @@ function toAcceptAttr(accept?: readonly string[]): string | undefined {
45
45
  return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
46
46
  }
47
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
+
48
74
  /** Drop zone + multi-file picker with a per-file status row (uploading/done/
49
75
  * error). For screens that accept several files and upload them
50
76
  * independently — unlike `FileField` (single file, FileRef value inside a
@@ -64,15 +90,19 @@ export function UploadZone({
64
90
  const inputRef = useRef<HTMLInputElement>(null);
65
91
  const [rows, setRows] = useState<readonly UploadRow[]>([]);
66
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);
67
97
 
68
98
  async function uploadOne(file: File): Promise<void> {
69
- const rowId = crypto.randomUUID();
99
+ const rowId = String(nextRowId.current++);
70
100
  setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
71
101
  try {
72
102
  await onUpload(await resizeImageBeforeUpload(file));
73
103
  setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "done" } : row)));
74
104
  } catch (cause) {
75
- const message = cause instanceof Error ? cause.message : "upload_failed";
105
+ const message = cause instanceof Error ? cause.message : t("kumiko.widget.upload.error");
76
106
  setRows((prev) =>
77
107
  prev.map((row) => (row.id === rowId ? { ...row, status: "error", error: message } : row)),
78
108
  );
@@ -82,7 +112,19 @@ export function UploadZone({
82
112
  async function uploadFiles(files: FileList | null): Promise<void> {
83
113
  if (files === null || files.length === 0) return;
84
114
  const picked = multiple ? Array.from(files) : files[0] !== undefined ? [files[0]] : [];
85
- await Promise.all(picked.map((file) => uploadOne(file)));
115
+ const [accepted, rejected] = partitionByAccept(picked, accept);
116
+ for (const file of rejected) {
117
+ setRows((prev) => [
118
+ ...prev,
119
+ {
120
+ id: String(nextRowId.current++),
121
+ fileName: file.name,
122
+ status: "error",
123
+ error: t("kumiko.widget.upload.rejected-type"),
124
+ },
125
+ ]);
126
+ }
127
+ await Promise.all(accepted.map((file) => uploadOne(file)));
86
128
  // Reset so re-picking the SAME file still fires change — the browser
87
129
  // suppresses the event otherwise.
88
130
  if (inputRef.current) inputRef.current.value = "";