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