From 0bfc9306b6574f68db39cafc9fb23df62ec9a7e1 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 7 Oct 2026 18:48:38 -0400 Subject: [PATCH 1/3] Add preferred pixel sizing for rows and tabsets --- README.md | 12 + examples/index.html | 1 + examples/preferred-sizes/PreferredSizes.tsx | 104 +++++++ examples/preferred-sizes/index.html | 22 ++ examples/vite.config.examples.ts | 1 + src/model/IJsonModel.ts | 28 ++ src/model/RowNode.ts | 55 +++- src/model/Sizing.ts | 26 ++ src/model/TabSetNode.ts | 39 ++- src/view/FlexStyle.ts | 16 ++ src/view/Layout.tsx | 4 +- src/view/Row.tsx | 3 +- src/view/Splitter.tsx | 8 +- src/view/TabSet.tsx | 3 +- src/view/layout/LayoutController.tsx | 59 +++- tests-playwright/preferred-sizes.spec.ts | 299 ++++++++++++++++++++ tests/PreferredSizes.test.ts | 141 +++++++++ tests/view/LayoutControllerResize.test.tsx | 110 +++++++ 18 files changed, 901 insertions(+), 30 deletions(-) create mode 100644 examples/preferred-sizes/PreferredSizes.tsx create mode 100644 examples/preferred-sizes/index.html create mode 100644 src/model/Sizing.ts create mode 100644 src/view/FlexStyle.ts create mode 100644 tests-playwright/preferred-sizes.spec.ts create mode 100644 tests/PreferredSizes.test.ts create mode 100644 tests/view/LayoutControllerResize.test.tsx diff --git a/README.md b/README.md index 1fa0241b..b5cb6e7c 100755 --- a/README.md +++ b/README.md @@ -186,6 +186,18 @@ 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. + +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. + +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 ( +
    + +
    + + + + + + +
    +
    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..33842cbb 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; splitters update it, while passive resizing preserves it + + Default: undefined + */ + preferredHeight?: number; + + /** + preferred outer width in pixels; splitters update it, while passive resizing preserves it + + Default: undefined + */ + preferredWidth?: number; + /** @@ -731,6 +745,20 @@ export interface ITabSetAttributes { */ name?: string; + /** + preferred outer height in pixels; splitters update it, while passive resizing preserves it + + Default: undefined + */ + preferredHeight?: number; + + /** + preferred outer width in pixels; splitters update it, while passive resizing preserves it + + Default: undefined + */ + preferredWidth?: number; + /** index of selected/visible tab in tabset; -1 means no tab selected diff --git a/src/model/RowNode.ts b/src/model/RowNode.ts index a249c444..1e21c5a6 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,7 +174,39 @@ 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 weights = this.calculateSplit(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 = (weights[i] * initials.sum) / 100; + 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 */ @@ -325,7 +374,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); @@ -551,6 +600,8 @@ export class RowNode extends Node implements IDropTarget { 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("preferredWidth", undefined).setType(Attribute.NUMBER).setDescription(`preferred outer width in pixels; splitters update it, while passive resizing preserves it`); + attributeDefinitions.add("preferredHeight", undefined).setType(Attribute.NUMBER).setDescription(`preferred outer height in pixels; 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..f27f8037 --- /dev/null +++ b/src/model/Sizing.ts @@ -0,0 +1,26 @@ +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) => getPreferredSize(child, orientation)); + 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..bef51ba2 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); @@ -592,6 +611,8 @@ export class TabSetNode extends Node implements IDraggable, IDropTarget { 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("preferredWidth", undefined).setType(Attribute.NUMBER).setDescription(`preferred outer width in pixels; splitters update it, while passive resizing preserves it`); + attributeDefinitions.add("preferredHeight", undefined).setType(Attribute.NUMBER).setDescription(`preferred outer height in pixels; 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..c0e29d11 --- /dev/null +++ b/src/view/FlexStyle.ts @@ -0,0 +1,16 @@ +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; + return { + flexGrow: preferred === undefined ? Math.max(1, node.getWeight() * 1000) : 0, + flexShrink: 1, + flexBasis: preferred ?? 0, + }; +} 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..8a9b6e25 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,39 @@ 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().filter((_child, i) => updatePreferences || (typeof weights?.[i] === "number" && Number.isFinite(weights[i]))) as (RowNode | TabSetNode)[]; + if (updatePreferences) { + // Preferred sizes can change the effective basis of each containing row. + let ancestor: Node | undefined = row; + while (ancestor instanceof RowNode) { + nodes.push(ancestor); + ancestor = ancestor.getParent(); } + } + const elements: HTMLElement[] = []; + for (const 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 style = getFlexStyle(nodes[i]); + element.style.flexGrow = String(style.flexGrow); + element.style.flexShrink = String(style.flexShrink); + element.style.flexBasis = 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..b2828407 --- /dev/null +++ b/tests-playwright/preferred-sizes.spec.ts @@ -0,0 +1,299 @@ +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); +}); + +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("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..00149b78 --- /dev/null +++ b/tests/PreferredSizes.test.ts @@ -0,0 +1,141 @@ +// @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("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"), + ], + }, + }); + 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.deleteTab("right-tab")); + const restored = Model.fromJson(model.toJson()); + expect(getPreferredSize(getTabset(restored, "left").getParent() as RowNode, Orientation.HORZ)).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..f46a7aa6 --- /dev/null +++ b/tests/view/LayoutControllerResize.test.tsx @@ -0,0 +1,110 @@ +// @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); + 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("0px"); + 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"); + }); +}); From 425e06391da942bd71045b65af5c905a8e8ff5a5 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 7 Oct 2026 19:10:38 -0400 Subject: [PATCH 2/3] Fix preferred sizing compatibility and boundary cases --- README.md | 2 +- src/model/RowNode.ts | 16 +++-- src/model/Sizing.ts | 10 ++- src/view/FlexStyle.ts | 7 +- src/view/layout/LayoutController.tsx | 20 ++++-- tests-playwright/preferred-sizes.spec.ts | 81 ++++++++++++++++++++++ tests/PreferredSizes.test.ts | 16 ++++- tests/view/LayoutControllerResize.test.tsx | 4 +- 8 files changed, 137 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b5cb6e7c..8ea8e0c4 100755 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Preferred sizes describe the outer panel dimensions, including its tabstrip/head 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. +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. diff --git a/src/model/RowNode.ts b/src/model/RowNode.ts index 1e21c5a6..2a1aa90f 100755 --- a/src/model/RowNode.ts +++ b/src/model/RowNode.ts @@ -184,7 +184,7 @@ export class RowNode extends Node implements IDropTarget { /** @internal */ calculateSplitAction(index: number, splitterPos: number, initials: ReturnType): Action { - const weights = this.calculateSplit(index, splitterPos, initials.initialSizes, initials.sum, initials.startPosition); + 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; @@ -197,7 +197,7 @@ export class RowNode extends Node implements IDropTarget { continue; } const child = this.children[i] as RowNode | TabSetNode; - const size = (weights[i] * initials.sum) / 100; + const size = sizes[i]; const delta = size - initials.initialSizes[i]; const h = this.getOrientation() === Orientation.HORZ; const min = h ? child.getMinWidth() : child.getMinHeight(); @@ -211,15 +211,19 @@ export class RowNode extends Node implements IDropTarget { /** @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) { @@ -300,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 */ diff --git a/src/model/Sizing.ts b/src/model/Sizing.ts index f27f8037..93362302 100644 --- a/src/model/Sizing.ts +++ b/src/model/Sizing.ts @@ -17,7 +17,15 @@ export function getPreferredSize(node: RowNode | TabSetNode, orientation: Orient if (children.length === 0) { return undefined; } - const sizes = children.map((child) => getPreferredSize(child, orientation)); + 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; } diff --git a/src/view/FlexStyle.ts b/src/view/FlexStyle.ts index c0e29d11..c32548df 100644 --- a/src/view/FlexStyle.ts +++ b/src/view/FlexStyle.ts @@ -8,9 +8,12 @@ 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: preferred === undefined ? Math.max(1, node.getWeight() * 1000) : 0, + flexGrow: 0, flexShrink: 1, - flexBasis: preferred ?? 0, + flexBasis: preferred, }; } diff --git a/src/view/layout/LayoutController.tsx b/src/view/layout/LayoutController.tsx index 8a9b6e25..2e66f6f1 100644 --- a/src/view/layout/LayoutController.tsx +++ b/src/view/layout/LayoutController.tsx @@ -336,17 +336,20 @@ export class LayoutController { return; } const weights = action.data.weights as number[] | undefined; - const nodes = row.getChildren().filter((_child, i) => updatePreferences || (typeof weights?.[i] === "number" && Number.isFinite(weights[i]))) as (RowNode | TabSetNode)[]; + 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(ancestor); + nodes.push({ node: ancestor, weight: undefined }); ancestor = ancestor.getParent(); } } const elements: HTMLElement[] = []; - for (const child of nodes) { + 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 || !element.isConnected || child.getLayoutId() !== this._layoutId) { @@ -358,10 +361,13 @@ export class LayoutController { } for (let i = 0; i < nodes.length; i++) { const element = elements[i]; - const style = getFlexStyle(nodes[i]); - element.style.flexGrow = String(style.flexGrow); - element.style.flexShrink = String(style.flexShrink); - element.style.flexBasis = style.flexBasis + "px"; + 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 index b2828407..5202742d 100644 --- a/tests-playwright/preferred-sizes.spec.ts +++ b/tests-playwright/preferred-sizes.spec.ts @@ -116,6 +116,61 @@ test("window resizing preserves auxiliary widths and gives the center the differ 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); @@ -267,6 +322,32 @@ test("multiple flexible panels share remaining space by weight", async ({ page } 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 }); diff --git a/tests/PreferredSizes.test.ts b/tests/PreferredSizes.test.ts index 00149b78..bc41b0ee 100644 --- a/tests/PreferredSizes.test.ts +++ b/tests/PreferredSizes.test.ts @@ -59,6 +59,17 @@ describe("preferred panel sizes", () => { 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: { @@ -76,6 +87,7 @@ describe("preferred panel sizes", () => { ], }, }); + 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()!); @@ -101,9 +113,11 @@ describe("preferred panel sizes", () => { 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(240); + 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", () => { diff --git a/tests/view/LayoutControllerResize.test.tsx b/tests/view/LayoutControllerResize.test.tsx index f46a7aa6..589d4d1b 100644 --- a/tests/view/LayoutControllerResize.test.tsx +++ b/tests/view/LayoutControllerResize.test.tsx @@ -7,6 +7,7 @@ const initialJson = (): IJsonModel => ({ layout: { type: "row", id: "root", chil 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 }); @@ -77,7 +78,8 @@ describe("preferred-size live resizing", () => { 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("0px"); + 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(); }); From 6b947d0261d95800a9d1c10378bac3639c1b0d3c Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Wed, 7 Oct 2026 20:19:36 -0400 Subject: [PATCH 3/3] Document preferred size precedence over weights --- README.md | 2 ++ src/model/IJsonModel.ts | 12 ++++++------ src/model/RowNode.ts | 21 ++++++++++++++++++--- src/model/TabSetNode.ts | 21 ++++++++++++++++++--- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8ea8e0c4..c6b2f0a9 100755 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ Weights on rows and tabsets specify their relative size within the parent row. T 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. diff --git a/src/model/IJsonModel.ts b/src/model/IJsonModel.ts index 33842cbb..a892028c 100755 --- a/src/model/IJsonModel.ts +++ b/src/model/IJsonModel.ts @@ -563,14 +563,14 @@ export interface IRowAttributes { id?: string; /** - preferred outer height in pixels; splitters update it, while passive resizing preserves it + 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; splitters update it, while passive resizing preserves it + 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 */ @@ -584,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 */ @@ -746,14 +746,14 @@ export interface ITabSetAttributes { name?: string; /** - preferred outer height in pixels; splitters update it, while passive resizing preserves it + 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; splitters update it, while passive resizing preserves it + 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 */ @@ -781,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 2a1aa90f..2ec78c6a 100755 --- a/src/model/RowNode.ts +++ b/src/model/RowNode.ts @@ -603,9 +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("preferredWidth", undefined).setType(Attribute.NUMBER).setDescription(`preferred outer width in pixels; splitters update it, while passive resizing preserves it`); - attributeDefinitions.add("preferredHeight", undefined).setType(Attribute.NUMBER).setDescription(`preferred outer height in pixels; splitters update it, while passive resizing preserves it`); + 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/TabSetNode.ts b/src/model/TabSetNode.ts index bef51ba2..0d736fd9 100755 --- a/src/model/TabSetNode.ts +++ b/src/model/TabSetNode.ts @@ -610,9 +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("preferredWidth", undefined).setType(Attribute.NUMBER).setDescription(`preferred outer width in pixels; splitters update it, while passive resizing preserves it`); - attributeDefinitions.add("preferredHeight", undefined).setType(Attribute.NUMBER).setDescription(`preferred outer height in pixels; splitters update it, while passive resizing preserves it`); + 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`);