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:
@@ -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