@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.
- package/package.json +5 -5
- package/src/__tests__/form-action-bar.test.tsx +1 -1
- package/src/__tests__/kumiko-screen.test.tsx +60 -5
- package/src/__tests__/nav-tree.test.tsx +6 -14
- package/src/__tests__/primitives.test.tsx +83 -3
- package/src/__tests__/render-edit.test.tsx +8 -14
- package/src/__tests__/render-list-unit-locale.test.tsx +125 -0
- package/src/app/__tests__/document-lang-sync.test.tsx +43 -0
- package/src/app/client-plugin.tsx +2 -1
- package/src/app/document-lang-sync.tsx +14 -8
- package/src/app/plain-content-editor.tsx +6 -2
- package/src/lib/__tests__/download.test.ts +3 -3
- package/src/lib/__tests__/download.test.tsx +30 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/date-parse.test.ts +6 -3
- package/src/primitives/__tests__/embedded-list-input.test.tsx +21 -0
- package/src/primitives/date-parse.ts +2 -0
- package/src/primitives/index.tsx +55 -33
- package/src/ui/sheet.tsx +2 -15
- package/src/widgets/__tests__/feed-list.test.tsx +3 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +17 -9
- package/src/widgets/__tests__/widgets.test.tsx +27 -0
- package/src/widgets/charts.tsx +46 -27
- package/src/widgets/drawer.tsx +22 -14
- package/src/widgets/infinity-list.tsx +41 -5
- package/src/widgets/sheet-parts.tsx +62 -0
- package/src/widgets/upload-zone.tsx +5 -5
package/src/widgets/drawer.tsx
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
141
|
-
event.currentTarget.
|
|
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
|
-
<
|
|
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 &&
|
|
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={
|
|
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
|
-
</
|
|
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
|
|
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 =
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
95
|
+
const uploadFile = await resizeImageBeforeUpload(file);
|
|
96
|
+
setRows((prev) => [...prev, { id: rowId, fileName: uploadFile.name, status: "uploading" }]);
|
|
96
97
|
try {
|
|
97
|
-
await onUpload(
|
|
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 {
|