Shadow DOM CSS
The shadow DOM CSS is inlined as the string CSS_TEXT and injected
via a <style> element appended to the shadow root.
.wrap is a CSS grid with two equal minmax(0, 1fr)
columns by default. When the host carries data-left-fixed, the
grid switches to a fixed-left-plus-fill layout driven by the
--two-left custom property.
Height is controlled by --two-height; the default value
max-content lets the grid grow to fit its content.
The collapsed-left attribute hides the left column and collapses
the grid to a single column.
Each .panel is a flex column. Its .scroller child
expands (flex: 1 1 auto) and scrolls independently.
Shadow DOM Template
The shadow DOM is constructed programmatically in the constructor function
rather than from a template string.
A <style> element carries CSS_TEXT. A
.wrap div holds two <section> elements:
.panel.left and .panel.right. Each section contains
a .scroller div that wraps the named slot
(slot="left" or slot="right").
All five structural elements expose CSS part names
(wrap, left-panel, right-panel,
left-scroller, right-scroller) for external
::part() styling.
Constructor Pattern
XTwoPanel is defined using a constructor function and
Reflect.construct(HTMLElement, [], new.target) to extend the
built-in element. This classic-script pattern avoids ES module requirements
and works with a plain <script> tag.
The constructor initializes four instance fields: _left and
_right arrays (the collected item pairs), _i (the
current index, initialized to -1), and _init (a record of the
original left and gap attribute values saved for
reset()).
connectedCallback
On first connect, _init.left and _init.gap capture
the original attribute values. _applyAll() transfers all layout
attributes to CSS custom properties on the host.
If the click-controls attribute is present,
_wireClickControls() attaches single-click (step) and double-click
(reset / toggle) handlers directly to the shadow panel elements.
_recollect() queries both slots for matching elements using the
configured selectors, hides all of them, then sets _i = 0 and
calls _renderState() to show the first pair.
Array Collection & Render
_collectLeft() walks each element assigned to the left slot
and collects nodes matching data-desc-selector (default
.marked-box, .left-item). _collectRight() does
the same for data-right-selector (default .right-item).
_count() returns the minimum of the two array lengths, ensuring
only complete pairs are navigable.
_renderState() sets display, aria-hidden,
and the active class for every collected element, showing only the
current index pair. It then dispatches the two:nav custom event
with { index, count } so external listeners (including the
Prev/Next button disabler) can react.
External Controls
A global click listener (installed once via
window.__X_TWO_PANEL_EXTERNAL_WIRED__REFAC2__) intercepts clicks
on any element with a data-two attribute.
findScopedPanel(ctrl, sel) resolves the target panel: first by
the explicit data-two-for selector, then by walking ancestors for
an x-two-panel.with-buttons, and finally by falling back to the
first panel on the page.
Actions dispatch to the component's public API:
narrow/widen call step();
next/prev call next()/prev();
toggle-left, reset, collapse-left,
expand-left, set-left, and set-gap map
directly to the corresponding public methods.
A second IIFE attaches a two:nav listener to each panel on
DOMContentLoaded, automatically setting disabled on
Prev/Next buttons when the index is at the first or last pair.
Step & Width Helpers
step(sign, stepOverride) reads the current left width, resolves
the step amount from stepOverride, the --two-step CSS
variable, the step attribute, or a default of 6 rem, then clamps
the result between min-left and max-left before writing
back to the left attribute.
parseLen() and convert() handle unit conversion between
px, rem, and %.
availablePx() subtracts the current gap from the host width to
give the total usable space for both panels.
max-left="auto" computes the maximum left width as
available - min-right, keeping the right panel at least
min-right wide regardless of how much the user widens.
var CSS_TEXT = `
:host { display: block }
.wrap {
display: grid;
grid-template-columns: minmax(0,1fr) minmax(0,1fr);
column-gap: var(--two-gap, 0.50rem);
height: var(--two-height, max-content);
width: 100%; box-sizing: border-box;
}
:host([data-left-fixed]) .wrap {
grid-template-columns:
minmax(0, var(--two-left, calc(50% - var(--two-gap,0.50rem)/2)))
minmax(0, 1fr);
}
.panel {
min-width: 0; box-sizing: border-box;
background: var(--two-panel-bg, transparent);
border: var(--two-panel-border, 1px solid #ececf2);
border-radius: 10px;
display: flex; flex-direction: column; min-height: 0;
}
.scroller {
min-width: 0; min-height: 0; height: auto;
flex: 1 1 auto; overflow: auto;
padding: var(--two-panel-pad, 0.5rem 0rem);
box-sizing: border-box;
}
:host([collapsed-left]) .wrap {
grid-template-columns: 1fr;
}
:host([collapsed-left]) .left { display: none !important }
`;
// shadow DOM construction (inside XTwoPanel constructor)
var shadow = self.attachShadow({ mode: 'open' });
var style = document.createElement('style');
style.textContent = CSS_TEXT;
shadow.appendChild(style);
var wrap = document.createElement('div');
wrap.className = 'wrap';
wrap.setAttribute('part', 'wrap');
wrap.innerHTML = (
'<section class="panel left" part="left-panel">' +
'<div class="scroller" part="left-scroller">' +
'<slot name="left"></slot>' +
'</div>' +
'</section>' +
'<section class="panel right" part="right-panel">' +
'<div class="scroller" part="right-scroller">' +
'<slot name="right"></slot>' +
'</div>' +
'</section>'
);
shadow.appendChild(wrap);
// constructor function (extends HTMLElement without class syntax)
function XTwoPanel() {
var self = Reflect.construct(HTMLElement, [], new.target);
var shadow = self.attachShadow({ mode: 'open' });
// ... build shadow DOM ...
self._left = []; // description nodes
self._right = []; // code/content nodes
self._i = -1; // current index (-1 = not yet set)
self._init = { left: null, gap: null }; // for reset()
return self;
}
XTwoPanel.prototype = Object.create(HTMLElement.prototype);
XTwoPanel.prototype.constructor = XTwoPanel;
Object.defineProperty(XTwoPanel, 'observedAttributes', {
get: function() {
return [
'left', 'gap', 'height', 'step',
'min-left', 'max-left', 'min-right',
'click-controls',
'data-desc-selector', 'data-right-selector'
];
}
});
XTwoPanel.prototype.connectedCallback = function() {
if (!this._connectedOnce) {
// save originals for reset()
this._init.left = this.hasAttribute('left') ? this.getAttribute('left') : null;
this._init.gap = this.hasAttribute('gap') ? this.getAttribute('gap') : null;
this._connectedOnce = true;
}
this._applyAll();
if (this.hasAttribute('click-controls') ||
this.dataset.clickControls !== undefined) {
this._wireClickControls();
}
this._recollect();
var count = this._count();
if (count > 0) { this._i = 0; this._renderState(); }
this.scrollRightToTop();
};
XTwoPanel.prototype._recollect = function() {
this._left = this._collectLeft();
this._right = this._collectRight();
// hide all items; _renderState() reveals the active pair
for (var i = 0; i < this._left.length; i++) {
var L = this._left[i];
L.style.display = 'none';
L.setAttribute('aria-hidden', 'true');
L.classList.remove('active');
}
for (var k = 0; k < this._right.length; k++) {
var R = this._right[k];
R.style.display = 'none';
R.setAttribute('aria-hidden', 'true');
R.classList.remove('active');
}
};
XTwoPanel.prototype._renderState = function() {
var count = this._count(); if (count === 0) return;
var i = Math.max(0, Math.min(count - 1, this._i < 0 ? 0 : this._i));
for (var a = 0; a < this._left.length; a++) {
var show = (a === i);
this._left[a].style.display = show ? '' : 'none';
this._left[a].setAttribute('aria-hidden', show ? 'false' : 'true');
this._left[a].classList.toggle('active', show);
}
for (var b = 0; b < this._right.length; b++) {
var showR = (b === i);
this._right[b].style.display = showR ? '' : 'none';
this._right[b].setAttribute('aria-hidden', showR ? 'false' : 'true');
this._right[b].classList.toggle('active', showR);
}
this.dispatchEvent(new CustomEvent('two:nav',
{ detail: { index: i, count: count } }));
};
// global click delegation — installed once per page load
if (!window.__X_TWO_PANEL_EXTERNAL_WIRED__REFAC2__) {
document.addEventListener('click', function(ev) {
var ctrl = ev.target.closest('[data-two]');
if (!ctrl) return;
var sel = ctrl.getAttribute('data-two-for') ||
ctrl.getAttribute('aria-controls');
var host = findScopedPanel(ctrl, sel);
if (!host || host.tagName.toLowerCase() !== 'x-two-panel') return;
switch ((ctrl.dataset.two || '').toLowerCase()) {
case 'narrow': host.step(-1, ctrl.dataset.step); break;
case 'widen': host.step(+1, ctrl.dataset.step); break;
case 'toggle-left': host.toggleLeft(); break;
case 'reset': host.reset(); break;
case 'collapse-left': host.setAttribute('collapsed-left', ''); break;
case 'expand-left': host.removeAttribute('collapsed-left'); break;
case 'next':
case 'next-mark': host.next({ wrap: false }); break;
case 'prev':
case 'prev-mark': host.prev({ wrap: false }); break;
}
}, true);
window.__X_TWO_PANEL_EXTERNAL_WIRED__REFAC2__ = true;
}
XTwoPanel.prototype.step = function(sign, stepOverride) {
var cur = this.getAttribute('left');
if (!cur) {
var pxNow = this._leftPanel.getBoundingClientRect().width;
cur = (pxNow / rootFontSize(this)).toFixed(3) + 'rem';
this.setAttribute('left', cur);
}
var parsed = parseLen(cur);
var leftPx = convert(parsed.n, parsed.unit, 'px', this);
var deltaPx;
if (stepOverride) {
var so = parseLen(stepOverride);
deltaPx = convert(so.n, so.unit, 'px', this);
} else {
var cssStepPx = readVarPx(this, '--two-step');
deltaPx = cssStepPx ?? (this.hasAttribute('step')
? convert(parseLen(this.getAttribute('step')).n,
parseLen(this.getAttribute('step')).unit, 'px', this)
: convert(6, 'rem', 'px', this));
}
var minLeftPx = /* min-left attr or CSS var */ ...;
var minRightPx = /* min-right attr or CSS var */ ...;
var maxLeftPx = Math.min(availablePx(this) - minRightPx, userMaxPx);
var nextPx = Math.max(minLeftPx, Math.min(maxLeftPx, leftPx + sign * deltaPx));
this.setAttribute('left',
convert(nextPx, 'px', parsed.unit, this).toFixed(3) + parsed.unit);
this.removeAttribute('collapsed-left');
};