WebDev Bites: ViewImage Component Design

W3C web component

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