WebDev Bites: ViewCode Component Design

W3C web component

Template Strings

ViewCode defines two module-level constants before the class: VC_STYLE holds the shadow DOM CSS and VC_TEMPLATE holds the HTML structure. Separating layout from class logic keeps the constructor free of markup noise and makes each part independently readable. VC_STYLE uses CSS custom properties (--view-bg, --title-bg, --code-bg, --code-fg, --code-padding, --code-overflow-x, --code-height) so the class can update colors and sizing via this.style.setProperty() without touching the shadow stylesheet. CSS attribute selectors on :host([highlight="prism"]) toggle display between the internal #pre-internal (default path) and the named slot (Prism path) purely with CSS — no JavaScript needed to swap visibility.

Shadow DOM Template

VC_TEMPLATE embeds VC_STYLE directly, then defines a two-level wrapper: an outer .wrapper that provides padding so the component can be floated without text colliding with the border, and an inner .view that carries the visible border, shadow, and flex column layout. Inside .view, the <slot> in the title div lets the host page supply caption text as element content. The .code wrapper holds two children: #pre-internal with an inner #code-internal for the plain-text path, and <slot name="code"> for the Prism path. CSS selectors on :host([highlight="prism"]) toggle which one is visible.

Class and Attributes

observedAttributes declares 16 attributes covering colors, sizing, typography, Prism integration, text transforms, and resize behavior. The constructor caches shadow DOM element references in this._els, initializes resize state (_originWidthPx, _stepsFromOrigin), sets _displayEl to the internal #pre-internal element, and builds three bound handlers: _onBodyClick, _onTitleClick, and _onSlotChange.

Lifecycle Callbacks

connectedCallback calls _updateAll() to apply all current attributes, attaches the slotchange listener, and binds click listeners to the display element and title bar. disconnectedCallback removes all listeners to prevent leaks. attributeChangedCallback calls _updateAll() so the view stays in sync whenever an attribute changes after initial render. _updateAll() is the single orchestration point: calls each helper in order and conditionally resets the width origin when the rendering path changes.

Rendering Paths

_renderDefault() handles the plain-text path: gathers slotted nodes as raw text or serialized HTML, applies optional trim and normalize-indent, then writes the result into #code-internal as textContent. _maybeSetupPrism() handles the Prism path: ensures slotted content has proper <pre><code> structure, applies trim/normalize-indent, adds language classes to both elements, and calls Prism.highlightElement(). _resolveDisplayEl() selects which element receives click listeners and sizing: the internal #pre-internal for the default path, or the slotted <pre> for the Prism path.

Helper Functions

_bumpWidth() records the rendered width of .view as origin on first click, then steps from that origin by step-px increments, clamped at min-width (default 240 px). _applyBoxColors() reads the four color attributes and writes them as CSS custom properties on the host element, plus --code-padding. _applySizing() syncs width, height, and overflow-x to both CSS vars and the .view inline style. _applyTypography() applies font-family and font-size to the display element, its parent <pre>, and any inner <code>. _harmonizeBox() normalizes the visible element's padding, margins, and box-sizing after slot assignment or a path switch. _stripCommonIndent() finds the minimum leading whitespace across non-empty lines and strips it uniformly.

 1  const VC_STYLE = /* css */ `
 2    :host { display: inline-block; }
 3
 4    .wrapper {
 5      padding: 1rem;
 6      box-sizing: border-box;
 7    }
 8
 9    .view {
10      border: 2px solid var(--dark, #333);
11      padding: 0.5rem;
12      display: flex;
13      flex-direction: column;
14      user-select: none;
15      width: max-content;
16      box-shadow: 5px 5px 5px #999;
17      background-color: var(--view-bg, #f8f8f8);
18    }
19
20    .title {
21      font-size: var(--title-font-size, 1rem);
22      font-weight: bold;
23      cursor: pointer;
24      background-color: var(--title-bg, transparent);
25      color: var(--dark, #333);
         ....
34    }
35
36    .code { display: block; flex: 0 0 auto; }
37
38    #pre-internal { display: block; }
39    slot[name="code"]::slotted(*) { display: none !important; }
40
41    :host([highlight="prism"]) #pre-internal { display: none; }
42    :host([highlight="prism"]) slot[name="code"]::slotted(pre),
43    :host([highlight="prism"]) slot[name="code"]::slotted(code) {
44      display: block !important;
45      cursor: pointer;
46    }
47
48    #pre-internal {
49      padding: var(--code-padding, 0.75rem 1rem);
50      background-color: var(--code-bg, #f8f8f8);
51      color: var(--code-fg, #333);
52      overflow-x: var(--code-overflow-x, auto);
53      height: var(--code-height, auto);
54      cursor: pointer;
         ....
63    }
64
65    #code-internal {
66      display: block;
67      font-family: inherit;
68      font-size: inherit;
69    }
70  `;
          

75  const VC_TEMPLATE = /* html */ `
76    <style>${VC_STYLE}</style>
77    <div class="wrapper">
78      <div class="view" part="view">
79        <div class="title" part="title"><slot></slot></div>
80        <div class="code">
81          <pre id="pre-internal">
82            <code id="code-internal"></code>
83          </pre>
84          <slot name="code" id="code-slot"></slot>
85        </div>
86      </div>
87    </div>
88  `;
          

 88  class ViewCode extends HTMLElement {
 89    static get observedAttributes() {
 90      return [
 91        'bg-color', 'title-bg-color', 'background-color', 'color',
 92        'width', 'height', 'overflow-x',
 93        'font-family', 'font-size', 'code-padding',
 94        'highlight', 'language',
 95        'trim', 'normalize-indent',
 96        'step-px', 'min-width'
 97      ];
 98    }
 99
100    constructor() {
101      super();
102      this.attachShadow({ mode: 'open' });
103      this.shadowRoot.innerHTML = VC_TEMPLATE;
104
105      this._els = {
106        title: this.shadowRoot.querySelector('.title'),
107        view:  this.shadowRoot.querySelector('.view'),
108        pre:   this.shadowRoot.querySelector('#pre-internal'),
109        code:  this.shadowRoot.querySelector('#code-internal'),
110        slot:  this.shadowRoot.querySelector('#code-slot'),
111      };
112
113      this._originWidthPx   = null;
114      this._stepsFromOrigin = 0;
115      this._displayEl       = this._els.pre;
116
117      this._onBodyClick  = () => this._bumpWidth(+1);
118      this._onTitleClick = () => this._bumpWidth(-1);
119      this._onSlotChange = () => this._updateAll({
120                             maybeResetWidth: true, normalizeBox: true });
121    }
          

123    connectedCallback() {
124      this._updateAll({ resetWidth: true, normalizeBox: true });
125      this._els.slot.addEventListener('slotchange', this._onSlotChange);
126      this._bindDisplayListeners();
127      this._els.title.addEventListener('click', this._onTitleClick);
128    }
129
130    disconnectedCallback() {
131      this._unbindDisplayListeners();
132      this._els.title.removeEventListener('click', this._onTitleClick);
133      this._els.slot.removeEventListener('slotchange', this._onSlotChange);
134    }
135
136    attributeChangedCallback() {
137      this._updateAll({ maybeResetWidth: true, normalizeBox: true });
138    }
139
140    _updateAll({ resetWidth = false, maybeResetWidth = false,
141                 normalizeBox = false } = {}) {
142      this._applyBoxColors();
143      this._renderDefault();
144      this._maybeSetupPrism();
145      const changed = this._resolveDisplayEl(normalizeBox);
146      if (resetWidth || (maybeResetWidth && changed)) this._resetWidth();
147      this._applySizing();
148      this._applyTypography();
149    }
          

185    _renderDefault() {
186      if (this.getAttribute('highlight') === 'prism') return;
187      const nodes = this._els.slot.assignedNodes({ flatten: true });
188      let raw = '';
189      for (const n of nodes) {
190        if (n.nodeType === Node.ELEMENT_NODE &&
191            n.tagName === 'TEMPLATE') raw += n.innerHTML ?? '';
192        else if (n.nodeType === Node.ELEMENT_NODE) raw += n.outerHTML ?? '';
193        else raw += n.textContent ?? '';
194      }
195      if (this.hasAttribute('trim'))
196        raw = raw.replace(/^\s*\n/, '').replace(/\n\s*$/, '');
197      if (this.hasAttribute('normalize-indent'))
198        raw = this._stripCommonIndent(raw);
199      this._els.code.textContent = raw;
200    }
201
202    _maybeSetupPrism() {
203      if (this.getAttribute('highlight') !== 'prism') return;
204      const lang = (this.getAttribute('language') || '').trim();
205      const assigned = this._els.slot.assignedElements({ flatten: true });
206      if (!assigned.length) return;
          ....  // ensure <pre><code> structure; wrap if only <code> is present
          ....  // apply trim / normalize-indent to codeEl.textContent
          ....  // add language-* class to both <pre> and <code>
          ....  // call Prism.highlightElement() on each <code> in assigned
243    }
244
245    _resolveDisplayEl(normalize = false) {
246      let next = this._els.pre;
247      if (this.getAttribute('highlight') === 'prism') {
248        const assigned = this._els.slot.assignedElements({ flatten: true });
249        const pre = assigned.find(n => n.tagName === 'PRE');
250        next = pre || assigned[0] || this._els.pre;
251      }
252      const changed = next !== this._displayEl;
253      this._swapBodyListener(next);
254      if (normalize) this._harmonizeBox(next);
255      return changed;
256    }
          

168    _bumpWidth(dir) {
169      if (this._originWidthPx == null) {
170        const rect = this._els.view.getBoundingClientRect();
171        this._originWidthPx   = rect.width > 0 ? rect.width : 480;
172        this._stepsFromOrigin = 0;
173      }
174      const stepPx = parseFloat(this.getAttribute('step-px')) || 40;
175      const minPx  = parseFloat(this.getAttribute('min-width')) || 240;
176      let steps  = this._stepsFromOrigin + dir;
177      let target = this._originWidthPx + steps * stepPx;
178      if (target < minPx) {
179        target = minPx;
180        steps = Math.ceil((target - this._originWidthPx) / stepPx);
181      }
182      this._stepsFromOrigin = steps;
183      this._els.view.style.width = `${Math.round(target)}px`;
184    }
185
186    _applyBoxColors() {
187      this.style.setProperty('--view-bg',
188        this.getAttribute('bg-color') || 'var(--light, #f8f8f8)');
189      this.style.setProperty('--title-bg',
190        this.getAttribute('title-bg-color') || '#aaa');
191      this.style.setProperty('--code-bg',
192        this.getAttribute('background-color') || 'var(--light, #f8f8f8)');
193      this.style.setProperty('--code-fg',
194        this.getAttribute('color') || 'var(--dark, #333)');
195      this.style.setProperty('--code-padding',
196        this.getAttribute('code-padding') || '0.75rem 1rem');
197    }
198
199    _stripCommonIndent(text) {
200      const lines    = text.split('\n');
201      const nonEmpty = lines.filter(l => l.trim().length > 0);
202      if (!nonEmpty.length) return text;
203      const minIndent = Math.min(
204        ...nonEmpty.map(l => l.match(/^[ \t]*/)[0].length));
205      if (minIndent === 0) return text;
206      const re = new RegExp(`^[ \\t]{0,${minIndent}}`);
207      return lines.map(l => l.replace(re, '')).join('\n');
208    }
209  }
210
211  customElements.define('view-code', ViewCode);