From 4f01ca47e0ec6c28e68b157b2b2bed1522b92d0f Mon Sep 17 00:00:00 2001 From: akash-aman Date: Sun, 4 Oct 2026 22:24:54 +0530 Subject: [PATCH 1/4] feat(core): maximize and fold tabsets - New view-state module: maximize/restore/toggleMaximize and collapse/expand/toggleCollapse, plus normalizeViewState which keeps the state valid after any tree change (a lone tabset cannot stay folded, every row keeps one open child, a maximize whose tabset left the tree is cleared). - Maximize only changes the computed output: the maximized tabset and its tabs get the root dims, everything else is flagged nodHidden. Node dims and parts are untouched, so restore is exact. - Fold: a folded tabset's minimum along its parent row is collapsedSize and it takes no share of extra space; its part is preserved so unfold restores the previous size. Folding the last open child of a row unfolds the most recently folded sibling (LeetCode behaviour). - Bonds next to a folded tabset are locked (nodLocked) and ignored by the slider; nothing moves while maximized. Any updateTree ends maximize; dragged and drop-target tabsets unfold. - nodFold / nodMaxd round-trip through toJSON and the constructor. - Fix: a single-tab layout no longer creates an empty row sibling that took half of the width. Tests: view-state suite (maximize, exact restore, fold in both axes, exact unfold, last-open swap, locked bonds, resize sweep, JSON round trip, drag-and-drop interplay, randomized moves/folds/maximizes) and a single-tab regression. --- .changeset/maximize-and-fold-core.md | 12 + packages/core/src/app/comparator.ts | 4 + packages/core/src/app/dynamix.ts | 38 +++ packages/core/src/app/geometry.ts | 58 ++++- packages/core/src/app/node.ts | 23 +- packages/core/src/app/slider.ts | 6 + packages/core/src/app/state.ts | 6 + packages/core/src/app/tree-builder.ts | 23 ++ packages/core/src/app/tree-mutations.ts | 6 + packages/core/src/app/view-state.ts | 125 ++++++++++ packages/core/src/type/index.ts | 16 ++ packages/core/test/geometry.test.ts | 9 + packages/core/test/view-state.test.ts | 315 ++++++++++++++++++++++++ 13 files changed, 630 insertions(+), 11 deletions(-) create mode 100644 .changeset/maximize-and-fold-core.md create mode 100644 packages/core/src/app/view-state.ts create mode 100644 packages/core/test/view-state.test.ts diff --git a/.changeset/maximize-and-fold-core.md b/.changeset/maximize-and-fold-core.md new file mode 100644 index 0000000..c3dc221 --- /dev/null +++ b/.changeset/maximize-and-fold-core.md @@ -0,0 +1,12 @@ +--- +'@dynamix-layout/core': minor +--- + +Maximize and fold tabsets (engine): + +- `maximize(id)`, `restore()`, `toggleMaximize(id)` and `maximizedId`: one tabset fills the whole layout; the others are hidden but stay mounted, and restoring brings back the exact previous split. +- `collapse(id)`, `expand(id)`, `toggleCollapse(id)`: a tabset folds to a strip along its row (its tab bar). Siblings share the freed space, unfolding restores the previous size, and folding the last open child of a row unfolds its most recently folded sibling. +- New `collapsedSize` option (defaults to `minH`); pass the tab bar height. +- Computed options expose `nodFold`, `nodMaxd`, `nodHidden`, `nodLocked`, `nodFoldable` and `nodMaximizable`; saved layouts keep `nodFold` (tabsets) and `nodMaxd` (root). +- Bonds next to a folded tabset are locked; any drag-and-drop move ends maximize; a dragged or drop-target tabset unfolds. +- Fix: a layout created from a single tab now fills the whole area (an empty row used to take half of it). diff --git a/packages/core/src/app/comparator.ts b/packages/core/src/app/comparator.ts index 6bd38d3..08d4b17 100644 --- a/packages/core/src/app/comparator.ts +++ b/packages/core/src/app/comparator.ts @@ -23,6 +23,10 @@ export function areNodeOptionsEqual(a: NodeOptions, b: NodeOptions): boolean { if (a.nodName !== b.nodName) return false if (a.uidNode !== b.uidNode) return false if (a.nodPart !== b.nodPart) return false + if (a.nodFold !== b.nodFold) return false + if (a.nodMaxd !== b.nodMaxd) return false + if (a.nodHidden !== b.nodHidden) return false + if (a.nodLocked !== b.nodLocked) return false const dimA = a.nodDims const dimB = b.nodDims diff --git a/packages/core/src/app/dynamix.ts b/packages/core/src/app/dynamix.ts index b593b90..28f9e7a 100644 --- a/packages/core/src/app/dynamix.ts +++ b/packages/core/src/app/dynamix.ts @@ -16,6 +16,7 @@ import * as treeBuilder from './tree-builder' import * as geometry from './geometry' import * as slider from './slider' import * as treeMutations from './tree-mutations' +import * as viewState from './view-state' class Layout { static get _root(): Node { @@ -82,6 +83,8 @@ class Layout { createId?: () => string /** Schedules deferred updates (default `setTimeout`/`clearTimeout`). */ timer?: Timer + /** Size of a folded tabset along its row; pass the tab bar height. */ + collapsedSize?: number } = {} ) { const config = { @@ -98,10 +101,12 @@ class Layout { this.tabsIds = config.tabsIds layoutState.createId = config.createId ?? defaultCreateId layoutState.timer = config.timer ?? defaultTimer + layoutState.collapsedSize = config.collapsedSize ?? config.minH // Reset on every construction so a tabs-only layout never reuses the // tree of a previously created layout. Layout._tree = config.tree ?? null + layoutState.maximized = config.tree?.nodMaxd ?? null Layout._root = new Node({ unId: config.uqid, @@ -256,6 +261,39 @@ class Layout { clearAllCache() { return treeMutations.clearAllCache() } + + /** Uid of the maximized tabset, or null. */ + get maximizedId(): string | null { + return viewState.getMaximizedTabset()?.unId ?? null + } + + /** Show one tabset over the whole layout; the others stay mounted. */ + maximize(id: string): boolean { + return viewState.maximize(this, id) + } + + /** Leave maximized mode; the previous split is restored exactly. */ + restore(): boolean { + return viewState.restore(this) + } + + toggleMaximize(id: string): boolean { + return viewState.toggleMaximize(this, id) + } + + /** Fold a tabset to a strip along its row (its tab bar). */ + collapse(id: string): boolean { + return viewState.collapse(this, id) + } + + /** Unfold a tabset; it returns to its previous size. */ + expand(id: string): boolean { + return viewState.expand(this, id) + } + + toggleCollapse(id: string): boolean { + return viewState.toggleCollapse(this, id) + } } export { Node, Bond } diff --git a/packages/core/src/app/geometry.ts b/packages/core/src/app/geometry.ts index 29f82c2..b10b07e 100644 --- a/packages/core/src/app/geometry.ts +++ b/packages/core/src/app/geometry.ts @@ -1,5 +1,11 @@ import { Node } from './node' import { layoutState } from './state' +import { + canCollapse, + canMaximize, + getMaximizedTabset, + normalizeViewState, +} from './view-state' import type { Dimension, NodeOptions, @@ -16,6 +22,7 @@ export function calcTabsetCountAndMinDim( clear: boolean = true ) { if (root === layoutState.root && clear) { + normalizeViewState(root) Node.cache.tabCnts.clear() Node.cache.dimMins.clear() Node.cache.mapElem.clear() @@ -73,6 +80,24 @@ export function calcTabsetCountAndMinDim( vertical: 0, }) } + + // A folded tabset is only its tab bar along the parent row. + if (node.collapsed && node.host) { + const rowIsHorizontal = Node.cache.mapDirs.get(node.host.unId) + if (rowIsHorizontal) { + dimMins.set(node.unId, { + minWidth: layoutState.collapsedSize, + minHeight: layoutState.minH, + }) + tabCnts.get(node.unId)!.horizontal = 0 + } else { + dimMins.set(node.unId, { + minWidth: layoutState.minW, + minHeight: layoutState.collapsedSize, + }) + tabCnts.get(node.unId)!.vertical = 0 + } + } } if (node.type === 'row') { @@ -162,12 +187,24 @@ export function calcDimensions( if (isRoot) engine.calculateRootAdjustment() + // While a tabset is maximized it takes the whole layout and everything + // else is hidden. Node dims are still computed normally, so restoring is + // exact. + const maximized = getMaximizedTabset() + const maximizable = canMaximize() + for (const node of engine.NodeLayoutIterator(root)) { node.calcDimensions() const nodeDir = Node.cache.mapDirs.get(node.unId)! if (node.type === 'tabset') { + const isMaximized = node === maximized + const hidden = !!maximized && !isMaximized + const dims = isMaximized + ? { ...layoutState.root.dims } + : { ...node.dims } + const nodeOption: NodeOptions = { typNode: node.type, nodName: node.name, @@ -175,25 +212,28 @@ export function calcDimensions( nodPart: node.part, nodOpen: node.open, nodeDir: nodeDir, + nodFold: node.collapsed && !isMaximized, + nodMaxd: isMaximized, + nodHidden: hidden, + nodFoldable: canCollapse(node), + nodMaximizable: maximizable, nodKids: Array.from(node.kids).map((kid) => { - const KidNode = { + const KidNode: NodeOptions = { typNode: kid.type, nodName: kid.name, uidNode: kid.unId, nodPart: kid.part, nodOpen: node.open === kid.name ? true : false, nodeDir: !nodeDir, - nodDims: { - ...node.dims, - }, + nodHidden: hidden, + nodFold: node.collapsed && !isMaximized, + nodDims: { ...dims }, } tabsIds.set(kid.unId, KidNode) - return KidNode as NodeOptions + return KidNode }), - nodDims: { - ...node.dims, - }, + nodDims: dims, } nodeOpts.set(node.unId, nodeOption) } @@ -205,6 +245,8 @@ export function calcDimensions( uidNode: node.next.unId, nodeDir: nodeDir, nodPart: 0, + nodHidden: !!maximized, + nodLocked: node.collapsed || !!node.next.next?.collapsed, nodDims: { ...node.next.dims, }, diff --git a/packages/core/src/app/node.ts b/packages/core/src/app/node.ts index 610c67e..f86f2cb 100644 --- a/packages/core/src/app/node.ts +++ b/packages/core/src/app/node.ts @@ -34,6 +34,10 @@ export class Node { part: number = 0 host: Node | null = null open: string = '' + /** Tabset only: folded to a strip along its parent row. */ + collapsed: boolean = false + /** Fold order, used to pick which sibling to unfold. */ + foldedAt: number = 0 constructor( options: { @@ -107,8 +111,14 @@ export class Node { } let curntOffset = 0 + // Folded tabsets keep their strip size; only open kids share the + // extra space. Their parts are left untouched so unfolding restores + // the previous split. + let lastFlexKid: Node | undefined for (const kid of this.kids) { + if (kid.collapsed) continue totalWeight += kid.part + lastFlexKid = kid } if (totalWeight === 0) return @@ -121,7 +131,6 @@ export class Node { } const isBelowMinSize = extraSpace < 0 - const lastKid = this.kids.peekBack() // Round cumulative boundaries instead of individual sizes. Each edge // stays within half a pixel of its exact position, moves at most one @@ -137,10 +146,10 @@ export class Node { } let extraChildSpace = 0 - if (!isBelowMinSize) { + if (!isBelowMinSize && !kid.collapsed) { cumWeight += kid.part const boundary = - kid === lastKid + kid === lastFlexKid ? extraSpace : Math.round((extraSpace * cumWeight) / totalWeight) extraChildSpace = boundary - prevBoundary @@ -199,6 +208,14 @@ export class Node { result.nodOpen = this.open } + if (this.collapsed) { + result.nodFold = true + } + + if (this === layoutState.root && layoutState.maximized) { + result.nodMaxd = layoutState.maximized + } + if (children.length > 0) { result.nodKids = children } diff --git a/packages/core/src/app/slider.ts b/packages/core/src/app/slider.ts index 9129a1b..dfc254c 100644 --- a/packages/core/src/app/slider.ts +++ b/packages/core/src/app/slider.ts @@ -50,6 +50,12 @@ export function updateSliderDimension( return } + // Bonds next to a folded tabset are locked, and nothing moves while a + // tabset is maximized. + if (prev.collapsed || next.collapsed || layoutState.maximized) { + return + } + const prevMinDim = Node.cache.dimMins.get(prev.unId) || { minWidth: 0, minHeight: 0, diff --git a/packages/core/src/app/state.ts b/packages/core/src/app/state.ts index 7d35492..dc4063f 100644 --- a/packages/core/src/app/state.ts +++ b/packages/core/src/app/state.ts @@ -24,4 +24,10 @@ export const layoutState = { minH: 40, bond: 10, inst: null as DynamixLayoutCore | null, + /** Uid of the maximized tabset, if any. */ + maximized: null as string | null, + /** Size of a folded tabset along its parent row (its tab bar height). */ + collapsedSize: 40, + /** Increments on every fold so the most recently folded tabset is known. */ + foldSeq: 0, } diff --git a/packages/core/src/app/tree-builder.ts b/packages/core/src/app/tree-builder.ts index eb6d581..0748a6c 100644 --- a/packages/core/src/app/tree-builder.ts +++ b/packages/core/src/app/tree-builder.ts @@ -148,6 +148,28 @@ export function createBinaryNodeTreeFromQueue( } const dir = Node.cache.mapDirs.get(host.unId) + // A single tab gets one tabset; splitting would leave an empty row + // sibling taking half of the space. + if (queue.size() === 1) { + const name = queue.dequeue() + const only = new Node({ type: 'tabset', host }) + const tab = new Node({ + type: 'tab', + host: only, + name, + unId: engine.tabsIds.get(name ?? '') ?? layoutState.createId(), + }) + only.open = tab.name + only.kids.enqueue(tab) + host.kids.enqueue(only) + + Node.cache.mapDirs.set(only.unId, !dir!) + Node.cache.mapDirs.set(tab.unId, dir!) + Node.cache.mapElem.set(only.unId, only) + Node.cache.mapElem.set(tab.unId, tab) + break + } + if (queue.size() == 2) { const leftName = queue.dequeue() const rghtName = queue.dequeue() @@ -228,6 +250,7 @@ export function createNodeFromJSON(engine: Layout, json: LayoutTree): Node { dims: { w: 0, h: 0, x: 0, y: 0 }, open: typeof json.nodOpen === 'string' ? json.nodOpen : '', }) + newNode.collapsed = json.typNode === 'tabset' && json.nodFold === true if (json.typNode === 'tab') { newNode.unId = engine.tabsIds.get(json.nodName) || json.uidNode diff --git a/packages/core/src/app/tree-mutations.ts b/packages/core/src/app/tree-mutations.ts index ee17b49..0d3cca0 100644 --- a/packages/core/src/app/tree-mutations.ts +++ b/packages/core/src/app/tree-mutations.ts @@ -86,6 +86,10 @@ export function updateTree( return false } + // Any move ends maximize, and a dragged tabset arrives unfolded. + layoutState.maximized = null + srcNode.collapsed = false + if ( desNode.type === 'tab' && (layout === 'contain' || layout === 'left' || layout === 'right') @@ -160,6 +164,7 @@ export function moveNodeAsTab( desHst.kids.insert(insertIdx, src) src.host = desHst desHst.open = src.name + desHst.collapsed = false } else if (src.type == 'tabset') { const kidFst = src.kids.peek() const kidLst = src.kids.peekBack() @@ -180,6 +185,7 @@ export function moveNodeAsTab( } desHst.open = kidLst ? kidLst.name : '' + desHst.collapsed = false } engine.updateChildDirections(desHst) diff --git a/packages/core/src/app/view-state.ts b/packages/core/src/app/view-state.ts new file mode 100644 index 0000000..7aee166 --- /dev/null +++ b/packages/core/src/app/view-state.ts @@ -0,0 +1,125 @@ +import { Node } from './node' +import { layoutState } from './state' +import type { DynamixLayoutCore as Layout } from './dynamix' + +/** Maximize and fold: view state on top of the tree that never changes parts. */ + +function findTabset(id: string): Node | null { + const node = Node.cache.mapElem.get(id) + return node instanceof Node && node.type === 'tabset' ? node : null +} + +function countTabsets(root: Node = layoutState.root): number { + let count = 0 + const walk = (node: Node) => { + if (node.type === 'tabset') count++ + for (const kid of node.kids) walk(kid) + } + walk(root) + return count +} + +/** A tabset can fold when its row has another child to take the space. */ +export function canCollapse(node: Node): boolean { + return node.type === 'tabset' && !!node.host && node.host.kids.size() > 1 +} + +export function canMaximize(): boolean { + return countTabsets() > 1 +} + +/** The maximized tabset, or null when nothing is maximized. */ +export function getMaximizedTabset(): Node | null { + const id = layoutState.maximized + return id ? findTabset(id) : null +} + +/** Unfold the most recently folded child of a row other than `except`. */ +function unfoldMostRecent(row: Node, except?: Node) { + let pick: Node | null = null + for (const kid of row.kids) { + if (kid === except || !kid.collapsed) continue + if (!pick || kid.foldedAt > pick.foldedAt) pick = kid + } + if (pick) pick.collapsed = false +} + +/** + * Keeps view state valid after any tree change: a tabset alone in its row + * cannot stay folded, every row keeps at least one open child, and a + * maximized tabset that left the tree is forgotten. + */ +export function normalizeViewState(root: Node = layoutState.root) { + let maximizedFound = false + + const walk = (node: Node) => { + if (node.type === 'tabset') { + if (node.unId === layoutState.maximized) maximizedFound = true + if (node.collapsed && !canCollapse(node)) node.collapsed = false + } + + if (node.type !== 'tab' && node.kids.size() > 0) { + const allFolded = [...node.kids].every((kid) => kid.collapsed) + if (allFolded) unfoldMostRecent(node) + } + + for (const kid of node.kids) walk(kid) + } + walk(root) + + if (!maximizedFound) layoutState.maximized = null +} + +export function maximize(engine: Layout, id: string): boolean { + if (!findTabset(id) || !canMaximize()) return false + if (layoutState.maximized === id) return false + + layoutState.maximized = id + engine.calcDimensions() + return true +} + +export function restore(engine: Layout): boolean { + if (!layoutState.maximized) return false + + layoutState.maximized = null + engine.calcDimensions() + return true +} + +export function toggleMaximize(engine: Layout, id: string): boolean { + return layoutState.maximized === id ? restore(engine) : maximize(engine, id) +} + +/** Folding the last open child of a row unfolds its most recently folded sibling. */ +export function collapse(engine: Layout, id: string): boolean { + const node = findTabset(id) + if (!node || node.collapsed || !canCollapse(node)) return false + + layoutState.maximized = null + node.collapsed = true + node.foldedAt = ++layoutState.foldSeq + + const row = node.host! + if ([...row.kids].every((kid) => kid.collapsed)) unfoldMostRecent(row, node) + + engine.calcTabsetCountAndMinDim() + engine.calcDimensions() + return true +} + +export function expand(engine: Layout, id: string): boolean { + const node = findTabset(id) + if (!node || !node.collapsed) return false + + node.collapsed = false + engine.calcTabsetCountAndMinDim() + engine.calcDimensions() + return true +} + +export function toggleCollapse(engine: Layout, id: string): boolean { + const node = findTabset(id) + if (!node) return false + return node.collapsed ? expand(engine, id) : collapse(engine, id) +} diff --git a/packages/core/src/type/index.ts b/packages/core/src/type/index.ts index 97b2845..a7dac33 100644 --- a/packages/core/src/type/index.ts +++ b/packages/core/src/type/index.ts @@ -34,10 +34,26 @@ export interface BaseNode { export type LayoutTree = Omit & { nodKids?: LayoutTree[] + /** Tabset only: folded to a strip along its parent row. */ + nodFold?: boolean + /** Root only: uid of the maximized tabset. */ + nodMaxd?: string } export interface NodeOptions extends BaseNode { nodKids?: BaseNode[] + /** Tabset: folded to a strip (ignored while it is maximized). */ + nodFold?: boolean + /** Tabset: currently maximized over the whole layout. */ + nodMaxd?: boolean + /** Hidden because another tabset is maximized (content stays mounted). */ + nodHidden?: boolean + /** Bond: next to a folded tabset, so it cannot be dragged. */ + nodLocked?: boolean + /** Tabset: can be folded (it has siblings in its row). */ + nodFoldable?: boolean + /** Tabset: can be maximized (the layout has more than one tabset). */ + nodMaximizable?: boolean } export interface RootAdjustment { diff --git a/packages/core/test/geometry.test.ts b/packages/core/test/geometry.test.ts index 03514a3..3315668 100644 --- a/packages/core/test/geometry.test.ts +++ b/packages/core/test/geometry.test.ts @@ -307,4 +307,13 @@ describe('Layout geometry', () => { [] ) }) + + it('gives a single tab the whole layout', () => { + const layout = new DynamixLayoutCore({ tabs: ['only'], bond: BOND }) + layout.updateDimension({ w: 1200, h: 800, x: 0, y: 0 }, true) + + const root = DynamixLayoutCore._root + expect(root.kids.size()).toBe(1) + expect(root.kids.peek()!.dims).toEqual({ w: 1200, h: 800, x: 0, y: 0 }) + }) }) diff --git a/packages/core/test/view-state.test.ts b/packages/core/test/view-state.test.ts new file mode 100644 index 0000000..d0fa847 --- /dev/null +++ b/packages/core/test/view-state.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { DynamixLayoutCore, Node, Bond } from '../src' +import type { LayoutTree, NodeOptions } from '../src' + +const BOND = 10 +const STRIP = 36 +const SIZE = { w: 1200, h: 800, x: 0, y: 0 } + +const tabset = (name: string, part = 100): LayoutTree => ({ + typNode: 'tabset', + nodName: '', + uidNode: `ts-${name}`, + nodPart: part, + nodOpen: name, + nodKids: [ + { typNode: 'tab', nodName: name, uidNode: `tab-${name}`, nodPart: 100 }, + ], +}) + +/** Root (side by side): a | b | column(c over d). */ +const tree = (): LayoutTree => ({ + typNode: 'row', + nodName: 'dynamix-layout-root', + uidNode: 'dynamix-layout-root', + nodPart: 100, + nodKids: [ + tabset('a', 120), + tabset('b', 80), + { + typNode: 'row', + nodName: '', + uidNode: 'col', + nodPart: 100, + nodKids: [tabset('c'), tabset('d')], + }, + ], +}) + +const create = (layoutTree: LayoutTree = tree()) => { + const layout = new DynamixLayoutCore({ + tree: layoutTree, + bond: BOND, + minW: 40, + minH: 40, + collapsedSize: STRIP, + }) + layout.updateDimension(SIZE, true) + return layout +} + +const opts = (id: string) => Node.cache.nodOpts.get().get(id) as NodeOptions +const dims = (id: string) => ({ ...opts(id).nodDims }) +const node = (id: string) => Node.cache.mapElem.get(id) as Node + +/** Kids (plus bonds) must tile every row exactly. */ +const tilingProblems = (): string[] => { + const problems: string[] = [] + const walk = (n: Node) => { + const kids = [...n.kids] + if (n.type === 'row' && kids.length) { + const horizontal = Node.cache.mapDirs.get(n.unId) + const [pos, size] = horizontal + ? (['x', 'w'] as const) + : (['y', 'h'] as const) + let cursor = n.dims[pos] + for (const k of kids) { + if (k.dims[pos] !== cursor) + problems.push(`${k.unId} at ${k.dims[pos]}, want ${cursor}`) + cursor = k.dims[pos] + k.dims[size] + BOND + } + if (cursor - BOND !== n.dims[pos] + n.dims[size]) + problems.push(`${n.unId} not filled`) + } + kids.forEach(walk) + } + walk(DynamixLayoutCore._root) + return problems +} + +describe('Maximize', () => { + beforeEach(() => new DynamixLayoutCore().clearAllCache()) + + it('shows one tabset over the whole layout and hides the rest', () => { + const layout = create() + expect(layout.maximize('ts-c')).toBe(true) + + expect(layout.maximizedId).toBe('ts-c') + expect(opts('ts-c').nodMaxd).toBe(true) + expect(dims('ts-c')).toEqual(SIZE) + expect(Node.cache.tabOpts.get().get('tab-c')!.nodDims).toEqual(SIZE) + + for (const id of ['ts-a', 'ts-b', 'ts-d']) + expect(opts(id).nodHidden).toBe(true) + for (const bond of Node.cache.bndOpts.get().values()) + expect(bond.nodHidden).toBe(true) + }) + + it('restores the previous split exactly', () => { + const layout = create() + const before = ['ts-a', 'ts-b', 'ts-c', 'ts-d'].map(dims) + + layout.maximize('ts-b') + layout.restore() + + expect(['ts-a', 'ts-b', 'ts-c', 'ts-d'].map(dims)).toEqual(before) + expect(layout.maximizedId).toBeNull() + for (const o of Node.cache.nodOpts.get().values()) + expect(o.nodHidden).toBe(false) + }) + + it('toggles and refuses a layout with a single tabset', () => { + const layout = create() + expect(layout.toggleMaximize('ts-a')).toBe(true) + expect(layout.toggleMaximize('ts-a')).toBe(true) + expect(layout.maximizedId).toBeNull() + + new DynamixLayoutCore().clearAllCache() + const single = new DynamixLayoutCore({ tabs: ['only'] }) + single.updateDimension(SIZE, true) + const [only] = Node.cache.nodOpts.get().keys() + expect(opts(only).nodMaximizable).toBe(false) + expect(single.maximize(only)).toBe(false) + }) +}) + +describe('Fold', () => { + beforeEach(() => new DynamixLayoutCore().clearAllCache()) + + it('folds to a vertical strip in a horizontal row', () => { + const layout = create() + expect(layout.collapse('ts-a')).toBe(true) + + expect(opts('ts-a').nodFold).toBe(true) + expect(dims('ts-a').w).toBe(STRIP) + expect(dims('ts-a').h).toBe(SIZE.h) + expect(tilingProblems()).toEqual([]) + }) + + it('folds to a horizontal strip in a vertical row', () => { + const layout = create() + expect(layout.collapse('ts-c')).toBe(true) + + expect(dims('ts-c').h).toBe(STRIP) + expect(dims('ts-d').h).toBe(SIZE.h - STRIP - BOND) + expect(tilingProblems()).toEqual([]) + }) + + it('unfolds back to the exact previous size', () => { + const layout = create() + const before = ['ts-a', 'ts-b', 'ts-c', 'ts-d'].map(dims) + + layout.collapse('ts-b') + expect(dims('ts-b').w).toBe(STRIP) + layout.expand('ts-b') + + expect(['ts-a', 'ts-b', 'ts-c', 'ts-d'].map(dims)).toEqual(before) + }) + + it('folding the last open child unfolds the most recently folded sibling', () => { + const layout = create() + layout.collapse('ts-c') + expect(layout.collapse('ts-d')).toBe(true) + + expect(node('ts-d').collapsed).toBe(true) + expect(node('ts-c').collapsed).toBe(false) + expect(tilingProblems()).toEqual([]) + }) + + it('cannot fold a tabset that is alone in its row', () => { + const single = new DynamixLayoutCore({ tabs: ['only'] }) + single.updateDimension(SIZE, true) + const [only] = Node.cache.nodOpts.get().keys() + + expect(opts(only).nodFoldable).toBe(false) + expect(single.collapse(only)).toBe(false) + }) + + it('locks the bonds next to a folded tabset', () => { + const layout = create() + layout.collapse('ts-b') + + const bond = node('ts-b').prev as Bond + expect(Node.cache.bndOpts.get().get(bond.unId)!.nodLocked).toBe(true) + + const before = dims('ts-a') + layout.updateSliderDimension(bond.unId, { x: bond.dims.x + 100, y: 0 }) + expect(dims('ts-a')).toEqual(before) + }) + + it('keeps the strip size while the container resizes', () => { + const layout = create() + layout.collapse('ts-a') + + for (let w = 600; w <= 1600; w += 37) { + layout.updateDimension({ ...SIZE, w }, true) + expect(dims('ts-a').w).toBe(STRIP) + expect(tilingProblems()).toEqual([]) + } + }) + + it('folding a maximized tabset leaves maximized mode', () => { + const layout = create() + layout.maximize('ts-a') + layout.collapse('ts-a') + + expect(layout.maximizedId).toBeNull() + expect(dims('ts-a').w).toBe(STRIP) + }) +}) + +describe('View state with saved layouts and drag and drop', () => { + beforeEach(() => new DynamixLayoutCore().clearAllCache()) + + it('round-trips fold and maximize through toJSON', () => { + const layout = create() + layout.collapse('ts-a') + layout.maximize('ts-c') + const saved = DynamixLayoutCore._root.toJSON() + + expect(saved.nodMaxd).toBe('ts-c') + expect(saved.nodKids![0].nodFold).toBe(true) + + new DynamixLayoutCore().clearAllCache() + const restored = create(saved) + expect(restored.maximizedId).toBe('ts-c') + expect(node('ts-a').collapsed).toBe(true) + }) + + it('ends maximize on any move', () => { + const layout = create() + layout.maximize('ts-a') + expect(layout.updateTree('tab-b', 'tab-c', 'contain')).toBe(true) + expect(layout.maximizedId).toBeNull() + }) + + it('unfolds a tabset that receives a dropped tab', () => { + const layout = create() + layout.collapse('ts-a') + expect(layout.updateTree('tab-b', 'tab-a', 'contain')).toBe(true) + expect(node('ts-a').collapsed).toBe(false) + }) + + it('unfolds a tabset that is dragged elsewhere', () => { + const layout = create() + layout.collapse('ts-c') + expect(layout.updateTree('ts-c', 'ts-a', 'left')).toBe(true) + expect(node('ts-c').collapsed).toBe(false) + }) + + it('stays consistent under random moves, folds and maximizes', () => { + const tabs = ['a', 'b', 'c', 'd', 'e', 'f'] + const layout = new DynamixLayoutCore({ + tabs, + bond: BOND, + collapsedSize: STRIP, + }) + layout.updateDimension(SIZE, true) + + let seed = 42 + const rnd = (n: number) => { + seed = (seed * 16807) % 2147483647 + return seed % n + } + const areas = ['top', 'bottom', 'left', 'right', 'contain'] as const + const problems: string[] = [] + + for (let i = 0; i < 400 && problems.length < 5; i++) { + const nodes = [...Node.cache.mapElem.values()].filter( + (n): n is Node => n instanceof Node + ) + const tabsets = nodes.filter((n) => n.type === 'tabset') + const action = rnd(4) + if (action === 0) { + layout.toggleCollapse(tabsets[rnd(tabsets.length)].unId) + } else if (action === 1) { + layout.toggleMaximize(tabsets[rnd(tabsets.length)].unId) + } else { + const srcs = nodes.filter( + (n) => n.type === 'tab' || n.type === 'tabset' + ) + const targets = nodes.filter( + (n) => n.type !== 'row' || n === DynamixLayoutCore._root + ) + layout.updateTree( + srcs[rnd(srcs.length)].unId, + targets[rnd(targets.length)].unId, + areas[rnd(areas.length)] + ) + } + layout.updateDimension(SIZE, true) + + const found: string[] = [] + const walk = (n: Node) => { + if (n.type === 'tab') found.push(n.name) + if ( + n.type !== 'tab' && + n.kids.size() && + [...n.kids].every((k) => k.collapsed) + ) + problems.push( + `move ${i}: every child of ${n.unId} is folded` + ) + if (n.collapsed && n.host && n.host.kids.size() < 2) + problems.push(`move ${i}: ${n.unId} folded alone`) + for (const kid of n.kids) walk(kid) + } + walk(DynamixLayoutCore._root) + if (found.sort().join() !== tabs.join()) + problems.push(`move ${i}: tabs changed`) + problems.push(...tilingProblems().map((p) => `move ${i}: ${p}`)) + } + + expect(problems).toEqual([]) + }) +}) From 034fb0afced4a7a1b4ca033cbf73771780f520d4 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Sun, 4 Oct 2026 22:38:52 +0530 Subject: [PATCH 2/4] feat(react, solid): maximize and fold toolbar - DefaultTabsetToolbar (React and Solid): Maximize/Restore and Fold/Unfold buttons at the end of every tab bar, shown on hover (and always on folded strips), with aria labels and shortcut tooltips. Replaceable through the TabsetToolbar prop. - Folded tabsets in side-by-side rows render their tab bar rotated 90deg as a vertical strip (core getTabbarPlacement/placeTabbar); icons are counter-rotated so they read upright. Folded bodies stay mounted but hidden. - Maximized mode hides the other tab bars and bonds via a data-dx-hidden attribute + CSS, so components keep their own display values. - Double-click a tab bar to maximize/restore; Alt/Option + "+" / "-" toggle maximize/fold for the last touched tabset. - Drag start while maximized restores first; locked bonds ignore the pointer; collapsedSize follows tabHeadHeight. - Fix: drop targeting in a tab bar only considers tab labels (the toolbar was treated as the last tab, breaking drops on the right). - New props: enableMaximize, enableCollapse, enableDoubleClickMaximize, keyboardShortcuts, TabsetToolbar (all on by default). --- .changeset/maximize-and-fold-ui.md | 12 + packages/core/src/app/dom.ts | 33 ++- packages/react/src/components/Default.tsx | 85 +++++++ packages/react/src/components/Layout.tsx | 256 ++++++++++++++-------- packages/react/src/components/layout.css | 66 +++++- packages/react/src/hooks/useLayout.ts | 117 ++++++++-- packages/react/src/types/index.ts | 24 ++ packages/solid/src/components/Default.tsx | 71 ++++++ packages/solid/src/components/Layout.tsx | 244 +++++++++++++-------- packages/solid/src/components/layout.css | 67 +++++- packages/solid/src/hooks/useLayout.ts | 87 +++++++- packages/solid/src/types/index.ts | 24 ++ 12 files changed, 871 insertions(+), 215 deletions(-) create mode 100644 .changeset/maximize-and-fold-ui.md diff --git a/.changeset/maximize-and-fold-ui.md b/.changeset/maximize-and-fold-ui.md new file mode 100644 index 0000000..953f001 --- /dev/null +++ b/.changeset/maximize-and-fold-ui.md @@ -0,0 +1,12 @@ +--- +'@dynamix-layout/react': minor +'@dynamix-layout/solid': minor +--- + +Maximize and fold tabsets (UI), on by default: + +- Hover toolbar at the end of every tab bar with Maximize/Restore and Fold/Unfold buttons (accessible labels and tooltips), like LeetCode. +- Folded tabsets in side-by-side rows show their tab bar as a rotated vertical strip with the toolbar at the bottom; in stacked rows they shrink to their tab bar. +- Double-click a tab bar to maximize/restore; Alt/Option + `+` maximizes and Alt/Option + `-` folds the last touched tabset. +- New props: `enableMaximize`, `enableCollapse`, `enableDoubleClickMaximize`, `keyboardShortcuts`, and `TabsetToolbar` to replace the toolbar. +- Starting a drag while maximized restores the layout first; locked bonds next to folded tabsets ignore the pointer. diff --git a/packages/core/src/app/dom.ts b/packages/core/src/app/dom.ts index 7d5b305..aa1e330 100644 --- a/packages/core/src/app/dom.ts +++ b/packages/core/src/app/dom.ts @@ -1,4 +1,4 @@ -import type { Dimension } from '../type' +import type { Dimension, NodeOptions } from '../type' export function setElementRect(el: HTMLElement, { x, y, w, h }: Dimension) { el.style.left = `${x}px` @@ -20,6 +20,37 @@ export function getTabBodyRect( } } +export interface TabbarPlacement { + /** Box to give the tab bar element before `rotated` is applied. */ + rect: Dimension + /** Folded tabset in a side-by-side row: the bar is drawn as a vertical strip. */ + rotated: boolean +} + +/** + * Where a tabset's tab bar goes. A folded tabset in a side-by-side row shows + * its tab bar rotated 90 degrees: an element `h` wide and `w` tall, rotated + * around its top-left corner and placed at `x + w`, covers exactly the strip. + */ +export function getTabbarPlacement( + tabset: Pick, + tabbarHeight: number +): TabbarPlacement { + const { x, y, w, h } = tabset.nodDims + // A tabset's own direction is the opposite of its row's. + const rowIsHorizontal = tabset.nodeDir === false + if (tabset.nodFold && rowIsHorizontal) { + return { rect: { x: x + w, y, w: h, h: w }, rotated: true } + } + return { rect: { x, y, w, h: tabbarHeight }, rotated: false } +} + +export function placeTabbar(el: HTMLElement, placement: TabbarPlacement) { + setElementRect(el, placement.rect) + el.style.transformOrigin = '0 0' + el.style.transform = placement.rotated ? 'rotate(90deg)' : '' +} + export interface FrameScheduler { /** Remember `value`; `onFlush` runs with the latest value on the next frame. */ schedule: (value: T) => void diff --git a/packages/react/src/components/Default.tsx b/packages/react/src/components/Default.tsx index c76e70d..6876a92 100644 --- a/packages/react/src/components/Default.tsx +++ b/packages/react/src/components/Default.tsx @@ -1,4 +1,5 @@ import React, { forwardRef, HTMLAttributes, ReactNode } from 'react' +import type { TabsetToolbarProps } from '../types' type DefaultWrapTabLabelProps = HTMLAttributes & { active?: boolean @@ -297,3 +298,87 @@ export const RootSplitterHoverEl = forwardRef< } ) RootSplitterHoverEl.displayName = 'RootSplitterHoverEl' + +const ToolbarIcon = ({ + path, + rotate = 0, +}: { + path: string + rotate?: number +}) => ( + +) + +const ICON_MAXIMIZE = + 'M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3' +const ICON_RESTORE = + 'M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3' +const ICON_CHEVRON_LEFT = 'm15 18-6-6 6-6' + +export const DefaultTabsetToolbar = ({ + maximized, + folded, + rotated, + rowIsHorizontal, + showMaximize, + showFold, + onToggleMaximize, + onToggleFold, +}: TabsetToolbarProps) => { + // Fold points back along the row (left or up); unfold points the other way. + // Inside a rotated strip the icons are turned back so they read upright. + const chevronTurn = (rowIsHorizontal ? 0 : 90) + (folded ? 180 : 0) + const upright = rotated ? -90 : 0 + + return ( +
e.stopPropagation()} + > + {showMaximize && ( + + )} + {showFold && ( + + )} +
+ ) +} diff --git a/packages/react/src/components/Layout.tsx b/packages/react/src/components/Layout.tsx index 39dcb50..6046a55 100644 --- a/packages/react/src/components/Layout.tsx +++ b/packages/react/src/components/Layout.tsx @@ -6,7 +6,11 @@ import React, { useRef, } from 'react' import { useDynamixLayout } from '../hooks/useLayout' -import { DynamixLayoutCore, NodeOptions } from '@dynamix-layout/core' +import { + DynamixLayoutCore, + NodeOptions, + getTabbarPlacement, +} from '@dynamix-layout/core' import { LayoutProps, TabEntry, TabInput, TabOutput } from '../types' import { DefaultHoverElement, @@ -15,6 +19,7 @@ import { DefaultWrapTabHead, DefaultSliderElement, DefaultWrapTabBody, + DefaultTabsetToolbar, RootSplitterHoverEl, } from './Default' import './layout.css' @@ -87,6 +92,11 @@ export const DynamixLayout: ForwardRefExoticComponent< tabHeadElementClass, tabLabelElementClass, RootSplitterHoverElClass, + enableMaximize = true, + enableCollapse = true, + enableDoubleClickMaximize = true, + keyboardShortcuts = true, + TabsetToolbar = DefaultTabsetToolbar, ...props }: LayoutProps, ref? @@ -128,6 +138,10 @@ export const DynamixLayout: ForwardRefExoticComponent< handleRootSplit, tabsRef, isUpdating, + toggleMaximize, + toggleCollapse, + onRootPointerDown, + onTabbarDoubleClick, } = useDynamixLayout({ tabOutput, rootId, @@ -144,8 +158,13 @@ export const DynamixLayout: ForwardRefExoticComponent< disableSliderTimeout, disableResizeTimeout, hoverElementStyles, + keyboardShortcuts, + enableDoubleClickMaximize, }) + // Folding needs the tab bar: a folded tabset is only its tab bar. + const canFold = enableCollapse && enableTabbar + return (
{enableTabbar && tabsets && - Array.from(tabsets.values()).map((tabset) => ( - - { - const id = tabset.uidNode - if (el) panelsRef.current.set(id, el) - else panelsRef.current.delete(id) - }} - onDragEnd={onDragEnd} - onDragOver={onDragOver} - onDragEnter={onDragEnter} - onDragLeave={onDragLeave} - onDrop={onDrop} - data-uid={tabset.uidNode} - data-type={tabset.typNode} - className={ - 'hide-scrollbar DefaultWrapTabPanel ' + - (tabPanelElementClass || '') - } - style={{ - position: 'absolute', - ...tabPanelElementStyles, - width: `${tabset.nodDims.w}px`, - height: `${tabset.nodDims.h}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - backgroundColor: 'transparent', - }} - /> + Array.from(tabsets.values()).map((tabset) => { + const placement = getTabbarPlacement( + tabset, + tabHeadHeight + ) + const showMaximize = + enableMaximize && !!tabset.nodMaximizable + const showFold = + canFold && !!tabset.nodFoldable && !tabset.nodMaxd + const hasToolbar = showMaximize || showFold - { - const id = tabset.uidNode - if (el) tabsetsRef.current.set(id, el) - else tabsetsRef.current.delete(id) - }} - className={ - 'hide-scrollbar ' + - (tabHeadElementClass || '') - } - style={{ - ...tabHeadElementStyles, - position: 'absolute', - zIndex: 99, - width: `${tabset.nodDims.w}px`, - height: `${tabHeadHeight}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - cursor: isUpdating ? 'wait' : 'pointer', - }} - > - {tabset.nodKids && - tabset.nodKids.map((tab: NodeOptions) => ( - + { + const id = tabset.uidNode + if (el) panelsRef.current.set(id, el) + else panelsRef.current.delete(id) + }} + onDragEnd={onDragEnd} + onDragOver={onDragOver} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDrop={onDrop} + data-uid={tabset.uidNode} + data-type={tabset.typNode} + className={ + 'hide-scrollbar DefaultWrapTabPanel ' + + (tabPanelElementClass || '') + } + style={{ + position: 'absolute', + ...tabPanelElementStyles, + width: `${tabset.nodDims.w}px`, + height: `${tabset.nodDims.h}px`, + left: `${tabset.nodDims.x}px`, + top: `${tabset.nodDims.y}px`, + backgroundColor: 'transparent', + }} + /> + + { + const id = tabset.uidNode + if (el) tabsetsRef.current.set(id, el) + else tabsetsRef.current.delete(id) + }} + className={ + 'hide-scrollbar ' + + (tabHeadElementClass || '') + } + style={{ + ...tabHeadElementStyles, + position: 'absolute', + zIndex: 99, + width: `${placement.rect.w}px`, + height: `${placement.rect.h}px`, + left: `${placement.rect.x}px`, + top: `${placement.rect.y}px`, + transformOrigin: '0 0', + // Only add keys when needed: an explicit `undefined` + // would wipe the tab bar component's own value. + ...(placement.rotated + ? { transform: 'rotate(90deg)' } + : {}), + ...(hasToolbar + ? { paddingRight: '64px' } + : {}), + cursor: isUpdating ? 'wait' : 'pointer', + }} + > + {tabset.nodKids && + tabset.nodKids.map( + (tab: NodeOptions) => ( + + {tabNames.get( + tab.nodName + ) || tab.nodName} + + ) + )} + {hasToolbar && ( + - {tabNames.get(tab.nodName) || - tab.nodName} - - ))} - - - ))} + showMaximize={showMaximize} + showFold={showFold} + onToggleMaximize={() => + toggleMaximize(tabset.uidNode) + } + onToggleFold={() => + toggleCollapse(tabset.uidNode) + } + /> + )} + + + ) + })} {tabOutput.keys.map((key) => { const tabId = tabOutput.maps.get(key)?.uqid @@ -294,6 +370,7 @@ export const DynamixLayout: ForwardRefExoticComponent< key={slider.uidNode} id={slider.uidNode} data-uid={slider.uidNode} + data-dx-hidden={slider.nodHidden ? '' : undefined} direction={slider.nodeDir} onPointerDown={onPointerDown} className={ @@ -312,6 +389,9 @@ export const DynamixLayout: ForwardRefExoticComponent< cursor: slider.nodeDir ? 'ns-resize' : 'ew-resize', + ...(slider.nodLocked + ? { pointerEvents: 'none' as const } + : {}), ...sliderElementStyles, }} /> diff --git a/packages/react/src/components/layout.css b/packages/react/src/components/layout.css index 6c900d7..43ac7e5 100644 --- a/packages/react/src/components/layout.css +++ b/packages/react/src/components/layout.css @@ -4,26 +4,76 @@ /* --- Tab Label State --- */ /* Controls the background color of the DefaultWrapTabLabel component based on its state. */ .DefaultWrapTabLabel[data-state='active'] { - background-color: #ffffff; - font-family: 'Poppins', sans-serif; + background-color: #ffffff; + font-family: 'Poppins', sans-serif; } .DefaultWrapTabLabel[data-state='inactive'] { - background-color: #dfdfdf; - font-family: 'Poppins', sans-serif; + background-color: #dfdfdf; + font-family: 'Poppins', sans-serif; } /* --- Scrollbar Hiding --- */ /* Hides the scrollbar for WebKit-based browsers (Chrome, Safari, Opera) in components using the 'hide-scrollbar' class. */ .hide-scrollbar::-webkit-scrollbar { - display: none; + display: none; } /* --- Dragging State --- */ /* Prevents child elements (like a text editor) inside DefaultWrapTabBody from capturing mouse events when a parent container has the 'is-dragging' class. This ensures smooth drag-and-drop behavior. */ -.is-dragging .DefaultWrapTabBody>* { - pointer-events: none; -} \ No newline at end of file +.is-dragging .DefaultWrapTabBody > * { + pointer-events: none; +} +/* --- Tabset toolbar (maximize / fold) --- */ +/* Shown while the tab bar is hovered or focused, like LeetCode. Folded strips + always show it so they can be unfolded. */ +.DefaultTabsetToolbar { + position: absolute; + top: 0; + right: 6px; + height: 100%; + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; +} + +[data-tabbar]:hover .DefaultTabsetToolbar, +[data-tabbar][data-folded] .DefaultTabsetToolbar, +.DefaultTabsetToolbar:focus-within { + opacity: 1; +} + +@media (hover: none) { + .DefaultTabsetToolbar { + opacity: 1; + } +} + +.DefaultTabsetToolbarButton { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.DefaultTabsetToolbarButton:hover, +.DefaultTabsetToolbarButton:focus-visible { + background-color: rgba(0, 0, 0, 0.08); +} + +/* Hidden while another tabset is maximized. An attribute (not inline display) + keeps each component's own display value intact. */ +[data-dx-hidden] { + display: none !important; +} diff --git a/packages/react/src/hooks/useLayout.ts b/packages/react/src/hooks/useLayout.ts index 65bbe6e..5edca6c 100644 --- a/packages/react/src/hooks/useLayout.ts +++ b/packages/react/src/hooks/useLayout.ts @@ -10,7 +10,9 @@ import { getRootSplitPreview, getTabBodyRect, getTabsetDropPreview, + getTabbarPlacement, isSameDropPreview, + placeTabbar, setElementRect, } from '@dynamix-layout/core' import React, { useState, useEffect } from 'react' @@ -31,6 +33,8 @@ export const useDynamixLayout = ({ windowResizeTimeout, disableSliderTimeout, disableResizeTimeout, + keyboardShortcuts = true, + enableDoubleClickMaximize = true, }: useDynamixLayoutOptions) => { const tabsetsRef = React.useRef(new Map()) const slidersRef = React.useRef(new Map()) @@ -86,9 +90,20 @@ export const useDynamixLayout = ({ bond: bondWidth, uqid: rootId, tabsIds: tabOutput.name, + // A folded tabset shrinks to its tab bar. + collapsedSize: enableTabbar ? tabHeadHeight : minLayoutHeight, }) return LT - }, [layoutJSON, tabOutput, bondWidth, minLayoutHeight, minTabWidth, rootId]) + }, [ + layoutJSON, + tabOutput, + bondWidth, + minLayoutHeight, + minTabWidth, + rootId, + enableTabbar, + tabHeadHeight, + ]) const updateTabsets = (nodes: Map) => { const newTabsets = new Map(nodes) @@ -115,10 +130,14 @@ export const useDynamixLayout = ({ const tabsetEl = tabsetsRef.current.get(id) if (tabsetEl && enableTabbarRef.current) { - setElementRect(tabsetEl, { - ...node.nodDims, - h: tabHeadHeightRef.current, - }) + tabsetEl.toggleAttribute( + 'data-dx-hidden', + !!node.nodHidden + ) + placeTabbar( + tabsetEl, + getTabbarPlacement(node, tabHeadHeightRef.current) + ) } }) } @@ -128,7 +147,10 @@ export const useDynamixLayout = ({ (nodes: Map) => { nodes.forEach((node: NodeOptions, id: string) => { const sliderEl = slidersRef.current.get(id) - if (sliderEl) setElementRect(sliderEl, node.nodDims) + if (!sliderEl) return + setElementRect(sliderEl, node.nodDims) + sliderEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) + sliderEl.style.pointerEvents = node.nodLocked ? 'none' : '' }) } ) @@ -147,8 +169,12 @@ export const useDynamixLayout = ({ setElementRect(tabEl, body) // Empty bodies stay hidden so their borders/shadows don't // bleed onto the slider below. - tabEl.style.display = - node.nodOpen && body.h > 0 ? 'block' : 'none' + const visible = + node.nodOpen && + !node.nodFold && + !node.nodHidden && + body.h > 0 + tabEl.style.display = visible ? 'block' : 'none' }) } ) @@ -256,9 +282,12 @@ export const useDynamixLayout = ({ e.dataTransfer.dropEffect = 'move' } + // Only tab labels: the tab bar also holds the maximize/fold toolbar. const tabElems = Array.from( - e.currentTarget.childNodes - ) as HTMLDivElement[] + e.currentTarget.querySelectorAll( + ':scope > [data-type="tab"]' + ) + ) if (!hoverElementRef.current || !dragElemRef.current) return const preview = getNavbarDropPreview( @@ -309,8 +338,66 @@ export const useDynamixLayout = ({ lastHoverState.current = preview } + // --- Maximize / fold --- + const activeTabsetRef = React.useRef(null) + + const refreshViewState = () => { + updateTabsets(Node.cache.nodOpts.get()) + updateSliders(Node.cache.bndOpts.get()) + if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) + } + + const toggleMaximize = (tabsetId: string) => { + if (layoutInstance.toggleMaximize(tabsetId)) refreshViewState() + } + + const toggleCollapse = (tabsetId: string) => { + if (layoutInstance.toggleCollapse(tabsetId)) refreshViewState() + } + + /** Remembers which tabset the user last touched, for the shortcuts. */ + const onRootPointerDown = (e: React.PointerEvent) => { + const el = (e.target as HTMLElement).closest('[data-uid]') + const node = el?.dataset.uid + ? Node.cache.mapElem.get(el.dataset.uid) + : undefined + if (!(node instanceof Node)) return + if (node.type === 'tabset') activeTabsetRef.current = node.unId + else if (node.type === 'tab' && node.host) { + activeTabsetRef.current = node.host.unId + } + } + + const onTabbarDoubleClick = (e: React.MouseEvent) => { + const id = e.currentTarget.dataset.uid + if (enableDoubleClickMaximize && id) toggleMaximize(id) + } + + useEffect(() => { + if (!keyboardShortcuts) return + + const onKeyDown = (e: KeyboardEvent) => { + if (!e.altKey || e.ctrlKey || e.metaKey) return + const id = activeTabsetRef.current ?? layoutInstance.maximizedId + if (!id) return + + if (e.code === 'Equal' || e.code === 'NumpadAdd') { + e.preventDefault() + toggleMaximize(id) + } else if (e.code === 'Minus' || e.code === 'NumpadSubtract') { + e.preventDefault() + toggleCollapse(id) + } + } + + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }) + const onDragStart = (e: React.DragEvent) => { e.stopPropagation() + // Every drop target must be visible while dragging. + if (layoutInstance.restore()) refreshViewState() setDragging(true) document.body.style.cursor = 'move' if (e.currentTarget) { @@ -530,12 +617,10 @@ export const useDynamixLayout = ({ kid.nodOpen = true const tabbarHeight = enableTabbar ? tabHeadHeight : 0 const body = getTabBodyRect(kid.nodDims, tabbarHeight) + const visible = !kid.nodFold && !kid.nodHidden && body.h > 0 tabsRef.current .get(kid.uidNode) - ?.style.setProperty( - 'display', - body.h > 0 ? 'block' : 'none' - ) + ?.style.setProperty('display', visible ? 'block' : 'none') } else { kid.nodOpen = false tabsRef.current @@ -623,5 +708,9 @@ export const useDynamixLayout = ({ updateActiveTab, handleRootSplit, handleNavbarDragOver, + toggleMaximize, + toggleCollapse, + onRootPointerDown, + onTabbarDoubleClick, } } diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts index 7477aa0..34d109e 100644 --- a/packages/react/src/types/index.ts +++ b/packages/react/src/types/index.ts @@ -87,6 +87,28 @@ export interface LayoutProps { RootSplitterHoverElClass?: string rootId?: string tabNames?: Map + /** Show the maximize button on tab bars (default true). */ + enableMaximize?: boolean + /** Show the fold button on tab bars (default true; needs the tab bar). */ + enableCollapse?: boolean + /** Double-click a tab bar to maximize or restore it (default true). */ + enableDoubleClickMaximize?: boolean + /** Alt/Option + "+" maximizes, Alt/Option + "-" folds the active tabset (default true). */ + keyboardShortcuts?: boolean + TabsetToolbar?: React.ComponentType +} + +export interface TabsetToolbarProps { + maximized: boolean + folded: boolean + /** The tab bar is drawn as a rotated vertical strip (folded, side-by-side row). */ + rotated: boolean + /** The tabset's row lays its children out side by side. */ + rowIsHorizontal: boolean + showMaximize: boolean + showFold: boolean + onToggleMaximize: () => void + onToggleFold: () => void } export interface useDynamixLayoutOptions { @@ -105,4 +127,6 @@ export interface useDynamixLayoutOptions { minTabHeight: number minTabWidth: number tabHeadHeight: number + keyboardShortcuts?: boolean + enableDoubleClickMaximize?: boolean } diff --git a/packages/solid/src/components/Default.tsx b/packages/solid/src/components/Default.tsx index 5cafe14..24fdc12 100644 --- a/packages/solid/src/components/Default.tsx +++ b/packages/solid/src/components/Default.tsx @@ -1,4 +1,5 @@ import { JSX, createMemo, splitProps } from 'solid-js' +import type { TabsetToolbarProps } from '../types' type DivProps = { children?: JSX.Element @@ -267,3 +268,73 @@ export const RootSplitterHoverEl = (
) } + +const ToolbarIcon = (props: { path: string; rotate?: number }) => ( + +) + +const ICON_MAXIMIZE = + 'M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3' +const ICON_RESTORE = + 'M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3' +const ICON_CHEVRON_LEFT = 'm15 18-6-6 6-6' + +export const DefaultTabsetToolbar = (props: TabsetToolbarProps) => { + // Fold points back along the row (left or up); unfold points the other way. + // Inside a rotated strip the icons are turned back so they read upright. + const chevronTurn = () => + (props.rowIsHorizontal ? 0 : 90) + (props.folded ? 180 : 0) + const upright = () => (props.rotated ? -90 : 0) + + return ( +
e.stopPropagation()} + > + {props.showMaximize && ( + + )} + {props.showFold && ( + + )} +
+ ) +} diff --git a/packages/solid/src/components/Layout.tsx b/packages/solid/src/components/Layout.tsx index 4d0a49d..93eeec9 100644 --- a/packages/solid/src/components/Layout.tsx +++ b/packages/solid/src/components/Layout.tsx @@ -1,4 +1,8 @@ -import { DynamixLayoutCore, NodeOptions } from '@dynamix-layout/core' +import { + DynamixLayoutCore, + NodeOptions, + getTabbarPlacement, +} from '@dynamix-layout/core' import { createMemo, For, ParentComponent } from 'solid-js' import { DefaultHoverElement, @@ -7,6 +11,7 @@ import { DefaultWrapTabHead, DefaultWrapTabLabel, DefaultWrapTabPanel, + DefaultTabsetToolbar, RootSplitterHoverEl, } from './Default' import { useDynamixLayout } from '../hooks/useLayout' @@ -75,6 +80,10 @@ export const DynamixLayout: ParentComponent = (props) => { handleRootSplit, tabsRef, isUpdating, + toggleMaximize, + toggleCollapse, + onRootPointerDown, + onTabbarDoubleClick, } = useDynamixLayout({ tabOutput: tabOutput(), rootId: props.rootId || 'dynamix-layout-root', @@ -98,6 +107,8 @@ export const DynamixLayout: ParentComponent = (props) => { ? true : props.disableResizeTimeout, hoverElementStyles: props.hoverElementStyles, + keyboardShortcuts: props.keyboardShortcuts ?? true, + enableDoubleClickMaximize: props.enableDoubleClickMaximize ?? true, }) /* eslint-disable solid/reactivity */ const WrapTabPanel = props.WrapTabPanel || DefaultWrapTabPanel @@ -106,6 +117,12 @@ export const DynamixLayout: ParentComponent = (props) => { const WrapTabBody = props.WrapTabBody || DefaultWrapTabBody const SliderElement = props.SliderElement || DefaultSliderElement const HoverElement = props.HoverElement || DefaultHoverElement + const TabsetToolbar = props.TabsetToolbar || DefaultTabsetToolbar + const tabHeadHeight = props.tabHeadHeight || DynamixLayoutCore._minH + const enableTabbar = props.enableTabbar ?? true + const enableMaximize = props.enableMaximize ?? true + // Folding needs the tab bar: a folded tabset is only its tab bar. + const canFold = (props.enableCollapse ?? true) && enableTabbar return (
= (props) => { {...props} data-testid={props.rootId || 'dynamix-layout-root'} class={dragging() ? 'is-dragging' : ''} + onPointerDown={onRootPointerDown} style={{ position: 'relative', width: '100%', @@ -123,96 +141,142 @@ export const DynamixLayout: ParentComponent = (props) => { }} > - {(tabset) => ( - <> - { - const id = tabset.uidNode - if (el) panelsRef.set(id, el) - else panelsRef.delete(id) - }} - onDragEnd={onDragEnd} - onDragOver={onDragOver} - onDragEnter={onDragEnter} - onDragLeave={onDragLeave} - onDrop={onDrop} - data-uid={tabset.uidNode} - data-type={tabset.typNode} - class={ - 'hide-scrollbar DefaultWrapTabPanel ' + - (props.tabPanelElementClass || '') - } - style={{ - position: 'absolute', - ...(props.tabPanelElementStyles as object), - width: `${tabset.nodDims.w}px`, - height: `${tabset.nodDims.h}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - 'background-color': 'transparent', - }} - /> + {(tabset) => { + const placement = getTabbarPlacement(tabset, tabHeadHeight) + const showMaximize = + enableMaximize && !!tabset.nodMaximizable + const showFold = + canFold && !!tabset.nodFoldable && !tabset.nodMaxd + const hasToolbar = showMaximize || showFold - { - const id = tabset.uidNode - if (el) tabsetsRef.set(id, el) - else tabsetsRef.delete(id) - }} - class={ - 'hide-scrollbar ' + - (props.tabHeadElementClass || '') - } - style={{ - ...(props.tabHeadElementStyles as object), - position: 'absolute', - 'z-index': 99, - width: `${tabset.nodDims.w}px`, - height: `${props.tabHeadHeight || DynamixLayoutCore._minH}px`, - left: `${tabset.nodDims.x}px`, - top: `${tabset.nodDims.y}px`, - cursor: isUpdating() ? 'wait' : 'pointer', - }} - > - - {(tab: NodeOptions) => ( - + { + const id = tabset.uidNode + if (el) panelsRef.set(id, el) + else panelsRef.delete(id) + }} + onDragEnd={onDragEnd} + onDragOver={onDragOver} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDrop={onDrop} + data-uid={tabset.uidNode} + data-type={tabset.typNode} + class={ + 'hide-scrollbar DefaultWrapTabPanel ' + + (props.tabPanelElementClass || '') + } + style={{ + position: 'absolute', + ...(props.tabPanelElementStyles as object), + width: `${tabset.nodDims.w}px`, + height: `${tabset.nodDims.h}px`, + left: `${tabset.nodDims.x}px`, + top: `${tabset.nodDims.y}px`, + 'background-color': 'transparent', + }} + /> + + { + const id = tabset.uidNode + if (el) tabsetsRef.set(id, el) + else tabsetsRef.delete(id) + }} + class={ + 'hide-scrollbar ' + + (props.tabHeadElementClass || '') + } + style={{ + ...(props.tabHeadElementStyles as object), + position: 'absolute', + 'z-index': 99, + width: `${placement.rect.w}px`, + height: `${placement.rect.h}px`, + left: `${placement.rect.x}px`, + top: `${placement.rect.y}px`, + 'transform-origin': '0 0', + // Only add keys when needed so the tab bar + // component keeps its own values. + ...(placement.rotated + ? { transform: 'rotate(90deg)' } + : {}), + ...(hasToolbar + ? { 'padding-right': '64px' } + : {}), + cursor: isUpdating() ? 'wait' : 'pointer', + }} + > + + {(tab: NodeOptions) => ( + + {(props.tabNames && + props.tabNames.get( + tab.nodName + )) || + tab.nodName} + + )} + + {hasToolbar && ( + + toggleMaximize(tabset.uidNode) + } + onToggleFold={() => + toggleCollapse(tabset.uidNode) } - style={{ - cursor: isUpdating() - ? 'wait' - : 'pointer', - ...(props.tabLabelElementStyles as object), - }} - onClick={updateActiveTab} - > - {(props.tabNames && - props.tabNames.get(tab.nodName)) || - tab.nodName} - + /> )} - - - - )} + + + ) + }} @@ -257,6 +321,7 @@ export const DynamixLayout: ParentComponent = (props) => { = (props) => { left: `${slider.nodDims.x}px`, top: `${slider.nodDims.y}px`, cursor: slider.nodeDir ? 'ns-resize' : 'ew-resize', + ...(slider.nodLocked + ? { 'pointer-events': 'none' } + : {}), ...(props.sliderElementStyles as object), }} /> diff --git a/packages/solid/src/components/layout.css b/packages/solid/src/components/layout.css index 6c900d7..1de835e 100644 --- a/packages/solid/src/components/layout.css +++ b/packages/solid/src/components/layout.css @@ -4,26 +4,77 @@ /* --- Tab Label State --- */ /* Controls the background color of the DefaultWrapTabLabel component based on its state. */ .DefaultWrapTabLabel[data-state='active'] { - background-color: #ffffff; - font-family: 'Poppins', sans-serif; + background-color: #ffffff; + font-family: 'Poppins', sans-serif; } .DefaultWrapTabLabel[data-state='inactive'] { - background-color: #dfdfdf; - font-family: 'Poppins', sans-serif; + background-color: #dfdfdf; + font-family: 'Poppins', sans-serif; } /* --- Scrollbar Hiding --- */ /* Hides the scrollbar for WebKit-based browsers (Chrome, Safari, Opera) in components using the 'hide-scrollbar' class. */ .hide-scrollbar::-webkit-scrollbar { - display: none; + display: none; } /* --- Dragging State --- */ /* Prevents child elements (like a text editor) inside DefaultWrapTabBody from capturing mouse events when a parent container has the 'is-dragging' class. This ensures smooth drag-and-drop behavior. */ -.is-dragging .DefaultWrapTabBody>* { - pointer-events: none; -} \ No newline at end of file +.is-dragging .DefaultWrapTabBody > * { + pointer-events: none; +} + +/* --- Tabset toolbar (maximize / fold) --- */ +/* Shown while the tab bar is hovered or focused, like LeetCode. Folded strips + always show it so they can be unfolded. */ +.DefaultTabsetToolbar { + position: absolute; + top: 0; + right: 6px; + height: 100%; + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; +} + +[data-tabbar]:hover .DefaultTabsetToolbar, +[data-tabbar][data-folded] .DefaultTabsetToolbar, +.DefaultTabsetToolbar:focus-within { + opacity: 1; +} + +@media (hover: none) { + .DefaultTabsetToolbar { + opacity: 1; + } +} + +.DefaultTabsetToolbarButton { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.DefaultTabsetToolbarButton:hover, +.DefaultTabsetToolbarButton:focus-visible { + background-color: rgba(0, 0, 0, 0.08); +} + +/* Hidden while another tabset is maximized. An attribute (not inline display) + keeps each component's own display value intact. */ +[data-dx-hidden] { + display: none !important; +} diff --git a/packages/solid/src/hooks/useLayout.ts b/packages/solid/src/hooks/useLayout.ts index eda0392..86f4fec 100644 --- a/packages/solid/src/hooks/useLayout.ts +++ b/packages/solid/src/hooks/useLayout.ts @@ -10,7 +10,9 @@ import { getRootSplitPreview, getTabBodyRect, getTabsetDropPreview, + getTabbarPlacement, isSameDropPreview, + placeTabbar, setElementRect, } from '@dynamix-layout/core' import { createSignal, onCleanup, onMount } from 'solid-js' @@ -31,6 +33,8 @@ export const useDynamixLayout = ({ windowResizeTimeout, disableSliderTimeout, disableResizeTimeout, + keyboardShortcuts = true, + enableDoubleClickMaximize = true, }: useDynamixLayoutOptions) => { const tabsetsRef = new Map() const slidersRef = new Map() @@ -84,6 +88,8 @@ export const useDynamixLayout = ({ bond: bondWidth, uqid: rootId, tabsIds: tabOutput.name, + // A folded tabset shrinks to its tab bar. + collapsedSize: enableTabbar ? tabHeadHeight : minLayoutHeight, }) const updateTabsets = (nodes: Map) => { @@ -107,7 +113,9 @@ export const useDynamixLayout = ({ setElementRect(tabEl, body) // Empty bodies stay hidden so their borders/shadows don't bleed onto // the slider below. - tabEl.style.display = node.nodOpen && body.h > 0 ? 'block' : 'none' + const visible = + node.nodOpen && !node.nodFold && !node.nodHidden && body.h > 0 + tabEl.style.display = visible ? 'block' : 'none' }) } @@ -119,10 +127,11 @@ export const useDynamixLayout = ({ const tabsetEl = tabsetsRef.get(id) if (tabsetEl && enableTabbar) { - setElementRect(tabsetEl, { - ...node.nodDims, - h: tabHeadHeight, - }) + tabsetEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) + placeTabbar( + tabsetEl, + getTabbarPlacement(node, tabHeadHeight) + ) } }) } @@ -136,7 +145,10 @@ export const useDynamixLayout = ({ (nodes: Map) => { nodes.forEach((node: NodeOptions, id: string) => { const sliderEl = slidersRef.get(id) - if (sliderEl) setElementRect(sliderEl, node.nodDims) + if (!sliderEl) return + setElementRect(sliderEl, node.nodDims) + sliderEl.toggleAttribute('data-dx-hidden', !!node.nodHidden) + sliderEl.style.pointerEvents = node.nodLocked ? 'none' : '' }) } ) @@ -222,9 +234,12 @@ export const useDynamixLayout = ({ } const navbarElement = e.currentTarget as HTMLDivElement + // Only tab labels: the tab bar also holds the maximize/fold toolbar. const tabElems = Array.from( - navbarElement.childNodes - ) as HTMLDivElement[] + navbarElement.querySelectorAll( + ':scope > [data-type="tab"]' + ) + ) if (!hoverElementRef.current || !dragElemRef) return const preview = getNavbarDropPreview( @@ -275,8 +290,58 @@ export const useDynamixLayout = ({ lastHoverState = preview } + // --- Maximize / fold --- + let activeTabset: string | null = null + + const refreshViewState = () => { + updateTabsets(Node.cache.nodOpts.get()) + updateSliders(Node.cache.bndOpts.get()) + updateAllTabBodyStyles() + if (updateJSON) updateJSON(DynamixLayoutCore._root.toJSON()) + } + + const toggleMaximize = (tabsetId: string) => { + if (layoutInstance.toggleMaximize(tabsetId)) refreshViewState() + } + + const toggleCollapse = (tabsetId: string) => { + if (layoutInstance.toggleCollapse(tabsetId)) refreshViewState() + } + + /** Remembers which tabset the user last touched, for the shortcuts. */ + const onRootPointerDown = (e: PointerEvent) => { + const el = (e.target as HTMLElement).closest('[data-uid]') + const node = el?.dataset.uid + ? Node.cache.mapElem.get(el.dataset.uid) + : undefined + if (!(node instanceof Node)) return + if (node.type === 'tabset') activeTabset = node.unId + else if (node.type === 'tab' && node.host) activeTabset = node.host.unId + } + + const onTabbarDoubleClick = (e: MouseEvent) => { + const id = (e.currentTarget as HTMLElement).dataset.uid + if (enableDoubleClickMaximize && id) toggleMaximize(id) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (!keyboardShortcuts || !e.altKey || e.ctrlKey || e.metaKey) return + const id = activeTabset ?? layoutInstance.maximizedId + if (!id) return + + if (e.code === 'Equal' || e.code === 'NumpadAdd') { + e.preventDefault() + toggleMaximize(id) + } else if (e.code === 'Minus' || e.code === 'NumpadSubtract') { + e.preventDefault() + toggleCollapse(id) + } + } + const onDragStart = (e: DragEvent) => { e.stopPropagation() + // Every drop target must be visible while dragging. + if (layoutInstance.restore()) refreshViewState() setDragging(true) document.body.style.cursor = 'move' if (e.currentTarget) { @@ -510,6 +575,7 @@ export const useDynamixLayout = ({ const handler = () => updateDimension(disableResizeTimeout ?? false) window.addEventListener('resize', handler) + window.addEventListener('keydown', onKeyDown) updateTabsets(Node.cache.nodOpts.get()) updateSliders(Node.cache.bndOpts.get()) @@ -520,6 +586,7 @@ export const useDynamixLayout = ({ onCleanup(() => { window.removeEventListener('resize', handler) + window.removeEventListener('keydown', onKeyDown) offNodes() offBonds() offTabs() @@ -565,5 +632,9 @@ export const useDynamixLayout = ({ updateActiveTab, handleRootSplit, handleNavbarDragOver, + toggleMaximize, + toggleCollapse, + onRootPointerDown, + onTabbarDoubleClick, } } diff --git a/packages/solid/src/types/index.ts b/packages/solid/src/types/index.ts index aa60099..ef99808 100644 --- a/packages/solid/src/types/index.ts +++ b/packages/solid/src/types/index.ts @@ -91,6 +91,28 @@ export interface LayoutProps { RootSplitterHoverElClass?: string rootId?: string tabNames?: Map + /** Show the maximize button on tab bars (default true). */ + enableMaximize?: boolean + /** Show the fold button on tab bars (default true; needs the tab bar). */ + enableCollapse?: boolean + /** Double-click a tab bar to maximize or restore it (default true). */ + enableDoubleClickMaximize?: boolean + /** Alt/Option + "+" maximizes, Alt/Option + "-" folds the active tabset (default true). */ + keyboardShortcuts?: boolean + TabsetToolbar?: (props: TabsetToolbarProps) => JSX.Element +} + +export interface TabsetToolbarProps { + maximized: boolean + folded: boolean + /** The tab bar is drawn as a rotated vertical strip (folded, side-by-side row). */ + rotated: boolean + /** The tabset's row lays its children out side by side. */ + rowIsHorizontal: boolean + showMaximize: boolean + showFold: boolean + onToggleMaximize: () => void + onToggleFold: () => void } export interface useDynamixLayoutOptions { @@ -109,4 +131,6 @@ export interface useDynamixLayoutOptions { minTabHeight: number minTabWidth: number tabHeadHeight: number + keyboardShortcuts?: boolean + enableDoubleClickMaximize?: boolean } From 1be7413525df507b8e5e589e967a0c5f5651f595 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Sun, 4 Oct 2026 22:50:56 +0530 Subject: [PATCH 3/4] fix(react, solid): drops on folded strips and toolbar over narrow tab bars - Dragging over a folded (rotated) strip used the rotated tab label boxes for the insertion marker, producing a thin vertical sliver and an unreliable target. Rotated tab bars (data-rotated) now highlight the whole strip and drop into the tabset, which unfolds it. - In narrow tabsets the overflowing tab labels ran under the toolbar. The toolbar now inherits the tab bar background (labels slide under it) and stays pinned to the visible end while the tab bar scrolls. --- packages/react/src/components/Default.tsx | 24 ++++++++++++++++++++++- packages/react/src/components/Layout.tsx | 3 +++ packages/react/src/components/layout.css | 4 ++++ packages/react/src/hooks/useLayout.ts | 17 ++++++++++++++++ packages/solid/src/components/Default.tsx | 17 +++++++++++++++- packages/solid/src/components/Layout.tsx | 3 +++ packages/solid/src/components/layout.css | 4 ++++ packages/solid/src/hooks/useLayout.ts | 17 ++++++++++++++++ 8 files changed, 87 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/Default.tsx b/packages/react/src/components/Default.tsx index 6876a92..c5137bf 100644 --- a/packages/react/src/components/Default.tsx +++ b/packages/react/src/components/Default.tsx @@ -1,4 +1,10 @@ -import React, { forwardRef, HTMLAttributes, ReactNode } from 'react' +import React, { + forwardRef, + HTMLAttributes, + ReactNode, + useEffect, + useRef, +} from 'react' import type { TabsetToolbarProps } from '../types' type DefaultWrapTabLabelProps = HTMLAttributes & { @@ -343,9 +349,25 @@ export const DefaultTabsetToolbar = ({ // Inside a rotated strip the icons are turned back so they read upright. const chevronTurn = (rowIsHorizontal ? 0 : 90) + (folded ? 180 : 0) const upright = rotated ? -90 : 0 + const toolbarRef = useRef(null) + + // Stay pinned to the visible end of the tab bar when its tabs overflow + // and it scrolls. + useEffect(() => { + const toolbar = toolbarRef.current + const bar = toolbar?.parentElement + if (!toolbar || !bar) return + const pin = () => { + toolbar.style.transform = `translateX(${bar.scrollLeft}px)` + } + pin() + bar.addEventListener('scroll', pin, { passive: true }) + return () => bar.removeEventListener('scroll', pin) + }, []) return (
e.stopPropagation()} > diff --git a/packages/react/src/components/Layout.tsx b/packages/react/src/components/Layout.tsx index 6046a55..a9a392a 100644 --- a/packages/react/src/components/Layout.tsx +++ b/packages/react/src/components/Layout.tsx @@ -238,6 +238,9 @@ export const DynamixLayout: ForwardRefExoticComponent< data-dx-hidden={ tabset.nodHidden ? '' : undefined } + data-rotated={ + placement.rotated ? '' : undefined + } onDoubleClick={onTabbarDoubleClick} onDragOver={handleNavbarDragOver} onDragStart={onDragStart} diff --git a/packages/react/src/components/layout.css b/packages/react/src/components/layout.css index 43ac7e5..13703ad 100644 --- a/packages/react/src/components/layout.css +++ b/packages/react/src/components/layout.css @@ -38,6 +38,10 @@ display: flex; align-items: center; gap: 2px; + padding-left: 6px; + /* Opaque, so overflowing tab labels slide underneath instead of showing + through the buttons. */ + background: inherit; opacity: 0; transition: opacity 0.15s ease; } diff --git a/packages/react/src/hooks/useLayout.ts b/packages/react/src/hooks/useLayout.ts index 5edca6c..3cf0368 100644 --- a/packages/react/src/hooks/useLayout.ts +++ b/packages/react/src/hooks/useLayout.ts @@ -282,6 +282,23 @@ export const useDynamixLayout = ({ e.dataTransfer.dropEffect = 'move' } + // A folded strip shows its tab bar rotated; label boxes are vertical + // there, so the whole strip is the target and drops go into the tabset. + if (e.currentTarget.hasAttribute('data-rotated')) { + if (!hoverElementRef.current || !dragElemRef.current) return + const r = e.currentTarget.getBoundingClientRect() + updateHoverElement({ + area: 'contain', + left: r.left, + top: r.top, + width: r.width, + height: r.height, + }) + dragElemRef.current.des = e.currentTarget + dragElemRef.current.area = 'contain' + return + } + // Only tab labels: the tab bar also holds the maximize/fold toolbar. const tabElems = Array.from( e.currentTarget.querySelectorAll( diff --git a/packages/solid/src/components/Default.tsx b/packages/solid/src/components/Default.tsx index 24fdc12..b2522d4 100644 --- a/packages/solid/src/components/Default.tsx +++ b/packages/solid/src/components/Default.tsx @@ -1,4 +1,4 @@ -import { JSX, createMemo, splitProps } from 'solid-js' +import { JSX, createMemo, onCleanup, onMount, splitProps } from 'solid-js' import type { TabsetToolbarProps } from '../types' type DivProps = { @@ -299,9 +299,24 @@ export const DefaultTabsetToolbar = (props: TabsetToolbarProps) => { const chevronTurn = () => (props.rowIsHorizontal ? 0 : 90) + (props.folded ? 180 : 0) const upright = () => (props.rotated ? -90 : 0) + let toolbar: HTMLDivElement | undefined + + // Stay pinned to the visible end of the tab bar when its tabs overflow + // and it scrolls. + onMount(() => { + const bar = toolbar?.parentElement + if (!toolbar || !bar) return + const pin = () => { + toolbar!.style.transform = `translateX(${bar.scrollLeft}px)` + } + pin() + bar.addEventListener('scroll', pin, { passive: true }) + onCleanup(() => bar.removeEventListener('scroll', pin)) + }) return (
e.stopPropagation()} > diff --git a/packages/solid/src/components/Layout.tsx b/packages/solid/src/components/Layout.tsx index 93eeec9..271a217 100644 --- a/packages/solid/src/components/Layout.tsx +++ b/packages/solid/src/components/Layout.tsx @@ -189,6 +189,9 @@ export const DynamixLayout: ParentComponent = (props) => { data-dx-hidden={ tabset.nodHidden ? '' : undefined } + data-rotated={ + placement.rotated ? '' : undefined + } onDblClick={onTabbarDoubleClick} onDragOver={handleNavbarDragOver} onDragStart={onDragStart} diff --git a/packages/solid/src/components/layout.css b/packages/solid/src/components/layout.css index 1de835e..00c4de8 100644 --- a/packages/solid/src/components/layout.css +++ b/packages/solid/src/components/layout.css @@ -39,6 +39,10 @@ display: flex; align-items: center; gap: 2px; + padding-left: 6px; + /* Opaque, so overflowing tab labels slide underneath instead of showing + through the buttons. */ + background: inherit; opacity: 0; transition: opacity 0.15s ease; } diff --git a/packages/solid/src/hooks/useLayout.ts b/packages/solid/src/hooks/useLayout.ts index 86f4fec..bd95261 100644 --- a/packages/solid/src/hooks/useLayout.ts +++ b/packages/solid/src/hooks/useLayout.ts @@ -234,6 +234,23 @@ export const useDynamixLayout = ({ } const navbarElement = e.currentTarget as HTMLDivElement + // A folded strip shows its tab bar rotated; label boxes are vertical + // there, so the whole strip is the target and drops go into the tabset. + if (navbarElement.hasAttribute('data-rotated')) { + if (!hoverElementRef.current || !dragElemRef) return + const r = navbarElement.getBoundingClientRect() + updateHoverElement({ + area: 'contain', + left: r.left, + top: r.top, + width: r.width, + height: r.height, + }) + dragElemRef.des = navbarElement + dragElemRef.area = 'contain' + return + } + // Only tab labels: the tab bar also holds the maximize/fold toolbar. const tabElems = Array.from( navbarElement.querySelectorAll( From c7692cc7217cbeca47f4f3e102a28a20b7f6a7d9 Mon Sep 17 00:00:00 2001 From: akash-aman Date: Mon, 5 Oct 2026 08:27:29 +0530 Subject: [PATCH 4/4] docs(react): deprecation notes for the maximize and fold props renamed in 2.0 --- packages/react/src/types/index.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts index 4c720b0..90f0e10 100644 --- a/packages/react/src/types/index.ts +++ b/packages/react/src/types/index.ts @@ -133,14 +133,24 @@ export interface LayoutProps { rootId?: string /** @deprecated Replaced in v2 by `tabs: { id, title, content }[]`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ tabNames?: Map - /** Show the maximize button on tab bars (default true). */ + /** + * Show the maximize button on tab bars (default true). + * @deprecated Replaced in v2 by `allowMaximize`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md + */ enableMaximize?: boolean - /** Show the fold button on tab bars (default true; needs the tab bar). */ + /** + * Show the fold button on tab bars (default true; needs the tab bar). + * @deprecated Replaced in v2 by `allowFold`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md + */ enableCollapse?: boolean - /** Double-click a tab bar to maximize or restore it (default true). */ + /** + * Double-click a tab bar to maximize or restore it (default true). + * @deprecated Replaced in v2 by `maximizeOnDoubleClick`. See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md + */ enableDoubleClickMaximize?: boolean /** Alt/Option + "+" maximizes, Alt/Option + "-" folds the active tabset (default true). */ keyboardShortcuts?: boolean + /** @deprecated Replaced in v2 by `components.TabsetToolbar` (its props become `{ tabset, isRotated, canMaximize, canFold, … }`). See https://github.com/xcode-studio/dynamix-layout/blob/main/docs/migration-v1-to-v2.md */ TabsetToolbar?: React.ComponentType }