Browse documentation

UI / Foundations / Deep dive

WindowSplitter: accessible range state for resizable panes

Connect native pointer dragging and keyboard resizing to the same bounded pane layout.

A splitter must change both the pane’s visible width and the divider’s announced value. This example derives them from one bounded state. Keyboard resizing and pointer dragging update that same value; Enter collapses the pane and restores its remembered width.

Bind the value to a responsive pane layout

This splitter divides the available pane space into proportions, so the example shrinks with its container. Its minimum is deliberately nonzero: collapse means the smallest permitted share rather than hiding navigation completely. The accessible value text makes that unit explicit.

import { RefSubject } from "@typed/fx";
import { html, component } from "@typed/template";
import * as WindowSplitter from "@typed/ui/WindowSplitter";

export const ResizableInspector = component(function* (id: string) {
  const state = yield* WindowSplitter.makeState({
    value: 35, min: 15, max: 70, step: 5, orientation: "vertical",
  });

  // Reserve the handle width, then share the remaining space between panes.
  const layout = RefSubject.map(state, ({ value }) =>
    `display: grid; grid-template-columns: minmax(0, ${value}fr) 12px minmax(0, ${100 - value}fr);`);

  return html`<section>
    <p id=${`${id}-help`}>Drag the divider, or focus it and use Left/Right. Enter collapses or restores.</p>
    <div style=${layout}>
      <aside id=${`${id}-pane`} style="overflow-wrap: anywhere;">
        <h2>Inspector</h2><p>Selected project properties.</p>
      </aside>
      ${WindowSplitter.WindowSplitter({ state, primaryPaneId: `${id}-pane`, label: "Inspector width",
        valueText: RefSubject.map(state, ({ value }) => `${value}% of pane space`),
        props: { "aria-describedby": `${id}-help`, style: "cursor: col-resize; background: currentColor;" },
      })}
      <div style="overflow-wrap: anywhere;"><h2>Project content</h2></div>
    </div>
  </section>`;
});

Pass a stable, page-unique ID. The separator’s primaryPaneId points to the aside from this instance. State drives both grid proportions and aria-valuenow; valueText supplies readable units. The default drag scale treats the space excluding the handle as 100 units, matching these grid tracks. minmax(0, …) lets both tracks shrink instead of overflowing at their content’s intrinsic width. Keep a visible focus indicator and check real content at narrow widths; complex pane contents may need a stacked layout when neither pane has enough usable room.

Understand orientation and collapse memory

Orientation describes the separator line, not the direction the pane grows. A vertical separator uses Left/Right; a horizontal separator uses Up/Down. Arrows apply step, Home selects min, End selects max, and Enter calls toggleCollapsed. The separator remains a normal tab stop. When its rendered aria-disabled is true, keyboard and pointer handlers do not change state. Disabling during a drag ends that drag on its next pointer movement.

setValue and adjust clamp to the configured range. toggleCollapsed records the current value in previousValue when collapsing to min, and restores that recorded value when already at min. Ordinary resizing does not update the restore value. That distinction matters if another control jumps to min: Enter then restores the previously recorded collapse value, not necessarily the most recent width before that jump.

The constructor validates finite structural values through its schema but does not enforce the relationships min <= max or step > 0. Supply those semantic constraints yourself. A zero step makes arrow presses inert; a negative step reverses conventional movement. If viewport changes alter available space, update constraints and clamp the current size coherently rather than exposing an impossible layout range.

Translate pointer movement into the same value

The component captures the primary pointer on a left-button/touch press, keeping movement attached to the separator even when the pointer crosses into either pane. Vertical separators use horizontal movement; horizontal separators use vertical movement. The gesture starts from the current value, so grabbing an edge of a thick handle does not snap the layout to the pointer’s absolute position. Every movement goes through setValue, retaining the same min/max clamp as keyboard changes.

Without valuePerPixel, the parent dimension minus separator thickness represents 100 value units. This fits the default percentage range and a two-pane grid using value and 100 - value fractional tracks. For a pixel-based pane layout, pass valuePerPixel: 1 instead. Other units need a positive, finite scale. Scale and orientation are sampled at gesture start. Positive axis-aligned CSS scaling is supported; rotation, skew, or reversed visual layouts need a separate coordinate policy. See the WindowSplitter API for custom drag scaling.

Pointer up, cancellation, lost capture, and render-Scope teardown release the session. A second pointer cannot take over an active gesture. The host composes caller styles with touch-action: none so touch movement remains a resize gesture rather than page scrolling. This uses the normal reactive style binding and preserves the caller’s other style declarations. Keep the full supplied props on the actual separator when overriding its host.

The component does not persist preferred widths or set pane CSS itself. Persist a chosen value at an application boundary if needed; keep ARIA and visible layout derived from that same value.

The APG window splitter pattern describes the focusable separator interaction. MDN’s separator role reference distinguishes a static separator from an adjustable one. This component uses role=separator with range and controls attributes; a decorative Separator is not interchangeable with it.

Validate the layout and the announced range together

Check pane width and aria-valuenow together after keys and pointer drags. Verify clamping, collapse/restore, disabled behavior, and removal during a drag. If only one output changes, look for a missing style subscription or a second sizing state bypassing the family.

The splitter does not need a collection and has no selected-versus-active item distinction: its value is a continuous layout choice and its focus is the actual separator. Keep that simpler model instead of importing a roving registry intended for multi-item widgets. Public API: WindowSplitter.