@cosmicdrift/kumiko-renderer 0.392.2 → 0.394.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/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/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/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/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/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/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/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 +16 -7
- package/dist/web/widgets/plan-card.d.ts +2 -0
- package/dist/web/widgets/plan-card.js +5 -1
- package/package.json +6 -15
- package/src/changes.json +42 -0
|
@@ -3,6 +3,20 @@ import { lastSegment } from "../../index.js";
|
|
|
3
3
|
export function screenTitleKey(screenShortId) {
|
|
4
4
|
return `screen:${screenShortId}.title`;
|
|
5
5
|
}
|
|
6
|
+
export function screenCreateTitleKey(screenShortId) {
|
|
7
|
+
return `screen:${screenShortId}.createTitle`;
|
|
8
|
+
}
|
|
9
|
+
// An edit screen opened without a record creates one; a feature names that state with
|
|
10
|
+
// `createTitle`, and without it the screen title stays (t returns the key when untranslated).
|
|
11
|
+
function detailTitle(screenShortId, creating, t) {
|
|
12
|
+
if (creating) {
|
|
13
|
+
const createKey = screenCreateTitleKey(screenShortId);
|
|
14
|
+
const createTitle = t(createKey);
|
|
15
|
+
if (createTitle !== createKey)
|
|
16
|
+
return createTitle;
|
|
17
|
+
}
|
|
18
|
+
return t(screenTitleKey(screenShortId));
|
|
19
|
+
}
|
|
6
20
|
// Delegates to resolveNavParentScreen, supplying the feature-qualified-id
|
|
7
21
|
// normalization the client-side schema needs.
|
|
8
22
|
function resolveParentScreen(screens, detail) {
|
|
@@ -18,18 +32,19 @@ export function resolveParentScreenId(screens, screenId) {
|
|
|
18
32
|
const parent = resolveParentScreen(screens, detail);
|
|
19
33
|
return parent !== undefined ? lastSegment(parent.id) : undefined;
|
|
20
34
|
}
|
|
21
|
-
export function resolveDetailBreadcrumb(screens, detailScreenId, t) {
|
|
35
|
+
export function resolveDetailBreadcrumb(screens, detailScreenId, t, options = {}) {
|
|
36
|
+
const creating = options.creating === true;
|
|
22
37
|
const detail = screens.find((s) => lastSegment(s.id) === detailScreenId);
|
|
23
38
|
if (detail === undefined)
|
|
24
39
|
return undefined;
|
|
25
40
|
const list = resolveParentScreen(screens, detail);
|
|
26
41
|
if (list === undefined) {
|
|
27
|
-
return [{ label:
|
|
42
|
+
return [{ label: detailTitle(detailScreenId, creating, t) }];
|
|
28
43
|
}
|
|
29
44
|
const listId = lastSegment(list.id);
|
|
30
45
|
return [
|
|
31
46
|
{ label: t(screenTitleKey(listId)), screenId: listId },
|
|
32
|
-
{ label:
|
|
47
|
+
{ label: detailTitle(detailScreenId, creating, t) },
|
|
33
48
|
];
|
|
34
49
|
}
|
|
35
50
|
export function activeNavLabel(nodes, screenId, t) {
|
|
@@ -53,8 +53,10 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
|
|
|
53
53
|
activeNavLabel(tree, screenId, t);
|
|
54
54
|
if (navLabel !== undefined)
|
|
55
55
|
return [{ label: navLabel }];
|
|
56
|
-
return resolveDetailBreadcrumb(allScreens, screenId, t
|
|
57
|
-
|
|
56
|
+
return resolveDetailBreadcrumb(allScreens, screenId, t, {
|
|
57
|
+
creating: standInScreenId === undefined && entityId === undefined,
|
|
58
|
+
});
|
|
59
|
+
}, [allScreens, screenId, standInScreenId, entityId, t, tree, allowedTree]);
|
|
58
60
|
const setStatusElement = slot?.setStatusElement;
|
|
59
61
|
const setActionsElement = slot?.setActionsElement;
|
|
60
62
|
const titleOverride = slot?.title;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
// Generic action menu built on
|
|
3
|
-
//
|
|
2
|
+
// Generic action menu built on the radix-ui DropdownMenu.
|
|
3
|
+
// Used for ProfileMenu (topbar avatar), Edit-
|
|
4
4
|
// Header-Menu (three dots), and list-row kebab menus; only the
|
|
5
5
|
// trigger differs between uses.
|
|
6
6
|
//
|
|
@@ -9,8 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
// The text input (typing) intentionally lives in each Primitive, not here:
|
|
10
10
|
// date has one field, while timestamp has two (date + time). This lets both
|
|
11
11
|
// share the calendar mechanics without sharing their input layout.
|
|
12
|
-
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
13
12
|
import { Calendar as CalendarIcon } from "lucide-react";
|
|
13
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
14
14
|
import { lazy, Suspense } from "react";
|
|
15
15
|
import { cn } from "../lib/cn.js";
|
|
16
16
|
const CalendarDayPicker = lazy(() => import("./calendar-day-picker.js"));
|
|
@@ -12,9 +12,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
12
12
|
// Multi-mode: `multiple: true` switches to tag display and multi-selection.
|
|
13
13
|
// Selected values render as small chips, while the search input remains open
|
|
14
14
|
// for additional selections.
|
|
15
|
-
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
16
15
|
import { Command } from "cmdk";
|
|
17
16
|
import { Check, ChevronDown, Loader2, Plus } from "lucide-react";
|
|
17
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
18
18
|
import { useEffect, useState } from "react";
|
|
19
19
|
import { REFERENCE_SEARCH_DEBOUNCE_MS, useTranslation } from "../../index.js";
|
|
20
20
|
import { cn } from "../lib/cn.js";
|
|
@@ -8,9 +8,35 @@ import { columnCellPresentationProps } from "./data-cell-props.js";
|
|
|
8
8
|
// last column slid under the sticky actions column with
|
|
9
9
|
// nothing visibly cut off. max-w-xs bounds a single column;
|
|
10
10
|
// badges and dates stay nowrap by their own classes.
|
|
11
|
+
// A resizable table sizes by content until the user drags a column. Its
|
|
12
|
+
// first column names the row and gets the room it needs: it breaks anywhere
|
|
13
|
+
// only when the table runs out of width, so a long identifier stays on one
|
|
14
|
+
// line on a wide screen instead of wrapping at 20rem. Dates and timestamps
|
|
15
|
+
// keep one line there too; a narrow screen scrolls the table sideways.
|
|
16
|
+
const ONE_LINE_COLUMN_TYPES = new Set(["timestamp", "date"]);
|
|
17
|
+
// Projection lists synthesize every column as type "text"; their dates carry a format spec.
|
|
18
|
+
function isDateColumn(col) {
|
|
19
|
+
if (ONE_LINE_COLUMN_TYPES.has(col.type))
|
|
20
|
+
return true;
|
|
21
|
+
const renderer = col.renderer;
|
|
22
|
+
return (typeof renderer === "object" &&
|
|
23
|
+
renderer !== null &&
|
|
24
|
+
"format" in renderer &&
|
|
25
|
+
typeof renderer.format === "string" &&
|
|
26
|
+
ONE_LINE_COLUMN_TYPES.has(renderer.format));
|
|
27
|
+
}
|
|
28
|
+
function autoLayoutCellClassName(m, col, colIndex) {
|
|
29
|
+
if (m.sizing !== undefined && isDateColumn(col))
|
|
30
|
+
return "whitespace-nowrap";
|
|
31
|
+
return colIndex === 0 && m.sizing !== undefined
|
|
32
|
+
? "whitespace-normal [overflow-wrap:anywhere]"
|
|
33
|
+
: "max-w-xs whitespace-normal break-words";
|
|
34
|
+
}
|
|
11
35
|
function dataCellClassName(m, col, colIndex, pinClassName) {
|
|
12
36
|
return cn(col.fileDisplay === undefined
|
|
13
|
-
?
|
|
37
|
+
? m.sizing?.fixed === true
|
|
38
|
+
? "overflow-hidden whitespace-normal break-words"
|
|
39
|
+
: autoLayoutCellClassName(m, col, colIndex)
|
|
14
40
|
: FIT_CONTENT_CELL_CLASS, colIndex === 0 ? "font-medium text-foreground" : "text-foreground-secondary", NUMERIC_COLUMN_TYPES.has(col.type) && "text-right", TABULAR_COLUMN_TYPES.has(col.type) && "tabular-nums", col.highlighted === true && "bg-accent/40", m.density.cell, pinClassName);
|
|
15
41
|
}
|
|
16
42
|
export function renderDataCell(m, row, col, colIndex, opensOnClick) {
|
|
@@ -17,5 +17,5 @@ function renderExpandHeaderCell(m) {
|
|
|
17
17
|
return (_jsx(TableHead, { "data-testid": "column-expand", className: cn(EXPAND_TOGGLE_CELL_CLASS, expandHeaderPin.className), style: expandHeaderPin.style }));
|
|
18
18
|
}
|
|
19
19
|
export function renderTableHeader(m) {
|
|
20
|
-
return (_jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { ref: m.headerRowRef, className: "hover:bg-transparent", children: [m.selection.headerCell(m.pins.headerCell(SELECT_COLUMN_KEY)), m.expansion !== undefined && renderExpandHeaderCell(m), m.columns.map((col) => renderColumnHeader(m, col)), m.hasTableActions && (_jsx(ActionsHeaderCell, { label: m.tableTranslate?.("kumiko.list.actions") ?? "Actions", style: m.pins.actionsHeaderStyle, absorbsLeftover: m.sizing
|
|
20
|
+
return (_jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { ref: m.headerRowRef, className: "hover:bg-transparent", children: [m.selection.headerCell(m.pins.headerCell(SELECT_COLUMN_KEY)), m.expansion !== undefined && renderExpandHeaderCell(m), m.columns.map((col) => renderColumnHeader(m, col)), m.hasTableActions && (_jsx(ActionsHeaderCell, { label: m.tableTranslate?.("kumiko.list.actions") ?? "Actions", style: m.pins.actionsHeaderStyle, absorbsLeftover: m.sizing?.fixed === true }))] }) }));
|
|
21
21
|
}
|
|
@@ -8,7 +8,7 @@ import { renderTableHeader } from "./table-header-row.js";
|
|
|
8
8
|
import { TotalsFooterRow } from "./totals-footer.js";
|
|
9
9
|
function renderColumnWidths(m) {
|
|
10
10
|
const { sizing } = m;
|
|
11
|
-
if (sizing === undefined)
|
|
11
|
+
if (sizing === undefined || !sizing.fixed)
|
|
12
12
|
return null;
|
|
13
13
|
return (_jsx(ColumnWidthGroup, { fields: m.columns.map((col) => col.field), sizing: sizing, hasExpandColumn: m.expansion !== undefined, hasSelectColumn: m.selection.active, hasActionsColumn: m.hasTableActions }));
|
|
14
14
|
}
|
|
@@ -21,5 +21,5 @@ function renderTotalsRow(m) {
|
|
|
21
21
|
export function renderTableView(m) {
|
|
22
22
|
const { fillsHeight, chromeless, sizing, testId } = m;
|
|
23
23
|
const TableRoot = fillsHeight ? FlushTable : Table;
|
|
24
|
-
return (_jsx("div", { className: cn(!fillsHeight && "overflow-hidden", !fillsHeight && chromeless !== true && "rounded-lg border bg-card"), children: _jsxs(TableRoot, { "data-testid": testId, className: fillsHeight ? FILL_TABLE_CLASS : undefined, ...(sizing !== undefined && { style: sizing.tableStyle }), children: [renderColumnWidths(m), renderTableHeader(m), renderTableBody(m), renderTotalsRow(m)] }) }));
|
|
24
|
+
return (_jsx("div", { className: cn(!fillsHeight && "overflow-hidden", !fillsHeight && chromeless !== true && "rounded-lg border bg-card"), children: _jsxs(TableRoot, { "data-testid": testId, className: fillsHeight ? FILL_TABLE_CLASS : undefined, ...(sizing?.tableStyle !== undefined && { style: sizing.tableStyle }), children: [renderColumnWidths(m), renderTableHeader(m), renderTableBody(m), renderTotalsRow(m)] }) }));
|
|
25
25
|
}
|
|
@@ -2,14 +2,16 @@ import { type CSSProperties } from "react";
|
|
|
2
2
|
import type { DataTableViewOptions } from "../../../data-table-view-types.ts";
|
|
3
3
|
import { type MeasuredColumnWidths } from "./use-measured-column-widths.ts";
|
|
4
4
|
export type ColumnSizing = {
|
|
5
|
+
/** False until a column has a chosen width: the table keeps the auto layout and sizes columns by content. */
|
|
6
|
+
readonly fixed: boolean;
|
|
5
7
|
readonly widthOf: (field: string) => number;
|
|
6
8
|
readonly minWidthOf: (field: string) => number;
|
|
7
|
-
readonly tableStyle: CSSProperties;
|
|
9
|
+
readonly tableStyle: CSSProperties | undefined;
|
|
8
10
|
/** Live width while a drag is in progress; nothing reaches the view state until `commit`. */
|
|
9
11
|
readonly setLiveWidth: (field: string, width: number) => void;
|
|
10
12
|
readonly commit: (field: string, width: number) => void;
|
|
11
13
|
};
|
|
12
|
-
/**
|
|
14
|
+
/** Sizing for resizable columns; undefined when resizing is off. */
|
|
13
15
|
export declare function useColumnSizing({ columns, view, isNarrow, measured, hasExpandColumn, hasSelectColumn, }: {
|
|
14
16
|
readonly columns: readonly {
|
|
15
17
|
readonly field: string;
|
|
@@ -1,22 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { DEFAULT_COLUMN_WIDTH_PX } from "../../../hooks/list-column-layout.js";
|
|
2
3
|
import { isResizingActive } from "./column-layout-mode.js";
|
|
3
4
|
import { EXPAND_COLUMN_WIDTH_PX, SELECT_COLUMN_WIDTH_PX, } from "./use-measured-column-widths.js";
|
|
4
|
-
/**
|
|
5
|
+
/** Sizing for resizable columns; undefined when resizing is off. */
|
|
5
6
|
export function useColumnSizing({ columns, view, isNarrow, measured, hasExpandColumn, hasSelectColumn = false, }) {
|
|
6
7
|
const [live, setLive] = useState(null);
|
|
7
8
|
const active = isResizingActive(view, isNarrow);
|
|
8
9
|
const widths = view?.widths;
|
|
9
10
|
const minWidths = view?.minWidths;
|
|
10
11
|
const onWidthChange = view?.onWidthChange;
|
|
11
|
-
const commit = useCallback((field, width) => {
|
|
12
|
-
setLive(null);
|
|
13
|
-
onWidthChange?.(field, width);
|
|
14
|
-
}, [onWidthChange]);
|
|
15
12
|
return useMemo(() => {
|
|
16
13
|
if (!active || widths === undefined || minWidths === undefined)
|
|
17
14
|
return undefined;
|
|
18
15
|
const minWidthOf = (field) => minWidths[field] ?? 0;
|
|
19
|
-
const
|
|
16
|
+
const fixed = live !== null || columns.some((column) => widths[column.field] !== undefined);
|
|
17
|
+
// Columns without a chosen width keep the width the auto layout gave them, so the
|
|
18
|
+
// first drag moves one column instead of snapping every other one to a default.
|
|
19
|
+
const displayedWidths = () => live?.frozen ?? measured?.fields ?? {};
|
|
20
|
+
const widthOf = (field) => {
|
|
21
|
+
if (live?.field === field)
|
|
22
|
+
return live.width;
|
|
23
|
+
const chosen = widths[field];
|
|
24
|
+
if (chosen !== undefined)
|
|
25
|
+
return chosen;
|
|
26
|
+
const displayed = displayedWidths()[field];
|
|
27
|
+
if (displayed !== undefined && displayed > 0)
|
|
28
|
+
return displayed;
|
|
29
|
+
return fixed ? Math.max(DEFAULT_COLUMN_WIDTH_PX, minWidthOf(field)) : minWidthOf(field);
|
|
30
|
+
};
|
|
31
|
+
const unchosenDisplayedWidths = (dragged) => Object.fromEntries(columns
|
|
32
|
+
.filter((column) => column.field !== dragged && widths[column.field] === undefined)
|
|
33
|
+
.map((column) => [column.field, widthOf(column.field)]));
|
|
20
34
|
// The actions column keeps no width of its own: it absorbs the leftover space,
|
|
21
35
|
// so a dragged width holds exactly while the table is wider than its container.
|
|
22
36
|
const total = columns.reduce((sum, column) => sum + widthOf(column.field), 0) +
|
|
@@ -24,11 +38,20 @@ export function useColumnSizing({ columns, view, isNarrow, measured, hasExpandCo
|
|
|
24
38
|
(hasSelectColumn ? SELECT_COLUMN_WIDTH_PX : 0) +
|
|
25
39
|
(measured?.actions ?? 0);
|
|
26
40
|
return {
|
|
41
|
+
fixed,
|
|
27
42
|
widthOf,
|
|
28
43
|
minWidthOf,
|
|
29
|
-
tableStyle: { tableLayout: "fixed", width: `max(100%, ${total}px)` },
|
|
30
|
-
setLiveWidth: (field, width) => setLive(
|
|
31
|
-
|
|
44
|
+
tableStyle: fixed ? { tableLayout: "fixed", width: `max(100%, ${total}px)` } : undefined,
|
|
45
|
+
setLiveWidth: (field, width) => setLive((previous) => ({
|
|
46
|
+
field,
|
|
47
|
+
width,
|
|
48
|
+
frozen: previous?.frozen ?? { ...measured?.fields },
|
|
49
|
+
})),
|
|
50
|
+
commit: (field, width) => {
|
|
51
|
+
const others = unchosenDisplayedWidths(field);
|
|
52
|
+
setLive(null);
|
|
53
|
+
onWidthChange?.(field, width, others);
|
|
54
|
+
},
|
|
32
55
|
};
|
|
33
56
|
}, [
|
|
34
57
|
active,
|
|
@@ -39,6 +62,6 @@ export function useColumnSizing({ columns, view, isNarrow, measured, hasExpandCo
|
|
|
39
62
|
hasExpandColumn,
|
|
40
63
|
hasSelectColumn,
|
|
41
64
|
measured,
|
|
42
|
-
|
|
65
|
+
onWidthChange,
|
|
43
66
|
]);
|
|
44
67
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
3
2
|
import { Columns3 } from "lucide-react";
|
|
3
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
4
4
|
import { useOptionalTranslation } from "../../../index.js";
|
|
5
5
|
import { Button as UiButton } from "../../ui/button.js";
|
|
6
6
|
import { SegmentedControl } from "../segmented-select.js";
|
|
@@ -76,7 +76,7 @@ export function isBadgeColumn(col) {
|
|
|
76
76
|
export function FirstCellLink({ enabled, empty, emptyLabel, onOpen, children, }) {
|
|
77
77
|
if (!enabled || (empty && emptyLabel === undefined))
|
|
78
78
|
return children;
|
|
79
|
-
return (_jsx("button", { type: "button", className: "max-w-full cursor-pointer
|
|
79
|
+
return (_jsx("button", { type: "button", className: "max-w-full cursor-pointer bg-transparent p-0 text-left font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", onClick: (e) => {
|
|
80
80
|
e.stopPropagation();
|
|
81
81
|
onOpen();
|
|
82
82
|
}, onKeyDown: (e) => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// Confirm dialog built on ModalShell. Cancel is always secondary; async
|
|
3
3
|
// onConfirm shows a spinner on the confirm button until the promise settles.
|
|
4
|
-
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
4
|
import { Loader2 } from "lucide-react";
|
|
5
|
+
import { Dialog as DialogPrimitive } from "radix-ui";
|
|
6
6
|
import { useRef, useState } from "react";
|
|
7
7
|
import { useTranslation } from "../../index.js";
|
|
8
8
|
import { cn } from "../lib/cn.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { DropdownMenu as Primitive } from "radix-ui";
|
|
2
2
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
3
3
|
export declare const DropdownMenu: import("react").FC<Primitive.DropdownMenuProps>;
|
|
4
4
|
export declare const DropdownMenuTrigger: import("react").ForwardRefExoticComponent<Primitive.DropdownMenuTriggerProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// shadcn-style DropdownMenu built on Radix. Wrappers around
|
|
3
|
-
//
|
|
3
|
+
// the radix-ui DropdownMenu use the same Tailwind token classes as
|
|
4
4
|
// the other primitives. The caller provides the trigger-button styling
|
|
5
5
|
// through the asChild pattern; this module supplies only Content, Item,
|
|
6
6
|
// Label, Separator, and CheckboxItem with consistent styling.
|
|
@@ -11,8 +11,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
// - Portaled content renders across stacking-context boundaries, which is
|
|
12
12
|
// useful in dialogs and popovers.
|
|
13
13
|
// - Keyboard navigation (↑↓ + Home/End) works by default.
|
|
14
|
-
import * as Primitive from "@radix-ui/react-dropdown-menu";
|
|
15
14
|
import { Check } from "lucide-react";
|
|
15
|
+
import { DropdownMenu as Primitive } from "radix-ui";
|
|
16
16
|
import { cn } from "../lib/cn.js";
|
|
17
17
|
export const DropdownMenu = Primitive.Root;
|
|
18
18
|
export const DropdownMenuTrigger = Primitive.Trigger;
|
|
@@ -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();
|