@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.
Files changed (57) hide show
  1. package/dist/app/entity-list-screen.js +4 -2
  2. package/dist/app/kumiko-screen.js +4 -36
  3. package/dist/app/projection-list-body.js +1 -1
  4. package/dist/app/qn.d.ts +1 -6
  5. package/dist/app/qn.js +1 -12
  6. package/dist/app/screen-visibility-gate.d.ts +19 -0
  7. package/dist/app/screen-visibility-gate.js +60 -0
  8. package/dist/app/use-embedded-screen.d.ts +2 -0
  9. package/dist/app/use-embedded-screen.js +1 -1
  10. package/dist/app/use-gated-toolbar-actions.d.ts +7 -0
  11. package/dist/app/use-gated-toolbar-actions.js +51 -0
  12. package/dist/app/use-projection-list-actions.d.ts +1 -0
  13. package/dist/app/use-projection-list-actions.js +11 -2
  14. package/dist/app/visible-when.d.ts +5 -1
  15. package/dist/app/visible-when.js +11 -0
  16. package/dist/data-table-view-types.d.ts +4 -2
  17. package/dist/hooks/list-column-layout.d.ts +3 -1
  18. package/dist/hooks/list-column-layout.js +20 -7
  19. package/dist/hooks/use-list-view-state.d.ts +1 -1
  20. package/dist/hooks/use-list-view-state.js +3 -2
  21. package/dist/i18n-defaults.js +2 -0
  22. package/dist/web/app/dashboard-body.js +1 -1
  23. package/dist/web/app/dashboard-screen-tiles.d.ts +2 -1
  24. package/dist/web/app/dashboard-screen-tiles.js +9 -7
  25. package/dist/web/app/dashboard-shared.d.ts +2 -0
  26. package/dist/web/app/use-dashboard-screen-state.js +6 -1
  27. package/dist/web/layout/shell-breadcrumb.d.ts +4 -1
  28. package/dist/web/layout/shell-breadcrumb.js +18 -3
  29. package/dist/web/layout/shell-header.js +4 -2
  30. package/dist/web/primitives/action-menu.js +2 -2
  31. package/dist/web/primitives/calendar-popover.js +1 -1
  32. package/dist/web/primitives/combobox.js +1 -1
  33. package/dist/web/primitives/data-table/table-data-cell.js +27 -1
  34. package/dist/web/primitives/data-table/table-header-row.js +1 -1
  35. package/dist/web/primitives/data-table/table-view.js +2 -2
  36. package/dist/web/primitives/data-table/use-column-sizing.d.ts +4 -2
  37. package/dist/web/primitives/data-table/use-column-sizing.js +34 -11
  38. package/dist/web/primitives/data-table/view-menu.js +1 -1
  39. package/dist/web/primitives/data-table-cell.js +1 -1
  40. package/dist/web/primitives/dialog.js +1 -1
  41. package/dist/web/primitives/dropdown-menu.d.ts +1 -1
  42. package/dist/web/primitives/dropdown-menu.js +2 -2
  43. package/dist/web/primitives/lightbox.js +1 -1
  44. package/dist/web/primitives/modal-shell.js +1 -1
  45. package/dist/web/primitives/modal.js +1 -1
  46. package/dist/web/primitives/narrow-pinned-footer.js +1 -1
  47. package/dist/web/primitives/toast.js +2 -2
  48. package/dist/web/sse/live-events.js +0 -7
  49. package/dist/web/widgets/floating-panel-geometry.d.ts +32 -0
  50. package/dist/web/widgets/floating-panel-geometry.js +71 -0
  51. package/dist/web/widgets/floating-panel.d.ts +2 -6
  52. package/dist/web/widgets/floating-panel.js +11 -55
  53. package/dist/web/widgets/infinity-list.js +16 -7
  54. package/dist/web/widgets/plan-card.d.ts +2 -0
  55. package/dist/web/widgets/plan-card.js +5 -1
  56. package/package.json +6 -15
  57. 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: t(screenTitleKey(detailScreenId)) }];
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: t(screenTitleKey(detailScreenId)) },
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
- }, [allScreens, screenId, entityId, t, tree, allowedTree]);
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 @radix-ui/react-dropdown-
3
- // menu. Used for ProfileMenu (topbar avatar), Edit-
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
- ? cn(m.sizing ? "overflow-hidden" : "max-w-xs", "whitespace-normal break-words")
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 !== undefined }))] }) }));
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
- /** Fixed-layout sizing for resizable columns; undefined when resizing is off. */
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 { useCallback, useMemo, useState } from "react";
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
- /** Fixed-layout sizing for resizable columns; undefined when resizing is off. */
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 widthOf = (field) => live?.field === field ? live.width : (widths[field] ?? minWidthOf(field));
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({ field, width }),
31
- commit,
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
- commit,
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 truncate 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) => {
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 * as Primitive from "@radix-ui/react-dropdown-menu";
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
- // @radix-ui/react-dropdown-menu use the same Tailwind token classes as
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 * as DialogPrimitive from "@radix-ui/react-dialog";
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 @radix-ui/react-toast: a global provider and
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
- export type FloatingPanelGeometry = {
3
- readonly x: number;
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 - MARGIN_PX,
145
- y: defaults?.y ?? limits.viewportHeight - height - MARGIN_PX,
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
- applyGeometry(fitGeometry(geometryRef.current, getLimitsRef.current()));
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();