diff --git a/README.md b/README.md
index 1fa0241b..c6b2f0a9 100755
--- a/README.md
+++ b/README.md
@@ -186,6 +186,20 @@ Each node type has a defined set of required and optional attributes.
Weights on rows and tabsets specify their relative size within the parent row. The absolute values do not matter, only their proportions (e.g., two tabsets with weights 30 and 70 would render the same as if they had weights 3 and 7).
+Rows and tabsets can instead retain a chosen pixel size with `preferredWidth` or `preferredHeight`. For example, set `preferredWidth: 240` on a left tabset and `preferredWidth: 280` on a right tabset, leaving the middle tabset without a preferred width. The side panels keep their widths as the window resizes or a sibling closes, floats, or moves into another tabset; the middle panel fills the remaining space. Multiple flexible siblings share that space by weight. See [the preferred sizes example](examples/preferred-sizes/PreferredSizes.tsx) for a nested workspace with a retained-height console.
+
+Weight and preferred sizes can coexist. A preferred size takes precedence over weight on the parent row's split axis: `preferredWidth` for a horizontal row, `preferredHeight` for a vertical row. Weight remains stored and splitters continue to update it; weight controls sizing whenever no explicit or derived preference applies on that axis. For example, `{ weight: 30, preferredWidth: 240 }` requests 240 pixels in a horizontal parent row and uses weight in a vertical parent row without a preferred height. Every row and tabset has a default weight of 100 even if omitted from JSON, so setting a preference does not require removing weight.
+
+Preferred sizes describe the outer panel dimensions, including its tabstrip/header, and apply on the parent row's split axis. An explicit preference on a row sizes the whole group; otherwise a row derives its preference when all its children retain a size on that axis. Root rows, sole children and maximized tabsets fill their container. Without a flexible sibling, preferred panels can leave unused space.
+
+Dragging a splitter updates the affected preferred sizes. Minimum and maximum constraints still apply: panels may shrink when space is insufficient and recover their preferred sizes when it becomes available, without overwriting the saved preferences. While compressed, splitter movement adjusts the preference by the drag amount; constraints can limit the visible movement. Preferences are saved by `model.toJson()`, can be changed with `Actions.updateNodeAttributes`, and belong to the row or tabset rather than individual tabs. Moving tabs into an existing tabset uses the destination's sizing policy.
+
+Moving a whole tabset carries its preferences. Moving an individual tab into a new tabset does not copy its source tabset's preferences; the new tabset is flexible unless attributes supplied during creation give it a preference. Docking beside a tabset on the parent row's split axis preserves the target's preferred size even though its weight is split. Docking on the other axis transfers the target's preference for the parent axis to the new containing row. Resizing that row updates the group's preference, leaving each child tabset's own preferences intact. The group keeps its preference even if only one child remains; moving that tabset out of the group carries its own saved preference, which can differ from the group's visible size.
+
+If any child of a row has an explicit or derived preference on its split axis, splitter actions arrive at `onAction` and `onModelChange` as an `Actions.GROUP` (`GroupAction`) containing an `ADJUST_WEIGHTS` action and `UPDATE_NODE_ATTRIBUTES` actions for the preferences. Callbacks that detect splitter changes must inspect `GroupAction.actions` as well as bare `ADJUST_WEIGHTS` actions. Accept or cancel the whole group in `onAction` so weight and preference updates remain together. During live resizing, `isAdjusting()` is true on the group; the final action has it set to false. Weight-only row splitters still emit bare `ADJUST_WEIGHTS` actions.
+
+Live preferred-size updates use the same direct DOM sizing and measurement path as weight updates, including refreshing derived ancestor preferences, with a full redraw on drag completion or when the optimization cannot apply.
+
NOTE: The easiest way to create your initial layout JSON is to use the [demo](https://caplin.github.io/FlexLayout/demos/v0.11/demo/index.html) app. Modify an existing layout by dragging, dropping, and adding nodes, then press the 'print' button to print the JSON to the browser's developer console. Use the Model Explorer panel to view and modify the attributes of the layout and its nodes, and use the render dropdown in the demo to blank out the panels (other than the Model Explorer) so you can focus on the layout.
By changing global or node attributes, you can modify the layout's appearance and functionality. For example, setting `tabSetEnableTabStrip: false` in the global options would change the layout into a multi-splitter (without tabs or drag-and-drop):
diff --git a/examples/index.html b/examples/index.html
index 90238aa7..962a111e 100644
--- a/examples/index.html
+++ b/examples/index.html
@@ -42,6 +42,7 @@
FlexLayout Examples
Tabset Rendering — customizing the tabset header via onRenderTabSet
Placeholder — a custom placeholder when a tabset is empty
Min Sizes — panels with a 200px minimum size
+ Preferred Sizes — adjustable auxiliary panels retain pixel sizes while the workspace fills the remaining space
Tab Wrapping — wrapping tabs onto multiple lines
Pinned Tabs — tabs pinned to the start of the tabstrip
Tab Groups — Chrome-style colored group pills in the tabstrip
diff --git a/examples/preferred-sizes/PreferredSizes.tsx b/examples/preferred-sizes/PreferredSizes.tsx
new file mode 100644
index 00000000..06181f68
--- /dev/null
+++ b/examples/preferred-sizes/PreferredSizes.tsx
@@ -0,0 +1,104 @@
+import { createRoot } from "react-dom/client";
+import { Actions, DockLocation, IJsonModel, Layout, Model, TabNode, useUndo } from "../../src/index";
+import "../../style/combined.scss";
+import { ExampleHeader } from "../Header";
+import sourceCode from "./PreferredSizes.tsx?raw";
+
+const json: IJsonModel = {
+ global: { tabEnableFloat: true },
+ layout: {
+ type: "row",
+ id: "root",
+ children: [
+ {
+ type: "tabset",
+ id: "explorer",
+ preferredWidth: 240,
+ minWidth: 140,
+ children: [{ type: "tab", id: "explorer-tab", name: "Explorer", component: "explorer" }],
+ },
+ {
+ type: "row",
+ children: [
+ {
+ type: "tabset",
+ id: "workspace",
+ minWidth: 120,
+ minHeight: 80,
+ children: [{ type: "tab", id: "workspace-tab", name: "Workspace", component: "workspace" }],
+ },
+ {
+ type: "tabset",
+ id: "console",
+ preferredHeight: 180,
+ minHeight: 80,
+ children: [{ type: "tab", id: "console-tab", name: "Console", component: "console" }],
+ },
+ ],
+ },
+ {
+ type: "tabset",
+ id: "inspector",
+ preferredWidth: 280,
+ minWidth: 160,
+ children: [{ type: "tab", id: "inspector-tab", name: "Inspector", component: "inspector" }],
+ },
+ ],
+ },
+};
+
+const factory = (node: TabNode) => {
+ const descriptions: Record = {
+ explorer: "Starts at 240px wide. Its width stays the same when the inspector closes, floats, or joins the workspace.",
+ workspace: "Takes the remaining width and height. Resize the window or remove an auxiliary panel to see it grow.",
+ console: "Starts at 180px high. Drag its splitter to choose a new height, then resize the window.",
+ inspector: "Starts at 280px wide. Use the buttons above to close it, float it, or move its tab into the workspace.",
+ };
+ const description = descriptions[node.getComponent() ?? ""];
+ return description ? (
+
+
{node.getName()}
+
{description}
+
+ ) : undefined;
+};
+
+function PreferredSizes() {
+ const { model, setModel, undo, redo, canUndo, canRedo } = useUndo(() => Model.fromJson(json));
+ if (!model) {
+ return null;
+ }
+ const inspectorIsDocked = model.getNodeById("inspector")?.getParent()?.getId() === "root";
+ return (
+
+
+
+ model.doAction(Actions.deleteTabset("inspector"))} disabled={!inspectorIsDocked}>
+ Close inspector
+
+ model.doAction(Actions.popoutTabset("inspector", "float"))} disabled={!inspectorIsDocked}>
+ Float inspector
+
+ model.doAction(Actions.moveNode("inspector", "workspace", DockLocation.CENTER, -1))} disabled={!inspectorIsDocked || !model.getNodeById("workspace")}>
+ Join workspace
+
+
+ Undo
+
+
+ Redo
+
+ setModel(Model.fromJson(json))}>Reset
+
+
Drag the splitters to choose panel sizes. Those choices are retained when the window or layout changes.
+
+
+
+
+ );
+}
+
+const container = document.getElementById("container");
+if (container) {
+ createRoot(container).render( );
+}
diff --git a/examples/preferred-sizes/index.html b/examples/preferred-sizes/index.html
new file mode 100644
index 00000000..4716fcae
--- /dev/null
+++ b/examples/preferred-sizes/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+ FlexLayout Example: Preferred Sizes
+
+
+
+
+
+
+
diff --git a/examples/vite.config.examples.ts b/examples/vite.config.examples.ts
index 19d234e1..6c13660e 100644
--- a/examples/vite.config.examples.ts
+++ b/examples/vite.config.examples.ts
@@ -28,6 +28,7 @@ export default defineConfig({
"tabset-rendering": fileURLToPath(new URL("tabset-rendering/index.html", import.meta.url)),
"context-menu": fileURLToPath(new URL("context-menu/index.html", import.meta.url)),
"min-sizes": fileURLToPath(new URL("min-sizes/index.html", import.meta.url)),
+ "preferred-sizes": fileURLToPath(new URL("preferred-sizes/index.html", import.meta.url)),
"pinned-tabs": fileURLToPath(new URL("pinned-tabs/index.html", import.meta.url)),
"sticky-button": fileURLToPath(new URL("sticky-button/index.html", import.meta.url)),
"tab-groups": fileURLToPath(new URL("tab-groups/index.html", import.meta.url)),
diff --git a/src/model/IJsonModel.ts b/src/model/IJsonModel.ts
index 300fc0e6..a892028c 100755
--- a/src/model/IJsonModel.ts
+++ b/src/model/IJsonModel.ts
@@ -562,6 +562,20 @@ export interface IRowAttributes {
*/
id?: string;
+ /**
+ preferred outer height in pixels; takes precedence over weight in a vertical parent row without removing the weight; splitters update it, while passive resizing preserves it
+
+ Default: undefined
+ */
+ preferredHeight?: number;
+
+ /**
+ preferred outer width in pixels; takes precedence over weight in a horizontal parent row without removing the weight; splitters update it, while passive resizing preserves it
+
+ Default: undefined
+ */
+ preferredWidth?: number;
+
/**
@@ -570,7 +584,7 @@ export interface IRowAttributes {
type?: string;
/**
- relative weight for sizing of this row in parent row
+ relative weight for sizing of this row in parent row when no explicit or derived preferred size applies on the parent's split axis; retained and updated by splitters even when a preferred size takes precedence
Default: 100
*/
@@ -731,6 +745,20 @@ export interface ITabSetAttributes {
*/
name?: string;
+ /**
+ preferred outer height in pixels; takes precedence over weight in a vertical parent row without removing the weight; splitters update it, while passive resizing preserves it
+
+ Default: undefined
+ */
+ preferredHeight?: number;
+
+ /**
+ preferred outer width in pixels; takes precedence over weight in a horizontal parent row without removing the weight; splitters update it, while passive resizing preserves it
+
+ Default: undefined
+ */
+ preferredWidth?: number;
+
/**
index of selected/visible tab in tabset; -1 means no tab selected
@@ -753,7 +781,7 @@ export interface ITabSetAttributes {
type?: string;
/**
- relative weight for sizing of this tabset in parent row
+ relative weight for sizing of this tabset in parent row when no preferred size applies on the parent's split axis; retained and updated by splitters even when a preferred size takes precedence
Default: 100
*/
diff --git a/src/model/RowNode.ts b/src/model/RowNode.ts
index a249c444..2ec78c6a 100755
--- a/src/model/RowNode.ts
+++ b/src/model/RowNode.ts
@@ -14,6 +14,8 @@ import { Node } from "./Node";
import { TabSetNode } from "./TabSetNode";
import { isInSubtree } from "./Utils";
import { ModelLayout } from "./ModelLayout";
+import { Action, Actions } from "./Actions";
+import { getPreferredSize, validPreferredSize } from "./Sizing";
export class RowNode extends Node implements IDropTarget {
static readonly TYPE = "row";
@@ -70,6 +72,21 @@ export class RowNode extends Node implements IDropTarget {
return this.attributes.weight as number;
}
+ /** Preferred outer width in pixels, independent of the current allocation. */
+ getPreferredWidth(): number | undefined {
+ return validPreferredSize(this.attributes.preferredWidth);
+ }
+
+ /** Preferred outer height in pixels, independent of the current allocation. */
+ getPreferredHeight(): number | undefined {
+ return validPreferredSize(this.attributes.preferredHeight);
+ }
+
+ /** @internal */
+ hasPreferredSize() {
+ return this.getPreferredWidth() !== undefined || this.getPreferredHeight() !== undefined;
+ }
+
toJson(): IJsonRowNode {
const json: IJsonRowNode = {};
RowNode.attributeDefinitions.toJson(json, this.attributes);
@@ -157,20 +174,56 @@ export class RowNode extends Node implements IDropTarget {
const startRect = c[index].getRect();
const startPosition = (h ? startRect.x : startRect.y) - ss;
- return { initialSizes, sum, startPosition };
+ const preferredSizes = (c as (RowNode | TabSetNode)[]).map((child) => ({
+ preferredWidth: child.getPreferredWidth(),
+ preferredHeight: child.getPreferredHeight(),
+ }));
+ const retainedSizes = (c as (RowNode | TabSetNode)[]).map((child) => getPreferredSize(child, this.getOrientation()));
+ return { initialSizes, sum, startPosition, preferredSizes, retainedSizes };
+ }
+
+ /** @internal */
+ calculateSplitAction(index: number, splitterPos: number, initials: ReturnType): Action {
+ const { sizes, weights } = this.calculateSplitResult(index, splitterPos, initials.initialSizes, initials.sum, initials.startPosition);
+ const action = Actions.adjustWeights(this.getId(), weights);
+ if (weights.length === 0 || initials.retainedSizes.every((size) => size === undefined)) {
+ return action;
+ }
+ const dimension = this.getOrientation() === Orientation.HORZ ? "preferredWidth" : "preferredHeight";
+ const actions = [action];
+ for (let i = 0; i < weights.length; i++) {
+ const retainedSize = initials.retainedSizes[i];
+ if (retainedSize === undefined) {
+ continue;
+ }
+ const child = this.children[i] as RowNode | TabSetNode;
+ const size = sizes[i];
+ const delta = size - initials.initialSizes[i];
+ const h = this.getOrientation() === Orientation.HORZ;
+ const min = h ? child.getMinWidth() : child.getMinHeight();
+ const max = h ? child.getMaxWidth() : child.getMaxHeight();
+ const target = Math.max(min, Math.min(max, retainedSize));
+ const preferred = Math.abs(delta) > 0.01 ? (initials.initialSizes[i] < target - 0.01 ? Math.max(0, target + delta) : size) : initials.preferredSizes[i][dimension];
+ actions.push(Actions.updateNodeAttributes(child.getId(), { [dimension]: preferred }));
+ }
+ return Actions.group(actions);
}
/** @internal */
calculateSplit(index: number, splitterPos: number, initialSizes: number[], sum: number, startPosition: number) {
+ return this.calculateSplitResult(index, splitterPos, initialSizes, sum, startPosition).weights;
+ }
+
+ private calculateSplitResult(index: number, splitterPos: number, initialSizes: number[], sum: number, startPosition: number) {
const h = this.getOrientation() === Orientation.HORZ;
const c = this.getChildren();
const sizes = [...initialSizes];
- // a zero (unmeasured) row would divide by zero and emit Infinity/NaN weights; return an
- // empty array so the ADJUST_WEIGHTS consumer leaves the current weights untouched
+ // a zero (unmeasured) row would divide by zero and emit Infinity/NaN weights; return
+ // empty sizes and weights so the ADJUST_WEIGHTS consumer leaves the current weights untouched
if (sum <= 0 || sizes.length === 0) {
- return [];
+ return { sizes: [], weights: [] };
}
if (splitterPos < startPosition) {
@@ -251,7 +304,7 @@ export class RowNode extends Node implements IDropTarget {
// console.log(splitterPos, startPosition, "sizes", sizes);
// console.log("weights",weights);
- return weights;
+ return { sizes, weights };
}
/** @internal */
@@ -325,7 +378,7 @@ export class RowNode extends Node implements IDropTarget {
const childChildren = child.getChildren();
if (childChildren.length === 0) {
this.removeChild(child);
- } else if (childChildren.length === 1) {
+ } else if (childChildren.length === 1 && !child.hasPreferredSize() && !(childChildren[0] instanceof RowNode && childChildren[0].hasPreferredSize())) {
// hoist child/children up to this level
const subchild = childChildren[0];
this.removeChild(child);
@@ -550,7 +603,24 @@ export class RowNode extends Node implements IDropTarget {
const attributeDefinitions = new Attributes();
attributeDefinitions.add("type", RowNode.TYPE, true).setType(Attribute.STRING).setFixed();
attributeDefinitions.add("id", undefined).setType(Attribute.STRING).setDescription(`the unique id of the row, if left undefined a uuid will be assigned`);
- attributeDefinitions.add("weight", 100).setType(Attribute.NUMBER).setDescription(`relative weight for sizing of this row in parent row`);
+ attributeDefinitions
+ .add("weight", 100)
+ .setType(Attribute.NUMBER)
+ .setDescription(
+ `relative weight for sizing of this row in parent row when no explicit or derived preferred size applies on the parent's split axis; retained and updated by splitters even when a preferred size takes precedence`,
+ );
+ attributeDefinitions
+ .add("preferredWidth", undefined)
+ .setType(Attribute.NUMBER)
+ .setDescription(
+ `preferred outer width in pixels; takes precedence over weight in a horizontal parent row without removing the weight; splitters update it, while passive resizing preserves it`,
+ );
+ attributeDefinitions
+ .add("preferredHeight", undefined)
+ .setType(Attribute.NUMBER)
+ .setDescription(
+ `preferred outer height in pixels; takes precedence over weight in a vertical parent row without removing the weight; splitters update it, while passive resizing preserves it`,
+ );
return attributeDefinitions;
}
diff --git a/src/model/Sizing.ts b/src/model/Sizing.ts
new file mode 100644
index 00000000..93362302
--- /dev/null
+++ b/src/model/Sizing.ts
@@ -0,0 +1,34 @@
+import type { RowNode } from "./RowNode";
+import type { TabSetNode } from "./TabSetNode";
+import { Orientation } from "./Orientation";
+
+/** @internal */
+export function validPreferredSize(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
+}
+
+/** @internal */
+export function getPreferredSize(node: RowNode | TabSetNode, orientation: Orientation): number | undefined {
+ const preferred = orientation === Orientation.HORZ ? node.getPreferredWidth() : node.getPreferredHeight();
+ if (preferred !== undefined || node.getType() !== "row") {
+ return preferred;
+ }
+ const children = node.getChildren() as (RowNode | TabSetNode)[];
+ if (children.length === 0) {
+ return undefined;
+ }
+ const sizes = children.map((child) => {
+ const size = getPreferredSize(child, orientation);
+ if (size === undefined) {
+ return undefined;
+ }
+ const min = orientation === Orientation.HORZ ? child.getMinWidth() : child.getMinHeight();
+ const max = orientation === Orientation.HORZ ? child.getMaxWidth() : child.getMaxHeight();
+ return Math.max(min, Math.min(max, size));
+ });
+ if (sizes.some((size) => size === undefined)) {
+ return undefined;
+ }
+ const retained = sizes as number[];
+ return node.getOrientation() === orientation ? retained.reduce((sum, size) => sum + size, 0) + (children.length - 1) * node.getModel().getSplitterSize()! : Math.max(...retained);
+}
diff --git a/src/model/TabSetNode.ts b/src/model/TabSetNode.ts
index ad9f6781..0d736fd9 100755
--- a/src/model/TabSetNode.ts
+++ b/src/model/TabSetNode.ts
@@ -15,6 +15,8 @@ import { RowNode } from "./RowNode";
import { findStripDrop } from "./StripDrop";
import { TabNode } from "./TabNode";
import { adjustSelectedIndex, adjustSelectedIndexAfterInsert, detachDragNode, isInSubtree, restoreSelectionAfterInsert } from "./Utils";
+import { validPreferredSize } from "./Sizing";
+import { Orientation } from "./Orientation";
export class TabSetNode extends Node implements IDraggable, IDropTarget {
static readonly TYPE = "tabset";
@@ -161,6 +163,16 @@ export class TabSetNode extends Node implements IDraggable, IDropTarget {
return this.getAttr("weight") as number;
}
+ /** Preferred outer width in pixels, independent of the current allocation. */
+ getPreferredWidth(): number | undefined {
+ return validPreferredSize(this.getAttr("preferredWidth"));
+ }
+
+ /** Preferred outer height in pixels, independent of the current allocation. */
+ getPreferredHeight(): number | undefined {
+ return validPreferredSize(this.getAttr("preferredHeight"));
+ }
+
getAttrMinWidth() {
return this.getAttr("minWidth") as number;
}
@@ -541,19 +553,26 @@ export class TabSetNode extends Node implements IDraggable, IDropTarget {
parentRow.addChild(moveNode, pos + dockLocation.indexPlus);
} else {
// create a new row to host the new tabset (it will go in the opposite direction)
- const newRow = new RowNode(this.model, {});
+ const newRow = new RowNode(this.model, parentRow.getOrientation() === Orientation.HORZ ? { preferredWidth: this.getPreferredWidth() } : { preferredHeight: this.getPreferredHeight() });
newRow.setWeight(this.getWeight());
newRow.addChild(this);
this.setWeight(50);
if (dragNode instanceof RowNode && dragNode.getChildren().length > 0 && dragNode.getOrientation() !== parentRow.getOrientation()) {
- // the new row shares the dragged row's orientation, so flatten its children
- // into the new row to keep the dragged layout's orientation
- const dragChildren = dragNode.getChildren() as (RowNode | TabSetNode)[];
- const total = dragChildren.reduce((sum, c) => sum + c.getWeight(), 0);
- let insertIndex = dockLocation.indexPlus;
- for (const child of dragChildren) {
- child.setWeight(total === 0 ? 50 / dragChildren.length : (50 * child.getWeight()) / total);
- newRow.addChild(child, insertIndex++);
+ if (dragNode.hasPreferredSize()) {
+ const wrapper = new RowNode(this.model, {});
+ wrapper.addChild(dragNode);
+ wrapper.setWeight(50);
+ newRow.addChild(wrapper, dockLocation.indexPlus);
+ } else {
+ // the new row shares the dragged row's orientation, so flatten its children
+ // into the new row to keep the dragged layout's orientation
+ const dragChildren = dragNode.getChildren() as (RowNode | TabSetNode)[];
+ const total = dragChildren.reduce((sum, c) => sum + c.getWeight(), 0);
+ let insertIndex = dockLocation.indexPlus;
+ for (const child of dragChildren) {
+ child.setWeight(total === 0 ? 50 / dragChildren.length : (50 * child.getWeight()) / total);
+ newRow.addChild(child, insertIndex++);
+ }
}
} else {
moveNode.setWeight(50);
@@ -591,7 +610,24 @@ export class TabSetNode extends Node implements IDraggable, IDropTarget {
const attributeDefinitions = new Attributes();
attributeDefinitions.add("type", TabSetNode.TYPE, true).setType(Attribute.STRING).setFixed();
attributeDefinitions.add("id", undefined).setType(Attribute.STRING).setDescription(`the unique id of the tab set, if left undefined a uuid will be assigned`);
- attributeDefinitions.add("weight", 100).setType(Attribute.NUMBER).setDescription(`relative weight for sizing of this tabset in parent row`);
+ attributeDefinitions
+ .add("weight", 100)
+ .setType(Attribute.NUMBER)
+ .setDescription(
+ `relative weight for sizing of this tabset in parent row when no preferred size applies on the parent's split axis; retained and updated by splitters even when a preferred size takes precedence`,
+ );
+ attributeDefinitions
+ .add("preferredWidth", undefined)
+ .setType(Attribute.NUMBER)
+ .setDescription(
+ `preferred outer width in pixels; takes precedence over weight in a horizontal parent row without removing the weight; splitters update it, while passive resizing preserves it`,
+ );
+ attributeDefinitions
+ .add("preferredHeight", undefined)
+ .setType(Attribute.NUMBER)
+ .setDescription(
+ `preferred outer height in pixels; takes precedence over weight in a vertical parent row without removing the weight; splitters update it, while passive resizing preserves it`,
+ );
attributeDefinitions.add("selected", 0).setType(Attribute.NUMBER).setDescription(`index of selected/visible tab in tabset; -1 means no tab selected`);
attributeDefinitions.add("name", undefined).setType(Attribute.STRING).setDescription(`an accessible label for the tab strip (used as the tablist's aria-label; not displayed visually)`);
attributeDefinitions.add("config", undefined).setType("any").setDescription(`a place to hold json config used in your own code`);
diff --git a/src/view/FlexStyle.ts b/src/view/FlexStyle.ts
new file mode 100644
index 00000000..c32548df
--- /dev/null
+++ b/src/view/FlexStyle.ts
@@ -0,0 +1,19 @@
+import type { CSSProperties } from "react";
+import type { RowNode } from "../model/RowNode";
+import type { TabSetNode } from "../model/TabSetNode";
+import { getPreferredSize } from "../model/Sizing";
+
+/** @internal */
+export function getFlexStyle(node: RowNode | TabSetNode): CSSProperties {
+ const parent = node.getParent();
+ const maximized = node.getType() === "tabset" && (node as TabSetNode).isMaximized();
+ const preferred = parent && parent.getChildren().length > 1 && !maximized ? getPreferredSize(node, parent.getOrientation()) : undefined;
+ if (preferred === undefined) {
+ return { flexGrow: Math.max(1, node.getWeight() * 1000) };
+ }
+ return {
+ flexGrow: 0,
+ flexShrink: 1,
+ flexBasis: preferred,
+ };
+}
diff --git a/src/view/Layout.tsx b/src/view/Layout.tsx
index f7e4df42..2c25020c 100755
--- a/src/view/Layout.tsx
+++ b/src/view/Layout.tsx
@@ -26,7 +26,9 @@ export interface ILayoutProps {
keyMap?: IKeyMap;
/** Custom icons for close, pin, maximize, restore, more, popout buttons. */
icons?: IIcons;
- /** function called whenever the layout generates an action to update the model (allows for intercepting actions before they are dispatched to the model, for example, asking the user to confirm a tab close.) Returning undefined from the function will halt the action, otherwise return the action to continue */
+ /** function called whenever the layout generates an action to update the model (allows for intercepting actions before they are dispatched to the model, for example, asking the user to confirm a tab close.) Returning undefined from the function will halt the action, otherwise return the action to continue.
+ * Preferred-size row splitters emit a GroupAction containing weight and preference updates;
+ * inspect its actions when detecting splitter changes and accept or cancel the whole group. */
onAction?: (action: Action) => Action | undefined;
/** function called when rendering a tab, allows leading (icon), content section, buttons and name used in overflow menu to be customized */
onRenderTab?: (
diff --git a/src/view/Row.tsx b/src/view/Row.tsx
index e9cb10ff..fcdaa84a 100644
--- a/src/view/Row.tsx
+++ b/src/view/Row.tsx
@@ -6,6 +6,7 @@ import { LayoutController } from "./layout/LayoutInternal";
import { TabSet } from "./TabSet";
import { Splitter } from "./Splitter";
import { Orientation } from "../model/Orientation";
+import { getFlexStyle } from "./FlexStyle";
/** @internal */
export interface IRowProps {
@@ -48,7 +49,7 @@ export const Row = (props: IRowProps) => {
}
const style: React.CSSProperties = {
- flexGrow: Math.max(1, rowNode.getWeight() * 1000), // NOTE: flex-grow cannot have values < 1 otherwise will not fill parent, need to normalize
+ ...getFlexStyle(rowNode),
minWidth: rowNode.getMinWidth(),
minHeight: rowNode.getMinHeight(),
maxWidth: rowNode.getMaxWidth(),
diff --git a/src/view/Splitter.tsx b/src/view/Splitter.tsx
index af24083b..97fb84f8 100755
--- a/src/view/Splitter.tsx
+++ b/src/view/Splitter.tsx
@@ -28,7 +28,7 @@ export const Splitter = (props: ISplitterProps) => {
const handleDiv = React.useRef(undefined);
const dragStartX = React.useRef(0);
const dragStartY = React.useRef(0);
- const initalSizes = React.useRef<{ initialSizes: number[]; sum: number; startPosition: number }>({ initialSizes: [], sum: 0, startPosition: 0 });
+ const initalSizes = React.useRef>({ initialSizes: [], sum: 0, startPosition: 0, preferredSizes: [], retainedSizes: [] });
const draggingTimerRef = React.useRef(undefined);
const onTouchStart = React.useCallback((event: TouchEvent) => {
@@ -122,8 +122,7 @@ export const Splitter = (props: ISplitterProps) => {
}
const bounds = node.getSplitterBounds(index);
const pos = Math.max(bounds[0], Math.min(bounds[1], initials.startPosition + delta));
- const weights = node.calculateSplit(index, pos, initials.initialSizes, initials.sum, initials.startPosition);
- controller.doAction(Actions.adjustWeights(node.getId(), weights));
+ controller.doAction(node.calculateSplitAction(index, pos, initials));
}
// keep the flag long enough for the ResizeObserver to fire
setTimeout(() => controller.setSplitterDragging(false), 300);
@@ -201,8 +200,7 @@ export const Splitter = (props: ISplitterProps) => {
if (init.sum <= 0) {
return;
}
- const weights = node.calculateSplit(index, value, init.initialSizes, init.sum, init.startPosition);
- controller.doAction(Actions.adjustWeights(node.getId(), weights).setAdjusting(adjusting));
+ controller.doAction(node.calculateSplitAction(index, value, init).setAdjusting(adjusting));
}
}
};
diff --git a/src/view/TabSet.tsx b/src/view/TabSet.tsx
index 21fb508b..48dfaf0f 100755
--- a/src/view/TabSet.tsx
+++ b/src/view/TabSet.tsx
@@ -15,6 +15,7 @@ import { Orientation } from "../model/Orientation";
import { CLASSES } from "../CSSClassNames";
import { isAuxMouseEvent, tabButtonPath, toAriaKeyShortcuts } from "./Utils";
import { createPortal } from "react-dom";
+import { getFlexStyle } from "./FlexStyle";
/** @internal */
export interface ITabSetProps {
@@ -620,7 +621,7 @@ export const TabSet = (props: ITabSetProps) => {
const content = renderContent(tabStrip);
const style: React.CSSProperties = {
- flexGrow: Math.max(1, tabsetNode.getWeight() * 1000),
+ ...getFlexStyle(tabsetNode),
minWidth: tabsetNode.getMinWidth(),
minHeight: tabsetNode.getMinHeight(),
maxWidth: tabsetNode.getMaxWidth(),
diff --git a/src/view/layout/LayoutController.tsx b/src/view/layout/LayoutController.tsx
index de4694f2..2e66f6f1 100644
--- a/src/view/layout/LayoutController.tsx
+++ b/src/view/layout/LayoutController.tsx
@@ -15,6 +15,7 @@ import { RowNode } from "../../model/RowNode";
import { TabNode } from "../../model/TabNode";
import { TabGroupNode } from "../../model/TabGroupNode";
import { TabSetNode } from "../../model/TabSetNode";
+import { getFlexStyle } from "../FlexStyle";
import { AsterickIcon, GripIcon, CloseIcon, EdgeIcon, MaximizeIcon, OverflowIcon, PinIcon, PopoutIcon, PopoutFloatIcon, RestoreIcon } from "../Icons";
import { Row } from "../Row";
import { Tab } from "../Tab";
@@ -276,9 +277,10 @@ export class LayoutController {
}
}
- if (action.isAdjusting() && (action.type === Actions.ADJUST_WEIGHTS || action.type === Actions.ADJUST_BORDER_SPLIT)) {
- if (action.type === Actions.ADJUST_WEIGHTS) {
- this.applyAdjustingWeights(action);
+ const weightsAction = action.type === Actions.ADJUST_WEIGHTS ? action : action instanceof GroupAction ? this.getPreferredResizeWeights(action) : undefined;
+ if (action.isAdjusting() && (weightsAction || action.type === Actions.ADJUST_BORDER_SPLIT)) {
+ if (weightsAction) {
+ this.applyAdjustingWeights(weightsAction, action instanceof GroupAction);
} else {
this.applyAdjustingBorderSplit(action);
}
@@ -294,6 +296,29 @@ export class LayoutController {
}
};
+ private getPreferredResizeWeights(action: GroupAction): Action | undefined {
+ const [weights, ...updates] = action.actions;
+ if (weights?.type !== Actions.ADJUST_WEIGHTS || updates.length === 0) {
+ return undefined;
+ }
+ const row = this._props.model.getNodeById(weights.data.nodeId);
+ if (!(row instanceof RowNode)) {
+ return undefined;
+ }
+ const childIds = new Set(row.getChildren().map((child) => child.getId()));
+ const preferredOnly = updates.every((update) => {
+ const attributes = update.data.json;
+ return (
+ update.type === Actions.UPDATE_NODE_ATTRIBUTES &&
+ childIds.has(update.data.node) &&
+ attributes &&
+ Object.keys(attributes).length > 0 &&
+ Object.keys(attributes).every((key) => key === "preferredWidth" || key === "preferredHeight")
+ );
+ });
+ return preferredOnly ? weights : undefined;
+ }
+
// re-measure, reposition panels, and mount any newly sized content areas
private applyMeasuredGeometry() {
this.syncLayoutMetrics();
@@ -304,27 +329,45 @@ export class LayoutController {
}
}
- applyAdjustingWeights(action: Action) {
+ applyAdjustingWeights(action: Action, updatePreferences = false) {
const row = this._props.model.getNodeById(action.data.nodeId);
if (!(row instanceof RowNode)) {
this.redrawLayout();
return;
}
const weights = action.data.weights as number[] | undefined;
- const children = row.getChildren();
- for (let i = 0; i < children.length; i++) {
- const child = children[i];
- if (typeof weights?.[i] !== "number" || !Number.isFinite(weights[i])) {
- continue;
+ const nodes = row
+ .getChildren()
+ .map((child, i) => ({ node: child as RowNode | TabSetNode, weight: weights?.[i] }))
+ .filter(({ weight }) => updatePreferences || (typeof weight === "number" && Number.isFinite(weight)));
+ if (updatePreferences) {
+ // Preferred sizes can change the effective basis of each containing row.
+ let ancestor: Node | undefined = row;
+ while (ancestor instanceof RowNode) {
+ nodes.push({ node: ancestor, weight: undefined });
+ ancestor = ancestor.getParent();
}
+ }
+ const elements: HTMLElement[] = [];
+ for (const { node: child } of nodes) {
const kind: MeasurableKind = child instanceof RowNode ? "row" : "tabset";
const element = this._measurables.get(kind + ":" + child.getId())?.element;
- if (!element) {
+ if (!element || !element.isConnected || child.getLayoutId() !== this._layoutId) {
// not registered (e.g. row in a popout window): fall back to re-render path
this.redrawLayout();
return;
}
- element.style.flexGrow = String(Math.max(1, weights[i] * 1000));
+ elements.push(element);
+ }
+ for (let i = 0; i < nodes.length; i++) {
+ const element = elements[i];
+ const { node, weight } = nodes[i];
+ const style = getFlexStyle(node);
+ element.style.flexGrow = String(!updatePreferences && style.flexBasis === undefined ? Math.max(1, weight! * 1000) : style.flexGrow);
+ if (updatePreferences || style.flexBasis !== undefined) {
+ element.style.flexShrink = style.flexShrink === undefined ? "" : String(style.flexShrink);
+ element.style.flexBasis = style.flexBasis === undefined ? "" : style.flexBasis + "px";
+ }
}
this.applyMeasuredGeometry();
diff --git a/tests-playwright/preferred-sizes.spec.ts b/tests-playwright/preferred-sizes.spec.ts
new file mode 100644
index 00000000..5202742d
--- /dev/null
+++ b/tests-playwright/preferred-sizes.spec.ts
@@ -0,0 +1,380 @@
+import { test, expect, Page } from "@playwright/test";
+import type { IJsonModel, IJsonTabSetNode } from "../src";
+import { waitForBox } from "./helpers";
+
+const panel = (id: string, attributes: Partial = {}): IJsonTabSetNode => ({
+ type: "tabset",
+ id,
+ children: [{ type: "tab", id: id + "-tab", name: id, component: "testing" }],
+ ...attributes,
+});
+
+const initialLayout = (): IJsonModel => ({
+ layout: {
+ type: "row",
+ id: "root",
+ children: [panel("left", { preferredWidth: 240, minWidth: 100 }), panel("center", { minWidth: 180 }), panel("right", { preferredWidth: 280, minWidth: 100 })],
+ },
+});
+
+async function load(page: Page, json = initialLayout()) {
+ await page.setViewportSize({ width: 1200, height: 800 });
+ await page.addInitScript((json) => {
+ if (localStorage.getItem("test_preferred") === null) {
+ localStorage.setItem("test_preferred", JSON.stringify(json));
+ }
+ }, json);
+ await page.goto("/demo?layout=test_preferred");
+ await waitForBox(page.locator(".flexlayout__tabset").first(), "preferred layout");
+}
+
+async function extent(page: Page, id: string, axis: "width" | "height" = "width") {
+ const path = await page.evaluate((id) => (window as any).__flexModel().getNodeById(id).getPath(), id);
+ const box = await page.locator(`.flexlayout__tabset[data-layout-path="${path}"]`).locator("..").boundingBox();
+ return box?.[axis] ?? 0;
+}
+
+async function expectExtent(page: Page, id: string, size: number, axis: "width" | "height" = "width") {
+ await expect.poll(() => extent(page, id, axis)).toBeCloseTo(size, 0);
+}
+
+async function dragBy(page: Page, path: string, dx: number, dy = 0) {
+ const box = await waitForBox(page.locator(`[data-layout-path="${path}"]`), path);
+ const x = box.x + box.width / 2;
+ const y = box.y + box.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x + dx, y + dy, { steps: 8 });
+ await page.mouse.up();
+}
+
+async function recordRedraws(page: Page) {
+ await page.evaluate(async () => {
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
+ const w = window as any;
+ const controller = w.__flexModel().getRootRow().getLayout().getController();
+ const redraw = controller.redrawLayout.bind(controller);
+ w.__preferredRedraws = 0;
+ controller.redrawLayout = () => {
+ w.__preferredRedraws++;
+ redraw();
+ };
+ });
+}
+
+test("live preferred splitter resizing updates geometry before pointerup without requesting a layout redraw", async ({ page }) => {
+ await load(page);
+ await expectExtent(page, "left", 240);
+ const box = await waitForBox(page.locator('[data-layout-path="/s0"]'), "live preferred splitter");
+ const x = box.x + box.width / 2;
+ const y = box.y + box.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await recordRedraws(page);
+ await page.mouse.move(x + 60, y, { steps: 8 });
+ await expectExtent(page, "left", 300);
+ await expectExtent(page, "right", 280);
+ expect(await page.evaluate(() => (window as any).__preferredRedraws)).toBe(0);
+ await page.mouse.up();
+ await expect.poll(() => page.evaluate(() => (window as any).__preferredRedraws)).toBeGreaterThan(0);
+});
+
+test("live preference updates refresh a derived ancestor's geometry and can make it flexible", async ({ page }) => {
+ const json = initialLayout();
+ json.layout.children = [{ type: "row", id: "column", children: [panel("left", { preferredWidth: 240 }), panel("bottom", { preferredWidth: 240 })] }, panel("center")];
+ await load(page, json);
+ await expectExtent(page, "left", 240);
+ const before = await extent(page, "center");
+ await recordRedraws(page);
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.group([w.__flexActions.adjustWeights("column", [50, 50]), w.__flexActions.updateNodeAttributes("left", { preferredWidth: 320 })]).setAdjusting(true));
+ });
+ await expectExtent(page, "left", 320);
+ await expectExtent(page, "bottom", 320);
+ await expectExtent(page, "center", before - 80);
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.group([w.__flexActions.adjustWeights("column", [50, 50]), w.__flexActions.updateNodeAttributes("left", { preferredWidth: undefined })]).setAdjusting(true));
+ });
+ await expect.poll(async () => (await extent(page, "left")) / (await extent(page, "center"))).toBeCloseTo(1, 1);
+ expect(await page.evaluate(() => (window as any).__preferredRedraws)).toBe(0);
+});
+
+test("window resizing preserves auxiliary widths and gives the center the difference", async ({ page }) => {
+ await load(page);
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+ const before = await extent(page, "center");
+ await page.setViewportSize({ width: 1400, height: 800 });
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+ await expectExtent(page, "center", before + 200);
+ await page.setViewportSize({ width: 1000, height: 800 });
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+ await expectExtent(page, "center", before - 200);
+});
+
+test("weight-only layouts retain CSS sizing overrides before and after live resizing", async ({ page }) => {
+ const json = initialLayout();
+ json.layout.children = [panel("left"), panel("right")];
+ await load(page, json);
+ await page.addStyleTag({ content: ".flexlayout__row, .flexlayout__tabset_container { flex-basis: 200px; flex-shrink: 0; }" });
+ const left = page.locator('.flexlayout__tabset[data-layout-path="/ts0"]').locator("..");
+ await expect(left).toHaveCSS("flex-basis", "200px");
+ await expect(left).toHaveCSS("flex-shrink", "0");
+ await dragBy(page, "/s0", 30);
+ await expect(left).toHaveCSS("flex-basis", "200px");
+ await expect(left).toHaveCSS("flex-shrink", "0");
+ for (const preferredWidth of [300, undefined]) {
+ await page.evaluate((preferredWidth) => {
+ const w = window as any;
+ const update = w.__flexActions.updateNodeAttributes("left", { preferredWidth });
+ w.__flexDispatch(preferredWidth === undefined ? w.__flexActions.group([w.__flexActions.adjustWeights("root", [50, 50]), update]).setAdjusting(true) : update);
+ }, preferredWidth);
+ await expect(left).toHaveCSS("flex-basis", preferredWidth === undefined ? "200px" : "300px");
+ await expect(left).toHaveCSS("flex-shrink", preferredWidth === undefined ? "0" : "1");
+ }
+});
+
+test("nested preferences account for child minimum and maximum sizes", async ({ page }) => {
+ const json = initialLayout();
+ json.layout.children = [
+ {
+ type: "row",
+ id: "column",
+ children: [
+ { type: "row", id: "split", children: [panel("left", { preferredWidth: 100, minWidth: 300 }), panel("right", { preferredWidth: 200 })] },
+ panel("bottom", { preferredWidth: 200 }),
+ ],
+ },
+ panel("center"),
+ ];
+ await load(page, json);
+ await expectExtent(page, "left", 300);
+ await expectExtent(page, "right", 200);
+ const center = await extent(page, "center");
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.updateNodeAttributes("left", { preferredWidth: 400, minWidth: 0, maxWidth: 300 }));
+ });
+ await expectExtent(page, "left", 300);
+ await expectExtent(page, "right", 200);
+ await expectExtent(page, "center", center);
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.updateNodeAttributes("left", { maxWidth: 500 }));
+ });
+ await expectExtent(page, "left", 400);
+ await expectExtent(page, "right", 200);
+ await expectExtent(page, "center", center - 100);
+});
+
+for (const operation of ["close", "float", "merge"] as const) {
+ test(`${operation} releases the right allocation to the center and retains the left width`, async ({ page }) => {
+ await load(page);
+ const before = await extent(page, "center");
+ const splitterWidth = (await page.locator('[data-layout-path="/s1"]').boundingBox())!.width;
+ await page.evaluate((operation) => {
+ const w = window as any;
+ const a = w.__flexActions;
+ w.__flexDispatch(operation === "close" ? a.deleteTabset("right") : operation === "float" ? a.popoutTabset("right", "float") : a.moveNode("right", "left", { getName: () => "center" }, -1));
+ }, operation);
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "center", before + 280 + splitterWidth);
+ if (operation === "float") {
+ await expect(page.locator(".flexlayout__float_window")).toHaveCount(1);
+ const grip = page.locator('.flexlayout__float_window > div[style*="cursor: ew-resize"]').last();
+ const box = await waitForBox(grip, "float resize grip");
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(box.x + box.width / 2 - 120, box.y + box.height / 2, { steps: 8 });
+ await page.mouse.up();
+ await expect.poll(() => extent(page, "right")).toBeGreaterThan(350);
+ await expect.poll(() => page.evaluate(() => (window as any).__flexModel().getNodeById("right").getPreferredWidth())).toBe(280);
+ }
+ });
+}
+
+for (const realtime of [true, false]) {
+ test(`splitters retain chosen sizes and undo the entire drag (realtime=${realtime})`, async ({ page }) => {
+ await load(page);
+ await page.locator('input[name="realtimeResize"]').setChecked(realtime);
+ await dragBy(page, "/s0", 60);
+ await expectExtent(page, "left", 300);
+ await expectExtent(page, "right", 280);
+ await page.setViewportSize({ width: 1400, height: 800 });
+ await expectExtent(page, "left", 300);
+ await expectExtent(page, "right", 280);
+ await page.locator('button[title^="undo"]').click();
+ await expectExtent(page, "left", 240);
+ await expect(page.locator('button[title^="undo"]')).toBeDisabled();
+ await page.locator('button[title^="redo"]').click();
+ await expectExtent(page, "left", 300);
+ await page.locator('[data-layout-path="/s1"]').focus();
+ await page.keyboard.press("ArrowLeft");
+ await expectExtent(page, "right", 290);
+ await page.reload();
+ await expectExtent(page, "left", 300);
+ await expectExtent(page, "right", 290);
+ });
+}
+
+test("constrained preferences recover after shrinking and save without replacing the preference", async ({ page }) => {
+ await load(page);
+ await page.setViewportSize({ width: 600, height: 800 });
+ await expect.poll(() => extent(page, "left")).toBeLessThan(240);
+ await expect.poll(() => extent(page, "right")).toBeLessThan(280);
+ await expect.poll(() => extent(page, "center")).toBeGreaterThanOrEqual(180);
+ await page.evaluate(() => {
+ const w = window as any;
+ localStorage.setItem("test_preferred", JSON.stringify(w.__flexModel().toJson()));
+ });
+ await page.reload();
+ await page.setViewportSize({ width: 1200, height: 800 });
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+});
+
+for (const realtime of [true, false]) {
+ test(`dragging a compressed panel adjusts its preference without replacing its sibling's preference (realtime=${realtime})`, async ({ page }) => {
+ await load(page);
+ await page.locator('input[name="realtimeResize"]').setChecked(realtime);
+ await page.setViewportSize({ width: 600, height: 800 });
+ await expect.poll(() => extent(page, "left")).toBeLessThan(240);
+ await waitForBox(page.locator('[data-layout-path="/s0"]'), "compressed splitter");
+ const before = await extent(page, "left");
+ await dragBy(page, "/s0", -30);
+ await expect.poll(() => extent(page, "left")).toBeLessThan(before);
+ await expect.poll(() => page.evaluate(() => (window as any).__flexModel().getNodeById("left").getPreferredWidth())).toBeCloseTo(210, 0);
+ await page.setViewportSize({ width: 1200, height: 800 });
+ await expectExtent(page, "left", 210);
+ await expectExtent(page, "right", 280);
+ });
+}
+
+test("nested auxiliary heights stay retained while the center absorbs both axes", async ({ page }) => {
+ const json = initialLayout();
+ json.layout.children![1] = { type: "row", id: "middle", children: [panel("center"), panel("bottom", { preferredHeight: 180 })] };
+ await load(page, json);
+ await expectExtent(page, "bottom", 180, "height");
+ const before = await extent(page, "center", "height");
+ await page.setViewportSize({ width: 1200, height: 1000 });
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+ await expectExtent(page, "bottom", 180, "height");
+ await expectExtent(page, "center", before + 200, "height");
+ await dragBy(page, "/r1/s0", 0, -30);
+ await expectExtent(page, "bottom", 210, "height");
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.deleteTabset("right"));
+ });
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "bottom", 210, "height");
+});
+
+test("orthogonal docking and row collapse preserve the retained side column", async ({ page }) => {
+ await load(page);
+ const movedTabsetId = await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.moveNode("right-tab", "left", { getName: () => "bottom" }, -1));
+ return w.__flexModel().getNodeById("right-tab").getParent().getId();
+ });
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, movedTabsetId, 240);
+ await page.setViewportSize({ width: 1400, height: 800 });
+ await expectExtent(page, "left", 240);
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.deleteTab("right-tab"));
+ });
+ await expectExtent(page, "left", 240);
+ await page.reload();
+ await expectExtent(page, "left", 240);
+});
+
+test("maximizing and restoring leaves preferred sizes intact", async ({ page }) => {
+ await load(page);
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.maximizeToggle("left"));
+ });
+ await expect.poll(() => extent(page, "left")).toBeGreaterThan(1000);
+ await page.setViewportSize({ width: 1400, height: 800 });
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.maximizeToggle("left"));
+ });
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+});
+
+test("multiple flexible panels share remaining space by weight", async ({ page }) => {
+ const json = initialLayout();
+ json.layout.children = [panel("left", { preferredWidth: 240 }), panel("center", { weight: 1 }), panel("second", { weight: 2 }), panel("right", { preferredWidth: 280 })];
+ await load(page, json);
+ await expect.poll(async () => (await extent(page, "second")) / (await extent(page, "center"))).toBeCloseTo(2, 1);
+ await page.setViewportSize({ width: 1400, height: 800 });
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+ await expect.poll(async () => (await extent(page, "second")) / (await extent(page, "center"))).toBeCloseTo(2, 1);
+});
+
+test("splitters preserve unrelated panels while flexible siblings reach their minimums", async ({ page }) => {
+ const json = initialLayout();
+ json.layout.children = [panel("left", { preferredWidth: 240 }), panel("center", { weight: 1 }), panel("second", { weight: 2 }), panel("right", { preferredWidth: 280 })];
+ await load(page, json);
+ const center = await extent(page, "center");
+ const second = await extent(page, "second");
+ await dragBy(page, "/s1", 40);
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+ await expectExtent(page, "center", center + 40);
+ await expectExtent(page, "second", second - 40);
+ await dragBy(page, "/s0", 30);
+ await expectExtent(page, "left", 270);
+ await expectExtent(page, "center", center + 10);
+ await expectExtent(page, "second", second - 40);
+ await page.evaluate((minWidth) => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.updateNodeAttributes("center", { minWidth }));
+ }, center + 10);
+ await dragBy(page, "/s0", 30);
+ await expectExtent(page, "left", 300);
+ await expectExtent(page, "right", 280);
+ await expectExtent(page, "center", center + 10);
+ await expectExtent(page, "second", second - 70);
+});
+
+test("maximum sizes clamp allocations while preserving preferences", async ({ page }) => {
+ const json = initialLayout();
+ Object.assign(json.layout.children![0], { preferredWidth: 400, maxWidth: 300 });
+ await load(page, json);
+ await expectExtent(page, "left", 300);
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.updateNodeAttributes("left", { maxWidth: 500 }));
+ });
+ await expectExtent(page, "left", 400);
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.adjustWeights("root", [1, 98, 1]).setAdjusting(true));
+ });
+ await expectExtent(page, "left", 400);
+ await expectExtent(page, "right", 280);
+});
+
+test("all retained siblings leave free space, but a sole remaining panel fills the layout", async ({ page }) => {
+ const json = initialLayout();
+ json.layout.children = [panel("left", { preferredWidth: 240 }), panel("right", { preferredWidth: 280 })];
+ await load(page, json);
+ await expectExtent(page, "left", 240);
+ await expectExtent(page, "right", 280);
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__flexDispatch(w.__flexActions.deleteTabset("right"));
+ });
+ await expect.poll(() => extent(page, "left")).toBeGreaterThan(1000);
+});
diff --git a/tests/PreferredSizes.test.ts b/tests/PreferredSizes.test.ts
new file mode 100644
index 00000000..bc41b0ee
--- /dev/null
+++ b/tests/PreferredSizes.test.ts
@@ -0,0 +1,155 @@
+// @vitest-environment node
+import { Actions, DockLocation, Model, Orientation, Rect, RowNode, TabSetNode } from "../src";
+import { getPreferredSize } from "../src/model/Sizing";
+
+const tabset = (id: string, preferredWidth?: number) => ({
+ type: "tabset" as const,
+ id,
+ preferredWidth,
+ children: [{ type: "tab" as const, id: id + "-tab", name: id }],
+});
+
+const createModel = () => Model.fromJson({ layout: { type: "row", children: [tabset("left", 240), tabset("center"), tabset("right", 280)] } });
+const getTabset = (model: Model, id: string) => model.getNodeById(id) as TabSetNode;
+
+function measure(model: Model, sizes: number[]) {
+ let x = 0;
+ for (let i = 0; i < sizes.length; i++) {
+ (model.getRootRow()!.getChildren()[i] as TabSetNode).setRect(new Rect(x, 0, sizes[i], 500));
+ x += sizes[i] + model.getSplitterSize()!;
+ }
+ model.getRootRow()!.calcMinMaxSize();
+}
+
+describe("preferred panel sizes", () => {
+ it("persists physical dimensions and clears them through actions", () => {
+ const model = createModel();
+ model.doAction(Actions.updateNodeAttributes("left", { preferredHeight: 160 }));
+ const restored = Model.fromJson(model.toJson());
+ expect(getTabset(restored, "left").getPreferredWidth()).toBe(240);
+ expect(getTabset(restored, "left").getPreferredHeight()).toBe(160);
+ restored.doAction(Actions.updateNodeAttributes("left", { preferredWidth: undefined }));
+ expect(getTabset(restored, "left").getPreferredWidth()).toBeUndefined();
+ expect(getTabset(restored, "left").toJson()).not.toHaveProperty("preferredWidth");
+ });
+
+ it.each([-1, NaN, Infinity])("ignores invalid preferred size %s", (value) => {
+ const model = createModel();
+ model.doAction(Actions.updateNodeAttributes("left", { preferredWidth: value }));
+ expect(getTabset(model, "left").getPreferredWidth()).toBeUndefined();
+ });
+
+ it("updates a dragged preference without replacing a compressed untouched preference", () => {
+ const model = createModel();
+ measure(model, [180, 200, 220]);
+ const row = model.getRootRow()!;
+ const initials = row.getSplitterInitials(1);
+ model.doAction(row.calculateSplitAction(1, initials.startPosition + 30, initials));
+ expect(getTabset(model, "left").getPreferredWidth()).toBeCloseTo(270);
+ expect(getTabset(model, "right").getPreferredWidth()).toBe(280);
+ model.doAction(row.calculateSplitAction(1, initials.startPosition, initials));
+ expect(getTabset(model, "left").getPreferredWidth()).toBe(240);
+ });
+
+ it("leaves weight-only splitter actions unchanged", () => {
+ const model = Model.fromJson({ layout: { type: "row", children: [tabset("a"), tabset("b")] } });
+ measure(model, [300, 300]);
+ const row = model.getRootRow()!;
+ const initials = row.getSplitterInitials(1);
+ expect(row.calculateSplitAction(1, initials.startPosition + 20, initials).type).toBe(Actions.ADJUST_WEIGHTS);
+ });
+
+ it.each([0, 0.05])("preserves an untouched subpixel preference of %s when another splitter moves", (size) => {
+ const model = createModel();
+ model.doAction(Actions.updateNodeAttributes("left", { preferredWidth: size, minWidth: 0 }));
+ model.doAction(Actions.updateNodeAttributes("left-tab", { minWidth: 0 }));
+ measure(model, [size, 300, 300]);
+ const row = model.getRootRow()!;
+ const initials = row.getSplitterInitials(2);
+ model.doAction(row.calculateSplitAction(2, initials.startPosition + 30, initials));
+ expect(getTabset(model, "left").getPreferredWidth()).toBe(size);
+ });
+
+ it("derives stacked widths and split heights without persisting derived values", () => {
+ const model = Model.fromJson({
+ layout: {
+ type: "row",
+ children: [
+ {
+ type: "row",
+ id: "column",
+ children: [
+ { ...tabset("a", 240), preferredHeight: 100 },
+ { ...tabset("b", 260), preferredHeight: 150 },
+ ],
+ },
+ tabset("center"),
+ ],
+ },
+ });
+ model.getRootRow()!.calcMinMaxSize();
+ const column = model.getNodeById("column") as RowNode;
+ expect(getPreferredSize(column, Orientation.HORZ)).toBe(260);
+ expect(getPreferredSize(column, Orientation.VERT)).toBe(250 + model.getSplitterSize()!);
+ expect(column.toJson()).not.toHaveProperty("preferredWidth");
+ model.doAction(Actions.updateNodeAttributes("b", { preferredWidth: undefined }));
+ expect(getPreferredSize(column, Orientation.HORZ)).toBeUndefined();
+ });
+
+ it("preserves a preferred group and its orientation through normalization and loading", () => {
+ const model = Model.fromJson({
+ layout: { type: "row", children: [{ type: "row", id: "column", preferredWidth: 300, children: [{ type: "row", id: "split", children: [tabset("a"), tabset("b")] }] }, tabset("center")] },
+ });
+ expect(model.getNodeById("split")!.getOrientation()).toBe(Orientation.HORZ);
+ const restored = Model.fromJson(model.toJson());
+ expect((restored.getNodeById("column") as RowNode).getPreferredWidth()).toBe(300);
+ expect(restored.getNodeById("split")!.getParent()!.getId()).toBe("column");
+ expect(restored.getNodeById("split")!.getOrientation()).toBe(Orientation.HORZ);
+ });
+
+ it("keeps the target column preference when a tab docks below it and the row later collapses", () => {
+ const model = createModel();
+ model.doAction(Actions.moveNode("right-tab", "left", DockLocation.BOTTOM, -1));
+ const column = getTabset(model, "left").getParent() as RowNode;
+ expect(column.getPreferredWidth()).toBe(240);
+ expect(getTabset(model, "left").getPreferredWidth()).toBe(240);
+ model.doAction(Actions.updateNodeAttributes(column.getId(), { preferredWidth: 300 }));
+ model.doAction(Actions.deleteTab("right-tab"));
+ const restored = Model.fromJson(model.toJson());
+ expect(getPreferredSize(getTabset(restored, "left").getParent() as RowNode, Orientation.HORZ)).toBe(300);
+ expect(getTabset(restored, "left").getPreferredWidth()).toBe(240);
+ });
+
+ it("keeps destination preferences when tabsets merge", () => {
+ const model = createModel();
+ model.doAction(Actions.moveNode("right", "left", DockLocation.CENTER, -1));
+ expect(getTabset(model, "left").getPreferredWidth()).toBe(240);
+ expect(getTabset(model, "left").getTabNodes()).toHaveLength(2);
+ expect(model.getRootRow()!.getChildren()).toHaveLength(2);
+ });
+
+ it("keeps a preferred floated row as a group when docking would otherwise flatten it", () => {
+ const model = Model.fromJson({ layout: { type: "row", children: [{ type: "row", children: [tabset("a"), tabset("b")] }] } });
+ const layoutId = model.doAction(
+ Actions.createSubLayout({ type: "row", id: "group", preferredWidth: 320, children: [tabset("first"), tabset("second")] }, { x: 20, y: 20, width: 400, height: 300 }, "float"),
+ ) as string;
+ model.doAction(Actions.dockFloatToLayout(layoutId, "a", DockLocation.LEFT, -1));
+ const restored = Model.fromJson(model.toJson());
+ const group = restored.getNodeById("group") as RowNode;
+ expect(group.getPreferredWidth()).toBe(320);
+ expect(group.getOrientation()).toBe(Orientation.HORZ);
+ expect(group.getChildren().map((child) => child.getId())).toEqual(["first", "second"]);
+ expect(getPreferredSize(group.getParent() as RowNode, Orientation.HORZ)).toBe(320);
+ });
+
+ it("carries whole-tabset preferences into floats while keeping frame geometry separate", () => {
+ const model = createModel();
+ measure(model, [240, 480, 280]);
+ model.doAction(Actions.popoutTabset("right", "float"));
+ const right = getTabset(model, "right");
+ expect(right.getPreferredWidth()).toBe(280);
+ const layoutId = right.getLayoutId();
+ model.doAction(Actions.moveFloat(layoutId, new Rect(20, 20, 400, 300)));
+ expect(getTabset(Model.fromJson(model.toJson()), "right").getPreferredWidth()).toBe(280);
+ });
+});
diff --git a/tests/view/LayoutControllerResize.test.tsx b/tests/view/LayoutControllerResize.test.tsx
new file mode 100644
index 00000000..589d4d1b
--- /dev/null
+++ b/tests/view/LayoutControllerResize.test.tsx
@@ -0,0 +1,112 @@
+// @vitest-environment jsdom
+import { Action, Actions, IJsonModel, Model, RowNode, TabSetNode } from "../../src";
+import { createController } from "./testUtils";
+
+const panel = (id: string, preferredWidth?: number) => ({ type: "tabset" as const, id, preferredWidth, children: [{ type: "tab" as const, id: id + "-tab", name: id }] });
+const initialJson = (): IJsonModel => ({ layout: { type: "row", id: "root", children: [panel("left", 240), panel("center"), panel("right", 280)] } });
+
+function setup(json = initialJson()) {
+ const model = Model.fromJson(json);
+ model.getRootRow()!.calcMinMaxSize();
+ const onAction = vi.fn((action: Action): Action | undefined => action);
+ const onModelChange = vi.fn();
+ const controller = createController(model, { onAction, onModelChange });
+ model.addChangeListener(controller.onModelChange);
+ const redraw = vi.spyOn(controller, "redrawLayout");
+ const measure = vi.spyOn(controller, "syncLayoutMetrics").mockReturnValue(false);
+ const position = vi.spyOn(controller, "positionTabPanels").mockImplementation(() => {});
+ const elements = new Map();
+ model.visitLayoutNodes(Model.MAIN_LAYOUT_ID, (node) => {
+ if (node instanceof RowNode || node instanceof TabSetNode) {
+ const element = document.createElement("div");
+ element.style.flexBasis = "1px";
+ document.body.appendChild(element);
+ controller.registerMeasurable(node, node instanceof RowNode ? "row" : "tabset", element);
+ elements.set(node.getId(), element);
+ }
+ });
+ return { model, controller, onAction, onModelChange, redraw, measure, position, elements };
+}
+
+const resize = () =>
+ Actions.group([Actions.adjustWeights("root", [30, 42, 28]), Actions.updateNodeAttributes("left", { preferredWidth: 300 }), Actions.updateNodeAttributes("right", { preferredWidth: 280 })]);
+
+afterEach(() => {
+ document.body.replaceChildren();
+ vi.restoreAllMocks();
+});
+
+describe("preferred-size live resizing", () => {
+ it("updates styles and measures once without redrawing, then redraws on completion", () => {
+ const { controller, onAction, onModelChange, redraw, measure, position, elements } = setup();
+ const action = resize().setAdjusting(true);
+ controller.doAction(action);
+ expect(onAction).toHaveBeenCalledExactlyOnceWith(action);
+ expect(onModelChange).toHaveBeenCalledExactlyOnceWith(controller.getModel(), action);
+ expect(elements.get("left")!.style.flexBasis).toBe("300px");
+ expect(elements.get("left")!.style.flexGrow).toBe("0");
+ expect(elements.get("right")!.style.flexBasis).toBe("280px");
+ expect(Number(elements.get("center")!.style.flexGrow)).toBeGreaterThan(0);
+ expect(measure).toHaveBeenCalledTimes(1);
+ expect(position).toHaveBeenCalledTimes(1);
+ expect(redraw).not.toHaveBeenCalled();
+ controller.doAction(resize());
+ expect(redraw).toHaveBeenCalledTimes(1);
+ });
+
+ it("lets onAction cancel the entire resize before any model or style updates", () => {
+ const { model, controller, onAction, onModelChange, elements } = setup();
+ onAction.mockReturnValue(undefined);
+ controller.doAction(resize().setAdjusting(true));
+ expect((model.getNodeById("left") as TabSetNode).getPreferredWidth()).toBe(240);
+ expect(onModelChange).not.toHaveBeenCalled();
+ expect(elements.get("left")!.style.flexBasis).toBe("1px");
+ });
+
+ it("refreshes preferences even when a child's weight entry is missing", () => {
+ const { controller, elements, redraw } = setup();
+ controller.doAction(Actions.group([Actions.adjustWeights("root", [30]), Actions.updateNodeAttributes("right", { preferredWidth: 320 })]).setAdjusting(true));
+ expect(elements.get("right")!.style.flexBasis).toBe("320px");
+ expect(redraw).not.toHaveBeenCalled();
+ });
+
+ it("refreshes derived ancestors and makes them flexible when a child's preference is cleared", () => {
+ const { controller, elements, redraw } = setup({
+ layout: { type: "row", id: "root", children: [{ type: "row", id: "column", children: [panel("top", 240), panel("bottom", 240)] }, panel("center")] },
+ });
+ controller.doAction(Actions.group([Actions.adjustWeights("column", [50, 50]), Actions.updateNodeAttributes("top", { preferredWidth: 320 })]).setAdjusting(true));
+ expect(elements.get("column")!.style.flexBasis).toBe("320px");
+ expect(elements.get("column")!.style.flexGrow).toBe("0");
+ controller.doAction(Actions.group([Actions.adjustWeights("column", [50, 50]), Actions.updateNodeAttributes("top", { preferredWidth: undefined })]).setAdjusting(true));
+ expect(elements.get("column")!.style.flexBasis).toBe("");
+ expect(elements.get("column")!.style.flexShrink).toBe("");
+ expect(Number(elements.get("column")!.style.flexGrow)).toBeGreaterThan(0);
+ expect(redraw).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["other attributes", () => Actions.updateNodeAttributes("left", { preferredWidth: 300, minWidth: 200 })],
+ ["other nodes", () => Actions.updateNodeAttributes("root", { preferredWidth: 300 })],
+ ["structural actions", () => Actions.deleteTabset("right")],
+ ["nested groups", () => Actions.group([Actions.updateNodeAttributes("left", { preferredWidth: 300 })])],
+ ])("redraws groups containing %s", (_description, extra) => {
+ const { controller, redraw, measure, elements } = setup();
+ controller.doAction(Actions.group([Actions.adjustWeights("root", [30, 42, 28]), extra()]).setAdjusting(true));
+ expect(redraw).toHaveBeenCalledTimes(1);
+ expect(measure).not.toHaveBeenCalled();
+ expect(elements.get("left")!.style.flexBasis).toBe("1px");
+ });
+
+ it.each(["missing", "disconnected"])("falls back before writing any styles when a registration is %s", (kind) => {
+ const { model, controller, redraw, measure, elements } = setup();
+ if (kind === "missing") {
+ controller.registerMeasurable(model.getNodeById("right")!, "tabset", null);
+ } else {
+ elements.get("right")!.remove();
+ }
+ controller.doAction(resize().setAdjusting(true));
+ expect(redraw).toHaveBeenCalledTimes(1);
+ expect(measure).not.toHaveBeenCalled();
+ expect(elements.get("left")!.style.flexBasis).toBe("1px");
+ });
+});