@jungherz-de/glasskit-elements 1.11.0 → 1.13.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 (40) hide show
  1. package/README.md +4 -4
  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 +67 -7
  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-ClsXgLON.js} +38 -5
  32. package/dist/glasskit-elements.esm.js +185 -43
  33. package/dist/glasskit-elements.js +185 -43
  34. package/dist/glasskit-elements.min.js +1 -1
  35. package/package.json +4 -3
  36. package/src/base.js +36 -3
  37. package/src/components/content/glk-badge.js +66 -6
  38. package/src/components/feedback/glk-modal.js +18 -11
  39. package/src/components/forms/glk-select.js +38 -7
  40. package/src/components/navigation/glk-tab-item.js +25 -14
package/src/base.js CHANGED
@@ -84,6 +84,14 @@ export class GlkElement extends HTMLElement {
84
84
  */
85
85
  static get hostStyles() { return null; }
86
86
 
87
+ /**
88
+ * Opt in when the component copies light-DOM children into its shadow tree.
89
+ * A MutationObserver then calls projectLightDom() again whenever those
90
+ * children change, so a framework that swaps them keeps the rendered element
91
+ * in step. Without it the copy is made once and silently goes stale.
92
+ */
93
+ static get observesLightDom() { return false; }
94
+
87
95
  static get observedAttributes() {
88
96
  return [];
89
97
  }
@@ -110,15 +118,26 @@ export class GlkElement extends HTMLElement {
110
118
  this._shadow.appendChild(this._wrapper);
111
119
 
112
120
  this.render();
113
- this.setupEvents();
121
+ }
114
122
 
115
- // Register for theme sync
116
- instances.add(this);
123
+ // Everything below runs on every connect, not just the first. Moving an
124
+ // element in the DOM disconnects and reconnects it, and disconnectedCallback
125
+ // tears all of this down — without re-arming it here a moved element would
126
+ // keep its markup but silently stop reacting.
127
+ this.setupEvents();
128
+ instances.add(this);
129
+
130
+ if (this.constructor.observesLightDom) {
131
+ this._lightDomObserver ??= new MutationObserver(records => this.projectLightDom(records));
132
+ this._lightDomObserver.observe(this, {
133
+ childList: true, subtree: true, characterData: true
134
+ });
117
135
  }
118
136
  }
119
137
 
120
138
  disconnectedCallback() {
121
139
  instances.delete(this);
140
+ this._lightDomObserver?.disconnect();
122
141
  this.teardownEvents();
123
142
  }
124
143
 
@@ -146,6 +165,20 @@ export class GlkElement extends HTMLElement {
146
165
  /** Subclasses override to react to attribute changes. */
147
166
  onAttributeChanged(name, oldValue, newValue) {}
148
167
 
168
+ /**
169
+ * Subclasses that set observesLightDom override this to (re-)copy their
170
+ * light-DOM children into the shadow tree. Runs on every change to those
171
+ * children, so it has to be safe to call repeatedly.
172
+ */
173
+ projectLightDom() {}
174
+
175
+ /**
176
+ * Escape hatch: re-copy the light-DOM children now. The observer covers the
177
+ * ordinary cases; this is for the ones it cannot see, so nobody has to reach
178
+ * into element.shadowRoot.
179
+ */
180
+ refresh() { this.projectLightDom(); }
181
+
149
182
  // ── Utility Methods ──
150
183
 
151
184
  getBoolAttr(name) {
@@ -5,18 +5,70 @@ const VARIANTS = ['primary', 'success', 'error'];
5
5
  class GlkBadge extends GlkElement {
6
6
  static get displayInline() { return true; }
7
7
  static get observedAttributes() {
8
- return ['variant'];
8
+ return ['variant', 'interactive', 'selected'];
9
9
  }
10
10
 
11
11
  render() {
12
- this._badge = this.createElement('span', this._computeClasses());
13
- this._badge.appendChild(document.createElement('slot'));
14
- this._wrapper.appendChild(this._badge);
12
+ // One slot for the lifetime of the element — it moves between hosts when
13
+ // the badge turns into a chip and back, so the light-DOM children never
14
+ // have to be re-assigned.
15
+ this._slot = document.createElement('slot');
16
+ this._mount();
17
+ }
18
+
19
+ // A chip is a real <button>, so the keyboard, the focus ring and the
20
+ // aria-pressed announcement come for free; a plain badge stays a <span>.
21
+ // Toggling `interactive` therefore swaps the element, not just a class.
22
+ _mount() {
23
+ const interactive = this.getBoolAttr('interactive');
24
+ const el = this.createElement(interactive ? 'button' : 'span', this._computeClasses());
25
+ if (interactive) {
26
+ el.type = 'button';
27
+ el.setAttribute('aria-pressed', String(this.getBoolAttr('selected')));
28
+ }
29
+ el.appendChild(this._slot);
30
+
31
+ if (this._badge) {
32
+ this._badge.removeEventListener('click', this._onClick);
33
+ this._badge.replaceWith(el);
34
+ } else {
35
+ this._wrapper.appendChild(el);
36
+ }
37
+ this._badge = el;
38
+ // setupEvents() has not run yet on the very first mount; it attaches then.
39
+ if (this._onClick) el.addEventListener('click', this._onClick);
40
+ }
41
+
42
+ setupEvents() {
43
+ // Forward clicks as glk-click — only for chips, like glk-list-item.
44
+ this._onClick = () => {
45
+ if (this.getBoolAttr('interactive')) {
46
+ this.emit('glk-click');
47
+ }
48
+ };
49
+ this._badge.addEventListener('click', this._onClick);
50
+ }
51
+
52
+ teardownEvents() {
53
+ this._badge?.removeEventListener('click', this._onClick);
54
+ // Forget the handler, or a swap while disconnected would re-attach it and
55
+ // the next connect would add a second one — two glk-click per click.
56
+ this._onClick = null;
15
57
  }
16
58
 
17
59
  onAttributeChanged(name) {
18
- if (name === 'variant' && this._badge) {
19
- this._badge.className = this._computeClasses().join(' ');
60
+ if (!this._badge) return;
61
+ switch (name) {
62
+ case 'interactive':
63
+ this._mount();
64
+ break;
65
+ case 'variant':
66
+ case 'selected':
67
+ this._badge.className = this._computeClasses().join(' ');
68
+ if (this._badge.tagName === 'BUTTON') {
69
+ this._badge.setAttribute('aria-pressed', String(this.getBoolAttr('selected')));
70
+ }
71
+ break;
20
72
  }
21
73
  }
22
74
 
@@ -26,11 +78,19 @@ class GlkBadge extends GlkElement {
26
78
  if (variant && VARIANTS.includes(variant)) {
27
79
  classes.push(`glass-badge--${variant}`);
28
80
  }
81
+ if (this.getBoolAttr('interactive')) classes.push('glass-badge--interactive');
82
+ if (this.getBoolAttr('selected')) classes.push('glass-badge--selected');
29
83
  return classes;
30
84
  }
31
85
 
32
86
  get variant() { return this.getAttribute('variant'); }
33
87
  set variant(v) { this.setAttribute('variant', v); }
88
+
89
+ get interactive() { return this.getBoolAttr('interactive'); }
90
+ set interactive(v) { this.setBoolAttr('interactive', v); }
91
+
92
+ get selected() { return this.getBoolAttr('selected'); }
93
+ set selected(v) { this.setBoolAttr('selected', v); }
34
94
  }
35
95
 
36
96
  customElements.define('glk-badge', GlkBadge);
@@ -5,6 +5,8 @@ class GlkModal extends GlkElement {
5
5
  return ['open', 'title'];
6
6
  }
7
7
 
8
+ static get observesLightDom() { return true; }
9
+
8
10
  render() {
9
11
  this._overlay = this.createElement('div', ['glass-modal-overlay'], { part: 'overlay' });
10
12
 
@@ -28,7 +30,7 @@ class GlkModal extends GlkElement {
28
30
  modal.appendChild(this._footer);
29
31
 
30
32
  // Defer footer population — children may not be parsed yet
31
- requestAnimationFrame(() => this._populateFooter());
33
+ requestAnimationFrame(() => this.projectLightDom());
32
34
 
33
35
  this._overlay.appendChild(modal);
34
36
  this._wrapper.appendChild(this._overlay);
@@ -38,18 +40,23 @@ class GlkModal extends GlkElement {
38
40
  }
39
41
  }
40
42
 
41
- _populateFooter() {
43
+ projectLightDom() {
44
+ const buttons = [...this.querySelectorAll('[slot="actions"] button')];
45
+ const signature = buttons.map(b => b.outerHTML).join('');
46
+ if (signature === this._footerSignature) return;
47
+ this._footerSignature = signature;
48
+
42
49
  this._footer.innerHTML = '';
43
- const actionsSlot = this.querySelector('[slot="actions"]');
44
- if (actionsSlot) {
45
- const buttons = actionsSlot.querySelectorAll('button');
46
- buttons.forEach(btn => {
47
- const clone = btn.cloneNode(true);
48
- // Forward click events to the original button
49
- clone.addEventListener('click', () => btn.click());
50
- this._footer.appendChild(clone);
50
+ buttons.forEach((btn, i) => {
51
+ const clone = btn.cloneNode(true);
52
+ // Look the original up again at click time instead of closing over it: a
53
+ // framework that re-renders replaces the node, and a captured reference
54
+ // would then forward the click to a button no longer in the document.
55
+ clone.addEventListener('click', () => {
56
+ this.querySelectorAll('[slot="actions"] button')[i]?.click();
51
57
  });
52
- }
58
+ this._footer.appendChild(clone);
59
+ });
53
60
  }
54
61
 
55
62
  setupEvents() {
@@ -5,6 +5,8 @@ class GlkSelect extends GlkFormElement {
5
5
  return ['label', 'disabled', 'name', 'value', 'required'];
6
6
  }
7
7
 
8
+ static get observesLightDom() { return true; }
9
+
8
10
  render() {
9
11
  const group = this.createElement('div', ['glass-input-group']);
10
12
 
@@ -25,12 +27,29 @@ class GlkSelect extends GlkFormElement {
25
27
  this._wrapper.appendChild(group);
26
28
 
27
29
  // Defer option copying — children may not be parsed yet in connectedCallback
28
- requestAnimationFrame(() => {
29
- this._moveOptions();
30
- const value = this.getAttribute('value');
31
- if (value) this._select.value = value;
32
- this._syncFormValue();
33
- });
30
+ requestAnimationFrame(() => this.projectLightDom());
31
+ }
32
+
33
+ projectLightDom() {
34
+ // The options are pure data — the clones carry no listeners — so skipping an
35
+ // unchanged rebuild is safe, and it keeps an open dropdown from snapping shut
36
+ // on light-DOM churn elsewhere.
37
+ const signature = [...this.querySelectorAll('option')].map(o => o.outerHTML).join('');
38
+ if (signature === this._optionSignature) return;
39
+ this._optionSignature = signature;
40
+
41
+ // innerHTML = '' below drops the selection, so remember it first. A
42
+ // selectedIndex of -1 means "nothing is selected", which is not the same as
43
+ // an option whose value happens to be the empty string.
44
+ const previous = this._select.selectedIndex >= 0 ? this._select.value : null;
45
+
46
+ this._moveOptions();
47
+
48
+ // Keep the live selection when it survived the rebuild; otherwise fall back
49
+ // to the value attribute. Without this the selection jumps back to the first
50
+ // entry every time the list is updated.
51
+ if (!this._applyValue(previous)) this._applyValue(this.getAttribute('value'));
52
+ this._syncFormValue();
34
53
  }
35
54
 
36
55
  _moveOptions() {
@@ -41,6 +60,18 @@ class GlkSelect extends GlkFormElement {
41
60
  });
42
61
  }
43
62
 
63
+ /**
64
+ * Selects `value` if an option carries it, and reports whether it did. The
65
+ * empty string is a value like any other — "" is a real option in plenty of
66
+ * forms ("detect automatically", "enter your own below").
67
+ */
68
+ _applyValue(value) {
69
+ if (value === null) return false;
70
+ if (![...this._select.options].some(o => o.value === value)) return false;
71
+ this._select.value = value;
72
+ return true;
73
+ }
74
+
44
75
  setupEvents() {
45
76
  this._onChange = () => {
46
77
  this._syncFormValue();
@@ -67,7 +98,7 @@ class GlkSelect extends GlkFormElement {
67
98
  this._select.setAttribute('name', this.getAttribute('name') || '');
68
99
  break;
69
100
  case 'value':
70
- this._select.value = this.getAttribute('value') || '';
101
+ this._applyValue(this.getAttribute('value'));
71
102
  this._syncFormValue();
72
103
  break;
73
104
  }
@@ -5,6 +5,8 @@ class GlkTabItem extends GlkElement {
5
5
  return ['label', 'active', 'badge'];
6
6
  }
7
7
 
8
+ static get observesLightDom() { return true; }
9
+
8
10
  render() {
9
11
  this._btn = this.createElement('button', ['glass-tab-bar__item']);
10
12
  if (this.getBoolAttr('active')) this._btn.classList.add('is-active');
@@ -30,23 +32,32 @@ class GlkTabItem extends GlkElement {
30
32
  this._wrapper.appendChild(this._btn);
31
33
 
32
34
  // Defer icon cloning — children may not be parsed yet
33
- requestAnimationFrame(() => this._cloneIcon());
35
+ requestAnimationFrame(() => this.projectLightDom());
34
36
  }
35
37
 
36
- _cloneIcon() {
38
+ projectLightDom() {
37
39
  const svg = this.querySelector('svg');
38
- if (svg) {
39
- const clone = svg.cloneNode(true);
40
- // Remove inline attributes that would override GlassKit styles
41
- clone.removeAttribute('width');
42
- clone.removeAttribute('height');
43
- clone.removeAttribute('stroke');
44
- clone.removeAttribute('stroke-width');
45
- clone.removeAttribute('stroke-linecap');
46
- clone.removeAttribute('stroke-linejoin');
47
- clone.removeAttribute('fill');
48
- this._iconEl.insertBefore(clone, this._iconEl.firstChild);
49
- }
40
+ const signature = svg ? svg.outerHTML : '';
41
+ if (signature === this._iconSignature) return;
42
+ this._iconSignature = signature;
43
+
44
+ // Drop the previous clone by hand: the icon container also holds the badge,
45
+ // and inserting without removing would stack a second icon on every update.
46
+ this._iconClone?.remove();
47
+ this._iconClone = null;
48
+ if (!svg) return;
49
+
50
+ const clone = svg.cloneNode(true);
51
+ // Remove inline attributes that would override GlassKit styles
52
+ clone.removeAttribute('width');
53
+ clone.removeAttribute('height');
54
+ clone.removeAttribute('stroke');
55
+ clone.removeAttribute('stroke-width');
56
+ clone.removeAttribute('stroke-linecap');
57
+ clone.removeAttribute('stroke-linejoin');
58
+ clone.removeAttribute('fill');
59
+ this._iconEl.insertBefore(clone, this._iconEl.firstChild);
60
+ this._iconClone = clone;
50
61
  }
51
62
 
52
63
  setupEvents() {