WebDev Bites: TwoPanelViewer Component Design

W3C web component

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');
};