// ============================================================================
// Stylescape | Split Pane Manager
// ============================================================================
// Resizable split panes and sidebars: a focusable separator that can be
// dragged with a pointer or moved with the keyboard (WAI-ARIA window
// splitter pattern), with min/max limits, an optional collapse toggle and
// optional persistence in localStorage.
// ============================================================================
/**
* Configuration options for SplitPaneManager. Every option can also be set
* on the handle element (attributes listed per option).
*/
export interface SplitPaneOptions {
/** Smallest primary pane size in percent (`aria-valuemin`, default 10) */
min?: number;
/** Largest primary pane size in percent (`aria-valuemax`, default 90) */
max?: number;
/** Keyboard step in percent (`data-ss-split-step`, default 5) */
step?: number;
/** localStorage key to remember size and collapsed state (`data-ss-split-key`) */
storageKey?: string;
/** Custom property written on the root (`data-ss-split-property`, default `--ss-split-size`) */
property?: string;
/** Unit written to the property: `%` of the root, or `px` (`data-ss-split-unit`) */
unit?: "%" | "px";
/** Callback after every size change, with the size in percent */
onResize?: (size: number) => void;
/** Callback when the primary pane collapses or expands */
onToggle?: (collapsed: boolean) => void;
}
interface StoredState {
size: number;
collapsed: boolean;
}
const HANDLE_SELECTOR = ".ss-c-split__handle, [data-ss-split-handle]";
const TOGGLE_SELECTOR = ".ss-c-split__toggle, [data-ss-split-toggle]";
const GUTTER_SELECTOR = ".ss-c-split__gutter";
/**
* Accessible split pane / resizable sidebar.
*
* The handle is a `role="separator"` with `aria-controls` naming the
* primary pane (the one being sized). Its `aria-valuenow` is that pane's
* size in percent of the split root. Arrow keys move the separator, Home
* and End jump to the minimum and maximum, Enter collapses or restores the
* primary pane.
*
* @example HTML
* ```html
*
* ```
*
* @example JavaScript
* ```typescript
* SplitPaneManager.initAll()
* ```
*/
export class SplitPaneManager {
readonly handle: HTMLElement;
readonly pane: HTMLElement;
readonly root: HTMLElement;
private readonly options: Required<
Omit
> &
Pick;
private readonly horizontal: boolean;
private readonly paneAtStart: boolean;
private readonly toggles: HTMLElement[];
private size: number;
private collapsed = false;
private dragging = false;
constructor(
handleOrSelector: string | HTMLElement,
options: SplitPaneOptions = {},
) {
const handle =
typeof handleOrSelector === "string"
? document.querySelector(handleOrSelector)
: handleOrSelector;
if (!handle) {
throw new Error("SplitPaneManager: handle element not found");
}
this.handle = handle;
const ds = handle.dataset;
const num = (value: string | null | undefined, fallback: number) => {
const n =
value === null || value === undefined || value === ""
? NaN
: Number(value);
return Number.isFinite(n) ? n : fallback;
};
this.options = {
min: options.min ?? num(handle.getAttribute("aria-valuemin"), 10),
max: options.max ?? num(handle.getAttribute("aria-valuemax"), 90),
step: options.step ?? num(ds.ssSplitStep, 5),
storageKey: options.storageKey ?? ds.ssSplitKey,
property:
options.property ?? ds.ssSplitProperty ?? "--ss-split-size",
unit: options.unit ?? (ds.ssSplitUnit === "px" ? "px" : "%"),
onResize: options.onResize,
onToggle: options.onToggle,
};
// A gutter wraps the handle when it also holds a collapse toggle (a
// separator must not contain another focusable control).
const strip = handle.closest(GUTTER_SELECTOR) ?? handle;
const controls = handle.getAttribute("aria-controls");
const pane =
(controls && document.getElementById(controls)) ||
strip.parentElement?.querySelector(
":scope > .ss-c-split__pane--primary",
) ||
(strip.previousElementSibling as HTMLElement | null);
if (!pane) {
throw new Error("SplitPaneManager: primary pane not found");
}
this.pane = pane;
this.root =
handle.closest("[data-ss-split-root]") ||
handle.closest(".ss-c-split") ||
(pane.contains(handle) ? pane.parentElement : null) ||
handle.parentElement ||
document.documentElement;
// aria-orientation is the separator's own orientation: a vertical
// line splits panes side by side.
this.horizontal =
handle.getAttribute("aria-orientation") !== "horizontal";
this.paneAtStart = pane.contains(handle)
? !handle.classList.contains("ss-c-split__handle--end") &&
ds.ssSplitSide !== "end"
: Boolean(
pane.compareDocumentPosition(strip) &
Node.DOCUMENT_POSITION_FOLLOWING,
);
this.toggles = [
...Array.from(
strip === handle
? []
: strip.querySelectorAll(TOGGLE_SELECTOR),
),
...Array.from(
document.querySelectorAll(
"[data-ss-split-toggle][aria-controls]",
),
).filter(
(el) =>
pane.id !== "" &&
el.getAttribute("aria-controls") === pane.id &&
!strip.contains(el),
),
];
if (!handle.hasAttribute("role"))
handle.setAttribute("role", "separator");
if (!handle.hasAttribute("tabindex")) handle.tabIndex = 0;
if (!handle.hasAttribute("aria-orientation")) {
handle.setAttribute("aria-orientation", "vertical");
}
handle.setAttribute("aria-valuemin", String(this.options.min));
handle.setAttribute("aria-valuemax", String(this.options.max));
const stored = this.load();
this.size = this.clamp(
stored?.size ??
num(handle.getAttribute("aria-valuenow"), NaN) ??
NaN,
);
if (!Number.isFinite(this.size)) this.size = this.measure();
this.apply();
if (stored?.collapsed) this.collapse(false);
else this.syncToggles();
handle.addEventListener("pointerdown", this.onPointerDown);
handle.addEventListener("keydown", this.onKeyDown);
this.toggles.forEach((t) =>
t.addEventListener("click", this.onToggleClick),
);
}
// ========================================================================
// Public API
// ========================================================================
/** Current primary pane size in percent of the root. */
getSize(): number {
return this.size;
}
isCollapsed(): boolean {
return this.collapsed;
}
/** Set the primary pane size in percent (clamped to min/max). */
setSize(size: number, persist = true): void {
this.size = this.clamp(size);
if (this.collapsed) this.expand(false);
this.apply();
if (persist) this.save();
this.options.onResize?.(this.size);
}
collapse(persist = true): void {
this.collapsed = true;
this.pane.hidden = true;
this.root.dataset.state = "collapsed";
this.handle.setAttribute("aria-valuenow", "0");
this.syncToggles();
if (persist) this.save();
this.options.onToggle?.(true);
}
expand(persist = true): void {
this.collapsed = false;
this.pane.hidden = false;
if (this.root.dataset.state === "collapsed") {
delete this.root.dataset.state;
}
this.apply();
this.syncToggles();
if (persist) this.save();
this.options.onToggle?.(false);
}
toggle(): void {
if (this.collapsed) this.expand();
else this.collapse();
}
destroy(): void {
this.handle.removeEventListener("pointerdown", this.onPointerDown);
this.handle.removeEventListener("keydown", this.onKeyDown);
this.handle.removeEventListener("pointermove", this.onPointerMove);
this.handle.removeEventListener("pointerup", this.onPointerUp);
this.handle.removeEventListener("pointercancel", this.onPointerUp);
this.toggles.forEach((t) =>
t.removeEventListener("click", this.onToggleClick),
);
}
/** Initialise every split handle under `root`. */
static initAll(root: ParentNode = document): SplitPaneManager[] {
return Array.from(
root.querySelectorAll(HANDLE_SELECTOR),
).map((handle) => new SplitPaneManager(handle));
}
// ========================================================================
// Events
// ========================================================================
private onPointerDown = (event: PointerEvent): void => {
if (event.button !== 0) return;
if ((event.target as Element).closest(TOGGLE_SELECTOR)) return;
event.preventDefault();
this.dragging = true;
this.handle.classList.add("is-dragging");
this.handle.setPointerCapture?.(event.pointerId);
this.handle.addEventListener("pointermove", this.onPointerMove);
this.handle.addEventListener("pointerup", this.onPointerUp);
this.handle.addEventListener("pointercancel", this.onPointerUp);
this.handle.focus();
};
private onPointerMove = (event: PointerEvent): void => {
if (!this.dragging) return;
const rootRect = this.root.getBoundingClientRect();
const total = this.horizontal ? rootRect.width : rootRect.height;
if (total <= 0) return;
// Measure from the pane's outer edge; if the pane is hidden
// (collapsed), from the root's matching edge.
const rect = this.collapsed
? rootRect
: this.pane.getBoundingClientRect();
const pointer = this.horizontal ? event.clientX : event.clientY;
const start = this.horizontal ? rect.left : rect.top;
const end = this.horizontal ? rect.right : rect.bottom;
const px = this.paneAtStart ? pointer - start : end - pointer;
this.setSize((px / total) * 100, false);
};
private onPointerUp = (event: PointerEvent): void => {
this.dragging = false;
this.handle.classList.remove("is-dragging");
this.handle.releasePointerCapture?.(event.pointerId);
this.handle.removeEventListener("pointermove", this.onPointerMove);
this.handle.removeEventListener("pointerup", this.onPointerUp);
this.handle.removeEventListener("pointercancel", this.onPointerUp);
this.save();
};
private onKeyDown = (event: KeyboardEvent): void => {
const forward = this.horizontal ? "ArrowRight" : "ArrowDown";
const backward = this.horizontal ? "ArrowLeft" : "ArrowUp";
// Moving the separator toward the far side grows a start pane.
const sign = this.paneAtStart ? 1 : -1;
const base = this.collapsed ? this.options.min : this.size;
switch (event.key) {
case forward:
this.setSize(base + sign * this.options.step);
break;
case backward:
this.setSize(base - sign * this.options.step);
break;
case "Home":
this.setSize(this.options.min);
break;
case "End":
this.setSize(this.options.max);
break;
case "Enter":
this.toggle();
break;
default:
return;
}
event.preventDefault();
};
private onToggleClick = (event: Event): void => {
event.preventDefault();
this.toggle();
};
// ========================================================================
// Internals
// ========================================================================
private clamp(value: number): number {
if (!Number.isFinite(value)) return value;
return Math.min(this.options.max, Math.max(this.options.min, value));
}
private measure(): number {
const rootRect = this.root.getBoundingClientRect();
const paneRect = this.pane.getBoundingClientRect();
const total = this.horizontal ? rootRect.width : rootRect.height;
const part = this.horizontal ? paneRect.width : paneRect.height;
const pct = total > 0 ? (part / total) * 100 : NaN;
return this.clamp(
Number.isFinite(pct) && pct > 0
? pct
: (this.options.min + this.options.max) / 2,
);
}
private apply(): void {
const rounded = Math.round(this.size * 100) / 100;
let value = `${rounded}%`;
if (this.options.unit === "px") {
const rect = this.root.getBoundingClientRect();
const total = this.horizontal ? rect.width : rect.height;
if (total > 0)
value = `${Math.round((this.size / 100) * total)}px`;
}
this.root.style.setProperty(this.options.property, value);
if (!this.collapsed) {
this.handle.setAttribute(
"aria-valuenow",
String(Math.round(this.size)),
);
}
}
private syncToggles(): void {
this.toggles.forEach((t) =>
t.setAttribute("aria-expanded", String(!this.collapsed)),
);
}
private load(): StoredState | null {
if (!this.options.storageKey) return null;
try {
const raw = localStorage.getItem(this.options.storageKey);
if (!raw) return null;
const data = JSON.parse(raw) as Partial;
return typeof data.size === "number"
? { size: data.size, collapsed: Boolean(data.collapsed) }
: null;
} catch {
return null;
}
}
private save(): void {
if (!this.options.storageKey) return;
try {
localStorage.setItem(
this.options.storageKey,
JSON.stringify({ size: this.size, collapsed: this.collapsed }),
);
} catch {
// Storage unavailable (private mode, quota): the size still
// applies for this page view.
}
}
}