Skip to content

feat(splitter): add resizable two-pane Splitter component + useResponsiveSplitterSizes hook#1560

Merged
ByronDWall merged 51 commits into
mainfrom
FEC-977-nimbus-create-splitter-component-for-resizable-two-pane-layouts
Jun 11, 2026
Merged

feat(splitter): add resizable two-pane Splitter component + useResponsiveSplitterSizes hook#1560
ByronDWall merged 51 commits into
mainfrom
FEC-977-nimbus-create-splitter-component-for-resizable-two-pane-layouts

Conversation

@misama-ct

@misama-ct misama-ct commented May 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds Splitter, a WCAG 2.1 AA compliant compound component for a user-resizable
two-pane layout: a configurable Splitter.Aside and a Splitter.Main that fills
the remaining space, with a draggable, keyboard-operable handle between them — plus
useResponsiveSplitterSizes, a companion hook that lets consumers drive pane sizes
in pixels, size tokens, or per-container-width breakpoints.

Note: this PR previously consisted of two stacked PRs (the component and the
hook). They were combined: the Splitter is unreleased and our first consumer needs
the hook alongside it, so component + hook now ship as one PR, one changeset, and
two OpenSpec changes. The former hook PR (#1587) is folded in here.

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.
Splitter does 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. useResponsiveSplitterSizes is the pixel/token → percentage translator
that 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 the
component 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.

  • One dimension to configure. A 2-pane splitter has a single boundary, so the
    size is one number — the aside's percentage (defaultSize, or controlled
    size). The main pane is always the remainder. The role is designated by the
    component type, so there are no ids to wire and no pane-keyed config.
  • Either-side placement. The aside may be rendered before or after the main
    pane (a left/top nav or a right/bottom panel); size always refers to the
    aside, and the handle's ARIA value tracks the leading pane.
  • Flat constraints on Root. minSize / maxSize bound the aside; maxSize
    also fixes the main pane's floor (100 − maxSize).
  • Collapsible aside. collapsible + collapsedSize, controllable via the
    collapsed boolean (or defaultCollapsed); Enter on the focused handle toggles
    it, double-click restores the mount-time size.
  • Persistence is consumer-wired. Hydrate defaultSize from any storage and
    write back the single number from onSizeChangeEnd (settle-only; onSizeChange
    is the live, per-tick channel). No bundled hook, no autoSaveId.
  • Orientation (horizontal / vertical), keyboardStep,
    isDoubleClickDisabled, and isDisabled round out Root.
  • Three-or-more regions are composed by nesting a Splitter inside a pane.

useResponsiveSplitterSizes hook

An opt-in companion hook that returns rootProps to spread onto Splitter.Root. It
measures the container with a ResizeObserver, translates the consumer's config into
the percentage the component consumes, clamps to minSize / maxSize, drives the
settle-only controlled size channel (no snap-back), and can persist the user's
settled 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 (3xs8xl, breakpoint-*)
    resolves against the theme.
  • Per-container-width breakpoints via object notation, e.g. { 0: 320, 768: "30%" }
    — same notation supported for minSize / maxSize / collapsedSize.
  • Synchronous first paint for configs that need no measurement (% values), so
    there's no uncontrolled-default flash.
  • Degrades gracefully without ResizeObserver / localStorage.
import { Splitter, useResponsiveSplitterSizes } from "@commercetools/nimbus";

function AppLayout() {
  // 320px sidebar below a 768px container width, 30% above; persisted across reloads.
  const { rootProps } = useResponsiveSplitterSizes({
    persistKey: "app:main-splitter",
    size: { 0: 320, 768: "30%" },
    minSize: 240,
    maxSize: "50%",
  });

  return (
    <Splitter.Root {...rootProps} collapsible>
      <Splitter.Aside>{/* nav */}</Splitter.Aside>
      <Splitter.Handle />
      <Splitter.Main>{/* content */}</Splitter.Main>
    </Splitter.Root>
  );
}

How it works

Splitter.Root owns 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-valuenow is 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-token
lookup, storage adapter). The component never learns about pixels.

Test plan

  • pnpm --filter @commercetools/nimbus typecheck — clean
  • pnpm --filter @commercetools/nimbus build (incl. theme typings) — clean
  • pnpm test:dev packages/nimbus/src/components/splitter/103 passing across 12 files
    (util/hook specs + Storybook play functions + consumer docs.spec)
  • pnpm lint — clean
  • openspec validate add-splitter-component --strict — valid
  • openspec validate add-responsive-splitter-sizes-hook --strict — valid

Component 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 band
selection + 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.
  • NestedResponsiveSplitterinteractive: drag the outer handle to resize the inner
    container across the 768px threshold and watch the inner aside switch bands live.

Feedback wanted

  • The aside is the single configured pane and maxSize expresses the main pane's
    floor as its complement — does that read clearly at the call site, or would a
    named main-floor prop be worth the extra surface?
  • The hook treats a bare number as pixels unconditionally — clear enough given the
    component is percentage-native, or worth a more explicit { px: n } form?

Closes FEC-977

@vercel

vercel Bot commented May 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nimbus-documentation Ready Ready Preview, Comment Jun 11, 2026 7:45pm
nimbus-storybook Ready Ready Preview, Comment Jun 11, 2026 7:45pm

Request Review

@changeset-bot

changeset-bot Bot commented May 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5d0d5e1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@commercetools/nimbus Minor
@commercetools/nimbus-tokens Minor
@commercetools/nimbus-icons Minor
@commercetools/nimbus-design-token-ts-plugin Minor
@commercetools/nimbus-mcp Minor

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

@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown
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.

@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Report

Last updated: 2026-06-11 19:40:53 UTC

Package Format Current Baseline Delta Status
@commercetools/nimbus dist 16147.8 KB 15858.5 KB +1.8% ✅ ok
@commercetools/nimbus-icons dist 4787.6 KB 4787.6 KB +0.0% ✅ ok
@commercetools/nimbus-tokens dist 408.1 KB 408.1 KB +0.0% ✅ ok

Baseline source: comment-chain

@misama-ct misama-ct self-assigned this May 29, 2026
@misama-ct misama-ct added the WIP Work is ongoing, suggestions welcome label Jun 6, 2026
@misama-ct misama-ct force-pushed the FEC-977-nimbus-create-splitter-component-for-resizable-two-pane-layouts branch from 2e40a0a to 024150f Compare June 8, 2026 10:08
@misama-ct misama-ct removed the WIP Work is ongoing, suggestions welcome label Jun 8, 2026
@misama-ct misama-ct requested a review from a team June 8, 2026 11:42
@misama-ct misama-ct marked this pull request as ready for review June 8, 2026 11:42
misama-ct and others added 21 commits June 11, 2026 15:38
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
@ByronDWall ByronDWall force-pushed the FEC-977-nimbus-create-splitter-component-for-resizable-two-pane-layouts branch from 75debc3 to 5d0d5e1 Compare June 11, 2026 19:38
@ByronDWall ByronDWall merged commit 12f8dfc into main Jun 11, 2026
9 checks passed
@ByronDWall ByronDWall deleted the FEC-977-nimbus-create-splitter-component-for-resizable-two-pane-layouts branch June 11, 2026 19:48
@github-actions github-actions Bot added the bundle-sizes Housekeeping for merged PRs - allows the fetch sizes script to find the latest bundle check comment. label Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bundle-sizes Housekeeping for merged PRs - allows the fetch sizes script to find the latest bundle check comment.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants