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;
|
||||
|
||||
@@ -16,6 +16,14 @@ function mount(html: string): Window {
|
||||
(globalThis as Record<string, unknown>).MutationObserver = (
|
||||
win as unknown as { MutationObserver: unknown }
|
||||
).MutationObserver;
|
||||
/*
|
||||
* Bind the window CustomEvent too. Without it the runtime constructs events
|
||||
* from the host global, and a listener registered through happy-dom never
|
||||
* matches them, so anything dispatched looks silently lost.
|
||||
*/
|
||||
(globalThis as Record<string, unknown>).CustomEvent = (
|
||||
win as unknown as { CustomEvent: unknown }
|
||||
).CustomEvent;
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
|
||||
// test window may not fire). setupScope is idempotent, so this is safe.
|
||||
@@ -1110,3 +1118,79 @@ test("an open drawer locks the body scroll and traps Tab", () => {
|
||||
last.dispatchEvent(keydown(win, "Tab"));
|
||||
expect(doc.activeElement!.id).toBe("first");
|
||||
});
|
||||
|
||||
test("splitter keyboard steps move the divider and clamp at the bounds", () => {
|
||||
const win = mount(
|
||||
`<div data-wrn-splitter="horizontal" data-wrn-splitter-min="20" data-wrn-splitter-step="10"
|
||||
style="--wrn-split: 50%">
|
||||
<div>left</div>
|
||||
<div data-wrn-splitter-handle role="separator" tabindex="0"
|
||||
aria-valuenow="50" aria-valuemin="20" aria-valuemax="80"></div>
|
||||
<div>right</div>
|
||||
</div>`,
|
||||
);
|
||||
const doc = win.document;
|
||||
const root = doc.querySelector("[data-wrn-splitter]") as unknown as HTMLElement;
|
||||
const handle = doc.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement;
|
||||
|
||||
handle.dispatchEvent(keydown(win, "ArrowRight"));
|
||||
expect(handle.getAttribute("aria-valuenow")).toBe("60");
|
||||
expect(root.style.getPropertyValue("--wrn-split").trim()).toBe("60%");
|
||||
|
||||
handle.dispatchEvent(keydown(win, "ArrowLeft"));
|
||||
expect(handle.getAttribute("aria-valuenow")).toBe("50");
|
||||
|
||||
// Home and End go to the bounds, not to 0 and 100: a pane dragged to
|
||||
// nothing cannot be recovered with a pointer.
|
||||
handle.dispatchEvent(keydown(win, "Home"));
|
||||
expect(handle.getAttribute("aria-valuenow")).toBe("20");
|
||||
handle.dispatchEvent(keydown(win, "End"));
|
||||
expect(handle.getAttribute("aria-valuenow")).toBe("80");
|
||||
|
||||
// Already at the maximum; another step must not exceed it.
|
||||
handle.dispatchEvent(keydown(win, "ArrowRight"));
|
||||
expect(handle.getAttribute("aria-valuenow")).toBe("80");
|
||||
});
|
||||
|
||||
test("a vertical splitter responds to up and down instead", () => {
|
||||
const win = mount(
|
||||
`<div data-wrn-splitter="vertical" data-wrn-splitter-min="25" data-wrn-splitter-step="5"
|
||||
style="--wrn-split: 50%">
|
||||
<div>top</div>
|
||||
<div data-wrn-splitter-handle role="separator" tabindex="0"
|
||||
aria-valuenow="50" aria-valuemin="25" aria-valuemax="75"></div>
|
||||
<div>bottom</div>
|
||||
</div>`,
|
||||
);
|
||||
const handle = win.document.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement;
|
||||
|
||||
handle.dispatchEvent(keydown(win, "ArrowDown"));
|
||||
expect(handle.getAttribute("aria-valuenow")).toBe("55");
|
||||
// The other axis is left alone so the page can still scroll sideways.
|
||||
handle.dispatchEvent(keydown(win, "ArrowRight"));
|
||||
expect(handle.getAttribute("aria-valuenow")).toBe("55");
|
||||
});
|
||||
|
||||
test("splitter emits a resize event carrying the new size", () => {
|
||||
const win = mount(
|
||||
`<div data-wrn-splitter="horizontal" data-wrn-splitter-min="10" data-wrn-splitter-step="10"
|
||||
style="--wrn-split: 50%">
|
||||
<div>left</div>
|
||||
<div data-wrn-splitter-handle role="separator" tabindex="0"
|
||||
aria-valuenow="50" aria-valuemin="10" aria-valuemax="90"></div>
|
||||
<div>right</div>
|
||||
</div>`,
|
||||
);
|
||||
const doc = win.document;
|
||||
const root = doc.querySelector("[data-wrn-splitter]") as unknown as HTMLElement;
|
||||
const seen: number[] = [];
|
||||
// A resize listener is typed as UIEvent, so the detail needs the wider cast.
|
||||
root.addEventListener("resize", (event) =>
|
||||
seen.push((event as unknown as CustomEvent).detail.size),
|
||||
);
|
||||
|
||||
(doc.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement).dispatchEvent(
|
||||
keydown(win, "ArrowRight"),
|
||||
);
|
||||
expect(seen).toEqual([60]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user