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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user