@19h47/windowsplitter
v1.0.0
Published
Window splitter controller with accessibility in mind
Maintainers
Readme
@19h47/windowsplitter
Window splitter behaviour for UIs where a focusable role="separator" must resize a primary pane through JavaScript orchestration, while HTML remains the source of truth for ARIA.
Based on the W3C APG Window Splitter pattern: Window Splitter Pattern
Install
pnpm add @19h47/windowsplitterHTML
HTML is the source of truth. Set role, tabindex, aria-orientation, aria-valuemin, aria-valuemax, aria-valuenow, labelling attributes, and aria-controls (primary pane) in the markup; the library reads them and mirrors layout onto the separator and primary pane.
aria-valuemin / aria-valuemax must match the real allowed range of the primary pane (not always 0–100). If the pane cannot fully collapse, set min above 0.
Note on
role: the APG documentsrole="separator". In practice, JAWS / NVDA / VoiceOver often handle a focusablerole="slider"more reliably (discussion). The library does not enforce the role — pick what works for your AT testing.
<div class="panes">
<div id="toc" class="pane pane--primary">Table of contents</div>
<div
role="separator"
tabindex="0"
aria-orientation="vertical"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="30"
aria-controls="toc"
aria-label="Table of Contents"
></div>
<div class="pane pane--secondary">Content</div>
</div>JavaScript
import WindowSplitter from '@19h47/windowsplitter';
const $separator = document.querySelector('[role="separator"]');
const splitter = new WindowSplitter($separator);
splitter.init();Options
type Orientation = 'horizontal' | 'vertical';
type Mode = 'resize' | 'clip' | 'none';
type SizeContext = {
value: number;
ratio: number;
offset: number;
length: number;
};
type Options = {
orientation?: Orientation; // default: aria-orientation, then 'vertical'
mode?: Mode; // default: 'resize'
step?: number; // default: 1
page?: number; // default: 10
fixed?: boolean; // default: false (Enter-only when true)
container?: HTMLElement | null; // default: el.parentElement
formatSize?: (context: SizeContext) => string; // default: `${value}%`
formatValue?: (value: number) => string; // default: String(value) → aria-valuetext
};| Option | Description |
| ------------- | --------------------------------------------------------------------------- |
| orientation | vertical = left/right move; horizontal = up/down move |
| mode | resize sets primary width/height; clip sets clip-path; none events only |
| step | Arrow key increment |
| page | PageUp / PageDown / Shift+Arrow increment |
| fixed | Toggle-only splitter (no arrow keys; pointer toggles like Enter) |
| container | Element used for drag bounds (defaults to parent) |
| formatSize | CSS size for the primary pane in resize mode (unit, not a label) |
| formatValue | aria-valuetext string (localization, units in the announced name) |
// CSS pixels instead of percent
new WindowSplitter($separator, {
formatSize: ({ offset }) => `${offset}px`,
});
// French typography for screen readers (space before %)
new WindowSplitter($separator, {
formatValue: value => `${value} %`,
});Comparison images (mode: 'clip')
Same interaction as a before/after slider: overlay panes and clip the primary (before) layer.
const splitter = new WindowSplitter($separator, {
mode: 'clip',
step: 5,
page: 20,
});
splitter.init();Keyboard Support
| Key | Function |
| ----------- | ------------------------------------------------------------------------ |
| Left Arrow | Moves a vertical splitter to the left. |
| Right Arrow | Moves a vertical splitter to the right. |
| Up Arrow | Moves a horizontal splitter up. |
| Down Arrow | Moves a horizontal splitter down. |
| Enter | Collapses the primary pane, or restores the previous position. |
| Home | Moves splitter to aria-valuemin (optional in APG). |
| End | Moves splitter to aria-valuemax (optional in APG). |
| Page Up | Increases value by page. |
| Page Down | Decreases value by page. |
| Shift+Arrow | Uses page as the step size. |
A fixed splitter (fixed: true) omits arrow keys and only implements Enter (and pointer toggle).
F6 (optional pane cycling in the APG) is not implemented: it conflicts with browser chrome shortcuts and belongs to the app, not this controller.
Role, Property, State, and Tabindex Attributes
| Role | Attribute | Element | Usage |
| ----------- | ----------------------- | ------- | --------------------------------------------------------------------- |
| separator | | focusable splitter | Identifies the element as a window splitter. |
| | tabindex="0" | splitter | Includes the separator in the page tab sequence. |
| | aria-orientation | splitter | vertical or horizontal. |
| | aria-valuenow | splitter | Current size of the primary pane. Written by the library on change. |
| | aria-valuetext | splitter | Mirrored from aria-valuenow by the library (sync / setValue). |
| | aria-valuemin | splitter | Minimum primary pane size (typically 0). |
| | aria-valuemax | splitter | Maximum primary pane size (typically 100). |
| | aria-controls | splitter | References the primary pane id. |
| | aria-label / aria-labelledby | splitter | Accessible name matching the primary pane. |
| | aria-disabled="true" | splitter | Disables pointer and keyboard interaction. Prefer tabindex="-1". |
Methods
| Method | Description | Arguments |
| ------------- | ------------------------------------------------ | ------------------------------------------------------ |
| init() | Bind events and sync from HTML | |
| destroy() | Remove event listeners | |
| sync() | Re-apply HTML ? layout | |
| setValue() | Set aria-valuenow / aria-valuetext and update layout | value, trigger (optional, default true) |
| collapse() | Collapse primary pane to aria-valuemin | trigger (optional, default true) |
| restore() | Restore previous value after collapse | trigger (optional, default true) |
| toggle() | Collapse or restore | trigger (optional, default true) |
splitter.setValue(40);
splitter.collapse();
splitter.restore();
splitter.destroy();Getters
| Getter | Type | Source |
| ------------ | --------- | ------------------------------------------- |
| value | number | aria-valuenow |
| min | number | aria-valuemin |
| max | number | aria-valuemax |
| ratio | number | normalized 0
1 between min and max |
| disabled | boolean | aria-disabled="true" |
| collapsed | boolean | value === min |
| vertical | boolean | orientation is vertical |
| $primary | element | pane referenced by aria-controls |
| $container | element | drag bounds container |
Sync classes & CSS variables
sync() / setValue() mirror state onto optional CSS hooks and keep aria-valuetext in sync with aria-valuenow:
| Class / variable | When / meaning |
| ----------------------------- | --------------------------------------- |
| is-focus | Separator has focus |
| is-dragging | Pointer drag in progress |
| is-collapsed | Primary pane at aria-valuemin |
| is-disabled | aria-disabled="true" |
| --windowsplitter-value | Current value on the container |
| --windowsplitter-ratio | 0
1 ratio on the container |
| --windowsplitter-offset | Pixel offset of the separator |
The library does not ship styles for these classes use them in your own CSS.
Events
Events are dispatched on the separator element.
WindowSplitter.change
$separator.addEventListener('WindowSplitter.change', event => {
const { value, min, max, ratio, collapsed } = event.detail;
console.log({ value, min, max, ratio, collapsed });
});Native-like input and change events are also fired when trigger is true.
Example
An interactive demo is at 19h47.github.io/19h47-windowsplitter when published.
Run tests with pnpm test.
References
- Window Splitter Pattern
- ARIA
separatorrole - APG example tracking (#130) — still open; no official example yet
- Pattern draft notes (#129) —
aria-expandedremoved in ARIA 1.1; value attrs required
