Skip to content

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

Draft
misama-ct wants to merge 24 commits into
mainfrom
FEC-977-nimbus-create-splitter-component-for-resizable-two-pane-layouts
Draft

feat(splitter): add resizable two-pane Splitter component#1560
misama-ct wants to merge 24 commits into
mainfrom
FEC-977-nimbus-create-splitter-component-for-resizable-two-pane-layouts

Conversation

@misama-ct
Copy link
Copy Markdown
Collaborator

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

Summary

Adds Splitter to @commercetools/nimbus — a user-resizable two-pane layout (e.g. a sidebar next to a content area) built on the W3C window splitter pattern. It supports pointer and keyboard resizing, collapsible panes, per-pane minimum sizes, both orientations, and storage-agnostic layout persistence.

Motivation

We need a reusable, accessible primitive for resizable split layouts. The component is intentionally scoped to exactly two panes: a two-pane layout is a single boundary position (the second pane is always the complement of the first), which keeps the resize math and the accessibility contract simple and predictable. Layouts with three or more regions are composed by nesting a Splitter inside a Splitter.Pane.

API at a glance

<Splitter.Root
  defaultSizes={{ nav: 30, main: 70 }}   // single init path, full float precision
  onSizesChangeEnd={persist}             // settled value → persistence seam (no debounce)
  collapsedPane={collapsed}              // controlled collapse (one pane at a time)
  onCollapsedPaneChange={setCollapsed}
  panes={{ nav: { minSize: 10, collapsible: true }, main: { minSize: 20 } }}
  isDisabled={false}
>
  <Splitter.Pane id="nav"></Splitter.Pane>
  <Splitter.Handle />
  <Splitter.Pane id="main"></Splitter.Pane>
</Splitter.Root>

What changed

  • ComponentSplitter.Root / Splitter.Pane / Splitter.Handle compound component with id-keyed panes config (minSize, collapsible, collapsedSize), drag + keyboard resize, both orientations, a documented size variant, and an internationalized default handle label. Recipe + slots registered in the theme.
  • Sizes are uncontrolled for drag performance: defaultSizes (read once, normalized, full float precision) + onSizesChange (live) + onSizesChangeEnd (fires once per settled interaction — the persistence seam).
  • Collapse is controllable state, not an imperative API: collapsedPane / defaultCollapsedPane / onCollapsedPaneChange. A button anywhere in the app can collapse a pane with plain useState; Enter on the focused handle toggles it too.
  • Persistence is consumer-wired with any storage (localStorage, cookies, server, …) — hydrate defaultSizes, write back on onSizesChangeEnd, persist collapse via its controlled state. No bespoke hook.
  • isDisabled on Root makes the whole splitter non-interactive (handle out of tab order, aria-disabled).
  • Single boundary model — one minSize per pane; the upper bound is derived (100 − partner.minSize), so there is no redundant maxSize. Double-click restores the mount-time sizes.
  • Architecture — follows the Nimbus file-type guidelines: components/, hooks/, utils/ with barrels; a thin Splitter.Root delegating to useSplitterState; pure sizing helpers in utils/ with sibling unit specs.
  • Docs & specs — OpenSpec proposal/design/spec, Storybook stories with play functions, and a11y / developer / consumer documentation.

How it works

The handle is built on react-aria primitives (useSeparator, useMove, useFocusRing) and exposes role="separator" with aria-orientation, aria-valuenow/min/max derived from per-pane minSize, aria-valuetext, and aria-controls. Sizes are percentages summing to 100 with full float precision preserved end-to-end (only aria-valuenow is rounded, for AT — layout is unaffected), so consumers can hit exact pixels at a known container width.

Note on the API

This branch underwent a pre-release coherence reshape (no consumers shipped). The earlier useSplitterLayout hook, per-pane maxSize/defaultSize/disabled, and the imperative __layoutRef wiring were removed in favor of the smaller, convention-aligned surface above. See the OpenSpec change "Revision" sections for the rationale.

Test plan

  • Unit (JSDOM): sizing utilities + collapse-target selection + float-precision — 20 passing
  • Storybook play functions: drag, keyboard (arrows / Home / End), Enter collapse, controlled collapse from an external button, persistence hydration, double-click restore (incl. a 0% default), isDisabled, nesting, float precision — 14 passing
  • Consumer docs spec — 4 passing
  • typecheck and lint clean; openspec validate passes

Screenshots / recordings

Closes FEC-977

misama-ct added 17 commits May 25, 2026 11:26
…and context management

- Added WindowSplitter component with Root, Pane, and Separator subcomponents.
- Implemented context for managing splitter state and behavior.
- Created associated types and recipes for styling.
- Added stories for showcasing various configurations and interactions.
- Moved the `useWindowSplitterContext` hook to a new file for better structure.
- Updated exports in `window-splitter.tsx` to include subcomponents with clearer naming.
- Removed unused context-related code from `window-splitter.context.tsx`.
- Introduced comprehensive documentation for the Window Splitter component, detailing its functionality, usage guidelines, best practices, and accessibility features.
- Included examples for both horizontal and vertical orientations, as well as controlled and uncontrolled modes.
- Added tips and cautions for optimal usage scenarios and potential pitfalls.
…ests

- Updated WindowSplitter stories to include data-testid attributes for better testing.
- Introduced comprehensive keyboard interaction tests for both horizontal and vertical splitters, ensuring accessibility compliance.
- Refactored existing stories to improve clarity and maintainability.
- Adjusted imports for context management in pane and separator components.
- Updated WindowSplitter stories to utilize Chakra UI components for better styling and consistency.
- Refactored content in primary and secondary panes to use Heading, Text, and List components for improved readability.
- Adjusted box dimensions and border styles for a more polished appearance across all stories.
- Ensured that all interactive elements are accessible and visually aligned with design standards.
…ries

- Updated WindowSplitter stories to replace userEvent.tab() with direct focus calls for better focus management.
- Adjusted the WindowSplitterPane component to accept an explicit ID, enhancing accessibility and usability.
- Ensured all panes have a tabIndex of 0 for keyboard navigation compliance.
…ibility

- Enhanced WindowSplitter stories with updated dimensions, padding, and border styles for a more cohesive look.
- Replaced static values with responsive design elements, ensuring better adaptability across different screen sizes.
- Improved text hierarchy by adjusting Heading sizes and margins for better readability and visual appeal.
- Ensured all components maintain accessibility standards while providing a polished user experience.
…tter components

- Updated the window splitter recipe to improve layout with absolute positioning and z-index adjustments.
- Changed background colors for better visibility and hover effects.
- Enhanced the WindowSplitterSeparator component to dynamically calculate position based on orientation, improving usability and responsiveness.
Reshape the unmerged window-splitter proof of concept into a focused
N-pane primitive with predictable cascading behaviour, persistence
integration, and cross-app command support. App-shell layout concerns
are explicitly deferred to a follow-up pattern component.
…ples

- Rename window-splitter → splitter throughout: directory, files,
  identifiers, displayNames, MDX frontmatter, parent barrel export
- Register nimbusSplitter in theme/slot-recipes; switch Root and slots
  from inline-recipe to key-based recipe lookup
- Replace @chakra-ui/react barrel imports with @chakra-ui/react/styled-system
  subpath; pull mergeRefs from @/utils instead of Chakra
- Fix module resolution: @/utils/extractStyleProps → @/utils;
  Storybook 9 imports (@storybook/react → @storybook/react-vite,
  @storybook/test → storybook/test)
- Remove bare Root/Pane/Separator re-exports that collided with the
  existing Separator component at the package barrel; add underscore-
  prefixed docgen exports matching the Steps pattern
- Convert empty slot interface declarations to type aliases
- Demonstrate the ScrollArea composition pattern in stories and MDX
  examples for branded scrollbars; switch demo colors to indigo + amber
  for better hue contrast
…uration

- Reshape Splitter from N-pane + cascade to 2-pane + nesting; justify the
  choice on accessibility (W3C window splitter is specified for a separator
  between two regions), responsive design (nesting maps to real layout
  hierarchy), and implementation cost (no cascade algorithm needed)
- Switch per-pane configuration to a `panes: Record<string, PaneConfig>`
  map on Root keyed by pane id; Pane carries only `id` + content
- Switch sizes to `Record<string, number>` so persistence is reorder-safe
  and `aria-controls` resolves to a DOM id without index math
- Make Handle anonymous (no id, no per-handle config); Root carries
  `keyboardStep`, `disableDoubleClick`, default aria-label
- Drop cascading resize from spec/tasks; constraints clamp at min/max
- Add Nesting requirement with a 3-region-via-nesting scenario; rewrite
  hook reconciliation around id mismatch instead of array-length mismatch
…rLayout

Replaces the WindowSplitter scaffold with the OpenSpec-aligned 2-pane primitive:
id-keyed configuration via `panes` map on Root, uncontrolled state, anonymous
`Splitter.Handle`, per-pane min/max with no-cascade clamping, collapsible panes
via double-click and Enter, and a companion `useSplitterLayout` hook for
SSR-safe persistence and cross-component imperative commands.

- Rename `Splitter.Separator` -> `Splitter.Handle` (ARIA role stays `separator`)
- Recipe: `.ts`, `_focusVisible` + layerStyle focusRing/disabled, design tokens
- Four-layer types with OmitInternalProps; W3C ARIA model with per-pane bounds
- New `clampedResize` utility + 13 unit tests; 9 hook tests; docs.spec tests
- Stories rewritten for new API with play functions covering all spec scenarios
- New splitter.dev.mdx, splitter.a11y.mdx, splitter.docs.spec.tsx
- i18n: resizePanes default aria-label localised via shared pipeline
…lapse toggle

Decouples the mouse gesture from collapsibility. Double-click on the handle
now returns the boundary to the sizes resolved on mount (from `defaultSizes`
or `panes[id].defaultSize`) and works on every splitter, including ones
without `collapsible: true` panes. Collapse stays on Enter (focused handle)
and the imperative `useSplitterLayout.collapse(paneId)`.

The previous binding (double-click = same as Enter) left double-click as a
no-op on the common non-collapsible case. The new contract gives the gesture
a uniform meaning across all splitters — a quick "undo a stray drag" — and
documents the trade-off (consumers wanting a mouse affordance for collapse
surface it themselves) in design.md Decision 10.

- Spec: replace double-click-toggles-collapse scenarios with a new
  "Double-click restores defaults" requirement; clarify Enter scenarios.
- Context + Root: add `restoreDefaults`; cache derived initial sizes in a ref.
- Handle: onDoubleClick → restoreDefaults (gated by `disableDoubleClick`).
- Stories: replace CollapsibleByDoubleClick with DoubleClickRestoresDefaults;
  switch CollapseExpandCallbacks to keyboard; update DisableDoubleClick.
- Docs: clarify Collapsible-panes section, add Double-click-restores-defaults.
… guidelines

Extract the Root sizes state machine into hooks/use-splitter-state.ts so
SplitterRoot is a thin provider; pull the pure deriveInitialSizes and
pickCollapseTarget helpers into utils/ with sibling specs (matching the
existing clampedResize treatment). Add components/, hooks/, and utils/
barrels and route imports through them. Move the context module to the
component root (.tsx->.ts) and relocate SplitterContextValue into the types
file. De-duplicate the slot prop types. Add @supportsStyleProps and per-part
JSDoc, and fix the __layoutRef typo in the useSplitterLayout example.

No behavior change: typecheck, lint, and 37 unit tests (+14 new) pass.
… residue sweep

Persistence reconciliation now treats the layout as a single boundary
value (the second pane is always the complement of the first): salvage
the first pane's stored size, infer the partner, or fall back to
initialSizes. Replaces the generic N-pane normalize/fill logic that
produced surprising rescaled values for renamed/partial stored records.

Sweep remaining N-pane residue to explicit 2-pane form (deriveInitialSizes
math; direct partner lookup and literal 50 in collapse/expand). Wait for
the collapsed pane's ScrollArea viewport to become keyboard-focusable in
the Cross App Commands story so the a11y check sees the settled state.

Updates the persistence unit tests, the Graceful Persistence story
assertion, and trims the contradictory spec scenarios to the 2-pane rule.

Splitter suite: 38 unit + 14 storybook tests green.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 29, 2026

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

Project Deployment Actions Updated (UTC)
nimbus-documentation Ready Ready Preview, Comment May 29, 2026 12:34pm
nimbus-storybook Ready Ready Preview, Comment May 29, 2026 12:34pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: 28e89d7

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

github-actions Bot commented May 29, 2026

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

github-actions Bot commented May 29, 2026

Bundle Size Report

Last updated: 2026-05-29 12:33:57 UTC

Package Format Current Baseline Delta Status
@commercetools/nimbus dist 15878.3 KB 15682.8 KB +1.2% ✅ 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

A pre-release review found the API over-parameterized a single-value
boundary. Reshaped while there are no consumers:

- Collapse is now controllable state (collapsedPane / defaultCollapsedPane /
  onCollapsedPaneChange) instead of an imperative API.
- Removed the useSplitterLayout hook + __layoutRef; persistence is consumer-
  wired via the new onSizesChangeEnd (settled, debounce-free) + any storage.
- Dropped per-pane maxSize (derived from partner minSize), per-pane
  defaultSize (single canonical defaultSizes), and per-pane disabled
  (replaced by Root-level isDisabled, matching the Nimbus convention).
- Fixed restoreDefaults zero-size guard; removed dead commitCollapse path.
- Documented the size variant; guaranteed full float precision in the size
  pipeline (only aria-valuenow rounds, for AT; added aria-valuetext).

Docs, stories, unit/docs specs, OpenSpec change, and changeset updated.
…o guidelines

The three doc pages didn't follow the documented structure:

- Overview (.mdx): use lifecycleState + exportName frontmatter (was the
  invalid documentState/InitialDraft), drop the in-body H1, restructure to
  Overview → Resources → Variables → Guidelines, and use `jsx live` fences
  (were the invalid `jsx-live`). Implementation detail moved to the dev page.
- Implementation (.dev.mdx): rewritten to the engineering-docs template —
  Getting started, Usage examples (jsx live-dev), Component requirements,
  Accessibility (labeling + PERSISTENT_ID + Keyboard navigation), API
  reference (<PropsTable id="Splitter" />), Testing (mandatory disclaimer +
  {{docs-tests}} directive), and Resources. (Previously it was a design-
  rationale doc missing all of these.)
- Accessibility (.a11y.mdx): align to the standard shape — no title in
  frontmatter, standard intro boilerplate, Accessibility standards bullet
  list, and Resources.
The handle track is invisible at rest, so the `disabled` layerStyle
(opacity 0.5 + not-allowed cursor) had nothing to attach to and only
surfaced a misleading not-allowed cursor. Replace it with `cursor: default`
so the disabled handle drops the resize cursor without implying a blocked
interaction. Spec scenario updated to match.
misama-ct added 2 commits May 29, 2026 13:24
Align the boolean with the house is*Disabled convention (isDisabled,
isKeyboardDismissDisabled). The imperative disable* form had no precedent
outside Splitter. Pure rename across source, stories, docs, and the
OpenSpec proposal/design/tasks/spec.
- lifecycleState Experimental → Beta (component is stable enough for
  cautious non-critical production use).
- Correct the SplitterHandleProps JSDoc: it claimed the handle 'accepts
  no id', but id (and other DOM/style props) flow through the slot and
  the accessibility docs explicitly recommend a persistent id for
  analytics. Reword to 'no per-handle behaviour config' instead.
Lead with the component name in backticks (drop the conventional-commit
title line), switch to one-bullet-per-observable-change, and strip
implementation mechanics (ARIA attribute names, focus-ring, hit-area)
that the conventions doc says belong in the PR/commits, not the
consumer-facing changelog.
Drop the prop-by-prop enumeration (API-reference material that belongs
in the docs PropsTable, not the consumer changelog) in favor of a short
'what it is / what it's for' summary that links to the docs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant