@jungherz-de/glasskit-elements 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/dist/components/glk-accordion-item.js +1 -1
  3. package/dist/components/glk-accordion.js +1 -1
  4. package/dist/components/glk-avatar.js +1 -1
  5. package/dist/components/glk-badge.js +1 -1
  6. package/dist/components/glk-button.js +1 -1
  7. package/dist/components/glk-card.js +1 -1
  8. package/dist/components/glk-checkbox.js +1 -1
  9. package/dist/components/glk-divider.js +1 -1
  10. package/dist/components/glk-input.js +1 -1
  11. package/dist/components/glk-list-item.js +1 -1
  12. package/dist/components/glk-list.js +1 -1
  13. package/dist/components/glk-modal.js +19 -12
  14. package/dist/components/glk-nav.js +1 -1
  15. package/dist/components/glk-pill.js +1 -1
  16. package/dist/components/glk-popover.js +1 -1
  17. package/dist/components/glk-progress.js +1 -1
  18. package/dist/components/glk-radio.js +1 -1
  19. package/dist/components/glk-range.js +1 -1
  20. package/dist/components/glk-search.js +1 -1
  21. package/dist/components/glk-select.js +39 -8
  22. package/dist/components/glk-status.js +1 -1
  23. package/dist/components/glk-tab-accessory.js +1 -1
  24. package/dist/components/glk-tab-bar.js +1 -1
  25. package/dist/components/glk-tab-dock.js +1 -1
  26. package/dist/components/glk-tab-item.js +26 -15
  27. package/dist/components/glk-textarea.js +1 -1
  28. package/dist/components/glk-title.js +1 -1
  29. package/dist/components/glk-toast.js +1 -1
  30. package/dist/components/glk-toggle.js +1 -1
  31. package/dist/components/shared/{base-C0B1hgOt.js → base-BoN33KPe.js} +36 -3
  32. package/dist/glasskit-elements.esm.js +117 -35
  33. package/dist/glasskit-elements.js +117 -35
  34. package/dist/glasskit-elements.min.js +1 -1
  35. package/package.json +1 -1
  36. package/src/base.js +36 -3
  37. package/src/components/feedback/glk-modal.js +18 -11
  38. package/src/components/forms/glk-select.js +38 -7
  39. package/src/components/navigation/glk-tab-item.js +25 -14
@@ -104,6 +104,14 @@ class GlkElement extends HTMLElement {
104
104
  */
105
105
  static get hostStyles() { return null; }
106
106
 
107
+ /**
108
+ * Opt in when the component copies light-DOM children into its shadow tree.
109
+ * A MutationObserver then calls projectLightDom() again whenever those
110
+ * children change, so a framework that swaps them keeps the rendered element
111
+ * in step. Without it the copy is made once and silently goes stale.
112
+ */
113
+ static get observesLightDom() { return false; }
114
+
107
115
  static get observedAttributes() {
108
116
  return [];
109
117
  }
@@ -130,15 +138,26 @@ class GlkElement extends HTMLElement {
130
138
  this._shadow.appendChild(this._wrapper);
131
139
 
132
140
  this.render();
133
- this.setupEvents();
141
+ }
134
142
 
135
- // Register for theme sync
136
- instances.add(this);
143
+ // Everything below runs on every connect, not just the first. Moving an
144
+ // element in the DOM disconnects and reconnects it, and disconnectedCallback
145
+ // tears all of this down — without re-arming it here a moved element would
146
+ // keep its markup but silently stop reacting.
147
+ this.setupEvents();
148
+ instances.add(this);
149
+
150
+ if (this.constructor.observesLightDom) {
151
+ this._lightDomObserver ??= new MutationObserver(records => this.projectLightDom(records));
152
+ this._lightDomObserver.observe(this, {
153
+ childList: true, subtree: true, characterData: true
154
+ });
137
155
  }
138
156
  }
139
157
 
140
158
  disconnectedCallback() {
141
159
  instances.delete(this);
160
+ this._lightDomObserver?.disconnect();
142
161
  this.teardownEvents();
143
162
  }
144
163
 
@@ -166,6 +185,20 @@ class GlkElement extends HTMLElement {
166
185
  /** Subclasses override to react to attribute changes. */
167
186
  onAttributeChanged(name, oldValue, newValue) {}
168
187
 
188
+ /**
189
+ * Subclasses that set observesLightDom override this to (re-)copy their
190
+ * light-DOM children into the shadow tree. Runs on every change to those
191
+ * children, so it has to be safe to call repeatedly.
192
+ */
193
+ projectLightDom() {}
194
+
195
+ /**
196
+ * Escape hatch: re-copy the light-DOM children now. The observer covers the
197
+ * ordinary cases; this is for the ones it cannot see, so nobody has to reach
198
+ * into element.shadowRoot.
199
+ */
200
+ refresh() { this.projectLightDom(); }
201
+
169
202
  // ── Utility Methods ──
170
203
 
171
204
  getBoolAttr(name) {
@@ -354,6 +387,8 @@ class GlkTabItem extends GlkElement {
354
387
  return ['label', 'active', 'badge'];
355
388
  }
356
389
 
390
+ static get observesLightDom() { return true; }
391
+
357
392
  render() {
358
393
  this._btn = this.createElement('button', ['glass-tab-bar__item']);
359
394
  if (this.getBoolAttr('active')) this._btn.classList.add('is-active');
@@ -379,23 +414,32 @@ class GlkTabItem extends GlkElement {
379
414
  this._wrapper.appendChild(this._btn);
380
415
 
381
416
  // Defer icon cloning — children may not be parsed yet
382
- requestAnimationFrame(() => this._cloneIcon());
417
+ requestAnimationFrame(() => this.projectLightDom());
383
418
  }
384
419
 
385
- _cloneIcon() {
420
+ projectLightDom() {
386
421
  const svg = this.querySelector('svg');
387
- if (svg) {
388
- const clone = svg.cloneNode(true);
389
- // Remove inline attributes that would override GlassKit styles
390
- clone.removeAttribute('width');
391
- clone.removeAttribute('height');
392
- clone.removeAttribute('stroke');
393
- clone.removeAttribute('stroke-width');
394
- clone.removeAttribute('stroke-linecap');
395
- clone.removeAttribute('stroke-linejoin');
396
- clone.removeAttribute('fill');
397
- this._iconEl.insertBefore(clone, this._iconEl.firstChild);
398
- }
422
+ const signature = svg ? svg.outerHTML : '';
423
+ if (signature === this._iconSignature) return;
424
+ this._iconSignature = signature;
425
+
426
+ // Drop the previous clone by hand: the icon container also holds the badge,
427
+ // and inserting without removing would stack a second icon on every update.
428
+ this._iconClone?.remove();
429
+ this._iconClone = null;
430
+ if (!svg) return;
431
+
432
+ const clone = svg.cloneNode(true);
433
+ // Remove inline attributes that would override GlassKit styles
434
+ clone.removeAttribute('width');
435
+ clone.removeAttribute('height');
436
+ clone.removeAttribute('stroke');
437
+ clone.removeAttribute('stroke-width');
438
+ clone.removeAttribute('stroke-linecap');
439
+ clone.removeAttribute('stroke-linejoin');
440
+ clone.removeAttribute('fill');
441
+ this._iconEl.insertBefore(clone, this._iconEl.firstChild);
442
+ this._iconClone = clone;
399
443
  }
400
444
 
401
445
  setupEvents() {
@@ -1058,6 +1102,8 @@ class GlkSelect extends GlkFormElement {
1058
1102
  return ['label', 'disabled', 'name', 'value', 'required'];
1059
1103
  }
1060
1104
 
1105
+ static get observesLightDom() { return true; }
1106
+
1061
1107
  render() {
1062
1108
  const group = this.createElement('div', ['glass-input-group']);
1063
1109
 
@@ -1078,12 +1124,29 @@ class GlkSelect extends GlkFormElement {
1078
1124
  this._wrapper.appendChild(group);
1079
1125
 
1080
1126
  // Defer option copying — children may not be parsed yet in connectedCallback
1081
- requestAnimationFrame(() => {
1082
- this._moveOptions();
1083
- const value = this.getAttribute('value');
1084
- if (value) this._select.value = value;
1085
- this._syncFormValue();
1086
- });
1127
+ requestAnimationFrame(() => this.projectLightDom());
1128
+ }
1129
+
1130
+ projectLightDom() {
1131
+ // The options are pure data — the clones carry no listeners — so skipping an
1132
+ // unchanged rebuild is safe, and it keeps an open dropdown from snapping shut
1133
+ // on light-DOM churn elsewhere.
1134
+ const signature = [...this.querySelectorAll('option')].map(o => o.outerHTML).join('');
1135
+ if (signature === this._optionSignature) return;
1136
+ this._optionSignature = signature;
1137
+
1138
+ // innerHTML = '' below drops the selection, so remember it first. A
1139
+ // selectedIndex of -1 means "nothing is selected", which is not the same as
1140
+ // an option whose value happens to be the empty string.
1141
+ const previous = this._select.selectedIndex >= 0 ? this._select.value : null;
1142
+
1143
+ this._moveOptions();
1144
+
1145
+ // Keep the live selection when it survived the rebuild; otherwise fall back
1146
+ // to the value attribute. Without this the selection jumps back to the first
1147
+ // entry every time the list is updated.
1148
+ if (!this._applyValue(previous)) this._applyValue(this.getAttribute('value'));
1149
+ this._syncFormValue();
1087
1150
  }
1088
1151
 
1089
1152
  _moveOptions() {
@@ -1094,6 +1157,18 @@ class GlkSelect extends GlkFormElement {
1094
1157
  });
1095
1158
  }
1096
1159
 
1160
+ /**
1161
+ * Selects `value` if an option carries it, and reports whether it did. The
1162
+ * empty string is a value like any other — "" is a real option in plenty of
1163
+ * forms ("detect automatically", "enter your own below").
1164
+ */
1165
+ _applyValue(value) {
1166
+ if (value === null) return false;
1167
+ if (![...this._select.options].some(o => o.value === value)) return false;
1168
+ this._select.value = value;
1169
+ return true;
1170
+ }
1171
+
1097
1172
  setupEvents() {
1098
1173
  this._onChange = () => {
1099
1174
  this._syncFormValue();
@@ -1120,7 +1195,7 @@ class GlkSelect extends GlkFormElement {
1120
1195
  this._select.setAttribute('name', this.getAttribute('name') || '');
1121
1196
  break;
1122
1197
  case 'value':
1123
- this._select.value = this.getAttribute('value') || '';
1198
+ this._applyValue(this.getAttribute('value'));
1124
1199
  this._syncFormValue();
1125
1200
  break;
1126
1201
  }
@@ -1830,6 +1905,8 @@ class GlkModal extends GlkElement {
1830
1905
  return ['open', 'title'];
1831
1906
  }
1832
1907
 
1908
+ static get observesLightDom() { return true; }
1909
+
1833
1910
  render() {
1834
1911
  this._overlay = this.createElement('div', ['glass-modal-overlay'], { part: 'overlay' });
1835
1912
 
@@ -1853,7 +1930,7 @@ class GlkModal extends GlkElement {
1853
1930
  modal.appendChild(this._footer);
1854
1931
 
1855
1932
  // Defer footer population — children may not be parsed yet
1856
- requestAnimationFrame(() => this._populateFooter());
1933
+ requestAnimationFrame(() => this.projectLightDom());
1857
1934
 
1858
1935
  this._overlay.appendChild(modal);
1859
1936
  this._wrapper.appendChild(this._overlay);
@@ -1863,18 +1940,23 @@ class GlkModal extends GlkElement {
1863
1940
  }
1864
1941
  }
1865
1942
 
1866
- _populateFooter() {
1943
+ projectLightDom() {
1944
+ const buttons = [...this.querySelectorAll('[slot="actions"] button')];
1945
+ const signature = buttons.map(b => b.outerHTML).join('');
1946
+ if (signature === this._footerSignature) return;
1947
+ this._footerSignature = signature;
1948
+
1867
1949
  this._footer.innerHTML = '';
1868
- const actionsSlot = this.querySelector('[slot="actions"]');
1869
- if (actionsSlot) {
1870
- const buttons = actionsSlot.querySelectorAll('button');
1871
- buttons.forEach(btn => {
1872
- const clone = btn.cloneNode(true);
1873
- // Forward click events to the original button
1874
- clone.addEventListener('click', () => btn.click());
1875
- this._footer.appendChild(clone);
1950
+ buttons.forEach((btn, i) => {
1951
+ const clone = btn.cloneNode(true);
1952
+ // Look the original up again at click time instead of closing over it: a
1953
+ // framework that re-renders replaces the node, and a captured reference
1954
+ // would then forward the click to a button no longer in the document.
1955
+ clone.addEventListener('click', () => {
1956
+ this.querySelectorAll('[slot="actions"] button')[i]?.click();
1876
1957
  });
1877
- }
1958
+ this._footer.appendChild(clone);
1959
+ });
1878
1960
  }
1879
1961
 
1880
1962
  setupEvents() {
@@ -107,6 +107,14 @@ var GlassKitElements = (function (exports) {
107
107
  */
108
108
  static get hostStyles() { return null; }
109
109
 
110
+ /**
111
+ * Opt in when the component copies light-DOM children into its shadow tree.
112
+ * A MutationObserver then calls projectLightDom() again whenever those
113
+ * children change, so a framework that swaps them keeps the rendered element
114
+ * in step. Without it the copy is made once and silently goes stale.
115
+ */
116
+ static get observesLightDom() { return false; }
117
+
110
118
  static get observedAttributes() {
111
119
  return [];
112
120
  }
@@ -133,15 +141,26 @@ var GlassKitElements = (function (exports) {
133
141
  this._shadow.appendChild(this._wrapper);
134
142
 
135
143
  this.render();
136
- this.setupEvents();
144
+ }
137
145
 
138
- // Register for theme sync
139
- instances.add(this);
146
+ // Everything below runs on every connect, not just the first. Moving an
147
+ // element in the DOM disconnects and reconnects it, and disconnectedCallback
148
+ // tears all of this down — without re-arming it here a moved element would
149
+ // keep its markup but silently stop reacting.
150
+ this.setupEvents();
151
+ instances.add(this);
152
+
153
+ if (this.constructor.observesLightDom) {
154
+ this._lightDomObserver ??= new MutationObserver(records => this.projectLightDom(records));
155
+ this._lightDomObserver.observe(this, {
156
+ childList: true, subtree: true, characterData: true
157
+ });
140
158
  }
141
159
  }
142
160
 
143
161
  disconnectedCallback() {
144
162
  instances.delete(this);
163
+ this._lightDomObserver?.disconnect();
145
164
  this.teardownEvents();
146
165
  }
147
166
 
@@ -169,6 +188,20 @@ var GlassKitElements = (function (exports) {
169
188
  /** Subclasses override to react to attribute changes. */
170
189
  onAttributeChanged(name, oldValue, newValue) {}
171
190
 
191
+ /**
192
+ * Subclasses that set observesLightDom override this to (re-)copy their
193
+ * light-DOM children into the shadow tree. Runs on every change to those
194
+ * children, so it has to be safe to call repeatedly.
195
+ */
196
+ projectLightDom() {}
197
+
198
+ /**
199
+ * Escape hatch: re-copy the light-DOM children now. The observer covers the
200
+ * ordinary cases; this is for the ones it cannot see, so nobody has to reach
201
+ * into element.shadowRoot.
202
+ */
203
+ refresh() { this.projectLightDom(); }
204
+
172
205
  // ── Utility Methods ──
173
206
 
174
207
  getBoolAttr(name) {
@@ -357,6 +390,8 @@ var GlassKitElements = (function (exports) {
357
390
  return ['label', 'active', 'badge'];
358
391
  }
359
392
 
393
+ static get observesLightDom() { return true; }
394
+
360
395
  render() {
361
396
  this._btn = this.createElement('button', ['glass-tab-bar__item']);
362
397
  if (this.getBoolAttr('active')) this._btn.classList.add('is-active');
@@ -382,23 +417,32 @@ var GlassKitElements = (function (exports) {
382
417
  this._wrapper.appendChild(this._btn);
383
418
 
384
419
  // Defer icon cloning — children may not be parsed yet
385
- requestAnimationFrame(() => this._cloneIcon());
420
+ requestAnimationFrame(() => this.projectLightDom());
386
421
  }
387
422
 
388
- _cloneIcon() {
423
+ projectLightDom() {
389
424
  const svg = this.querySelector('svg');
390
- if (svg) {
391
- const clone = svg.cloneNode(true);
392
- // Remove inline attributes that would override GlassKit styles
393
- clone.removeAttribute('width');
394
- clone.removeAttribute('height');
395
- clone.removeAttribute('stroke');
396
- clone.removeAttribute('stroke-width');
397
- clone.removeAttribute('stroke-linecap');
398
- clone.removeAttribute('stroke-linejoin');
399
- clone.removeAttribute('fill');
400
- this._iconEl.insertBefore(clone, this._iconEl.firstChild);
401
- }
425
+ const signature = svg ? svg.outerHTML : '';
426
+ if (signature === this._iconSignature) return;
427
+ this._iconSignature = signature;
428
+
429
+ // Drop the previous clone by hand: the icon container also holds the badge,
430
+ // and inserting without removing would stack a second icon on every update.
431
+ this._iconClone?.remove();
432
+ this._iconClone = null;
433
+ if (!svg) return;
434
+
435
+ const clone = svg.cloneNode(true);
436
+ // Remove inline attributes that would override GlassKit styles
437
+ clone.removeAttribute('width');
438
+ clone.removeAttribute('height');
439
+ clone.removeAttribute('stroke');
440
+ clone.removeAttribute('stroke-width');
441
+ clone.removeAttribute('stroke-linecap');
442
+ clone.removeAttribute('stroke-linejoin');
443
+ clone.removeAttribute('fill');
444
+ this._iconEl.insertBefore(clone, this._iconEl.firstChild);
445
+ this._iconClone = clone;
402
446
  }
403
447
 
404
448
  setupEvents() {
@@ -1061,6 +1105,8 @@ var GlassKitElements = (function (exports) {
1061
1105
  return ['label', 'disabled', 'name', 'value', 'required'];
1062
1106
  }
1063
1107
 
1108
+ static get observesLightDom() { return true; }
1109
+
1064
1110
  render() {
1065
1111
  const group = this.createElement('div', ['glass-input-group']);
1066
1112
 
@@ -1081,12 +1127,29 @@ var GlassKitElements = (function (exports) {
1081
1127
  this._wrapper.appendChild(group);
1082
1128
 
1083
1129
  // Defer option copying — children may not be parsed yet in connectedCallback
1084
- requestAnimationFrame(() => {
1085
- this._moveOptions();
1086
- const value = this.getAttribute('value');
1087
- if (value) this._select.value = value;
1088
- this._syncFormValue();
1089
- });
1130
+ requestAnimationFrame(() => this.projectLightDom());
1131
+ }
1132
+
1133
+ projectLightDom() {
1134
+ // The options are pure data — the clones carry no listeners — so skipping an
1135
+ // unchanged rebuild is safe, and it keeps an open dropdown from snapping shut
1136
+ // on light-DOM churn elsewhere.
1137
+ const signature = [...this.querySelectorAll('option')].map(o => o.outerHTML).join('');
1138
+ if (signature === this._optionSignature) return;
1139
+ this._optionSignature = signature;
1140
+
1141
+ // innerHTML = '' below drops the selection, so remember it first. A
1142
+ // selectedIndex of -1 means "nothing is selected", which is not the same as
1143
+ // an option whose value happens to be the empty string.
1144
+ const previous = this._select.selectedIndex >= 0 ? this._select.value : null;
1145
+
1146
+ this._moveOptions();
1147
+
1148
+ // Keep the live selection when it survived the rebuild; otherwise fall back
1149
+ // to the value attribute. Without this the selection jumps back to the first
1150
+ // entry every time the list is updated.
1151
+ if (!this._applyValue(previous)) this._applyValue(this.getAttribute('value'));
1152
+ this._syncFormValue();
1090
1153
  }
1091
1154
 
1092
1155
  _moveOptions() {
@@ -1097,6 +1160,18 @@ var GlassKitElements = (function (exports) {
1097
1160
  });
1098
1161
  }
1099
1162
 
1163
+ /**
1164
+ * Selects `value` if an option carries it, and reports whether it did. The
1165
+ * empty string is a value like any other — "" is a real option in plenty of
1166
+ * forms ("detect automatically", "enter your own below").
1167
+ */
1168
+ _applyValue(value) {
1169
+ if (value === null) return false;
1170
+ if (![...this._select.options].some(o => o.value === value)) return false;
1171
+ this._select.value = value;
1172
+ return true;
1173
+ }
1174
+
1100
1175
  setupEvents() {
1101
1176
  this._onChange = () => {
1102
1177
  this._syncFormValue();
@@ -1123,7 +1198,7 @@ var GlassKitElements = (function (exports) {
1123
1198
  this._select.setAttribute('name', this.getAttribute('name') || '');
1124
1199
  break;
1125
1200
  case 'value':
1126
- this._select.value = this.getAttribute('value') || '';
1201
+ this._applyValue(this.getAttribute('value'));
1127
1202
  this._syncFormValue();
1128
1203
  break;
1129
1204
  }
@@ -1833,6 +1908,8 @@ var GlassKitElements = (function (exports) {
1833
1908
  return ['open', 'title'];
1834
1909
  }
1835
1910
 
1911
+ static get observesLightDom() { return true; }
1912
+
1836
1913
  render() {
1837
1914
  this._overlay = this.createElement('div', ['glass-modal-overlay'], { part: 'overlay' });
1838
1915
 
@@ -1856,7 +1933,7 @@ var GlassKitElements = (function (exports) {
1856
1933
  modal.appendChild(this._footer);
1857
1934
 
1858
1935
  // Defer footer population — children may not be parsed yet
1859
- requestAnimationFrame(() => this._populateFooter());
1936
+ requestAnimationFrame(() => this.projectLightDom());
1860
1937
 
1861
1938
  this._overlay.appendChild(modal);
1862
1939
  this._wrapper.appendChild(this._overlay);
@@ -1866,18 +1943,23 @@ var GlassKitElements = (function (exports) {
1866
1943
  }
1867
1944
  }
1868
1945
 
1869
- _populateFooter() {
1946
+ projectLightDom() {
1947
+ const buttons = [...this.querySelectorAll('[slot="actions"] button')];
1948
+ const signature = buttons.map(b => b.outerHTML).join('');
1949
+ if (signature === this._footerSignature) return;
1950
+ this._footerSignature = signature;
1951
+
1870
1952
  this._footer.innerHTML = '';
1871
- const actionsSlot = this.querySelector('[slot="actions"]');
1872
- if (actionsSlot) {
1873
- const buttons = actionsSlot.querySelectorAll('button');
1874
- buttons.forEach(btn => {
1875
- const clone = btn.cloneNode(true);
1876
- // Forward click events to the original button
1877
- clone.addEventListener('click', () => btn.click());
1878
- this._footer.appendChild(clone);
1953
+ buttons.forEach((btn, i) => {
1954
+ const clone = btn.cloneNode(true);
1955
+ // Look the original up again at click time instead of closing over it: a
1956
+ // framework that re-renders replaces the node, and a captured reference
1957
+ // would then forward the click to a button no longer in the document.
1958
+ clone.addEventListener('click', () => {
1959
+ this.querySelectorAll('[slot="actions"] button')[i]?.click();
1879
1960
  });
1880
- }
1961
+ this._footer.appendChild(clone);
1962
+ });
1881
1963
  }
1882
1964
 
1883
1965
  setupEvents() {