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