feat(splitter): add resizable two-pane Splitter component + useResponsiveSplitterSizes hook#1560
Merged
ByronDWall merged 51 commits intoJun 11, 2026
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 5d0d5e1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
|
Localization reminder: This PR adds or modifies `.i18n.ts` files. Please create a Jira ticket for the localization manager to initiate translation of any new or updated strings in Transifex. See LOC-1766 as an example. |
Contributor
Bundle Size ReportLast updated: 2026-06-11 19:40:53 UTC
Baseline source: comment-chain |
2e40a0a to
024150f
Compare
Align the three doc files to the house pattern: Overview (visual variables + guidelines), Implementation (one example per prop, simple to complex, plus common patterns), Accessibility (conformance). Add a collapse visual to the Overview, and fill the Implementation gaps — handle size, collapsedSize rail, defaultCollapsedPane, keyboardStep, isDoubleClickDisabled — plus IDE-layout and editor/preview examples showing common property combinations. Documents the resize-while-collapsed lock across the dev and a11y tabs.
…logic Split splitter.handle.tsx (303 lines) into focused units, each with a single clear responsibility: - utils/compute-aria-bounds.ts: pure, collapse-aware aria-valuemin/max math (+ spec) - hooks/use-handle-resize.ts: pointer-drag px->% conversion + clamped applyDelta writer - hooks/use-handle-keyboard.ts: arrow / Home / End / Enter handling The handle is now a thin assembler. No behavior change.
Splitter.Root gains an optional controlled `sizes` prop, the counterpart to `defaultSizes`. Control is settle-only: internal state drives the layout during drag/keyboard (no per-tick consumer feedback, so no ~60Hz re-render), and the prop is reconciled in when it changes — mirroring the controlled `collapsedPane` pattern. This enables in-place, per-breakpoint layout control without remounting panes (preserving scroll/focus), which a `key` swap cannot do. - Extract normalizeSizes + sizesEqual helpers (with specs); deriveInitialSizes reuses normalizeSizes. - Effect-based reconcile written silently at rest; collapse wins precedence; fire-once dev warnings for misuse. Hot-path writers unchanged. - ControlledSizes story + controlled-sizes consumer example; dev.mdx + changeset. - Reverse the OpenSpec 'No controlled sizes' non-goal accordingly.
The Splitter.Handle JSDoc had grown to ~45 lines of prose (ARIA model, keyboard, pointer, resize-lock) that duplicated the focused hooks' own docs; condense it to a tight summary. Tighten the extracted hooks' headers and a couple of state-machine block comments. Left intact: consumer-facing prop JSDoc in splitter.types.ts (feeds the JSDoc-extracted API docs), pure-util @examples, and the per-guard 'why' comments that explain non-obvious state-machine behaviour.
Trim the Root/Pane/Handle component-file headers to the terse 1-3 line + @supportsStyleProps convention used by peer components (accordion, menu, tabs); the consumer-facing compound JSDoc in splitter.tsx already matched.
…ement The 'Optional controlled `sizes` prop' requirement description used MAY; OpenSpec strict validation requires SHALL or MUST in the requirement body. Reword to use SHALL while preserving the opt-in semantics.
Replace the record-based 2-pane API with a single configurable dimension. The two panes are now distinct components — Splitter.Aside (the sized pane) and Splitter.Main (the remainder) — so role is designated by type rather than by id. The whole size dimension is one number, the aside's percentage. - defaultSizes/sizes (Record) -> defaultSize/size (number); onSizesChange* emit a single number; persistence round-trips one value - panes config map -> flat minSize/maxSize/collapsible/collapsedSize on Root (maxSize derives the main pane's floor) - collapsedPane (id|null) -> collapsed (boolean); only the aside collapses - pane id is now optional; aside may lead or trail the main pane (the handle's ARIA value tracks the leading pane) - remove the handle-thickness size prop and recipe size variants (single fixed thickness) Internals keep the proven state machine, reshaped to a scalar size + boolean collapse + role-based registration. Updates the (unarchived) OpenSpec change, stories, docs, and the changeset to match. Component is unpublished — no consumer impact.
…lash The size state was seeded to 50 and the real size derived in a mount effect gated on pane registration, so the first committed paint was always 50/50 before snapping to defaultSize/size — a visible flash for every consumer. Derive the initial layout (controlled size, defaultSize fallback, and initial collapse) synchronously from props and seed useState + the bookkeeping refs with it, so the first render is already correct. The registration-gated init effect is removed; the reconcile effects keep their no-op mount behavior. Drop the now-vestigial hasInitializedRef. Adds a use-splitter-state unit test asserting the first-render size for the uncontrolled, controlled, clamped, and initially-collapsed paths.
Add the OpenSpec proposal for useResponsiveSplitterSizes, a companion hook that translates a pixel-/token-/percent size config into the percentage Splitter.Root consumes. Targets the simplified single-dimension Aside/Main API: number is always pixels, tokens resolve via themeTokens.size, container-width threshold keys with an explicit resolveAgainst axis, a pixel facade over minSize/maxSize/collapsedSize with hook-side clamping, and versioned pixel-first per-band persistence. The component first-paint flash is tracked as a separate fix.
Implement the pixel/token → percentage translator companion hook so consumers can size the aside in pixels, size tokens, or per container width while the component stays percentage-native. - number = px, size tokens (3xs–8xl, breakpoint-*) → px, "N%" passthrough - container-width threshold map with explicit resolveAgainst (container only) - pixel facade over minSize/maxSize/collapsedSize with hook-side clamping - versioned, pixel-first per-band persistence; collapse suppressed via the forwarded onCollapsedChange signal (not value equality) - ResizeObserver measurement with feature-detect, width guard, emit gating, and band hysteresis; pure resolvers extracted for unit testing - curated SplitterSizeToken union + existence guard test; barrel + public exports - Storybook story + dev docs section Tests: 72 splitter unit tests pass; 20 splitter story play fns pass; typecheck, lint, and package build (export verified) all clean. Tracked separately: component first-paint 50/50 flash (task 11.1). Implements openspec/changes/add-responsive-splitter-sizes-hook.
The hook only ever resolved against the container, so a required single-value option was pure ceremony. Remove resolveAgainst (and the SplitterSizeResolveAgainst type) from the hook, tests, story, docs, and the OpenSpec proposal. Resolution is always container-relative; a viewport variant, if ever needed, is better as its own explicitly named hook than a mode flag that reinterprets threshold keys.
The hook only surfaced `size` from the gated `emittedSize` state, which is set in a post-render effect — so even a `%` config (resolvable without measurement) emitted no `size` on the first render, and the component fell back to its uncontrolled default for a frame. Fall back to the freshly-resolved `targetSize` until the gate's effect first runs, so a synchronous config drives the controlled `size` on the first render and the component (which now seeds size synchronously) honors it on first paint — no flash. Pixel/token configs still settle once after the post-paint container measurement, which is inherent. Adds a probe test asserting the first render already carries the percent size.
…ive story
- Hook tests: minSize/maxSize via container-width threshold maps (px + percent
bands), and a vertical splitter resolving against the measured height axis.
- New ResponsiveByContainerWidth story: the same { 0: "40%", 768: 320 } config
resolved against two different container widths (640 → 40%, 1000 → 32%),
demonstrating that resolution is container- not viewport-relative.
…ive hook The existing hook stories were static. Add NestedResponsiveSplitter: an outer Splitter whose handle resizes the inner splitter's container, so dragging it (or resizing the window) makes useResponsiveSplitterSizes re-resolve live — crossing the 768px container threshold switches the inner aside between its 320px-pinned and 40% bands. The play function drives the outer handle and asserts the inner band switch, so it doubles as a regression test for container-relative re-resolution.
The hook ships in the same release as the component; the changeset is the consumer-facing release note, so it should mention both.
…nsive hook Render-only story (no play assertions): passes persistKey so the hook writes settled sizes to real localStorage, with a live readout and a reset button so the restore-on-reload behaviour can be verified in the browser.
…xamples Replace domain-specific pane labels (Nav, Sidebar, Editor, Preview, Top, Bottom, Files) with the component vocabulary (Aside, Main) so examples consistently reinforce which component maps to which pane. Nesting examples use Aside (outer)/Aside (inner) to distinguish.
Replace raw CSS pixel values with design tokens throughout stories and dev docs. Hook configs now use size tokens (e.g. "xs", "3xs", "lg", "breakpoint-md") instead of bare pixel numbers. Container Box dimensions use size tokens instead of "Npx" strings.
The drag accumulator zeroed after each applied delta, discarding the sub-tolerance fraction from px→% conversion. Subtract the applied delta instead so the remainder carries forward and slow/noisy input devices see proper accumulation across pointer-move events.
- Relax spec to permit `id` on Handle (DOM attribute, not behavioral config) - Wrap aria-valuenow assertions in waitFor to fix race with pane registration - Add explicit return type to useHandleResize to avoid TS2742 type portability error - Guard process.env.NODE_ENV with typeof check to fix Vite build TS2580 - Update pattern intl translations via automated hook
75debc3 to
5d0d5e1
Compare
This was referenced Jun 11, 2026
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.
Summary
Adds
Splitter, a WCAG 2.1 AA compliant compound component for a user-resizabletwo-pane layout: a configurable
Splitter.Asideand aSplitter.Mainthat fillsthe remaining space, with a draggable, keyboard-operable handle between them — plus
useResponsiveSplitterSizes, a companion hook that lets consumers drive pane sizesin pixels, size tokens, or per-container-width breakpoints.
Motivation
Nimbus has no primitive for user-resizable panes. Consumers building IDE-like or
sidebar-plus-content layouts otherwise hand-roll the drag math, keyboard handling,
and the W3C window-splitter ARIA contract — error-prone and rarely accessible.
Splitterdoes that one job well, with clean seams for persistence and collapse.The component is deliberately percentage-native (the boundary is one number,
0–100). But real layouts are often specified in pixels ("a 320px sidebar") or per
breakpoint.
useResponsiveSplitterSizesis the pixel/token → percentage translatorthat keeps the component simple while giving consumers the units they actually design in.
What changed
Component
New component at
packages/nimbus/src/components/splitter/(registered in thecomponent barrel and the theme slot-recipes), built on React Aria primitives
(
useSeparator,useMove,useFocusRing) and a Chakra slot recipe.Compound API:
Splitter.Root,Splitter.Aside,Splitter.Main,Splitter.Handle.size is one number — the aside's percentage (
defaultSize, or controlledsize). The main pane is always the remainder. The role is designated by thecomponent type, so there are no ids to wire and no pane-keyed config.
pane (a left/top nav or a right/bottom panel);
sizealways refers to theaside, and the handle's ARIA value tracks the leading pane.
Root.minSize/maxSizebound the aside;maxSizealso fixes the main pane's floor (
100 − maxSize).collapsible+collapsedSize, controllable via thecollapsedboolean (ordefaultCollapsed); Enter on the focused handle togglesit, double-click restores the mount-time size.
defaultSizefrom any storage andwrite back the single number from
onSizeChangeEnd(settle-only;onSizeChangeis the live, per-tick channel). No bundled hook, no
autoSaveId.horizontal/vertical),keyboardStep,isDoubleClickDisabled, andisDisabledround outRoot.Splitterinside a pane.useResponsiveSplitterSizeshookAn opt-in companion hook that returns
rootPropsto spread ontoSplitter.Root. Itmeasures the container with a
ResizeObserver, translates the consumer's config intothe percentage the component consumes, clamps to
minSize/maxSize, drives thesettle-only controlled
sizechannel (no snap-back), and can persist the user'ssettled size across reloads (pixel-first, per-band, versioned).
number= pixels. Period. A bare number is always a pixel value; a"30%"string is a passthrough percentage; a size token (
3xs–8xl,breakpoint-*)resolves against the theme.
{ 0: 320, 768: "30%" }— same notation supported for
minSize/maxSize/collapsedSize.%values), sothere's no uncontrolled-default flash.
ResizeObserver/localStorage.How it works
Splitter.Rootowns the aside-size state machine (synchronous mount-time seeding,live vs. settled change channels, controlled/uncontrolled size and collapse with
reconciliation) and exposes it via context. The panes register their role + DOM id;
the handle resolves the leading/trailing siblings, applies clamped resize, and renders
the W3C separator ARIA model (
role="separator",aria-valuenow/min/max/text,aria-orientation,aria-controls). Sizes carry full float precision end-to-end;only
aria-valuenowis rounded for AT.The hook sits entirely outside the component as a pure translator: all pixel math,
container-width resolution, hook-side clamping, and per-band persistence live in
hooks/use-responsive-splitter-sizes.ts+utils/(pure resolvers, size-tokenlookup, storage adapter). The component never learns about pixels.
Test plan
pnpm --filter @commercetools/nimbus typecheck— cleanpnpm --filter @commercetools/nimbus build(incl. theme typings) — cleanpnpm test:dev packages/nimbus/src/components/splitter/— 103 passing across 12 files(util/hook specs + Storybook play functions + consumer docs.spec)
pnpm lint— cleanopenspec validate add-splitter-component --strict— validopenspec validate add-responsive-splitter-sizes-hook --strict— validComponent play functions cover default/vertical layouts, aside-trailing (either-side),
pointer drag, keyboard (arrows + Home/End), min/max clamping, collapse (keyboard +
controlled), resize-lock while collapsed, double-click restore (incl. 0%), controlled
size, persistence, nesting, float precision, and disabled states.
Hook coverage: synchronous
%first paint, px→% conversion, container-width bandselection + hysteresis, object-notation
minSize/maxSize, vertical (height) axis,emit gating, pixel-first persistence round-trip across remount + resize, collapse-driven
settle suppression, and graceful degradation without
ResizeObserver/storage.Interactive Storybook stories
In this PR's Storybook preview (Vercel check below), under Components › Splitter:
ResponsivePixelSizesHook— fixed-width container, px config.ResponsiveByContainerWidth— two static containers (640 vs 1000px) showing band switch.NestedResponsiveSplitter— interactive: drag the outer handle to resize the innercontainer across the 768px threshold and watch the inner aside switch bands live.
Feedback wanted
maxSizeexpresses the main pane'sfloor as its complement — does that read clearly at the call site, or would a
named main-floor prop be worth the extra surface?
numberas pixels unconditionally — clear enough given thecomponent is percentage-native, or worth a more explicit
{ px: n }form?Closes FEC-977