WebDev Bites: ViewSplitterBar Component Design

W3C web component

Template Strings

ViewSplitterBar defines two module-level constants before the class: VSB_STYLE holds the shadow DOM CSS and VSB_TEMPLATE holds the HTML structure. VSB_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 ::slotted(pre) rule in VSB_STYLE provides baseline styling for slotted <pre> elements. The _harmonizeSlotted() helper overrides these with inline styles after slot assignment to apply the current code-padding attribute.

Shadow DOM Template

VSB_TEMPLATE embeds VSB_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) holds <slot name="right"> and expands to fill remaining width. The .resizer strip sits below the container and handles vertical drag to change component height. Both bars use the same --bar-width and --bar-color variables.

Class and Constructor

observedAttributes declares 13 attributes covering dimensions, split ratio, bar appearance, panel colors, overflow, Prism, and resize steps. The constructor caches 7 shadow DOM references in this._els, initializes drag state variables (_leftPx, _heightPx, _dragging, and four drag-start coordinates), and builds all event handler closures as instance properties. The _onSlotChange closure calls _maybePrism() and _harmonizeSlotted(), then resets scroll position in both panels via requestAnimationFrame. A ResizeObserver on the host element clears _leftPx and schedules a re-application of the left panel width on the next frame whenever the component width changes, unless a drag is in progress.

Lifecycle Callbacks

connectedCallback applies styles and layout, runs the initial Prism pass and slot harmonization, attaches all event listeners, 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.

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, plain px, and min() expressions that resolve once the element has width). _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 so min()/100% expressions have resolved. _maybePrism() highlights all <code> elements in both slots when highlight="prism" is set. _harmonizeSlotted() applies flex and padding to every slotted <pre> so it fills its panel correctly.

 1  const VSB_STYLE = /* css */ `
 2    :host { display: flex; flex-direction: column; }
 3
 4    .container {
 5      display: flex;
 6      flex-direction: row;
 7      border: 2px solid var(--dark, #333);
 8      box-shadow: 5px 5px 5px #999;
 9      box-sizing: border-box;
10      overflow: hidden;
11    }
12
13    .panel {
14      display: flex;
15      flex-direction: column;
16      overflow-x: var(--panel-overflow-x, auto);
17      overflow-y: auto;
18      background-color: var(--panel-bg, #f8f8f8);
19      color: var(--panel-fg, #333);
20      cursor: pointer;
21      user-select: none;
22    }
23
24    .panel-left  { flex: none; }
25    .panel-right { flex: 1; min-width: 0; }
26
27    .splitter {
28      flex: none;
29      width: var(--bar-width, 6px);
30      background-color: var(--bar-color, #888);
31      cursor: col-resize;
32    }
33
34    .resizer {
35      flex: none;
36      height: var(--bar-width, 6px);
37      background-color: var(--bar-color, #888);
38      cursor: ns-resize;
39    }
40
41    ::slotted(pre) {
42      flex: 1;
43      min-height: 0;
44      margin: 0 !important;
45      padding: var(--panel-padding, 0.75rem 1rem);
46      white-space: pre;
47    }
48  `;
          

63  const VSB_TEMPLATE = /* html */ `
64    <style>${VSB_STYLE}</style>
65    <div class="container" part="container">
66      <div class="panel panel-left" part="panel-left">
67        <slot name="left"></slot>
68      </div>
69      <div class="splitter" part="splitter"></div>
70      <div class="panel panel-right" part="panel-right">
71        <slot name="right"></slot>
72      </div>
73    </div>
74    <div class="resizer" part="resizer"></div>
75  `;
          

77  class ViewSplitterBar extends HTMLElement {
78    static get observedAttributes() {
79      return [
80        'width', 'height', 'left-ratio', 'bar-width', 'bar-color',
81        'bg-color', 'color', 'overflow-x', 'code-padding',
82        'highlight', 'step-px', 'min-panel-px', 'min-height-px'
83      ];
84    }
85
86    constructor() {
87      super();
88      this.attachShadow({ mode: 'open' });
89      this.shadowRoot.innerHTML = VSB_TEMPLATE;
90
91      this._els = {
92        container:  this.shadowRoot.querySelector('.container'),
93        panelLeft:  this.shadowRoot.querySelector('.panel-left'),
94        splitter:   this.shadowRoot.querySelector('.splitter'),
95        panelRight: this.shadowRoot.querySelector('.panel-right'),
96        resizer:    this.shadowRoot.querySelector('.resizer'),
97        slotLeft:   this.shadowRoot.querySelector('slot[name="left"]'),
98        slotRight:  this.shadowRoot.querySelector('slot[name="right"]'),
99      };
100
101     this._leftPx     = null;    this._heightPx   = null;
102     this._dragging   = false;   this._dragStartX = 0;
103     this._dragStartW = 0;       this._dragStartY = 0;
104     this._dragStartH = 0;
105
106     this._onMousedown        = e  => this._startDrag(e);
107     this._onMousemove        = e  => this._onDrag(e);
108     this._onMouseup          = () => this._endDrag();
109     this._onResizerMousedown = e  => this._startHeightDrag(e);
110     this._onResizerMousemove = e  => this._onHeightDrag(e);
111     this._onResizerMouseup   = () => this._endHeightDrag();
112     this._onClickLeft        = () => this._bump(+1);
113     this._onClickRight       = () => this._bump(-1);
114     this._onSlotChange       = () => {
115       this._maybePrism();
116       this._harmonizeSlotted();
117       requestAnimationFrame(() => {
118         this._els.panelLeft.scrollTop  = 0;
119         this._els.panelRight.scrollTop = 0;
120         [this._els.slotLeft, this._els.slotRight].forEach(slot => {
121           slot.assignedElements({ flatten: true })
122               .forEach(el => { el.scrollTop = 0; });
123         });
124       });
125     };
126
127     this._resizeObserver = new ResizeObserver(() => {
128       if (this._dragging) return;
129       this._leftPx = null;
130       requestAnimationFrame(() => {
131         this._els.panelLeft.style.width = `${this._getLeftPx()}px`;
132       });
133     });
134   }
          

138   connectedCallback() {
139     this._applyStyles();
140     this._applyLayout();
141     this._maybePrism();
142     this._harmonizeSlotted();
143     this._els.splitter.addEventListener('mousedown',   this._onMousedown);
144     this._els.resizer.addEventListener('mousedown',    this._onResizerMousedown);
145     this._els.panelLeft.addEventListener('click',      this._onClickLeft);
146     this._els.panelRight.addEventListener('click',     this._onClickRight);
147     this._els.slotLeft.addEventListener('slotchange',  this._onSlotChange);
148     this._els.slotRight.addEventListener('slotchange', this._onSlotChange);
149     this._resizeObserver.observe(this);
150     requestAnimationFrame(() => {
151       this._els.panelLeft.scrollTop  = 0;
152       this._els.panelRight.scrollTop = 0;
153     });
154   }
155
156   disconnectedCallback() {
157     this._els.splitter.removeEventListener('mousedown',   this._onMousedown);
158     this._els.resizer.removeEventListener('mousedown',    this._onResizerMousedown);
159     this._els.panelLeft.removeEventListener('click',      this._onClickLeft);
160     this._els.panelRight.removeEventListener('click',     this._onClickRight);
161     this._els.slotLeft.removeEventListener('slotchange',  this._onSlotChange);
162     this._els.slotRight.removeEventListener('slotchange', this._onSlotChange);
163     document.removeEventListener('mousemove', this._onMousemove);
164     document.removeEventListener('mouseup',   this._onMouseup);
165     document.removeEventListener('mousemove', this._onResizerMousemove);
166     document.removeEventListener('mouseup',   this._onResizerMouseup);
167     this._resizeObserver.disconnect();
168   }
169
170   attributeChangedCallback() {
171     this._leftPx   = null;
172     this._heightPx = null;
173     this._applyStyles();
174     this._applyLayout();
175   }
          

179   _startDrag(e) {
180     e.preventDefault();
181     this._dragging   = true;
182     this._dragStartX = e.clientX;
183     this._dragStartW = this._getLeftPx();
184     document.addEventListener('mousemove', this._onMousemove);
185     document.addEventListener('mouseup',   this._onMouseup);
186   }
187
188   _onDrag(e) {
189     this._setLeftPx(this._dragStartW + (e.clientX - this._dragStartX));
190   }
191
192   _endDrag() {
193     this._dragging = false;
194     document.removeEventListener('mousemove', this._onMousemove);
195     document.removeEventListener('mouseup',   this._onMouseup);
196   }
197
198   _startHeightDrag(e) {
199     e.preventDefault();
200     this._dragging   = true;
201     this._dragStartY = e.clientY;
202     this._dragStartH = this._getHeightPx();
203     document.addEventListener('mousemove', this._onResizerMousemove);
204     document.addEventListener('mouseup',   this._onResizerMouseup);
205   }
206
207   _onHeightDrag(e) {
208     this._setHeightPx(this._dragStartH + (e.clientY - this._dragStartY));
209   }
210
211   _endHeightDrag() {
212     this._dragging = false;
213     document.removeEventListener('mousemove', this._onResizerMousemove);
214     document.removeEventListener('mouseup',   this._onResizerMouseup);
215   }
216
217   _bump(dir) {
218     const step = parseFloat(this.getAttribute('step-px')) || 40;
219     this._setLeftPx(this._getLeftPx() + dir * step);
220   }
          

222   _getLeftPx() {
223     if (this._leftPx != null) return this._leftPx;
224     const ratio  = parseFloat(this.getAttribute('left-ratio')) || 0.5;
225     this._leftPx = Math.round(ratio * this._availableWidth());
226     return this._leftPx;
227   }
228
229   _setLeftPx(px) {
230     const minPx     = parseFloat(this.getAttribute('min-panel-px')) || 120;
231     const available = this._availableWidth();
232     this._leftPx    = Math.min(Math.max(Math.round(px), minPx), available - minPx);
233     this._els.panelLeft.style.width = `${this._leftPx}px`;
234   }
235
236   _totalWidthPx() {
237     const r = this.getBoundingClientRect();
238     if (r.width > 0) return r.width;
239     const w = this.getAttribute('width');
240     if (w) {
241       if (w.endsWith('rem')) {
242         const fs = parseFloat(
243           getComputedStyle(document.documentElement).fontSize) || 16;
244         return parseFloat(w) * fs;
245       }
246       const px = parseFloat(w);
247       if (!isNaN(px)) return px;
248     }
249     return 600;
250   }
251
252   _applyStyles() {
253     this.style.setProperty('--panel-bg',         this.getAttribute('bg-color')     || 'var(--light, #f8f8f8)');
254     this.style.setProperty('--panel-fg',         this.getAttribute('color')        || 'var(--dark, #333)');
255     this.style.setProperty('--panel-padding',    this.getAttribute('code-padding') || '0.75rem 1rem');
256     this.style.setProperty('--panel-overflow-x', this.getAttribute('overflow-x')   || 'auto');
257     this.style.setProperty('--bar-width',        this.getAttribute('bar-width')    || '6px');
258     this.style.setProperty('--bar-color',        this.getAttribute('bar-color')    || '#888');
259   }
260
261   _applyLayout() {
262     const w = this.getAttribute('width');
263     if (w) this.style.width = w;
264     if (this.getAttribute('height') || this._heightPx != null)
265       this._els.container.style.height = `${this._getHeightPx()}px`;
266     if (this._leftPx != null) {
267       this._els.panelLeft.style.width = `${this._leftPx}px`;
268     } else {
269       requestAnimationFrame(() => {
270         this._els.panelLeft.style.width = `${this._getLeftPx()}px`;
271       });
272     }
273   }
274
275   _maybePrism() {
276     if (this.getAttribute('highlight') !== 'prism' || !window.Prism) return;
277     [this._els.slotLeft, this._els.slotRight].forEach(slot => {
278       slot.assignedElements({ flatten: true }).forEach(el => {
279         const codes = el.tagName === 'CODE'
280           ? [el] : [...el.querySelectorAll('code')];
281         codes.forEach(c => window.Prism.highlightElement(c));
282       });
283     });
284   }
285
286   _harmonizeSlotted() {
287     const pad = this.getAttribute('code-padding') || '0.75rem 1rem';
288     [this._els.slotLeft, this._els.slotRight].forEach(slot => {
289       slot.assignedElements({ flatten: true }).forEach(el => {
290         if (el.tagName !== 'PRE') return;
291         el.style.margin    = '0';
292         el.style.padding   = pad;
293         el.style.flex      = '1';
294         el.style.minHeight = '0';
295         el.style.boxSizing = 'border-box';
296       });
297     });
298   }
299 }
300
301 customElements.define('view-splitter-bar', ViewSplitterBar);