diff --git a/.changeset/add-splitter-component.md b/.changeset/add-splitter-component.md new file mode 100644 index 000000000..a275dc619 --- /dev/null +++ b/.changeset/add-splitter-component.md @@ -0,0 +1,19 @@ +--- +"@commercetools/nimbus": minor +--- + +`Splitter`: new compound component for user-resizable two-pane layouts. A +draggable, keyboard-operable handle sits between a configurable `Splitter.Aside` +and a `Splitter.Main` that fills the remaining space (the aside can sit on +either side, horizontal or vertical). You configure a single dimension — the +aside's `size` — plus optional `minSize` / `maxSize` and a collapsible aside. +Size is uncontrolled by default (`defaultSize`) or controllable in place via the +`size` prop for responsive, per-breakpoint layouts; a single number round-trips +to your own storage via `onSizeChangeEnd`. Nest splitters for three or more +regions. See the docs for the full API. + +Also ships `useResponsiveSplitterSizes`, a companion hook for consumers who want +to express pane sizes in pixels, size tokens, or per-container-width breakpoints +instead of percentages. It measures the container, translates your config into +the percentage `Splitter.Root` consumes, clamps to your `minSize` / `maxSize`, +and can persist the user's settled size across reloads. diff --git a/openspec/changes/add-responsive-splitter-sizes-hook/.openspec.yaml b/openspec/changes/add-responsive-splitter-sizes-hook/.openspec.yaml new file mode 100644 index 000000000..2cb80411e --- /dev/null +++ b/openspec/changes/add-responsive-splitter-sizes-hook/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-10 diff --git a/openspec/changes/add-responsive-splitter-sizes-hook/design.md b/openspec/changes/add-responsive-splitter-sizes-hook/design.md new file mode 100644 index 000000000..0eebc94df --- /dev/null +++ b/openspec/changes/add-responsive-splitter-sizes-hook/design.md @@ -0,0 +1,224 @@ +## Context + +As of commit `ab266b119` the `Splitter` is a single-dimension component: one +`Splitter.Aside` (the pane you size) and one `Splitter.Main` (the remainder), +with role designated by component type rather than id. `Splitter.Root` owns one +number — the aside's percentage `size` (`0–100`; main is `100 − size`) — and +exposes a controlled, **settle-only** `size` prop alongside `onSizeChangeEnd`. +Control is reconciled at rest by an effect: internal state stays authoritative +during a drag, the prop is written into state in place, silently (no callbacks), +normalized to `[0,100]`, and **not** clamped to `minSize`/`maxSize` (the next +interaction re-clamps). If the controlled value is not fed back, the splitter +keeps the last interactive value and behaves uncontrolled from then on (no +snap-back). Resizing is locked while the aside is collapsed. See +`hooks/use-splitter-state.ts` and `splitter.types.ts`. + +The component is percentage-native and has no pixel code path by design. But the +most common real layout — a fixed-width sidebar (`320px` nav, icon rail) whose +split differs per device and survives reload — is naturally expressed in pixels. +Pixels, responsive resolution, and persistence are consumer- and surface-specific +policy that does not belong in the component's state machine. The settle-only +controlled channel is exactly the runtime seam a companion hook needs: pushing a +new value lands in place, no remount, so pane scroll state survives. + +This design was pressure-tested by four independent reviewers (consumer-DX, +API-minimalism, runtime-correctness, long-term-maintainability) before being +written down; their findings are folded into the Decisions and Risks below. + +## Goals / Non-Goals + +**Goals:** + +- A `useResponsiveSplitterSizes` hook that maps a pixel-/token-/percent size + config into `{ rootProps: { size, minSize, maxSize, collapsedSize, onSizeChangeEnd, ref, orientation } }`. +- `number` always means pixels; tokens resolve to pixels; `` `${number}%` `` + passes through. All pixel/token values convert to a percentage of the measured + container so the component stays percentage-only. +- Optional per-container-width responsiveness via a min-width threshold cascade + keyed by pixel/token thresholds, resolved against the splitter's own width. +- A pixel facade over `minSize` / `maxSize` / `collapsedSize`, with hook-side + clamping of the resolved `size`. +- Versioned, per-band, pixel-first persistence through injectable storage, with + `stored[active] ?? default[active]` resolution. +- Keep the controlled loop closed (no snap-back, no churn) and degrade safely + when browser APIs are unavailable. + +**Non-Goals:** + +- No change to `Splitter.Root` / `Splitter.Aside` / `Splitter.Main` / + `Splitter.Handle`. +- No live per-tick control — control stays settle-only; `onSizeChange` is not + used to drive the controlled value. +- No viewport-relative resolution — the hook always measures the splitter's own + container. (There is no `resolveAgainst` option.) +- No pixel code path inside the component. +- The hook does not fix or mask the component's first-paint `50/50` flash — that + is a separate component change (see Risks). + +## Decisions + +### D1. A companion hook, not a component feature + +The component stays a pure percentage size engine. Pixel math, responsive +resolution, and persistence live in the hook, composed on the already-shipped +controlled `size` + `onSizeChangeEnd` pair. **Why:** these are consumer- and +surface-specific policies; baking them in would put layout truth outside the +component's state machine and reintroduce the pixel path it deliberately omits. +*Alternative considered:* a `units`/`responsive` prop on `Splitter.Root` — +rejected as scope creep coupling the component to pixels, tokens, breakpoints, +and storage. + +### D2. `number` is always pixels (single, position-independent unit rule) + +A size value is `number` (pixels), a size token (→ pixels), or `` `${number}%` `` +(percentage passthrough). A threshold **key** is `number` (pixels) or a size +token — never a percentage (a percentage threshold of the container against +itself is meaningless). **Why:** the hook exists to let consumers think in +pixels; `number = px` is its whole reason for being, and one rule ("a bare +number is pixels, everywhere") is easier to hold than per-position inference. +*Known tension (reviewers):* `Splitter.Root`'s own `size`/`minSize`/… props are +percentages, so a bare number means px in the hook but `%` on the raw component. +This is accepted deliberately: the hook owns the **full** facade (`size` + +`minSize`/`maxSize`/`collapsedSize`), so a consumer using the hook never hand-writes +a raw percentage onto the root, which closes the collision in practice. Docs +state the rule prominently. + +### D3. Container-width threshold keys, container-only resolution + +Responsive config is an object whose keys are container **min-width** thresholds +(pixel numbers or size tokens), resolved against the splitter's own width via a +`ResizeObserver`. The active band is the largest threshold `≤` the measured +width; the smallest entry also applies below it. Resolution is **always against +the container** — there is no `resolveAgainst` option. **Why:** the hook resolves +against the element, so keying by viewport breakpoint *names* would lie about +what is measured; explicit pixel/token thresholds say what they mean. Viewport +resolution was considered and dropped: a one-value option is ceremony, and if a +viewport variant is ever needed it is better introduced as its own explicitly +named hook than as a mode flag that silently reinterprets the same threshold +keys. *Alternative considered:* borrowing Chakra's breakpoint **condition +names** (`sm`/`md`/…) as keys — rejected as viewport-coded and misleading for +container resolution, and it would have coupled the hook to +`theme/breakpoints.ts`. + +### D4. Tokens resolve to pixels via a curated union + guard test + +`size` tokens accepted as values and keys are the named families only: `3xs`–`8xl` +and `breakpoint-sm`…`breakpoint-2xl`. They are exposed as a hand-authored +`SplitterSizeToken` union (not `keyof typeof themeTokens.size`, which also +contains the numeric scale `25`…`9600` and would both pollute autocomplete and +make `"400"`-as-token collide with `400`-as-pixels). A unit test asserts every +union member still exists in `themeTokens.size`. **Why:** tokens give ergonomic +parity with the rest of the system, but the token names are volatile; a curated +union keeps autocomplete clean and the existence test turns a token rename from a +silent runtime miss into a red build. *Alternative considered:* deriving the +type from the token object — rejected (absorbs renames silently, imports the +numeric-scale noise). + +### D5. Pixel facade over `minSize` / `maxSize` / `collapsedSize`, hook clamps `size` + +The hook accepts pixel/token/percent for `minSize`, `maxSize`, and +`collapsedSize`, translates each to a percentage, and forwards them via +`rootProps`. It clamps the resolved `size` into `[minSize, maxSize]` **before** +emitting. **Why:** these are size-dimensional constraints a pixel-thinking +consumer needs in pixels too, and the component reconciles controlled `size` +with normalization only — it does **not** re-clamp to min/max until the next +interaction (`use-splitter-state.ts`), so an unclamped px→% result would render +out of bounds for a frame. The facade is explicitly scoped to the **size +dimension only**; it is not a general root pass-through (consumers spread their +own remaining props onto `Splitter.Root` directly). + +### D6. Drive the existing settle-only controlled `size`, equality-gated + +The hook returns `size` (controlled) plus `onSizeChangeEnd` and feeds the +emitted value back. It equality-gates its own emitted `size` with a tolerance +coarser than the component's internal `1e-6`, so pixel↔percent round-trips +triggered by `ResizeObserver` ticks cannot push a fresh prop every frame. +**Why:** the reconcile effect already writes the prop into state in place and +silently and is double-equality-gated, so a settled push is a no-remount, +no-flash, no-callback-loop update — but only if the hook does not emit a +micro-different value on every measurement. + +### D7. Versioned, per-band, pixel-first persistence; collapse suppresses writes + +Persist `{ v, bands: { [thresholdPx]: { unit, value } } }` under `persistKey` +via an injectable Storage-like interface (default `localStorage`). On a genuine +settle the hook writes the active band: pixel/token bands store **pixels** +(re-derived from the settled percentage and the measured container, so the size +re-pins on resize), percent bands store a percentage. Bands are keyed by the +**resolved pixel threshold** (stable across token renames). Resolution is +`stored[active] ?? configDefault[active]`. `collapsedSize` is never persisted; +while the aside is collapsed the hook suppresses persistence (keyed off the +collapse signal, not a value comparison) so the latest expanded size survives +collapse/expand. **Why:** pixel-first storage keeps the "320px stays 320px" +promise across drags and reloads; per-band keeps each device's remembered size +independent; the version envelope lets a future shape change migrate rather than +silently misread old data. *Alternative considered:* a single shared value +(can't express per-device memory) and value-equality collapse detection +(rejected — a legitimate expanded size equal to `collapsedSize` would be dropped). + +### D8. Two-phase resolution, within the component's first-paint reality + +`%`-only config resolves synchronously; pixel/token config is resolved after the +first container measurement in a layout effect. **Why:** this is the best the +hook can do toward a correct first commit. Reviewers verified the component +itself paints `50/50` first (it seeds `useState(50)` and derives in a mount +effect gated on pane registration), so the hook cannot guarantee a flash-free +first frame on its own; the genuine fix is the separate component seeding change. +The hook therefore aims for "correct as early as measurement allows" and the docs +state the dependency rather than over-promising. + +### D9. Defensive access to browser APIs + +`ResizeObserver` and `storage` are feature-detected and wrapped so SSR, older +runtimes, and storage-denied contexts fall back to config-default resolution +without throwing. A container width of `0`/non-finite is guarded so pixel→percent +never divides by zero; resolution retries on the next measurement. + +## Risks / Trade-offs + +- **First-paint flash is a component behavior the hook can't mask.** The root + seeds `50%` and derives in a mount effect, so the first frame is `50/50` for + every consumer. → Tracked as a separate component fix (seed `size`/`defaultSize` + synchronously). The hook resolves `%` synchronously and pixels in a layout + effect, and the docs state the dependency; it does not claim a flash-free first + paint. +- **`number = px` in the hook vs `number = %` on the raw component.** → Mitigated + by the hook owning the full size facade, so consumers don't hand-write root + percentages; stated prominently in docs. +- **Pixel values that convert outside `[minSize, maxSize]`.** → The hook clamps + the resolved `size` itself before emitting, because the component won't + re-clamp controlled `size` until the next interaction. +- **`ResizeObserver` churn / controlled-loop oscillation.** → The hook + equality-gates its emitted `size` with a tolerance coarser than the component's + `1e-6`; observe/unobserve is cleanup-symmetric and StrictMode-safe; writes are + idempotent. +- **Band-boundary thrash when the width sits on a threshold.** → A hysteresis + deadband around thresholds (and a deterministic band assignment for boundary + values) prevents per-frame flapping and split persistence history. +- **Persisted pixels from a wide session restored into a narrow container.** → + The px→% restore is clamped into `[minSize, maxSize]` like any other resolved + value before emitting. +- **`localStorage` quota / corrupt JSON / unavailable.** → All access is + try/caught; failures no-op and resolution falls back to defaults; the persisted + payload is versioned so shape changes migrate. +- **Token rename/removal.** → Curated `SplitterSizeToken` union plus an + existence test against `themeTokens.size` converts a rename into a build + failure. +- **Container-width measurement vs exact pixels.** → `%` config is exact on first + commit; pixel correction runs in a layout effect, bounded to a single pre-paint + reconcile (modulo the separate component first-paint fix above). + +## Migration Plan + +Additive only — a new hook plus barrel/public-API exports and a curated token +union. No existing API changes, so no consumer migration is required. The +versioned persistence envelope (`v: 1`) reserves room for future storage-shape +changes to migrate rather than break. Rollback is removing the new files and +their exports. The separate component first-paint seeding fix is independent and +can land before, with, or after this hook. + +## Open Questions + +- Should the hook expose the resolved active band (e.g. a `rootProps` sibling + like `activeThreshold`) for consumers who want to label the current size band? + (Deferred unless a concrete need appears.) diff --git a/openspec/changes/add-responsive-splitter-sizes-hook/proposal.md b/openspec/changes/add-responsive-splitter-sizes-hook/proposal.md new file mode 100644 index 000000000..4a44216b7 --- /dev/null +++ b/openspec/changes/add-responsive-splitter-sizes-hook/proposal.md @@ -0,0 +1,110 @@ +## Why + +The `Splitter` component sizes its single configurable pane (`Splitter.Aside`) +as a percentage of its container — one number, `0–100`, with `Splitter.Main` +taking the remainder. That percentage model keeps the component's state machine +simple, but it is the wrong unit for the most common real layout: a sidebar that +should be a fixed width (a `320px` nav, an icon rail), and whose right split +differs per device. Consumers cannot express "320px here, 30% there" today, the +component has no pixel code path by design, and any value a consumer does pick is +lost on reload. + +The component already exposes the exact runtime seam a companion hook needs: a +controlled, **settle-only** `size` prop that reconciles in place (no remount, no +snap-back) plus `onSizeChangeEnd`. A hook can sit in front of that seam and act +as a pure **pixel/token → percentage translator**, keeping every responsive, +pixel, and persistence concern out of the component. + +## What Changes + +- Add `useResponsiveSplitterSizes`, a `Splitter` companion hook that resolves a + pixel-/token-based, optionally per-container-width size config down to the + percentage `Splitter.Root` consumes, and returns props to spread onto the + root: `{ rootProps: { size, minSize, maxSize, collapsedSize, onSizeChangeEnd, onCollapsedChange, ref, orientation } }`. + The forwarded `onCollapsedChange` lets the hook observe collapse (it fires + before the collapse-driven settle) so it can suppress persistence while + collapsed; an optional `onCollapsedChange` option is called through for + consumers who also want to observe collapse. +- **Unit model (`number` is always pixels).** A size value is one of: a `number` + (pixels), a size **token** string (`3xs`–`8xl` or `breakpoint-sm`…`breakpoint-2xl`, + resolving to pixels via `themeTokens.size`), or a `` `${number}%` `` string + (a percentage, passed through untranslated). The hook converts pixels and + tokens to a percentage of the **measured container** so the component stays + percentage-only. +- **Responsive config keyed by container width.** Each configurable dimension + (`size`, and the `minSize` / `maxSize` / `collapsedSize` facade) is either a + single value (applies at every width) **or** an object whose keys are + container **min-width thresholds** — a `number` (px) or a size token — forming + a min-width cascade resolved (via `ResizeObserver`) against the splitter's + **own** measured width — never the viewport. The largest threshold `≤` the + measured width wins; the smallest entry also applies below it. +- **Pixel facade over the aside's constraints.** `minSize`, `maxSize`, and + `collapsedSize` accept the same pixel/token/percent values, are translated to + percentages the same way, and are forwarded via `rootProps`. The hook clamps + the resolved `size` into `[minSize, maxSize]` **itself** before emitting, + because the component reconciles controlled `size` without re-clamping until + the next interaction. +- **Persistence in pixels, per band, versioned.** On a genuine settle the hook + stores the size under the active threshold band through an injectable, + Storage-like `storage` (default `localStorage`) under a versioned envelope. + Pixel/token bands persist **pixels** (so a dragged `320px` re-pins to `320px` + across reloads and resizes); percent bands persist a percentage. Resolution is + `stored[band] ?? configDefault[band]`. `collapsedSize` is static config and is + never persisted; while the aside is collapsed the hook suppresses persistence + so the latest expanded size survives collapse/expand. +- Close the controlled loop without snap-back: feed the resolved value back as + `size` and wire `onSizeChangeEnd`, honoring the component's settle-only + contract. The hook equality-gates its emitted `size` so pixel↔percent + round-trips under `ResizeObserver` churn cannot thrash the controlled prop. +- No changes to `Splitter.Root` / `Splitter.Aside` / `Splitter.Main` / + `Splitter.Handle`. + +Explicitly **out of scope**: live per-tick (`onSizeChange`) control — control +stays settle-only; viewport-relative resolution (the hook always measures the +splitter's own container); any pixel code path inside the component itself. + +**Known dependency, tracked separately:** the component currently seeds its +first paint at `50%` and derives `size`/`defaultSize` in a mount effect, so the +first committed frame is `50/50` for any consumer (hook or not) before it snaps +to the configured value. That first-paint flash is a standalone component +behavior fixed independently (seed the size synchronously); the hook does not +attempt to mask it and this proposal does not depend on the responsive feature +to fix it. + +## Capabilities + +### New Capabilities + +- `nimbus-use-responsive-splitter-sizes`: A React hook that translates a + pixel-/token-based, optionally per-container-width pane-size config into the + percentage `Splitter.Root` accepts — with pixel-to-percentage conversion + against the measured container, a pixel facade over `minSize` / `maxSize` / + `collapsedSize`, hook-side clamping, and versioned per-band persistence in + pixels across sessions and resizes. + +### Modified Capabilities + + + +## Impact + +- **New code**: + `packages/nimbus/src/components/splitter/hooks/use-responsive-splitter-sizes.ts`, + a curated `SplitterSizeToken` union + pure resolution helpers under + `components/splitter/utils/`, exported from the splitter barrel + (`components/splitter/index.ts`) and surfaced through the package public API. +- **New tests**: a `.spec.tsx` unit test for the hook and its pure resolvers + (JSDOM, per the hooks-are-unit-tested convention), a token-existence guard + test, plus a Storybook story demonstrating the responsive + pixel + persisted + flow. +- **Docs**: a usage section in the splitter `.dev.mdx` / `.mdx` covering the + pixel/token/responsive + persistence recipe and the `ref` requirement. +- **Depends on**: the existing controlled `size` + `onSizeChangeEnd` API on + `Splitter.Root`; the size tokens in `themeTokens.size`. No new runtime + dependencies. +- **Browser APIs**: `ResizeObserver` and `localStorage` — both accessed + defensively so SSR and storage-denied environments degrade to config-default + resolution without throwing. diff --git a/openspec/changes/add-responsive-splitter-sizes-hook/specs/nimbus-use-responsive-splitter-sizes/spec.md b/openspec/changes/add-responsive-splitter-sizes-hook/specs/nimbus-use-responsive-splitter-sizes/spec.md new file mode 100644 index 000000000..b688d5850 --- /dev/null +++ b/openspec/changes/add-responsive-splitter-sizes-hook/specs/nimbus-use-responsive-splitter-sizes/spec.md @@ -0,0 +1,231 @@ +## ADDED Requirements + +### Requirement: Resolve a pixel/token/percent size config into root props + +The hook SHALL accept a `size` config for the aside expressed as a single value +or a per-threshold map, plus an optional `orientation`, and SHALL return a +`rootProps` object containing `size`, `minSize`, `maxSize`, +`collapsedSize`, `onSizeChangeEnd`, `onCollapsedChange`, `ref`, and +`orientation`, intended to be spread onto `Splitter.Root`. The forwarded +`onCollapsedChange` lets the hook observe collapse so it can suppress +persistence while collapsed. A size value SHALL be one of: a `number` +(interpreted as **pixels**), a size **token** string (resolving to pixels), or a +`` `${number}%` `` string (a percentage passed through untranslated). The +returned `rootProps.size` SHALL always be a valid controlled value: a single +percentage in `[0, 100]`. + +#### Scenario: Config resolves to a controlled size + +- **WHEN** the hook is called with a `size` config and its `ref` is attached to + a mounted `Splitter.Root` +- **THEN** `rootProps.size` is a percentage in `[0, 100]` for the active band, + and spreading `rootProps` onto `Splitter.Root` drives the controlled + (settle-only) size channel without remounting it + +#### Scenario: A bare number is always pixels + +- **WHEN** a size value is the bare `number` `320` +- **THEN** the hook treats it as `320px` and converts it to a percentage of the + measured container — never as `320%` + +#### Scenario: A percent string passes through untranslated + +- **WHEN** a size value is the string `"30%"` +- **THEN** the hook resolves the aside to `30` percent directly, without + measuring the container + +### Requirement: Convert pixel and token values to percentages against the container + +When a resolved value is expressed in pixels or as a size token, the hook SHALL +convert it to a percentage of the measured `Splitter.Root` width (horizontal +splitter) or height (vertical splitter) before exposing it as a controlled size. +Tokens SHALL resolve to pixels via the size-token source before conversion. +Pixel conversion SHALL always measure the splitter's own container. The +`Splitter` component SHALL NOT receive pixel values. + +#### Scenario: Pixel value becomes a percentage of the container + +- **WHEN** the active band resolves the aside to a `px` value and the container + measures a known size on the relevant axis +- **THEN** the hook converts the `px` value to the equivalent percentage of the + container and exposes it as `rootProps.size` + +#### Scenario: Token value resolves to pixels then to a percentage + +- **WHEN** the active band resolves the aside to a size token (e.g. + `"breakpoint-sm"`) +- **THEN** the hook resolves the token to its pixel value and converts that to a + percentage of the measured container + +#### Scenario: Only curated size tokens are accepted + +- **WHEN** a value or threshold key is a string that is not a member of the + curated `SplitterSizeToken` set (`3xs`–`8xl`, `breakpoint-sm`…`breakpoint-2xl`) + and is not a `` `${number}%` `` value +- **THEN** the type does not permit it, and at runtime the hook ignores the + unresolvable entry and falls back to a valid resolution rather than throwing + +### Requirement: Resolve the active band against the container by min-width threshold + +The hook SHALL always resolve the active band against the splitter's own +container (there is no resolution-axis option). It SHALL observe the referenced +`Splitter.Root` element with a `ResizeObserver` and select the band whose +**min-width threshold** the element's measured size satisfies. Thresholds SHALL +be expressed as pixel numbers or size tokens (never percentages). The active +band SHALL be the largest threshold less than or equal to the measured size; the +smallest configured entry SHALL also apply below its threshold. The hook SHALL +re-resolve when the active band changes. + +#### Scenario: Container width selects the band + +- **WHEN** the observed root element's measured size crosses from one threshold + band into another +- **THEN** the hook re-resolves the active band to the one matching the new + measured size and updates `rootProps.size` accordingly + +#### Scenario: Below the smallest threshold + +- **WHEN** the measured size is smaller than the smallest configured threshold +- **THEN** the hook resolves using the smallest configured entry + +#### Scenario: A single value applies at every width + +- **WHEN** `size` is a single value rather than a threshold map +- **THEN** the hook resolves that value at every measured width without band + selection + +#### Scenario: Boundary stability + +- **WHEN** the measured size sits at or oscillates around a threshold by + sub-pixel deltas +- **THEN** the hook applies hysteresis so the active band does not flap between + values frame to frame + +### Requirement: Apply a pixel facade over the aside constraints and clamp the size + +The hook SHALL accept `minSize`, `maxSize`, and `collapsedSize` in the same +pixel/token/percent units (each a single value or a per-threshold map), SHALL +translate them to percentages the same way as `size`, and SHALL forward them via +`rootProps`. The hook SHALL clamp the resolved `rootProps.size` into the resolved +`[minSize, maxSize]` window before emitting it. + +#### Scenario: Resolved size is clamped into the configured window + +- **WHEN** a resolved pixel `size` converts to a percentage below the resolved + `minSize` or above the resolved `maxSize` +- **THEN** the hook clamps `rootProps.size` into `[minSize, maxSize]` before + exposing it, rather than relying on the component (which does not re-clamp a + controlled size until the next interaction) + +#### Scenario: Constraints are forwarded as percentages + +- **WHEN** `minSize` / `maxSize` / `collapsedSize` are given in pixels or tokens +- **THEN** the hook converts each to a percentage of the measured container and + exposes them on `rootProps`, so the component receives only percentages + +### Requirement: Persist the settled size per band, in pixels, versioned + +The hook SHALL persist the settled size keyed by the active threshold band, +through an injectable `storage` (a `localStorage`-like get/set interface) +defaulting to `localStorage`, under a consumer-provided `persistKey`. The stored +payload SHALL be versioned and keyed by the resolved pixel threshold. Pixel/token +bands SHALL persist a pixel value (re-derived from the settled percentage and the +measured container); percent bands SHALL persist a percentage. Resolution SHALL +follow `stored[activeBand] ?? configDefault[activeBand]`. A restored value SHALL +be clamped into `[minSize, maxSize]` like any other resolved value. + +#### Scenario: A settled drag is persisted in pixels under the active band + +- **WHEN** a resize settles (the spread `onSizeChangeEnd` fires) in a + pixel/token band +- **THEN** the hook re-derives the pixel width from the settled percentage and + the measured container and writes it under that band, leaving other bands + untouched + +#### Scenario: A pixel pin survives reload and resize + +- **WHEN** the user drags to a width in a pixel band, reloads, and the container + is a different size +- **THEN** the hook restores the stored pixel width and re-converts it to a + percentage for the new container, so the pinned pixel width is preserved + +#### Scenario: Stored value takes precedence over the config default + +- **WHEN** the active band has a previously stored value +- **THEN** the hook resolves to the stored value rather than the configured + default for that band + +#### Scenario: Persistence degrades gracefully when storage is unavailable + +- **WHEN** `storage` access throws, is unavailable (SSR, privacy mode), or holds + corrupt/old data +- **THEN** the hook does not throw and resolves from the configured defaults, + using the payload version to migrate where possible + +### Requirement: Never persist the collapsed size + +The hook SHALL treat `collapsedSize` as static configuration and SHALL NOT +persist it. While the aside is collapsed the hook SHALL suppress persistence so +the latest expanded size is preserved across collapse and expand. Suppression +SHALL be keyed off the collapse signal, not a comparison of the settled value to +`collapsedSize`. + +#### Scenario: Collapsing does not overwrite the stored expanded size + +- **WHEN** the aside collapses (a settle fires with the collapsed size) and later + expands +- **THEN** the stored value still reflects the last expanded size, and expanding + restores it + +#### Scenario: An expanded size equal to the collapsed size is still persisted + +- **WHEN** the user drags the expanded aside to a size that happens to equal + `collapsedSize` without collapsing +- **THEN** the hook persists that size, because suppression is keyed off the + collapse signal rather than value equality + +### Requirement: Maintain the controlled loop without snap-back or churn + +The hook SHALL feed its resolved value back as the controlled `size` and SHALL +wire `onSizeChangeEnd`, honoring the component's settle-only, no-snap-back +contract: live drag/keyboard motion is owned by the component's internal state +and the hook reconciles only at rest. The hook SHALL equality-gate its emitted +`size` with a tolerance coarser than the component's internal epsilon so +pixel↔percent round-trips under `ResizeObserver` ticks do not push a new prop +every frame. + +#### Scenario: Interaction settles into the controlled value + +- **WHEN** the user drags the handle and releases +- **THEN** the component animates from its internal state during the drag, fires + `onSizeChangeEnd` once on release, and the hook persists and feeds the settled + value back as `size` without a visible snap-back + +#### Scenario: Resize ticks do not thrash the controlled prop + +- **WHEN** the container resizes within the same band, causing repeated + pixel→percent recomputation +- **THEN** the hook only emits a new `rootProps.size` when the resolved value + changes beyond its equality tolerance, so the controlled prop does not update + every frame + +### Requirement: Degrade safely without browser APIs or measurement + +The hook SHALL feature-detect `ResizeObserver` and `storage` and SHALL guard +non-positive or non-finite container measurements. When measurement or +`ResizeObserver` is unavailable, the hook SHALL resolve `%`-only config and fall +back for pixel/token config without throwing, and SHALL retry resolution once a +positive measurement becomes available. + +#### Scenario: No ResizeObserver available + +- **WHEN** `ResizeObserver` is undefined (SSR, older runtimes, JSDOM without a + polyfill) +- **THEN** the hook resolves any `%`-only config and returns a valid + `rootProps.size` without throwing + +#### Scenario: Container not yet laid out + +- **WHEN** the container measures `0` or a non-finite size (e.g. `display:none`) +- **THEN** the hook does not divide by zero, does not emit a non-finite size, and + re-resolves once a positive measurement is observed diff --git a/openspec/changes/add-responsive-splitter-sizes-hook/tasks.md b/openspec/changes/add-responsive-splitter-sizes-hook/tasks.md new file mode 100644 index 000000000..fdcfa87d9 --- /dev/null +++ b/openspec/changes/add-responsive-splitter-sizes-hook/tasks.md @@ -0,0 +1,138 @@ +## 1. Types & scaffolding + +- [x] 1.1 Add a curated `SplitterSizeToken` union to `splitter.types.ts`: + `3xs`–`8xl` and `breakpoint-sm`…`breakpoint-2xl` (hand-authored, **not** + `keyof typeof themeTokens.size`). JSDoc the accepted set and why it is + curated. +- [x] 1.2 Add the value/config types: `ResponsiveSplitterSizeValue` + (`number` px | `SplitterSizeToken` | `` `${number}%` ``), + `ResponsiveSplitterSizeThreshold` (`number` px | `SplitterSizeToken` — + no percent), `ResponsiveSplitterSizeConfig` + (`ResponsiveSplitterSizeValue` | a threshold-keyed map of it), + `SplitterSizesStorage` (Storage-like `getItem`/`setItem`), + `UseResponsiveSplitterSizesOptions` (`orientation?`, `size`, optional + `minSize`/`maxSize`/`collapsedSize`, `persistKey`, `storage?`, + `onCollapsedChange?`), and the return type + `UseResponsiveSplitterSizesResult` + (`{ rootProps: { size, minSize, maxSize, collapsedSize, onSizeChangeEnd, ref, orientation } }`). + JSDoc every field; state the `number = px` rule explicitly. +- [x] 1.3 Create `hooks/use-responsive-splitter-sizes.ts` with the signature and + a typed stub returning a valid (config-default, `%`-resolvable) `rootProps`. + +## 2. Pure resolution helpers (unit-testable in isolation) + +- [x] 2.1 `resolveTokenToPx(token, tokens)` — resolve a `SplitterSizeToken` to a + pixel number via injected `themeTokens.size`; throw/ignore policy for + unknown tokens defined and documented. +- [x] 2.2 `valueToPercent(value, containerPx, tokens)` — pure: `number`→px→%, + token→px→%, `` `${n}%` ``→% passthrough; guards `containerPx <= 0` / + non-finite (returns a sentinel the caller treats as "unresolved"). +- [x] 2.3 `selectBand(map, measuredPx, tokens, hysteresis)` — resolve all + threshold keys to px, sort ascending, pick the largest `≤ measuredPx` + (smallest applies below), with a hysteresis deadband around boundaries. +- [x] 2.4 `clampPercent(percent, minPercent, maxPercent)` — clamp the resolved + size into the resolved `[minSize, maxSize]` window. +- [x] 2.5 `percentToPx(percent, containerPx)` — inverse, for persistence re-derive. + +## 3. Container resolution (ResizeObserver) + +- [x] 3.1 Implement `"container"` resolution: a `ResizeObserver` on the + `rootProps.ref` element measuring width (horizontal) / height (vertical), + driving band selection and pixel→percent conversion. +- [x] 3.2 Feature-detect `ResizeObserver`; with none, resolve `%`-only config and + fall back for px/token without throwing; retry once a positive measurement + arrives. +- [x] 3.3 Guard width `0`/non-finite; never emit a non-finite size; re-resolve on + the next positive measurement. +- [x] 3.4 Always resolve against the container — no resolution-axis option. +- [x] 3.5 Observe/unobserve cleanup-symmetric and StrictMode-safe (no leaked + observers, no double-bind). + +## 4. Two-phase resolution & emit gating + +- [x] 4.1 Resolve `%`-only config synchronously for the earliest correct value; + resolve px/token config after the first measurement in a `useLayoutEffect`. +- [x] 4.2 Clamp every resolved `size` (live, default, restored) into + `[minSize, maxSize]` before emitting. +- [x] 4.3 Equality-gate the emitted `rootProps.size` with a tolerance coarser + than the component's `1e-6` so px↔% round-trips under resize ticks do not + thrash the controlled prop. +- [x] 4.4 Translate `minSize`/`maxSize`/`collapsedSize` to percentages and expose + them on `rootProps`. + +## 5. Persistence + +- [x] 5.1 Versioned storage adapter defaulting to `localStorage`, all access + try/caught; read/parse `{ v, bands: { [thresholdPx]: { unit, value } } }`, + tolerating absent/corrupt/old data (migrate by version where possible). +- [x] 5.2 Resolution precedence `stored[activeBand] ?? configDefault[activeBand]`; + key bands by the resolved pixel threshold (stable across token renames). +- [x] 5.3 On settle, write the active band: px/token bands store px (re-derived + via `percentToPx` from the measured container); percent bands store percent. +- [x] 5.4 Clamp restored values into `[minSize, maxSize]` before emitting (covers + a wide-session px restored into a narrow container). +- [x] 5.5 Never persist `collapsedSize`; suppress persistence while collapsed, + keyed off the collapse signal (not value equality), so an expanded size + equal to `collapsedSize` is still persisted. + +## 6. Controlled-loop wiring + +- [x] 6.1 Expose the resolved value as `rootProps.size` (controlled) and keep it + stable across renders unless the resolved value changes beyond the gate. +- [x] 6.2 Wire `onSizeChangeEnd` to persist (subject to the collapse suppression) + and feed the settled value back; never emit both `size` and `defaultSize`. +- [x] 6.3 Re-resolve on band change, relying on the component's in-place, + settle-only reconcile (no remount); verify no snap-back. + +## 7. Exports & token guard + +- [x] 7.1 Export the hook and its public types (incl. `SplitterSizeToken`) from + `components/splitter/index.ts`. +- [x] 7.2 Confirm the splitter barrel is surfaced in the package public API so + `useResponsiveSplitterSizes` is importable from `@commercetools/nimbus`. +- [x] 7.3 Add a token-existence guard test: every `SplitterSizeToken` member + exists in `themeTokens.size` (turns a token rename into a red build). + +## 8. Tests (unit — hooks are JSDOM-tested) + +- [x] 8.1 Pure helpers (§2): `number = px` conversion, token→px→%, `%` + passthrough, band selection + hysteresis, clamping, px round-trip. +- [x] 8.2 Container-mode band selection across simulated width bands (mock + `ResizeObserver`); single-value-applies-everywhere case. +- [x] 8.3 Clamping: a px `size` resolving below `minSize` / above `maxSize` is + clamped before emit; restored wide px in a narrow container is clamped. +- [x] 8.4 Persistence: settle writes px under the active band; `stored ?? default` + precedence; per-band independence; px pin survives a simulated reload + + resize; versioned/corrupt/absent data tolerated. +- [x] 8.5 Collapse: `collapsedSize` never stored; suppression while collapsed; + an expanded size equal to `collapsedSize` still persisted. +- [x] 8.6 Emit gating: resize ticks within a band do not push a new `size` unless + it changes beyond the tolerance (no oscillation). +- [x] 8.7 Degradation: no `ResizeObserver`, no/throwing `storage`, width `0` — + no throw, `%`-only still resolves. + +## 9. Story & docs + +- [x] 9.1 Storybook story: the responsive + pixel/token + persisted flow on a + real `Splitter.Root` (play function exercising a settle and a simulated + band change). +- [x] 9.2 Document in the splitter `.dev.mdx` / `.mdx`: the recipe, the + **`number = px`** rule (and the contrast with the component's percentage + props), container-width threshold keys vs viewport, the curated token set, + the `ref` requirement, and the px-pin-survives-drag persistence behavior. + +## 10. Verification + +- [x] 10.1 `pnpm --filter @commercetools/nimbus typecheck:strict` passes (no + `any`); `SplitterSizeToken` gives clean autocomplete. +- [x] 10.2 `pnpm test:dev` for the new spec passes; `pnpm lint` clean. +- [x] 10.3 Build the package and confirm the hook is exported from the published + entry (`pnpm --filter @commercetools/nimbus build`). + +## 11. Tracked separately (not part of this change) + +- [ ] 11.1 Component first-paint fix: seed `Splitter.Root`'s `size`/`defaultSize` + synchronously (lazy `useState` initializer / `useLayoutEffect`) instead of + the pane-registration mount effect, so the first committed frame honors the + configured size rather than painting `50/50` first. Validate during hook + implementation; land as an independent fix. diff --git a/openspec/changes/add-splitter-component/design.md b/openspec/changes/add-splitter-component/design.md new file mode 100644 index 000000000..f543bec98 --- /dev/null +++ b/openspec/changes/add-splitter-component/design.md @@ -0,0 +1,305 @@ +# Design: Splitter + +This document captures the architectural decisions behind the Splitter +primitive. Each decision is paired with the alternative considered and the +trade-off accepted. + +## Decision 1: 2-pane primitive, composable via nesting + +**Decision.** `Splitter.Root` accepts one `Splitter.Aside` and one +`Splitter.Main` child with one `Handle` between them. Layouts requiring more +than two panes are expressed by nesting one `Splitter` inside another's pane. + +**Alternative considered.** Flat N-pane: ≥ 2 pane children with N − 1 +`Handle` children between them, sizes keyed across all of them, with a +cascading resize algorithm to spill drag remainder through neighbours +that hit `minSize` / `maxSize`. + +**Why rejected.** Three issues: + +1. **Accessibility.** The W3C window splitter pattern is specified for *a + separator between two regions* — that is the model in the ARIA APG + example. With N-pane + cascade, pressing an arrow on a middle handle + resizes panes that `aria-controls` doesn't point at — non-local + behaviour with no clean ARIA expression. Nested 2-pane gives a screen + reader a tree of self-contained widgets, each matching the W3C example + exactly. Each splitter is locally reasonable. +2. **Responsive design.** App-shell layouts have natural hierarchy: + `main` is primary; `nav` and `aside` are secondary. Nesting expresses + that — `[nav | [main | aside]]` lets a consumer collapse the inner + splitter at one breakpoint and the outer at another, or swap a pane + for a `Drawer` cleanly. Flat N-pane treats every pane as a peer; + responsive collapse then becomes hacky (a size of 0 isn't the same as + removing a pane; orphaned handles need hiding separately). +3. **Implementation cost.** The cascade algorithm is the bulk of + `react-resizable-panels`'s source. Skipping it lets the primitive + ship simpler and tighter without losing any layout that nesting can't + express. + +**Cost accepted.** Layouts where panes are genuinely peer-equal (3-way +diff viewer, IDE tree | editor | terminal) read more naturally as flat +JSX than as nested. They still work — one boundary lives one level +deeper in the tree — but the JSX is less obviously a peer relationship. + +## Decision 2: A single aside dimension, designated by component type + +**Decision.** A 2-pane splitter has one boundary — one degree of freedom — so +the size is a single number: the **aside**'s percentage (`size` / +`defaultSize`). The **main** pane always takes the remainder (`100 − size`). The +two panes are distinct components — `Splitter.Aside` (the configured, sized one) +and `Splitter.Main` — so the role is designated by the component *type*, not by +an id or a pointer prop. Sizing/collapse config is flat on `Splitter.Root`. Pane +`id` is optional (analytics / test hooks). + +**Alternatives considered.** + +- **A. Id-keyed record.** A generic `Splitter.Pane id="…"`, with + `defaultSizes: Record` summing to 100 and a `panes` config map + keyed by id. +- **B. Index/position-based.** `defaultSizes: [number, number]`, the first child + always primary. + +**Why rejected.** Both over-express the one degree of freedom. The record forces +consumers to maintain two interdependent numbers that must sum to 100 (and a +parallel id-keyed config map), when only one number is free. Index/position makes +"which child is sized" invisible at the call site and fragile under reorder. +Naming the sized pane by type (`Splitter.Aside`) makes the single `size` number +unambiguous, removes the record and the config map, and lets the aside sit on +either side without changing what `size` means. + +**Cost accepted.** A consumer who thinks "the main pane should be at least 40%" +expresses it as the aside's `maxSize` (`100 − 40 = 60`) rather than a +main-specific prop — one small mental step, in exchange for keeping all config +on the single configured pane. Collapse is likewise aside-only (Decision 6). + +## Decision 3: Anonymous handle + +**Decision.** `Splitter.Handle` carries no `id` and no per-handle config +props. Behaviour (`keyboardStep`, `isDoubleClickDisabled`, default +`aria-label`) lives on `Root`. The handle infers the two panes it +controls from its sibling panes in DOM order, and its ARIA value tracks the +leading pane (so it is correct whether the aside leads or trails). + +**Alternative considered.** Per-handle config via an `id` + `handles` map +on Root. + +**Why rejected.** A 2-pane splitter has exactly one handle; there is +nothing to differentiate per handle. The config-on-Root rule applies to +the single handle just as well as it would to many. + +**Cost accepted.** None within the 2-pane shape. + +## Decision 4: Size is uncontrolled by default (optional settle-only control); collapse is controllable + +**Decision.** Two stateful concerns, two idioms chosen by their update +frequency: + +- **Size** is uncontrolled by default. The component owns the aside size; the + public props are `defaultSize` (read once on mount), `onSizeChange` (live, + every drag tick), and `onSizeChangeEnd` (fired once when an interaction + settles). An optional **settle-only** `size` prop adds controlled-at-rest + behaviour without the 60Hz cost (see below). All emit/accept a single number. +- **Collapse** is controllable state — `collapsed` / `defaultCollapsed` / + `onCollapsedChange` (a boolean) — the standard Nimbus controlled/uncontrolled + pair. + +**Alternative considered.** A *live* controlled `size` prop where the +prop is the render source on every drag tick. + +**Why rejected.** Drag fires at ~60Hz. A live-controlled `size` prop +means every tick goes through consumer `setState` → re-render of the +consumer's tree, because the handle can only move once the prop comes +back. For an app-shell wrapping a large React tree that's a real cost for +no semantic win — splitter sizes are UI ergonomics, not data the rest of +the app reacts to mid-drag. + +**What we adopted instead.** An optional **settle-only** controlled +`size` prop. Internal state stays the render source and the authority +*during* interaction (drag/keyboard need no consumer feedback, so the +60Hz cost above never applies), and the prop is reconciled into state +only when it *changes* (the settle seam) — mirroring the `collapsed` +controlled pattern. `onSizeChange`/`onSizeChangeEnd` are unchanged. +This unlocks in-place, per-breakpoint layout control without remounting +panes (a `key` swap would tear down pane content), which `defaultSize` + +`onSizeChangeEnd` alone cannot do. + +**Details.** Reconcile is effect-based, so a consumer that sets `size` +but ignores `onSizeChangeEnd` keeps the last interactive value (no +snap-back) and behaves as uncontrolled thereafter — dev-warned. Inbound +values are clamped into `0–100` but not `minSize`-clamped (the next +interaction re-clamps). When both `size` and `collapsed` are controlled +and disagree, collapse wins (the aside stays at `collapsedSize`, and the +controlled `size` governs the expanded proportion). + +## Decision 5: Persistence is consumer-wired, not baked in + +**Decision.** The component ships no persistence machinery. Consumers +hydrate `defaultSize` from whatever storage they choose and write back +in `onSizeChangeEnd`; collapse persists through its controlled +`collapsed` boolean. There is no bundled hook and no `autoSaveId`. + +**Alternative considered.** An `autoSaveId`-style prop (as in +`react-resizable-panels`) — pass a string and the component writes to +`localStorage` automatically; or a bundled persistence hook that owns +loading, saving, and debouncing. + +**Why rejected.** Both couple the component to a storage backend. Cookies +(for SSR), query params (for shareable layouts), server-side storage (for +cross-device persistence), or an external state store all become awkward +without escape hatches. Because the size is a single number that +`onSizeChangeEnd` emits and `defaultSize` accepts, the round-trip is already a +one-liner against any storage, so a dedicated hook earns its surface area only +marginally. (A pixel-aware companion hook — converting px intents to the +percentage the component consumes — is a separate, later effort and is +explicitly out of scope here.) + +**Shape consumers see:** + +```tsx +const [size, setSize] = useStoredValue("ide-layout", 30); + + + + + +; +``` + +## Decision 6: Cross-subtree control is plain controlled state, not a ref + +**Decision.** Collapsing the aside from outside the splitter subtree (a +header or toolbar button) is done with the controlled `collapsed` boolean +driven by ordinary `useState`. Only the aside collapses, so the state is a +boolean — not an id. There is no imperative ref or command object. + +**Alternative considered A.** Expose an imperative `ref` (or a bundled +hook holding one) on `Root` with `collapse()` / `expand()` commands. + +**Why rejected.** Forces consumers into ref plumbing (lift the ref, thread +it through context or props) for what is plain state: "is the aside +collapsed." Controlled state lifts naturally and reads declaratively. + +**Alternative considered B.** State-hook-as-prop pattern (React Aria +style): a hook owns state, `` reads it via +`useSyncExternalStore`. + +**Why rejected.** Nimbus' established public API uses +controlled/uncontrolled prop pairs, not state hooks as props. Following the +state-hook pattern would introduce a new convention for a single component. + +**Shape consumers see:** + +```tsx +const [collapsed, setCollapsed] = useState(false); + + + + + + +; +``` + +## Decision 7: Naming — `Splitter` / `Aside` / `Main` / `Handle` + +- **`Splitter`** over `WindowSplitter` (drops irrelevant "window" + connotation), `Resizable*` (capability-first reads worse in + compositions like `AppLayout`), `SplitPane` (singular, not + compound-friendly). +- **`Aside` / `Main`** over a generic `Pane`. The two panes of an app-shell + split are not peers — one is the primary content area, the other a + supporting rail. Naming them by role makes the single `size` number + unambiguous (it is always the aside's), removes the need for ids or a + `primaryPane` pointer, and reads as the layout intent at the call site. + `Main` carries the `
`-adjacent "primary content" connotation; `Aside` + the supporting one. +- **`Handle`** over `Separator` for two reasons: (1) Nimbus already + has a decorative `Separator` component, and (2) "handle" communicates + "you grab this" — the part is interactive, not just a visual divider. + The underlying ARIA role remains `separator` (per W3C spec); the + rename is purely API-level. + +## Decision 8: Drag clamps at the boundary (no cascade) + +Dragging the handle by Δ (in percentage points, after pixel→% conversion): + +1. Translate the gesture (grow the leading pane by Δ) into an aside Δ: aside + leading → `+Δ`, aside trailing → `−Δ`. +2. Apply the aside Δ, clamped into the aside's `[minSize, maxSize]` window. The + handle stops at the boundary; because the main pane is the remainder, + `maxSize` is also the main pane's floor (`100 − maxSize`). + +No cascade — with only two panes, there is nowhere to spill to. This +is a meaningful simplification over the N-pane case and removes the +biggest single chunk of complexity from comparable libraries. + +The algorithm runs synchronously during `useMove` callbacks, preserving full +float precision (no rounding). + +## Decision 9: Flat aside configuration on Root + +Static sizing settings — `minSize`, `maxSize`, `collapsible`, `collapsedSize` — +are flat props on `Splitter.Root` and describe the aside. There is no per-pane +config map (the role-by-type design removes the need to key config by id) and no +per-pane `defaultSize` (the initial proportion is the single `defaultSize` on +Root). Per-pane disabling is not a knob either — `isDisabled` on Root disables +the whole splitter, per the Nimbus `isDisabled` convention. + +**Why.** Project rule: configuration on Root, not on sub-components. With one +configured pane (the aside), the config is a flat handful of props rather than a +keyed map. `maxSize` bounds the main pane's floor as the complement, so the one +aside window fully describes both sides of the single boundary. `keyboardStep` +and `isDoubleClickDisabled` likewise live on Root. + +## Decision 10: Double-click restores defaults; Enter toggles collapse + +**Decision.** Double-click on the handle restores the boundary to the +initial size derived on mount. Enter on the focused handle toggles the +aside's collapse (when collapsible). The two gestures bind to different +actions, not the same action. + +**Alternatives considered.** + +- **A. Double-click toggles collapse (same as Enter).** Pairs the mouse + affordance with the keyboard one, giving the collapse feature two + discoverable bindings. +- **B. Double-click restores defaults; Enter toggles collapse.** + (Chosen.) Decouples the gestures so each does something on every + splitter — including those that aren't collapsible. + +**Why B over A.** Most splitters in real apps aren't collapsible +(e.g. a code editor's left/right pane split). Under A, double-click is +a no-op on those splitters — discoverable but useless, exactly the +case where a user would reach for the gesture to "reset". Under B, +double-click is meaningful everywhere: a single, intuitive way to undo +a stray drag. Collapse remains reachable via the keyboard (Enter on the +focused handle) and the controlled `collapsed` prop. + +`isDoubleClickDisabled` on Root keeps the same name but now suppresses the +restore-defaults action. + +## What's out of scope (recap) + +- 3+ panes per splitter — see Decision 1; use nesting. +- Cascading resize — see Decision 8; not needed for 2 panes. +- Live controlled `size` prop — see Decision 4 (settle-only only). +- Multiple size units (px / rem / vh) and a pixel-aware companion hook — a + separate, later effort; the component consumes percentages only. +- Baked-in persistence (`autoSaveId`, bundled hook) — see Decision 5. +- App-shell behaviour (responsive collapse, landmark slots, drawer + fallback on narrow viewports) — explicit follow-up as `AppLayout` + pattern component. +- Per-handle configuration — see Decision 3. +- Imperative ref / state-hook-as-prop for cross-subtree control — see + Decision 6. diff --git a/openspec/changes/add-splitter-component/proposal.md b/openspec/changes/add-splitter-component/proposal.md new file mode 100644 index 000000000..cc54b785f --- /dev/null +++ b/openspec/changes/add-splitter-component/proposal.md @@ -0,0 +1,209 @@ +# Change: Add Splitter component + +## Why + +Nimbus has no primitive for user-resizable panes. Consumers building IDE-like +or sidebar-plus-content layouts have to hand-roll the drag math, keyboard +handling, and the W3C window-splitter ARIA contract — error-prone and rarely +accessible. + +`Splitter` fills that gap with one focused job: **a user dragging the boundary +between two panes**, with clean integration points for persistence and +collapse. It is intentionally scoped to exactly two panes; layouts with more +regions are composed by **nesting** a `Splitter` inside a pane. + +App-shell layouts (nav + main + aside, responsive collapse to drawers) are +explicitly **not** in scope here. Those belong in a follow-up pattern component +(`AppLayout` or similar) that composes `Splitter` and adds breakpoint logic. + +## Why 2-pane and not N-pane + +A flat N-pane API was considered and rejected. The reasoning: + +### Accessibility + +The W3C window splitter pattern is specified for *a separator between two +regions* — that is the model. `aria-valuenow` has unambiguous meaning +because there is exactly one primary pane on each side of the separator. +The W3C ARIA Authoring Practices Guide example for window splitter is +2-pane for this reason. + +With N panes and a cascading resize algorithm, pressing an arrow on a +middle handle resizes panes that `aria-controls` doesn't point at — +non-local behaviour with no clean ARIA expression. The model doesn't +extend naturally. + +Nesting two-pane splitters gives screen reader users a tree of +self-contained widgets, each matching the W3C example exactly. Each +splitter is announced as "separator between two regions"; AT users can +reason about each one locally. + +### Responsive design + +App-shell layouts have natural hierarchy: `main` is primary; `nav` and +`aside` are secondary. Nesting expresses that — `[nav | [main | aside]]` +lets a consumer collapse the inner splitter at one breakpoint and the +outer at another, or swap a pane for a `Drawer` cleanly. Flat N-pane +treats every pane as a peer; responsive collapse then becomes hacky +(setting a size to 0 isn't the same as removing a pane; orphaned handles +have to be hidden separately). + +### Implementation cost + +N-pane requires a cascading resize algorithm (when a neighbour hits +min/max, spill to the next pane in order), multi-handle indexing, and +constraint logic that interacts with the cascade. That's the bulk of +`react-resizable-panels`'s source. None of that complexity is needed for +the 2-pane primitive, and consumers don't need it for layouts where two +panes plus nesting covers the case. + +## What Changes + +**Component:** `Splitter` (compound: `Root` / `Aside` / `Main` / `Handle`). +**Shape:** one `Splitter.Aside` and one `Splitter.Main` with one `Handle` +between them (aside on either side). + +### Single-dimension configuration + +A 2-pane splitter has a single boundary — one degree of freedom — so the size is +a single number: the **aside**'s percentage. The **main** pane always takes the +remainder. The role is designated by the component type (`Splitter.Aside` vs +`Splitter.Main`), not by an id, so there is no id-keyed record and no +`primaryPane` pointer. All sizing and collapse configuration is flat on +`Splitter.Root`. + +```tsx + {/* a single number */}} +> + + + + +``` + +Pane `id` is optional (analytics / test hooks); when omitted a stable DOM id is +generated for the handle's `aria-controls`. The aside may be placed before or +after the main pane — `size` always refers to the aside, and the handle's ARIA +value tracks the leading pane so it is correct on either side. + +### Three or more regions via nesting + +```tsx + + + + + + + + + + + +``` + +Each splitter is independently persistable, independently collapsible, +and independently announced to assistive tech. + +### Size is uncontrolled by default, optionally controllable + +- `defaultSize: number` (the aside %, read once on mount, clamped to `0–100`, + full float precision), `onSizeChange` (live, every drag tick), and + `onSizeChangeEnd` (fires once when an interaction settles — the persistence + seam, no debounce needed). All emit a single number. +- An optional `size` prop adds **settle-only** controlled behaviour: internal + state drives the layout during interaction (no per-tick consumer feedback, so + no ~60Hz re-render), and the prop is reconciled in when it changes. This lets + consumers swap the proportion per breakpoint in place — no remount, so pane + content (scroll, focus) survives, which a `key` swap could not preserve. + +### Flat aside configuration on Root + +- `minSize` (default 0) and `maxSize` (default 100) bound the aside. Because the + main pane is the remainder, `maxSize` fixes the main pane's floor + (`100 − maxSize`), so the single aside window fully describes both sides of the + one boundary — there is no main-specific knob. +- `keyboardStep` (arrow-key delta, default 5), `isDoubleClickDisabled`, and + `isDisabled` (makes the whole splitter non-interactive, per the Nimbus + `isDisabled` convention) live on Root. + +### Collapsible aside + +- `collapsible` + `collapsedSize` on Root. Only the aside collapses. +- Collapse is controllable boolean state — `collapsed` / `defaultCollapsed` / + `onCollapsedChange(boolean)`. Any control in the app can drive it with plain + `useState`. +- Keyboard: Enter on the focused `Handle` toggles the aside's collapse. + +### Double-click restores defaults + +- Double-click on `Handle` restores the boundary to its initial position + (the size resolved on mount from `defaultSize`, otherwise the 50/50 + fallback). +- The gesture is decoupled from collapsibility: it works on every splitter, + including non-collapsible ones. Gated by Root-level `isDoubleClickDisabled`. + +### Persistence + +- Consumer-wired with any storage (localStorage, cookies, query params, + server): hydrate `defaultSize` from a stored number, write back the number in + `onSizeChangeEnd`, and persist collapse via its controlled boolean. No + bundled hook and no baked-in `autoSaveId` — the component stays decoupled + from any storage backend. + +### Visual presentation + +- The handle track has a single fixed thickness. Sizes carry full float + precision end-to-end; only the handle's `aria-valuenow` is rounded (for AT), + alongside `aria-valuetext`. (Future visual variants can be added to the recipe + as an explicit dimension if needed.) + +### ARIA and keyboard model + +- `Handle` is `role="separator"` with `aria-valuenow` / `aria-valuemin` / + `aria-valuemax` / `aria-valuetext`, `aria-orientation`, and `aria-controls` + pointing at the leading Pane sibling. Built on React Aria primitives + (`useSeparator`, `useMove`, `useFocusRing`). The value tracks the leading pane, + so it is correct whether the aside leads or trails. +- Arrow keys move the boundary by `keyboardStep` (orientation-aware); Home / + End jump to the aside's bounds; Enter toggles the aside's collapse. +- `orientation: "horizontal" | "vertical"` on Root sets the layout axis and + the active arrow keys. + +### Explicit non-goals + +- **No 3+ panes per splitter.** Use nesting. See "Why 2-pane and not + N-pane" above. +- **No cascading resize.** Not needed without N-pane; `minSize` / `maxSize` + simply clamp the aside at the boundary. +- **No multi-unit sizes** (px / rem / vh) on the component. Percentages only. + A pixel-aware companion hook is a separate, later effort. +- **No baked-in persistence.** No `autoSaveId`, no bundled persistence hook; + consumers choose the storage and wire it through `defaultSize` + + `onSizeChangeEnd`. +- **No app-shell behaviour.** Responsive collapse, drawer fallbacks on + narrow viewports, landmark slots — all deferred to a separate `AppLayout` + pattern component that composes `Splitter`. +- **No per-handle configuration.** A 2-pane splitter has one handle; + there's nothing to differentiate. +- **No imperative ref or state-hook-as-prop.** Nimbus' public API + consistently uses controlled/uncontrolled prop pairs; cross-subtree + collapse is the controlled `collapsed` prop, not an imperative command + channel. + +## Impact + +- **Affected specs:** `nimbus-splitter` (new capability). +- **Affected code:** + - **NEW**: `packages/nimbus/src/components/splitter/` + - **MODIFIED**: `packages/nimbus/src/components/index.ts` (export + `./splitter`) + - **MODIFIED**: `packages/nimbus/src/theme/slot-recipes/index.ts` + (register `splitterSlotRecipe` as `nimbusSplitter`) +- **Consumers:** none — new component, no breaking changes for downstream + code. diff --git a/openspec/changes/add-splitter-component/specs/nimbus-splitter/spec.md b/openspec/changes/add-splitter-component/specs/nimbus-splitter/spec.md new file mode 100644 index 000000000..47622f146 --- /dev/null +++ b/openspec/changes/add-splitter-component/specs/nimbus-splitter/spec.md @@ -0,0 +1,475 @@ +# Specification: Splitter + +## Overview + +The Splitter component provides a compound primitive for a user-resizable +two-pane layout. A `Splitter.Root` contains one `Splitter.Aside` (the +configurable, sized pane) and one `Splitter.Main` (which takes the remaining +space), with one `Splitter.Handle` between them. The aside may be placed before +or after the main pane (a leading or trailing panel). Users drag the handle (or +use the keyboard) to redistribute space; the component owns its size state +internally. + +A 2-pane splitter has a single boundary — one degree of freedom — so the entire +size dimension is a single number: the aside's percentage (`size` / +`defaultSize`). The main pane is always the remainder (`100 − size`). +Persistence is wired in the consuming app via `defaultSize` + `onSizeChangeEnd` +and any storage (a single number round-trips); collapse uses the controlled +`collapsed` boolean — there is no companion hook or imperative API. + +All sizing and collapse configuration (`minSize`, `maxSize`, `collapsible`, +`collapsedSize`) lives on `Splitter.Root` as flat props. The aside is the only +configurable pane; `maxSize` caps how far it can grow, which fixes the main +pane's floor (`100 − maxSize`). `Splitter.Aside` / `Splitter.Main` carry only +content and an optional `id`. `Splitter.Handle` is anonymous. + +Layouts requiring more than two panes are expressed by nesting one `Splitter` +inside another's pane. App-shell layout behaviour (responsive collapse to +drawers, landmark slot semantics) is not part of this component and belongs to a +separate pattern component. + +**Component:** `Splitter` (compound API: `Root`, `Aside`, `Main`, `Handle`) +**Package:** `@commercetools/nimbus` **Category:** Layout + +## ADDED Requirements + +### Requirement: Aside + Main compound API + +The component SHALL accept exactly one `Splitter.Aside` and one `Splitter.Main` +child with exactly one `Splitter.Handle` between them. The pane components carry +only content and an optional `id`; the role is designated by the component type, +not by an id. + +#### Scenario: Aside + main layout + +- **WHEN** `` contains a `Splitter.Aside` and a `Splitter.Main` + with one `Handle` between them +- **THEN** SHALL render the two panes side by side with a draggable handle on + the boundary +- **AND** SHALL size the aside to `size` and the main pane to `100 − size` + +#### Scenario: Aside on either side + +- **WHEN** the `Splitter.Aside` is rendered before the `Splitter.Main` (leading) + or after it (trailing) +- **THEN** SHALL honour the DOM order for layout +- **AND** SHALL keep `size` referring to the aside regardless of side + +#### Scenario: Layouts with more than two panes + +- **WHEN** a consumer requires three or more regions +- **THEN** SHALL achieve it by nesting one `Splitter.Root` inside the `children` + of a `Splitter.Aside` or `Splitter.Main` +- **AND** SHALL NOT support a flat list of three or more panes on a single + `Splitter.Root` + +#### Scenario: Wrong pane count + +- **WHEN** `` does not contain exactly one `Splitter.Aside` and + one `Splitter.Main`, or a number of `Handle` children other than one +- **THEN** SHALL emit a development-time warning +- **AND** SHALL render best-effort + +#### Scenario: Optional pane id + +- **WHEN** a `Splitter.Aside` or `Splitter.Main` is given an `id` +- **THEN** SHALL render it as the pane element's DOM id (used by the handle's + `aria-controls`) +- **WHEN** no `id` is given +- **THEN** SHALL generate a stable DOM id automatically + +### Requirement: Uncontrolled state model + +By default the component SHALL own its aside `size` state internally, exposed via +`defaultSize` (initial value, read once), `onSizeChange` (live notification), and +`onSizeChangeEnd` (settled notification). For the optional controlled +counterpart, see "Optional controlled `size` prop". + +#### Scenario: Initial size from `defaultSize` + +- **WHEN** `` is rendered +- **THEN** SHALL initialize the internal aside size to `30` (main `70`) +- **AND** SHALL ignore subsequent prop changes to `defaultSize` +- **AND** SHALL clamp an out-of-range `defaultSize` into `0–100`, preserving + float precision (no rounding) + +#### Scenario: Initial size with no default + +- **WHEN** `defaultSize` is omitted or non-finite +- **THEN** SHALL fall back to a 50/50 split + +#### Scenario: `onSizeChange` fires on every size update + +- **WHEN** the user drags the handle (each tick), presses an arrow key on a + focused handle, collapses/expands, or double-clicks to restore +- **THEN** SHALL call `onSizeChange(size)` with the post-change aside size + (`0–100`) + +#### Scenario: `onSizeChangeEnd` fires once per settled interaction + +- **WHEN** a drag ends, an arrow/Home/End key is pressed, the aside collapses or + expands, or double-click restores defaults +- **THEN** SHALL call `onSizeChangeEnd(size)` exactly once for that interaction + (the seam consumers wire persistence to — no debounce required) + +#### Scenario: Size preserves float precision + +- **WHEN** `defaultSize`, drag, or keyboard produce a fractional percentage + (e.g. `31.25`) +- **THEN** SHALL apply and report it unrounded through the size pipeline +- **AND** the handle's `aria-valuenow` MAY be rounded for assistive technology + without affecting the applied layout + +### Requirement: Aside size constraints with clamping + +`Splitter.Root` SHALL accept flat `minSize` (default 0) and `maxSize` (default +100) describing the aside's allowed window. Drag and keyboard operations on the +handle SHALL clamp the aside size into `[minSize, maxSize]`. Because the main +pane is the remainder, `maxSize` fixes the main pane's floor (`100 − maxSize`); +there is no main-specific knob. + +#### Scenario: Drag respects the aside `minSize` + +- **GIVEN** `minSize: 10`, current aside size `30` +- **WHEN** the user drags the handle to attempt an aside size of `5` +- **THEN** SHALL clamp the aside to `10` + +#### Scenario: Drag respects the aside `maxSize` (the main pane's floor) + +- **GIVEN** `maxSize: 70`, current aside size `60` +- **WHEN** the user drags the handle to attempt an aside size of `80` +- **THEN** SHALL clamp the aside to `70` (main pane never below `30`) + +### Requirement: Collapsible aside + +When `collapsible: true` is set on `Splitter.Root`, the aside SHALL support being +collapsed to its `collapsedSize` (default 0). Only the aside collapses. Collapse +state is a boolean: `collapsed` (controlled), `defaultCollapsed` (uncontrolled), +and `onCollapsedChange(collapsed)` (notification). Enter on the focused handle +toggles collapse. Double-click is reserved for restoring the boundary to its +initial position, not for collapsing. + +While the aside is collapsed, resizing the boundary (drag and the arrow / Home / +End keys) SHALL be disabled. A collapsed aside sits at its `collapsedSize`, below +its `minSize`, so a resize could only snap straight to `minSize`. It is instead +reopened via Enter (toggle), double-click (restore defaults), or the controlled +`collapsed` prop. + +#### Scenario: Enter on focused handle toggles collapse + +- **GIVEN** aside size `30` with `collapsible: true` +- **WHEN** the handle has keyboard focus and Enter is pressed +- **THEN** SHALL collapse the aside if it is not collapsed (set its size to + `collapsedSize`, grow the main pane to absorb the freed space) +- **AND** SHALL fire `onCollapsedChange(true)` +- **OR** SHALL expand the aside (restore the pre-collapse size) if it is + currently collapsed +- **AND** SHALL fire `onCollapsedChange(false)` for the expand transition + +#### Scenario: Controlled `collapsed` from anywhere + +- **GIVEN** the consumer renders `` +- **WHEN** code outside the splitter subtree sets `state` to `true` (e.g. a + toolbar button) +- **THEN** SHALL collapse the aside to its `collapsedSize` and grow the main pane +- **WHEN** `state` is set back to `false` +- **THEN** SHALL restore the pre-collapse size + +#### Scenario: Enter is a no-op when the aside is not collapsible + +- **GIVEN** `collapsible` is not set +- **WHEN** the handle has keyboard focus and Enter is pressed +- **THEN** SHALL NOT change the size and SHALL NOT fire `onCollapsedChange` + +#### Scenario: `onCollapsedChange(false)` fires when leaving the collapsed state + +- **GIVEN** a collapsed aside (size === `collapsedSize`) +- **WHEN** any operation (Enter toggle, double-click restore, controlled prop) + increases the aside's size above `collapsedSize` +- **THEN** SHALL fire `onCollapsedChange(false)` exactly once for that transition + +#### Scenario: Resizing is disabled while the aside is collapsed + +- **GIVEN** a collapsed aside (size === `collapsedSize`) +- **WHEN** the user drags the handle or presses an arrow / Home / End key +- **THEN** SHALL NOT change the size +- **AND** SHALL keep the aside collapsed (no `onCollapsedChange`) + +### Requirement: Double-click restores defaults + +Double-click on the handle SHALL restore the boundary to its initial position — +the size the component resolved on mount from `defaultSize` (if provided), +otherwise the 50/50 fallback. The behaviour applies to all splitters, not only +collapsible ones. + +#### Scenario: Double-click restores the initial size + +- **GIVEN** the splitter was mounted with `defaultSize = 30` +- **AND** the user has dragged the aside to `60` +- **WHEN** the user double-clicks the handle +- **THEN** SHALL set the aside size back to `30` +- **AND** SHALL fire `onSizeChangeEnd(30)` + +#### Scenario: Double-click is disabled + +- **WHEN** `` is set +- **THEN** SHALL NOT restore defaults on the handle's double-click +- **AND** SHALL NOT prevent other handle interactions (drag, keyboard) + +#### Scenario: Double-click does not collapse + +- **GIVEN** `collapsible` is set +- **WHEN** the user double-clicks the handle +- **THEN** SHALL NOT collapse the aside +- **AND** SHALL restore the boundary to its initial position + +### Requirement: Keyboard navigation on Handle + +The `Handle` SHALL be focusable, have `role="separator"`, and respond to arrow +keys, Home, End, and Enter according to the W3C window splitter pattern. Δ is +expressed as growing the leading pane; for a trailing aside this maps to +shrinking the aside. + +#### Scenario: Arrow keys on a horizontal handle + +- **GIVEN** `` with a + focused handle and the aside leading +- **WHEN** the user presses ArrowRight +- **THEN** SHALL grow the leading (aside) pane by 5 percentage points and shrink + the main pane by 5 +- **AND** SHALL clamp at the aside's `minSize` / `maxSize` if a limit is hit +- **AND** SHALL emit `onSizeChange` with the new aside size +- **AND** SHALL prevent default browser scroll + +#### Scenario: Arrow keys on a vertical handle + +- **GIVEN** `` with a focused handle and + the aside leading +- **WHEN** the user presses ArrowDown +- **THEN** SHALL apply Δ = +5 to grow the leading (aside) pane and shrink the + main pane + +#### Scenario: Home/End jump to bounds + +- **GIVEN** a focused handle with the aside leading +- **WHEN** the user presses Home +- **THEN** SHALL shrink the aside to its `minSize` +- **WHEN** the user presses End +- **THEN** SHALL grow the aside to its `maxSize` + +#### Scenario: Keyboard interactions are no-ops when the splitter is disabled + +- **WHEN** `` is set +- **THEN** SHALL ignore keyboard input (and drag, and collapse) +- **AND** SHALL set `tabIndex={-1}` so the handle is not in the tab order + +### Requirement: ARIA semantics on Handle + +The `Handle` SHALL expose the W3C window-splitter ARIA model. The value tracks +the leading pane, so it is correct whichever side the aside is on. + +#### Scenario: ARIA attributes reflect handle position + +- **GIVEN** the aside leads with size 30 and the main pane is 70, with + `minSize: 10` and `maxSize: 90` +- **THEN** the handle SHALL emit: + - `role="separator"` + - `aria-valuenow={30}` (size of the leading pane, rounded for AT) + - `aria-valuetext="30%"` + - `aria-valuemin={10}` (`minSize`) + - `aria-valuemax={90}` (`maxSize`) + - `aria-orientation` matching `Splitter.Root.orientation` + - `aria-controls={asideDomId}` (the DOM id of the leading Pane sibling) + - `aria-disabled="true"` only when `Splitter.Root` is `isDisabled` + +#### Scenario: ARIA bounds map onto a trailing aside + +- **GIVEN** the aside trails (main pane leading) with the aside window + `[minSize: 10, maxSize: 80]` +- **THEN** the handle SHALL emit `aria-valuemin={20}` and `aria-valuemax={90}` + (the complement of the aside window, since the leading pane is the main pane) + +#### Scenario: aria-label defaults + +- **WHEN** no `aria-label` or `aria-labelledby` is supplied on the `Handle` +- **THEN** SHALL apply a default `aria-label` localised via i18n (English + fallback: `"Resize panes"`) + +### Requirement: Orientation + +The `Splitter.Root` SHALL accept `orientation: "horizontal" | "vertical"` +(default `"horizontal"`) and lay panes / handle accordingly. + +#### Scenario: Horizontal layout + +- **WHEN** `orientation="horizontal"` +- **THEN** panes SHALL be arranged left-to-right +- **AND** the handle SHALL have `aria-orientation="horizontal"` (per W3C + separator semantics, which describe the boundary axis, not the layout axis) +- **AND** drag SHALL respond to `deltaX` +- **AND** ArrowLeft / ArrowRight SHALL be the active keys + +#### Scenario: Vertical layout + +- **WHEN** `orientation="vertical"` +- **THEN** panes SHALL be arranged top-to-bottom +- **AND** the handle SHALL have `aria-orientation="vertical"` +- **AND** drag SHALL respond to `deltaY` +- **AND** ArrowUp / ArrowDown SHALL be the active keys + +### Requirement: Behaviour-anonymous Handle + +`Splitter.Handle` SHALL NOT accept per-handle *behaviour* configuration props +(e.g. `keyboardStep`, `collapsible`). All behaviour is configured on +`Splitter.Root`. The handle still accepts standard DOM attributes (`id`, +`className`, `data-*`, etc.) and `aria-label` / `aria-labelledby` overrides. + +#### Scenario: Handle resolves its panes from sibling DOM order + +- **GIVEN** a `Splitter.Handle` rendered between the aside and main panes +- **THEN** SHALL resolve the "leading pane" and "trailing pane" from their DOM + order +- **AND** SHALL derive `aria-controls` from the leading pane's DOM id + +#### Scenario: Per-handle behavioural config is rejected at the type level + +- **WHEN** consumer attempts `` or any + behavioural prop that belongs on `Splitter.Root` +- **THEN** TypeScript SHALL emit a compile error (`Handle` props include only + standard HTML/style/DOM props plus `aria-label` overrides) + +### Requirement: Nesting for layouts with more than two regions + +A `Splitter` SHALL be nestable inside the `children` of any `Splitter.Aside` or +`Splitter.Main`. The inner Splitter is independent of the outer — its own state, +persistence, callbacks, and ARIA subtree. + +#### Scenario: Three-region layout via nesting + +- **GIVEN** a consumer wants a three-region layout `nav | main | aside` +- **WHEN** they render an outer `` splitting a nav aside from a + main pane, and an inner `` inside the main pane's children +- **THEN** SHALL render three resizable regions +- **AND** SHALL expose two independently focusable handles in the tab order +- **AND** SHALL allow each splitter to be configured / persisted / collapsed + independently +- **AND** SHALL announce each splitter as a self-contained widget to assistive + technology + +### Requirement: Persistence via storage-agnostic props + +The component SHALL be persistable with any storage the consumer chooses, with +no bespoke hook. The initial size is hydrated by passing a stored number to +`defaultSize`; the settled value is reported as a number via `onSizeChangeEnd` +for writing back; collapse persists via its controlled `collapsed` boolean. + +#### Scenario: Hydrate from a stored size + +- **WHEN** the consumer passes `defaultSize={storedSize}` (read from any storage + during render) +- **THEN** SHALL initialize the boundary from that value on first render (no + flicker), clamped into `0–100` with float precision preserved + +#### Scenario: Persist on settled change + +- **WHEN** an interaction settles (drag end, keypress, collapse/expand, restore) +- **THEN** SHALL call `onSizeChangeEnd(size)` once, which the consumer wires to + its storage `set` — no debouncing required since it fires per interaction, not + per drag tick + +#### Scenario: No imperative API + +- **WHEN** the consumer needs to collapse the aside from outside the subtree +- **THEN** SHALL do so via the controlled `collapsed` prop (plain state), NOT via + an imperative ref or hook command + +### Requirement: Visual presentation via Chakra slot recipe + +The component SHALL ship a `splitterSlotRecipe` registered as `nimbusSplitter` +in `packages/nimbus/src/theme/slot-recipes/index.ts` with slots `root`, `pane`, +and `handle`, and an `orientation` variant (`horizontal` / `vertical`). The +handle track has a single fixed thickness (no `size` variant). + +#### Scenario: Handle visibility states + +- **WHEN** the handle is hovered or focused +- **THEN** SHALL apply the corresponding visual state (background colour change, + focus ring) defined in the recipe +- **AND** SHALL use Nimbus design tokens for all colours (no hardcoded hex + values) + +#### Scenario: Disabled handle styling + +- **WHEN** `Splitter.Root` is `isDisabled` +- **THEN** SHALL not show the resize cursor and SHALL not reveal the handle + track on hover +- **AND** SHALL NOT apply a `not-allowed` cursor or reduced opacity — the + handle track is invisible at rest, so such an affordance has nothing to + attach to and would only surface a misleading cursor + +#### Scenario: Focus ring uses `_focusVisible` + +- **WHEN** the handle receives keyboard focus +- **THEN** SHALL render the focus ring via the recipe's `_focusVisible` selector +- **AND** SHALL NOT render the focus ring on mouse-click focus + +### Requirement: Optional controlled `size` prop + +The component SHALL accept an optional `size` prop — the controlled counterpart +to `defaultSize`, mutually exclusive with it. When provided, the splitter SHALL +be controlled for size with **settle-only** semantics: the internal size stays +authoritative during interaction (drag/keyboard update live, with no consumer +feedback), and the prop SHALL be reconciled into state when it changes. + +#### Scenario: Controlled `size` reflects external changes in place + +- **GIVEN** the consumer renders `` +- **WHEN** code outside the splitter sets `state` to a new aside size +- **THEN** SHALL render at the new size WITHOUT remounting the panes (pane + content — scroll, focus, inputs — is preserved) +- **AND** SHALL clamp the incoming value into `0–100` + +#### Scenario: Settle-only notification + +- **WHEN** the user drags or uses the keyboard on a controlled splitter +- **THEN** SHALL update the layout live from internal state (no per-tick consumer + feedback required) +- **AND** SHALL call `onSizeChangeEnd` once when the interaction settles + +#### Scenario: Ignoring `onSizeChangeEnd` falls back to uncontrolled + +- **GIVEN** `size` is set but `onSizeChangeEnd` is not wired (or not fed back) +- **WHEN** the user resizes +- **THEN** SHALL keep the last interactive value (no snap-back) and behave as + uncontrolled thereafter +- **AND** SHALL emit a development-time warning + +#### Scenario: Collapse takes precedence over controlled `size` + +- **GIVEN** both `size` and `collapsed` are controlled +- **WHEN** the aside is collapsed +- **THEN** the aside SHALL stay at its `collapsedSize`, and the controlled `size` + SHALL govern the expanded proportion (applied on expand) + +### Requirement: WCAG 2.1 AA compliance + +The component SHALL meet WCAG 2.1 AA requirements as exercised in Storybook play +functions. + +#### Scenario: Handle reachable by keyboard + +- **WHEN** the user navigates with Tab +- **THEN** SHALL reach the handle in DOM order +- **AND** SHALL skip the handle when `Splitter.Root` is `isDisabled` + +#### Scenario: Focus indicator is visible + +- **WHEN** the handle receives keyboard focus +- **THEN** SHALL render a visible focus ring (via `_focusVisible` in the recipe) + with contrast meeting WCAG AA + +#### Scenario: Touch target size + +- **WHEN** rendered +- **THEN** the handle's interactive hit area SHALL be at least 24×24 CSS pixels + (with hit area extended via padding if the visual width is smaller) diff --git a/openspec/changes/add-splitter-component/tasks.md b/openspec/changes/add-splitter-component/tasks.md new file mode 100644 index 000000000..987734e29 --- /dev/null +++ b/openspec/changes/add-splitter-component/tasks.md @@ -0,0 +1,171 @@ +# Tasks: Add Splitter component + +> **API note:** the splitter exposes a single-dimension Aside/Main API — a +> distinct `Splitter.Aside` (the configurable, sized pane) and `Splitter.Main` +> (the remainder), a single aside `size` number, flat sizing/collapse config on +> `Root`, and a boolean `collapsed` (only the aside collapses). The tasks below +> reflect that shape. + +## 1. Scaffolding and registration + +- [x] 1.1 Create the component directory + `packages/nimbus/src/components/splitter/` following the Nimbus + file-type layout (`components/`, `hooks/`, `utils/`, `intl/` with + barrels). +- [x] 1.2 Export the component from + `packages/nimbus/src/components/index.ts` (`export * from "./splitter"`). +- [x] 1.3 Add `splitter.recipe.ts` (`splitterSlotRecipe`) with slots `root`, + `pane`, `handle` and an `orientation` (`horizontal` / `vertical`) variant + (single fixed handle thickness — no `size` variant), and register it as + `nimbusSplitter` in `packages/nimbus/src/theme/slot-recipes/index.ts`. +- [x] 1.4 Add `splitter.slots.tsx` deriving the slot prop types from the + recipe. + +## 2. Types and context + +- [x] 2.1 In `splitter.types.ts`, define: + - `SplitterRootProps`: `orientation`, `defaultSize`, `size`, `minSize`, + `maxSize`, `collapsible`, `collapsedSize`, `onSizeChange`, + `onSizeChangeEnd`, `collapsed`, `defaultCollapsed`, `onCollapsedChange`, + `keyboardStep` (default 5), `isDoubleClickDisabled`, `isDisabled`, + `children`, `ref`. + - `ResolvedAsideConfig`: `{ minSize; maxSize; collapsible; collapsedSize }` + (defaults applied), and `SplitterPaneRole` = `"aside" | "main"`. + - `SplitterAsideProps` / `SplitterMainProps`: content + optional `id` + + `ref` (no required id, no config). + - `SplitterHandleProps`: standard HTML/style props plus `aria-label` / + `aria-labelledby` overrides — no `id`-config or per-handle behaviour + props. +- [x] 2.2 Define `SplitterContextValue` and `splitter.context.ts`: scalar + `size`, `setSize` / `commitSize`, role-based pane registration + (`registerPane` / `unregisterPane`, `paneOrder`, `paneDomIds`), + `asideConfig`, collapse state (`collapsed` / `setCollapsed`), + `restoreDefaults`, plus `orientation`, `keyboardStep`, + `isDoubleClickDisabled`, `isDisabled`. + +## 3. Sizing utilities (pure, unit-tested) + +- [x] 3.1 `utils/derive-initial-sizes.ts` (`deriveInitialSize`): resolve the + mount-time aside size from `defaultSize` (clamped to `0–100`, float + precision preserved), falling back to 50 when absent or non-finite. +- [x] 3.2 `utils/normalize-sizes.ts` (`normalizeSize`): clamp a size into + `0–100`, returning `null` for non-finite input. +- [x] 3.3 `utils/clamped-resize.ts` (`clampedResize`): apply Δ to the aside size + clamped into `[minSize, maxSize]` (the main pane's floor is the complement + of `maxSize`); no cascade. +- [x] 3.4 `utils/compute-aria-bounds.ts` + `utils/sizes-equal.ts`: collapse-aware + ARIA bounds mapped onto the leading pane, and scalar size equality within + epsilon for the controlled reconcile. + +## 4. State hook + +- [x] 4.1 `hooks/use-splitter-state.ts` owns the scalar size state machine: lazy + initial-size derivation once both panes register (by role), the live + (`setSize` → `onSizeChange`) and settled (`commitSize` → `onSizeChangeEnd`) + channels, controlled/uncontrolled boolean collapse (aside-only) with size + reconciliation, and the memoized context value. +- [x] 4.2 `hooks/use-splitter-context.ts`: typed context consumer for the + pane and handle. + +## 5. Components + +- [x] 5.1 `components/splitter.root.tsx`: thin root that wires the recipe, + extracts style props, resolves `asideConfig`, calls `useSplitterState`, and + provides context. Dev-time warning when the pane count is not exactly 2. +- [x] 5.2 `components/splitter.pane.tsx` (internal base) + `splitter.aside.tsx` + / `splitter.main.tsx`: each registers its role + DOM id with context and + applies its size via inline style (aside = `size`%, main = `100 − size`%); + optional `id`. +- [x] 5.3 `components/splitter.handle.tsx`: the interactive separator — + resolves leading/trailing panes from sibling DOM order, runs drag + (`useMove`) and keyboard, and renders the W3C separator ARIA model (value + tracks the leading pane). +- [x] 5.4 `splitter.tsx`: assemble the compound `Splitter` namespace + (`Root` / `Aside` / `Main` / `Handle`). + +## 6. Interaction behaviour + +- [x] 6.1 Drag: convert pointer delta to percentage points, translate to an + aside Δ by side, and apply via `clampedResize`; live ticks fire + `onSizeChange`, drag end commits via `onSizeChangeEnd`. +- [x] 6.2 Keyboard: arrow keys move the boundary by `keyboardStep` + (orientation-aware, prevent default scroll); Home / End jump to the + aside's bounds; each keypress commits. +- [x] 6.3 Collapse: Enter on the focused handle toggles the aside's collapse; + the controlled `collapsed` boolean (and `defaultCollapsed` / + `onCollapsedChange`) drives collapse from anywhere, with size + reconciliation and pre-collapse restore. +- [x] 6.4 Double-click restores the mount-time size (existence-checked so a + legitimate `0` initial size restores); gated by `isDoubleClickDisabled`. +- [x] 6.5 `isDisabled` removes the handle from the tab order (`tabIndex={-1}`), + sets `aria-disabled`, and ignores drag / keyboard / collapse. + +## 7. i18n + +- [x] 7.1 `splitter.i18n.ts` with the default handle `aria-label` + (`"Resize panes"`); `intl/*` locale files generated. +- [x] 7.2 Run `pnpm extract-intl` and verify the key lands in the i18n data. + +## 8. Stories + +- [x] 8.1 Stories with play functions covering: Default, Vertical, aside + trailing (either-side), Disabled, keyboard interaction, pointer drag, + size constraints (clamp at `minSize` / `maxSize`), ARIA semantics, collapse + by keyboard, controlled collapse from an external button, controlled + collapse + restore, controlled size (in-place change), resize-locked while + collapsed, double-click restore (including a 0% default), persistence + hydration via `defaultSize` + `onSizeChangeEnd`, nested splitters, float + precision, and double-click disabled. + +## 9. Documentation + +- [x] 9.1 `splitter.mdx` (overview): component shape (Aside/Main), the single + `defaultSize`, flat `minSize` / `maxSize`, anonymous `Handle`, and the + nesting pattern for 3+ regions. +- [x] 9.2 `splitter.dev.mdx` (engineering guide): the uncontrolled-size / + controllable-collapse model, flat aside config, consumer-wired + single-number persistence, either-side placement, and 2-pane + nesting + rationale. +- [x] 9.3 `splitter.a11y.mdx`: ARIA model, keyboard reference, focus order, + and what nesting means for the AT tree. +- [x] 9.4 `splitter.docs.spec.tsx`: consumer-facing examples that compile and + run (basic, controlled size, controlled collapse, persistence, nested). + +## 10. Unit tests + +- [x] 10.1 `utils/derive-initial-sizes.spec.ts`: clamp, float precision, 50 + fallback, non-finite input. +- [x] 10.2 `utils/normalize-sizes.spec.ts` + `utils/sizes-equal.spec.ts`: clamp / + null handling and scalar equality within epsilon. +- [x] 10.3 `utils/clamped-resize.spec.ts` + `utils/compute-aria-bounds.spec.ts`: + clamp at `minSize` / `maxSize`, collapse-aware bounds, leading/trailing + mapping, float precision. + +## 11. Validation + +- [x] 11.1 TypeScript compiles cleanly + (`pnpm --filter @commercetools/nimbus typecheck`). +- [x] 11.2 Build succeeds + (`pnpm --filter @commercetools/nimbus build`). +- [x] 11.3 Storybook tests pass + (`pnpm test:storybook:dev packages/nimbus/src/components/splitter/splitter.stories.tsx`). +- [x] 11.4 Unit tests pass + (`pnpm test:unit packages/nimbus/src/components/splitter/`). +- [x] 11.5 Lint passes + (`pnpm lint -- packages/nimbus/src/components/splitter/`). +- [x] 11.6 Add a changeset (minor bump on `@commercetools/nimbus`). + +## 12. Optional controlled `size` + +- [x] 12.1 `normalizeSize` + `sizeEqual` in `utils/` (with specs); + `deriveInitialSize` reuses `normalizeSize`. +- [x] 12.2 Add `size?: number` to `SplitterRootProps` (settle-only JSDoc) and + thread it through `Splitter.Root`. +- [x] 12.3 In `use-splitter-state.ts`, add settle-only controlled reconciliation: + controlled init seed, a prop-reconcile effect (after the collapse effect) + that writes silently at rest, collapse-precedence, and fire-once dev + warnings. +- [x] 12.4 Add a `ControlledSize` story (asserts in-place change + pane content + survives) and a `controlled-size` consumer example in + `splitter.docs.spec.tsx`. +- [x] 12.5 Document controlled size in `splitter.dev.mdx`; update the changeset. diff --git a/packages/i18n/data/core.json b/packages/i18n/data/core.json index 232efe068..aba677845 100644 --- a/packages/i18n/data/core.json +++ b/packages/i18n/data/core.json @@ -595,6 +595,10 @@ "developer_comment": "fallback message when split button has no menu items", "string": "No actions available" }, + "Nimbus.Splitter.resizePanes": { + "developer_comment": "Default aria-label for the Splitter handle that users drag to redistribute space between the two panes.", + "string": "Resize panes" + }, "Nimbus.TagGroup.removeTag": { "developer_comment": "aria-label for remove tag button", "string": "Remove tag" diff --git a/packages/i18n/data/de.json b/packages/i18n/data/de.json index dae71fdd2..de199cfc3 100644 --- a/packages/i18n/data/de.json +++ b/packages/i18n/data/de.json @@ -595,6 +595,10 @@ "developer_comment": "fallback message when split button has no menu items", "string": "Keine Aktionen verfügbar" }, + "Nimbus.Splitter.resizePanes": { + "developer_comment": "Default aria-label for the Splitter handle that users drag to redistribute space between the two panes.", + "string": "Resize panes" + }, "Nimbus.TagGroup.removeTag": { "developer_comment": "aria-label for remove tag button", "string": "Tag entfernen" diff --git a/packages/i18n/data/en.json b/packages/i18n/data/en.json index 232efe068..aba677845 100644 --- a/packages/i18n/data/en.json +++ b/packages/i18n/data/en.json @@ -595,6 +595,10 @@ "developer_comment": "fallback message when split button has no menu items", "string": "No actions available" }, + "Nimbus.Splitter.resizePanes": { + "developer_comment": "Default aria-label for the Splitter handle that users drag to redistribute space between the two panes.", + "string": "Resize panes" + }, "Nimbus.TagGroup.removeTag": { "developer_comment": "aria-label for remove tag button", "string": "Remove tag" diff --git a/packages/i18n/data/es.json b/packages/i18n/data/es.json index 399ed7377..d97fd8650 100644 --- a/packages/i18n/data/es.json +++ b/packages/i18n/data/es.json @@ -595,6 +595,10 @@ "developer_comment": "fallback message when split button has no menu items", "string": "No hay acciones disponibles" }, + "Nimbus.Splitter.resizePanes": { + "developer_comment": "Default aria-label for the Splitter handle that users drag to redistribute space between the two panes.", + "string": "Resize panes" + }, "Nimbus.TagGroup.removeTag": { "developer_comment": "aria-label for remove tag button", "string": "Retirar etiqueta" diff --git a/packages/i18n/data/fr-FR.json b/packages/i18n/data/fr-FR.json index 4306fd42a..f350ddd0f 100644 --- a/packages/i18n/data/fr-FR.json +++ b/packages/i18n/data/fr-FR.json @@ -595,6 +595,10 @@ "developer_comment": "fallback message when split button has no menu items", "string": "Aucune action disponible" }, + "Nimbus.Splitter.resizePanes": { + "developer_comment": "Default aria-label for the Splitter handle that users drag to redistribute space between the two panes.", + "string": "Resize panes" + }, "Nimbus.TagGroup.removeTag": { "developer_comment": "aria-label for remove tag button", "string": "Supprimer la balise" diff --git a/packages/i18n/data/pt-BR.json b/packages/i18n/data/pt-BR.json index 77d2c595c..d2402d819 100644 --- a/packages/i18n/data/pt-BR.json +++ b/packages/i18n/data/pt-BR.json @@ -595,6 +595,10 @@ "developer_comment": "fallback message when split button has no menu items", "string": "Não há ações disponíveis" }, + "Nimbus.Splitter.resizePanes": { + "developer_comment": "Default aria-label for the Splitter handle that users drag to redistribute space between the two panes.", + "string": "Resize panes" + }, "Nimbus.TagGroup.removeTag": { "developer_comment": "aria-label for remove tag button", "string": "Remover tag" diff --git a/packages/nimbus/src/components/index.ts b/packages/nimbus/src/components/index.ts index 4969ac3f6..525d47461 100644 --- a/packages/nimbus/src/components/index.ts +++ b/packages/nimbus/src/components/index.ts @@ -76,3 +76,4 @@ export * from "./tabs"; export * from "./localized-field"; export * from "./steps"; export * from "./modal-page"; +export * from "./splitter"; diff --git a/packages/nimbus/src/components/splitter/components/index.ts b/packages/nimbus/src/components/splitter/components/index.ts new file mode 100644 index 000000000..48ebe3998 --- /dev/null +++ b/packages/nimbus/src/components/splitter/components/index.ts @@ -0,0 +1,4 @@ +export { SplitterRoot } from "./splitter.root"; +export { SplitterAside } from "./splitter.aside"; +export { SplitterMain } from "./splitter.main"; +export { SplitterHandle } from "./splitter.handle"; diff --git a/packages/nimbus/src/components/splitter/components/splitter.aside.tsx b/packages/nimbus/src/components/splitter/components/splitter.aside.tsx new file mode 100644 index 000000000..fd0658286 --- /dev/null +++ b/packages/nimbus/src/components/splitter/components/splitter.aside.tsx @@ -0,0 +1,16 @@ +import { SplitterPaneBase } from "./splitter.pane"; +import type { SplitterAsideProps } from "../splitter.types"; + +/** + * The configurable pane inside a `Splitter.Root`. Its size is driven by Root's + * `size` / `defaultSize` (and the `minSize` / `maxSize` / collapse config); + * carries only its content and an optional `id`. May be placed before or after + * `Splitter.Main`. + * + * @supportsStyleProps + */ +export const SplitterAside = (props: SplitterAsideProps) => ( + +); + +SplitterAside.displayName = "Splitter.Aside"; diff --git a/packages/nimbus/src/components/splitter/components/splitter.handle.tsx b/packages/nimbus/src/components/splitter/components/splitter.handle.tsx new file mode 100644 index 000000000..add85f99a --- /dev/null +++ b/packages/nimbus/src/components/splitter/components/splitter.handle.tsx @@ -0,0 +1,134 @@ +import { useCallback, useRef } from "react"; +import { + mergeProps, + useFocusRing, + useObjectRef, + useSeparator, +} from "react-aria"; +import { useLocalizedStringFormatter } from "@/hooks"; +import { mergeRefs } from "@/utils"; +import { SplitterHandleSlot } from "../splitter.slots"; +import { + useSplitterContext, + useHandleResize, + useHandleKeyboard, +} from "../hooks"; +import { computeAriaBounds } from "../utils"; +import { splitterMessagesStrings } from "../splitter.messages"; +import type { SplitterHandleProps } from "../splitter.types"; + +/** + * Interactive `role="separator"` between `Splitter.Aside` and `Splitter.Main`, + * exposing the W3C window-splitter ARIA value attributes. Takes no per-handle + * config (that lives on `Splitter.Root`); delegates drag, keyboard, and ARIA + * bounds to focused hooks/utils. The aria value tracks the *leading* pane, so it + * works whichever side the aside is on. + * + * @supportsStyleProps + */ +export const SplitterHandle = ({ + "aria-label": ariaLabelProp, + "aria-labelledby": ariaLabelledBy, + style, + ref: forwardedRef, + ...props +}: SplitterHandleProps) => { + const { + size, + orientation, + isDoubleClickDisabled, + isDisabled, + asideConfig, + paneOrder, + paneDomIds, + collapsed, + restoreDefaults, + } = useSplitterContext(); + + const msg = useLocalizedStringFormatter(splitterMessagesStrings); + const ariaLabel = ariaLabelProp ?? msg.format("resizePanes"); + + const localRef = useRef(null); + const handleRef = useObjectRef(mergeRefs(localRef, forwardedRef)); + + // Resolve the two panes this handle controls from registration (DOM) order. + // The first registered role is the "leading" (left/top) pane; the aside can be + // on either side, so the single `size` maps to the leading pane accordingly. + const prevRole = paneOrder[0]; + const nextRole = paneOrder[1]; + const isReady = !!prevRole && !!nextRole && prevRole !== nextRole; + const asideLeads = prevRole === "aside"; + + // A collapsed aside sits below its `minSize`, so any resize could only snap + // back to `minSize` — lock resizing while collapsed (reopen via Enter, + // double-click, or the controlled prop). + const isResizeLocked = collapsed; + + const { moveProps, applyDelta } = useHandleResize({ + handleRef, + asideLeads, + isReady, + isResizeLocked, + }); + const { onKeyDown } = useHandleKeyboard({ isReady, applyDelta }); + + const { separatorProps } = useSeparator({ + orientation, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + }); + const { focusProps, isFocusVisible } = useFocusRing(); + + const handleDoubleClick = useCallback(() => { + if (isDisabled || isDoubleClickDisabled) return; + restoreDefaults(); + }, [isDisabled, isDoubleClickDisabled, restoreDefaults]); + + const { min: ariaValueMin, max: ariaValueMax } = computeAriaBounds( + asideConfig, + asideLeads + ); + + // The boundary position = the leading pane's size. With the aside leading + // that is `size`; with the main pane leading it is the remainder. + const prevSize = isReady ? (asideLeads ? size : 100 - size) : 0; + const ariaControls = isReady ? paneDomIds[prevRole] : undefined; + const roundedValueNow = isReady ? Math.round(prevSize) : undefined; + + // Position the absolute-positioned handle on the boundary between the two + // panes. The recipe's `transform: translate(±50%)` centers the visible + // track on this offset so the interactive area straddles both panes. + const positionStyle = + orientation === "horizontal" + ? { left: `${prevSize}%` } + : { top: `${prevSize}%` }; + + const combinedProps = mergeProps( + separatorProps, + moveProps, + focusProps, + { + // `role="separator"` comes from `separatorProps` (useSeparator) above. + tabIndex: isDisabled ? -1 : 0, + "aria-valuenow": roundedValueNow, + "aria-valuemin": ariaValueMin, + "aria-valuemax": ariaValueMax, + "aria-valuetext": + roundedValueNow !== undefined ? `${roundedValueNow}%` : undefined, + "aria-orientation": orientation, + "aria-controls": ariaControls, + "aria-disabled": isDisabled || undefined, + "data-focus-visible": isFocusVisible || undefined, + "data-disabled": isDisabled || undefined, + "data-resize-locked": isResizeLocked || undefined, + onKeyDown, + onDoubleClick: handleDoubleClick, + style: { ...positionStyle, ...style }, + }, + props + ); + + return ; +}; + +SplitterHandle.displayName = "Splitter.Handle"; diff --git a/packages/nimbus/src/components/splitter/components/splitter.main.tsx b/packages/nimbus/src/components/splitter/components/splitter.main.tsx new file mode 100644 index 000000000..d63b9f574 --- /dev/null +++ b/packages/nimbus/src/components/splitter/components/splitter.main.tsx @@ -0,0 +1,15 @@ +import { SplitterPaneBase } from "./splitter.pane"; +import type { SplitterMainProps } from "../splitter.types"; + +/** + * The primary content pane inside a `Splitter.Root`. Always takes the space the + * aside does not (`100 − size`); never configured directly. Carries only its + * content and an optional `id`. May be placed before or after `Splitter.Aside`. + * + * @supportsStyleProps + */ +export const SplitterMain = (props: SplitterMainProps) => ( + +); + +SplitterMain.displayName = "Splitter.Main"; diff --git a/packages/nimbus/src/components/splitter/components/splitter.pane.tsx b/packages/nimbus/src/components/splitter/components/splitter.pane.tsx new file mode 100644 index 000000000..58d910e8f --- /dev/null +++ b/packages/nimbus/src/components/splitter/components/splitter.pane.tsx @@ -0,0 +1,49 @@ +import { useEffect, useId } from "react"; +import { SplitterPaneSlot } from "../splitter.slots"; +import { useSplitterContext } from "../hooks/use-splitter-context"; +import type { SplitterAsideProps, SplitterPaneRole } from "../splitter.types"; + +type SplitterPaneBaseProps = SplitterAsideProps & { + /** Which pane this is — drives sizing and handle resolution. */ + paneRole: SplitterPaneRole; +}; + +/** + * Internal base shared by `Splitter.Aside` and `Splitter.Main`. Registers its + * role + DOM id with the splitter (so the handle can resolve siblings and wire + * `aria-controls`) and applies its size from context: the aside renders `size`%, + * the main pane renders the remainder (`100 − size`%). + * + * @internal + */ +export const SplitterPaneBase = ({ + paneRole, + id, + children, + style, + ref, + ...props +}: SplitterPaneBaseProps) => { + const { size, orientation, registerPane, unregisterPane } = + useSplitterContext(); + + // Stable DOM id used by the handle's `aria-controls`. A consumer-provided `id` + // wins so analytics / test hooks resolve to a known element. + const generatedDomId = useId(); + const domId = id ?? generatedDomId; + + useEffect(() => { + registerPane(paneRole, domId); + return () => unregisterPane(paneRole); + }, [paneRole, domId, registerPane, unregisterPane]); + + const paneSize = paneRole === "aside" ? size : 100 - size; + const sizeProperty = orientation === "horizontal" ? "width" : "height"; + const paneStyle = { [sizeProperty]: `${paneSize}%`, ...style }; + + return ( + + {children} + + ); +}; diff --git a/packages/nimbus/src/components/splitter/components/splitter.root.tsx b/packages/nimbus/src/components/splitter/components/splitter.root.tsx new file mode 100644 index 000000000..9ebe0d943 --- /dev/null +++ b/packages/nimbus/src/components/splitter/components/splitter.root.tsx @@ -0,0 +1,101 @@ +import { useEffect, useMemo } from "react"; +import { useSlotRecipe } from "@chakra-ui/react/styled-system"; +import { extractStyleProps } from "@/utils"; +import { SplitterRootSlot } from "../splitter.slots"; +import { SplitterContext } from "../splitter.context"; +import { useSplitterState } from "../hooks/use-splitter-state"; +import type { ResolvedAsideConfig, SplitterRootProps } from "../splitter.types"; + +/** + * Splitter root container. Owns the single aside `size` and resolves + * controlled/uncontrolled size + collapse. Wrap one `Splitter.Aside` and one + * `Splitter.Main` with one `Splitter.Handle` between them (aside on either side). + * + * @see https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/ + * @supportsStyleProps + */ +export const SplitterRoot = ({ + children, + orientation = "horizontal", + defaultSize, + size, + minSize, + maxSize, + collapsible, + collapsedSize, + onSizeChange, + onSizeChangeEnd, + collapsed, + defaultCollapsed, + onCollapsedChange, + keyboardStep = 5, + isDoubleClickDisabled = false, + isDisabled = false, + ref, + ...props +}: SplitterRootProps) => { + const recipe = useSlotRecipe({ key: "nimbusSplitter" }); + const [recipeProps, restRecipeProps] = recipe.splitVariantProps({ + orientation, + ...props, + }); + const [styleProps, restProps] = extractStyleProps(restRecipeProps); + + const asideConfig = useMemo( + () => ({ + minSize: minSize ?? 0, + maxSize: maxSize ?? 100, + collapsible: collapsible ?? false, + collapsedSize: collapsedSize ?? 0, + }), + [minSize, maxSize, collapsible, collapsedSize] + ); + + const contextValue = useSplitterState({ + orientation, + defaultSize, + size, + asideConfig, + collapsed, + defaultCollapsed, + keyboardStep, + isDoubleClickDisabled, + isDisabled, + onSizeChange, + onSizeChangeEnd, + onCollapsedChange, + }); + + // Dev-time warning: the Splitter primitive is strictly aside + main. Evaluated + // in an effect (not during render) so it fires after pane registration settles + // — panes register via effects, so a transient 1-pane commit (StrictMode + // double-invoke, staggered child mounts) is normal and must not warn. + const paneCount = contextValue.paneOrder.length; + useEffect(() => { + if ( + typeof process !== "undefined" && + process.env.NODE_ENV !== "production" && + paneCount > 0 && + paneCount !== 2 + ) { + console.warn( + `[Splitter] Expected one and one , got ${paneCount} pane(s). The Splitter primitive is 2-pane; nest a second Splitter inside a pane for 3+ regions.` + ); + } + }, [paneCount]); + + return ( + + + {children} + + + ); +}; + +SplitterRoot.displayName = "Splitter.Root"; diff --git a/packages/nimbus/src/components/splitter/hooks/index.ts b/packages/nimbus/src/components/splitter/hooks/index.ts new file mode 100644 index 000000000..670c4b388 --- /dev/null +++ b/packages/nimbus/src/components/splitter/hooks/index.ts @@ -0,0 +1,4 @@ +export { useHandleKeyboard } from "./use-handle-keyboard"; +export { useHandleResize } from "./use-handle-resize"; +export { useSplitterContext } from "./use-splitter-context"; +export { useSplitterState } from "./use-splitter-state"; diff --git a/packages/nimbus/src/components/splitter/hooks/use-handle-keyboard.ts b/packages/nimbus/src/components/splitter/hooks/use-handle-keyboard.ts new file mode 100644 index 000000000..bb5d4d755 --- /dev/null +++ b/packages/nimbus/src/components/splitter/hooks/use-handle-keyboard.ts @@ -0,0 +1,92 @@ +import { useCallback } from "react"; +import type { KeyboardEvent } from "react"; +import { useSplitterContext } from "./use-splitter-context"; + +type UseHandleKeyboardOptions = { + /** True once both panes are registered. */ + isReady: boolean; + /** + * Clamped size writer from `useHandleResize`. Already a no-op while disabled + * or collapse-locked, so the arrow / Home / End cases need no extra guard. + */ + applyDelta: (delta: number, commit: boolean) => void; +}; + +/** + * Owns the handle's keyboard model (W3C window splitter): orientation-aware + * arrow keys move by `keyboardStep`, Home/End jump to min/max, Enter toggles + * the aside's collapse. Each keypress commits (settled), unlike a live drag tick. + * + * Δ is expressed as "grow the leading pane"; `useHandleResize` translates it to + * an aside Δ. Collapse is aside-only, so Enter simply toggles the boolean. + */ +export const useHandleKeyboard = ({ + isReady, + applyDelta, +}: UseHandleKeyboardOptions) => { + const { + orientation, + keyboardStep, + isDisabled, + collapsed, + setCollapsed, + asideConfig, + } = useSplitterContext(); + + const toggleCollapse = useCallback(() => { + if (!isReady || isDisabled) return; + // If collapsed, Enter expands; otherwise collapse the aside (when allowed). + if (collapsed) { + setCollapsed(false); + return; + } + if (asideConfig.collapsible) setCollapsed(true); + }, [isReady, isDisabled, collapsed, asideConfig.collapsible, setCollapsed]); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!isReady || isDisabled) return; + switch (event.key) { + case "ArrowLeft": + if (orientation === "horizontal") { + event.preventDefault(); + applyDelta(-keyboardStep, true); + } + return; + case "ArrowRight": + if (orientation === "horizontal") { + event.preventDefault(); + applyDelta(keyboardStep, true); + } + return; + case "ArrowUp": + if (orientation === "vertical") { + event.preventDefault(); + applyDelta(-keyboardStep, true); + } + return; + case "ArrowDown": + if (orientation === "vertical") { + event.preventDefault(); + applyDelta(keyboardStep, true); + } + return; + case "Home": + event.preventDefault(); + applyDelta(-100, true); + return; + case "End": + event.preventDefault(); + applyDelta(100, true); + return; + case "Enter": + event.preventDefault(); + toggleCollapse(); + return; + } + }, + [isReady, isDisabled, orientation, keyboardStep, applyDelta, toggleCollapse] + ); + + return { onKeyDown }; +}; diff --git a/packages/nimbus/src/components/splitter/hooks/use-handle-resize.ts b/packages/nimbus/src/components/splitter/hooks/use-handle-resize.ts new file mode 100644 index 000000000..df5cb50f8 --- /dev/null +++ b/packages/nimbus/src/components/splitter/hooks/use-handle-resize.ts @@ -0,0 +1,102 @@ +import { useCallback, useRef } from "react"; +import type { HTMLAttributes, RefObject } from "react"; +import { useMove } from "react-aria"; +import { useSplitterContext } from "./use-splitter-context"; +import { clampedResize } from "../utils"; + +// Drag deltas (in percentage points) below this are accumulated rather than +// dropped, so slow / sub-pixel movement still registers once it adds up. +const MOVE_TOLERANCE = 0.0001; + +type UseHandleResizeOptions = { + /** Ref to the handle, used to measure the container for px→% conversion. */ + handleRef: RefObject; + /** True when the aside is the leading (prev) sibling — flips the Δ sign. */ + asideLeads: boolean; + /** True once both panes are registered. */ + isReady: boolean; + /** Locked while the aside is collapsed (resize would only snap to `minSize`). */ + isResizeLocked: boolean; +}; + +/** + * Owns the handle's resize mechanics: `applyDelta` (a clamped size writer, + * returned so the keyboard hook reuses it) and `moveProps` (pointer drag via + * react-aria `useMove`, converting px deltas to container percentages). + * + * The handle's gesture grows the *leading* pane by Δ. This is translated into an + * aside Δ before clamping: aside leading → `+Δ`, aside trailing → `−Δ`. The + * single aside size is then clamped into `[minSize, maxSize]`. + */ +export const useHandleResize = ({ + handleRef, + asideLeads, + isReady, + isResizeLocked, +}: UseHandleResizeOptions) => { + const { size, setSize, commitSize, orientation, isDisabled, asideConfig } = + useSplitterContext(); + + const applyDelta = useCallback( + (delta: number, commit: boolean) => { + if (!isReady || isDisabled || isResizeLocked) return; + const asideDelta = asideLeads ? delta : -delta; + const next = clampedResize({ + size, + delta: asideDelta, + minSize: asideConfig.minSize, + maxSize: asideConfig.maxSize, + }); + if (commit) { + commitSize(next); + } else { + setSize(next); + } + }, + [ + isReady, + isDisabled, + isResizeLocked, + asideLeads, + size, + asideConfig.minSize, + asideConfig.maxSize, + setSize, + commitSize, + ] + ); + + // Accumulate per-event deltas across a single drag so movements smaller than + // MOVE_TOLERANCE aren't dropped — they build up until they clear the gate. + const dragAccumRef = useRef(0); + + const { moveProps } = useMove({ + onMoveStart() { + dragAccumRef.current = 0; + }, + onMove(e) { + if (!isReady || isDisabled || isResizeLocked) return; + const parent = handleRef.current?.parentElement; + if (!parent) return; + const containerSize = + orientation === "horizontal" ? parent.offsetWidth : parent.offsetHeight; + if (containerSize <= 0) return; + const deltaPx = orientation === "horizontal" ? e.deltaX : e.deltaY; + dragAccumRef.current += (deltaPx / containerSize) * 100; + const wholeDelta = dragAccumRef.current; + if (Math.abs(wholeDelta) < MOVE_TOLERANCE) return; + dragAccumRef.current -= wholeDelta; + applyDelta(wholeDelta, false); + }, + onMoveEnd() { + if (!isReady || isDisabled || isResizeLocked) return; + // Settle: fire onSizeChangeEnd with the current size (the persistence seam). + commitSize(); + }, + }); + + return { moveProps, applyDelta } as { + moveProps: HTMLAttributes; + applyDelta: (delta: number, commit: boolean) => void; + }; +}; diff --git a/packages/nimbus/src/components/splitter/hooks/use-responsive-splitter-sizes.spec.tsx b/packages/nimbus/src/components/splitter/hooks/use-responsive-splitter-sizes.spec.tsx new file mode 100644 index 000000000..ff3419366 --- /dev/null +++ b/packages/nimbus/src/components/splitter/hooks/use-responsive-splitter-sizes.spec.tsx @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { act, render, renderHook } from "@testing-library/react"; +import { useResponsiveSplitterSizes } from "./use-responsive-splitter-sizes"; +import type { + SplitterSizesStorage, + UseResponsiveSplitterSizesOptions, +} from "../splitter.types"; + +/** Record `rootProps.size` on every render so the FIRST render can be asserted. */ +const recordSizes = (options: UseResponsiveSplitterSizesOptions) => { + const sizes: Array = []; + const Probe = () => { + const { rootProps } = useResponsiveSplitterSizes(options); + sizes.push(rootProps.size); + return null; + }; + render(); + return sizes; +}; + +// --- Controllable ResizeObserver ------------------------------------------- +type ROCallback = (entries: Array<{ contentRect: DOMRectReadOnly }>) => void; +const observers: Array<{ cb: ROCallback; node: Element | null }> = []; + +class ControllableResizeObserver { + cb: ROCallback; + node: Element | null = null; + constructor(cb: ROCallback) { + this.cb = cb; + observers.push(this); + } + observe(node: Element) { + this.node = node; + } + unobserve() {} + disconnect() { + this.node = null; + } +} + +/** Fire a measurement on every active observer (the hook keeps a single one). */ +const fireResize = (width: number, height = 600) => { + act(() => { + for (const o of observers) { + if (o.node) { + o.cb([{ contentRect: { width, height } as DOMRectReadOnly }]); + } + } + }); +}; + +/** Render the hook and attach its ref to a detached node. */ +const mountHook = ( + options: Parameters[0] +) => { + const view = renderHook((o) => useResponsiveSplitterSizes(o), { + initialProps: options, + }); + const node = document.createElement("div"); + act(() => { + (view.result.current.rootProps.ref as (n: HTMLDivElement | null) => void)( + node + ); + }); + return view; +}; + +const memoryStorage = (): SplitterSizesStorage & { + dump: Record; +} => { + const dump: Record = {}; + return { + dump, + getItem: (k) => dump[k] ?? null, + setItem: (k, v) => { + dump[k] = v; + }, + }; +}; + +let originalRO: typeof ResizeObserver; +beforeEach(() => { + observers.length = 0; + originalRO = globalThis.ResizeObserver; + globalThis.ResizeObserver = + ControllableResizeObserver as unknown as typeof ResizeObserver; +}); +afterEach(() => { + globalThis.ResizeObserver = originalRO; + vi.restoreAllMocks(); +}); + +describe("useResponsiveSplitterSizes", () => { + it("resolves a single percent value synchronously, before measurement", () => { + const view = renderHook(() => useResponsiveSplitterSizes({ size: "30%" })); + expect(view.result.current.rootProps.size).toBe(30); + }); + + it("emits a synchronous percent size on the very first render (no undefined frame)", () => { + // A `%` config needs no measurement, so the controlled `size` must be + // present on the first render — otherwise the component shows its + // uncontrolled default for a frame (the first-paint flash). + const sizes = recordSizes({ size: "30%" }); + expect(sizes[0]).toBe(30); + }); + + it("converts a pixel value to a percentage of the measured container", () => { + const view = mountHook({ size: 320 }); + // No measurement yet → size omitted (component stays uncontrolled). + expect(view.result.current.rootProps.size).toBeUndefined(); + fireResize(1000); + expect(view.result.current.rootProps.size).toBe(32); + fireResize(800); + expect(view.result.current.rootProps.size).toBe(40); + }); + + it("selects the active band by container width", () => { + const view = mountHook({ + size: { 0: 320, 768: "30%" }, + }); + fireResize(640); // below 768 → 320px / 640 = 50% + expect(view.result.current.rootProps.size).toBe(50); + fireResize(1000); // at/above 768 → 30% + expect(view.result.current.rootProps.size).toBe(30); + }); + + it("forwards minSize/maxSize/collapsedSize as percentages and clamps size", () => { + const view = mountHook({ + size: 100, // 100px + minSize: 200, // 200px + maxSize: 600, // 600px + collapsedSize: 0, + }); + fireResize(1000); + // minSize 20%, maxSize 60%, collapsedSize 0%; raw size 10% clamped up to 20%. + expect(view.result.current.rootProps.minSize).toBe(20); + expect(view.result.current.rootProps.maxSize).toBe(60); + expect(view.result.current.rootProps.collapsedSize).toBe(0); + expect(view.result.current.rootProps.size).toBe(20); + }); + + it("supports object (container-width) notation for minSize/maxSize", () => { + const view = mountHook({ + size: "50%", + minSize: { 0: 100, 768: 200 }, // px per band + maxSize: { 0: "60%", 768: "80%" }, // percent per band + }); + fireResize(1000); // ≥ 768 band + expect(view.result.current.rootProps.minSize).toBe(20); // 200px / 1000 + expect(view.result.current.rootProps.maxSize).toBe(80); + fireResize(640); // < 768 band + expect(view.result.current.rootProps.minSize).toBeCloseTo(15.625); // 100px / 640 + expect(view.result.current.rootProps.maxSize).toBe(60); + }); + + it("measures the height axis for a vertical splitter", () => { + const view = mountHook({ orientation: "vertical", size: 300 }); // 300px + fireResize(1000, 600); // vertical → uses height 600 → 300/600 = 50% + expect(view.result.current.rootProps.size).toBe(50); + expect(view.result.current.rootProps.orientation).toBe("vertical"); + }); + + it("does not thrash the emitted size on sub-tolerance resize ticks", () => { + const view = mountHook({ size: 320 }); + fireResize(1000); + const first = view.result.current.rootProps.size; + expect(first).toBe(32); + // 320/1001 ≈ 31.97% — within EMIT_TOLERANCE of 32, so size holds steady. + fireResize(1001); + expect(view.result.current.rootProps.size).toBe(first); + // A clearly larger change does move it. + fireResize(1280); // 320/1280 = 25% + expect(view.result.current.rootProps.size).toBe(25); + }); + + it("persists a settled drag in pixels and restores it across a remount + resize", () => { + const storage = memoryStorage(); + const view = mountHook({ + size: 320, + persistKey: "k", + storage, + }); + fireResize(1000); + // User drags and releases at 28% (≈280px in a 1000px container). + act(() => view.result.current.rootProps.onSizeChangeEnd(28)); + expect(view.result.current.rootProps.size).toBe(28); // fed back, no snap-back + expect(storage.dump.k).toContain('"unit":"px"'); + + // Remount into an 800px container — the 280px pin re-converts to 35%. + const remount = mountHook({ + size: 320, + persistKey: "k", + storage, + }); + fireResize(800); + expect(remount.result.current.rootProps.size).toBeCloseTo(35); + }); + + it("does not persist a collapse-driven settle", () => { + const storage = memoryStorage(); + const view = mountHook({ + size: 320, + collapsedSize: 0, + persistKey: "k", + storage, + }); + fireResize(1000); + act(() => view.result.current.rootProps.onSizeChangeEnd(32)); // real settle + const afterReal = storage.dump.k; + expect(afterReal).toBeDefined(); + + // Collapse: onCollapsedChange(true) fires before the collapse settle. + act(() => view.result.current.rootProps.onCollapsedChange(true)); + act(() => view.result.current.rootProps.onSizeChangeEnd(0)); // collapsed size + // Storage unchanged — the collapsed value was not persisted. + expect(storage.dump.k).toBe(afterReal); + }); + + it("calls the optional onCollapsedChange passthrough", () => { + const onCollapsedChange = vi.fn(); + const view = mountHook({ + size: "30%", + onCollapsedChange, + }); + act(() => view.result.current.rootProps.onCollapsedChange(true)); + expect(onCollapsedChange).toHaveBeenCalledWith(true); + }); + + it("degrades without ResizeObserver: percent config still resolves", () => { + globalThis.ResizeObserver = undefined as unknown as typeof ResizeObserver; + const view = mountHook({ size: "40%" }); + expect(view.result.current.rootProps.size).toBe(40); + }); + + it("tolerates a throwing storage on write", () => { + const storage: SplitterSizesStorage = { + getItem: () => null, + setItem: () => { + throw new Error("quota"); + }, + }; + const view = mountHook({ + size: 320, + persistKey: "k", + storage, + }); + fireResize(1000); + expect(() => + act(() => view.result.current.rootProps.onSizeChangeEnd(30)) + ).not.toThrow(); + expect(view.result.current.rootProps.size).toBe(30); + }); +}); diff --git a/packages/nimbus/src/components/splitter/hooks/use-responsive-splitter-sizes.ts b/packages/nimbus/src/components/splitter/hooks/use-responsive-splitter-sizes.ts new file mode 100644 index 000000000..958f2d2f4 --- /dev/null +++ b/packages/nimbus/src/components/splitter/hooks/use-responsive-splitter-sizes.ts @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + ResponsiveSplitterRootProps, + UseResponsiveSplitterSizesOptions, + UseResponsiveSplitterSizesResult, +} from "../splitter.types"; +import { + bandValueAt, + clampPercent, + isPercentValue, + percentToPx, + resolveDimension, + type StoredBand, +} from "../utils/responsive-size"; +import { + createLocalStorageAdapter, + parseStoredBands, + serializeStoredBands, +} from "../utils/responsive-size-storage"; + +/** + * Emitted `size` only changes when it moves by more than this many percentage + * points — coarser than the component's internal `1e-6` epsilon so pixel↔percent + * round-trips under `ResizeObserver` ticks don't thrash the controlled prop. + */ +const EMIT_TOLERANCE = 0.05; +/** Ignore sub-pixel measurement noise. */ +const MEASURE_TOLERANCE = 0.5; +/** Hysteresis deadband (px) around band thresholds. */ +const BAND_DEADBAND = 2; + +/** Module-level default so the adapter identity is stable across renders. */ +const defaultStorage = createLocalStorageAdapter(); + +/** + * Translate a pixel-/token-/percent size config — optionally responsive by + * container width — into the percentage `Splitter.Root` consumes, returning + * `rootProps` to spread onto the root. + * + * The hook is the **pixel/token → percentage translator** the component + * deliberately omits: `Splitter.Root` stays percentage-native; all pixel math, + * container-width resolution, hook-side clamping, and per-band persistence live + * here. It drives the component's settle-only controlled `size` channel and + * feeds the settled value back, so the controlled loop stays closed with no + * snap-back. A bare `number` is **always pixels**. + * + * @example + * const { rootProps } = useResponsiveSplitterSizes({ + * orientation: "horizontal", + * persistKey: "app:main-splitter", + * size: { 0: 320, 768: "30%" }, // 320px below 768px container width, 30% above + * }); + * return ( + * + * + * + * + * + * ); + */ +export const useResponsiveSplitterSizes = ( + options: UseResponsiveSplitterSizesOptions +): UseResponsiveSplitterSizesResult => { + const { + orientation = "horizontal", + size, + minSize, + maxSize, + collapsedSize, + persistKey, + storage = defaultStorage, + onCollapsedChange, + } = options; + + // Latest measured container size on the relevant axis (null until observed). + const [measured, setMeasured] = useState(null); + const measuredRef = useRef(null); + + // Persisted bands for the size dimension, keyed by resolved px threshold. + const [storedBands, setStoredBands] = useState>( + () => + persistKey ? (parseStoredBands(storage.getItem(persistKey)) ?? {}) : {} + ); + const storedBandsRef = useRef(storedBands); + storedBandsRef.current = storedBands; + + // Hysteresis: the band the size dimension currently resolves to. + const activeThresholdRef = useRef(null); + // Gate for the emitted controlled size. + const lastEmittedRef = useRef(null); + const [emittedSize, setEmittedSize] = useState(undefined); + // Whether the aside is currently collapsed (so settles aren't persisted). + const collapsedRef = useRef(false); + + // Latest config snapshot for the (stable) settle handler. + const latestRef = useRef({ size, persistKey, onCollapsedChange }); + latestRef.current = { size, persistKey, onCollapsedChange }; + + // --- Resolution (recomputed each render; cheap + pure) --------------------- + const sizeRes = resolveDimension(size, measured, { + activeThresholdPx: activeThresholdRef.current, + deadbandPx: BAND_DEADBAND, + stored: storedBands, + }); + const minPct = + minSize !== undefined + ? (resolveDimension(minSize, measured).percent ?? undefined) + : undefined; + const maxPct = + maxSize !== undefined + ? (resolveDimension(maxSize, measured).percent ?? undefined) + : undefined; + const collapsedPct = + collapsedSize !== undefined + ? (resolveDimension(collapsedSize, measured).percent ?? undefined) + : undefined; + + const targetSize = + sizeRes.percent === null + ? null + : clampPercent(sizeRes.percent, minPct ?? 0, maxPct ?? 100); + + // Commit the active band + gated emitted size after render (never during). + useEffect(() => { + if (sizeRes.thresholdPx !== null) { + activeThresholdRef.current = sizeRes.thresholdPx; + } + if (targetSize === null) return; + if ( + lastEmittedRef.current !== null && + Math.abs(targetSize - lastEmittedRef.current) < EMIT_TOLERANCE + ) { + return; + } + lastEmittedRef.current = targetSize; + setEmittedSize(targetSize); + }, [targetSize, sizeRes.thresholdPx]); + + // --- Container measurement (ResizeObserver) -------------------------------- + const ref = useCallback( + (node: HTMLDivElement | null) => { + if (!node) return; + + const read = (width: number, height: number) => { + const next = orientation === "vertical" ? height : width; + if (!Number.isFinite(next) || next <= 0) return; + measuredRef.current = next; + setMeasured((prev) => + prev !== null && Math.abs(prev - next) < MEASURE_TOLERANCE + ? prev + : next + ); + }; + + if (typeof ResizeObserver === "undefined") { + // No observer (SSR/older runtimes): best-effort one-shot measurement so + // px/token configs can still resolve once; %-only config is unaffected. + const rect = node.getBoundingClientRect(); + read(rect.width, rect.height); + return; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + read(entry.contentRect.width, entry.contentRect.height); + }); + observer.observe(node); + return () => observer.disconnect(); + }, + [orientation] + ); + + // --- Settle handler: persist (unless collapsed) + feed value back ---------- + const handleSizeChangeEnd = useCallback( + (settledPct: number) => { + // Collapse fires `onCollapsedChange(true)` before this settle, so a + // collapse-driven settle is suppressed here: no feedback (keep the + // controlled value at the expanded size, which becomes the expand target) + // and no persistence. + if (collapsedRef.current) return; + + lastEmittedRef.current = settledPct; + setEmittedSize(settledPct); + + const { size: sizeCfg, persistKey: key } = latestRef.current; + const containerPx = measuredRef.current; + if (!key || containerPx === null || containerPx <= 0) return; + + const { thresholdPx } = resolveDimension(sizeCfg, containerPx, { + activeThresholdPx: activeThresholdRef.current, + deadbandPx: BAND_DEADBAND, + }); + if (thresholdPx === null) return; + + const configured = bandValueAt(sizeCfg, thresholdPx); + const unit: StoredBand["unit"] = isPercentValue(configured) + ? "pct" + : "px"; + const value = + unit === "pct" ? settledPct : percentToPx(settledPct, containerPx); + + const nextBands = { + ...storedBandsRef.current, + [thresholdPx]: { unit, value }, + }; + storedBandsRef.current = nextBands; + setStoredBands(nextBands); + try { + storage.setItem(key, serializeStoredBands(nextBands)); + } catch { + // Best-effort; resolution still works from in-memory state. + } + }, + [storage] + ); + + const handleCollapsedChange = useCallback((collapsed: boolean) => { + collapsedRef.current = collapsed; + latestRef.current.onCollapsedChange?.(collapsed); + }, []); + + // Prefer the gated `emittedSize` (stable across sub-tolerance resize ticks), + // but fall back to the freshly-resolved `targetSize` before the gate's effect + // first runs. This is what makes a synchronously-resolvable config (e.g. a + // `%` value, which needs no measurement) drive the controlled `size` on the + // very first render — so the component honors it on first paint with no flash, + // rather than briefly showing its uncontrolled default. + const resolvedSize = emittedSize ?? targetSize ?? undefined; + + const rootProps = useMemo(() => { + const props: ResponsiveSplitterRootProps = { + ref, + orientation, + onSizeChangeEnd: handleSizeChangeEnd, + onCollapsedChange: handleCollapsedChange, + }; + if (resolvedSize !== undefined) props.size = resolvedSize; + if (minPct !== undefined) props.minSize = minPct; + if (maxPct !== undefined) props.maxSize = maxPct; + if (collapsedPct !== undefined) props.collapsedSize = collapsedPct; + return props; + }, [ + ref, + orientation, + handleSizeChangeEnd, + handleCollapsedChange, + resolvedSize, + minPct, + maxPct, + collapsedPct, + ]); + + return { rootProps }; +}; diff --git a/packages/nimbus/src/components/splitter/hooks/use-splitter-context.ts b/packages/nimbus/src/components/splitter/hooks/use-splitter-context.ts new file mode 100644 index 000000000..5f9328a20 --- /dev/null +++ b/packages/nimbus/src/components/splitter/hooks/use-splitter-context.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { SplitterContext } from "../splitter.context"; + +export const useSplitterContext = () => { + const context = useContext(SplitterContext); + if (!context) { + throw new Error("useSplitterContext must be used within a Splitter.Root"); + } + return context; +}; diff --git a/packages/nimbus/src/components/splitter/hooks/use-splitter-state.spec.tsx b/packages/nimbus/src/components/splitter/hooks/use-splitter-state.spec.tsx new file mode 100644 index 000000000..7718b9deb --- /dev/null +++ b/packages/nimbus/src/components/splitter/hooks/use-splitter-state.spec.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useSplitterState } from "./use-splitter-state"; +import type { ResolvedAsideConfig } from "../splitter.types"; + +const config = ( + over: Partial = {} +): ResolvedAsideConfig => ({ + minSize: 0, + maxSize: 100, + collapsible: false, + collapsedSize: 0, + ...over, +}); + +const base = { + orientation: "horizontal" as const, + keyboardStep: 5, + isDoubleClickDisabled: false, + isDisabled: false, +}; + +/** + * Regression guard: the size state must be seeded synchronously from props so + * the FIRST render is already correct — no 50/50 flash before an effect runs. + * These assertions read `result.current.size` immediately after the initial + * render, before any pane has registered. + */ +describe("useSplitterState — synchronous first-render size", () => { + it("seeds the uncontrolled defaultSize on the first render", () => { + const { result } = renderHook(() => + useSplitterState({ ...base, defaultSize: 30, asideConfig: config() }) + ); + expect(result.current.size).toBe(30); + }); + + it("falls back to 50 when no size is configured", () => { + const { result } = renderHook(() => + useSplitterState({ ...base, asideConfig: config() }) + ); + expect(result.current.size).toBe(50); + }); + + it("seeds the controlled size on the first render", () => { + const { result } = renderHook(() => + useSplitterState({ ...base, size: 25, asideConfig: config() }) + ); + expect(result.current.size).toBe(25); + }); + + it("normalizes an out-of-range size on the first render", () => { + const { result } = renderHook(() => + useSplitterState({ ...base, defaultSize: 150, asideConfig: config() }) + ); + expect(result.current.size).toBe(100); + }); + + it("shows the collapsed size on the first render when initially collapsed", () => { + const { result } = renderHook(() => + useSplitterState({ + ...base, + defaultSize: 30, + defaultCollapsed: true, + asideConfig: config({ collapsible: true, collapsedSize: 6 }), + }) + ); + expect(result.current.size).toBe(6); + expect(result.current.collapsed).toBe(true); + }); + + it("ignores initial collapse when the aside is not collapsible", () => { + const { result } = renderHook(() => + useSplitterState({ + ...base, + defaultSize: 30, + defaultCollapsed: true, + asideConfig: config({ collapsible: false, collapsedSize: 6 }), + }) + ); + // Not collapsible → the collapse is ignored, the configured size shows. + expect(result.current.size).toBe(30); + }); +}); diff --git a/packages/nimbus/src/components/splitter/hooks/use-splitter-state.ts b/packages/nimbus/src/components/splitter/hooks/use-splitter-state.ts new file mode 100644 index 000000000..cc3c9544c --- /dev/null +++ b/packages/nimbus/src/components/splitter/hooks/use-splitter-state.ts @@ -0,0 +1,351 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { deriveInitialSize, normalizeSize, sizeEqual } from "../utils"; +import type { + ResolvedAsideConfig, + SplitterContextValue, + SplitterPaneRole, +} from "../splitter.types"; + +const COLLAPSE_TOLERANCE = 0.001; + +type UseSplitterStateOptions = { + /** Splitter orientation; determines layout axis and active arrow keys. */ + orientation: "horizontal" | "vertical"; + /** Explicit initial aside size (read once on mount). */ + defaultSize?: number; + /** Controlled aside size (settle-only). When set, size is controlled. */ + size?: number; + /** Resolved aside constraints (clamping, collapse). */ + asideConfig: ResolvedAsideConfig; + /** Keyboard step in percentage points per arrow-key press. */ + keyboardStep: number; + /** When true, the handle ignores double-clicks. */ + isDoubleClickDisabled: boolean; + /** When true, the whole splitter is non-interactive. */ + isDisabled: boolean; + /** Controlled collapsed state of the aside. When set, collapse is controlled. */ + collapsed?: boolean; + /** Uncontrolled initial collapsed state. */ + defaultCollapsed?: boolean; + /** Fired on every size change, including each drag tick. */ + onSizeChange?: (size: number) => void; + /** Fired once when a size interaction settles. */ + onSizeChangeEnd?: (size: number) => void; + /** Fired whenever the aside collapses or expands. */ + onCollapsedChange?: (collapsed: boolean) => void; +}; + +/** True once both the aside and main panes have registered. */ +const bothRegistered = (order: SplitterPaneRole[]): boolean => + order.includes("aside") && order.includes("main"); + +/** + * Owns the size state machine for `Splitter.Root`: role-based pane registration, + * lazy initial-size derivation on mount, controlled/uncontrolled collapse of the + * aside with size reconciliation, and the memoized context value handed to the + * pane components and `Splitter.Handle`. + * + * The single source of truth is the aside's `size` (%); the main pane is always + * `100 − size`. Sizes carry full float precision end-to-end — no rounding + * anywhere in this pipeline (the only rounding lives on the handle's + * `aria-valuenow`, an AT announcement that does not affect layout). + * + * Two change channels: `setSize` is the live drag channel (fires `onSizeChange` + * only); `commitSize` is the settled channel (fires `onSizeChangeEnd`, the + * persistence seam). Collapse is plain controlled/uncontrolled boolean state. + */ +export const useSplitterState = ( + options: UseSplitterStateOptions +): SplitterContextValue => { + const { + orientation, + defaultSize, + size: sizeProp, + asideConfig, + keyboardStep, + isDoubleClickDisabled, + isDisabled, + collapsed: collapsedProp, + defaultCollapsed, + onSizeChange, + onSizeChangeEnd, + onCollapsedChange, + } = options; + + // Pane registration: role order in DOM, and the DOM id rendered on each. + const [paneOrder, setPaneOrder] = useState([]); + const [paneDomIds, setPaneDomIds] = useState< + Partial> + >({}); + + // Size: controlled (prop provided) or uncontrolled (internal state). Control + // is settle-only — internal `size` stays authoritative during interaction; the + // prop is reconciled in at rest by the effect below. + const isSizeControlled = sizeProp !== undefined; + const isCollapseControlled = collapsedProp !== undefined; + + // Initial layout, derived synchronously from props so the FIRST committed + // paint is correct — no 50/50 flash before an effect runs. Controlled `size` + // seeds the layout when present + valid; else the uncontrolled `defaultSize` + // path (with its 50/50 fallback). If the aside starts collapsed it shows + // `collapsedSize`, and the uncollapsed value is stashed as the expand target. + const initialBase = + (isSizeControlled ? normalizeSize(sizeProp) : null) ?? + deriveInitialSize(defaultSize); + const initiallyCollapsed = + (collapsedProp ?? defaultCollapsed ?? false) && asideConfig.collapsible; + const initialDisplay = initiallyCollapsed + ? asideConfig.collapsedSize + : initialBase; + + // Aside size state, seeded synchronously (above); the ref mirrors state so + // settled-commit and collapse reconciliation read the current value + // synchronously. + const [size, setSizeState] = useState(initialDisplay); + const sizeRef = useRef(initialDisplay); + + // The controlled value the reconcile effect last acted on. Seeded to the raw + // prop so the effect's first run is a no-op; gates it against the *prop* + // changing, independent of internal drift. + const lastReconciledSizeRef = useRef(sizeProp ?? null); + const didWarnControlledSizeRef = useRef(false); + + // Mount snapshot for double-click restore; pre-collapse snapshot for expand + // (seeded to the uncollapsed value when the aside starts collapsed). + const initialSizeRef = useRef(initialBase); + const preCollapseSizeRef = useRef( + initiallyCollapsed ? initialBase : null + ); + + // Collapse: controlled (prop provided) or uncontrolled (internal state). + const [internalCollapsed, setInternalCollapsed] = useState( + defaultCollapsed ?? false + ); + const collapsed = isCollapseControlled + ? (collapsedProp ?? false) + : internalCollapsed; + // Whether the size state already reflects the collapse state. Seeded to the + // initial collapse so the reconciliation effect's mount run is a no-op; lets + // it skip when sizes already match, and lets an expand-by-resize clear + // collapse without the effect fighting it. + const appliedCollapseRef = useRef(initiallyCollapsed); + + // Single low-level size writer. `commit` additionally fires onSizeChangeEnd. + // Also detects expand-by-resize: if the aside grows past its collapsedSize + // (e.g. a double-click restore), collapse state is cleared. + const writeSize = useCallback( + (next: number, opts: { commit: boolean }) => { + sizeRef.current = next; + setSizeState(next); + onSizeChange?.(next); + if (opts.commit) onSizeChangeEnd?.(next); + + if ( + appliedCollapseRef.current && + next > asideConfig.collapsedSize + COLLAPSE_TOLERANCE + ) { + appliedCollapseRef.current = false; + preCollapseSizeRef.current = null; + if (!isCollapseControlled) setInternalCollapsed(false); + onCollapsedChange?.(false); + } + }, + [ + onSizeChange, + onSizeChangeEnd, + asideConfig.collapsedSize, + isCollapseControlled, + onCollapsedChange, + ] + ); + + const setSize = useCallback( + (next: number) => writeSize(next, { commit: false }), + [writeSize] + ); + + const commitSize = useCallback( + (next?: number) => { + if (next !== undefined) { + writeSize(next, { commit: true }); + } else { + onSizeChangeEnd?.(sizeRef.current); + } + }, + [writeSize, onSizeChangeEnd] + ); + + // Initial size + collapse are seeded synchronously above (defaults are read + // once on mount, per spec — no onSizeChange on mount), so there is no + // registration-gated init effect. The reconcile effects below only wait for + // both panes to register before reacting to later prop/state changes. + + // Reconcile size when the resolved collapsed state changes (controlled prop + // change or internal toggle). The mount case is a no-op (state already matches + // the seeded collapse). + useEffect(() => { + if (!bothRegistered(paneOrder)) return; + const cur = collapsed; + const prev = appliedCollapseRef.current; + if (cur === prev) return; + // Can't collapse a non-collapsible aside — leave the layout, stay a no-op. + if (cur && !asideConfig.collapsible) return; + appliedCollapseRef.current = cur; + + if (cur) { + if (!prev) preCollapseSizeRef.current = sizeRef.current; + commitSize(asideConfig.collapsedSize); + } else { + const restore = preCollapseSizeRef.current; + preCollapseSizeRef.current = null; + commitSize(restore ?? initialSizeRef.current); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collapsed, paneOrder]); + + // Reconcile a controlled `size` prop into internal state at rest — only when + // the prop changes, never on a drag tick (internal state stays authoritative + // during interaction). Declared after the collapse effect so collapse settles + // first. The write is silent (no callbacks): the value is the consumer's own. + useEffect(() => { + if (!isSizeControlled) return; + if (!bothRegistered(paneOrder)) return; + // Prop unchanged since last reconcile → nothing to do. Covers the post-init + // no-op and the "consumer never feeds the value back" case (no snap-back). + if (sizeEqual(sizeProp, lastReconciledSizeRef.current)) return; + + const normalized = normalizeSize(sizeProp); + if (normalized === null) return; // malformed input → ignore, keep state + lastReconciledSizeRef.current = sizeProp ?? null; + + // Collapse owns the aside's size. While collapsed, don't overwrite the + // collapsed layout — stash the controlled value as the expand target so a + // later expand restores it. + if (collapsed) { + preCollapseSizeRef.current = normalized; + return; + } + + // Internal already matches (e.g. consumer fed back the emitted value) → no + // write. `size` is not a dep, so a silent write can't re-trigger this. + if (sizeEqual(normalized, sizeRef.current)) return; + sizeRef.current = normalized; + setSizeState(normalized); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sizeProp, paneOrder, collapsed]); + + // Dev-time guidance on controlled-`size` misuse. Fires at most once. + useEffect(() => { + if (typeof process === "undefined" || process.env.NODE_ENV === "production") + return; + if (didWarnControlledSizeRef.current || !isSizeControlled) return; + if (defaultSize !== undefined) { + didWarnControlledSizeRef.current = true; + console.warn( + "[Splitter] Both `size` (controlled) and `defaultSize` (uncontrolled) were provided. `defaultSize` is ignored — pass one or the other." + ); + } else if (onSizeChangeEnd === undefined) { + didWarnControlledSizeRef.current = true; + console.warn( + "[Splitter] `size` is controlled but `onSizeChangeEnd` is not set. After a drag or keyboard resize the splitter keeps that value and behaves as uncontrolled. Wire `onSizeChangeEnd` and feed the value back to stay controlled." + ); + } + }, [isSizeControlled, defaultSize, onSizeChangeEnd]); + + const registerPane = useCallback((role: SplitterPaneRole, domId: string) => { + setPaneOrder((order) => (order.includes(role) ? order : [...order, role])); + setPaneDomIds((map) => + map[role] === domId ? map : { ...map, [role]: domId } + ); + }, []); + + const unregisterPane = useCallback((role: SplitterPaneRole) => { + setPaneOrder((order) => order.filter((r) => r !== role)); + setPaneDomIds((map) => { + if (!(role in map)) return map; + const next = { ...map }; + delete next[role]; + return next; + }); + }, []); + + const setCollapsed = useCallback( + (next: boolean) => { + if (isDisabled) return; + if (next === collapsed) return; + if (next && !asideConfig.collapsible) return; + if (!isCollapseControlled) setInternalCollapsed(next); + onCollapsedChange?.(next); + // Size reconciliation happens in the collapse effect reacting to the + // resolved collapsed change (covers controlled + uncontrolled). + }, + [ + isDisabled, + collapsed, + asideConfig.collapsible, + isCollapseControlled, + onCollapsedChange, + ] + ); + + const restoreDefaults = useCallback(() => { + if (!bothRegistered(paneOrder)) return; + const initial = initialSizeRef.current; + if (collapsed) { + // Only touch internal collapse bookkeeping when uncontrolled. In + // controlled mode the prop is the source of truth — resetting the refs + // here would desync them from a prop that hasn't changed yet; instead we + // just fire the callback so the consumer can clear `collapsed`. + if (!isCollapseControlled) { + appliedCollapseRef.current = false; + preCollapseSizeRef.current = null; + setInternalCollapsed(false); + } + onCollapsedChange?.(false); + } + commitSize(initial); + }, [ + paneOrder, + collapsed, + isCollapseControlled, + onCollapsedChange, + commitSize, + ]); + + return useMemo( + () => ({ + size, + setSize, + commitSize, + orientation, + keyboardStep, + isDoubleClickDisabled, + isDisabled, + asideConfig, + paneOrder, + paneDomIds, + registerPane, + unregisterPane, + collapsed, + setCollapsed, + restoreDefaults, + }), + [ + size, + setSize, + commitSize, + orientation, + keyboardStep, + isDoubleClickDisabled, + isDisabled, + asideConfig, + paneOrder, + paneDomIds, + registerPane, + unregisterPane, + collapsed, + setCollapsed, + restoreDefaults, + ] + ); +}; diff --git a/packages/nimbus/src/components/splitter/index.ts b/packages/nimbus/src/components/splitter/index.ts new file mode 100644 index 000000000..a67b730c2 --- /dev/null +++ b/packages/nimbus/src/components/splitter/index.ts @@ -0,0 +1,5 @@ +export * from "./splitter"; +export * from "./splitter.types"; +export { useResponsiveSplitterSizes } from "./hooks/use-responsive-splitter-sizes"; +export { SPLITTER_SIZE_TOKENS } from "./utils/size-tokens"; +export type { SplitterSizeToken } from "./utils/size-tokens"; diff --git a/packages/nimbus/src/components/splitter/splitter.a11y.mdx b/packages/nimbus/src/components/splitter/splitter.a11y.mdx new file mode 100644 index 000000000..abe443041 --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.a11y.mdx @@ -0,0 +1,67 @@ +--- +tab-title: Accessibility +tab-order: 4 +--- + +## Accessibility + +Accessibility ensures that digital content and functionality are usable by +everyone, including people with disabilities, by addressing visual, auditory, +cognitive, and physical limitations. + +The Splitter implements the +[W3C window splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) +pattern on top of React Aria's `useSeparator`, `useMove`, and `useFocusRing`. +The handle is a `role="separator"` whose value reports the leading pane's size, +so screen-reader users can perceive and adjust the boundary with the keyboard. + +```jsx live +const App = () => ( + + + + + Tab to the handle, then use the arrow keys + + + + + + Main + + + + +); +``` + +### Accessibility standards + +- The handle is keyboard-reachable in DOM order and operable with the arrow + keys, Home/End, and Enter; it is removed from the tab order only when the + splitter is `isDisabled`. While the aside is collapsed, the resize keys + (arrows, Home/End) are inactive — Enter still toggles collapse — because a + collapsed aside sits below its `minSize` and has no valid in-range position to + resize to. +- The handle exposes `role="separator"` with `aria-orientation`, `aria-valuenow` + (rounded for assistive technology), `aria-valuemin`, `aria-valuemax`, + `aria-valuetext` (the size as a percentage), and `aria-controls` referencing + the leading pane. +- Provide a meaningful label: the handle ships a localized default ("Resize + panes"); override it per handle with `aria-label` or `aria-labelledby` when a + more specific label helps. +- A visible focus ring appears on keyboard focus only (via `_focusVisible`), + with contrast meeting WCAG 2.1 AA. +- The interactive hit area is at least 24×24 CSS pixels (WCAG 2.5.5), even when + the visible handle track is thinner. +- Resizing has a keyboard alternative to dragging (arrow keys, Home/End), so the + component does not rely on a dragging movement (WCAG 2.5.7). +- Nested splitters are each announced as a self-contained separator widget with + their own `aria-controls`, so assistive-technology users reason about each + boundary locally. + +### Resources + +- [W3C ARIA Authoring Practices Guide (APG) - Window Splitter Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) +- [React Aria useSeparator](https://react-spectrum.adobe.com/react-aria/useSeparator.html) +- [React Aria useMove](https://react-spectrum.adobe.com/react-aria/useMove.html) diff --git a/packages/nimbus/src/components/splitter/splitter.context.ts b/packages/nimbus/src/components/splitter/splitter.context.ts new file mode 100644 index 000000000..4926f5c5f --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.context.ts @@ -0,0 +1,15 @@ +import { createContext } from "react"; +import type { SplitterContextValue } from "./splitter.types"; + +/** + * Internal context shared between `Splitter.Root`, the pane components + * (`Splitter.Aside` / `Splitter.Main`), and `Splitter.Handle`. Carries the + * single aside `size`, role-based pane registration, commands, and the + * configuration needed by the handle to compute its keyboard behavior and ARIA + * attributes. + * + * @internal + */ +export const SplitterContext = createContext( + undefined +); diff --git a/packages/nimbus/src/components/splitter/splitter.dev.mdx b/packages/nimbus/src/components/splitter/splitter.dev.mdx new file mode 100644 index 000000000..6e509323e --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.dev.mdx @@ -0,0 +1,645 @@ +--- +title: Splitter Component +tab-title: Implementation +tab-order: 3 +--- + +## Getting started + +### Import + +```tsx +import { Splitter, type SplitterRootProps } from "@commercetools/nimbus"; +``` + +### Basic usage + +`Splitter.Root` wraps one `Splitter.Aside` and one `Splitter.Main` with a +`Splitter.Handle` between them. `Splitter.Aside` is the configurable pane you +size; `Splitter.Main` takes the remaining space. The initial split is set with +`defaultSize` — a single percentage that always refers to the aside (the main +pane is `100 − size`). + +```jsx live-dev +const App = () => ( + + + + + + Drag the handle to resize. Tab to it, then use the arrow keys. + + + + + + + Main + + + + +); +``` + +## Usage examples + +### Orientation + +`orientation="vertical"` stacks the panes and makes ArrowUp / ArrowDown the +active keys. The handle's `aria-orientation` reflects this (W3C separator +semantics describe the boundary axis, not the layout axis). + +```jsx live-dev +const App = () => ( + + + + + Aside + + + + + + Main + + + + +); +``` + +### Aside constraints + +`minSize` and `maxSize` bound the single aside dimension. `minSize` (default +`0`) is the aside's floor; `maxSize` (default `100`) caps how far the aside can +grow — which in turn fixes the main pane's floor at `100 − maxSize`. Because +there is one boundary, this single `[minSize, maxSize]` window on the aside +fully describes both sides; there is no main-specific prop. + +```jsx live-dev +const App = () => ( + + + + + Aside (min 15, max 75) + + + + + + Main (floor 25) + + + + +); +``` + +### Collapsible aside + +Set `collapsible` to let the aside collapse, and control its collapsed state +with the boolean `collapsed`. Because collapse is plain controlled state, any +control can drive it; Enter on the focused handle toggles it too. +`onCollapsedChange` fires on every transition. Only the aside collapses. + +While the aside is collapsed the handle can't be resized (drag and the arrow +keys are inactive) — a collapsed aside sits below its `minSize`, so a resize +could only snap. Reopen it with the control, Enter, or a double-click; the +collapsed state holds until you explicitly leave it. + +```jsx live-dev +const App = () => { + const [collapsed, setCollapsed] = useState(false); + + return ( + + + + + + + Aside + + + + + + Main + + + + + + ); +}; +``` + +### Collapsing to a rail + +Collapse is a two-tier model. `minSize` is the floor for _dragging_ and the +arrow keys; `collapsedSize` is a distinct discrete state that collapse snaps to, +below `minSize`. It defaults to `0` (the aside hides entirely) — set it to leave +a thin rail behind, e.g. an icon strip the user can click to expand. + +```jsx live-dev +const App = () => { + const [collapsed, setCollapsed] = useState(false); + + return ( + + + + + + + Aside + + + + + + Main + + + + + + ); +}; +``` + +### Uncontrolled collapse + +When you don't need to drive collapse from outside the splitter, seed the +initial collapsed state with `defaultCollapsed` and let the splitter own it. +Enter on the handle toggles it from there, and double-click restores the default +split. + +```jsx live-dev +const App = () => ( + + + + + Aside (starts collapsed) + + + + + + Tab to the handle and press Enter to toggle + + + + +); +``` + +### Keyboard step + +Arrow keys move the boundary by `keyboardStep` percentage points (default `5`); +Home / End jump it to the bounds. Accepts floats for finer control. + +```jsx live-dev +const App = () => ( + + + + + + Tab to the handle — each arrow press moves the boundary 10% + + + + + + + Main + + + + +); +``` + +### Restoring the default split + +Double-click the handle to restore the boundary to the sizes resolved on mount. +Set `isDoubleClickDisabled` to turn that off; drag and keyboard stay active. + +```jsx live-dev +const App = () => ( + + + + + + Drag, then double-click the handle to restore + + + + + + + + + + + + + Double-click restore disabled + + + + + + + + + +); +``` + +### Disabled state + +`isDisabled` makes the whole splitter non-interactive: the handle leaves the tab +order, gets `aria-disabled`, and ignores drag, keyboard, and collapse input. + +```jsx live-dev +const App = () => ( + + + + + Aside + + + + + + Main + + + + +); +``` + +### Persistence + +Size is uncontrolled by default, so persist it in your app with any storage: +hydrate `defaultSize` from stored state and write the settled value back in +`onSizeChangeEnd` (it fires once per settled interaction — drag end, each +keypress, collapse/expand, double-click restore — so no debouncing is needed). A +single `number` round-trips: the value `onSizeChangeEnd` emits is exactly the +shape `defaultSize` accepts. Collapse persists through its controlled boolean +state. + +```tsx +// `useLocalStorage` is any storage hook of your choosing. +const [size, setSize] = useLocalStorage("ide-layout", 30); +const [collapsed, setCollapsed] = useLocalStorage("ide-collapsed", false); + + + + + +; +``` + +`onSizeChange` (live, every drag tick ~60Hz) is also available when you need a +real-time read-out; prefer `onSizeChangeEnd` for persistence. + +### Controlled size + +Pass `size` instead of `defaultSize` to drive the layout from outside — useful +for responsive layouts that swap the proportion per breakpoint, since external +changes apply in place (no remount, so pane content keeps its scroll and focus). +Control is settled, not live: drag and keyboard stay smooth from internal state +and report once via `onSizeChangeEnd`. Wire that callback and feed the value +back, or the splitter keeps the last interactive size and behaves as +uncontrolled from then on. + +```tsx +const [size, setSize] = useState(30); + + + + + +; +``` + +### Pixel-precise sizes + +The aside percentage carries full float precision end-to-end (nothing in the +size pipeline is rounded — only the handle's `aria-valuenow` is rounded, for +assistive technology). To land on an exact pixel at a known container width, +pass the computed float — `250px` in an `800px` container is `31.25`. + +```jsx live-dev +const App = () => ( + + + + + 31.25% + + + + + + 68.75% + + + + +); +``` + +### Responsive pixel & token sizes with `useResponsiveSplitterSizes` + +`Splitter.Root` is percentage-native — it has no pixel code path. When you'd +rather think in pixels (a fixed-width sidebar, an icon rail) or size per device, +reach for the companion hook `useResponsiveSplitterSizes`. It is a **pixel/token +→ percentage translator**: it measures the splitter's container, converts your +config to the percentage the component wants, drives the controlled `size` +channel, and persists the result — all without the component gaining a pixel +path. + +Spread its `rootProps` onto `Splitter.Root` and attach the `ref` it returns (the +`ref` is required — the hook measures the container through it): + +```tsx +const { rootProps } = useResponsiveSplitterSizes({ + orientation: "horizontal", + persistKey: "app:main-splitter", + size: "xs", // 320px — size tokens resolve to pixels + minSize: "3xs", // 224px + maxSize: "lg", // 512px +}); + + + + + +; +``` + +**Units.** A value is a `number` (pixels), a size token (`3xs`–`8xl` or +`breakpoint-sm`…`breakpoint-2xl`, resolving to pixels), or a `"N%"` string +(passed through untranslated). Note the contrast with `Splitter.Root`'s own +`size` / `minSize` / … props, which are **percentages**: through the hook, a +bare number means pixels. Because the hook owns the full facade (`size` plus +`minSize` / `maxSize` / `collapsedSize`), you don't hand-write percentages on +the root when you use it. + +**Responsive by container width.** Any dimension can be a map keyed by container +**min-width thresholds** (pixels or tokens) — a min-width cascade resolved +against the splitter's own width, not the viewport. The largest threshold `≤` +the measured width wins; the smallest entry also applies below it. + +```tsx +const { rootProps } = useResponsiveSplitterSizes({ + size: { 0: "xs", "breakpoint-md": "30%" }, // "xs" (320px) below 768px, 30% above +}); +``` + +**Persistence.** Pass a `persistKey` (and optionally a `storage` adapter, +defaulting to `localStorage`) and the hook stores the settled size **in pixels** +per band, so a dragged `320px` re-pins to `320px` across reloads and container +resizes. `collapsedSize` is static config and is never persisted; the latest +expanded size survives collapse and expand. + +### Three or more regions + +The Splitter is two-pane by design. For additional regions, nest a Splitter +inside a pane's children; each splitter owns its own state independently. The +inner aside can sit on either side of its main pane — here it trails as a right +panel. + +```jsx live-dev +const App = () => ( + + + + + Aside (outer) + + + + + + + + Main + + + + + + Aside (inner) + + + + + + +); +``` + +## Common patterns + +### IDE-style layout + +The most common real-world composition: a persisted split (`defaultSize` + +`onSizeChangeEnd`) with a `collapsible` aside that snaps to a rail +(`collapsedSize`), driven by both a toolbar button and the handle, plus aside +bounds and a specific handle label. + +```jsx live-dev +const App = () => { + const [size, setSize] = useState(22); + const [collapsed, setCollapsed] = useState(false); + + return ( + + + + + + + Aside + + + + + + Aside + + + + + + ); +}; +``` + +### Vertical with persistence + +A vertical split with a persisted boundary and aside bounds so neither region +collapses to nothing while dragging. + +```jsx live-dev +const App = () => { + const [size, setSize] = useState(60); + + return ( + + + + + Aside + + + + + + Main + + + + + ); +}; +``` + +## Component requirements + +### Structure + +- `Splitter.Root` must contain exactly one `Splitter.Aside` and one + `Splitter.Main` with one `Splitter.Handle` between them; a development-time + warning is emitted otherwise. The aside may be placed before or after the main + pane (a leading or trailing panel) — `size` always refers to the aside. +- All sizing and collapse configuration (`defaultSize` / `size`, `minSize`, + `maxSize`, `collapsible`, `collapsedSize`) lives on `Splitter.Root`. Panes + take only their content and an optional `id` for analytics/testing — nothing + is configured on the pane itself. + +## Accessibility + +The Splitter handles the W3C window-splitter semantics internally: the handle is +`role="separator"` with `aria-orientation`, `aria-valuenow` / `aria-valuemin` / +`aria-valuemax`, `aria-valuetext`, and `aria-controls` pointing at the leading +pane. For the full conformance details, see the Accessibility tab. + +#### Labeling + +The handle ships a localized default `aria-label` ("Resize panes"); override it +per handle with `aria-label` or `aria-labelledby` for a more specific label: + +```tsx + +``` + +#### Persistent ID + +If your use case requires tracking and analytics, it is good practice to add a +**persistent**, **unique** id to the handle: + +```tsx +const PERSISTENT_ID = "ide-layout-splitter-handle"; + +export const Example = () => ( + + + + + +); +``` + +#### Keyboard navigation + +When the handle has focus: + +- `Tab` / `Shift+Tab`: Move focus to/from the handle (in DOM order). +- `ArrowLeft` / `ArrowRight` (horizontal) or `ArrowUp` / `ArrowDown` (vertical): + Move the boundary by `keyboardStep` percentage points (inactive while the + aside is collapsed). +- `Home` / `End`: Jump the boundary to the aside's minimum / maximum (inactive + while the aside is collapsed). +- `Enter`: Toggle collapse of the aside (when `collapsible`). + +## API reference + + + +## Testing your implementation + +These examples demonstrate how to test your implementation when using Splitter +in your application. As the component's internal functionality is already tested +by Nimbus, these patterns help you verify your integration and +application-specific logic. + +{{docs-tests: splitter.docs.spec.tsx}} + +## Resources + +- [Storybook](https://nimbus-storybook.vercel.app/?path=/docs/components-splitter--docs) +- [React Aria useSeparator](https://react-spectrum.adobe.com/react-aria/useSeparator.html) +- [W3C ARIA window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) diff --git a/packages/nimbus/src/components/splitter/splitter.docs.spec.tsx b/packages/nimbus/src/components/splitter/splitter.docs.spec.tsx new file mode 100644 index 000000000..ed50c8575 --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.docs.spec.tsx @@ -0,0 +1,195 @@ +import { describe, it, expect } from "vitest"; +import { useState } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NimbusProvider, Splitter } from "@commercetools/nimbus"; + +/** + * @docs-section basic-rendering + * @docs-title Basic Rendering + * @docs-description Minimal Splitter — a configurable aside and a main pane — + * wired up in a consumer app. + * @docs-order 1 + */ +describe("Splitter - Basic rendering", () => { + it("renders an aside, a main pane, and one handle", async () => { + render( + + + Aside + + Main + + + ); + + const handle = await screen.findByRole("separator"); + expect(handle).toBeInTheDocument(); + }); +}); + +/** + * @docs-section persistence + * @docs-title Persistence with any storage + * @docs-description Hydrate `defaultSize` from stored state and persist the + * settled value via `onSizeChangeEnd` — a single number, no bespoke hook. + * @docs-order 2 + */ +describe("Splitter - persistence", () => { + it("hydrates from the stored size on first render", async () => { + // Stand-in for a `useLocalStorage`-style hook seeded from storage. + const Demo = () => { + const [size, setSize] = useState(25); + return ( + + Aside + + Main + + ); + }; + + render( + + + + ); + + const handle = await screen.findByRole("separator"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(25); + }); + }); +}); + +/** + * @docs-section controlled-size + * @docs-title Controlled size from anywhere + * @docs-description Drive the layout with the `size` prop and update it from + * outside the splitter — changes apply in place (no remount), so stateful pane + * content is preserved. Wire `onSizeChangeEnd` to keep it controlled after a + * drag. + * @docs-order 2.5 + */ +describe("Splitter - controlled size", () => { + it("reflects an external size change in place", async () => { + const user = userEvent.setup(); + const Demo = () => { + const [size, setSize] = useState(30); + return ( + <> + + + Aside + + Main + + + ); + }; + + render( + + + + ); + + const handle = await screen.findByRole("separator"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(30); + }); + + await user.click(screen.getByText("widen-aside")); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(60); + }); + }); +}); + +/** + * @docs-section controlled-collapse + * @docs-title Controlled collapse from anywhere + * @docs-description Collapse is plain controlled boolean state, so a button + * outside the splitter can toggle the aside — no imperative API. + * @docs-order 3 + */ +describe("Splitter - controlled collapse", () => { + it("collapses the aside from a button outside the subtree", async () => { + const user = userEvent.setup(); + const Demo = () => { + const [collapsed, setCollapsed] = useState(false); + return ( + <> + + + Aside + + Main + + + ); + }; + + render( + + + + ); + + await user.click(screen.getByText("toggle-aside")); + const handle = await screen.findByRole("separator"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(0); + }); + }); +}); + +/** + * @docs-section nesting + * @docs-title Nested splitters for 3+ regions + * @docs-description Each nested Splitter is an independent widget. + * @docs-order 4 + */ +describe("Splitter - Nested", () => { + it("nests inside a pane to express three regions", async () => { + render( + + + Aside + + + + Main + + Aside + + + + + ); + + const handles = await screen.findAllByRole("separator"); + expect(handles).toHaveLength(2); + }); +}); diff --git a/packages/nimbus/src/components/splitter/splitter.i18n.ts b/packages/nimbus/src/components/splitter/splitter.i18n.ts new file mode 100644 index 000000000..e0221ba61 --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.i18n.ts @@ -0,0 +1,8 @@ +export const messages = { + resizePanes: { + id: "Nimbus.Splitter.resizePanes", + description: + "Default aria-label for the Splitter handle that users drag to redistribute space between the two panes.", + defaultMessage: "Resize panes", + }, +}; diff --git a/packages/nimbus/src/components/splitter/splitter.mdx b/packages/nimbus/src/components/splitter/splitter.mdx new file mode 100644 index 000000000..b3098173d --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.mdx @@ -0,0 +1,163 @@ +--- +id: Components-Splitter +title: Splitter +exportName: Splitter +description: + A two-pane compound primitive for user-resizable layouts with a draggable, + keyboard-operable handle. +lifecycleState: Beta +order: 999 +menu: + - Components + - Layout + - Splitter +tags: + - component + - layout + - resizable +figmaLink: >- + https://www.figma.com/design/gHbAJGfcrCv7f2bgzUQgHq/NIMBUS-Guidelines?node-id=1793-8790&m=dev +--- + +## Overview + +The Splitter lets people resize two adjacent regions by dragging the handle +between them — a sidebar next to a content area, a list next to a detail view, +an editor next to a preview. It is intentionally a **two-pane** primitive: the +boundary is a single position, which keeps the interaction predictable and the +accessibility model clean. Layouts with three or more regions are composed by +**nesting** one Splitter inside another's pane. + +### Resources + +Deep dive into implementation details and access the Nimbus design library. + +- [Figma library](https://www.figma.com/design/gHbAJGfcrCv7f2bgzUQgHq/NIMBUS-Guidelines?node-id=1793-8790&m=dev) +- [W3C ARIA window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) + +## Variables + +Get familiar with the visual options. + +### Orientation + +`horizontal` (default) places the panes side by side with a vertical handle; +`vertical` stacks them with a horizontal handle. The handle is keyboard operable +in both orientations. + +```jsx live +const App = () => ( + + + + + + Left + + + + + + Right + + + + + + + + + Top + + + + + + Bottom + + + + + +); +``` + +### Collapse + +The aside can be made collapsible so a region — usually a sidebar or panel — can +be hidden and brought back. Collapsing animates the boundary to the aside's +collapsed size: fully hidden by default, or a thin rail when a collapsed size is +set. The freed space goes to the main pane. Surface a visible control (a button) +to toggle it; on the handle, Enter toggles collapse and double-click restores +the default split. + +```jsx live +const App = () => { + const [collapsed, setCollapsed] = useState(false); + + return ( + + + + + + + Nav + + + + + + Main + + + + + + ); +}; +``` + +## Guidelines + +### Best practices + +- **Two panes per Splitter.** Nest splitters for layouts with three or more + regions; each nested splitter is independently focusable and announced to + assistive technology as its own widget. +- **Bound the aside.** Set `minSize` and `maxSize` so dragging can't shrink the + aside — or, via `maxSize`, the main pane — below its usable content width. Pair + the bounds with content that scrolls inside the pane. +- **Use collapsing for chrome.** A navigation or panel the user may want hidden + should be the `collapsible` aside; surface a visible control (a button) for the + mouse, since double-click is reserved for restoring the default split. +- **Persist the layout** when the split is part of the user's workflow, so it + survives across sessions. + +### When to use + +> [!TIP]\ +> Use when + +- Side-by-side content the user benefits from resizing themselves. +- App shells that pair a flexible navigation with a main content area. +- Master/detail views where the relative split is part of the workflow. + +### When not to use + +> [!CAUTION]\ +> When not to use + +- Static two-column layouts — use a flex or grid layout directly. +- Narrow, mobile-first viewports — dragging is awkward; use a Drawer or stack + the regions vertically. +- More than two peer regions in one splitter — nest splitters instead. diff --git a/packages/nimbus/src/components/splitter/splitter.recipe.ts b/packages/nimbus/src/components/splitter/splitter.recipe.ts new file mode 100644 index 000000000..4f38219e5 --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.recipe.ts @@ -0,0 +1,117 @@ +import { defineSlotRecipe } from "@chakra-ui/react/styled-system"; + +/** + * Recipe configuration for the Splitter component. + * + * Slots: + * - `root` — flex container holding the two panes. Position-relative so the + * absolutely-positioned handle can sit on the boundary. + * - `pane` — a resizable region; size is driven from props via inline style. + * The two panes together fill 100% of the root. + * - `handle` — interactive separator the user drags. Positioned absolutely on + * the boundary between the panes (no flex track, so the panes' content + * touches edge-to-edge). The visible track stays transparent until hover or + * keyboard focus to keep the resting layout uncluttered. Hit area is + * expanded via a `_before` pseudo-element so the handle always meets the + * 24×24 CSS-pixel touch target (WCAG 2.5.5) even when the visible track is + * thinner than that. + * + * Variants: + * - `orientation`: `horizontal` (default) | `vertical` — flex direction + + * axis-specific handle dimensions. The handle track has a single fixed + * thickness (no `size` variant); future visual variants can be added here. + * + * @see https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/ + */ +export const splitterSlotRecipe = defineSlotRecipe({ + className: "nimbus-splitter", + slots: ["root", "pane", "handle"], + base: { + root: { + display: "flex", + position: "relative", + width: "100%", + height: "100%", + overflow: "hidden", + }, + pane: { + overflow: "auto", + minWidth: 0, + minHeight: 0, + }, + handle: { + position: "absolute", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", + transitionProperty: "background-color", + transitionDuration: "fast", + zIndex: 1, + // Expand the interactive hit area to at least 24x24 CSS pixels so the + // handle meets WCAG 2.5.5 even when the visible track is thinner. + _before: { + content: '""', + position: "absolute", + top: "50%", + left: "50%", + minWidth: "600", + minHeight: "600", + width: "100%", + height: "100%", + transform: "translate(-50%, -50%)", + }, + _hover: { + backgroundColor: "neutral.6", + }, + _focusVisible: { + layerStyle: "focusRing", + backgroundColor: "neutral.6", + }, + "&[data-disabled='true']": { + // The handle track is invisible at rest, so a disabled affordance + // (reduced opacity / not-allowed cursor) has nothing to attach to and + // would only surface a misleading cursor. Just neutralize the resize + // cursor and keep the track from appearing on hover. + cursor: "default", + _hover: { backgroundColor: "transparent" }, + }, + "&[data-resize-locked='true']": { + // While a pane is collapsed the handle can't resize (see + // splitter.handle.tsx). Drop the resize cursor so the affordance matches + // the behavior; the track still shows on hover/focus so the handle stays + // discoverable for Enter (toggle) and double-click (restore). + cursor: "default", + }, + }, + }, + variants: { + orientation: { + horizontal: { + root: { flexDirection: "row" }, + handle: { + top: 0, + height: "100%", + // Single fixed handle-track thickness (the former `md`). + width: "200", + cursor: "col-resize", + transform: "translateX(-50%)", + }, + }, + vertical: { + root: { flexDirection: "column" }, + handle: { + left: 0, + width: "100%", + // Single fixed handle-track thickness (the former `md`). + height: "200", + cursor: "row-resize", + transform: "translateY(-50%)", + }, + }, + }, + }, + defaultVariants: { + orientation: "horizontal", + }, +}); diff --git a/packages/nimbus/src/components/splitter/splitter.slots.tsx b/packages/nimbus/src/components/splitter/splitter.slots.tsx new file mode 100644 index 000000000..000d1ab56 --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.slots.tsx @@ -0,0 +1,46 @@ +import { + createSlotRecipeContext, + type HTMLChakraProps, + type RecipeVariantProps, +} from "@chakra-ui/react/styled-system"; +import type { splitterSlotRecipe } from "./splitter.recipe"; + +const { withProvider, withContext } = createSlotRecipeContext({ + key: "nimbusSplitter", +}); + +// ============================================================ +// Root Slot +// ============================================================ + +export type SplitterRootSlotProps = HTMLChakraProps< + "div", + RecipeVariantProps +>; + +export const SplitterRootSlot = withProvider< + HTMLDivElement, + SplitterRootSlotProps +>("div", "root"); + +// ============================================================ +// Pane Slot +// ============================================================ + +export type SplitterPaneSlotProps = HTMLChakraProps<"div">; + +export const SplitterPaneSlot = withContext< + HTMLDivElement, + SplitterPaneSlotProps +>("div", "pane"); + +// ============================================================ +// Handle Slot +// ============================================================ + +export type SplitterHandleSlotProps = HTMLChakraProps<"div">; + +export const SplitterHandleSlot = withContext< + HTMLDivElement, + SplitterHandleSlotProps +>("div", "handle"); diff --git a/packages/nimbus/src/components/splitter/splitter.stories.tsx b/packages/nimbus/src/components/splitter/splitter.stories.tsx new file mode 100644 index 000000000..2091ef5c3 --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.stories.tsx @@ -0,0 +1,1334 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; +import { Box, Button, Heading, ScrollArea, Stack } from "@commercetools/nimbus"; +import { + userEvent, + within, + expect, + fn, + waitFor, + fireEvent, +} from "storybook/test"; +import { Splitter } from "./splitter"; +import { useResponsiveSplitterSizes } from "./hooks/use-responsive-splitter-sizes"; + +/** + * Pane content wrapped in a ScrollArea so overflow stays inside the pane and + * doesn't push the splitter layout around. + */ +const DemoPane = ({ + bg, + title, + children, +}: { + bg: string; + title: string; + children?: ReactNode; +}) => ( + + + + {title} + + {children} + + +); + +const meta: Meta = { + title: "Components/Splitter", + component: Splitter.Root, + parameters: { + layout: "fullscreen", + }, + argTypes: { + orientation: { + control: { type: "radio" }, + options: ["horizontal", "vertical"], + }, + defaultSize: { + control: { type: "range", min: 0, max: 100, step: 1 }, + }, + keyboardStep: { + control: { type: "range", min: 1, max: 20, step: 1 }, + }, + collapsible: { control: { type: "boolean" } }, + isDoubleClickDisabled: { control: { type: "boolean" } }, + isDisabled: { control: { type: "boolean" } }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================================================ +// Default (horizontal, aside 30 / main 70) +// ============================================================ + +export const Default: Story = { + args: { + orientation: "horizontal", + defaultSize: 30, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + await expect(handle).toBeInTheDocument(); + await expect(handle).toHaveAttribute("aria-orientation", "horizontal"); + await expect(handle).toHaveAttribute("aria-valuemin", "0"); + await expect(handle).toHaveAttribute("aria-valuemax", "100"); + await waitFor(() => { + expect(handle).toHaveAttribute("aria-valuenow", "30"); + }); + await expect(handle).toHaveAttribute("aria-valuetext", "30%"); + }, +}; + +// ============================================================ +// Vertical orientation +// ============================================================ + +export const Vertical: Story = { + args: { + orientation: "vertical", + defaultSize: 40, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + await expect(handle).toHaveAttribute("aria-orientation", "vertical"); + }, +}; + +// ============================================================ +// Aside trailing — the aside can be placed after the main pane (a right/bottom +// panel). `size` still refers to the aside; the handle's aria value tracks the +// leading (main) pane. +// ============================================================ + +export const AsideTrailing: Story = { + args: { + orientation: "horizontal", + defaultSize: 30, + minSize: 10, + maxSize: 80, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + + // Aside is 30% on the right; the leading (main) pane is 70%, which the + // handle's aria value tracks. + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(70); + }); + + // aria-controls points at the leading (main) pane. + const ariaControls = handle.getAttribute("aria-controls"); + const mainPane = canvasElement.querySelector(`#${ariaControls!}`); + await expect(mainPane!.textContent).toContain("Main"); + + // Bounds map onto the leading pane: aside ∈ [10, 80] → main ∈ [20, 90]. + await expect(handle).toHaveAttribute("aria-valuemin", "20"); + await expect(handle).toHaveAttribute("aria-valuemax", "90"); + }, +}; + +// ============================================================ +// Keyboard interaction (arrows + Home/End) — also asserts the +// settled-change channel (onSizeChangeEnd) fires per keypress. +// ============================================================ + +export const KeyboardInteraction: Story = { + args: { + orientation: "horizontal", + keyboardStep: 5, + defaultSize: 30, + minSize: 10, + maxSize: 80, + onSizeChange: fn(), + onSizeChangeEnd: fn(), + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + handle.focus(); + await expect(handle).toHaveFocus(); + + await userEvent.keyboard("{ArrowRight}"); + await expect(args.onSizeChange).toHaveBeenCalled(); + await expect(args.onSizeChangeEnd).toHaveBeenCalled(); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(35); + }); + + await userEvent.keyboard("{ArrowLeft}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(30); + }); + + // Home → aside shrinks to its minSize. + await userEvent.keyboard("{Home}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(10); + }); + + // End → aside grows to its maxSize. + await userEvent.keyboard("{End}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(80); + }); + }, +}; + +// ============================================================ +// Pointer drag (useMove) — dragging the handle resizes the panes and fires +// the live (onSizeChange) + settled (onSizeChangeEnd) channels. Covers the +// pointer path that the keyboard / collapse stories don't exercise. +// ============================================================ + +export const PointerDragResize: Story = { + args: { + orientation: "horizontal", + defaultSize: 30, + minSize: 10, + maxSize: 90, + onSizeChange: fn(), + onSizeChangeEnd: fn(), + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(30); + }); + + const rect = handle.getBoundingClientRect(); + const startX = rect.x + rect.width / 2; + const y = rect.y + rect.height / 2; + + // Press on the handle, drag right in steps, release. react-aria's useMove + // matches events by a single pointerId and derives the delta from pageX + // (=== clientX at scroll 0), so the sequence shares one pointerId and + // carries changing client coords. fireEvent gives that precise control; + // userEvent.pointer doesn't supply a stable pageX, so the delta reads 0. + const pointer = { pointerId: 1, pointerType: "mouse", button: 0 }; + fireEvent.pointerDown(handle, { ...pointer, clientX: startX, clientY: y }); + fireEvent.pointerMove(document, { + ...pointer, + clientX: startX + 60, + clientY: y, + }); + fireEvent.pointerMove(document, { + ...pointer, + clientX: startX + 120, + clientY: y, + }); + fireEvent.pointerUp(document, { + ...pointer, + clientX: startX + 120, + clientY: y, + }); + + // Live channel fired and the boundary moved right (aside grew past 30%). + await waitFor(() => { + expect(args.onSizeChange).toHaveBeenCalled(); + expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(30); + }); + + // Drag stayed within the announced upper bound. + const now = Number(handle.getAttribute("aria-valuenow")); + expect(now).toBeLessThanOrEqual( + Number(handle.getAttribute("aria-valuemax")) + ); + + // Settled channel fired on pointer release (the persistence seam). + await waitFor(() => { + expect(args.onSizeChangeEnd).toHaveBeenCalled(); + }); + }, +}; + +// ============================================================ +// Size constraints — `minSize` / `maxSize` bound the single aside dimension. +// `maxSize` also fixes the main pane's floor (`100 − maxSize`). +// ============================================================ + +export const SizeConstraints: Story = { + args: { + orientation: "horizontal", + keyboardStep: 100, + defaultSize: 30, + minSize: 15, + maxSize: 75, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + + // aria-valuemin = minSize; aria-valuemax = maxSize. + await expect(handle).toHaveAttribute("aria-valuemin", "15"); + await expect(handle).toHaveAttribute("aria-valuemax", "75"); + + handle.focus(); + // Large jump right → clamps at maxSize (75). + await userEvent.keyboard("{End}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(75); + }); + // Large jump left → clamps at minSize (15). + await userEvent.keyboard("{Home}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(15); + }); + }, +}; + +// ============================================================ +// isDisabled — the whole splitter is non-interactive; the handle is +// removed from the tab order and announces aria-disabled. +// ============================================================ + +export const Disabled: Story = { + args: { + isDisabled: true, + defaultSize: 30, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + await expect(handle).toHaveAttribute("tabindex", "-1"); + await expect(handle).toHaveAttribute("aria-disabled", "true"); + }, +}; + +// ============================================================ +// ARIA — aria-controls points at the leading pane's DOM id. +// ============================================================ + +export const AriaControlsAttribute: Story = { + args: { + defaultSize: 30, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + const ariaControls = handle.getAttribute("aria-controls"); + await expect(ariaControls).toBeTruthy(); + + // aria-controls should point at the leading pane (first DOM sibling = aside). + const asidePane = canvasElement.querySelector(`#${ariaControls!}`); + await expect(asidePane).toBeTruthy(); + await expect(asidePane!.textContent).toContain("Aside"); + }, +}; + +// ============================================================ +// Double-click restores the boundary to its initial position. +// ============================================================ + +export const DoubleClickRestoresDefaults: Story = { + args: { + defaultSize: 30, + minSize: 10, + maxSize: 90, + onSizeChangeEnd: fn(), + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + + // Move the boundary via keyboard so the story has a non-default state. + handle.focus(); + await userEvent.keyboard("{End}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(30); + }); + + await userEvent.dblClick(handle); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(30); + }); + expect(args.onSizeChangeEnd).toHaveBeenCalled(); + }, +}; + +// ============================================================ +// Double-click restores correctly even when the initial size is 0 +// (regression guard for the falsy restore guard). +// ============================================================ + +export const RestoreDefaultsWithZeroSize: Story = { + args: { + defaultSize: 0, + minSize: 0, + maxSize: 100, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(0); + }); + + // Grow the aside off 0, then double-click to restore back to 0. + handle.focus(); + await userEvent.keyboard("{End}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(0); + }); + + await userEvent.dblClick(handle); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(0); + }); + }, +}; + +// ============================================================ +// Float-precision — fractional percentages are applied unrounded. +// ============================================================ + +export const FloatPrecision: Story = { + args: { + defaultSize: 31.25, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + + // aria-valuenow rounds for AT, but the applied layout keeps full precision. + // waitFor: aria-valuenow depends on pane registration (useEffect), which may + // not have run by the time findByRole returns the handle element. + await waitFor(() => { + expect(handle).toHaveAttribute("aria-valuenow", "31"); + }); + const ariaControls = handle.getAttribute("aria-controls"); + const asidePane = canvasElement.querySelector( + `#${ariaControls!}` + ); + await expect(asidePane).toBeTruthy(); + await waitFor(() => { + expect(asidePane!.style.width).toBe("31.25%"); + }); + }, +}; + +// ============================================================ +// Enter on focused handle toggles aside collapse (uncontrolled). +// ============================================================ + +export const CollapsibleByKeyboard: Story = { + args: { + defaultSize: 30, + minSize: 10, + maxSize: 80, + collapsible: true, + onCollapsedChange: fn(), + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + handle.focus(); + + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(args.onCollapsedChange).toHaveBeenCalledWith(true); + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(0); + }); + + // Regression: bounds are collapse-aware, so the collapsed value (0) stays + // within [valuemin, valuemax]. The aside is collapsible → valuemin = + // min(10, 0) = 0; maxSize 80 → valuemax = 80. + const min = Number(handle.getAttribute("aria-valuemin")); + const max = Number(handle.getAttribute("aria-valuemax")); + const now = Number(handle.getAttribute("aria-valuenow")); + expect(min).toBe(0); + expect(max).toBe(80); + expect(now).toBeGreaterThanOrEqual(min); + expect(now).toBeLessThanOrEqual(max); + + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(args.onCollapsedChange).toHaveBeenCalledWith(false); + expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(0); + }); + }, +}; + +// ============================================================ +// Controlled collapse — a button outside the splitter drives `collapsed`. +// ============================================================ + +const ControlledCollapseComponent = ({ + onChange, +}: { + onChange?: (collapsed: boolean) => void; +}) => { + const [collapsed, setCollapsed] = useState(false); + return ( + + + + + + + + + + + + + ); +}; + +export const ControlledCollapse: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + const button = canvas.getByTestId("toggle-btn"); + + await userEvent.click(button); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(0); + }); + + // ScrollArea marks an overflowing viewport keyboard-focusable + // asynchronously after the abrupt resize; wait for it to settle so the + // a11y afterEach sees the accessible state. + await waitFor(() => { + const navViewport = canvas + .getByText("Aside") + .closest('[data-part="viewport"]'); + expect(navViewport).toHaveAttribute("tabindex", "0"); + }); + + await userEvent.click(button); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(0); + }); + }, +}; + +// ============================================================ +// Controlled collapse + keyboard + double-click restore — restore must land on +// the mount-time size (30), not the pre-collapse size (50), even though collapse +// is controlled. Regression for the controlled-restore path. +// ============================================================ + +const ControlledRestoreComponent = () => { + const [collapsed, setCollapsed] = useState(false); + return ( + + + + + + + + + + + + ); +}; + +export const ControlledCollapseRestore: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(30); + }); + + // Move the boundary off its mount default (30 → 50) via keyboard. + handle.focus(); + await userEvent.keyboard( + "{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}" + ); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(50); + }); + + // Collapse the aside (controlled — Enter drives onCollapsedChange → state). + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(0); + }); + + // Double-click restores to the mount-time size (30), not pre-collapse 50. + await userEvent.dblClick(handle); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(30); + }); + }, +}; + +// ============================================================ +// Controlled size — a button outside the splitter sets `size`; the layout +// updates in place (no remount), so stateful pane content is preserved. Drag / +// keyboard stay live internally and settle through onSizeChangeEnd. +// ============================================================ + +const ControlledSizeComponent = () => { + const [size, setSize] = useState(30); + return ( + + + + + + {/* Uncontrolled input: its value survives only if the pane is not + remounted when `size` changes in place. */} + + + + + + + + + + ); +}; + +export const ControlledSize: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(30); + }); + + // Give the pane's input state that a remount would lose. + const input = canvas.getByTestId("aside-input"); + await userEvent.type(input, "preserve-me"); + expect(input).toHaveValue("preserve-me"); + + // External control sets the size; the layout reflects it in place. + await userEvent.click(canvas.getByTestId("set-size-btn")); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(60); + }); + + // The input kept its value → the pane was not remounted. + expect(canvas.getByTestId("aside-input")).toHaveValue( + "preserve-me" + ); + }, +}; + +// ============================================================ +// Resize is locked while the aside is collapsed — drag + arrow/Home/End do +// nothing and the aside stays at collapsedSize. A visible "Collapse / expand +// nav" button drives the collapse (controlled) so the lock is verifiable by +// hand; it's reopened via that button, Enter, double-click, or the prop — +// never by resizing. +// ============================================================ + +const ResizeLockedWhileCollapsedComponent = ({ + onSizeChange, + onSizeChangeEnd, + onCollapsedChange, +}: { + onSizeChange?: (size: number) => void; + onSizeChangeEnd?: (size: number) => void; + onCollapsedChange?: (collapsed: boolean) => void; +}) => { + const [collapsed, setCollapsed] = useState(false); + // The button and the splitter's own toggle (Enter / double-click) both route + // through here, so the visible control and the keyboard path stay in sync. + const updateCollapsed = (next: boolean) => { + setCollapsed(next); + onCollapsedChange?.(next); + }; + return ( + + + + + + + + + + + + + ); +}; + +export const ResizeLockedWhileCollapsed: Story = { + args: { + onSizeChange: fn(), + onSizeChangeEnd: fn(), + onCollapsedChange: fn(), + }, + render: (args) => ( + + ), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + const toggle = canvas.getByTestId("toggle-btn"); + + // The fn() mocks are typed as the bare callback signatures on args, so cast + // to the mock type to read call state. + const onSizeChange = args.onSizeChange as unknown as ReturnType; + const onSizeChangeEnd = args.onSizeChangeEnd as unknown as ReturnType< + typeof fn + >; + const onCollapsedChange = args.onCollapsedChange as unknown as ReturnType< + typeof fn + >; + + // Collapse the aside to its rail (collapsedSize 6) via the visible button. + await userEvent.click(toggle); + await waitFor(() => { + expect(onCollapsedChange).toHaveBeenCalledWith(true); + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(6); + }); + + const collapsedNow = Number(handle.getAttribute("aria-valuenow")); + // The handle advertises the locked state (drives the recipe's cursor reset). + expect(handle).toHaveAttribute("data-resize-locked", "true"); + + onSizeChange.mockClear(); + onSizeChangeEnd.mockClear(); + onCollapsedChange.mockClear(); + + // Keyboard resize is locked: arrows / Home / End do nothing while collapsed. + handle.focus(); + await userEvent.keyboard("{ArrowRight}{ArrowRight}{End}{Home}"); + + // Pointer drag is locked too (same fireEvent technique as PointerDragResize). + const rect = handle.getBoundingClientRect(); + const startX = rect.x + rect.width / 2; + const y = rect.y + rect.height / 2; + const pointer = { pointerId: 1, pointerType: "mouse", button: 0 }; + fireEvent.pointerDown(handle, { ...pointer, clientX: startX, clientY: y }); + fireEvent.pointerMove(document, { + ...pointer, + clientX: startX + 120, + clientY: y, + }); + fireEvent.pointerUp(document, { + ...pointer, + clientX: startX + 120, + clientY: y, + }); + + // Nothing moved, nothing fired, and the aside is still collapsed. + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(collapsedNow); + }); + expect(onSizeChange).not.toHaveBeenCalled(); + expect(onSizeChangeEnd).not.toHaveBeenCalled(); + expect(onCollapsedChange).not.toHaveBeenCalled(); + + // The button reopens it — the lock blocks resizing, not the collapse toggle. + await userEvent.click(toggle); + await waitFor(() => { + expect(onCollapsedChange).toHaveBeenCalledWith(false); + expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(6); + }); + }, +}; + +// ============================================================ +// Persistence — hydrate from any storage; persist on onSizeChangeEnd. +// ============================================================ + +const PersistenceComponent = () => { + // Stand-in for a useLocalStorage-style hook: state seeded from "storage". + const [size, setSize] = useState(25); + return ( + + {`stored aside: ${Math.round(size)}`} + + + + + + + + + + + ); +}; + +export const Persistence: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + // Hydrated from "storage" (25%), not a 50/50 fallback. + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(25); + }); + + // Move the boundary; the settled value is written back to storage state. + handle.focus(); + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(canvas.getByTestId("stored-aside").textContent).not.toBe( + "stored aside: 25" + ); + }); + }, +}; + +// ============================================================ +// Nested splitters — 3 regions via nesting. The inner splitter places its +// aside on the trailing side (a right panel). +// ============================================================ + +export const NestedSplitters: Story = { + render: () => ( + + + + + + + + + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handles = await canvas.findAllByRole("separator"); + // Each nested splitter contributes one handle. + await expect(handles.length).toBe(2); + }, +}; + +// ============================================================ +// isDoubleClickDisabled — handle ignores double-click; keyboard unaffected. +// ============================================================ + +export const DisableDoubleClick: Story = { + args: { + isDoubleClickDisabled: true, + defaultSize: 30, + minSize: 10, + maxSize: 90, + }, + render: (args) => ( + + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + + // Move the boundary off default. + handle.focus(); + await userEvent.keyboard("{End}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(30); + }); + const afterMove = Number(handle.getAttribute("aria-valuenow")); + + // Double-click should be a no-op — the size doesn't snap back. + await userEvent.dblClick(handle); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(afterMove); + }); + + // Keyboard still works. + await userEvent.keyboard("{Home}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(10); + }); + }, +}; + +// ============================================================ +// useResponsiveSplitterSizes — token sizes via the companion hook. +// The aside is configured as "xs" (320px); the hook measures the container and +// feeds the equivalent percentage to the component. In a "breakpoint-2xl" +// (1536px) container that is ~21%. +// ============================================================ + +const ResponsiveSizesHookComponent = () => { + const { rootProps } = useResponsiveSplitterSizes({ + orientation: "horizontal", + size: "xs", + minSize: "3xs", + maxSize: "lg", + }); + return ( + + + + + + + + + + + + ); +}; + +export const ResponsivePixelSizesHook: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("separator"); + + // "xs" (320px) in a "breakpoint-2xl" (1536px) container → ~21%. + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(21); + }); + + // A keyboard resize settles into the hook's controlled value (no snap-back). + handle.focus(); + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(21); + }); + const settled = Number(handle.getAttribute("aria-valuenow")); + await userEvent.keyboard("{Tab}"); + await waitFor(() => { + expect(Number(handle.getAttribute("aria-valuenow"))).toBe(settled); + }); + }, +}; + +// ============================================================ +// useResponsiveSplitterSizes — responsive by CONTAINER width (object notation). +// The same config resolves against each splitter's OWN width (not the viewport): +// an "xl" (576px) container is below the "breakpoint-md" (768px) threshold (40%), +// a "5xl" (1024px) container is at/above it ("xs"=320px → ~31%). +// ============================================================ + +const ResponsiveBandDemo = ({ width }: { width: string }) => { + const { rootProps } = useResponsiveSplitterSizes({ + orientation: "horizontal", + size: { 0: "40%", "breakpoint-md": "xs" }, + }); + return ( + + + + + + + + + + + + ); +}; + +export const ResponsiveByContainerWidth: Story = { + render: () => ( + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handles = await canvas.findAllByRole("separator"); + expect(handles).toHaveLength(2); + + // "xl" (576px) container is below the "breakpoint-md" threshold → base band, 40%. + await waitFor(() => { + expect(Number(handles[0].getAttribute("aria-valuenow"))).toBe(40); + }); + // "5xl" (1024px) container is at/above "breakpoint-md" → "xs" (320px) → ~31%. + await waitFor(() => { + expect(Number(handles[1].getAttribute("aria-valuenow"))).toBe(31); + }); + }, +}; + +// ============================================================ +// useResponsiveSplitterSizes — INTERACTIVE: a Splitter inside a Splitter. +// The OUTER handle resizes the inner splitter's CONTAINER, so dragging it (or +// resizing the window) makes the inner hook re-resolve live. The inner config +// uses "breakpoint-md" as the threshold and "xs" (320px) as the aside size: +// a wide container (≥ 768px) pins the aside to 320px (a small %), a narrow one +// falls back to the base 40% band. Resolution is container-, not viewport-relative. +// ============================================================ + +const NestedResponsiveComponent = () => { + const { rootProps } = useResponsiveSplitterSizes({ + orientation: "horizontal", + size: { 0: "40%", "breakpoint-md": "xs" }, + }); + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export const NestedResponsiveSplitter: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handles = await canvas.findAllByRole("separator"); + expect(handles).toHaveLength(2); + const [outer, inner] = handles; + + // Wide inner container (≥ "breakpoint-md") → the aside is pinned to "xs" + // (320px), which is less than the base 40% band. + await waitFor(() => { + expect(Number(inner.getAttribute("aria-valuenow"))).toBeLessThan(40); + }); + + // Drag the outer divider to shrink the inner container below "breakpoint-md" + // → the inner config falls back to its base 40% band, live. + outer.focus(); + await userEvent.keyboard("{End}"); + await waitFor(() => { + expect(Number(inner.getAttribute("aria-valuenow"))).toBe(40); + }); + }, +}; + +// ============================================================ +// useResponsiveSplitterSizes — PERSISTENCE to real localStorage. +// Manual demo (no play assertions): pass `persistKey` and the hook writes the +// user's settled size to localStorage (pixel-first, versioned) via its default +// adapter. Drag + release, then RELOAD the page — the size is restored. The +// readout below polls localStorage so you can watch the stored value update +// live as you drag. Use "Reset stored size" to clear the key and start over. +// ============================================================ + +const PERSIST_DEMO_KEY = "nimbus:splitter-persistence-demo"; + +const PersistedResponsiveComponent = () => { + const { rootProps } = useResponsiveSplitterSizes({ + orientation: "horizontal", + persistKey: PERSIST_DEMO_KEY, + size: "xs", + minSize: "3xs", + maxSize: "lg", + }); + + // Mirror the persisted value so the reload-and-restore behaviour is visible. + const [stored, setStored] = useState(() => + typeof window === "undefined" + ? null + : window.localStorage.getItem(PERSIST_DEMO_KEY) + ); + useEffect(() => { + const id = window.setInterval(() => { + setStored(window.localStorage.getItem(PERSIST_DEMO_KEY)); + }, 250); + return () => window.clearInterval(id); + }, []); + + const resetStored = () => { + window.localStorage.removeItem(PERSIST_DEMO_KEY); + window.location.reload(); + }; + + return ( + + + + Persisted to localStorage + + + Drag the divider and release, then reload the page — + the size is restored. The settled width is stored in pixels and + re-converted to a percentage for whatever width the container has on + the next load. + + + localStorage["{PERSIST_DEMO_KEY}"] = {stored ?? "(empty)"} + + + + + + + + + + + + + + + + ); +}; + +export const PersistedResponsiveSizes: Story = { + render: () => , +}; diff --git a/packages/nimbus/src/components/splitter/splitter.tsx b/packages/nimbus/src/components/splitter/splitter.tsx new file mode 100644 index 000000000..7058d3d48 --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.tsx @@ -0,0 +1,96 @@ +// Import from the barrel export index for consistent module resolution. +import { + SplitterRoot, + SplitterAside, + SplitterMain, + SplitterHandle, +} from "./components"; + +/** + * Splitter + * ============================================================ + * Compound primitive for a user-resizable 2-pane layout: a configurable + * `Splitter.Aside` and a `Splitter.Main` that fills the remaining space. + * + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/} + * + * @example + * ```tsx + * + * Navigation + * + * Content + * + * ``` + */ +export const Splitter = { + /** + * # Splitter.Root + * + * The root container that owns the single aside `size` and the flat sizing / + * collapse configuration. Must wrap one `Splitter.Aside` and one + * `Splitter.Main` with one `Splitter.Handle` between them (aside on either + * side). + * + * @example + * ```tsx + * + * Left + * + * Right + * + * ``` + */ + Root: SplitterRoot, + /** + * # Splitter.Aside + * + * The configurable pane. Its size is driven by `Splitter.Root`'s `size` / + * `defaultSize` (and `minSize` / `maxSize` / collapse config); carries only + * its content and an optional `id`. + * + * @example + * ```tsx + * Sidebar content + * ``` + */ + Aside: SplitterAside, + /** + * # Splitter.Main + * + * The primary content pane. Always takes the space the aside does not + * (`100 − size`); never configured directly. Carries only its content and an + * optional `id`. + * + * @example + * ```tsx + * Main content + * ``` + */ + Main: SplitterMain, + /** + * # Splitter.Handle + * + * The interactive separator between the aside and main panes. Drag, arrow + * keys, and Home/End move the boundary; Enter toggles the aside's collapse + * (also controllable via `Splitter.Root`'s `collapsed`); double-click restores + * the boundary to its initial size. + * + * @example + * ```tsx + * + * ``` + */ + Handle: SplitterHandle, +}; + +// Underscore-prefixed re-exports exist solely so the react-docgen-typescript +// script can extract per-subcomponent prop tables. Consumers should use the +// namespaced `Splitter.Root` / `Splitter.Aside` / `Splitter.Main` / +// `Splitter.Handle`. +export { + SplitterRoot as _SplitterRoot, + SplitterAside as _SplitterAside, + SplitterMain as _SplitterMain, + SplitterHandle as _SplitterHandle, +}; diff --git a/packages/nimbus/src/components/splitter/splitter.types.ts b/packages/nimbus/src/components/splitter/splitter.types.ts new file mode 100644 index 000000000..b0c680d98 --- /dev/null +++ b/packages/nimbus/src/components/splitter/splitter.types.ts @@ -0,0 +1,400 @@ +import type { ReactNode, Ref } from "react"; +import type { OmitInternalProps } from "../../type-utils/omit-props"; +import type { + SplitterRootSlotProps, + SplitterPaneSlotProps, + SplitterHandleSlotProps, +} from "./splitter.slots"; +import type { SplitterSizeToken } from "./utils/size-tokens"; + +// ============================================================ +// SHARED VALUE TYPES +// ============================================================ + +/** + * The role of a pane within a `Splitter`. A splitter is always one configurable + * `Splitter.Aside` (the pane you size) and one `Splitter.Main` (the pane that + * takes the remaining space). The role — not an id — designates which pane is + * which, so the single `size` number always refers to the aside. + */ +export type SplitterPaneRole = "aside" | "main"; + +/** + * Aside configuration with defaults applied, as consumed internally by the + * handle (clamping, ARIA bounds) and the state machine (collapse). Built from + * the flat `minSize` / `maxSize` / `collapsible` / `collapsedSize` props on + * `Splitter.Root`. + * + * The aside is the only configurable pane: there is one boundary, so the aside's + * `[minSize, maxSize]` window fully describes it — the main pane's floor is the + * complement (`100 − maxSize`) and needs no separate knob. + * + * @internal + */ +export type ResolvedAsideConfig = { + /** Aside lower bound (%). */ + minSize: number; + /** Aside upper bound (%). Caps aside growth; main's floor is `100 − maxSize`. */ + maxSize: number; + /** Whether the aside can collapse to `collapsedSize`. */ + collapsible: boolean; + /** Aside size when collapsed (%). */ + collapsedSize: number; +}; + +/** + * Internal context shared between `Splitter.Root`, the pane components + * (`Splitter.Aside` / `Splitter.Main`), and `Splitter.Handle`. Carries the + * single aside `size`, role-based pane registration, the collapse setter, and + * the configuration the handle needs to compute its keyboard behavior and ARIA + * attributes. Produced by `useSplitterState`. + * + * @internal + */ +export type SplitterContextValue = { + /** Aside pane size as a percentage (0–100); main is `100 − size`. Full-precision float. */ + size: number; + /** Replace the size live (drag ticks). Fires `onSizeChange` only. */ + setSize: (size: number) => void; + /** + * Commit a settled size change. With a number (keyboard, collapse, restore) + * it writes + fires `onSizeChange` and `onSizeChangeEnd`. With no argument + * (drag end) it fires `onSizeChangeEnd` with the current size only. + */ + commitSize: (size?: number) => void; + /** Splitter orientation. Determines layout axis and active arrow keys. */ + orientation: "horizontal" | "vertical"; + /** Keyboard step in percentage points per arrow-key press. Accepts floats. */ + keyboardStep: number; + /** When true, the handle ignores double-clicks. */ + isDoubleClickDisabled: boolean; + /** When true, the whole splitter is non-interactive (drag, keyboard, collapse). */ + isDisabled: boolean; + + /** Resolved aside constraints (clamping + ARIA bounds + collapse). */ + asideConfig: ResolvedAsideConfig; + + /** Registered pane roles, in DOM order. */ + paneOrder: SplitterPaneRole[]; + /** Map from pane role to the DOM id rendered on that pane element. */ + paneDomIds: Partial>; + /** Register a pane with the splitter under its role. */ + registerPane: (role: SplitterPaneRole, domId: string) => void; + /** Unregister a pane on unmount. */ + unregisterPane: (role: SplitterPaneRole) => void; + + /** Whether the aside is currently collapsed (resolved controlled/uncontrolled). */ + collapsed: boolean; + /** Collapse (`true`) or expand (`false`) the aside. Drives size reconciliation. */ + setCollapsed: (collapsed: boolean) => void; + /** Restore the size derived on mount (double-click). */ + restoreDefaults: () => void; +}; + +// ============================================================ +// MAIN PROPS +// ============================================================ + +/** + * Props for `` — the compound root that owns the single size + * dimension for its `Splitter.Aside` + `Splitter.Main` children. The aside is + * the pane you configure; the main pane always takes the remainder. + */ +export type SplitterRootProps = OmitInternalProps & { + /** + * The orientation of the splitter — drives layout direction and which + * arrow keys are active on the handle. The handle's `aria-orientation` + * reflects this prop (W3C separator semantics). + * @default "horizontal" + */ + orientation?: "horizontal" | "vertical"; + + /** + * Initial size of the **aside** pane as a percentage (0–100). The *dynamic* + * seed of the splitter's size: read once on mount, and exactly the shape + * `onSizeChange` / `onSizeChangeEnd` emit — so a persisted value round-trips + * straight back in here. Subsequent prop changes are ignored; an out-of-range + * or omitted value falls back to a 50/50 split. The main pane is `100 − size`. + * + * For the controlled counterpart, see `size`. Provide one or the other, not both. + */ + defaultSize?: number; + + /** + * Controlled size of the **aside** pane as a percentage (0–100). When + * provided, the splitter is controlled for size: it renders this value and + * reflects external changes in place (no remount). Control is **settled, not + * live** — drag and keyboard update the layout smoothly from internal state + * and notify once per interaction via `onSizeChangeEnd`. Wire `onSizeChangeEnd` + * and feed the value back to stay controlled — if you don't, the splitter + * keeps the last interactive value and behaves as uncontrolled from then on + * (no snap-back). Mutually exclusive with `defaultSize`. + */ + size?: number; + + /** + * Aside lower bound as a percentage. Drag/keyboard clamp at this boundary. + * @default 0 + */ + minSize?: number; + + /** + * Aside upper bound as a percentage — caps how far the aside can grow. The + * main pane's floor is the complement (`100 − maxSize`), so this single value + * bounds both sides of the one boundary. + * @default 100 + */ + maxSize?: number; + + /** + * When true, the aside can collapse to `collapsedSize` via Enter on the + * focused handle or the controlled `collapsed` prop. Only the aside collapses. + * @default false + */ + collapsible?: boolean; + + /** + * Aside size as a percentage when collapsed. + * @default 0 + */ + collapsedSize?: number; + + /** + * Notification callback fired on every size change, including each drag tick + * (~60Hz). Receives the aside size (0–100). For persistence, prefer + * `onSizeChangeEnd`. + */ + onSizeChange?: (size: number) => void; + + /** + * Notification callback fired once when a size interaction settles (drag end, + * each keypress, collapse/expand, double-click restore). Receives the aside + * size (0–100). This is the seam to wire persistence to — no debouncing. + */ + onSizeChangeEnd?: (size: number) => void; + + /** + * Controlled collapsed state of the aside. When provided, the splitter is + * controlled for collapse. + */ + collapsed?: boolean; + + /** + * Uncontrolled initial collapsed state of the aside. Used when `collapsed` is + * not provided. + * @default false + */ + defaultCollapsed?: boolean; + + /** Fired whenever the aside collapses (`true`) or expands (`false`). */ + onCollapsedChange?: (collapsed: boolean) => void; + + /** + * Percentage delta applied per arrow-key press on the focused handle. + * Home/End jump the boundary to the relevant bounds. Accepts floats. + * @default 5 + */ + keyboardStep?: number; + + /** + * When true, double-click on the handle does not restore the default size. + * Drag and keyboard remain active. + * @default false + */ + isDoubleClickDisabled?: boolean; + + /** + * When true, the splitter is non-interactive: the handle is removed from the + * tab order (`tabIndex={-1}`), gets `aria-disabled`, and ignores drag, + * keyboard, and collapse input. + * @default false + */ + isDisabled?: boolean; + + /** + * Exactly one `Splitter.Aside` and one `Splitter.Main` with one + * `Splitter.Handle` between them. The aside may be placed before or after the + * main pane (leading or trailing) — `size` always refers to the aside. + */ + children: ReactNode; + + /** Ref to the root element. */ + ref?: Ref; +}; + +/** + * Props for `` — the configurable pane whose size is driven by + * `Splitter.Root`'s `size` / `defaultSize`. Carries only its content and an + * optional `id` (analytics/testing); all sizing and collapse configuration + * lives on `Splitter.Root`. + */ +export type SplitterAsideProps = OmitInternalProps & { + /** Optional id for the pane element (analytics/testing). Not required. */ + id?: string; + + /** Pane content. */ + children?: ReactNode; + + /** Ref to the pane element. */ + ref?: Ref; +}; + +/** + * Props for `` — the primary content pane. It always takes the + * space the aside does not (`100 − size`) and is never configured directly. + * Carries only its content and an optional `id` (analytics/testing). + */ +export type SplitterMainProps = OmitInternalProps & { + /** Optional id for the pane element (analytics/testing). Not required. */ + id?: string; + + /** Pane content. */ + children?: ReactNode; + + /** Ref to the pane element. */ + ref?: Ref; +}; + +/** + * Props for `` — the interactive separator between the aside + * and main panes. The handle takes no per-handle *behaviour* configuration: + * resize and collapse behaviour is configured on `` + * (`keyboardStep`, `isDoubleClickDisabled`, `isDisabled`). It accepts standard + * DOM attributes (`id`, `className`, `data-*`) via the slot, and `aria-label` / + * `aria-labelledby` to override the localized default ("Resize panes"). + */ +export type SplitterHandleProps = OmitInternalProps & { + /** Accessible label override; defaults to a localized "Resize panes". */ + "aria-label"?: string; + /** Accessible label via reference; takes precedence over `aria-label`. */ + "aria-labelledby"?: string; + /** Ref to the handle element. */ + ref?: Ref; +}; + +// ============================================================ +// useResponsiveSplitterSizes — companion hook types +// ============================================================ + +/** + * A single size value for the `useResponsiveSplitterSizes` hook. **A bare + * `number` is always pixels** — the hook exists to let consumers think in + * pixels and translates to the percentage `Splitter.Root` consumes. The three + * forms: + * + * - `number` — pixels (e.g. `320` → `320px`), converted against the measured + * container. + * - {@link SplitterSizeToken} — a size token (`3xs`–`8xl`, `breakpoint-*`) + * resolving to pixels, then converted. + * - `` `${number}%` `` — a percentage, passed through to the component + * untranslated (no measurement needed). + * + * Contrast with `Splitter.Root`'s own `size`/`minSize`/… props, which are + * percentages: through the hook a bare number is pixels. + */ +export type ResponsiveSplitterSizeValue = + | number + | SplitterSizeToken + | `${number}%`; + +/** + * A container **min-width threshold** key for a responsive size map. A threshold + * is a pixel `number` or a size token (resolving to pixels) — never a + * percentage (a percentage threshold of the container against itself is + * meaningless). + */ +export type ResponsiveSplitterSizeThreshold = number | SplitterSizeToken; + +/** + * A size configuration for one dimension. Either a single value (applies at + * every width) or a map keyed by container min-width thresholds — a min-width + * cascade resolved against the splitter's **own** measured width (not the + * viewport). The largest threshold `≤` the measured width wins; the smallest + * entry also applies below it. + * + * @example + * 320 // 320px at every width + * "30%" // 30% at every width + * { 0: 320, 768: "30%", "breakpoint-lg": 400 } // px → %, by container width + */ +export type ResponsiveSplitterSizeConfig = + | ResponsiveSplitterSizeValue + | Partial< + Record + >; + +/** + * Minimal `localStorage`-like interface the hook persists through. Injectable so + * persistence can be redirected (tests, app-scoped stores) or disabled. + */ +export type SplitterSizesStorage = { + /** Read a previously stored payload string, or `null`. May be wrapped to never throw. */ + getItem: (key: string) => string | null; + /** Write a payload string. May be wrapped to never throw. */ + setItem: (key: string, value: string) => void; +}; + +/** + * Options for {@link useResponsiveSplitterSizes}. + */ +export type UseResponsiveSplitterSizesOptions = { + /** + * Splitter orientation — selects the measured axis (width for `"horizontal"`, + * height for `"vertical"`) and is forwarded to `Splitter.Root`. + * @default "horizontal" + */ + orientation?: "horizontal" | "vertical"; + + /** Aside size config (pixels/token/percent, single value or per-threshold map). */ + size: ResponsiveSplitterSizeConfig; + + /** Aside lower bound (pixels/token/percent). Translated to a percentage and forwarded. */ + minSize?: ResponsiveSplitterSizeConfig; + /** Aside upper bound (pixels/token/percent). Translated to a percentage and forwarded. */ + maxSize?: ResponsiveSplitterSizeConfig; + /** Aside collapsed size (pixels/token/percent). Static config — never persisted. */ + collapsedSize?: ResponsiveSplitterSizeConfig; + + /** Storage key for persistence. When omitted, sizes are not persisted. */ + persistKey?: string; + /** Storage adapter. Defaults to a `localStorage` wrapper that never throws. */ + storage?: SplitterSizesStorage; + + /** + * Optional passthrough for collapse changes. The hook always wires its own + * `onCollapsedChange` (to suppress persistence while collapsed); this is + * invoked in addition so consumers can observe collapse too. + */ + onCollapsedChange?: (collapsed: boolean) => void; +}; + +/** + * Props produced by {@link useResponsiveSplitterSizes} to spread onto + * `Splitter.Root`. All sizes are percentages (the component's native unit); + * `size` may be absent for one frame before the container is first measured + * with a pixel/token config. + */ +export type ResponsiveSplitterRootProps = { + /** Controlled aside size (percentage). Absent until resolvable for px/token configs. */ + size?: number; + /** Aside lower bound (percentage). Present only when `minSize` was configured + resolved. */ + minSize?: number; + /** Aside upper bound (percentage). Present only when `maxSize` was configured + resolved. */ + maxSize?: number; + /** Aside collapsed size (percentage). Present only when `collapsedSize` was configured + resolved. */ + collapsedSize?: number; + /** Settle handler — persists and feeds the value back to keep the loop closed. */ + onSizeChangeEnd: (size: number) => void; + /** Collapse tracker (also calls the optional `onCollapsedChange` option). */ + onCollapsedChange: (collapsed: boolean) => void; + /** Ref that attaches the container `ResizeObserver`. Required for px/token/responsive resolution. */ + ref: Ref; + /** Forwarded orientation. */ + orientation: "horizontal" | "vertical"; +}; + +/** Return value of {@link useResponsiveSplitterSizes}. */ +export type UseResponsiveSplitterSizesResult = { + /** Spread onto `Splitter.Root`. */ + rootProps: ResponsiveSplitterRootProps; +}; diff --git a/packages/nimbus/src/components/splitter/utils/clamped-resize.spec.ts b/packages/nimbus/src/components/splitter/utils/clamped-resize.spec.ts new file mode 100644 index 000000000..f32e7bb5d --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/clamped-resize.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { clampedResize } from "./clamped-resize"; + +describe("clampedResize", () => { + it("applies a positive Δ to the aside size when within bounds", () => { + expect( + clampedResize({ size: 30, delta: 10, minSize: 0, maxSize: 100 }) + ).toBe(40); + }); + + it("applies a negative Δ to the aside size when within bounds", () => { + expect( + clampedResize({ size: 50, delta: -20, minSize: 0, maxSize: 100 }) + ).toBe(30); + }); + + it("clamps at the aside minSize", () => { + expect( + clampedResize({ size: 30, delta: -40, minSize: 10, maxSize: 100 }) + ).toBe(10); + }); + + it("clamps at the aside maxSize (which bounds the main pane's floor)", () => { + // maxSize 70 → main never below 30. + expect( + clampedResize({ size: 60, delta: 20, minSize: 0, maxSize: 70 }) + ).toBe(70); + }); + + it("preserves full float precision (no rounding)", () => { + expect( + clampedResize({ size: 31.25, delta: 1.125, minSize: 0, maxSize: 100 }) + ).toBe(32.375); + }); +}); diff --git a/packages/nimbus/src/components/splitter/utils/clamped-resize.ts b/packages/nimbus/src/components/splitter/utils/clamped-resize.ts new file mode 100644 index 000000000..c513acb5c --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/clamped-resize.ts @@ -0,0 +1,30 @@ +export type ClampedResizeArgs = { + /** Current aside size (%), `0–100`. */ + size: number; + /** + * Δ in percentage points applied to the aside size. Accepts floats. The + * handle translates its "grow the leading pane" gesture into an aside Δ before + * calling (aside leading → `+Δ`; aside trailing → `−Δ`). + */ + delta: number; + /** Aside lower bound (%). */ + minSize: number; + /** Aside upper bound (%) — caps aside growth; main's floor is `100 − maxSize`. */ + maxSize: number; +}; + +/** + * Apply Δ to the aside size, clamped into the aside's `[minSize, maxSize]` + * window. With one boundary, that window fully describes the constraint: the + * main pane's floor is the complement (`100 − maxSize`), enforced here by the + * `maxSize` ceiling. The result preserves full float precision (no rounding). + * + * @see specs/nimbus-splitter/spec.md "Aside size constraints with clamping" + */ +export const clampedResize = ({ + size, + delta, + minSize, + maxSize, +}: ClampedResizeArgs): number => + Math.min(Math.max(size + delta, minSize), maxSize); diff --git a/packages/nimbus/src/components/splitter/utils/compute-aria-bounds.spec.ts b/packages/nimbus/src/components/splitter/utils/compute-aria-bounds.spec.ts new file mode 100644 index 000000000..03167f014 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/compute-aria-bounds.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { computeAriaBounds } from "./compute-aria-bounds"; +import type { ResolvedAsideConfig } from "../splitter.types"; + +const cfg = (over: Partial = {}): ResolvedAsideConfig => ({ + minSize: 0, + maxSize: 100, + collapsible: false, + collapsedSize: 0, + ...over, +}); + +describe("computeAriaBounds", () => { + it("defaults to the full 0–100 range when the aside leads", () => { + expect(computeAriaBounds(cfg(), true)).toEqual({ min: 0, max: 100 }); + }); + + it("maps the aside's [minSize, maxSize] window when the aside leads", () => { + expect(computeAriaBounds(cfg({ minSize: 10, maxSize: 80 }), true)).toEqual({ + min: 10, + max: 80, + }); + }); + + it("complements the window when the main pane leads (aside trailing)", () => { + // aside ∈ [10, 80] → leading main ∈ [20, 90]. + expect(computeAriaBounds(cfg({ minSize: 10, maxSize: 80 }), false)).toEqual( + { + min: 20, + max: 90, + } + ); + }); + + it("lets a collapsible aside reach its collapsedSize below minSize", () => { + expect( + computeAriaBounds( + cfg({ minSize: 15, maxSize: 100, collapsible: true, collapsedSize: 0 }), + true + ) + ).toEqual({ min: 0, max: 100 }); + }); + + it("keeps minSize as the floor when collapsedSize is the larger value", () => { + expect( + computeAriaBounds( + cfg({ minSize: 5, collapsible: true, collapsedSize: 12 }), + true + ) + ).toEqual({ min: 5, max: 100 }); + }); + + it("preserves float precision (no rounding)", () => { + expect( + computeAriaBounds(cfg({ minSize: 12.5, maxSize: 92.75 }), true) + ).toEqual({ min: 12.5, max: 92.75 }); + }); +}); diff --git a/packages/nimbus/src/components/splitter/utils/compute-aria-bounds.ts b/packages/nimbus/src/components/splitter/utils/compute-aria-bounds.ts new file mode 100644 index 000000000..26c49fcc6 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/compute-aria-bounds.ts @@ -0,0 +1,41 @@ +import type { ResolvedAsideConfig } from "../splitter.types"; + +/** + * The lowest size the aside can reach. Normally its `minSize`, but a collapsible + * aside can sit at its `collapsedSize` (the discrete collapse state, below + * `minSize`), so the floor must include that — otherwise `aria-valuenow` would + * fall outside the announced range while the aside is collapsed. + */ +const asideFloor = (cfg: ResolvedAsideConfig): number => + cfg.collapsible ? Math.min(cfg.minSize, cfg.collapsedSize) : cfg.minSize; + +/** + * Compute the W3C window-splitter ARIA bounds for the boundary, expressed for + * the **leading** pane (the handle's `aria-valuenow` tracks the leading pane's + * size). The aside's allowed window is `[asideFloor, maxSize]`; the bounds are + * mapped onto whichever pane leads: + * + * - aside leads → `{ min: asideFloor, max: maxSize }` + * - main leads → `{ min: 100 − maxSize, max: 100 − asideFloor }` + * + * Bounds are collapse-aware so `aria-valuenow` stays in range even when the + * aside is collapsed below its `minSize`. + * + * @param asideConfig - Resolved aside constraints. + * @param asideLeads - True when the aside is the leading (prev) sibling. + * @returns `{ min, max }` for `aria-valuemin` / `aria-valuemax`. + * + * @example + * computeAriaBounds({ minSize: 10, maxSize: 80, collapsible: false, collapsedSize: 0 }, true); + * // → { min: 10, max: 80 } + */ +export const computeAriaBounds = ( + asideConfig: ResolvedAsideConfig, + asideLeads: boolean +): { min: number; max: number } => { + const floor = asideFloor(asideConfig); + const ceil = asideConfig.maxSize; + return asideLeads + ? { min: floor, max: ceil } + : { min: 100 - ceil, max: 100 - floor }; +}; diff --git a/packages/nimbus/src/components/splitter/utils/derive-initial-sizes.spec.ts b/packages/nimbus/src/components/splitter/utils/derive-initial-sizes.spec.ts new file mode 100644 index 000000000..94596c1f5 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/derive-initial-sizes.spec.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { deriveInitialSize } from "./derive-initial-sizes"; + +describe("deriveInitialSize", () => { + it("uses an explicit defaultSize", () => { + expect(deriveInitialSize(30)).toBe(30); + }); + + it("preserves float precision", () => { + expect(deriveInitialSize(31.25)).toBe(31.25); + }); + + it("clamps an out-of-range defaultSize into 0–100", () => { + expect(deriveInitialSize(150)).toBe(100); + expect(deriveInitialSize(-10)).toBe(0); + }); + + it("falls back to 50 when defaultSize is omitted", () => { + expect(deriveInitialSize(undefined)).toBe(50); + }); + + it("falls back to 50 when defaultSize is non-finite", () => { + expect(deriveInitialSize(NaN)).toBe(50); + }); +}); diff --git a/packages/nimbus/src/components/splitter/utils/derive-initial-sizes.ts b/packages/nimbus/src/components/splitter/utils/derive-initial-sizes.ts new file mode 100644 index 000000000..be3a7064b --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/derive-initial-sizes.ts @@ -0,0 +1,17 @@ +import { normalizeSize } from "./normalize-sizes"; + +/** + * Derive the initial aside size (%) for a splitter from `defaultSize`. There is + * a single initialization path: + * 1. Explicit `defaultSize` (clamped into `0–100`, full float precision). + * 2. Otherwise an equal 50/50 split. + * + * @param defaultSize - Optional explicit aside size from `Splitter.Root`. + * @returns The aside size as a percentage (`0–100`). + * + * @example + * deriveInitialSize(31.25); // → 31.25 + * deriveInitialSize(undefined); // → 50 + */ +export const deriveInitialSize = (defaultSize: number | undefined): number => + normalizeSize(defaultSize) ?? 50; diff --git a/packages/nimbus/src/components/splitter/utils/index.ts b/packages/nimbus/src/components/splitter/utils/index.ts new file mode 100644 index 000000000..e51e31c5c --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/index.ts @@ -0,0 +1,6 @@ +export { clampedResize } from "./clamped-resize"; +export type { ClampedResizeArgs } from "./clamped-resize"; +export { computeAriaBounds } from "./compute-aria-bounds"; +export { deriveInitialSize } from "./derive-initial-sizes"; +export { normalizeSize } from "./normalize-sizes"; +export { sizeEqual } from "./sizes-equal"; diff --git a/packages/nimbus/src/components/splitter/utils/normalize-sizes.spec.ts b/packages/nimbus/src/components/splitter/utils/normalize-sizes.spec.ts new file mode 100644 index 000000000..3ea6f0a68 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/normalize-sizes.spec.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { normalizeSize } from "./normalize-sizes"; + +describe("normalizeSize", () => { + it("passes through a value already in range", () => { + expect(normalizeSize(25)).toBe(25); + }); + + it("preserves float precision (no rounding)", () => { + expect(normalizeSize(31.25)).toBe(31.25); + }); + + it("clamps a value above 100", () => { + expect(normalizeSize(150)).toBe(100); + }); + + it("clamps a value below 0", () => { + expect(normalizeSize(-10)).toBe(0); + }); + + it("returns null for non-finite or missing input", () => { + expect(normalizeSize(NaN)).toBeNull(); + expect(normalizeSize(undefined)).toBeNull(); + expect(normalizeSize(null)).toBeNull(); + }); +}); diff --git a/packages/nimbus/src/components/splitter/utils/normalize-sizes.ts b/packages/nimbus/src/components/splitter/utils/normalize-sizes.ts new file mode 100644 index 000000000..7e29f8a26 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/normalize-sizes.ts @@ -0,0 +1,24 @@ +/** + * Normalize an aside size (a single percentage) into the valid `0–100` range, + * with full float precision preserved (no rounding, no `minSize` clamp). Used + * both to seed the initial size from `defaultSize` and to reconcile an inbound + * controlled `size` prop. + * + * Returns `null` when the value is not a finite number — letting callers + * distinguish "valid size" from "nothing usable" rather than silently inventing + * a layout. A finite but out-of-range value is clamped into `[0, 100]`. + * + * @param size - The aside size as a percentage (e.g. `defaultSize` or `size`). + * @returns A percentage in `[0, 100]`, or `null` when the input is unusable. + * + * @example + * normalizeSize(25); // → 25 + * normalizeSize(150); // → 100 + * normalizeSize(NaN); // → null + */ +export const normalizeSize = ( + size: number | null | undefined +): number | null => { + if (typeof size !== "number" || !Number.isFinite(size)) return null; + return Math.min(Math.max(size, 0), 100); +}; diff --git a/packages/nimbus/src/components/splitter/utils/responsive-size-storage.spec.ts b/packages/nimbus/src/components/splitter/utils/responsive-size-storage.spec.ts new file mode 100644 index 000000000..e73237e95 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/responsive-size-storage.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { + parseStoredBands, + serializeStoredBands, + STORAGE_VERSION, +} from "./responsive-size-storage"; + +describe("parseStoredBands", () => { + it("returns null for absent or empty input", () => { + expect(parseStoredBands(null)).toBeNull(); + expect(parseStoredBands("")).toBeNull(); + }); + + it("returns null for corrupt JSON", () => { + expect(parseStoredBands("{not json")).toBeNull(); + }); + + it("returns null for an unknown/older version", () => { + expect( + parseStoredBands( + JSON.stringify({ v: 0, bands: { "0": { unit: "px", value: 1 } } }) + ) + ).toBeNull(); + }); + + it("parses a valid payload into a numeric-keyed band map", () => { + const raw = JSON.stringify({ + v: STORAGE_VERSION, + bands: { + "0": { unit: "px", value: 320 }, + "768": { unit: "pct", value: 30 }, + }, + }); + expect(parseStoredBands(raw)).toEqual({ + 0: { unit: "px", value: 320 }, + 768: { unit: "pct", value: 30 }, + }); + }); + + it("drops malformed bands but keeps valid ones", () => { + const raw = JSON.stringify({ + v: STORAGE_VERSION, + bands: { + "0": { unit: "px", value: 320 }, + "768": { unit: "bogus", value: 30 }, + "1024": { unit: "px", value: "nope" }, + }, + }); + expect(parseStoredBands(raw)).toEqual({ 0: { unit: "px", value: 320 } }); + }); +}); + +describe("serializeStoredBands", () => { + it("round-trips through parse", () => { + const bands = { 0: { unit: "px" as const, value: 296 } }; + expect(parseStoredBands(serializeStoredBands(bands))).toEqual(bands); + }); +}); diff --git a/packages/nimbus/src/components/splitter/utils/responsive-size-storage.ts b/packages/nimbus/src/components/splitter/utils/responsive-size-storage.ts new file mode 100644 index 000000000..331ae3089 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/responsive-size-storage.ts @@ -0,0 +1,81 @@ +import type { SplitterSizesStorage } from "../splitter.types"; +import type { StoredBand } from "./responsive-size"; + +/** Current persisted-payload schema version. Bump + migrate on shape changes. */ +export const STORAGE_VERSION = 1; + +type StoredPayload = { + v: number; + /** Bands keyed by resolved pixel threshold (as a string, per JSON). */ + bands: Record; +}; + +const isStoredBand = (value: unknown): value is StoredBand => { + if (value === null || typeof value !== "object") return false; + const band = value as Record; + return ( + (band.unit === "px" || band.unit === "pct") && + typeof band.value === "number" && + Number.isFinite(band.value) + ); +}; + +/** + * Parse a raw stored payload string into a threshold-keyed band map. Tolerates + * absent, corrupt, or older/unknown-version data by returning `null` (the caller + * then resolves from config defaults). Versioned so future shape changes can + * migrate rather than silently misread. + */ +export const parseStoredBands = ( + raw: string | null +): Record | null => { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (parsed === null || typeof parsed !== "object") return null; + const payload = parsed as Partial; + if (payload.v !== STORAGE_VERSION || typeof payload.bands !== "object") { + // Unknown/older version: no migration defined yet → ignore. + return null; + } + const out: Record = {}; + for (const [key, value] of Object.entries(payload.bands ?? {})) { + const thresholdPx = Number(key); + if (!Number.isFinite(thresholdPx)) continue; + if (isStoredBand(value)) out[thresholdPx] = value; + } + return out; +}; + +/** Serialize a band map into the versioned payload string. */ +export const serializeStoredBands = ( + bands: Record +): string => JSON.stringify({ v: STORAGE_VERSION, bands }); + +/** + * A `localStorage`-backed {@link SplitterSizesStorage} that never throws and + * no-ops when `localStorage` is unavailable (SSR, privacy mode, quota). Used as + * the default adapter. + */ +export const createLocalStorageAdapter = (): SplitterSizesStorage => ({ + getItem: (key) => { + try { + if (typeof localStorage === "undefined") return null; + return localStorage.getItem(key); + } catch { + return null; + } + }, + setItem: (key, value) => { + try { + if (typeof localStorage === "undefined") return; + localStorage.setItem(key, value); + } catch { + // Quota / denied — persistence is best-effort. + } + }, +}); diff --git a/packages/nimbus/src/components/splitter/utils/responsive-size.spec.ts b/packages/nimbus/src/components/splitter/utils/responsive-size.spec.ts new file mode 100644 index 000000000..ac65411ee --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/responsive-size.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from "vitest"; +import { + bandValueAt, + clampPercent, + isPercentValue, + isThresholdMap, + percentToPx, + resolveDimension, + selectBand, + toBands, + valueToPercent, + type SizeBand, +} from "./responsive-size"; + +describe("isPercentValue", () => { + it("recognizes percent strings", () => { + expect(isPercentValue("30%")).toBe(true); + expect(isPercentValue("12.5%")).toBe(true); + }); + it("rejects non-percent values", () => { + expect(isPercentValue(320)).toBe(false); + expect(isPercentValue("md")).toBe(false); + expect(isPercentValue("320px")).toBe(false); + }); +}); + +describe("valueToPercent", () => { + it("treats a bare number as pixels", () => { + expect(valueToPercent(320, 1000)).toBe(32); + expect(valueToPercent(500, 1000)).toBe(50); + }); + it("passes a percent string through without measurement", () => { + expect(valueToPercent("30%", null)).toBe(30); + expect(valueToPercent("12.5%", 1000)).toBe(12.5); + }); + it("resolves a size token to pixels then to a percentage", () => { + // md = 448px + expect(valueToPercent("md", 896)).toBe(50); + // breakpoint-sm = 480px + expect(valueToPercent("breakpoint-sm", 480)).toBe(100); + }); + it("returns null for pixels/tokens without a usable measurement", () => { + expect(valueToPercent(320, null)).toBeNull(); + expect(valueToPercent(320, 0)).toBeNull(); + expect(valueToPercent("md", null)).toBeNull(); + expect(valueToPercent(320, -10)).toBeNull(); + }); +}); + +describe("clampPercent", () => { + it("clamps into [0,100] by default", () => { + expect(clampPercent(150)).toBe(100); + expect(clampPercent(-5)).toBe(0); + expect(clampPercent(42)).toBe(42); + }); + it("clamps into an explicit window", () => { + expect(clampPercent(8, 15, 60)).toBe(15); + expect(clampPercent(80, 15, 60)).toBe(60); + }); +}); + +describe("percentToPx", () => { + it("inverts a percentage against the container", () => { + expect(percentToPx(32, 1000)).toBe(320); + }); +}); + +describe("isThresholdMap", () => { + it("distinguishes maps from single values", () => { + expect(isThresholdMap({ 0: 320 })).toBe(true); + expect(isThresholdMap(320)).toBe(false); + expect(isThresholdMap("30%")).toBe(false); + expect(isThresholdMap("md")).toBe(false); + }); +}); + +describe("toBands", () => { + it("wraps a single value as one band at threshold 0", () => { + expect(toBands(320)).toEqual([{ thresholdPx: 0, value: 320 }]); + }); + it("resolves numeric and token threshold keys, sorted ascending", () => { + const bands = toBands({ 768: "30%", 0: 320, "breakpoint-lg": 400 }); + expect(bands).toEqual([ + { thresholdPx: 0, value: 320 }, + { thresholdPx: 768, value: "30%" }, + { thresholdPx: 1024, value: 400 }, // breakpoint-lg + ]); + }); +}); + +describe("bandValueAt", () => { + it("looks up the configured value at a threshold", () => { + const config = { 0: 320, 768: "30%" } as const; + expect(bandValueAt(config, 0)).toBe(320); + expect(bandValueAt(config, 768)).toBe("30%"); + expect(bandValueAt(config, 999)).toBeUndefined(); + }); +}); + +describe("selectBand", () => { + const bands: SizeBand[] = [ + { thresholdPx: 0, value: 320 }, + { thresholdPx: 768, value: "30%" }, + { thresholdPx: 1024, value: 400 }, + ]; + + it("picks the largest threshold <= measured", () => { + expect(selectBand(bands, 500)?.thresholdPx).toBe(0); + expect(selectBand(bands, 800)?.thresholdPx).toBe(768); + expect(selectBand(bands, 1200)?.thresholdPx).toBe(1024); + }); + + it("applies the smallest band below its threshold", () => { + const noBase: SizeBand[] = [ + { thresholdPx: 768, value: "30%" }, + { thresholdPx: 1024, value: 400 }, + ]; + expect(selectBand(noBase, 500)?.thresholdPx).toBe(768); + }); + + it("holds the active band within the hysteresis deadband", () => { + // Active = 0; just past 768 stays on 0 within the deadband, switches beyond. + expect( + selectBand(bands, 769, { activeThresholdPx: 0, deadbandPx: 2 }) + ?.thresholdPx + ).toBe(0); + expect( + selectBand(bands, 771, { activeThresholdPx: 0, deadbandPx: 2 }) + ?.thresholdPx + ).toBe(768); + }); +}); + +describe("resolveDimension", () => { + it("resolves a single percent value synchronously (no measurement)", () => { + expect(resolveDimension("30%", null)).toEqual({ + percent: 30, + thresholdPx: 0, + }); + }); + + it("requires measurement for a single pixel value", () => { + expect(resolveDimension(320, null)).toEqual({ + percent: null, + thresholdPx: 0, + }); + expect(resolveDimension(320, 1000)).toEqual({ + percent: 32, + thresholdPx: 0, + }); + }); + + it("requires measurement to select a band in a multi-entry map", () => { + const config = { 0: 320, 768: "30%" } as const; + expect(resolveDimension(config, null).percent).toBeNull(); + expect(resolveDimension(config, 1000)).toEqual({ + percent: 30, + thresholdPx: 768, + }); + }); + + it("lets a stored value override the config default", () => { + expect( + resolveDimension(320, 1000, { + stored: { 0: { unit: "px", value: 300 } }, + }) + ).toEqual({ percent: 30, thresholdPx: 0 }); + expect( + resolveDimension(320, 1000, { + stored: { 0: { unit: "pct", value: 25 } }, + }) + ).toEqual({ percent: 25, thresholdPx: 0 }); + }); + + it("re-pins a stored pixel value against a new container size", () => { + // 300px stored from a 1000px session, restored into an 800px container. + expect( + resolveDimension(320, 800, { + stored: { 0: { unit: "px", value: 300 } }, + }).percent + ).toBeCloseTo(37.5); + }); + + it("falls back to the config default when a stored px lacks measurement", () => { + expect( + resolveDimension(320, null, { + stored: { 0: { unit: "px", value: 300 } }, + }).percent + ).toBeNull(); + }); +}); diff --git a/packages/nimbus/src/components/splitter/utils/responsive-size.ts b/packages/nimbus/src/components/splitter/utils/responsive-size.ts new file mode 100644 index 000000000..b55b7cfdb --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/responsive-size.ts @@ -0,0 +1,193 @@ +import type { + ResponsiveSplitterSizeConfig, + ResponsiveSplitterSizeValue, +} from "../splitter.types"; +import { isSplitterSizeToken, resolveTokenToPx } from "./size-tokens"; + +/** A finite, positive container measurement is required to convert pixels/tokens. */ +const isMeasurable = (px: number | null | undefined): px is number => + typeof px === "number" && Number.isFinite(px) && px > 0; + +const PERCENT_PATTERN = /^-?\d+(?:\.\d+)?%$/; +const NUMERIC_KEY_PATTERN = /^-?\d+(?:\.\d+)?$/; + +/** Type guard: a `` `${number}%` `` string. */ +export const isPercentValue = (value: unknown): value is `${number}%` => + typeof value === "string" && PERCENT_PATTERN.test(value); + +/** + * Resolve a single size value to a percentage of `containerPx`. A bare `number` + * is pixels, a token resolves to pixels, a `"N%"` string passes through. Returns + * `null` when unresolvable — a pixel/token value without a usable measurement, + * or an unknown token. + */ +export const valueToPercent = ( + value: ResponsiveSplitterSizeValue, + containerPx: number | null +): number | null => { + if (typeof value === "number") { + return isMeasurable(containerPx) ? (value / containerPx) * 100 : null; + } + if (isPercentValue(value)) { + const n = parseFloat(value); + return Number.isFinite(n) ? n : null; + } + if (isSplitterSizeToken(value)) { + const px = resolveTokenToPx(value); + if (px === null || !isMeasurable(containerPx)) return null; + return (px / containerPx) * 100; + } + return null; +}; + +/** Clamp a percentage into `[min, max]` (defaults `[0, 100]`). */ +export const clampPercent = (percent: number, min = 0, max = 100): number => + Math.min(max, Math.max(min, percent)); + +/** Convert a percentage back to pixels for persistence. */ +export const percentToPx = (percent: number, containerPx: number): number => + (percent / 100) * containerPx; + +/** A resolved band: the container min-width threshold (px) and its size value. */ +export type SizeBand = { + thresholdPx: number; + value: ResponsiveSplitterSizeValue; +}; + +const thresholdKeyToPx = (key: string): number | null => { + if (NUMERIC_KEY_PATTERN.test(key)) { + const n = parseFloat(key); + return Number.isFinite(n) ? n : null; + } + if (isSplitterSizeToken(key)) return resolveTokenToPx(key); + return null; +}; + +/** True when the config is a threshold map rather than a single value. */ +export const isThresholdMap = ( + config: ResponsiveSplitterSizeConfig +): config is Partial> => + config !== null && typeof config === "object"; + +/** + * Resolve a config into bands sorted ascending by threshold. A single value + * becomes one band at threshold `0`; map keys resolve to pixels (numeric or + * token), and unresolvable keys/values are dropped. + */ +export const toBands = (config: ResponsiveSplitterSizeConfig): SizeBand[] => { + if (!isThresholdMap(config)) { + return [{ thresholdPx: 0, value: config }]; + } + const bands: SizeBand[] = []; + for (const [key, value] of Object.entries(config)) { + if (value === undefined) continue; + const thresholdPx = thresholdKeyToPx(key); + if (thresholdPx === null) continue; + bands.push({ thresholdPx, value }); + } + bands.sort((a, b) => a.thresholdPx - b.thresholdPx); + return bands; +}; + +/** The configured value at a given threshold (for persistence unit lookup). */ +export const bandValueAt = ( + config: ResponsiveSplitterSizeConfig, + thresholdPx: number +): ResponsiveSplitterSizeValue | undefined => + toBands(config).find((b) => b.thresholdPx === thresholdPx)?.value; + +/** + * Select the active band for a measured size: the largest threshold `≤` + * measured, with the smallest band applying below it. When an `activeThresholdPx` + * is supplied, a hysteresis `deadbandPx` keeps the active band until the measured + * size crosses the band's boundaries by more than the deadband, preventing + * per-frame flapping at a threshold. + */ +export const selectBand = ( + bands: SizeBand[], + measuredPx: number, + opts?: { activeThresholdPx?: number | null; deadbandPx?: number } +): SizeBand | null => { + if (bands.length === 0) return null; + + const pick = (m: number): SizeBand => { + let chosen = bands[0]!; + for (const band of bands) { + if (band.thresholdPx <= m) chosen = band; + else break; + } + return chosen; + }; + + const candidate = pick(measuredPx); + const active = opts?.activeThresholdPx; + const deadband = opts?.deadbandPx ?? 0; + if (active == null || deadband <= 0 || candidate.thresholdPx === active) { + return candidate; + } + + const activeIdx = bands.findIndex((b) => b.thresholdPx === active); + if (activeIdx === -1) return candidate; + const activeBand = bands[activeIdx]!; + const upperBound = bands[activeIdx + 1]?.thresholdPx ?? Infinity; + // The active band covers [active, upperBound). Keep it until the measured size + // is clearly outside that range by more than the deadband. + if (measuredPx >= active - deadband && measuredPx < upperBound + deadband) { + return activeBand; + } + return candidate; +}; + +/** A persisted band value: a pixel width or a percentage. */ +export type StoredBand = { unit: "px" | "pct"; value: number }; + +/** + * Resolve one size dimension to a percentage given the current measurement, + * optional hysteresis state, and (for the size dimension) an optional stored + * override that takes precedence over the config default for the active band. + * + * Returns the resolved `percent` (or `null` when not yet resolvable — a + * pixel/token value awaiting measurement) and the active band's `thresholdPx` + * (the persistence key). + */ +export const resolveDimension = ( + config: ResponsiveSplitterSizeConfig, + measuredPx: number | null, + opts?: { + activeThresholdPx?: number | null; + deadbandPx?: number; + stored?: Record | null; + } +): { percent: number | null; thresholdPx: number | null } => { + const bands = toBands(config); + if (bands.length === 0) return { percent: null, thresholdPx: null }; + + let active: SizeBand | null; + if (bands.length === 1) { + active = bands[0]!; + } else { + if (!isMeasurable(measuredPx)) return { percent: null, thresholdPx: null }; + active = selectBand(bands, measuredPx, { + activeThresholdPx: opts?.activeThresholdPx, + deadbandPx: opts?.deadbandPx, + }); + } + if (!active) return { percent: null, thresholdPx: null }; + + const stored = opts?.stored?.[active.thresholdPx]; + if (stored) { + const pct = + stored.unit === "pct" + ? stored.value + : isMeasurable(measuredPx) + ? (stored.value / measuredPx) * 100 + : null; + // A stored px without a measurement yet → fall through to the config default. + if (pct !== null) return { percent: pct, thresholdPx: active.thresholdPx }; + } + + return { + percent: valueToPercent(active.value, measuredPx), + thresholdPx: active.thresholdPx, + }; +}; diff --git a/packages/nimbus/src/components/splitter/utils/size-tokens.spec.ts b/packages/nimbus/src/components/splitter/utils/size-tokens.spec.ts new file mode 100644 index 000000000..7b46d6564 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/size-tokens.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { themeTokens } from "@commercetools/nimbus-tokens"; +import { + SPLITTER_SIZE_TOKENS, + isSplitterSizeToken, + resolveTokenToPx, +} from "./size-tokens"; + +describe("SPLITTER_SIZE_TOKENS", () => { + // Guard: a token rename/removal in the design tokens becomes a red build here, + // rather than a silent runtime miss in the hook. + it("every curated token exists in themeTokens.size with a finite px value", () => { + const size = themeTokens.size as Record< + string, + { value: string } | undefined + >; + for (const token of SPLITTER_SIZE_TOKENS) { + const entry = size[token]; + expect(entry, `missing size token: ${token}`).toBeDefined(); + expect(Number.isFinite(parseFloat(entry!.value))).toBe(true); + } + }); +}); + +describe("isSplitterSizeToken", () => { + it("accepts curated tokens and rejects everything else", () => { + expect(isSplitterSizeToken("md")).toBe(true); + expect(isSplitterSizeToken("breakpoint-lg")).toBe(true); + expect(isSplitterSizeToken("9600")).toBe(false); // numeric scale excluded + expect(isSplitterSizeToken("nope")).toBe(false); + expect(isSplitterSizeToken(320)).toBe(false); + }); +}); + +describe("resolveTokenToPx", () => { + it("resolves known tokens to their pixel values", () => { + expect(resolveTokenToPx("md")).toBe(448); + expect(resolveTokenToPx("3xs")).toBe(224); + expect(resolveTokenToPx("breakpoint-sm")).toBe(480); + expect(resolveTokenToPx("breakpoint-2xl")).toBe(1536); + }); +}); diff --git a/packages/nimbus/src/components/splitter/utils/size-tokens.ts b/packages/nimbus/src/components/splitter/utils/size-tokens.ts new file mode 100644 index 000000000..c1147a12c --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/size-tokens.ts @@ -0,0 +1,63 @@ +import { themeTokens } from "@commercetools/nimbus-tokens"; + +/** + * The curated set of `sizes` design tokens accepted by + * `useResponsiveSplitterSizes` as size values and threshold keys: the named + * t-shirt scale (`3xs`–`8xl`) and the `breakpoint-*` sizes. + * + * Hand-authored on purpose — deriving from `keyof typeof themeTokens.size` would + * also pull in the numeric scale (`"25"`…`"9600"`), which both pollutes + * autocomplete and makes a string like `"400"` ambiguous against the pixel + * `number` `400`. A unit test asserts every member still exists in + * `themeTokens.size`, so a token rename/removal becomes a build failure rather + * than a silent runtime miss. + */ +export const SPLITTER_SIZE_TOKENS = [ + "3xs", + "2xs", + "xs", + "sm", + "md", + "lg", + "xl", + "2xl", + "3xl", + "4xl", + "5xl", + "6xl", + "7xl", + "8xl", + "breakpoint-sm", + "breakpoint-md", + "breakpoint-lg", + "breakpoint-xl", + "breakpoint-2xl", +] as const; + +/** A size token name accepted by `useResponsiveSplitterSizes`. */ +export type SplitterSizeToken = (typeof SPLITTER_SIZE_TOKENS)[number]; + +const TOKEN_SET: ReadonlySet = new Set(SPLITTER_SIZE_TOKENS); + +/** Type guard: is `value` one of the curated splitter size tokens? */ +export const isSplitterSizeToken = ( + value: unknown +): value is SplitterSizeToken => + typeof value === "string" && TOKEN_SET.has(value); + +/** + * Resolve a size token to its pixel value via `themeTokens.size`. Returns `null` + * when the token is missing or its value is not a finite pixel number. + * + * @example + * resolveTokenToPx("breakpoint-sm"); // → 480 + * resolveTokenToPx("md"); // → 448 + */ +export const resolveTokenToPx = (token: SplitterSizeToken): number | null => { + const entry = ( + themeTokens.size as Record + )[token]; + if (!entry) return null; + const px = parseFloat(entry.value); + return Number.isFinite(px) ? px : null; +}; diff --git a/packages/nimbus/src/components/splitter/utils/sizes-equal.spec.ts b/packages/nimbus/src/components/splitter/utils/sizes-equal.spec.ts new file mode 100644 index 000000000..105cfd510 --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/sizes-equal.spec.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { sizeEqual } from "./sizes-equal"; + +describe("sizeEqual", () => { + it("treats equal numbers as equal", () => { + expect(sizeEqual(30, 30)).toBe(true); + }); + + it("treats values within epsilon as equal (float drift)", () => { + expect(sizeEqual(30, 30 + 1e-9)).toBe(true); + }); + + it("treats a meaningful difference as not equal", () => { + expect(sizeEqual(30, 31)).toBe(false); + }); + + it("returns false when one side is nullish", () => { + expect(sizeEqual(null, 50)).toBe(false); + expect(sizeEqual(50, undefined)).toBe(false); + }); + + it("treats two nullish values as equal only by identity", () => { + expect(sizeEqual(undefined, undefined)).toBe(true); + expect(sizeEqual(null, undefined)).toBe(false); + }); +}); diff --git a/packages/nimbus/src/components/splitter/utils/sizes-equal.ts b/packages/nimbus/src/components/splitter/utils/sizes-equal.ts new file mode 100644 index 000000000..d2cd079ae --- /dev/null +++ b/packages/nimbus/src/components/splitter/utils/sizes-equal.ts @@ -0,0 +1,24 @@ +// Aside sizes are compared within this tolerance so a value re-normalized on the +// way in (e.g. a consumer feeding back the value we just emitted) reads as equal +// despite last-ULP float drift — looser than that drift, far tighter than any +// meaningful proportion change. +const SIZE_EPSILON = 1e-6; + +/** + * Value equality for two aside sizes, within `SIZE_EPSILON`. Used to + * short-circuit the controlled-`size` reconcile effect when the incoming value + * already matches internal state (no write, no loop). + * + * @example + * sizeEqual(30, 30); // → true + * sizeEqual(30, 30.0000001); // → true + * sizeEqual(30, 31); // → false + */ +export const sizeEqual = ( + a: number | null | undefined, + b: number | null | undefined +): boolean => { + if (a === b) return true; + if (a == null || b == null) return false; + return Math.abs(a - b) <= SIZE_EPSILON; +}; diff --git a/packages/nimbus/src/patterns/actions/form-action-bar/intl/de.ts b/packages/nimbus/src/patterns/actions/form-action-bar/intl/de.ts index 3b72ca8f2..7eb749d07 100644 --- a/packages/nimbus/src/patterns/actions/form-action-bar/intl/de.ts +++ b/packages/nimbus/src/patterns/actions/form-action-bar/intl/de.ts @@ -5,8 +5,8 @@ */ export default { - ariaLabel: `Form actions`, - cancel: `Cancel`, - delete: `Delete`, - save: `Save`, + ariaLabel: `Formular-Aktionen`, + cancel: `Abbrechen`, + delete: `Löschen`, + save: `Speichern`, }; diff --git a/packages/nimbus/src/patterns/actions/form-action-bar/intl/fr-FR.ts b/packages/nimbus/src/patterns/actions/form-action-bar/intl/fr-FR.ts index 0f379d2e5..c711122de 100644 --- a/packages/nimbus/src/patterns/actions/form-action-bar/intl/fr-FR.ts +++ b/packages/nimbus/src/patterns/actions/form-action-bar/intl/fr-FR.ts @@ -5,8 +5,8 @@ */ export default { - ariaLabel: `Form actions`, - cancel: `Cancel`, - delete: `Delete`, - save: `Save`, + ariaLabel: `Actions du formulaire`, + cancel: `Annuler`, + delete: `Supprimer`, + save: `Enregistrer`, }; diff --git a/packages/nimbus/src/patterns/dialogs/confirmation-dialog/intl/de.ts b/packages/nimbus/src/patterns/dialogs/confirmation-dialog/intl/de.ts index 3ec197b03..d7c7bd4ed 100644 --- a/packages/nimbus/src/patterns/dialogs/confirmation-dialog/intl/de.ts +++ b/packages/nimbus/src/patterns/dialogs/confirmation-dialog/intl/de.ts @@ -4,4 +4,4 @@ * DO NOT EDIT MANUALLY */ -export default { cancel: `Cancel`, confirm: `Confirm` }; +export default { cancel: `Abbrechen`, confirm: `Bestätigen` }; diff --git a/packages/nimbus/src/patterns/dialogs/confirmation-dialog/intl/fr-FR.ts b/packages/nimbus/src/patterns/dialogs/confirmation-dialog/intl/fr-FR.ts index 03b77787e..c256c4e8d 100644 --- a/packages/nimbus/src/patterns/dialogs/confirmation-dialog/intl/fr-FR.ts +++ b/packages/nimbus/src/patterns/dialogs/confirmation-dialog/intl/fr-FR.ts @@ -4,4 +4,4 @@ * DO NOT EDIT MANUALLY */ -export default { cancel: `Cancel`, confirm: `Confirm` }; +export default { cancel: `Annuler`, confirm: `Confirmer` }; diff --git a/packages/nimbus/src/patterns/dialogs/form-dialog/intl/de.ts b/packages/nimbus/src/patterns/dialogs/form-dialog/intl/de.ts index c0f8eab27..fb7cad9d2 100644 --- a/packages/nimbus/src/patterns/dialogs/form-dialog/intl/de.ts +++ b/packages/nimbus/src/patterns/dialogs/form-dialog/intl/de.ts @@ -4,4 +4,4 @@ * DO NOT EDIT MANUALLY */ -export default { cancel: `Cancel`, save: `Save` }; +export default { cancel: `Abbrechen`, save: `Speichern` }; diff --git a/packages/nimbus/src/patterns/dialogs/form-dialog/intl/fr-FR.ts b/packages/nimbus/src/patterns/dialogs/form-dialog/intl/fr-FR.ts index 795229f8d..6b77d4b29 100644 --- a/packages/nimbus/src/patterns/dialogs/form-dialog/intl/fr-FR.ts +++ b/packages/nimbus/src/patterns/dialogs/form-dialog/intl/fr-FR.ts @@ -4,4 +4,4 @@ * DO NOT EDIT MANUALLY */ -export default { cancel: `Cancel`, save: `Save` }; +export default { cancel: `Annuler`, save: `Enregistrer` }; diff --git a/packages/nimbus/src/theme/slot-recipes/index.ts b/packages/nimbus/src/theme/slot-recipes/index.ts index d4ba20d65..8106ee31c 100644 --- a/packages/nimbus/src/theme/slot-recipes/index.ts +++ b/packages/nimbus/src/theme/slot-recipes/index.ts @@ -33,6 +33,7 @@ import { scopedSearchInputSlotRecipe } from "@/components/scoped-search-input/sc import { searchInputSlotRecipe } from "@/components/search-input/search-input.recipe"; import { selectSlotRecipe } from "@/components/select/select.recipe"; import { splitButtonSlotRecipe } from "@/components/split-button/split-button.recipe"; +import { splitterSlotRecipe } from "@/components/splitter/splitter.recipe"; import { switchSlotRecipe } from "@/components/switch/switch.recipe"; import { tableSlotRecipe } from "@/components/table/table.recipe"; import { tabNavSlotRecipe } from "@/components/tab-nav/tab-nav.recipe"; @@ -105,6 +106,7 @@ export const slotRecipes = { nimbusSearchInput: searchInputSlotRecipe, nimbusSelect: selectSlotRecipe, nimbusSplitButton: splitButtonSlotRecipe, + nimbusSplitter: splitterSlotRecipe, nimbusSwitch: switchSlotRecipe, nimbusTable: tableSlotRecipe, nimbusTabNav: tabNavSlotRecipe,