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;
+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]);
});
+5 -5
View File
@@ -633,9 +633,9 @@ Constrain and align page content with responsive gutters and compact, wide, or f
Theme-aware, responsive custom scrollbar component.
- Mount: `data-component="CustomScrollbar"`
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
- Props: `color: string = "primary"`, `size: string = "default"`, `axis: string = "vertical"`, `thickness: number = 8`, `maxHeight: string = "20rem"`, `radius: string = "999px"`, `class: string = ""`
- Slots: `default`
- Outputs: `scroll({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
- Outputs: None
### Divider
@@ -696,9 +696,9 @@ Theme-aware, responsive kbd component.
Theme-aware, responsive layout splitter component.
- Mount: `data-component="LayoutSplitter"`
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
- Slots: `default`
- Outputs: `resizeStart({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `resize({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `resizeEnd({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
- Props: `color: string = "primary"`, `size: number = 50`, `orientation: string = "horizontal"`, `minSize: number = 15`, `step: number = 5`, `label: string = "Resize panels"`, `class: string = ""`
- Slots: `start`, `end`, `default`
- Outputs: `resize({ size: number })`
### Link
+45 -44
View File
@@ -4443,13 +4443,6 @@
"category": "layout",
"purpose": "Theme-aware, responsive custom scrollbar component.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{
"name": "color",
"type": "string",
@@ -4458,24 +4451,38 @@
"options": []
},
{
"name": "columns",
"name": "size",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{
"name": "axis",
"type": "string",
"required": false,
"default": "\"vertical\"",
"options": []
},
{
"name": "thickness",
"type": "number",
"required": false,
"default": "2",
"default": "8",
"options": []
},
{
"name": "gap",
"name": "maxHeight",
"type": "string",
"required": false,
"default": "\"md\"",
"default": "\"20rem\"",
"options": []
},
{
"name": "maxWidth",
"name": "radius",
"type": "string",
"required": false,
"default": "\"xl\"",
"default": "\"999px\"",
"options": []
},
{
@@ -4487,13 +4494,8 @@
}
],
"slots": ["default"],
"outputs": [
{
"name": "scroll",
"payloadType": "{ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]"
}
],
"events": ["scroll"],
"outputs": [],
"events": [],
"source": "components/CustomScrollbar.wrn"
},
{
@@ -7772,13 +7774,6 @@
"category": "layout",
"purpose": "Theme-aware, responsive layout splitter component.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{
"name": "color",
"type": "string",
@@ -7787,24 +7782,38 @@
"options": []
},
{
"name": "columns",
"name": "size",
"type": "number",
"required": false,
"default": "2",
"default": "50",
"options": []
},
{
"name": "gap",
"name": "orientation",
"type": "string",
"required": false,
"default": "\"md\"",
"default": "\"horizontal\"",
"options": []
},
{
"name": "maxWidth",
"name": "minSize",
"type": "number",
"required": false,
"default": "15",
"options": []
},
{
"name": "step",
"type": "number",
"required": false,
"default": "5",
"options": []
},
{
"name": "label",
"type": "string",
"required": false,
"default": "\"xl\"",
"default": "\"Resize panels\"",
"options": []
},
{
@@ -7815,22 +7824,14 @@
"options": []
}
],
"slots": ["default"],
"slots": ["start", "end", "default"],
"outputs": [
{
"name": "resizeStart",
"payloadType": "{ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]"
},
{
"name": "resize",
"payloadType": "{ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]"
},
{
"name": "resizeEnd",
"payloadType": "{ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]"
"payloadType": "{ size: number }"
}
],
"events": ["resizeStart", "resize", "resizeEnd"],
"events": ["resize"],
"source": "components/LayoutSplitter.wrn"
},
{
+114 -9
View File
@@ -1,17 +1,122 @@
// CustomScrollbar -- a scrolling region with a themed scrollbar.
//
// <CustomScrollbar maxHeight="20rem">...long content...</CustomScrollbar>
//
// Scrollbar appearance is CSS, not script: scrollbar-width and scrollbar-color
// are the standard properties, and the ::-webkit-scrollbar rules cover the
// browsers that still need them.
//
// This component used to declare a scroll output it never emitted, and its
// props were columns, gap and maxWidth copied from a grid scaffold. The output
// is removed rather than left unimplemented -- a caller can listen for a plain
// scroll event on the element, which is what it would have been anyway.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component CustomScrollbar {
outputs {
scroll(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
}
props {
size: string = "default"
color: string = "primary"
columns: number = 2
gap: string = "md"
maxWidth: string = "xl"
size: string = "default"
axis: string = "vertical"
// Track thickness in pixels. Clamped, because it arrives as an attribute
// and a scrollbar wider than the content is not useful to anyone.
thickness: number = 8
maxHeight: string = "20rem"
radius: string = "999px"
class: string = ""
}
functions {
shared function trackSize() {
var value = Number(thickness)
if (!value || value < 2) {
return 8
}
return Math.min(24, value)
}
}
view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--custom-scrollbar wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div>
<div
{...attrs}
data-ui-component="CustomScrollbar"
class='wire-scrollbar {class}'
data-color='{color}'
data-size='{size}'
data-axis='{axis}'
style='--scrollbar-thickness:{trackSize()}px;--scrollbar-radius:{radius};max-height:{maxHeight};'
>
<slot />
</div>
}
style {
.wire-scrollbar {
--scrollbar-accent: var(--wire-color-primary);
min-width: 0;
overflow: auto;
/*
* The standard properties. Firefox and current Chrome honour these; the
* webkit rules below are only for the engines that still ignore them.
*/
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--scrollbar-accent) 55%, transparent) transparent;
overscroll-behavior: contain;
}
.wire-scrollbar[data-color="secondary"] {
--scrollbar-accent: var(--wire-color-secondary);
}
.wire-scrollbar[data-color="success"] {
--scrollbar-accent: var(--wire-color-success);
}
.wire-scrollbar[data-color="danger"] {
--scrollbar-accent: var(--wire-color-danger);
}
.wire-scrollbar[data-color="info"] {
--scrollbar-accent: var(--wire-color-info);
}
.wire-scrollbar[data-axis="vertical"] {
overflow-x: hidden;
overflow-y: auto;
}
.wire-scrollbar[data-axis="horizontal"] {
overflow-x: auto;
overflow-y: hidden;
}
.wire-scrollbar::-webkit-scrollbar {
width: var(--scrollbar-thickness, 8px);
height: var(--scrollbar-thickness, 8px);
}
.wire-scrollbar::-webkit-scrollbar-track {
background: var(--wire-color-surface-soft);
border-radius: var(--scrollbar-radius, 999px);
}
.wire-scrollbar::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--scrollbar-accent) 45%, transparent);
border-radius: var(--scrollbar-radius, 999px);
}
.wire-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-accent);
}
/*
* A pointer-less device paints its own overlay scrollbar and ignores the
* width above, so the region keeps its own padding instead.
*/
@media (hover: none) {
.wire-scrollbar {
scrollbar-width: auto;
}
}
}
}
+199 -8
View File
@@ -1,19 +1,210 @@
// LayoutSplitter -- two panes with a divider the reader can move.
//
// <LayoutSplitter size={40} minSize={20}>
// <div data-slot="start">...</div>
// <div data-slot="end">...</div>
// </LayoutSplitter>
//
// The dragging lives in the reactive runtime behind data-wrn-splitter. 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 resolved size is
// held in the DOM as the --wrn-split custom property and these styles read it.
//
// This component previously declared resizeStart, resize and resizeEnd with no
// pointer handling whatsoever: a caller wired up @resize and received nothing,
// for ever, with no error.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component LayoutSplitter {
outputs {
resizeStart(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
resize(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
resizeEnd(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
resize(payload: { size: number })
}
props {
size: string = "default"
color: string = "primary"
columns: number = 2
gap: string = "md"
maxWidth: string = "xl"
size: number = 50
orientation: string = "horizontal"
// Smallest share either pane may take, as a percentage. It also fixes the
// upper bound at 100 - minSize, so neither pane can be dragged away to
// nothing and left unrecoverable by pointer.
minSize: number = 15
step: number = 5
label: string = "Resize panels"
class: string = ""
}
functions {
shared function isVertical() {
return orientation === "vertical"
}
shared function lowerBound() {
var value = Number(minSize)
if (!value || value < 0) {
return 15
}
return Math.min(45, value)
}
shared function upperBound() {
return 100 - lowerBound()
}
// Sizes arrive as HTML attributes, so a value outside the bounds is
// routine rather than exceptional. Clamp instead of rendering something
// the reader cannot undo.
shared function currentSize() {
var value = Number(size)
if (!value || value < 0) {
return 50
}
return Math.min(upperBound(), Math.max(lowerBound(), value))
}
shared function stepSize() {
var value = Number(step)
return value && value > 0 ? value : 5
}
}
view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--layout-splitter wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div>
<div
{...attrs}
data-ui-component="LayoutSplitter"
class='wire-splitter {class}'
data-color='{color}'
data-orientation='{orientation}'
data-wrn-splitter='{isVertical() ? "vertical" : "horizontal"}'
data-wrn-splitter-min='{lowerBound()}'
data-wrn-splitter-step='{stepSize()}'
style='--wrn-split:{currentSize()}%;'
>
<div class="wire-splitter__pane wire-splitter__pane--start">
<slot name="start"></slot>
</div>
<div
class="wire-splitter__handle"
data-wrn-splitter-handle="true"
role="separator"
tabindex="0"
aria-label='{label}'
aria-orientation='{isVertical() ? "horizontal" : "vertical"}'
aria-valuenow='{currentSize()}'
aria-valuemin='{lowerBound()}'
aria-valuemax='{upperBound()}'
>
<span class="wire-splitter__grip" aria-hidden="true"></span>
</div>
<div class="wire-splitter__pane wire-splitter__pane--end">
<slot name="end"></slot>
</div>
<slot />
</div>
}
style {
.wire-splitter {
--splitter-accent: var(--wire-color-primary);
display: grid;
/* The first track follows the size the runtime resolves. */
grid-template-columns: var(--wrn-split, 50%) auto minmax(0, 1fr);
align-items: stretch;
width: 100%;
min-width: 0;
}
.wire-splitter[data-color="secondary"] {
--splitter-accent: var(--wire-color-secondary);
}
.wire-splitter[data-color="success"] {
--splitter-accent: var(--wire-color-success);
}
.wire-splitter[data-color="danger"] {
--splitter-accent: var(--wire-color-danger);
}
.wire-splitter[data-color="info"] {
--splitter-accent: var(--wire-color-info);
}
.wire-splitter[data-orientation="vertical"] {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--wrn-split, 50%) auto minmax(0, 1fr);
}
.wire-splitter__pane {
min-width: 0;
min-height: 0;
overflow: auto;
}
.wire-splitter__handle {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0 0.25rem;
border: 0;
background: transparent;
cursor: col-resize;
/* Without this the pointer drag selects the text in both panes. */
touch-action: none;
user-select: none;
}
.wire-splitter[data-orientation="vertical"] .wire-splitter__handle {
padding: 0.25rem 0;
cursor: row-resize;
}
.wire-splitter__grip {
display: block;
width: 2px;
height: 100%;
min-height: 1.5rem;
border-radius: 999px;
background: var(--wire-color-border);
transition: background 140ms ease;
}
.wire-splitter[data-orientation="vertical"] .wire-splitter__grip {
width: 100%;
min-width: 1.5rem;
height: 2px;
}
.wire-splitter__handle:hover .wire-splitter__grip,
.wire-splitter[data-wrn-splitter-dragging="true"] .wire-splitter__grip {
background: var(--splitter-accent);
}
.wire-splitter__handle:focus-visible {
outline: 2px solid var(--splitter-accent);
outline-offset: -2px;
border-radius: var(--wire-radius-sm);
}
/*
* Two panes side by side stop making sense on a phone. Stacking them keeps
* both readable, and the divider stops being draggable because the grid
* no longer has a second track to trade against.
*/
@media (max-width: 639px) {
.wire-splitter,
.wire-splitter[data-orientation="vertical"] {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto;
}
.wire-splitter__handle {
cursor: default;
}
}
}
}
+75
View File
@@ -2944,3 +2944,78 @@ test("stepper next can be gated so a form can hold it until the step validates",
const dom = mountHtml(html);
expect(dom.querySelector(".wire-stepper__next")!.getAttribute("disabled")).not.toBeNull();
});
test("layout splitter renders two panes and an operable separator", async () => {
const source = readFileSync(uiComponentPath("LayoutSplitter"), "utf8");
/*
* It used to declare resizeStart, resize and resizeEnd with no pointer
* handling at all, so a caller wired up @resize and received nothing for
* ever. The behaviour now lives in the runtime behind these markers.
*/
expect(source).toContain("data-wrn-splitter");
expect(source).toContain("data-wrn-splitter-handle");
const html = await renderComponent(source, {
orientation: "horizontal",
size: 40,
minSize: 20,
label: "Resize panels",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wire-splitter") as HTMLElement;
expect(root.getAttribute("data-wrn-splitter")).toBe("horizontal");
expect(root.getAttribute("style")).toContain("--wrn-split");
const handle = dom.querySelector("[data-wrn-splitter-handle]") as HTMLElement;
expect(handle.getAttribute("role")).toBe("separator");
expect(handle.getAttribute("tabindex")).toBe("0");
expect(handle.getAttribute("aria-valuenow")).toBe("40");
expect(handle.getAttribute("aria-valuemin")).toBe("20");
expect(handle.getAttribute("aria-valuemax")).toBe("80");
expect(handle.getAttribute("aria-orientation")).toBe("vertical");
expect(dom.querySelectorAll(".wire-splitter__pane")).toHaveLength(2);
});
test("layout splitter clamps an out-of-range size and flips orientation", async () => {
const source = readFileSync(uiComponentPath("LayoutSplitter"), "utf8");
const dom = mountHtml(
await renderComponent(source, { orientation: "vertical", size: 95, minSize: 25 }),
);
const handle = dom.querySelector("[data-wrn-splitter-handle]") as HTMLElement;
// 95 is past the 75 bound implied by minSize, so it clamps rather than
// leaving one pane unrecoverable.
expect(handle.getAttribute("aria-valuenow")).toBe("75");
// A vertical splitter stacks, so its separator is a horizontal bar.
expect(handle.getAttribute("aria-orientation")).toBe("horizontal");
});
test("custom scrollbar styles the scrollbar and drops its fake output", async () => {
const source = readFileSync(uiComponentPath("CustomScrollbar"), "utf8");
/*
* It declared a scroll output it never emitted, and its props were columns,
* gap and maxWidth copied from a grid scaffold. A caller can listen for a
* plain scroll event on the element, so the output is gone rather than left
* unimplemented.
*/
expect(source).not.toContain("outputs {");
// Matched as a declaration: the word still appears in the comment recording
// why those props were wrong.
expect(source).not.toMatch(/^\s*columns:/m);
expect(source).not.toMatch(/^\s*gap:/m);
expect(source).toContain("scrollbar-color");
expect(source).toContain("::-webkit-scrollbar");
const html = await renderComponent(source, {
axis: "vertical",
thickness: 10,
maxHeight: "18rem",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wire-scrollbar") as HTMLElement;
expect(root.getAttribute("data-axis")).toBe("vertical");
const style = root.getAttribute("style") || "";
expect(style).toContain("--scrollbar-thickness");
expect(style).toContain("18rem");
});