import { define, setState, sheet } from "../internals.ts"; export type Axis = "row" | "column"; // row = children side by side /** The smallest a panel gets along its split's axis when no `minSize` is set. */ const DEFAULT_MIN = { row: 160, column: 80 } as const; /** Fired by `` while a gutter drags or steps. `committed` is false mid-drag, true on release and each keyboard step. */ export class SplitResizeEvent extends Event { readonly sizes: readonly number[]; readonly committed: boolean; constructor(sizes: readonly number[], committed: boolean) { super("resize", { bubbles: true }); this.sizes = sizes; this.committed = committed; } } /* * The gutter takes a hairline of layout space. ::before widens its pointer target past the line, * over the neighbouring panels, and ::after is the bar it shows while hovered, focused, or dragged. * It stacks above its panels so that target isn't covered. */ const gutterSheet = sheet(` :host { --overhang: calc((var(--nib-size-gutter-hitbox) - var(--nib-size-gutter)) / -2); --bar: calc((var(--nib-size-gutter-active) - var(--nib-size-gutter)) / -2); position: relative; z-index: 1; display: block; background: var(--nib-color-border); touch-action: none; pointer-events: auto; outline: none; } :host::before, :host::after { content: ""; position: absolute; } :host::after { background: var(--nib-color-accent); visibility: hidden; } :host(:state(vertical)) { cursor: col-resize; } :host(:state(vertical))::before { inset-block: 0; inset-inline: var(--overhang); } :host(:state(vertical))::after { inset-block: 0; inset-inline: var(--bar); } :host(:state(horizontal)) { cursor: row-resize; } :host(:state(horizontal))::before { inset-inline: 0; inset-block: var(--overhang); } :host(:state(horizontal))::after { inset-inline: 0; inset-block: var(--bar); } :host(:is(:hover, :focus-visible, :state(dragging)))::after { visibility: visible; } `); /** * A WAI-ARIA window splitter, created and owned by a ``. Its role, orientation, and * value all live on `ElementInternals`, so it carries no ARIA attributes. */ export class GutterElement extends HTMLElement { readonly #internals = this.attachInternals(); constructor() { super(); this.#internals.role = "separator"; const root = this.attachShadow({ mode: "open" }); root.adoptedStyleSheets = [gutterSheet]; } connectedCallback(): void { this.tabIndex = 0; } /** Internal: set by `` while a pointer drags this gutter, so it keeps its active look off-hover. */ set dragging(value: boolean) { setState(this.#internals, "dragging", value); } /** Internal: `` configures the gutter it owns on every layout pass. */ configure(options: { orientation: "vertical" | "horizontal"; valueNow: number; valueMin: number; valueMax: number; label: string; controls: Element }): void { this.#internals.ariaOrientation = options.orientation; this.#internals.ariaValueNow = String(options.valueNow); this.#internals.ariaValueMin = String(options.valueMin); this.#internals.ariaValueMax = String(options.valueMax); this.#internals.ariaLabel = options.label; this.#internals.ariaControlsElements = [options.controls]; setState(this.#internals, "vertical", options.orientation === "vertical"); setState(this.#internals, "horizontal", options.orientation === "horizontal"); } } const panelSheet = sheet(` :host { display: flex; min-width: 0; min-height: 0; /* Panels get their size from the split, so size containment never fights their own content. */ container: nib-panel / size; } /* A panel holds exactly one thing (a stack, or a nested split) and must stretch it to fill; a layout primitive doesn't pad its content, that's for whatever the panel holds to decide. */ ::slotted(*) { flex: 1 1 auto; min-width: 0; min-height: 0; } `); /** One pane of a ``. Its label names the gutter that controls it. */ export class PanelElement extends HTMLElement { #label = ""; constructor() { super(); const root = this.attachShadow({ mode: "open" }); root.adoptedStyleSheets = [panelSheet]; root.append(document.createElement("slot")); } get label(): string { return this.#label; } set label(value: string) { this.#label = value; } } const splitSheet = sheet(` :host { display: flex; min-width: 0; min-height: 0; container: nib-split / size; } :host(:state(row)) { flex-direction: row; } :host(:state(column)) { flex-direction: column; } slot { display: flex; flex-shrink: 0; flex-basis: 0; min-width: 0; min-height: 0; } ::slotted(nib-panel) { flex: 1 1 auto; min-width: 0; min-height: 0; } nib-gutter { flex: 0 0 var(--nib-size-gutter); } `); /** The share (0..1), and its pixel-derived clamp bounds, of the panel preceding a gutter. */ interface PairInfo { readonly shareNow: number; readonly minShare: number; readonly maxShare: number; readonly total: number; readonly pairWeight: number; } /** Normalizes `raw` to `count` weights summing to 1, falling back to equal weights on any mismatch. */ function resolveWeights(raw: readonly number[] | undefined, count: number): number[] { if (count === 0) return []; if (!raw || raw.length !== count) return Array(count).fill(1 / count); const clamped = raw.map((n) => (Number.isFinite(n) && n > 0 ? n : 0)); const total = clamped.reduce((sum, n) => sum + n, 0); return total > 0 ? clamped.map((n) => n / total) : Array(count).fill(1 / count); } /** * A resizable split. Light-DOM `` children are assigned to manual slots in the shadow * root, with a `` between each pair. Structure reacts to panels being added, removed, * or reordered; the consumer owns nothing but `axis`, `sizes`, and `minSize`. */ export class SplitElement extends HTMLElement { readonly #internals = this.attachInternals(); readonly #root: ShadowRoot; readonly #observer: MutationObserver; #axis: Axis = "row"; #minSize: number | undefined; #rawSizes: readonly number[] | undefined; #weights: number[] = []; #panels: PanelElement[] = []; #slots: HTMLSlotElement[] = []; #gutters: GutterElement[] = []; #collapsedMemory = new Map(); constructor() { super(); this.#root = this.attachShadow({ mode: "open", slotAssignment: "manual" }); this.#root.adoptedStyleSheets = [splitSheet]; setState(this.#internals, "row", true); this.#observer = new MutationObserver(() => this.#rebuild()); } connectedCallback(): void { this.#observer.observe(this, { childList: true }); this.#rebuild(); } disconnectedCallback(): void { this.#observer.disconnect(); } get axis(): Axis { return this.#axis; } set axis(value: Axis) { this.#axis = value; setState(this.#internals, "row", value === "row"); setState(this.#internals, "column", value === "column"); this.#applyLayout(); } get sizes(): readonly number[] { return this.#weights; } set sizes(value: readonly number[]) { this.#rawSizes = value; this.#weights = resolveWeights(this.#rawSizes, this.#panels.length); this.#applyLayout(); } get minSize(): number | undefined { return this.#minSize; } set minSize(value: number | undefined) { this.#minSize = value; this.#applyLayout(); } /** * The smallest this split can get along `axis` before its panels overflow: along its own axis, * every panel's minimum plus the gutters between them; across it, the largest panel minimum. */ minAlong(axis: Axis): number { const mins = this.#panels.map((panel) => this.#panelMin(panel, axis)); if (mins.length === 0) return 0; if (axis !== this.#axis) return Math.max(...mins); const gutters = this.#gutters.reduce((sum, g) => sum + (axis === "row" ? g.offsetWidth : g.offsetHeight), 0); return mins.reduce((sum, min) => sum + min, 0) + gutters; } /** A panel's minimum along `axis`: a nested split's own minimum, or the leaf minimum. */ #panelMin(panel: PanelElement | undefined, axis: Axis = this.#axis): number { const nested = panel && [...panel.children].find((child): child is SplitElement => child instanceof SplitElement); if (nested) return nested.minAlong(axis); return axis === this.#axis ? (this.#minSize ?? DEFAULT_MIN[axis]) : DEFAULT_MIN[axis]; } /** A nested split's minimum feeds its parent's, so a change here re-lays the parent out. */ #relayoutParent(): void { const parent = this.parentElement?.parentElement; if (parent instanceof SplitElement) parent.#applyLayout(); } #rebuild(): void { const panels = [...this.children].filter((el): el is PanelElement => el instanceof PanelElement); this.#panels = panels; this.#weights = resolveWeights(this.#rawSizes, panels.length); this.#collapsedMemory.clear(); const slots: HTMLSlotElement[] = []; const gutters: GutterElement[] = []; const children: Element[] = []; panels.forEach((panel, i) => { if (i > 0) { const g = this.#makeGutter(); gutters.push(g); children.push(g); } const slot = document.createElement("slot"); slot.assign(panel); slots.push(slot); children.push(slot); }); this.#slots = slots; this.#gutters = gutters; this.#root.replaceChildren(...children); this.#applyLayout(); this.#relayoutParent(); } #makeGutter(): GutterElement { const gutter = document.createElement("nib-gutter"); gutter.addEventListener("pointerdown", (event) => this.#onPointerDown(gutter, event)); gutter.addEventListener("keydown", (event) => this.#onKeydown(gutter, event)); return gutter; } #applyLayout(): void { const horizontal = this.#axis === "row"; this.#slots.forEach((slot, i) => { const minPx = this.#panelMin(this.#panels[i]); slot.style.flexGrow = String(this.#weights[i] ?? 0); slot.style.minWidth = horizontal ? `${minPx}px` : ""; slot.style.minHeight = horizontal ? "" : `${minPx}px`; }); this.#gutters.forEach((gutter, i) => { const info = this.#pairInfo(i); const panel = this.#panels[i]; gutter.configure({ orientation: horizontal ? "vertical" : "horizontal", valueNow: Math.round(info.shareNow * 100), valueMin: Math.round(info.minShare * 100), valueMax: Math.round(info.maxShare * 100), label: panel?.label ? `Resize ${panel.label}` : "Resize", controls: panel!, }); }); } #pairInfo(index: number): PairInfo { const a = this.#slots[index]; const b = this.#slots[index + 1]; const horizontal = this.#axis === "row"; const lengthOf = (el: Element | undefined): number => { if (!el) return 0; const rect = el.getBoundingClientRect(); return horizontal ? rect.width : rect.height; }; const lengthA = lengthOf(a); const lengthB = lengthOf(b); const total = lengthA + lengthB; const weightA = this.#weights[index] ?? 0; const weightB = this.#weights[index + 1] ?? 0; const pairWeight = weightA + weightB; const shareNow = pairWeight > 0 ? weightA / pairWeight : 0.5; if (total <= 0) return { shareNow, minShare: 0, maxShare: 1, total, pairWeight }; const minA = this.#panelMin(this.#panels[index]); const minB = this.#panelMin(this.#panels[index + 1]); // Too small for both minimums: split the shortfall in proportion rather than starve one side. if (minA + minB >= total) { const share = minA / (minA + minB); return { shareNow, minShare: share, maxShare: share, total, pairWeight }; } return { shareNow, minShare: minA / total, maxShare: 1 - minB / total, total, pairWeight }; } #setPairShare(index: number, share: number): void { const info = this.#pairInfo(index); const clamped = Math.min(Math.max(share, info.minShare), info.maxShare); this.#weights[index] = info.pairWeight * clamped; this.#weights[index + 1] = info.pairWeight * (1 - clamped); this.#collapsedMemory.delete(index); } #dispatchResize(committed: boolean): void { if (committed) this.#rawSizes = this.#weights.slice(); this.dispatchEvent(new SplitResizeEvent(this.#weights.slice(), committed)); } #onPointerDown(gutter: GutterElement, start: PointerEvent): void { const index = this.#gutters.indexOf(gutter); if (index === -1) return; const info = this.#pairInfo(index); if (info.total <= 0) return; start.preventDefault(); gutter.setPointerCapture(start.pointerId); this.#collapsedMemory.delete(index); const horizontal = this.#axis === "row"; const startCoord = horizontal ? start.clientX : start.clientY; const lengthA = info.shareNow * info.total; const [lowPx, highPx] = [info.minShare * info.total, info.maxShare * info.total]; setState(this.#internals, "resizing", true); gutter.dragging = true; const onMove = (event: PointerEvent) => { const coord = horizontal ? event.clientX : event.clientY; const share = Math.min(Math.max(lengthA + (coord - startCoord), lowPx), highPx) / info.total; this.#weights[index] = info.pairWeight * share; this.#weights[index + 1] = info.pairWeight * (1 - share); this.#applyLayout(); this.#dispatchResize(false); }; const onUp = () => { gutter.removeEventListener("pointermove", onMove); setState(this.#internals, "resizing", false); gutter.dragging = false; this.#applyLayout(); this.#dispatchResize(true); }; gutter.addEventListener("pointermove", onMove); gutter.addEventListener("pointerup", onUp, { once: true }); } #onKeydown(gutter: GutterElement, event: KeyboardEvent): void { const index = this.#gutters.indexOf(gutter); if (index === -1) return; const horizontal = this.#axis === "row"; const forward = horizontal ? "ArrowRight" : "ArrowDown"; const backward = horizontal ? "ArrowLeft" : "ArrowUp"; const step = event.shiftKey ? 50 : 10; switch (event.key) { case forward: this.#stepPair(index, step); break; case backward: this.#stepPair(index, -step); break; case "Home": this.#setPairShare(index, this.#pairInfo(index).minShare); break; case "End": this.#setPairShare(index, this.#pairInfo(index).maxShare); break; case "Enter": this.#toggleCollapse(index); break; default: return; } event.preventDefault(); this.#applyLayout(); this.#dispatchResize(true); } #stepPair(index: number, deltaPx: number): void { const info = this.#pairInfo(index); if (info.total <= 0) return; this.#setPairShare(index, info.shareNow + deltaPx / info.total); } #toggleCollapse(index: number): void { const info = this.#pairInfo(index); const collapsedNow = Math.abs(info.shareNow - info.minShare) < 0.0025; if (collapsedNow) { const restore = this.#collapsedMemory.get(index) ?? info.maxShare; this.#collapsedMemory.delete(index); this.#setPairShare(index, restore); } else { const previous = info.shareNow; this.#setPairShare(index, info.minShare); this.#collapsedMemory.set(index, previous); } } } declare global { interface HTMLElementTagNameMap { "nib-split": SplitElement; "nib-panel": PanelElement; "nib-gutter": GutterElement; } } define("nib-gutter", GutterElement); define("nib-panel", PanelElement); define("nib-split", SplitElement);