css grid rewrite#29
Merged
Merged
Conversation
Replaces the JS-driven sizing engine with one backed by CSS Grid, drops the runtime dependencies on @madronejs/core / lodash / animejs / sass, and reshapes the Vue API around a durable handle. WHY The previous engine resolved every panel size in JS and wrote per-element widths/heights. Those numbers disagreed with what CSS itself resolved (resizer-track px, min-clamp interactions, container-resize timing), so layouts overflowed or shrank under common conditions; fix attempts compounded. The wrapper API was wired up via template refs + SplitGridViewApi, with no cross-tree access or pre-mount listener registration. ARCHITECTURE CSS Grid owns layout. Each container is a real `display: grid` element with a single `grid-template-columns` (or `-rows`) string composed from the tree's stored sizes; the browser handles min/max via `clamp()` in the track string and `transitionend` signals "layout settled". The runtime's job shrinks to writing one inline `--sp-tracks` value per container — JS no longer computes pixels CSS will then re-derive. State lives in framework-free TypeScript (src/). Sizes are stored in one consistent denominator (`pctBudgetPx`) so saturated layouts always sum to 100, and the math is unit-tested in isolation. The Vue wrapper (src/vue/) publishes a `SplitGridHandle` facade keyed by container id — descendants and ancestors both reach it via `useSplitGrid(id)`, the same handle survives v-if / HMR cycles, and event hooks registered before mount drain to the live grid on attach. No reactivity dependency, no lodash, no animejs, no sass. Plain CSS, plain TypeScript. See CHANGELOG.md for the breaking surface, new APIs, and the bug class the rewrite eliminates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `id` prop on `<SplitGridView>` defaulted to required, which forced
consumers to invent a string even when they had no cross-tree handle
access in mind. It also produced a registry collision when two
component instances declared the same id (e.g. two stacked
ScdBuilders both naming their root `'scb-panels'`), causing the second
`attachHandle` to throw.
Now:
- `id` is optional. When omitted, an instance-scoped id is generated
via `useAutoId('sgv')`. Stable per instance, unique across them.
- `useAutoId(prefix, explicit?)` is re-exported from the public
`@madronejs/splitpanel/vue` entry. Consumers that need parent-side
`useSplitGrid(id)` access but don't want a global identifier can do
`const ROOT_ID = useAutoId('myprefix')` and pass that id to both
`useSplitGrid` and the wrapper — instance-scoped uniqueness with
setup-phase handle access.
- Auto-id handles are pruned from the module registry on unmount. No
external consumer can reach them, so retaining them would just
leak across v-if cycles. Explicit-id handles still persist (queued
listeners survive a remount under the same id; documented feature).
- Explicit-id collisions still throw on second attach. Explicit ids
are global identifiers by intent — silently sharing a handle would
cross-wire two unrelated trees.
Four new regression tests in SplitGridView-props.dom.spec.ts cover
the optional-id path, two-instances-without-id coexistence, auto-id
registry cleanup on unmount, and explicit-id preservation on unmount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a declarative `<SplitPanel>` was v-if'd off and back on, the
re-registered panel landed at the end of the parent's children array
(`addChild`'s default is append). A panel that started in slot
position 2 ended up at position N after a hide/show cycle — visible
ordering drift in any consumer with toggle-able panels.
New optional `:order?: string[]` prop on `<SplitGridView>`: declares
the canonical id sequence. At register time, the wrapper computes the
insertion index from the new id's position in `order` and inserts
before the first existing child whose order-index is greater.
Semantics:
- IDs in the tree but not in `order` keep their relative position.
- IDs in `order` not in the tree are ignored.
- Insertion-only — changing the `order` prop at runtime doesn't
re-shuffle existing children. Consumer calls `grid.syncChildren`
if they want that.
Two regression tests in SplitGridView-props.dom.spec.ts:
- v-if'd-off-then-on panel re-inserts at its canonical slot
- ids absent from `order` still append (`order` is an override for
the panels it names, not a whitelist that drops the rest).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ResizerState.before` and `.after` are `Node<T> | undefined` — a `Leaf<T> | Container<T>` union. Only the leaf variant carries `data`, so templates that wanted the leaf payload had to narrow the union with an `as` cast (containers don't have `data`, the type system rightly refuses the direct access). Add typed convenience accessors: `beforeData: T | undefined` and `afterData: T | undefined`. Container neighbors → `undefined` (same shape as `PanelState.data` exposes for container nodes), so consumers read `resizer.afterData?.foo` without narrowing. The raw `before` / `after` `Node<T>` fields stay for the (rare) consumer who needs container-vs-leaf access. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new state queries on SplitGrid (the framework-free runtime):
- `getMaximizedIndex(containerId)`: index of the currently-maximized
child within the container's children array, or -1 if none. Belongs
on the runtime, not on the Vue PanelState — derived projection of
container state, useful to any consumer building "expand prev /
expand next" UI.
- `areChildrenEqual(containerId)`: true when 0-1 children, or when
every child shares the same `Length`. The "is equalize a no-op?"
check that toolbars want for disabled-state on an Equalize button.
Both surface on `PanelState` for container nodes (as
`maximizedChildIndex` + `childrenEqual`) and on the `SplitGridHandle`
public API (alongside isMaximized / isAtDefault) so consumers reach
them via `useSplitGrid(id)` too.
Also exposes `beforeData` / `afterData` on `ResizerState` — `T |
undefined` projections of the leaf payload, so consumer templates no
longer have to `as` cast `Node<T>` from a `Leaf | Container` union
just to read `.data`.
While in handle.ts, collapsed the six reactive-read methods to
one-liner ternaries. The bare `isReady.value;` statement + lint-disable
at each call was duplicated boilerplate; the ternary `isReady.value ?
real : fallback` does double duty — gates the attach check AND
registers the reactive dep so pre-attach computeds re-evaluate at
attach time. One block comment above the six lines explains the trick.
Five new node-suite tests pin getMaximizedIndex (-1 / index / leaf /
unknown / minimize-restores-minus-one) and areChildrenEqual (true post
equalize / false post setSize / true with one child / false on leaves
and unknown ids).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`scheduleMeasure` (fired by the rootObserver `ResizeObserver` on container size changes) used to update `c.availPx` via `measureAll` but never re-emit `--sp-tracks`. CSS Grid then re-resolved the LAST-written pct string against the new container width — which drifts whenever px-sized siblings or resizer-track px are in play (the scale factor `budget / containerAxisPx` depends on the container size). Now scheduleMeasure also calls `writeAllTracks(this.rootEl)` after the measure, with `animate: false` (the user IS the resize input; CSS just keeps in lockstep). Layouts re-fit the new container width without drifting off-axis. Regression test in layout.browser.spec.ts: 1000px container with a pct/fr player + px markers + a maximize op (which freezes player to stored pct). Shrink the host to 800px and assert sum(panels + resizer) ≈ 800. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squashes 12 review-driven commits: Bugs (must-fix): - emit distinct 'maximize' / 'minimize' reason via a shared applySize() helper instead of routing through setSize, so subscribers can tell "user dragged to 100%" from "panel maximized." - setBounds on the root persists the merged bounds and emits 'set-bounds' — previously early-returned silently. - prepareForLayoutOp returns false when availPx <= 0; every entry caller (applySize, setBounds, toggleExpand, equalize) bails so pre-measure / detached / display:none paths don't store [0%, 0%, …] and paint a collapsed grid. - <SplitGridView> pre-flights the handle before mounting. The old mount → subscribe → attachHandle order leaked an orphan grid (DOM + ResizeObserver + subscriber) when attachHandle threw on a duplicate id; onUnmounted only cleaned the winning grid. Risks: - scheduleMeasure's pending rAF is tracked and cancelled in unmount; rafScheduled reset so a remount on the same instance doesn't silently swallow its first measure. - dragHandle reads immMin alongside immMax and floors the immediate at its bounds.min on apply so stale-state callers can't leave it below its declared floor. - equalize's storage doc clarified per unit mix (all-pct sums to 100, mixed splits, all-px stores avail/N px). Tests lock both shapes. BREAKING: - LayoutChangeEvent.containerId is now string | [string, string] and sizes is now Length[] | [Length[], Length[]]. Cross-parent swap emits ONE composed event covering both containers; same-parent swap still uses the single-string form. Consumers narrow with Array.isArray. Convention drift: - Registry typed Map<string, SplitGridHandle<unknown>> instead of <any>; lookup-site cast in useSplitGrid<T>(id). - toPx returns NaN for 'fr' (was the denom sentinel) so unit-mix bugs surface instead of returning fake numbers. - trackForChild always emits minmax(min, 1fr) for fr tracks; max is dropped on fr (the prior minmax(min, max) form silently switched the track from flex-fill to a bounded fixed range). - getMaximizedIndex uses the O(1) findMaximizedIndex helper. - unmount disposes resizers via detachResizers per container so the ':scope > .sp-resizer' selector matches the per-container churn path. - emit wraps cfg.onChange in try/catch matching the subscribers loop. Tests: - invariant.browser asserts storage sum-to-100 in addition to rendered-px sum-to-container. - syncChildren event-count locks per-op emit shape. - :order runtime no-reshuffle. - PanelState container fields (maximizedChildIndex, childrenEqual) + leaves report undefined. - ResizerState beforeData/afterData exposure. - dragHandle: immediate-neighbor min respected on small deltas. - setBounds on root, setSize when availPx=0, unmount cancels rAF. Tooling: - pnpm watch (vite build --watch) for live dev-link iteration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0.1.0 carried a default rgba(0,0,0,0.2) background on the resizer track so the divider was visible without consumer styling; the CSS Grid rewrite dropped it. Restore a default tint, but adapt to dark mode via color-mix against currentColor (8% idle, 16% on hover / active drag). Overridable via --sp-resizer-bg / --sp-resizer-bg-hover (any CSS color, or transparent to opt out). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The split-out maximize/minimize reasons weren't in RESIZE_REASONS, so consumers using handle.onResize() to react to any size change stopped seeing those events. They're size changes; categorize them as such. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- maximize() now freezes fr → pct BEFORE snapshotting parent.max.restore.
A fresh container (sizes still fr) used to snapshot fr entries, and
the eventual toggleExpand-restore wrote them back into c.sizes —
writeTracks then emitted `minmax(0, 1fr)` against a previously-pct
track, hitting the function-shape mismatch styles.css warns about
(background flashes through the transition).
- Cross-parent swap clears BOTH containers' max inside the single
mutator callback. Previously, pB.max stayed referencing an id whose
node had moved to pA until the second mutateStructure(pB) ran —
a fully synchronous but stale window.
- getRawDefinition({ withCurrentSizes: true }) falls back to the
no-sizes path when the host has no measurable rect (pre-paint,
detached, display:none) instead of writing "0px" into every child's
bounds.size.
- <SplitContainer> propagates `bounds` prop changes to grid.setBounds,
matching <SplitGridView>. `resizer` is documented as mount-time
only (no setResizer in the core).
- Doc nits: setBounds merge spread semantics (`{ min: undefined }`
clears, intentional), <SplitPanel>'s `data` prop call-out on
inline-literal binding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bitencode
approved these changes
May 15, 2026
Collaborator
bitencode
left a comment
There was a problem hiding this comment.
🎉 LGTM - I like this interface much better. Will be interesting to see how people use it 👍
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
splitpanel: CSS Grid rewrite
Why
There were big performance issues in the previous iteration, and some bugs that were hard to fix given the architecture. It also had a few external dependencies when it really didn't need them. The consumer API also was pretty terrible.
New Architecture
CSS Grid owns layout. Each container is a real
display: gridelement with a singlegrid-template-columns(or-rows) string composed from the tree's stored sizes; the browser handles min/max viaclamp()in the track string andtransitionendsignals "layout settled". The runtime's job shrinks to writing one inline--sp-tracksvalue per container — JS no longer computes pixels CSS will then re-derive.State lives in framework-free TypeScript (
src/). Sizes are stored in one consistent denominator (pctBudgetPx) so saturated layouts always sum to 100, and the math is unit-tested in isolation. The optional Vue wrapper (src/vue/) publishes aSplitGridHandlefacade keyed by container id — descendants and ancestors both reach it viauseSplitGrid(id), the same handle survives v-if / HMR cycles, and event hooks registered before mount drain to the live grid on attach.No reactivity dependency, no lodash, no animejs, no sass. Plain CSS, plain TypeScript.
📚 Bookkeeping:
Testing (if applicable):
Checklist
CHANGELOG.md