Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432import { 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 `<nib-split>` 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 `<nib-split>`. 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 `<nib-split>` while a pointer drags this gutter, so it keeps its active look off-hover. */ set dragging(value: boolean) { setState(this.#internals, "dragging", value); }
/** Internal: `<nib-split>` 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 `<nib-split>`. 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 `<nib-panel>` children are assigned to manual slots in the shadow * root, with a `<nib-gutter>` 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<number, number>();
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);