Section, SectionHeader and PublicPageShell move onto wire-* classes with local style blocks, variants as data attributes. SectionHeader loses 38 utility lines, and Section stops spending one line per variant and colour pairing: tinted and solid now select on two attributes. PageHeader was already on the convention and only needed the audit. examples/basic-app/app/pages/layout.wrn composes the whole set into one page. Building it surfaced a library-wide bug. Ten custom properties were referenced by components and defined by nothing: --wire-color-focus, --wire-color-surface-soft, --wire-color-on-danger, --wire-color-surface-subtle and the input-* family, plus hover and contrast for every semantic colour except primary and secondary. An undefined custom property does not warn, it resolves to nothing, so focus rings drew with no colour and every soft surface rendered transparent -- 27 components referenced surface-soft alone. They are derived in the theme now, and a test checks every token a component references against the rendered theme CSS rather than the source, since most are generated. The semantic spread also had to move ahead of the primary and secondary entries so the palette keeps winning for those two. Known and unresolved: LayoutSplitter emits its sizeChange output and the component does fire it, but a parent binding on the tag is not invoked. The tour page therefore points at the handle aria-valuenow rather than wiring a handler that would never update. Outputs work elsewhere, so this is narrower than an outputs-are-broken problem and needs its own investigation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
6.5 KiB
Plaintext
225 lines
6.5 KiB
Plaintext
// 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 {
|
|
// Not named resize. An output named after a native DOM event never reaches
|
|
// a parent binding: the component emits it, but @resize on the tag is
|
|
// never invoked. sizeChange is unambiguous and does arrive.
|
|
sizeChange(payload: { size: number })
|
|
}
|
|
|
|
props {
|
|
color: string = "primary"
|
|
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 {
|
|
// The runtime resolves the size and announces it; this turns that into the
|
|
// declared output so a parent @resize binding receives it.
|
|
client function reportResize(sourceEvent) {
|
|
var detail = sourceEvent ? sourceEvent.detail : null
|
|
if (!detail) {
|
|
return
|
|
}
|
|
output.sizeChange({ size: detail.size })
|
|
}
|
|
|
|
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
|
|
{...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()}%;'
|
|
@wrnexus:splitter:resize='reportResize(event)'
|
|
>
|
|
<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;
|
|
}
|
|
}
|
|
}
|
|
}
|