@cosmicdrift/kumiko-renderer 0.393.0 → 0.395.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/dist/app/content-preview.js +1 -9
- package/dist/app/entity-list-screen.js +4 -2
- package/dist/app/kumiko-screen.js +4 -36
- package/dist/app/projection-list-body.js +1 -1
- package/dist/app/qn.d.ts +1 -6
- package/dist/app/qn.js +1 -12
- package/dist/app/screen-visibility-gate.d.ts +19 -0
- package/dist/app/screen-visibility-gate.js +60 -0
- package/dist/app/secrets-edit-body.js +5 -4
- package/dist/app/use-embedded-screen.d.ts +2 -0
- package/dist/app/use-embedded-screen.js +1 -1
- package/dist/app/use-gated-toolbar-actions.d.ts +7 -0
- package/dist/app/use-gated-toolbar-actions.js +51 -0
- package/dist/app/use-projection-list-actions.d.ts +1 -0
- package/dist/app/use-projection-list-actions.js +11 -2
- package/dist/app/visible-when.d.ts +5 -1
- package/dist/app/visible-when.js +11 -0
- package/dist/components/form-screen-layout.d.ts +2 -1
- package/dist/components/form-screen-layout.js +2 -2
- package/dist/components/render-edit-banners.js +1 -1
- package/dist/components/render-edit-layout-flags.d.ts +2 -1
- package/dist/components/render-edit-layout-flags.js +3 -2
- package/dist/components/render-edit-logic.d.ts +2 -1
- package/dist/components/render-edit-logic.js +14 -0
- package/dist/data-table-view-types.d.ts +4 -2
- package/dist/hooks/list-column-layout.d.ts +3 -1
- package/dist/hooks/list-column-layout.js +20 -7
- package/dist/hooks/use-list-view-state.d.ts +1 -1
- package/dist/hooks/use-list-view-state.js +3 -2
- package/dist/i18n-defaults.js +2 -0
- package/dist/web/app/client-plugin.d.ts +1 -1
- package/dist/web/app/dashboard-body.js +1 -1
- package/dist/web/app/dashboard-screen-tiles.d.ts +2 -1
- package/dist/web/app/dashboard-screen-tiles.js +9 -7
- package/dist/web/app/dashboard-shared.d.ts +2 -0
- package/dist/web/app/use-dashboard-screen-state.js +6 -1
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +1 -1
- package/dist/web/layout/shell-breadcrumb.d.ts +4 -1
- package/dist/web/layout/shell-breadcrumb.js +18 -3
- package/dist/web/layout/shell-header.js +4 -2
- package/dist/web/lib/upload-file-ref.d.ts +4 -1
- package/dist/web/lib/upload-file-ref.js +15 -3
- package/dist/web/primitives/action-menu.js +2 -2
- package/dist/web/primitives/calendar-popover.js +1 -1
- package/dist/web/primitives/combobox.js +1 -1
- package/dist/web/primitives/data-table/table-data-cell.js +27 -1
- package/dist/web/primitives/data-table/table-header-row.js +1 -1
- package/dist/web/primitives/data-table/table-view.js +2 -2
- package/dist/web/primitives/data-table/use-column-sizing.d.ts +4 -2
- package/dist/web/primitives/data-table/use-column-sizing.js +34 -11
- package/dist/web/primitives/data-table/view-menu.js +1 -1
- package/dist/web/primitives/data-table-cell.js +1 -1
- package/dist/web/primitives/dialog.js +1 -1
- package/dist/web/primitives/dropdown-menu.d.ts +1 -1
- package/dist/web/primitives/dropdown-menu.js +2 -2
- package/dist/web/primitives/file-upload.js +5 -2
- package/dist/web/primitives/files-dropzone.js +8 -2
- package/dist/web/primitives/lightbox.js +1 -1
- package/dist/web/primitives/modal-shell.js +1 -1
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/narrow-pinned-footer.js +1 -1
- package/dist/web/primitives/toast.js +2 -2
- package/dist/web/sse/live-events.js +0 -7
- package/dist/web/widgets/floating-panel-geometry.d.ts +32 -0
- package/dist/web/widgets/floating-panel-geometry.js +71 -0
- package/dist/web/widgets/floating-panel.d.ts +2 -6
- package/dist/web/widgets/floating-panel.js +11 -55
- package/dist/web/widgets/infinity-list.js +27 -14
- package/dist/web/widgets/plan-card.d.ts +2 -0
- package/dist/web/widgets/plan-card.js +5 -1
- package/package.json +6 -19
- package/src/changes.json +54 -0
- package/dist/web/app/rich-content-editor.d.ts +0 -3
- package/dist/web/app/rich-content-editor.js +0 -15
- package/dist/web/app/tiptap-editor.d.ts +0 -3
- package/dist/web/app/tiptap-editor.js +0 -120
|
@@ -6,17 +6,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
6
6
|
// file name link. A disabled input shows the value only, without controls.
|
|
7
7
|
import { ImageIcon, Loader2, Upload } from "lucide-react";
|
|
8
8
|
import { useRef, useState } from "react";
|
|
9
|
-
import { useTranslation } from "../../index.js";
|
|
9
|
+
import { useSessionEndedSignal, useTranslation } from "../../index.js";
|
|
10
10
|
import { toAcceptAttr } from "../lib/accept-attr.js";
|
|
11
11
|
import { resizeImageBeforeUpload } from "../lib/resize-image.js";
|
|
12
12
|
import { uploadErrorKey } from "../lib/upload-error-key.js";
|
|
13
|
-
import { uploadFileRef } from "../lib/upload-file-ref.js";
|
|
13
|
+
import { isSessionEndedUploadError, uploadFileRef } from "../lib/upload-file-ref.js";
|
|
14
14
|
import { Button as UiButton } from "../ui/button.js";
|
|
15
15
|
import { FileNameLink, FileThumbnail } from "./file-thumbnail.js";
|
|
16
16
|
// React's input typings have no `webkitdirectory`; spreading keeps it typed-cast-free.
|
|
17
17
|
const DIRECTORY_INPUT_ATTRS = { webkitdirectory: "", multiple: true };
|
|
18
18
|
export function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, uploadActionForm, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }) {
|
|
19
19
|
const t = useTranslation();
|
|
20
|
+
const sessionEnded = useSessionEndedSignal();
|
|
20
21
|
const inputRef = useRef(null);
|
|
21
22
|
const [uploading, setUploading] = useState(false);
|
|
22
23
|
const [error, setError] = useState(null);
|
|
@@ -42,6 +43,8 @@ export function FileUploadInput({ kind, id, value, onChange, accept, disabled, e
|
|
|
42
43
|
}));
|
|
43
44
|
}
|
|
44
45
|
catch (cause) {
|
|
46
|
+
if (isSessionEndedUploadError(cause))
|
|
47
|
+
sessionEnded?.notify();
|
|
45
48
|
setError(t(uploadErrorKey(cause)));
|
|
46
49
|
}
|
|
47
50
|
finally {
|
|
@@ -3,12 +3,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
// file, the field value is the array of FileRef ids of the files that uploaded. Failed files stay
|
|
4
4
|
// out of the value; removing a row removes its id.
|
|
5
5
|
import { useEffect, useRef } from "react";
|
|
6
|
-
import { useTranslation } from "../../index.js";
|
|
6
|
+
import { useSessionEndedSignal, useTranslation } from "../../index.js";
|
|
7
7
|
import { FileCountLimitError, uploadErrorKey } from "../lib/upload-error-key.js";
|
|
8
|
-
import { uploadFileRef } from "../lib/upload-file-ref.js";
|
|
8
|
+
import { isSessionEndedUploadError, uploadFileRef, } from "../lib/upload-file-ref.js";
|
|
9
9
|
import { UploadZone } from "../widgets/upload-zone.js";
|
|
10
10
|
export function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCount, disabled, entityType, uploadActionForm, entityId, fieldName, testId, }) {
|
|
11
11
|
const t = useTranslation();
|
|
12
|
+
const sessionEnded = useSessionEndedSignal();
|
|
12
13
|
// Uploads of one batch finish independently; building each next value from the render's
|
|
13
14
|
// `value` would drop the ids of the others. Re-synced whenever the owner changes the value.
|
|
14
15
|
const latestIds = useRef(value);
|
|
@@ -33,6 +34,11 @@ export function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCo
|
|
|
33
34
|
onChange(latestIds.current);
|
|
34
35
|
return fileId;
|
|
35
36
|
}
|
|
37
|
+
catch (cause) {
|
|
38
|
+
if (isSessionEndedUploadError(cause))
|
|
39
|
+
sessionEnded?.notify();
|
|
40
|
+
throw cause;
|
|
41
|
+
}
|
|
36
42
|
finally {
|
|
37
43
|
uploadsRunning.current -= 1;
|
|
38
44
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// Full-size image overlay — ModalShell without confirm/cancel actions.
|
|
3
|
-
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
4
3
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
4
|
+
import { Dialog as DialogPrimitive } from "radix-ui";
|
|
5
5
|
import { useTranslation } from "../../index.js";
|
|
6
6
|
import { cn } from "../lib/cn.js";
|
|
7
7
|
import { ModalShell } from "./modal-shell.js";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// Shared Radix dialog chrome for confirm Dialog + image Lightbox.
|
|
3
3
|
// Not exported from the package — only an internal building block.
|
|
4
|
-
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
4
|
import { X } from "lucide-react";
|
|
5
|
+
import { Dialog as DialogPrimitive } from "radix-ui";
|
|
6
6
|
import { cn } from "../lib/cn.js";
|
|
7
7
|
export function ModalShell({ open, onOpenChange, children, testId, contentClassName, noAriaDescription, closeLabel, showCloseButton = true, onOpenAutoFocus, onContentKeyDown, }) {
|
|
8
8
|
return (_jsx(DialogPrimitive.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogPrimitive.Portal, { children: [_jsx(DialogPrimitive.Overlay, { className: "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" }), _jsxs(DialogPrimitive.Content, { "data-testid": testId, ...(noAriaDescription && { "aria-describedby": undefined }), ...(onOpenAutoFocus !== undefined && { onOpenAutoFocus }), ...(onContentKeyDown !== undefined && { onKeyDown: onContentKeyDown }), className: cn("fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", contentClassName), children: [children, showCloseButton && closeLabel !== undefined && (_jsx(DialogPrimitive.Close, { asChild: true, children: _jsx("button", { type: "button", "aria-label": closeLabel, className: "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none", children: _jsx(X, { className: "size-4" }) }) }))] })] }) }));
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
// Bare content shell for hosting self-contained widgets (own submit/cancel
|
|
3
3
|
// buttons) in a modal overlay — same Radix chrome as DefaultDialog, no
|
|
4
4
|
// footer buttons of its own.
|
|
5
|
-
import
|
|
5
|
+
import { Dialog as DialogPrimitive } from "radix-ui";
|
|
6
6
|
import { useTranslation } from "../../index.js";
|
|
7
7
|
import { cn } from "../lib/cn.js";
|
|
8
8
|
import { BareFormProvider } from "./contexts.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
3
2
|
import { MoreHorizontal } from "lucide-react";
|
|
3
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
4
4
|
import { cloneElement, isValidElement, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react";
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
6
6
|
import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, } from "../../index.js";
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
// Toast primitive built on
|
|
2
|
+
// Toast primitive built on radix-ui Toast: a global provider and
|
|
3
3
|
// a useToast() hook for triggering toasts throughout the app. Consumers
|
|
4
4
|
// get only the `toast()` trigger, without Context boilerplate.
|
|
5
5
|
//
|
|
6
6
|
// Variant is StatusTone, using the same color family as StatusBadge;
|
|
7
7
|
// the default is "muted" (neutral). Toasts auto-dismiss after 5s and can
|
|
8
8
|
// be closed manually with the X button. Radix provides the ARIA live region.
|
|
9
|
-
import * as Primitive from "@radix-ui/react-toast";
|
|
10
9
|
import { X } from "lucide-react";
|
|
10
|
+
import { Toast as Primitive } from "radix-ui";
|
|
11
11
|
import { createContext, useCallback, useContext, useId, useMemo, useRef, useState, } from "react";
|
|
12
12
|
import { useTranslation } from "../../index.js";
|
|
13
13
|
import { cn } from "../lib/cn.js";
|
|
@@ -74,7 +74,6 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
74
74
|
const wiredEntities = new Set();
|
|
75
75
|
let cancelReconnect;
|
|
76
76
|
let reconnectAttempt = 0;
|
|
77
|
-
let needsResync = false;
|
|
78
77
|
// `subscribers` is a flat Set across all entities — the browser-side
|
|
79
78
|
// addEventListener(entityName, ...) gate below only filters which frames
|
|
80
79
|
// arrive at all, not which subscriber a given frame is for. Without this
|
|
@@ -134,10 +133,6 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
134
133
|
};
|
|
135
134
|
connection.addEventListener("open", () => {
|
|
136
135
|
reconnectAttempt = 0;
|
|
137
|
-
// skip: first connect, nothing was missed
|
|
138
|
-
if (!needsResync)
|
|
139
|
-
return;
|
|
140
|
-
needsResync = false;
|
|
141
136
|
emitResync();
|
|
142
137
|
});
|
|
143
138
|
connection.addEventListener(SESSION_ENDED_EVENT, (e) => {
|
|
@@ -146,7 +141,6 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
146
141
|
signalSessionEnded();
|
|
147
142
|
});
|
|
148
143
|
connection.addEventListener("error", () => {
|
|
149
|
-
needsResync = true;
|
|
150
144
|
// skip: CONNECTING means a dropped stream the browser retries itself;
|
|
151
145
|
// only a refused handshake (non-200) closes the EventSource for good.
|
|
152
146
|
if (connection.readyState !== EventSource.CLOSED)
|
|
@@ -191,7 +185,6 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
191
185
|
cancelReconnect = undefined;
|
|
192
186
|
}
|
|
193
187
|
reconnectAttempt = 0;
|
|
194
|
-
needsResync = false;
|
|
195
188
|
// skip: already closed, nothing to close
|
|
196
189
|
if (source === undefined)
|
|
197
190
|
return;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DragDelta } from "./use-pointer-drag.ts";
|
|
2
|
+
export type FloatingPanelGeometry = {
|
|
3
|
+
readonly x: number;
|
|
4
|
+
readonly y: number;
|
|
5
|
+
readonly width: number;
|
|
6
|
+
readonly height: number;
|
|
7
|
+
};
|
|
8
|
+
export type Edge = "top" | "right" | "bottom" | "left";
|
|
9
|
+
export declare const FLOATING_PANEL_MARGIN_PX = 24;
|
|
10
|
+
export type Limits = {
|
|
11
|
+
readonly minWidth: number;
|
|
12
|
+
readonly minHeight: number;
|
|
13
|
+
readonly maxWidth: number;
|
|
14
|
+
readonly maxHeight: number;
|
|
15
|
+
readonly viewportWidth: number;
|
|
16
|
+
readonly viewportHeight: number;
|
|
17
|
+
};
|
|
18
|
+
type LimitOptions = {
|
|
19
|
+
readonly minWidthPx?: number | undefined;
|
|
20
|
+
readonly minHeightPx?: number | undefined;
|
|
21
|
+
readonly maxWidthPx?: number | undefined;
|
|
22
|
+
readonly maxHeightPx?: number | undefined;
|
|
23
|
+
};
|
|
24
|
+
export declare function readLimits(options: LimitOptions): Limits;
|
|
25
|
+
export declare function fitGeometry(geometry: FloatingPanelGeometry, limits: Limits): FloatingPanelGeometry;
|
|
26
|
+
export type ViewportSize = {
|
|
27
|
+
readonly width: number;
|
|
28
|
+
readonly height: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function refitToViewport(geometry: FloatingPanelGeometry, previousViewport: ViewportSize, limits: Limits): FloatingPanelGeometry;
|
|
31
|
+
export declare function resizeGeometry(start: FloatingPanelGeometry, edges: readonly Edge[], delta: DragDelta, limits: Limits): FloatingPanelGeometry;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { clamp } from "../lib/clamp.js";
|
|
2
|
+
export const FLOATING_PANEL_MARGIN_PX = 24;
|
|
3
|
+
// Minimum distance to every viewport edge, so the panel never sticks to the window border.
|
|
4
|
+
const VIEWPORT_GAP_PX = 16;
|
|
5
|
+
const DEFAULT_MIN_WIDTH_PX = 280;
|
|
6
|
+
const DEFAULT_MIN_HEIGHT_PX = 200;
|
|
7
|
+
export function readLimits(options) {
|
|
8
|
+
const viewportWidth = window.innerWidth;
|
|
9
|
+
const viewportHeight = window.innerHeight;
|
|
10
|
+
const maxWidth = Math.min(options.maxWidthPx ?? viewportWidth - 2 * FLOATING_PANEL_MARGIN_PX, viewportWidth - 2 * VIEWPORT_GAP_PX);
|
|
11
|
+
const maxHeight = Math.min(options.maxHeightPx ?? viewportHeight - 2 * FLOATING_PANEL_MARGIN_PX, viewportHeight - 2 * VIEWPORT_GAP_PX);
|
|
12
|
+
return {
|
|
13
|
+
// A viewport smaller than the configured minimum wins: the panel must stay fully visible.
|
|
14
|
+
minWidth: Math.min(options.minWidthPx ?? DEFAULT_MIN_WIDTH_PX, maxWidth),
|
|
15
|
+
minHeight: Math.min(options.minHeightPx ?? DEFAULT_MIN_HEIGHT_PX, maxHeight),
|
|
16
|
+
maxWidth,
|
|
17
|
+
maxHeight,
|
|
18
|
+
viewportWidth,
|
|
19
|
+
viewportHeight,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// Size first, then position, so a shrunk panel is placed against its final size.
|
|
23
|
+
export function fitGeometry(geometry, limits) {
|
|
24
|
+
const width = clamp(geometry.width, limits.minWidth, limits.maxWidth);
|
|
25
|
+
const height = clamp(geometry.height, limits.minHeight, limits.maxHeight);
|
|
26
|
+
return {
|
|
27
|
+
width,
|
|
28
|
+
height,
|
|
29
|
+
x: clampIntoViewport(geometry.x, width, limits.viewportWidth),
|
|
30
|
+
y: clampIntoViewport(geometry.y, height, limits.viewportHeight),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function clampIntoViewport(position, size, viewportSize) {
|
|
34
|
+
return clamp(position, VIEWPORT_GAP_PX, Math.max(VIEWPORT_GAP_PX, viewportSize - size - VIEWPORT_GAP_PX));
|
|
35
|
+
}
|
|
36
|
+
// A panel closer to the right (bottom) edge keeps its distance to that edge when the window
|
|
37
|
+
// changes size; otherwise it keeps its distance to the left (top) edge.
|
|
38
|
+
function anchoredPosition(position, size, previousViewportSize, fittedSize, viewportSize) {
|
|
39
|
+
const endGap = previousViewportSize - (position + size);
|
|
40
|
+
return endGap < position ? viewportSize - fittedSize - endGap : position;
|
|
41
|
+
}
|
|
42
|
+
export function refitToViewport(geometry, previousViewport, limits) {
|
|
43
|
+
const sized = fitGeometry(geometry, limits);
|
|
44
|
+
return fitGeometry({
|
|
45
|
+
...sized,
|
|
46
|
+
x: anchoredPosition(geometry.x, geometry.width, previousViewport.width, sized.width, limits.viewportWidth),
|
|
47
|
+
y: anchoredPosition(geometry.y, geometry.height, previousViewport.height, sized.height, limits.viewportHeight),
|
|
48
|
+
}, limits);
|
|
49
|
+
}
|
|
50
|
+
// The edge being dragged follows the pointer; the opposite edge stays put.
|
|
51
|
+
function resizeAxis(start, delta, movesStartEdge, min, max, viewportSize) {
|
|
52
|
+
if (movesStartEdge) {
|
|
53
|
+
const end = start.position + start.size;
|
|
54
|
+
const size = clamp(start.size - delta, min, Math.min(max, end - VIEWPORT_GAP_PX));
|
|
55
|
+
return { position: end - size, size };
|
|
56
|
+
}
|
|
57
|
+
const size = clamp(start.size + delta, min, Math.min(max, viewportSize - VIEWPORT_GAP_PX - start.position));
|
|
58
|
+
return { position: start.position, size };
|
|
59
|
+
}
|
|
60
|
+
export function resizeGeometry(start, edges, delta, limits) {
|
|
61
|
+
let { x, y, width, height } = start;
|
|
62
|
+
for (const edge of edges) {
|
|
63
|
+
if (edge === "left" || edge === "right") {
|
|
64
|
+
({ position: x, size: width } = resizeAxis({ position: start.x, size: start.width }, delta.dx, edge === "left", limits.minWidth, limits.maxWidth, limits.viewportWidth));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
({ position: y, size: height } = resizeAxis({ position: start.y, size: start.height }, delta.dy, edge === "top", limits.minHeight, limits.maxHeight, limits.viewportHeight));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { x, y, width, height };
|
|
71
|
+
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
readonly y: number;
|
|
5
|
-
readonly width: number;
|
|
6
|
-
readonly height: number;
|
|
7
|
-
};
|
|
2
|
+
import { type FloatingPanelGeometry } from "./floating-panel-geometry.ts";
|
|
3
|
+
export type { FloatingPanelGeometry } from "./floating-panel-geometry.ts";
|
|
8
4
|
export type FloatingPanelProps = {
|
|
9
5
|
/** Header content; the header is the move handle. */
|
|
10
6
|
readonly title: ReactNode;
|
|
@@ -3,15 +3,12 @@ import { GripVerticalIcon } from "lucide-react";
|
|
|
3
3
|
import { useCallback, useEffect, useId, useRef, useState, } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { useTranslation } from "../../index.js";
|
|
6
|
-
import { clamp } from "../lib/clamp.js";
|
|
7
6
|
import { cn } from "../lib/cn.js";
|
|
8
7
|
import { useIsNarrowViewport } from "../primitives/use-narrow-viewport.js";
|
|
8
|
+
import { FLOATING_PANEL_MARGIN_PX, fitGeometry, readLimits, refitToViewport, resizeGeometry, } from "./floating-panel-geometry.js";
|
|
9
9
|
import { arrowKeyDelta, usePointerDrag } from "./use-pointer-drag.js";
|
|
10
|
-
const MARGIN_PX = 24;
|
|
11
10
|
const DEFAULT_WIDTH_PX = 400;
|
|
12
11
|
const DEFAULT_HEIGHT_PX = 560;
|
|
13
|
-
const DEFAULT_MIN_WIDTH_PX = 280;
|
|
14
|
-
const DEFAULT_MIN_HEIGHT_PX = 200;
|
|
15
12
|
const EDGES = ["top", "right", "bottom", "left"];
|
|
16
13
|
const EDGE_HANDLE_BASE = "absolute z-10 touch-none hover:bg-border focus-visible:bg-ring";
|
|
17
14
|
const CORNER_HANDLE_BASE = "absolute z-20 size-3 touch-none";
|
|
@@ -44,54 +41,6 @@ const EDGE_LABEL_KEYS = {
|
|
|
44
41
|
};
|
|
45
42
|
const DRAG_HANDLE_SELECTOR = "[data-drag-handle]";
|
|
46
43
|
const INTERACTIVE_HEADER_TARGET = "button, a, input, select, textarea, [role=button]";
|
|
47
|
-
function readLimits(options) {
|
|
48
|
-
const viewportWidth = window.innerWidth;
|
|
49
|
-
const viewportHeight = window.innerHeight;
|
|
50
|
-
const maxWidth = Math.min(options.maxWidthPx ?? viewportWidth - 2 * MARGIN_PX, viewportWidth);
|
|
51
|
-
const maxHeight = Math.min(options.maxHeightPx ?? viewportHeight - 2 * MARGIN_PX, viewportHeight);
|
|
52
|
-
return {
|
|
53
|
-
// A viewport smaller than the configured minimum wins: the panel must stay fully visible.
|
|
54
|
-
minWidth: Math.min(options.minWidthPx ?? DEFAULT_MIN_WIDTH_PX, maxWidth),
|
|
55
|
-
minHeight: Math.min(options.minHeightPx ?? DEFAULT_MIN_HEIGHT_PX, maxHeight),
|
|
56
|
-
maxWidth,
|
|
57
|
-
maxHeight,
|
|
58
|
-
viewportWidth,
|
|
59
|
-
viewportHeight,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
// Size first, then position, so a shrunk panel is placed against its final size.
|
|
63
|
-
function fitGeometry(geometry, limits) {
|
|
64
|
-
const width = clamp(geometry.width, limits.minWidth, limits.maxWidth);
|
|
65
|
-
const height = clamp(geometry.height, limits.minHeight, limits.maxHeight);
|
|
66
|
-
return {
|
|
67
|
-
width,
|
|
68
|
-
height,
|
|
69
|
-
x: clamp(geometry.x, 0, Math.max(0, limits.viewportWidth - width)),
|
|
70
|
-
y: clamp(geometry.y, 0, Math.max(0, limits.viewportHeight - height)),
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
// The edge being dragged follows the pointer; the opposite edge stays put.
|
|
74
|
-
function resizeAxis(start, delta, movesStartEdge, min, max, viewportSize) {
|
|
75
|
-
if (movesStartEdge) {
|
|
76
|
-
const end = start.position + start.size;
|
|
77
|
-
const size = clamp(start.size - delta, min, Math.min(max, end));
|
|
78
|
-
return { position: end - size, size };
|
|
79
|
-
}
|
|
80
|
-
const size = clamp(start.size + delta, min, Math.min(max, viewportSize - start.position));
|
|
81
|
-
return { position: start.position, size };
|
|
82
|
-
}
|
|
83
|
-
function resizeGeometry(start, edges, delta, limits) {
|
|
84
|
-
let { x, y, width, height } = start;
|
|
85
|
-
for (const edge of edges) {
|
|
86
|
-
if (edge === "left" || edge === "right") {
|
|
87
|
-
({ position: x, size: width } = resizeAxis({ position: start.x, size: start.width }, delta.dx, edge === "left", limits.minWidth, limits.maxWidth, limits.viewportWidth));
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
({ position: y, size: height } = resizeAxis({ position: start.y, size: start.height }, delta.dy, edge === "top", limits.minHeight, limits.maxHeight, limits.viewportHeight));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return { x, y, width, height };
|
|
94
|
-
}
|
|
95
44
|
function isFiniteNumber(value) {
|
|
96
45
|
return typeof value === "number" && Number.isFinite(value);
|
|
97
46
|
}
|
|
@@ -141,8 +90,8 @@ function initialGeometry(storageKey, defaults, limits) {
|
|
|
141
90
|
return fitGeometry({
|
|
142
91
|
width,
|
|
143
92
|
height,
|
|
144
|
-
x: defaults?.x ?? limits.viewportWidth - width -
|
|
145
|
-
y: defaults?.y ?? limits.viewportHeight - height -
|
|
93
|
+
x: defaults?.x ?? limits.viewportWidth - width - FLOATING_PANEL_MARGIN_PX,
|
|
94
|
+
y: defaults?.y ?? limits.viewportHeight - height - FLOATING_PANEL_MARGIN_PX,
|
|
146
95
|
}, limits);
|
|
147
96
|
}
|
|
148
97
|
/** Non-modal panel floating above the page: draggable by its header,
|
|
@@ -158,6 +107,8 @@ export function FloatingPanel({ title, headerActions, children, storageKey, defa
|
|
|
158
107
|
const [geometry, setGeometry] = useState(() => initialGeometry(storageKey, defaultGeometry, getLimits()));
|
|
159
108
|
const geometryRef = useRef(geometry);
|
|
160
109
|
geometryRef.current = geometry;
|
|
110
|
+
// The viewport the current geometry was last fitted to; null until the first fit.
|
|
111
|
+
const fittedViewportRef = useRef(null);
|
|
161
112
|
const applyGeometry = useCallback((next) => {
|
|
162
113
|
geometryRef.current = next;
|
|
163
114
|
setGeometry(next);
|
|
@@ -167,7 +118,12 @@ export function FloatingPanel({ title, headerActions, children, storageKey, defa
|
|
|
167
118
|
if (narrow)
|
|
168
119
|
return;
|
|
169
120
|
const onWindowResize = () => {
|
|
170
|
-
|
|
121
|
+
const limits = getLimitsRef.current();
|
|
122
|
+
const previousViewport = fittedViewportRef.current;
|
|
123
|
+
applyGeometry(previousViewport === null
|
|
124
|
+
? fitGeometry(geometryRef.current, limits)
|
|
125
|
+
: refitToViewport(geometryRef.current, previousViewport, limits));
|
|
126
|
+
fittedViewportRef.current = { width: limits.viewportWidth, height: limits.viewportHeight };
|
|
171
127
|
};
|
|
172
128
|
// Resize events fired while narrow were ignored, so re-fit once on the way back to wide.
|
|
173
129
|
onWindowResize();
|
|
@@ -32,6 +32,9 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
32
32
|
// this, a slow earlier response can land after a faster later one and
|
|
33
33
|
// overwrite it, leaving stale rows on screen.
|
|
34
34
|
const requestSeq = useRef(0);
|
|
35
|
+
// Lets refreshFirstPage tell "initial load still running" from outside a state updater.
|
|
36
|
+
const stateKindRef = useRef(state.kind);
|
|
37
|
+
stateKindRef.current = state.kind;
|
|
35
38
|
// IDs of the most recently loaded first page. refreshFirstPage() needs
|
|
36
39
|
// this to tell "row dropped from page 1 elsewhere" (must be pruned) apart
|
|
37
40
|
// from "row belongs to an already-accumulated later page" (must survive)
|
|
@@ -81,6 +84,12 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
81
84
|
requestSeq.current += 1;
|
|
82
85
|
};
|
|
83
86
|
}, [load]);
|
|
87
|
+
// Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
|
|
88
|
+
// into one query per event. A refresh already running schedules
|
|
89
|
+
// exactly one trailing pass after it settles.
|
|
90
|
+
const liveTimerRef = useRef(null);
|
|
91
|
+
const liveInFlightRef = useRef(false);
|
|
92
|
+
const liveTrailingRef = useRef(false);
|
|
84
93
|
// Live-mode: on an SSE event for the query's entity, refetch only the
|
|
85
94
|
// first page and merge it in — rows the fresh page still contains move
|
|
86
95
|
// to the front (newest-first feeds), rows it dropped (edited/deleted
|
|
@@ -89,19 +98,32 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
89
98
|
// scroll position.
|
|
90
99
|
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
91
100
|
const refreshFirstPage = useCallback(async () => {
|
|
92
|
-
// Read, don't bump: a concurrent load() (e.g. the mount
|
|
93
|
-
// in flight) must
|
|
94
|
-
//
|
|
95
|
-
//
|
|
101
|
+
// Read, don't bump at the start: a concurrent load() (e.g. the mount
|
|
102
|
+
// fetch still in flight) must not be discarded by a refresh that then
|
|
103
|
+
// fails or is superseded. Only a refresh that actually delivers fresh
|
|
104
|
+
// rows bumps (below), because those are newer than the running load's.
|
|
96
105
|
const seqAtStart = requestSeq.current;
|
|
97
106
|
const res = await dispatcher.query(query, { ...payload, limit: pageSize });
|
|
98
|
-
if (seqAtStart !== requestSeq.current)
|
|
107
|
+
if (seqAtStart !== requestSeq.current) {
|
|
108
|
+
// A load-more bumps the sequence without covering page 1, so the signal that
|
|
109
|
+
// triggered this refresh would otherwise be lost.
|
|
110
|
+
liveTrailingRef.current = true;
|
|
99
111
|
return;
|
|
112
|
+
}
|
|
100
113
|
// skip: background live refresh failed, keep showing the current rows
|
|
101
114
|
if (!res.isSuccess)
|
|
102
115
|
return;
|
|
103
116
|
const freshRows = rowsRef.current(res.data);
|
|
104
117
|
const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
|
|
118
|
+
if (stateKindRef.current === "loading") {
|
|
119
|
+
// The initial load was issued before the change that triggered this
|
|
120
|
+
// refresh (the live stream reports its first open as a resync), so
|
|
121
|
+
// its page 1 may be stale: adopt the fresh page and drop the older load.
|
|
122
|
+
requestSeq.current += 1;
|
|
123
|
+
firstPageIdsRef.current = freshIds;
|
|
124
|
+
setState({ kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
105
127
|
// A row absent from freshIds is stale for one of two reasons: it was
|
|
106
128
|
// dropped from page 1 (deleted, or filtered out elsewhere) and must be
|
|
107
129
|
// pruned, or it belongs to an already-accumulated later page and must
|
|
@@ -110,9 +132,6 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
110
132
|
const previousFirstPageIds = firstPageIdsRef.current;
|
|
111
133
|
firstPageIdsRef.current = freshIds;
|
|
112
134
|
setState((prev) => {
|
|
113
|
-
// skip: the initial load() is still in flight and owns the eventual
|
|
114
|
-
// state — a concurrent refresh landing here has nothing accumulated
|
|
115
|
-
// to merge into yet.
|
|
116
135
|
if (prev.kind === "loading")
|
|
117
136
|
return prev;
|
|
118
137
|
if (prev.kind !== "ready") {
|
|
@@ -128,12 +147,6 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
|
|
|
128
147
|
return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
|
|
129
148
|
});
|
|
130
149
|
}, [dispatcher, query, pageSize, payloadKey]);
|
|
131
|
-
// Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
|
|
132
|
-
// into one query per event. A refresh already running schedules
|
|
133
|
-
// exactly one trailing pass after it settles.
|
|
134
|
-
const liveTimerRef = useRef(null);
|
|
135
|
-
const liveInFlightRef = useRef(false);
|
|
136
|
-
const liveTrailingRef = useRef(false);
|
|
137
150
|
useEffect(() => {
|
|
138
151
|
// skip: live mode off, no SSE subscription needed
|
|
139
152
|
if (!live)
|
|
@@ -4,6 +4,8 @@ export type PlanCardPrice = {
|
|
|
4
4
|
readonly period?: string;
|
|
5
5
|
/** Shown before the amount, e.g. "from" when prices differ per provider. */
|
|
6
6
|
readonly prefix?: string;
|
|
7
|
+
/** Small line under the price row, e.g. "plus VAT". */
|
|
8
|
+
readonly note?: string;
|
|
7
9
|
};
|
|
8
10
|
export type PlanCardActionSlot = {
|
|
9
11
|
readonly label: string;
|
|
@@ -6,8 +6,12 @@ export function PlanCard({ title, description, price, features, current = false,
|
|
|
6
6
|
const t = useTranslation();
|
|
7
7
|
const compact = variant === "compact";
|
|
8
8
|
const footer = cta !== undefined || ctaLink !== undefined || secondaryAction !== undefined ? (_jsxs("div", { className: "flex w-full flex-col gap-2", children: [ctaLink !== undefined && (_jsx(Link, { href: ctaLink.href, variant: "button", target: ctaLink.target, className: "w-full justify-center", testId: testId !== undefined ? `${testId}-cta` : "plan-card-cta", children: ctaLink.label })), ctaLink === undefined && cta !== undefined && (_jsx(Button, { width: "full", disabled: cta.disabled, onClick: cta.onClick, testId: testId !== undefined ? `${testId}-cta` : "plan-card-cta", children: cta.label })), secondaryAction !== undefined && (_jsx(Button, { variant: "secondary", width: "full", disabled: secondaryAction.disabled, onClick: secondaryAction.onClick, children: secondaryAction.label }))] })) : undefined;
|
|
9
|
-
return (_jsxs(Card, { testId: testId ?? "plan-card", className: cn(current && "border-primary"), slots: { footer }, children: [_jsxs("div", { className: cn("flex items-start justify-between gap-2", compact && "items-center"), children: [_jsx("h2", { className: cn("font-semibold", compact ? "text-base" : "text-lg"), children: title }), current && (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-current` : "plan-card-current", className: "inline-block rounded-xl bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary", children: t("kumiko.planCard.current") }))] }), description !== undefined && !compact && (_jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: description })), price !== undefined &&
|
|
9
|
+
return (_jsxs(Card, { testId: testId ?? "plan-card", className: cn(current && "border-primary"), slots: { footer }, children: [_jsxs("div", { className: cn("flex items-start justify-between gap-2", compact && "items-center"), children: [_jsx("h2", { className: cn("font-semibold", compact ? "text-base" : "text-lg"), children: title }), current && (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-current` : "plan-card-current", className: "inline-block rounded-xl bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary", children: t("kumiko.planCard.current") }))] }), description !== undefined && !compact && (_jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: description })), price !== undefined && _jsx(PlanCardPriceRow, { price: price, compact: compact, testId: testId }), !compact && features !== undefined && features.length > 0 && (_jsx("ul", { className: "space-y-1 text-sm", children: features.map((feature, index) => (_jsxs("li", { className: "flex gap-2", children: [_jsx("span", { "aria-hidden": "true", children: "\u2713" }), _jsx("span", { children: feature })] }, index))) }))] }));
|
|
10
10
|
}
|
|
11
11
|
export function PlanGrid({ children, testId }) {
|
|
12
12
|
return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", children: children }));
|
|
13
13
|
}
|
|
14
|
+
function PlanCardPriceRow({ price, compact, testId, }) {
|
|
15
|
+
const t = useTranslation();
|
|
16
|
+
return (_jsxs("div", { className: cn(compact ? "mt-1" : "mt-1 mb-3"), children: [price === null ? (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-price-unavailable` : "plan-card-price-unavailable", className: "text-sm text-muted-foreground", children: t("kumiko.planCard.priceUnavailable") })) : (_jsxs(_Fragment, { children: [price.prefix !== undefined && (_jsx("span", { className: "mr-1 text-sm text-muted-foreground", children: price.prefix })), _jsx("span", { className: "text-2xl font-bold", children: price.amount }), price.period !== undefined && (_jsx("span", { className: "ml-1 text-sm text-muted-foreground", children: price.period }))] })), price?.note !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-price-note` : "plan-card-price-note", className: "text-xs text-muted-foreground", children: price.note }))] }));
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.395.0",
|
|
4
4
|
"description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,21 +48,8 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
52
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
53
|
-
"@radix-ui/react-dialog": "^1.1.15",
|
|
54
|
-
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
55
|
-
"@radix-ui/react-label": "^2.1.8",
|
|
56
|
-
"@radix-ui/react-popover": "^1.1.15",
|
|
57
|
-
"@radix-ui/react-select": "^2.2.6",
|
|
58
|
-
"@radix-ui/react-separator": "^1.1.7",
|
|
59
|
-
"@radix-ui/react-slot": "^1.2.4",
|
|
60
|
-
"@radix-ui/react-toast": "^1.2.15",
|
|
61
|
-
"@radix-ui/react-tooltip": "^1.2.8",
|
|
62
|
-
"@tiptap/core": "^3.31.3",
|
|
63
|
-
"@tiptap/pm": "^3.31.3",
|
|
64
|
-
"@tiptap/react": "^3.31.3",
|
|
65
|
-
"@tiptap/starter-kit": "^3.31.3",
|
|
51
|
+
"@cosmicdrift/kumiko-framework": "0.395.0",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.395.0",
|
|
66
53
|
"@types/qrcode": "^1.5.5",
|
|
67
54
|
"class-variance-authority": "^0.7.1",
|
|
68
55
|
"clsx": "^2.1.1",
|
|
@@ -70,7 +57,7 @@
|
|
|
70
57
|
"lucide-react": "^1.14.0",
|
|
71
58
|
"pdfjs-dist": "6.3.289",
|
|
72
59
|
"qrcode": "^1.5.4",
|
|
73
|
-
"radix-ui": "^1.
|
|
60
|
+
"radix-ui": "^1.8.1",
|
|
74
61
|
"react": "^19.2.6",
|
|
75
62
|
"react-day-picker": "^10.0.0",
|
|
76
63
|
"react-dom": "^19.2.6",
|
|
@@ -92,8 +79,8 @@
|
|
|
92
79
|
}
|
|
93
80
|
},
|
|
94
81
|
"devDependencies": {
|
|
95
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
82
|
+
"@cosmicdrift/kumiko-locale-de": "0.395.0",
|
|
83
|
+
"@cosmicdrift/kumiko-testing": "0.395.0",
|
|
97
84
|
"@tailwindcss/cli": "^4.3.0",
|
|
98
85
|
"@testing-library/react": "^16.3.2",
|
|
99
86
|
"@testing-library/user-event": "^14.6.1",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,58 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.395.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Server field errors without a field on the form show in the form banner",
|
|
6
|
+
"detail": "An entityEdit save that fails with a field error whose path is not a rendered field (for example a field an extension section writes) now shows the message in the form error banner instead of losing it."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.395.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "A live refresh that a load-more supersedes is retried instead of lost",
|
|
12
|
+
"detail": "When a live signal triggered a first-page refresh while the list loaded its next page, the refresh result was discarded and nothing re-ran it, so the change on page 1 stayed invisible until the next event. The discarded refresh now runs once more."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.394.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "edit screens opened without a record use screen:<id>.createTitle",
|
|
18
|
+
"detail": "When a detail screen is opened without an entity id and the feature translates `screen:<id>.createTitle`, the breadcrumb and page title use it instead of the edit title. managed-pages names its create state \"New page\"."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.394.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "resizable tables size columns by content until the user resizes one",
|
|
24
|
+
"detail": "With `table.resizing` on (also through `bundledLists`), columns without a schema `width` no longer start at a flat 160px. The table keeps the auto layout and sizes them by content until the user drags or arrow-keys a column; that first resize stores the displayed widths of the other columns too, so nothing jumps. The first column of a resizable table breaks only when the table runs out of width, and a clickable first cell wraps instead of truncating, and dates and timestamps (also through a `format` renderer) stay on one line. `onWidthChange` gets an optional third argument with those widths. Stored widths from earlier sessions still apply; a browser that saved any view setting before this release still holds the old 160px widths until the view is reset. Apps with `bundledLists.table.resizing` (use-all-bundled, solon) see wider name columns and narrower leftover space on first visit."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.394.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Floating panel keeps its distance to the window edge",
|
|
30
|
+
"detail": "Moving, resizing and window resizes clamp the panel 16px inside the viewport instead of at the border. On a window resize the panel keeps its gap to the nearer horizontal and vertical edge, so a bottom-right panel no longer sticks to the right border when the window narrows. Stored geometry stays valid and is only clamped again."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.394.0",
|
|
34
|
+
"type": "fix",
|
|
35
|
+
"title": "One copy of Radix dismissable layers",
|
|
36
|
+
"detail": "Dialog, Popover, DropdownMenu and Toast now come from the radix-ui package instead of the single @radix-ui/react-* packages, which dropped nine direct dependencies. A root override pins @radix-ui/react-dialog to 1.3.1 because cmdk would otherwise pull an older dialog with its own react-dismissable-layer. Apps that import @radix-ui/react-* themselves, or add cmdk, should align their own lockfile to one react-dialog version."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.394.0",
|
|
40
|
+
"type": "improvement",
|
|
41
|
+
"title": "A projectionDetail can be a dashboard screen panel",
|
|
42
|
+
"detail": "A `screen` panel or its `fallback` may point at a projectionDetail. The id comes from the host dashboard's `record` binding and is passed to the embedded screen as its entityId. Boot fails when the host dashboard has no `record`. Header, fields and Detail query render inline in the tile."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.394.0",
|
|
46
|
+
"type": "improvement",
|
|
47
|
+
"title": "Toolbar actions accept visibleWhen",
|
|
48
|
+
"detail": "Every toolbar action of a projectionList or entityList takes an optional `visibleWhen` ({ query, field, eq }). The button shows only while the live query matches; it stays hidden while the query loads and when it fails. Buttons gating on the same query share one request. Boot checks the query, its output field and that it needs no input. relatedList toolbar actions reject `visibleWhen`."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"version": "0.394.0",
|
|
52
|
+
"type": "improvement",
|
|
53
|
+
"title": "Screen fallback can depend on the failure reason",
|
|
54
|
+
"detail": "A screen-level `fallback` is either a screen id (unchanged) or a map `{ hidden, forbidden, notFound, error }`. `forbidden` is a 403 and `notFound` a 404 on the gate query; `hidden` is an unmet condition; `error` is any other failure. A reason without an entry shows a default notice (new keys kumiko.screen.forbidden and kumiko.screen.notFound) and `error` shows an error banner with retry. Boot rejects an empty map and checks each entry like a string fallback."
|
|
55
|
+
},
|
|
2
56
|
{
|
|
3
57
|
"version": "0.392.1",
|
|
4
58
|
"type": "fix",
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
// @runtime client
|
|
3
|
-
//
|
|
4
|
-
// "rich" contentFormat editor: WYSIWYG on tiptap. Public contract is the
|
|
5
|
-
// same four props every ContentEditorComponent gets — no tiptap type
|
|
6
|
-
// crosses this boundary. tiptap itself is dynamic-imported (TiptapEditor
|
|
7
|
-
// lives in ./tiptap-editor) so an app that never mounts a "rich" collection
|
|
8
|
-
// never pays for it; the fallback while the chunk loads is the same plain
|
|
9
|
-
// textarea uses for a missing editor, never a blank panel.
|
|
10
|
-
import { lazy, Suspense } from "react";
|
|
11
|
-
import { TextareaContentEditor } from "../../index.js";
|
|
12
|
-
const TiptapEditor = lazy(() => import("./tiptap-editor.js"));
|
|
13
|
-
export function RichContentEditor(props) {
|
|
14
|
-
return (_jsx(Suspense, { fallback: _jsx(TextareaContentEditor, { ...props }), children: _jsx(TiptapEditor, { ...props }) }));
|
|
15
|
-
}
|