Files
WRNexusJS/packages/ui/components/LayoutSplitter.wrn
T

278 lines
8.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 connected 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 {
// Named sizeChange rather than resize so it cannot be confused with the
// native window event a caller may already be listening for. An earlier
// comment here claimed a natively-named output could never reach a parent
// binding; that was wrong, and the rename was never what fixed anything.
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='wrn-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)'
data-wrn-owner="LayoutSplitter" data-wrn-part="root" data-wrn-component="LayoutSplitter" data-wrn-axis-color='{color}'>
<div class="wrn-splitter__pane wrn-splitter__pane--start" data-wrn-owner="LayoutSplitter" data-wrn-part="pane" data-wrn-axis-color="{color}">
<slot name="start"></slot>
</div>
<div
class="wrn-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()}'
data-wrn-owner="LayoutSplitter" data-wrn-part="handle" data-wrn-axis-color="{color}">
<span class="wrn-splitter__grip" aria-hidden="true" data-wrn-owner="LayoutSplitter" data-wrn-part="grip" data-wrn-axis-color="{color}"></span>
</div>
<div class="wrn-splitter__pane wrn-splitter__pane--end" data-wrn-owner="LayoutSplitter" data-wrn-part="pane" data-wrn-axis-color="{color}">
<slot name="end"></slot>
</div>
<slot />
</div>
}
style {
.wrn-splitter {
--splitter-accent: var(--wrn-color-primary);
display: grid;
grid-template-columns: var(--wrn-split, 50%) auto minmax(0, 1fr);
align-items: stretch;
width: 100%;
min-width: 0;
min-height: 14rem;
overflow: hidden;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-1);
}
.wrn-splitter[data-color="secondary"] {
--splitter-accent: var(--wrn-color-secondary);
}
.wrn-splitter[data-color="success"] {
--splitter-accent: var(--wrn-color-success);
}
.wrn-splitter[data-color="danger"] {
--splitter-accent: var(--wrn-color-danger);
}
.wrn-splitter[data-color="info"] {
--splitter-accent: var(--wrn-color-info);
}
.wrn-splitter[data-orientation="vertical"] {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--wrn-split, 50%) auto minmax(0, 1fr);
}
.wrn-splitter__pane {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 1rem 1.125rem;
color: var(--wrn-color-text);
font-size: 0.875rem;
line-height: 1.55;
}
.wrn-splitter__handle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 0.75rem;
padding: 0;
border: 0;
background: var(--wrn-color-surface);
cursor: col-resize;
touch-action: none;
user-select: none;
}
.wrn-splitter[data-orientation="vertical"] .wrn-splitter__handle {
width: auto;
height: 0.75rem;
padding: 0;
cursor: row-resize;
}
.wrn-splitter__grip {
position: relative;
z-index: 1;
display: block;
width: 0.375rem;
height: 1.75rem;
min-height: 0;
border: 1px solid var(--wrn-color-border-strong, var(--wrn-color-border));
border-radius: 999px;
background: var(--wrn-color-surface-raised, var(--wrn-color-surface));
box-shadow: var(--wrn-shadow-1);
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.wrn-splitter[data-orientation="vertical"] .wrn-splitter__grip {
width: 1.75rem;
min-width: 0;
height: 0.375rem;
}
.wrn-splitter__handle:hover .wrn-splitter__grip,
.wrn-splitter[data-wrn-splitter-dragging="true"] .wrn-splitter__grip {
border-color: var(--splitter-accent);
background: var(--splitter-accent);
}
.wrn-splitter__handle:focus-visible {
outline: 2px solid var(--splitter-accent);
outline-offset: -2px;
border-radius: var(--wrn-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) {
.wrn-splitter,
.wrn-splitter[data-orientation="vertical"] {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto;
}
.wrn-splitter__handle {
width: auto;
height: 1px;
cursor: default;
background: var(--wrn-color-border);
}
.wrn-splitter__handle::before,
.wrn-splitter__grip {
display: none;
}
}
.wrn-splitter__handle::before {
position: absolute;
inset-block: 0;
inset-inline-start: calc(50% - 0.5px);
width: 1px;
background: var(--wrn-color-border);
content: "";
transition: background 140ms ease;
}
.wrn-splitter[data-orientation="vertical"] .wrn-splitter__handle::before {
inset-block: auto;
inset-inline: 0;
top: calc(50% - 0.5px);
width: auto;
height: 1px;
}
.wrn-splitter__handle:hover::before,
.wrn-splitter[data-wrn-splitter-dragging="true"] .wrn-splitter__handle::before {
background: color-mix(in srgb, var(--splitter-accent) 60%, var(--wrn-color-border));
}
.wrn-splitter__pane--end {
background: var(--wrn-color-surface-soft, var(--wrn-color-surface));
}
}
}