@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.
@@ -0,0 +1,28 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ // Matches ui/use-mobile.ts's MOBILE_BREAKPOINT. Kept as a separate constant
4
+ // because that file is vendored shadcn (regenerated via scripts/sync-shadcn.ts)
5
+ // and cannot be imported from without risking a future overwrite.
6
+ const MOBILE_BREAKPOINT = 768;
7
+
8
+ function subscribe(callback: () => void): () => void {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10
+ mql.addEventListener("change", callback);
11
+ return () => mql.removeEventListener("change", callback);
12
+ }
13
+
14
+ function getSnapshot(): boolean {
15
+ return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
16
+ }
17
+
18
+ function getServerSnapshot(): boolean {
19
+ return false;
20
+ }
21
+
22
+ // Unlike the vendored `useIsMobile` (ui/use-mobile.ts), which only sets its
23
+ // result in a `useEffect` and therefore always reports `false` on the first
24
+ // render regardless of actual viewport, this reads the real value up front
25
+ // via `useSyncExternalStore` — no wrong-then-corrected first render.
26
+ export function useIsNarrowViewport(): boolean {
27
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
28
+ }
@@ -299,6 +299,28 @@ describe("Drawer", () => {
299
299
  expect(handle.getAttribute("aria-valuenow")).toBe("484");
300
300
  });
301
301
 
302
+ test("PointerDown on the handle locks text selection, PointerUp restores it (fw#1965)", () => {
303
+ render(
304
+ <Drawer
305
+ open={true}
306
+ onOpenChange={() => {}}
307
+ side="right"
308
+ testId="drawer"
309
+ resize={{ defaultWidthPx: 400, minWidthPx: 300, maxWidthPx: 500 }}
310
+ >
311
+ <div>Body</div>
312
+ </Drawer>,
313
+ );
314
+ const handle = screen.getByRole("separator");
315
+ expect(document.body.style.userSelect).toBe("");
316
+
317
+ fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100 });
318
+ expect(document.body.style.userSelect).toBe("none");
319
+
320
+ fireEvent.pointerUp(handle, { pointerId: 1, clientX: 100 });
321
+ expect(document.body.style.userSelect).toBe("");
322
+ });
323
+
302
324
  test("side='top' with resize set: no resize handle, no maximize button (vertical drawers can't resize)", () => {
303
325
  render(
304
326
  <Drawer
@@ -110,6 +110,30 @@ describe("UploadZone", () => {
110
110
  expect(screen.queryByText("upload_failed")).toBeNull();
111
111
  });
112
112
 
113
+ test("uploads multiple files with distinct, stable row keys even without crypto.randomUUID (insecure-context LAN preview)", async () => {
114
+ // `crypto.randomUUID` only exists in a secure context — an HTTP-over-LAN
115
+ // preview leaves it undefined, so calling it throws TypeError. Row ids
116
+ // must not depend on it.
117
+ const originalRandomUUID = crypto.randomUUID;
118
+ (crypto as unknown as { randomUUID?: () => string }).randomUUID = undefined;
119
+ try {
120
+ const onUpload = mock(async () => {});
121
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
122
+ const a = new File(["a"], "a.pdf");
123
+ const b = new File(["b"], "b.pdf");
124
+ pick(screen.getByTestId("zone-input"), [a, b]);
125
+
126
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(2));
127
+ expect(screen.getByText("a.pdf")).toBeTruthy();
128
+ expect(screen.getByText("b.pdf")).toBeTruthy();
129
+ // Two distinct rows in the DOM (not collapsed onto one shared/undefined
130
+ // React key) proves the ids stayed unique without crypto.randomUUID.
131
+ expect(screen.getAllByTestId("zone-row")).toHaveLength(2);
132
+ } finally {
133
+ crypto.randomUUID = originalRandomUUID;
134
+ }
135
+ });
136
+
113
137
  test("disabled unterdrückt den Drop", () => {
114
138
  const onUpload = mock(async () => {});
115
139
  render(<UploadZone title="Datei hochladen" onUpload={onUpload} disabled testId="zone" />);
@@ -164,7 +188,9 @@ describe("UploadZone", () => {
164
188
  try {
165
189
  const onUpload = mock(async (_uploaded: File) => {});
166
190
  render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
167
- const file = new File(["x"], "photo.jpg", { type: "image/jpeg" });
191
+ // Content sized well above the fake resize's 13 bytes — resizeImageBeforeUpload
192
+ // (framework#1979) only takes the re-encode when it's actually smaller.
193
+ const file = new File(["x".repeat(2000)], "photo.jpg", { type: "image/jpeg" });
168
194
  pick(screen.getByTestId("zone-input"), [file]);
169
195
 
170
196
  await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
@@ -1,3 +1,4 @@
1
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
1
2
  import { Maximize2Icon, Minimize2Icon } from "lucide-react";
2
3
  import { type ReactNode, useRef, useState } from "react";
3
4
  import { clamp } from "../lib/clamp";
@@ -54,13 +55,13 @@ function defaultWidthFromViewport(): number {
54
55
  function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
55
56
  switch (side) {
56
57
  case "left":
57
- return "inset-y-8 left-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl";
58
+ return "inset-y-8 left-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
58
59
  case "top":
59
- return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
60
+ return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
60
61
  case "bottom":
61
- return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
62
+ return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
62
63
  default:
63
- return "inset-y-8 right-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl";
64
+ return "inset-y-8 right-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
64
65
  }
65
66
  }
66
67
 
@@ -79,6 +80,7 @@ export function Drawer({
79
80
  resize,
80
81
  backdrop,
81
82
  }: DrawerProps): ReactNode {
83
+ const t = useTranslation();
82
84
  const canResize = resize !== undefined && (side === "left" || side === "right");
83
85
  const minWidthPx = resize?.minWidthPx ?? MIN_WIDTH_PX;
84
86
  const maxWidthPx = resize?.maxWidthPx ?? MAX_WIDTH_PX;
@@ -101,9 +103,14 @@ export function Drawer({
101
103
  const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
102
104
 
103
105
  const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
106
+ event.preventDefault();
104
107
  event.currentTarget.setPointerCapture(event.pointerId);
105
108
  dragRef.current = { startX: event.clientX, startWidth: effectiveWidthPx };
106
109
  setMaximized(false);
110
+ // Handle already carries `cursor-col-resize`, so only the text-selection
111
+ // lock is needed here — without it, a fast drag over the drawer content
112
+ // selects the text underneath instead of just resizing.
113
+ document.body.style.setProperty("user-select", "none");
107
114
  };
108
115
  const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
109
116
  if (dragRef.current === null) return;
@@ -114,6 +121,7 @@ export function Drawer({
114
121
  const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
115
122
  event.currentTarget.releasePointerCapture(event.pointerId);
116
123
  dragRef.current = null;
124
+ document.body.style.removeProperty("user-select");
117
125
  };
118
126
  const onHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
119
127
  const step = event.shiftKey ? 40 : 16;
@@ -146,7 +154,9 @@ export function Drawer({
146
154
  type="button"
147
155
  onClick={() => setMaximized((m) => !m)}
148
156
  aria-pressed={maximized}
149
- aria-label={maximized ? "Restore drawer width" : "Maximize drawer width"}
157
+ aria-label={
158
+ maximized ? t("kumiko.widget.drawer.restore") : t("kumiko.widget.drawer.maximize")
159
+ }
150
160
  className="absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
151
161
  >
152
162
  {maximized ? (
@@ -169,7 +179,7 @@ export function Drawer({
169
179
  <div
170
180
  role="separator"
171
181
  aria-orientation="vertical"
172
- aria-label="Resize drawer"
182
+ aria-label={t("kumiko.widget.drawer.resize")}
173
183
  aria-valuenow={effectiveWidthPx}
174
184
  aria-valuemin={minWidthPx}
175
185
  aria-valuemax={effectiveMaxWidthPx()}
@@ -20,6 +20,12 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
20
20
  /** Must derive from row content (e.g. `row.id`), not from `index` — a
21
21
  * live refresh reorders rows (new/changed rows move to the front). */
22
22
  readonly rowId: (row: TRow, index: number) => string;
23
+ /** Pass this when `rowId` depends on list position — the live-merge path
24
+ * needs a position-independent identity. Without it, an index-based
25
+ * `rowId` (legitimate when live-merge isn't used) can collide between
26
+ * the freshly-fetched first page's local indices and the accumulated
27
+ * list's indices, silently dropping an unrelated mid-list row on merge. */
28
+ readonly rowKey?: (row: TRow) => string;
23
29
  readonly renderRow: (row: TRow) => ReactNode;
24
30
  readonly emptyState?: ReactNode;
25
31
  readonly className?: string;
@@ -49,6 +55,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
49
55
  rows,
50
56
  nextCursor,
51
57
  rowId,
58
+ rowKey,
52
59
  renderRow,
53
60
  emptyState,
54
61
  className,
@@ -73,6 +80,16 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
73
80
  nextCursorRef.current = nextCursor;
74
81
  const rowIdRef = useRef(rowId);
75
82
  rowIdRef.current = rowId;
83
+ const rowKeyRef = useRef(rowKey);
84
+ rowKeyRef.current = rowKey;
85
+ // Position-independent when rowKey is supplied, otherwise falls back to
86
+ // the (possibly position-dependent) rowId — same identity function used
87
+ // for both sides of the live-merge diff below so a position-based rowId
88
+ // can no longer collide across the fresh page's local indices and the
89
+ // accumulated list's indices (fw#1829).
90
+ const identifyRef = useRef((row: TRow, index: number): string =>
91
+ rowKeyRef.current !== undefined ? rowKeyRef.current(row) : rowIdRef.current(row, index),
92
+ );
76
93
 
77
94
  // Discards a response whose request was superseded by a newer one before
78
95
  // it resolved (e.g. two searches fired in quick succession) — without
@@ -102,8 +119,11 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
102
119
  }
103
120
  const nextRows = rowsRef.current(res.data);
104
121
  if (cursor === null) {
122
+ // Same identity as refreshFirstPage's freshIds/staleRows diff below
123
+ // — this ref is what that diff's previousFirstPageIds reads, so it
124
+ // must agree on rowKey vs rowId or the two id spaces never overlap.
105
125
  firstPageIdsRef.current = new Set(
106
- nextRows.map((row, index) => rowIdRef.current(row, index)),
126
+ nextRows.map((row, index) => identifyRef.current(row, index)),
107
127
  );
108
128
  }
109
129
  setState((prev) => {
@@ -154,7 +174,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
154
174
  // skip: background live refresh failed, keep showing the current rows
155
175
  if (!res.isSuccess) return;
156
176
  const freshRows = rowsRef.current(res.data);
157
- const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
177
+ const freshIds = new Set(freshRows.map((row, index) => identifyRef.current(row, index)));
158
178
  // A row absent from freshIds is stale for one of two reasons: it was
159
179
  // dropped from page 1 (deleted, or filtered out elsewhere) and must be
160
180
  // pruned, or it belongs to an already-accumulated later page and must
@@ -163,10 +183,18 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
163
183
  const previousFirstPageIds = firstPageIdsRef.current;
164
184
  firstPageIdsRef.current = freshIds;
165
185
  setState((prev) => {
166
- // skip: not showing an accumulated list yet, nothing to merge into
167
- if (prev.kind !== "ready") return prev;
186
+ // skip: the initial load() is still in flight and owns the eventual
187
+ // state a concurrent refresh landing here has nothing accumulated
188
+ // to merge into yet.
189
+ if (prev.kind === "loading") return prev;
190
+ if (prev.kind !== "ready") {
191
+ // prev.kind === "error": this refresh succeeded — promote straight
192
+ // to "ready" instead of leaving the list stuck on the earlier
193
+ // failure forever (a live event should be able to recover it).
194
+ return { kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) };
195
+ }
168
196
  const staleRows = prev.rows.filter((row, index) => {
169
- const id = rowIdRef.current(row, index);
197
+ const id = identifyRef.current(row, index);
170
198
  return !freshIds.has(id) && !previousFirstPageIds.has(id);
171
199
  });
172
200
  return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
@@ -1,6 +1,7 @@
1
1
  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
+ import { toAcceptAttr } from "../lib/accept-attr";
4
5
  import { cn } from "../lib/cn";
5
6
  import { resizeImageBeforeUpload } from "../lib/resize-image";
6
7
 
@@ -39,12 +40,6 @@ export type UploadZoneProps = {
39
40
  readonly testId?: string;
40
41
  };
41
42
 
42
- // "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
43
- function toAcceptAttr(accept?: readonly string[]): string | undefined {
44
- if (accept === undefined || accept.length === 0) return undefined;
45
- return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
46
- }
47
-
48
43
  // `accept` on the native <input> only filters the file-picker dialog — a
49
44
  // drag&drop drop is never routed through it, so any file type lands in
50
45
  // `onUpload` regardless of what `accept` promises. Same matching rules as
@@ -90,9 +85,13 @@ export function UploadZone({
90
85
  const inputRef = useRef<HTMLInputElement>(null);
91
86
  const [rows, setRows] = useState<readonly UploadRow[]>([]);
92
87
  const [dragOver, setDragOver] = useState(false);
88
+ // `crypto.randomUUID` only exists in a secure context — a plain-HTTP LAN
89
+ // preview leaves it undefined. These ids are React keys/row ids, not
90
+ // globally unique identifiers, so a per-instance counter is enough.
91
+ const nextRowId = useRef(0);
93
92
 
94
93
  async function uploadOne(file: File): Promise<void> {
95
- const rowId = crypto.randomUUID();
94
+ const rowId = String(nextRowId.current++);
96
95
  setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
97
96
  try {
98
97
  await onUpload(await resizeImageBeforeUpload(file));
@@ -113,7 +112,7 @@ export function UploadZone({
113
112
  setRows((prev) => [
114
113
  ...prev,
115
114
  {
116
- id: crypto.randomUUID(),
115
+ id: String(nextRowId.current++),
117
116
  fileName: file.name,
118
117
  status: "error",
119
118
  error: t("kumiko.widget.upload.rejected-type"),