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
+84
View File
@@ -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]);
});