@cosmicdrift/kumiko-renderer-web 0.220.1 → 0.222.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.
@@ -4,14 +4,16 @@ import { type ReactNode, useRef, useState } from "react";
4
4
  import { clamp } from "../lib/clamp";
5
5
  import { cn } from "../lib/cn";
6
6
  import { useIsNarrowViewport } from "../primitives/use-narrow-viewport";
7
- import {
8
- Sheet,
9
- SheetContent,
10
- SheetDescription,
11
- SheetFooter,
12
- SheetHeader,
13
- SheetTitle,
14
- } from "../ui/sheet";
7
+ import { Sheet, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "../ui/sheet";
8
+ import { DrawerSheetContent } from "./sheet-parts";
9
+
10
+ // Mirrors primitives/index.tsx's cardFooter + cardFooterBorder (row layout,
11
+ // end-justified actions, top border, panel surface instead of the card
12
+ // footer's bg-muted/30) — those two consts aren't exported, so the
13
+ // equivalent classes are inlined here rather than growing the vendored
14
+ // SheetFooter's own default className with panel-specific styling.
15
+ const DRAWER_FOOTER_CLASS =
16
+ "flex-row items-center justify-end border-t bg-background px-[var(--card-padding)] py-4";
15
17
 
16
18
  export type DrawerProps = {
17
19
  readonly open: boolean;
@@ -137,8 +139,10 @@ export function Drawer({
137
139
  const signedDelta = side === "right" ? -deltaX : deltaX;
138
140
  setWidth(clamp(dragRef.current.startWidth + signedDelta, minWidthPx, effectiveMaxWidthPx()));
139
141
  };
140
- const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
141
- event.currentTarget.releasePointerCapture(event.pointerId);
142
+ const endHandlePointerDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
143
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
144
+ event.currentTarget.releasePointerCapture(event.pointerId);
145
+ }
142
146
  dragRef.current = null;
143
147
  document.body.style.removeProperty("user-select");
144
148
  };
@@ -161,7 +165,7 @@ export function Drawer({
161
165
 
162
166
  return (
163
167
  <Sheet open={open} onOpenChange={onOpenChange}>
164
- <SheetContent
168
+ <DrawerSheetContent
165
169
  side={side}
166
170
  data-testid={testId}
167
171
  overlayStyle={overlayStyle}
@@ -193,7 +197,9 @@ export function Drawer({
193
197
  </SheetHeader>
194
198
  )}
195
199
  <div className="flex-1 overflow-y-auto px-4">{children}</div>
196
- {footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
200
+ {footer !== undefined && (
201
+ <SheetFooter className={DRAWER_FOOTER_CLASS}>{footer}</SheetFooter>
202
+ )}
197
203
  {canResize && !narrow && (
198
204
  // biome-ignore lint/a11y/useSemanticElements: <hr> can't carry pointer/keyboard drag interaction or a live width value — a draggable separator needs a div with the ARIA role.
199
205
  <div
@@ -206,7 +212,9 @@ export function Drawer({
206
212
  tabIndex={0}
207
213
  onPointerDown={onHandlePointerDown}
208
214
  onPointerMove={onHandlePointerMove}
209
- onPointerUp={onHandlePointerUp}
215
+ onPointerUp={endHandlePointerDrag}
216
+ onPointerCancel={endHandlePointerDrag}
217
+ onLostPointerCapture={endHandlePointerDrag}
210
218
  onKeyDown={onHandleKeyDown}
211
219
  className={cn(
212
220
  "absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border",
@@ -214,7 +222,7 @@ export function Drawer({
214
222
  )}
215
223
  />
216
224
  )}
217
- </SheetContent>
225
+ </DrawerSheetContent>
218
226
  </Sheet>
219
227
  );
220
228
  }
@@ -28,7 +28,7 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
28
28
  * (`<feature>:query:<entity>:<verb>`) and refetch the first page on
29
29
  * any create/update/delete/restore event, merging it into the
30
30
  * already-accumulated rows instead of collapsing them — see
31
- * `useQuery`'s `live` option for the same convention. Default true. */
31
+ * `useQuery`'s `live` option for the same convention. Default false. */
32
32
  readonly live?: boolean;
33
33
  };
34
34
 
@@ -53,7 +53,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
53
53
  emptyState,
54
54
  className,
55
55
  testId,
56
- live = true,
56
+ live = false,
57
57
  }: InfinityListProps<TData, TRow>): ReactNode {
58
58
  const dispatcher = useDispatcher();
59
59
  const t = useTranslation();
@@ -183,15 +183,51 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
183
183
  });
184
184
  }, [dispatcher, query, pageSize, payloadKey]);
185
185
 
186
+ // Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
187
+ // into one query per event (fw#1829). A refresh already running schedules
188
+ // exactly one trailing pass after it settles.
189
+ const liveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190
+ const liveInFlightRef = useRef(false);
191
+ const liveTrailingRef = useRef(false);
192
+
186
193
  useEffect(() => {
187
194
  // skip: live mode off, no SSE subscription needed
188
195
  if (!live) return;
189
196
  const entity = entityFromQueryType(query);
190
197
  // skip: query type has no mapped entity, nothing to subscribe to
191
198
  if (entity === undefined) return;
192
- return subscribeLive(entity, () => {
193
- void refreshFirstPage();
194
- });
199
+
200
+ const runRefresh = (): void => {
201
+ liveInFlightRef.current = true;
202
+ void refreshFirstPage().finally(() => {
203
+ liveInFlightRef.current = false;
204
+ if (liveTrailingRef.current) {
205
+ liveTrailingRef.current = false;
206
+ runRefresh();
207
+ }
208
+ });
209
+ };
210
+
211
+ const scheduleRefresh = (): void => {
212
+ if (liveTimerRef.current !== null) clearTimeout(liveTimerRef.current);
213
+ liveTimerRef.current = setTimeout(() => {
214
+ liveTimerRef.current = null;
215
+ if (liveInFlightRef.current) {
216
+ liveTrailingRef.current = true;
217
+ return;
218
+ }
219
+ runRefresh();
220
+ }, 250);
221
+ };
222
+
223
+ const unsubscribe = subscribeLive(entity, scheduleRefresh);
224
+ return () => {
225
+ unsubscribe();
226
+ if (liveTimerRef.current !== null) {
227
+ clearTimeout(liveTimerRef.current);
228
+ liveTimerRef.current = null;
229
+ }
230
+ };
195
231
  }, [live, query, refreshFirstPage, subscribeLive]);
196
232
 
197
233
  useEffect(() => {
@@ -0,0 +1,62 @@
1
+ import { XIcon } from "lucide-react";
2
+ import { Dialog as SheetPrimitive } from "radix-ui";
3
+ import type { ComponentProps, CSSProperties, ReactNode } from "react";
4
+ import { cn } from "../lib/cn";
5
+
6
+ // Drawer needs a per-instance overlay dim/blur (its `backdrop` prop), but
7
+ // ../ui/sheet.tsx is vendored shadcn (regenerated via scripts/sync-shadcn.ts,
8
+ // never hand-edited — #1965) and its SheetContent hard-codes a plain
9
+ // `<SheetOverlay />` with no way to reach the overlay's inline style. Rather
10
+ // than growing the vendored primitive an ad-hoc prop that a resync would
11
+ // silently drop, this composes the same Portal + Overlay + Content tree
12
+ // directly from the underlying radix-ui Dialog primitive (mirrors
13
+ // SheetContent's className logic 1:1 so Drawer's other side-variant
14
+ // behavior — slide animation, borders — stays identical).
15
+ export type DrawerSheetContentProps = ComponentProps<typeof SheetPrimitive.Content> & {
16
+ readonly side?: "top" | "right" | "bottom" | "left";
17
+ readonly showCloseButton?: boolean;
18
+ readonly overlayStyle?: CSSProperties;
19
+ };
20
+
21
+ export function DrawerSheetContent({
22
+ className,
23
+ overlayStyle,
24
+ children,
25
+ side = "right",
26
+ showCloseButton = true,
27
+ ...props
28
+ }: DrawerSheetContentProps): ReactNode {
29
+ return (
30
+ <SheetPrimitive.Portal data-slot="sheet-portal">
31
+ <SheetPrimitive.Overlay
32
+ data-slot="sheet-overlay"
33
+ style={overlayStyle}
34
+ className="fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
35
+ />
36
+ <SheetPrimitive.Content
37
+ data-slot="sheet-content"
38
+ className={cn(
39
+ "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
40
+ side === "right" &&
41
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
42
+ side === "left" &&
43
+ "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
44
+ side === "top" &&
45
+ "inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
46
+ side === "bottom" &&
47
+ "inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
48
+ className,
49
+ )}
50
+ {...props}
51
+ >
52
+ {children}
53
+ {showCloseButton && (
54
+ <SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
55
+ <XIcon className="size-4" />
56
+ <span className="sr-only">Close</span>
57
+ </SheetPrimitive.Close>
58
+ )}
59
+ </SheetPrimitive.Content>
60
+ </SheetPrimitive.Portal>
61
+ );
62
+ }
@@ -92,9 +92,10 @@ export function UploadZone({
92
92
 
93
93
  async function uploadOne(file: File): Promise<void> {
94
94
  const rowId = String(nextRowId.current++);
95
- setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
95
+ const uploadFile = await resizeImageBeforeUpload(file);
96
+ setRows((prev) => [...prev, { id: rowId, fileName: uploadFile.name, status: "uploading" }]);
96
97
  try {
97
- await onUpload(await resizeImageBeforeUpload(file));
98
+ await onUpload(uploadFile);
98
99
  setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "done" } : row)));
99
100
  } catch (cause) {
100
101
  const message = cause instanceof Error ? cause.message : t("kumiko.widget.upload.error");
@@ -107,6 +108,8 @@ export function UploadZone({
107
108
  async function uploadFiles(files: FileList | null): Promise<void> {
108
109
  if (files === null || files.length === 0) return;
109
110
  const picked = multiple ? Array.from(files) : files[0] !== undefined ? [files[0]] : [];
111
+ // Reset before awaits so re-picking the same file during a slow upload still fires change.
112
+ if (inputRef.current) inputRef.current.value = "";
110
113
  const [accepted, rejected] = partitionByAccept(picked, accept);
111
114
  for (const file of rejected) {
112
115
  setRows((prev) => [
@@ -120,9 +123,6 @@ export function UploadZone({
120
123
  ]);
121
124
  }
122
125
  await Promise.all(accepted.map((file) => uploadOne(file)));
123
- // Reset so re-picking the SAME file still fires change — the browser
124
- // suppresses the event otherwise.
125
- if (inputRef.current) inputRef.current.value = "";
126
126
  }
127
127
 
128
128
  function handleDrop(e: DragEvent<HTMLLabelElement>): void {