@bsuite/page-builder 0.4.3 → 0.5.1

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/README.md CHANGED
@@ -7,7 +7,44 @@ Exports:
7
7
  - `PageGridLayout`
8
8
  - `usePageGridLayout`
9
9
  - `rescaleLayout`
10
+ - `computeAutoHeightRows`
10
11
  - `GridLayouts`, `GridLayoutItem`, `WidgetMeta`, and related types
11
12
 
12
13
  Consumer apps must depend on the published npm package, not `workspace:*` or
13
14
  `file:` links, because each app deploys independently.
15
+
16
+ ## Measured auto-height (`autoHeight`, 0.5.0+)
17
+
18
+ Opt a widget into content-driven height by setting `autoHeight: true` on its
19
+ `GridLayoutItem`:
20
+
21
+ ```ts
22
+ const defaultLayouts: GridLayouts = {
23
+ lg: [
24
+ // Fixed card — user resizes it manually, height persists.
25
+ { i: 'summary', x: 0, y: 0, w: 6, h: 8 },
26
+ // Measured card — height tracks rendered content; `h` is only the
27
+ // initial seed used until the first measurement lands.
28
+ { i: 'details', x: 6, y: 0, w: 6, h: 20, autoHeight: true },
29
+ ],
30
+ };
31
+ ```
32
+
33
+ Behaviour and guarantees:
34
+
35
+ - A `ResizeObserver` on an unconstrained measure wrapper reads the content's
36
+ intrinsic height; `computeAutoHeightRows` converts px → rows using the
37
+ same `rowHeight`/`margin` the grid renders with, so the loop converges to
38
+ a fixed point instead of oscillating.
39
+ - Auto-height items render with `isResizable: false` automatically — do not
40
+ also wire a manual resize path for them.
41
+ - Measured rows are **derived, in-memory-only state**: merged over the saved
42
+ layout at render time for every viewer, and **never persisted**. A
43
+ read-only viewer switching tabs inside a card produces zero preference
44
+ writes. User-driven `x`/`y`/`w` (and `h` for non-autoHeight items)
45
+ persistence is unchanged.
46
+ - Updates are rAF-batched (all cards settling in a frame apply as one state
47
+ update) and suppressed mid-drag/mid-resize, then flushed on gesture end.
48
+
49
+ When adding `autoHeight` to an existing page's layout, bump that page's
50
+ `layoutVersion` so stale persisted layouts (without the flag) are reset.
@@ -1 +1 @@
1
- {"version":3,"file":"PageGridLayout.d.ts","sourceRoot":"","sources":["../src/PageGridLayout.tsx"],"names":[],"mappings":"AACA,OAAO,KAAgE,MAAM,OAAO,CAAC;AAGrF,OAAO,kCAAkC,CAAC;AAC1C,OAAO,gCAAgC,CAAC;AAIxC,OAAO,KAAK,EAAe,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA+KnE,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,iBAAiB,EACjB,OAAO,EACP,UAAU,EACV,SAAS,EAMT,WAAkB,EAClB,aAAsC,EACtC,yBAAiE,EACjE,kBAAkB,EAClB,sBAAsB,GACvB,EAAE,mBAAmB,qBA+iBrB"}
1
+ {"version":3,"file":"PageGridLayout.d.ts","sourceRoot":"","sources":["../src/PageGridLayout.tsx"],"names":[],"mappings":"AACA,OAAO,KAA6E,MAAM,OAAO,CAAC;AAGlG,OAAO,kCAAkC,CAAC;AAC1C,OAAO,gCAAgC,CAAC;AAKxC,OAAO,KAAK,EAAe,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA0QnE,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,iBAAiB,EACjB,OAAO,EACP,UAAU,EACV,SAAS,EAMT,WAAkB,EAClB,aAAsC,EACtC,yBAAiE,EACjE,kBAAkB,EAClB,sBAAsB,GACvB,EAAE,mBAAmB,qBAooBrB"}
@@ -1,13 +1,32 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ArrowDown, ArrowUp, ChevronDown, ChevronUp, Eye, EyeOff, Layers, LayoutGrid, Lock, Plus, RotateCcw, Save, Settings2, Unlock } from 'lucide-react';
3
- import React, { startTransition, useEffect, useMemo, useRef, useState } from 'react';
3
+ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Responsive } from 'react-grid-layout';
5
5
  import { gridBounds, minMaxSize, minSize } from 'react-grid-layout/core';
6
6
  import 'react-grid-layout/css/styles.css';
7
7
  import 'react-resizable/css/styles.css';
8
+ import { computeAutoHeightRows } from './autoHeight.js';
8
9
  import { defaultPreferenceAdapter } from './preferences.js';
9
10
  import { usePageGridLayout } from './usePageGridLayout.js';
10
11
  import { cn } from './utils.js';
12
+ /**
13
+ * Single source of truth for the grid's pixel geometry — read by both the
14
+ * `<Responsive>` props below AND `GridItem`'s auto-height measurement
15
+ * (blueprint amendment A1). Previously these were separate inline literals
16
+ * (`rowHeight={32}`, `margin={[6, 6]}`) with no shared reference, which is
17
+ * exactly the kind of drift the auto-height px→row conversion cannot
18
+ * tolerate — `computeAutoHeightRows` must use the *same* rowHeight/margin
19
+ * the grid itself renders with, or the computed row count would target the
20
+ * wrong pixel size.
21
+ */
22
+ const DEFAULT_ROW_HEIGHT = 32;
23
+ const DEFAULT_MARGIN = [6, 6];
24
+ /**
25
+ * Fixed non-content chrome added before converting measured content px to
26
+ * rows — currently just the unconditional `border border-border` (1px top
27
+ * + 1px bottom) added to every card surface (blueprint item 1).
28
+ */
29
+ const DEFAULT_CARD_CHROME_PX = 2;
11
30
  /**
12
31
  * Default resize bounds applied as a *global* constraint to ALL grid items.
13
32
  *
@@ -55,7 +74,60 @@ const DEFAULT_ADD_ENTITY_WIDGET_EVENT_NAMES = ['bsu-add-entity-widget', 'crm7-ad
55
74
  const RGLResizeHandle = React.forwardRef(function RGLResizeHandle({ axis = 'se', className: injectedClassName, ...rest }, ref) {
56
75
  return (_jsx("span", { ref: ref, "data-no-drag": true, className: `react-resizable-handle react-resizable-handle-${axis}${injectedClassName ? ' ' + injectedClassName : ''}`, ...rest }));
57
76
  });
58
- const GridItem = React.memo(React.forwardRef(function GridItem({ id, content, isEditing, label, onHide, children: injectedChildren, className: injectedClassName, style: injectedStyle, ...rest }, ref) {
77
+ const GridItem = React.memo(React.forwardRef(function GridItem({ id, content, isEditing, label, onHide, autoHeight, onAutoHeightChange, children: injectedChildren, className: injectedClassName, style: injectedStyle, ...rest }, ref) {
78
+ // Auto-height measurement (blueprint amendment A1). `measureRef` wraps
79
+ // `content` with NO height constraint of its own — its parent has
80
+ // `overflow-hidden` + `flex-1 min-h-0` (clips *painting* to the current
81
+ // grid cell height, per MDN's overflow semantics), but that never
82
+ // shrinks this child's own computed box height. So
83
+ // `entry.contentRect.height` here is always the content's true
84
+ // intrinsic size, independent of however many rows the item currently
85
+ // occupies — the property that makes the auto-height loop converge to
86
+ // a fixed point instead of oscillating (see autoHeight.ts).
87
+ const measureRef = useRef(null);
88
+ const lastReportedRowsRef = useRef(null);
89
+ const measureRafRef = useRef(null);
90
+ useEffect(() => {
91
+ if (!autoHeight || !onAutoHeightChange)
92
+ return;
93
+ const el = measureRef.current;
94
+ if (!el || typeof ResizeObserver === 'undefined')
95
+ return;
96
+ const ro = new ResizeObserver((entries) => {
97
+ const entry = entries[0];
98
+ if (!entry)
99
+ return;
100
+ const contentPx = entry.contentRect.height;
101
+ if (measureRafRef.current !== null)
102
+ return;
103
+ const schedule = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
104
+ ? window.requestAnimationFrame.bind(window)
105
+ : (cb) => setTimeout(cb, 0);
106
+ measureRafRef.current = schedule(() => {
107
+ measureRafRef.current = null;
108
+ const rows = computeAutoHeightRows({
109
+ contentPx,
110
+ cardChromePx: DEFAULT_CARD_CHROME_PX,
111
+ rowHeightPx: DEFAULT_ROW_HEIGHT,
112
+ marginYPx: DEFAULT_MARGIN[1],
113
+ });
114
+ if (rows !== lastReportedRowsRef.current) {
115
+ lastReportedRowsRef.current = rows;
116
+ onAutoHeightChange(id, rows);
117
+ }
118
+ });
119
+ });
120
+ ro.observe(el);
121
+ return () => {
122
+ ro.disconnect();
123
+ if (measureRafRef.current !== null &&
124
+ typeof window !== 'undefined' &&
125
+ typeof window.cancelAnimationFrame === 'function') {
126
+ window.cancelAnimationFrame(measureRafRef.current);
127
+ }
128
+ measureRafRef.current = null;
129
+ };
130
+ }, [autoHeight, id, onAutoHeightChange]);
59
131
  // DnD root-cause fix (2026-05-07): react-draggable@4 (used internally by
60
132
  // react-grid-layout@2) checks the drag handle via
61
133
  // `matchesSelectorAndParentsTo(target, handle, baseNode)` — it walks UP
@@ -73,7 +145,7 @@ const GridItem = React.memo(React.forwardRef(function GridItem({ id, content, is
73
145
  return (_jsxs("div", { ref: ref, className: outerClass, style: injectedStyle, ...rest, children: [_jsxs("div", { className: "h-full w-full relative", children: [isEditing && (_jsx("div", { className: "absolute inset-0 z-10 pointer-events-none rounded-3xl border-2 border-transparent group-hover:border-primary/50 transition-colors bg-black/5" })), isEditing && (_jsx("div", { className: "absolute top-2 left-2 z-30 flex items-center gap-1 pointer-events-none", children: _jsx("span", { className: "text-[10px] px-1.5 py-0.5 rounded-md opacity-80 font-medium bg-muted text-muted-foreground", children: label }) })), isEditing && (_jsx("button", { className: "absolute top-2 right-2 z-30 h-6 w-6 rounded-full flex items-center justify-center bg-destructive/80 hover:bg-destructive text-white shadow transition-colors", "data-no-drag": true, onPointerDown: (event) => event.stopPropagation(), onClick: (event) => {
74
146
  event.stopPropagation();
75
147
  onHide(id);
76
- }, title: `Hide ${label}`, "aria-label": `Hide ${label}`, children: _jsx(EyeOff, { className: "h-3 w-3", "aria-hidden": "true" }) })), _jsx("div", { className: "h-full w-full rounded-3xl transition-all flex flex-col bg-card shadow-sm", style: { contain: 'layout style' }, children: _jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: content }) })] }), injectedChildren] }));
148
+ }, title: `Hide ${label}`, "aria-label": `Hide ${label}`, children: _jsx(EyeOff, { className: "h-3 w-3", "aria-hidden": "true" }) })), _jsx("div", { className: "h-full w-full rounded-3xl transition-all flex flex-col bg-card border border-border shadow-sm dark:shadow-[var(--glow-card,none)]", style: { contain: 'layout style' }, children: _jsx("div", { className: cn('flex-1 min-h-0', autoHeight ? 'overflow-hidden' : 'overflow-auto'), children: autoHeight ? _jsx("div", { ref: measureRef, children: content }) : content }) })] }), injectedChildren] }));
77
149
  }));
78
150
  export function PageGridLayout({ pageKey, defaultLayouts, defaultCols, layoutVersion, canEditPage, editorEventNames, preferenceAdapter, widgets, widgetMeta, className,
79
151
  // Resize default flipped 2026-05-12: rgl v2 dropped auto-handle rendering
@@ -82,7 +154,7 @@ export function PageGridLayout({ pageKey, defaultLayouts, defaultCols, layoutVer
82
154
  // the safe default is true so any consumer who forgets to set it still
83
155
  // gets a usable canvas. Opt out per-page with isResizable={false}.
84
156
  isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEventNames = DEFAULT_ADD_ENTITY_WIDGET_EVENT_NAMES, createEntityWidget, onRegisterEntityWidget, }) {
85
- const { currentLayouts, layoutCols, isEditing, setIsEditing, activeCols, activeCompactor, onLayoutChange, handleColumnChange, handleCompact, handleReset, addWidget, moveWidget, setWidgetLocked, resetConfirmOpen, setResetConfirmOpen, containerRef, containerWidth, } = usePageGridLayout({
157
+ const { currentLayouts, layoutCols, isEditing, setIsEditing, activeCols, activeCompactor, onLayoutChange, handleColumnChange, handleCompact, handleReset, addWidget, moveWidget, setWidgetLocked, applyAutoHeightRows, autoHeightRows, resetConfirmOpen, setResetConfirmOpen, containerRef, containerWidth, } = usePageGridLayout({
86
158
  pageKey,
87
159
  defaultLayouts,
88
160
  defaultCols,
@@ -91,6 +163,52 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
91
163
  editorEventNames,
92
164
  preferenceAdapter,
93
165
  });
166
+ // Auto-height dispatcher (blueprint amendment A1, hardened per quality
167
+ // review 2026-07-14). `GridItem`'s ResizeObserver calls this per-widget
168
+ // whenever its measured content height changes; we rAF-batch and flush ALL
169
+ // pending widgets in ONE `applyAutoHeightRows` call — a single functional
170
+ // state update, so two cards settling in the same frame (e.g. card2+card3
171
+ // on /people/:id mount) can never last-writer-wins each other. Measured
172
+ // rows are DERIVED, in-memory-only state: they merge into `activeLayouts`
173
+ // below for ALL viewers and are never persisted (see usePageGridLayout).
174
+ // Updates are suppressed entirely while the user is mid-drag/mid-resize so
175
+ // auto-height never fights a manual gesture. A suppressed update is
176
+ // queued, not dropped — the drag/resize *Stop handlers flush whatever's
177
+ // pending the moment the gesture ends.
178
+ const isInteractingRef = useRef(false);
179
+ const pendingAutoHeightRef = useRef(new Map());
180
+ const autoHeightFrameRef = useRef(null);
181
+ const scheduleFrame = useCallback((callback) => {
182
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
183
+ return window.requestAnimationFrame(callback);
184
+ }
185
+ return setTimeout(callback, 0);
186
+ }, []);
187
+ const flushAutoHeightUpdates = useCallback(() => {
188
+ autoHeightFrameRef.current = null;
189
+ if (isInteractingRef.current)
190
+ return;
191
+ const pending = pendingAutoHeightRef.current;
192
+ if (pending.size === 0)
193
+ return;
194
+ pendingAutoHeightRef.current = new Map();
195
+ applyAutoHeightRows(Object.fromEntries(pending));
196
+ }, [applyAutoHeightRows]);
197
+ const handleAutoHeightChange = useCallback((widgetKey, rows) => {
198
+ pendingAutoHeightRef.current.set(widgetKey, rows);
199
+ if (isInteractingRef.current || autoHeightFrameRef.current !== null)
200
+ return;
201
+ autoHeightFrameRef.current = scheduleFrame(flushAutoHeightUpdates);
202
+ }, [flushAutoHeightUpdates, scheduleFrame]);
203
+ const handleInteractionStart = useCallback(() => {
204
+ isInteractingRef.current = true;
205
+ }, []);
206
+ const handleInteractionStop = useCallback(() => {
207
+ isInteractingRef.current = false;
208
+ if (pendingAutoHeightRef.current.size > 0 && autoHeightFrameRef.current === null) {
209
+ autoHeightFrameRef.current = scheduleFrame(flushAutoHeightUpdates);
210
+ }
211
+ }, [flushAutoHeightUpdates, scheduleFrame]);
94
212
  const resizeEnabled = isEditing && isResizable;
95
213
  const resizeConstraints = useMemo(() => [
96
214
  // `gridBounds` caps width at the active column count, so we don't need
@@ -123,10 +241,32 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
123
241
  const activeLayouts = useMemo(() => {
124
242
  const filtered = { lg: [] };
125
243
  for (const bp in currentLayouts) {
126
- filtered[bp] = (currentLayouts[bp] ?? []).filter((item) => renderableWidgetKeys.has(item.i) && !hiddenLayerIds[item.i]);
244
+ filtered[bp] = (currentLayouts[bp] ?? [])
245
+ .filter((item) => renderableWidgetKeys.has(item.i) && !hiddenLayerIds[item.i])
246
+ // autoHeight items are measured, not manually resized (blueprint
247
+ // amendment A1) — force isResizable: false centrally here so every
248
+ // call site that sets autoHeight: true gets this for free, rather
249
+ // than needing to remember to also set isResizable itself.
250
+ //
251
+ // Measured rows (quality-review design ruling, 2026-07-14) merge
252
+ // over the saved/base `h`/`minH` HERE — the render layer — so the
253
+ // grid renders full-height cards for every viewer while the saved
254
+ // layout (and thus the preference adapter) never sees a measured
255
+ // height. Until the first measurement lands, `autoHeightRows` has
256
+ // no entry and the item's seed `h` renders as-is.
257
+ .map((item) => {
258
+ if (!item.autoHeight)
259
+ return item;
260
+ const measuredRows = autoHeightRows[item.i];
261
+ return {
262
+ ...item,
263
+ isResizable: false,
264
+ ...(measuredRows !== undefined ? { h: measuredRows, minH: measuredRows } : {}),
265
+ };
266
+ });
127
267
  }
128
268
  return filtered;
129
- }, [currentLayouts, hiddenLayerIds, renderableWidgetKeys]);
269
+ }, [autoHeightRows, currentLayouts, hiddenLayerIds, renderableWidgetKeys]);
130
270
  const hideLayer = (layerId) => {
131
271
  setHiddenLayerIds((previous) => ({ ...previous, [layerId]: true }));
132
272
  };
@@ -295,7 +435,7 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
295
435
  }) })] })), layerItems.length > 0 && (_jsxs("div", { className: "basis-full rounded-md border border-border bg-background/80 p-2", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), "Layers"] }), _jsx("div", { className: "flex flex-col gap-1", children: layerItems.map((layer) => (_jsxs("div", { className: "flex items-center gap-1 rounded border border-border bg-card px-2 py-1", children: [_jsx("input", { type: "text", value: layer.label, onChange: (event) => handleLayerRename(layer.id, event.target.value), className: "min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none", "aria-label": `Rename ${layer.label}` }), _jsx("button", { type: "button", onClick: () => moveWidget(layer.id, 'up'), disabled: !layer.canMoveUp, "aria-label": `Move ${layer.label} up`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40", children: _jsx(ArrowUp, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => moveWidget(layer.id, 'down'), disabled: !layer.canMoveDown, "aria-label": `Move ${layer.label} down`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40", children: _jsx(ArrowDown, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => setWidgetLocked(layer.id, !layer.locked), "aria-label": `${layer.locked ? 'Unlock' : 'Lock'} ${layer.label}`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: layer.locked ? _jsx(Lock, { className: "h-3.5 w-3.5" }) : _jsx(Unlock, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => hideLayer(layer.id), "aria-label": `Hide ${layer.label}`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: _jsx(EyeOff, { className: "h-3.5 w-3.5" }) })] }, layer.id))) })] })), _jsxs("button", { type: "button", onClick: handleCompact, className: cn('ml-auto inline-flex items-center rounded-md border border-border px-2 py-1 text-sm transition-colors', 'text-muted-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(Layers, { className: "h-4 w-4 mr-1" }), "Compact Layout"] }), _jsxs("button", { type: "button", onClick: () => setResetConfirmOpen(true), className: cn('inline-flex items-center rounded-md border border-border px-2 py-1 text-sm transition-colors', 'text-muted-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(RotateCcw, { className: "h-4 w-4 mr-1" }), "Reset to Default"] })] })] })), resetConfirmOpen && (_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4", role: "dialog", "aria-modal": "true", "aria-labelledby": "page-grid-reset-title", "aria-describedby": "page-grid-reset-description", children: _jsxs("div", { className: "max-w-md rounded-lg bg-card p-5 shadow-lg border border-border", children: [_jsx("h2", { id: "page-grid-reset-title", className: "text-lg font-semibold", children: "Reset page layout?" }), _jsx("p", { id: "page-grid-reset-description", className: "mt-2 text-sm text-muted-foreground", children: "This will discard your current layout and restore all widgets to their default positions." }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { type: "button", ref: resetCancelButtonRef, onClick: () => setResetConfirmOpen(false), className: cn('inline-flex items-center rounded-md border border-border px-3 py-2 text-sm transition-colors', 'bg-card text-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: "Cancel" }), _jsx("button", { type: "button", ref: resetConfirmButtonRef, onClick: () => {
296
436
  handleReset();
297
437
  setResetConfirmOpen(false);
298
- }, className: cn('inline-flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors', 'bg-destructive text-white shadow hover:bg-destructive/90', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-1'), children: "Reset to Default" })] })] }) })), _jsx("div", { ref: containerRef, className: cn('relative z-0 overflow-visible', isEditing && 'min-h-[200px] bg-muted/30'), "data-page-grid-editing": isEditing || undefined, children: _jsx("div", { className: "relative z-0", style: { opacity: containerWidth > 0 ? 1 : 0 }, "aria-busy": containerWidth <= 0, children: _jsx(Responsive, { width: Math.max(containerWidth, 1), className: "layout page-grid-canvas", layouts: activeLayouts, breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }, rowHeight: 32, onLayoutChange: onLayoutChange, dragConfig: {
438
+ }, className: cn('inline-flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors', 'bg-destructive text-white shadow hover:bg-destructive/90', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-1'), children: "Reset to Default" })] })] }) })), _jsx("div", { ref: containerRef, className: cn('relative z-0 overflow-visible', isEditing && 'min-h-[200px] bg-muted/30'), "data-page-grid-editing": isEditing || undefined, children: _jsx("div", { className: "relative z-0", style: { opacity: containerWidth > 0 ? 1 : 0 }, "aria-busy": containerWidth <= 0, children: _jsx(Responsive, { width: Math.max(containerWidth, 1), className: "layout page-grid-canvas", layouts: activeLayouts, breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }, rowHeight: DEFAULT_ROW_HEIGHT, onLayoutChange: onLayoutChange, dragConfig: {
299
439
  enabled: isEditing,
300
440
  handle: '.drag-handle',
301
441
  bounded: false,
@@ -305,10 +445,15 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
305
445
  handles: resizeHandles,
306
446
  // Required for v2 — see RGLResizeHandle comment above.
307
447
  handleComponent: (axis, ref) => (_jsx(RGLResizeHandle, { axis: axis, ref: ref })),
308
- }, constraints: resizeEnabled ? resizeConstraints : undefined, compactor: activeCompactor, cols: activeCols, margin: [6, 6], children: activeLayouts.lg.map((layoutItem) => {
448
+ }, constraints: resizeEnabled ? resizeConstraints : undefined, compactor: activeCompactor, cols: activeCols, margin: DEFAULT_MARGIN,
449
+ // Suppress auto-height writes mid-gesture (blueprint amendment
450
+ // A1) — coexists with dragConfig/resizeConfig above; RGL v2's
451
+ // grouped config props and top-level *Start/*Stop callbacks are
452
+ // independent, not mutually exclusive.
453
+ onDragStart: handleInteractionStart, onDragStop: handleInteractionStop, onResizeStart: handleInteractionStart, onResizeStop: handleInteractionStop, children: activeLayouts.lg.map((layoutItem) => {
309
454
  const content = allWidgets[layoutItem.i];
310
455
  if (content === undefined || content === null)
311
456
  return null;
312
- return (_jsx(GridItem, { id: layoutItem.i, content: content, isEditing: isEditing, label: layerNames[layoutItem.i] || widgetMeta?.[layoutItem.i]?.label || layoutItem.i, onHide: hideLayer }, layoutItem.i));
457
+ return (_jsx(GridItem, { id: layoutItem.i, content: content, isEditing: isEditing, label: layerNames[layoutItem.i] || widgetMeta?.[layoutItem.i]?.label || layoutItem.i, onHide: hideLayer, autoHeight: layoutItem.autoHeight, onAutoHeightChange: handleAutoHeightChange }, layoutItem.i));
313
458
  }) }) }) })] }));
314
459
  }
@@ -0,0 +1,49 @@
1
+ export interface ComputeAutoHeightRowsOptions {
2
+ /**
3
+ * Measured intrinsic content height in pixels. Callers MUST measure an
4
+ * UNCONSTRAINED wrapper around the card content (see `GridItem`'s
5
+ * ResizeObserver in `PageGridLayout.tsx`) — `overflow: hidden` on an
6
+ * ancestor clips *painting* only, it does not shrink a descendant's own
7
+ * computed box height, so an unconstrained wrapper reports the content's
8
+ * true size regardless of the grid item's current allocated pixel height.
9
+ * That property is what makes this function converge to a fixed point
10
+ * (see the "idempotent" test below) instead of oscillating: the same
11
+ * content always measures the same, no matter how many rows the item
12
+ * currently occupies.
13
+ */
14
+ contentPx: number;
15
+ /**
16
+ * Fixed non-content chrome (e.g. the card's top+bottom border added by
17
+ * the unconditional `border border-border` — blueprint item 1) added
18
+ * before converting to rows, so rounding/border pixels never clip
19
+ * content by a sub-row amount.
20
+ */
21
+ cardChromePx: number;
22
+ /**
23
+ * Must match the `rowHeight` passed to `<Responsive>`. Single source of
24
+ * truth: `DEFAULT_ROW_HEIGHT` in `PageGridLayout.tsx`.
25
+ */
26
+ rowHeightPx: number;
27
+ /**
28
+ * Must match the vertical component of the `margin` tuple passed to
29
+ * `<Responsive>`. Single source of truth: `DEFAULT_MARGIN` in
30
+ * `PageGridLayout.tsx`.
31
+ */
32
+ marginYPx: number;
33
+ /**
34
+ * Row-count floor. Defaults to 1 — a widget can never auto-collapse to 0
35
+ * rows even if content momentarily measures 0px (e.g. before first paint).
36
+ */
37
+ minRows?: number;
38
+ }
39
+ /**
40
+ * Pure px→row conversion for auto-height grid items (blueprint amendment
41
+ * A1). Deliberately uses `Math.ceil`, NOT react-grid-layout's own internal
42
+ * `Math.round` convention (see `calcWH` in react-grid-layout's compiled
43
+ * source) — rounding down would silently clip content roughly half the
44
+ * time. Ceiling always over-allocates rather than under-allocates, trading
45
+ * a few pixels of empty card padding for a guarantee that content is never
46
+ * cut off.
47
+ */
48
+ export declare function computeAutoHeightRows({ contentPx, cardChromePx, rowHeightPx, marginYPx, minRows, }: ComputeAutoHeightRowsOptions): number;
49
+ //# sourceMappingURL=autoHeight.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"autoHeight.d.ts","sourceRoot":"","sources":["../src/autoHeight.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,4BAA4B;IAC3C;;;;;;;;;;;OAWG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,YAAY,EACZ,WAAW,EACX,SAAS,EACT,OAAW,GACZ,EAAE,4BAA4B,GAAG,MAAM,CAKvC"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pure px→row conversion for auto-height grid items (blueprint amendment
3
+ * A1). Deliberately uses `Math.ceil`, NOT react-grid-layout's own internal
4
+ * `Math.round` convention (see `calcWH` in react-grid-layout's compiled
5
+ * source) — rounding down would silently clip content roughly half the
6
+ * time. Ceiling always over-allocates rather than under-allocates, trading
7
+ * a few pixels of empty card padding for a guarantee that content is never
8
+ * cut off.
9
+ */
10
+ export function computeAutoHeightRows({ contentPx, cardChromePx, rowHeightPx, marginYPx, minRows = 1, }) {
11
+ const rowUnitPx = rowHeightPx + marginYPx;
12
+ if (rowUnitPx <= 0)
13
+ return minRows;
14
+ const raw = Math.ceil((contentPx + cardChromePx) / rowUnitPx);
15
+ return Math.max(minRows, raw);
16
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { PageGridLayout } from './PageGridLayout.js';
2
2
  export { usePageGridLayout, DEFAULT_EDITOR_EVENT_NAMES, PAGE_GRID_EDITING_EVENT, } from './usePageGridLayout.js';
3
3
  export type { PageGridEditingEventDetail } from './usePageGridLayout.js';
4
4
  export { rescaleLayout } from './rescaleLayout.js';
5
+ export { computeAutoHeightRows } from './autoHeight.js';
6
+ export type { ComputeAutoHeightRowsOptions } from './autoHeight.js';
5
7
  export { WYSIWYG_REQUIRED_PRIMITIVES, WYSIWYG_SURFACE_CONTRACTS, assertWysiwygPrimitiveCoverage, getWysiwygSurfaceContract, } from './wysiwygContract.js';
6
8
  export type { WysiwygPrimitive, WysiwygSurfaceContract, WysiwygSurfaceId, } from './wysiwygContract.js';
7
9
  export { buildResponsiveLayouts, RESPONSIVE_BREAKPOINTS, } from './buildResponsiveLayouts.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,iBAAiB,EACjB,0BAA0B,EAC1B,uBAAuB,GACxB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EACV,6BAA6B,EAC7B,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAChF,YAAY,EACV,kBAAkB,EAClB,0BAA0B,EAC1B,cAAc,EACd,WAAW,EACX,mBAAmB,EACnB,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,uBAAuB,EACvB,UAAU,GACX,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,iBAAiB,EACjB,0BAA0B,EAC1B,uBAAuB,GACxB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxD,YAAY,EAAE,4BAA4B,EAAE,MAAM,iBAAiB,CAAC;AACpE,OAAO,EACL,2BAA2B,EAC3B,yBAAyB,EACzB,8BAA8B,EAC9B,yBAAyB,GAC1B,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,gBAAgB,EAChB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EACV,6BAA6B,EAC7B,oBAAoB,GACrB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAChF,YAAY,EACV,kBAAkB,EAClB,0BAA0B,EAC1B,cAAc,EACd,WAAW,EACX,mBAAmB,EACnB,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,uBAAuB,EACvB,UAAU,GACX,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { PageGridLayout } from './PageGridLayout.js';
2
2
  export { usePageGridLayout, DEFAULT_EDITOR_EVENT_NAMES, PAGE_GRID_EDITING_EVENT, } from './usePageGridLayout.js';
3
3
  export { rescaleLayout } from './rescaleLayout.js';
4
+ export { computeAutoHeightRows } from './autoHeight.js';
4
5
  export { WYSIWYG_REQUIRED_PRIMITIVES, WYSIWYG_SURFACE_CONTRACTS, assertWysiwygPrimitiveCoverage, getWysiwygSurfaceContract, } from './wysiwygContract.js';
5
6
  export { buildResponsiveLayouts, RESPONSIVE_BREAKPOINTS, } from './buildResponsiveLayouts.js';
6
7
  export { useLocalPreference, defaultPreferenceAdapter } from './preferences.js';
package/dist/types.d.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import type React from 'react';
2
2
  import type { Compactor, LayoutItem, ResizeHandleAxis } from 'react-grid-layout';
3
- export type GridLayoutItem = LayoutItem;
3
+ export interface GridLayoutItem extends LayoutItem {
4
+ /**
5
+ * When true, this item's height is driven by its own measured content
6
+ * height rather than manual resize (blueprint amendment A1). `GridItem`
7
+ * mounts a `ResizeObserver` on an unconstrained content wrapper and
8
+ * reports the computed row count upward via `computeAutoHeightRows`;
9
+ * `PageGridLayout` force-sets `isResizable: false` for any item with
10
+ * `autoHeight: true` (see the `activeLayouts` memo) so callers don't need
11
+ * to remember to set both fields.
12
+ */
13
+ autoHeight?: boolean;
14
+ }
4
15
  export interface GridLayouts {
5
16
  lg: GridLayoutItem[];
6
17
  [key: string]: GridLayoutItem[];
@@ -67,6 +78,21 @@ export interface UsePageGridLayoutResult {
67
78
  moveWidget: (widgetKey: string, direction: 'up' | 'down') => void;
68
79
  setWidgetLocked: (widgetKey: string, locked: boolean) => void;
69
80
  removeWidget: (widgetKey: string) => void;
81
+ /**
82
+ * Applies a batch of measured auto-height row counts (widgetKey -> rows)
83
+ * in a SINGLE functional state update. Measured heights are DERIVED,
84
+ * in-memory-only state: they are merged over the saved layout when
85
+ * producing the layouts handed to react-grid-layout (so every viewer
86
+ * renders full-height cards) and are NEVER written to the preference
87
+ * adapter — measurements re-derive on every mount, so persisting them
88
+ * would be redundant and would cause storage writes from mere viewing
89
+ * (e.g. switching tabs inside a card). Diff-guarded: returns the previous
90
+ * map identity when nothing changed, so no re-render occurs.
91
+ */
92
+ applyAutoHeightRows: (rowsByWidget: Record<string, number>) => void;
93
+ /** Current measured auto-height overrides (widgetKey -> rows). In-memory
94
+ * only; see `applyAutoHeightRows`. */
95
+ autoHeightRows: Record<string, number>;
70
96
  resetConfirmOpen: boolean;
71
97
  setResetConfirmOpen: React.Dispatch<React.SetStateAction<boolean>>;
72
98
  canEditPage: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"usePageGridLayout.d.ts","sourceRoot":"","sources":["../src/usePageGridLayout.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAGV,wBAAwB,EACxB,uBAAuB,EACxB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,0BAA0B,2IAM7B,CAAC;AAEX;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,6BAA6B,CAAC;AAElE,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,cAAc,EACd,WAAgB,EAChB,aAAiB,EACjB,WAAkB,EAClB,gBAA6C,EAC7C,iBAA4C,GAC7C,EAAE,wBAAwB,GAAG,uBAAuB,CA8UpD"}
1
+ {"version":3,"file":"usePageGridLayout.d.ts","sourceRoot":"","sources":["../src/usePageGridLayout.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAGV,wBAAwB,EACxB,uBAAuB,EACxB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,0BAA0B,2IAM7B,CAAC;AAEX;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,6BAA6B,CAAC;AAElE,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,cAAc,EACd,WAAgB,EAChB,aAAiB,EACjB,WAAkB,EAClB,gBAA6C,EAC7C,iBAA4C,GAC7C,EAAE,wBAAwB,GAAG,uBAAuB,CA8ZpD"}
@@ -141,6 +141,40 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
141
141
  */
142
142
  const pendingLayoutRef = useRef(null);
143
143
  const layoutCommitFrameRef = useRef(null);
144
+ /**
145
+ * Strips DERIVED measured heights out of a layout before it reaches the
146
+ * preference adapter. react-grid-layout is rendered with `autoHeightRows`
147
+ * merged over the saved layout (see `applyAutoHeightRows`), so the layouts
148
+ * it echoes back through `onLayoutChange` carry the MEASURED `h` for
149
+ * autoHeight items — persisting that verbatim would smuggle measured
150
+ * heights into user_preferences through the drag/resize path and drop the
151
+ * `autoHeight` flag (react-grid-layout does not round-trip custom item
152
+ * props). Restore `h`/`minH`/`autoHeight` from the un-merged base layout;
153
+ * user-driven `x`/`y`/`w` (and `h` for non-autoHeight items) pass through
154
+ * untouched. Reads the base via a ref so the trailing-rAF commit below
155
+ * never closes over a stale snapshot.
156
+ */
157
+ const currentLayoutsForStripRef = useRef(null);
158
+ const stripAutoHeightRows = useCallback((layouts) => {
159
+ const base = currentLayoutsForStripRef.current;
160
+ if (!base)
161
+ return layouts;
162
+ const result = { lg: [] };
163
+ for (const bp of Object.keys(layouts)) {
164
+ const baseItems = base[bp] ?? base.lg ?? [];
165
+ const baseByKey = new Map(baseItems.map((item) => [item.i, item]));
166
+ result[bp] = (layouts[bp] ?? []).map((item) => {
167
+ const baseItem = baseByKey.get(item.i);
168
+ if (!baseItem?.autoHeight)
169
+ return item;
170
+ return { ...item, autoHeight: true, h: baseItem.h, minH: baseItem.minH };
171
+ });
172
+ }
173
+ return result;
174
+ }, []);
175
+ useEffect(() => {
176
+ currentLayoutsForStripRef.current = currentLayouts;
177
+ }, [currentLayouts]);
144
178
  const onLayoutChange = useCallback((_layout, layouts) => {
145
179
  if (!isEditing)
146
180
  return;
@@ -148,7 +182,7 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
148
182
  if (layoutCommitFrameRef.current !== null)
149
183
  return;
150
184
  if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
151
- setSavedLayout(layouts);
185
+ setSavedLayout(stripAutoHeightRows(layouts));
152
186
  return;
153
187
  }
154
188
  // Trailing rAF — lets a burst of resize ticks coalesce into one commit.
@@ -157,10 +191,10 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
157
191
  const pending = pendingLayoutRef.current;
158
192
  if (pending) {
159
193
  pendingLayoutRef.current = null;
160
- setSavedLayout(pending);
194
+ setSavedLayout(stripAutoHeightRows(pending));
161
195
  }
162
196
  });
163
- }, [isEditing, setSavedLayout]);
197
+ }, [isEditing, setSavedLayout, stripAutoHeightRows]);
164
198
  useEffect(() => {
165
199
  return () => {
166
200
  if (layoutCommitFrameRef.current !== null &&
@@ -267,6 +301,37 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
267
301
  }
268
302
  startTransition(() => setSavedLayout(updated));
269
303
  }, [currentLayouts, setSavedLayout]);
304
+ /**
305
+ * Measured auto-height overrides (widgetKey -> rows). DERIVED, in-memory-
306
+ * only state (quality-review design ruling, 2026-07-14):
307
+ *
308
+ * - Applied for ALL viewers — the map is merged over the saved layout when
309
+ * producing the layouts handed to react-grid-layout (see
310
+ * `PageGridLayout`'s `activeLayouts` memo), so read-only users get
311
+ * full-height cards too.
312
+ * - NEVER persisted — not even while editing. Measurements re-derive on
313
+ * every mount; writing them to the preference adapter would be redundant
314
+ * AND would turn mere viewing (e.g. Radix tab switches inside a card,
315
+ * which unmount/remount panel content and fire the ResizeObserver) into
316
+ * storage upserts for any authenticated viewer.
317
+ * - Updated FUNCTIONALLY in one combined state update per flush, so two
318
+ * cards settling in the same animation frame can never last-writer-wins
319
+ * each other through a stale closure.
320
+ */
321
+ const [autoHeightRows, setAutoHeightRows] = useState({});
322
+ const applyAutoHeightRows = useCallback((rowsByWidget) => {
323
+ setAutoHeightRows((previous) => {
324
+ let changed = false;
325
+ const next = { ...previous };
326
+ for (const [widgetKey, rows] of Object.entries(rowsByWidget)) {
327
+ if (next[widgetKey] !== rows) {
328
+ next[widgetKey] = rows;
329
+ changed = true;
330
+ }
331
+ }
332
+ return changed ? next : previous;
333
+ });
334
+ }, []);
270
335
  useEffect(() => {
271
336
  if (!canEditPage || typeof window === 'undefined')
272
337
  return;
@@ -274,7 +339,16 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
274
339
  const detail = event.detail;
275
340
  if (!detail?.page || detail.page === pageKey) {
276
341
  detail?.ack?.();
277
- setIsEditing(true);
342
+ // `detail.editing` defaults to `true` when omitted — every existing
343
+ // dispatcher across all 6 apps only ever sent `{ page, path }` to
344
+ // OPEN the editor, so this preserves 100% backward compatibility.
345
+ // An explicit `editing: false` lets a launcher force-close a grid
346
+ // instance that's still mounted after an in-route navigation (e.g.
347
+ // wouter's `/people/:id` not remounting between different ids) —
348
+ // see crm7's `PageEditorLauncher.tsx` navigation-close effect
349
+ // (blueprint amendment A3). Reuses this exact event channel rather
350
+ // than introducing a second one.
351
+ setIsEditing(detail?.editing ?? true);
278
352
  }
279
353
  };
280
354
  for (const eventName of editorEventNames) {
@@ -285,7 +359,7 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
285
359
  window.removeEventListener(eventName, handler);
286
360
  }
287
361
  };
288
- }, [canEditPage, editorEventNames, pageKey]);
362
+ }, [canEditPage, editorEventNames, pageKey, setIsEditing]);
289
363
  return {
290
364
  currentLayouts,
291
365
  layoutCols,
@@ -301,6 +375,8 @@ export function usePageGridLayout({ pageKey, defaultLayouts, defaultCols = 12, l
301
375
  moveWidget,
302
376
  setWidgetLocked,
303
377
  removeWidget,
378
+ applyAutoHeightRows,
379
+ autoHeightRows,
304
380
  resetConfirmOpen,
305
381
  setResetConfirmOpen,
306
382
  canEditPage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsuite/page-builder",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "Shared BSuite responsive page-builder grid and layout persistence primitives",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -32,6 +32,7 @@
32
32
  "prepublishOnly": "pnpm build"
33
33
  },
34
34
  "peerDependencies": {
35
+ "@bsuite/theme": ">=0.5.0",
35
36
  "@dnd-kit/core": "^6",
36
37
  "@dnd-kit/sortable": "^10",
37
38
  "@dnd-kit/utilities": "^3",
@@ -42,6 +43,9 @@
42
43
  "react-resizable": "^3 || ^4"
43
44
  },
44
45
  "peerDependenciesMeta": {
46
+ "@bsuite/theme": {
47
+ "optional": true
48
+ },
45
49
  "@dnd-kit/core": {
46
50
  "optional": true
47
51
  },