feat(ui): build LayoutSplitter and CustomScrollbar for real
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user