WebDev Bites: ViewComparator Component Design

W3C web component

Template Strings

ViewComparator defines two module-level constants before the class: VC_STYLE holds the shadow DOM CSS and VC_TEMPLATE holds the HTML structure. VC_STYLE uses CSS custom properties (--panel-bg, --panel-fg, --panel-padding, --panel-overflow-x, --bar-width, --bar-color) so _applyStyles() can update appearance without touching the shadow stylesheet directly. The .offset-controls block in VC_STYLE positions the three offset buttons as an absolutely positioned overlay in the top-right corner of the right panel. Opacity transitions from 0.3 to 1 on hover or when the right panel has focus, keeping the buttons unobtrusive.

Shadow DOM Template

VC_TEMPLATE embeds VC_STYLE then defines two top-level elements: a .container flex row and a .resizer strip. Inside .container: .panel-left (fixed width via flex: none) holds <slot name="left">, .splitter provides the drag handle, and .panel-right (flex: 1, position: relative) holds both the .offset-controls overlay and <slot name="right">. The .resizer strip sits below the container and handles vertical drag to change component height. Hover brightening on both .splitter and .resizer provides visual affordance.

Class and Constructor

observedAttributes declares 14 attributes covering dimensions, split ratio, bar appearance, panel colors, overflow, Prism, resize steps, font size, and offset step. The constructor caches 10 shadow DOM references in this._els: the 7 structural elements shared with ViewSplitterBar plus btnUp, btnReset, and btnDown. Drag state mirrors ViewSplitterBar. The extra field _rightOffsetPx tracks the current vertical shift applied to the right panel's slotted content. Three additional handler closures cover the offset buttons (_onOffsetUp, _onOffsetDown, _onOffsetReset) and a fourth (_onKeydownRight) handles keyboard shortcuts on the right panel. All call e.stopPropagation() to prevent the click from reaching the panel-click bump handler.

Lifecycle Callbacks

connectedCallback applies styles and layout, runs the initial Prism pass and slot harmonization, attaches all event listeners including the offset buttons and right-panel keydown, starts the ResizeObserver, and resets panel scroll positions. disconnectedCallback removes every listener added in connectedCallback, including the document-level mousemove/mouseup pairs for both drag operations, and disconnects the ResizeObserver. attributeChangedCallback clears _leftPx and _heightPx so the next layout pass recomputes both from the current attributes, then re-applies styles and layout.

Drag Mechanics

Horizontal drag: _startDrag records the start X and left-panel width, attaches document mousemove/mouseup. _onDrag calls _setLeftPx with the start width plus the cursor delta. _endDrag clears the _dragging flag and removes the document listeners. Vertical drag mirrors the pattern: _startHeightDrag, _onHeightDrag, and _endHeightDrag operate on container height instead of panel width. _bump(dir) handles panel clicks: adds or subtracts one step-px increment via _setLeftPx.

Offset Controls

_adjustOffset(dir) adds or subtracts one offset-step-px increment (default 40) to _rightOffsetPx, then calls _setRightOffset(). _setRightOffset(px) clamps the value to zero or above and applies _applyRightPaddingTop() to every slotted element in the right slot. _applyRightPaddingTop(el) extracts the top component of the code-padding attribute and sets the element's paddingTop to calc(basePadTop + offsetPx), shifting the content down without changing left/right padding. _handleOffsetKey(e) intercepts Alt+ArrowDown, Alt+ArrowUp, and Escape on the focused right panel, calling _adjustOffset() or _setRightOffset(0) respectively.

Layout & Prism Helpers

_getLeftPx() computes the initial left width from left-ratio times available width and caches the result. _setLeftPx() clamps the value to [min-panel-px, available - min-panel-px] and applies it directly. _totalWidthPx() reads the actual rendered width first; if the element is not yet laid out, it falls back to parsing the width attribute (handling rem and plain px). _applyStyles() sets six CSS custom properties on the host. _applyLayout() applies the width attribute to the host, the computed height to the container, and defers the initial panel width via requestAnimationFrame. _maybePrism() highlights all <code> elements in both slots when highlight="prism" is set. _harmonizeSlotted() applies flex and padding to every slotted <pre>, optionally sets font-size, and calls _applyRightPaddingTop() for elements in the right slot.

 1  const VC_STYLE = /* css */ `
 2    :host { display: flex; flex-direction: column; }
 3
 4    .container {
 5      display: flex; flex-direction: row;
 6      border: 2px solid var(--dark, #333);
 7      box-shadow: 5px 5px 5px #999;
 8      box-sizing: border-box; overflow: hidden;
 9    }
10
11    .panel {
12      display: flex; flex-direction: column;
13      overflow-x: var(--panel-overflow-x, auto);
14      overflow-y: auto; box-sizing: border-box;
15      background-color: var(--panel-bg, #f8f8f8);
16      color: var(--panel-fg, #333);
17      cursor: pointer; user-select: none;
18    }
19
20    .panel-left  { flex: none; }
21    .panel-right { flex: 1; min-width: 0; position: relative; }
22
23    .splitter {
24      flex: none; width: var(--bar-width, 6px);
25      background-color: var(--bar-color, #888);
26      cursor: col-resize; user-select: none;
27    }
28    .splitter:hover { filter: brightness(0.75); }
29
30    .resizer {
31      flex: none; height: var(--bar-width, 6px);
32      background-color: var(--bar-color, #888);
33      cursor: ns-resize; user-select: none;
34    }
35    .resizer:hover { filter: brightness(0.75); }
36
37    ::slotted(pre) {
38      flex: 1; min-height: 0; margin: 0 !important;
39      padding: var(--panel-padding, 0.75rem 1rem);
40      line-height: 1.4; white-space: pre;
41      box-sizing: border-box; background: transparent;
42      color: inherit;
43      font-family: Consolas, 'Courier New', monospace;
44      font-size: 0.9rem;
45    }
46
47    .offset-controls {
48      position: absolute; top: 4px; right: 6px;
49      display: flex; flex-direction: column; gap: 2px;
50      z-index: 1; opacity: 0.3; transition: opacity 0.15s;
51    }
52    .panel-right:focus-within .offset-controls,
53    .offset-controls:hover { opacity: 1; }
54
55    .offset-btn {
56      width: 22px; height: 22px;
57      border: 1px solid var(--bar-color, #888);
58      background: var(--panel-bg, #f8f8f8);
59      color: var(--panel-fg, #333);
60      cursor: pointer; font-size: 11px; line-height: 1;
61      padding: 0; border-radius: 2px; user-select: none;
62    }
63    .offset-btn:hover { filter: brightness(0.85); }
64  `;
          

66  const VC_TEMPLATE = /* html */ `
67    <style>${VC_STYLE}</style>
68    <div class="container" part="container">
69      <div class="panel panel-left" part="panel-left">
70        <slot name="left"></slot>
71      </div>
72      <div class="splitter" part="splitter"></div>
73      <div class="panel panel-right" part="panel-right" tabindex="-1">
74        <div class="offset-controls" part="offset-controls">
75          <button class="offset-btn" part="offset-up"
76                  title="Shift content up (Alt+↑)">▲</button>
77          <button class="offset-btn" part="offset-reset"
78                  title="Reset offset (Esc)">0</button>
79          <button class="offset-btn" part="offset-down"
80                  title="Shift content down (Alt+↓)">▼</button>
81        </div>
82        <slot name="right"></slot>
83      </div>
84    </div>
85    <div class="resizer" part="resizer"></div>
86  `;
          

 88  class ViewComparator extends HTMLElement {
 89    static get observedAttributes() {
 90      return [
 91        'width', 'height', 'left-ratio', 'bar-width', 'bar-color',
 92        'bg-color', 'color', 'overflow-x', 'code-padding',
 93        'highlight', 'step-px', 'min-panel-px', 'min-height-px', 'font-size'
 94      ];
 95    }
 96
 97    constructor() {
 98      super();
 99      this.attachShadow({ mode: 'open' });
100      this.shadowRoot.innerHTML = VC_TEMPLATE;
101
102      this._els = {
103        container:  this.shadowRoot.querySelector('.container'),
104        panelLeft:  this.shadowRoot.querySelector('.panel-left'),
105        splitter:   this.shadowRoot.querySelector('.splitter'),
106        panelRight: this.shadowRoot.querySelector('.panel-right'),
107        resizer:    this.shadowRoot.querySelector('.resizer'),
108        slotLeft:   this.shadowRoot.querySelector('slot[name="left"]'),
109        slotRight:  this.shadowRoot.querySelector('slot[name="right"]'),
110        btnUp:      this.shadowRoot.querySelector('[part="offset-up"]'),
111        btnReset:   this.shadowRoot.querySelector('[part="offset-reset"]'),
112        btnDown:    this.shadowRoot.querySelector('[part="offset-down"]'),
113      };
114
115      this._leftPx        = null;    this._heightPx      = null;
116      this._rightOffsetPx = 0;       this._dragging      = false;
117      this._dragStartX    = 0;       this._dragStartW    = 0;
118      this._dragStartY    = 0;       this._dragStartH    = 0;
119
120      this._onMousedown        = e  => this._startDrag(e);
121      this._onMousemove        = e  => this._onDrag(e);
122      this._onMouseup          = () => this._endDrag();
123      this._onResizerMousedown = e  => this._startHeightDrag(e);
124      this._onResizerMousemove = e  => this._onHeightDrag(e);
125      this._onResizerMouseup   = () => this._endHeightDrag();
126      this._onClickLeft        = () => this._bump(+1);
127      this._onClickRight       = () => this._bump(-1);
128      this._onOffsetUp         = e  => { e.stopPropagation(); this._adjustOffset(-1); };
129      this._onOffsetDown       = e  => { e.stopPropagation(); this._adjustOffset(+1); };
130      this._onOffsetReset      = e  => { e.stopPropagation(); this._setRightOffset(0); };
131      this._onKeydownRight     = e  => this._handleOffsetKey(e);
132      this._onSlotChange       = () => {
133        this._maybePrism();
134        this._harmonizeSlotted();
135        requestAnimationFrame(() => {
136          this._els.panelLeft.scrollTop  = 0;
137          this._els.panelRight.scrollTop = 0;
138          [this._els.slotLeft, this._els.slotRight].forEach(slot => {
139            slot.assignedElements({ flatten: true })
140                .forEach(el => { el.scrollTop = 0; });
141          });
142        });
143      };
144
145      this._resizeObserver = new ResizeObserver(() => {
146        if (this._dragging) return;
147        this._leftPx = null;
148        requestAnimationFrame(() => {
149          this._els.panelLeft.style.width = `${this._getLeftPx()}px`;
150        });
151      });
152    }
          

154   connectedCallback() {
155     this._applyStyles();     this._applyLayout();
156     this._maybePrism();      this._harmonizeSlotted();
157     this._els.splitter.addEventListener('mousedown',   this._onMousedown);
158     this._els.resizer.addEventListener('mousedown',    this._onResizerMousedown);
159     this._els.panelLeft.addEventListener('click',      this._onClickLeft);
160     this._els.panelRight.addEventListener('click',     this._onClickRight);
161     this._els.slotLeft.addEventListener('slotchange',  this._onSlotChange);
162     this._els.slotRight.addEventListener('slotchange', this._onSlotChange);
163     this._els.btnUp.addEventListener('click',          this._onOffsetUp);
164     this._els.btnDown.addEventListener('click',        this._onOffsetDown);
165     this._els.btnReset.addEventListener('click',       this._onOffsetReset);
166     this._els.panelRight.addEventListener('keydown',   this._onKeydownRight);
167     this._resizeObserver.observe(this);
168     requestAnimationFrame(() => {
169       this._els.panelLeft.scrollTop  = 0;
170       this._els.panelRight.scrollTop = 0;
171     });
172   }
173
174   disconnectedCallback() {
175     this._els.splitter.removeEventListener('mousedown',   this._onMousedown);
176     this._els.resizer.removeEventListener('mousedown',    this._onResizerMousedown);
177     this._els.panelLeft.removeEventListener('click',      this._onClickLeft);
178     this._els.panelRight.removeEventListener('click',     this._onClickRight);
179     this._els.slotLeft.removeEventListener('slotchange',  this._onSlotChange);
180     this._els.slotRight.removeEventListener('slotchange', this._onSlotChange);
181     this._els.btnUp.removeEventListener('click',          this._onOffsetUp);
182     this._els.btnDown.removeEventListener('click',        this._onOffsetDown);
183     this._els.btnReset.removeEventListener('click',       this._onOffsetReset);
184     this._els.panelRight.removeEventListener('keydown',   this._onKeydownRight);
185     document.removeEventListener('mousemove', this._onMousemove);
186     document.removeEventListener('mouseup',   this._onMouseup);
187     document.removeEventListener('mousemove', this._onResizerMousemove);
188     document.removeEventListener('mouseup',   this._onResizerMouseup);
189     this._resizeObserver.disconnect();
190   }
191
192   attributeChangedCallback() {
193     this._leftPx   = null;
194     this._heightPx = null;
195     this._applyStyles();
196     this._applyLayout();
197   }
          

199   _adjustOffset(dir) {
200     const step = parseFloat(this.getAttribute('offset-step-px')) || 40;
201     this._setRightOffset(this._rightOffsetPx + dir * step);
202   }
203
204   _setRightOffset(px) {
205     this._rightOffsetPx = Math.max(0, Math.round(px));
206     this._els.slotRight.assignedElements({ flatten: true }).forEach(el => {
207       this._applyRightPaddingTop(el);
208     });
209   }
210
211   _applyRightPaddingTop(el) {
212     if (el.tagName !== 'PRE') return;
213     const pad = this.getAttribute('code-padding') || '0.75rem 1rem';
214     const basePadTop = pad.trim().split(/\s+/)[0];
215     el.style.paddingTop = `calc(${basePadTop} + ${this._rightOffsetPx}px)`;
216   }
217
218   _handleOffsetKey(e) {
219     if (e.altKey && e.key === 'ArrowDown') {
220       e.preventDefault(); this._adjustOffset(+1);
221     } else if (e.altKey && e.key === 'ArrowUp') {
222       e.preventDefault(); this._adjustOffset(-1);
223     } else if (e.key === 'Escape') {
224       this._setRightOffset(0);
225     }
226   }
          

228   _harmonizeSlotted() {
229     const pad = this.getAttribute('code-padding') || '0.75rem 1rem';
230     const fs  = this.getAttribute('font-size');
231     const apply = (el) => {
232       if (el.tagName !== 'PRE') return;
233       el.style.margin    = '0';
234       el.style.padding   = pad;
235       el.style.flex      = '1';
236       el.style.minHeight = '0';
237       el.style.boxSizing = 'border-box';
238       if (fs) {
239         el.style.fontSize = fs;
240         el.querySelectorAll('code').forEach(c => { c.style.fontSize = fs; });
241       }
242     };
243     this._els.slotLeft.assignedElements({ flatten: true }).forEach(apply);
244     this._els.slotRight.assignedElements({ flatten: true }).forEach(el => {
245       apply(el);
246       this._applyRightPaddingTop(el);
247     });
248   }
249 }
250
251 customElements.define('view-comparator', ViewComparator);