feat(ui): build LayoutSplitter and CustomScrollbar for real
Quality / quality (ubuntu-latest) (push) Failing after 6m8s
Quality / quality (windows-latest) (push) Canceled after 0s

Both advertised behaviour they did not have. LayoutSplitter declared
resizeStart, resize and resizeEnd with no pointer handling whatsoever, so a
caller wired up @resize and received nothing, for ever, with no error, and its
props were columns, gap and maxWidth copied from a grid scaffold.
CustomScrollbar was the same shape with a scroll output.

The splitter now resizes. Dragging lives in the reactive runtime behind
data-wrn-splitter, because a pointermove fires far too often to route through
a client function and a state write made in that callback is dropped; the
resolved size is held on the container as a --wrn-split custom property and
the component grids from it. The handle is a real separator: arrow keys step
it, Home and End go to the bounds rather than to nothing, and it carries
aria-valuenow, aria-valuemin and aria-valuemax. minSize fixes both bounds so
neither pane can be dragged away and left unrecoverable.

CustomScrollbar is CSS rather than script -- scrollbar-width and
scrollbar-color with webkit rules for the engines that still need them -- and
its fake scroll output is removed rather than left unimplemented, since a
caller can listen for a plain scroll event.

The test harness needed a fix too: mount did not bind the window CustomEvent,
so the runtime built events from the host global and happy-dom listeners never
matched them, which made anything dispatched look silently lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 11:22:45 +05:30
co-authored by Claude Opus 5
parent b3a4d80df3
commit b56cb8cba5
15 changed files with 794 additions and 184 deletions
+110
View File
@@ -3263,6 +3263,115 @@ export const REACTIVE_RUNTIME = String.raw`
});
}
/*
* Resizable split panes.
*
* The resolved size is written onto the container as a --wrn-split custom
* property and the component styles from it. Pointer moves fire far too
* often to route through a client function, and a state write made inside a
* pointermove callback is dropped, so the DOM holds the answer.
*/
var SPLITTER_SELECTOR = "[data-wrn-splitter]";
function splitterNumber(element, name, fallback) {
var raw = Number(element.getAttribute(name));
return isFinite(raw) && raw !== 0 ? raw : fallback;
}
function splitterBounds(root) {
var min = splitterNumber(root, "data-wrn-splitter-min", 10);
return { min: min, max: 100 - min };
}
function applySplit(root, handle, size) {
var bounds = splitterBounds(root);
var next = Math.min(bounds.max, Math.max(bounds.min, size));
next = Math.round(next * 100) / 100;
root.style.setProperty("--wrn-split", next + "%");
if (handle) {
handle.setAttribute("aria-valuenow", String(next));
handle.setAttribute("aria-valuemin", String(bounds.min));
handle.setAttribute("aria-valuemax", String(bounds.max));
}
// Named for the component output so a parent @resize binding receives it.
root.dispatchEvent(new CustomEvent("resize", { detail: { size: next } }));
return next;
}
function currentSplit(root, handle) {
var fromHandle = Number(handle && handle.getAttribute("aria-valuenow"));
if (isFinite(fromHandle) && fromHandle) return fromHandle;
var raw = String(root.style.getPropertyValue("--wrn-split") || "").replace("%", "");
var parsed = Number(raw);
return isFinite(parsed) && parsed ? parsed : 50;
}
function handleSplitterKeydown(event) {
var target = event.target;
if (!target || !target.closest) return;
var handle = target.closest("[data-wrn-splitter-handle]");
if (!handle) return;
var root = handle.closest(SPLITTER_SELECTOR);
if (!root) return;
var vertical = root.getAttribute("data-wrn-splitter") === "vertical";
var step = splitterNumber(root, "data-wrn-splitter-step", 5);
var bounds = splitterBounds(root);
var size = currentSplit(root, handle);
var key = event.key;
var next = size;
if (key === (vertical ? "ArrowDown" : "ArrowRight")) next = size + step;
else if (key === (vertical ? "ArrowUp" : "ArrowLeft")) next = size - step;
else if (key === "Home") next = bounds.min;
else if (key === "End") next = bounds.max;
else return;
event.preventDefault();
applySplit(root, handle, next);
}
function setupSplitters() {
if (window.__wrnexusSplitterBound) return;
window.__wrnexusSplitterBound = true;
document.addEventListener("keydown", handleSplitterKeydown, true);
var dragging = null;
document.addEventListener("pointerdown", function (event) {
var target = event.target;
var handle = target && target.closest ? target.closest("[data-wrn-splitter-handle]") : null;
if (!handle) return;
var root = handle.closest(SPLITTER_SELECTOR);
if (!root) return;
dragging = { root: root, handle: handle };
root.setAttribute("data-wrn-splitter-dragging", "true");
if (handle.setPointerCapture && event.pointerId !== undefined) {
handle.setPointerCapture(event.pointerId);
}
event.preventDefault();
});
document.addEventListener("pointermove", function (event) {
if (!dragging) return;
var rect = dragging.root.getBoundingClientRect();
var vertical = dragging.root.getAttribute("data-wrn-splitter") === "vertical";
var span = vertical ? rect.height : rect.width;
if (!span) return;
var offset = vertical ? event.clientY - rect.top : event.clientX - rect.left;
applySplit(dragging.root, dragging.handle, (offset / span) * 100);
});
var endDrag = function () {
if (!dragging) return;
dragging.root.removeAttribute("data-wrn-splitter-dragging");
dragging = null;
};
document.addEventListener("pointerup", endDrag);
document.addEventListener("pointercancel", endDrag);
}
function hydrateScopes(root) {
var host = root || document;
@@ -5401,6 +5510,7 @@ export const REACTIVE_RUNTIME = String.raw`
setupModalDialogs();
setupRovingFocus();
setupScrollspy();
setupSplitters();
startDocumentWatch();
window.__wrnexusRepositionAnchored = repositionAnchored;
window.__wrnexusHydrateScopes = hydrateScopes;