Template Strings
ViewImage defines two module-level constants before the class: VI_STYLE
holds the shadow DOM CSS and VI_TEMPLATE holds the HTML structure.
Separating layout from class logic makes each part independently readable and keeps
the constructor free of markup noise.
VI_STYLE uses CSS custom properties (--view-bg,
--title-bg, --title-font-size) so the host page can
override colors via this.style.setProperty() without touching
the shadow stylesheet.
Shadow DOM Template
VI_TEMPLATE embeds VI_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.
The <slot> in the title div lets the host page supply caption text
as element content: <view-image>Figure 1</view-image>.
Class and Attributes
observedAttributes declares seven attributes:
src, alt, width, bg-color,
title-bg-color, step-px, and min-width.
The constructor caches shadow DOM element references in this._els,
initializes resize state (_originWidthPx, _stepsFromOrigin),
and builds bound click handlers.
Lifecycle Callbacks
connectedCallback calls _updateAll() to apply all current
attributes, then attaches click listeners to the image panel and title.
disconnectedCallback removes those same listeners to prevent leaks.
attributeChangedCallback calls _updateAll() so the view
stays in sync whenever an attribute changes after initial render.
Update Helpers
_updateAll() delegates to three focused helpers so each concern
can be maintained or extended independently.
_applyBoxColors() sets the --view-bg and
--title-bg CSS custom properties on the host element. This
propagates into the shadow DOM without requiring direct shadow stylesheet access.
_applyImage() syncs src and alt on the
internal <img>. _applySizing() applies the
width attribute to the .view div.
_bumpWidth
Handles click-to-resize. On the first click it records the current rendered
width as _originWidthPx and resets the step counter to zero.
Each subsequent click adds or subtracts one step-px increment
from the origin. The step count is preserved between grow and shrink directions
so the image returns to exact widths it has been at before, rather than drifting.
Width is clamped to min-width (default 120 px) to prevent
the component from collapsing to zero.
1 const VI_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 box-sizing: border-box;
18 background-color: var(--view-bg, #f8f8f8);
19 }
20
21 .title {
22 font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
23 font-size: var(--title-font-size, 1rem);
24 font-weight: bold;
25 cursor: pointer;
26 max-width: 100%;
27 margin-bottom: 8px;
28 line-height: 1.2;
29 overflow-wrap: break-word;
30 white-space: normal;
31 color: var(--dark, #333);
32 background-color: var(--title-bg, transparent);
33 padding: 0.125rem 0.5rem;
34 }
35
36 .image-panel {
37 display: block;
38 cursor: pointer;
39 }
40
41 #img-internal {
42 display: block;
43 width: 100%;
44 height: auto;
45 }
46 `;
48 const VI_TEMPLATE = /* html */ `
49 <style>${VI_STYLE}</style>
50 <div class="wrapper">
51 <div class="view" part="view">
52 <div class="title" part="title"><slot></slot></div>
53 <div class="image-panel">
54 <img id="img-internal" alt="">
55 </div>
56 </div>
57 </div>
58 `;
60 class ViewImage extends HTMLElement {
61 static get observedAttributes() {
62 return ['src', 'alt', 'width', 'bg-color',
63 'title-bg-color', 'step-px', 'min-width'];
64 }
65
66 constructor() {
67 super();
68 this.attachShadow({ mode: 'open' });
69 this.shadowRoot.innerHTML = VI_TEMPLATE;
70
71 this._els = {
72 title: this.shadowRoot.querySelector('.title'),
73 view: this.shadowRoot.querySelector('.view'),
74 panel: this.shadowRoot.querySelector('.image-panel'),
75 img: this.shadowRoot.querySelector('#img-internal'),
76 };
77
78 this._originWidthPx = null;
79 this._stepsFromOrigin = 0;
80
81 this._onBodyClick = () => this._bumpWidth(+1);
82 this._onTitleClick = () => this._bumpWidth(-1);
83 }
85 connectedCallback() {
86 this._updateAll();
87 this._els.panel.addEventListener('click', this._onBodyClick);
88 this._els.title.addEventListener('click', this._onTitleClick);
89 }
90
91 disconnectedCallback() {
92 this._els.panel.removeEventListener('click', this._onBodyClick);
93 this._els.title.removeEventListener('click', this._onTitleClick);
94 }
95
96 attributeChangedCallback() {
97 this._updateAll();
98 }
100 _updateAll() {
101 this._applyBoxColors();
102 this._applyImage();
103 this._applySizing();
104 }
105
106 _applyBoxColors() {
107 const viewBg = this.getAttribute('bg-color') || 'var(--light, #f8f8f8)';
108 const titleBg = this.getAttribute('title-bg-color') || '#aaa';
109 this.style.setProperty('--view-bg', viewBg);
110 this.style.setProperty('--title-bg', titleBg);
111 }
112
113 _applyImage() {
114 this._els.img.src = this.getAttribute('src') || '';
115 this._els.img.alt = this.getAttribute('alt') || '';
116 }
117
118 _applySizing() {
119 const width = this.getAttribute('width');
120 if (width) this._els.view.style.width = width;
121 }
123 _bumpWidth(dir) {
124 if (this._originWidthPx == null) {
125 const rect = this._els.view.getBoundingClientRect();
126 this._originWidthPx = rect.width > 0 ? rect.width : 320;
127 this._stepsFromOrigin = 0;
128 }
129 const stepPx = parseFloat(this.getAttribute('step-px')) || 40;
130 const minPx = parseFloat(this.getAttribute('min-width')) || 120;
131 let steps = this._stepsFromOrigin + dir;
132 let target = this._originWidthPx + steps * stepPx;
133 if (target < minPx) {
134 target = minPx;
135 steps = Math.ceil((target - this._originWidthPx) / stepPx);
136 }
137 this._stepsFromOrigin = steps;
138 this._els.view.style.width = `${Math.round(target)}px`;
139 }
140 }
141
142 customElements.define('view-image', ViewImage);