@jungherz-de/glasskit-elements 1.27.0 → 1.28.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/glasskit-elements",
3
- "version": "1.27.0",
3
+ "version": "1.28.1",
4
4
  "description": "Vanilla JavaScript Web Components wrapping the GlassKit CSS glassmorphism library",
5
5
  "license": "MIT",
6
6
  "homepage": "https://glasskit-elements.jungherz.com",
@@ -49,10 +49,10 @@
49
49
  "vanilla-js"
50
50
  ],
51
51
  "peerDependencies": {
52
- "@jungherz-de/glasskit": ">=1.27.0"
52
+ "@jungherz-de/glasskit": ">=1.28.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@jungherz-de/glasskit": "^1.27.0",
55
+ "@jungherz-de/glasskit": "^1.28.0",
56
56
  "@rollup/plugin-node-resolve": "^15.0.0",
57
57
  "@rollup/plugin-terser": "^0.4.0",
58
58
  "rollup": "^4.0.0"
package/src/base.js CHANGED
@@ -209,8 +209,9 @@ export class GlkElement extends HTMLElement {
209
209
  /**
210
210
  * Opt in when the component copies light-DOM children into its shadow tree.
211
211
  * A MutationObserver then calls projectLightDom() again whenever those
212
- * children change, so a framework that swaps them keeps the rendered element
213
- * in step. Without it the copy is made once and silently goes stale.
212
+ * children change — their content and, since 1.28.0, their attributes — so
213
+ * a framework that swaps them or only binds `disabled` keeps the rendered
214
+ * element in step. Without it the copy is made once and silently goes stale.
214
215
  */
215
216
  static get observesLightDom() { return false; }
216
217
 
@@ -272,8 +273,13 @@ export class GlkElement extends HTMLElement {
272
273
 
273
274
  if (this.constructor.observesLightDom) {
274
275
  this._lightDomObserver ??= new MutationObserver(records => this.projectLightDom(records));
276
+ // Attributes too: the copies show them. Until 1.27.0 a modal action
277
+ // that started disabled stayed disabled in its copy after the page
278
+ // enabled it, and the dialog could not be confirmed. The elements'
279
+ // own projectLightDom() compare a signature first, so a change
280
+ // elsewhere in the content costs a string comparison.
275
281
  this._lightDomObserver.observe(this, {
276
- childList: true, subtree: true, characterData: true
282
+ childList: true, subtree: true, characterData: true, attributes: true
277
283
  });
278
284
  }
279
285
  }
@@ -23,6 +23,33 @@ function reducedMotion() {
23
23
  return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
24
24
  }
25
25
 
26
+ // The copies carry no inline handlers (onclick="…"): a click on a copy is
27
+ // handed to its original, and until 1.28.0 such a handler ran twice.
28
+ const isHandler = name => name.startsWith('on');
29
+
30
+ function copyOf(node) {
31
+ const copy = node.cloneNode(true);
32
+ if (copy.nodeType === Node.ELEMENT_NODE) {
33
+ for (const el of [copy, ...copy.querySelectorAll('*')]) {
34
+ for (const { name } of [...el.attributes]) if (isHandler(name)) el.removeAttribute(name);
35
+ }
36
+ }
37
+ return copy;
38
+ }
39
+
40
+ /** Gives an action's copy the attributes and the content of its original. */
41
+ function mirror(copy, original) {
42
+ for (const { name } of [...copy.attributes]) {
43
+ if (!original.hasAttribute(name)) copy.removeAttribute(name);
44
+ }
45
+ for (const { name, value } of original.attributes) {
46
+ if (!isHandler(name) && copy.getAttribute(name) !== value) copy.setAttribute(name, value);
47
+ }
48
+ if (copy.innerHTML !== original.innerHTML) {
49
+ copy.replaceChildren(...[...original.childNodes].map(copyOf));
50
+ }
51
+ }
52
+
26
53
  class GlkModal extends GlkElement {
27
54
  static get observedAttributes() {
28
55
  return ['open', 'title'];
@@ -68,9 +95,18 @@ class GlkModal extends GlkElement {
68
95
  if (signature === this._footerSignature) return;
69
96
  this._footerSignature = signature;
70
97
 
98
+ // As many actions as copies: each copy takes its original's attributes
99
+ // and content in place, so the one that has the focus keeps it — a
100
+ // confirm button that the page enables, or relabels while it works.
101
+ const copies = [...this._footer.children];
102
+ if (copies.length === buttons.length) {
103
+ buttons.forEach((btn, i) => mirror(copies[i], btn));
104
+ return;
105
+ }
106
+
71
107
  this._footer.innerHTML = '';
72
108
  buttons.forEach((btn, i) => {
73
- const clone = btn.cloneNode(true);
109
+ const clone = copyOf(btn);
74
110
  // Look the original up again at click time instead of closing over it: a
75
111
  // framework that re-renders replaces the node, and a captured reference
76
112
  // would then forward the click to a button no longer in the document.
@@ -12,9 +12,16 @@ import { attachDroplet } from '@jungherz-de/glasskit/glasskit-droplet.js';
12
12
  // disabled (the whole row; since 1.22.3, as is a disabled
13
13
  // fieldset around it), droplet (since 1.27.0: the chosen option
14
14
  // sits on a drop of glass that glides to the next one and can be
15
- // dragged — GlassKit's .glass-segmented--droplet)
15
+ // dragged — GlassKit's .glass-segmented--droplet), icon-only
16
+ // (since 1.28.0: the options show their icons alone, each label
17
+ // becomes the button's aria-label and title — GlassKit's
18
+ // .glass-segmented--icons)
16
19
  // Properties: value, options (array or JSON text), full, overflow, label,
17
- // disabled, droplet
20
+ // disabled, droplet, iconOnly
21
+ // Slots: icon-<value> (since 1.28.0) — the icon of the option with that
22
+ // value: an <svg>, a font icon, an <img>. It sits in GlassKit's
23
+ // .glass-segmented__icon before the label and takes the option's
24
+ // colour; an option without one has no icon box.
18
25
  // Event: glk-change { value } — only on a change made by the user
19
26
  // Part: group
20
27
  //
@@ -43,7 +50,7 @@ function parseList(json) {
43
50
  }
44
51
 
45
52
  class GlkSegmented extends GlkFormElement {
46
- static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label', 'droplet']; }
53
+ static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label', 'droplet', 'icon-only']; }
47
54
 
48
55
  // A row of buttons with one tab stop: delegated focus would land on the
49
56
  // first button, not the chosen one.
@@ -60,20 +67,39 @@ class GlkSegmented extends GlkFormElement {
60
67
 
61
68
  _build() {
62
69
  this._group.replaceChildren();
70
+ const iconOnly = this.iconOnly;
63
71
  for (const option of parseList(this.getAttribute('options'))) {
64
72
  const value = String(option.value ?? '');
73
+ const label = String(option.label ?? value);
65
74
  const button = this.createElement('button', ['glass-segmented__item'], { type: 'button', 'data-value': value });
66
75
  if (TONES.includes(option.tone)) {
67
76
  button.classList.add(`glass-segmented__item--${option.tone}`);
68
77
  button.appendChild(this.createElement('span', ['glass-segmented__dot']));
69
78
  }
70
- button.appendChild(document.createTextNode(option.label ?? value));
79
+ // The label names the button; an icon font's text must not join it.
80
+ const icon = this.createElement('span', ['glass-segmented__icon'], { 'aria-hidden': 'true' });
81
+ icon.appendChild(this.createElement('slot', [], { name: `icon-${value}` }));
82
+ button.appendChild(icon);
83
+ if (iconOnly) {
84
+ button.setAttribute('aria-label', label);
85
+ button.title = label;
86
+ } else {
87
+ button.appendChild(document.createTextNode(label));
88
+ }
71
89
  this._group.appendChild(button);
72
90
  }
91
+ this._syncIcons();
73
92
  this.applyDisabled(this._actuallyDisabled);
74
93
  this._syncValue();
75
94
  }
76
95
 
96
+ /** An option shows its icon box only while an icon is slotted into it. */
97
+ _syncIcons() {
98
+ for (const slot of this._group.querySelectorAll('.glass-segmented__icon > slot')) {
99
+ slot.parentElement.hidden = slot.assignedElements().length === 0;
100
+ }
101
+ }
102
+
77
103
  /** The whole row is disabled, or an option by its own `disabled`. */
78
104
  applyDisabled(disabled) {
79
105
  const options = this.options;
@@ -95,6 +121,7 @@ class GlkSegmented extends GlkFormElement {
95
121
  const overflow = this.overflow;
96
122
  this._group.classList.toggle('glass-segmented--full', this.getBoolAttr('full'));
97
123
  this._group.classList.toggle('glass-segmented--droplet', this.getBoolAttr('droplet'));
124
+ this._group.classList.toggle('glass-segmented--icons', this.iconOnly);
98
125
  for (const mode of OVERFLOWS) this._group.classList.toggle(`glass-segmented--${mode}`, overflow === mode);
99
126
  this._reveal('instant');
100
127
  }
@@ -113,7 +140,10 @@ class GlkSegmented extends GlkFormElement {
113
140
 
114
141
  setupEvents() {
115
142
  this._onClick = (event) => {
116
- const button = event.target.closest('button[data-value]');
143
+ // The option's button from the event's path. A click on a slotted icon
144
+ // has that icon as its target, in the light DOM, where closest() never
145
+ // meets the button — until 1.28.1 such a tap chose nothing.
146
+ const button = event.composedPath().find(node => node.parentNode === this._group);
117
147
  if (!button || button.disabled) return;
118
148
  const { value } = button.dataset;
119
149
  if (value === this.value) return;
@@ -121,14 +151,18 @@ class GlkSegmented extends GlkFormElement {
121
151
  this.emit('glk-change', { value });
122
152
  };
123
153
  this._onResize = () => { if (this._group.clientWidth !== this._revealedAt) this._reveal('instant'); };
154
+ this._onSlotChange = () => this._syncIcons();
124
155
  this._group.addEventListener('click', this._onClick);
156
+ this._group.addEventListener('slotchange', this._onSlotChange);
125
157
  this._resizeObserver = new ResizeObserver(this._onResize);
126
158
  this._resizeObserver.observe(this._group);
159
+ this._syncIcons();
127
160
  this._applyDroplet();
128
161
  }
129
162
 
130
163
  teardownEvents() {
131
164
  this._group?.removeEventListener('click', this._onClick);
165
+ this._group?.removeEventListener('slotchange', this._onSlotChange);
132
166
  this._resizeObserver?.disconnect();
133
167
  this._resizeObserver = null;
134
168
  this._droplet?.destroy();
@@ -151,6 +185,7 @@ class GlkSegmented extends GlkFormElement {
151
185
  case 'full':
152
186
  case 'overflow': this._applyLayout(); break;
153
187
  case 'droplet': this._applyLayout(); this._applyDroplet(); break;
188
+ case 'icon-only': this._build(); this._applyLayout(); break;
154
189
  case 'label': this._applyLabel(); break;
155
190
  }
156
191
  }
@@ -173,6 +208,9 @@ class GlkSegmented extends GlkFormElement {
173
208
  get droplet() { return this.getBoolAttr('droplet'); }
174
209
  set droplet(v) { this.setBoolAttr('droplet', v); }
175
210
 
211
+ get iconOnly() { return this.getBoolAttr('icon-only'); }
212
+ set iconOnly(v) { this.setBoolAttr('icon-only', v); }
213
+
176
214
  get overflow() {
177
215
  const v = this.getAttribute('overflow');
178
216
  return OVERFLOWS.includes(v) ? v : '';
@@ -82,20 +82,20 @@ class GlkTabBar extends GlkElement {
82
82
  this._syncItems();
83
83
  }
84
84
 
85
- setupEvents() {
86
- // Listen for tab selection events from child glk-tab-item elements
87
- this._onTabSelect = (e) => {
88
- // Deactivate all other tabs
89
- const items = this.querySelectorAll('glk-tab-item');
90
- items.forEach(item => {
91
- if (item !== e.target) {
92
- item.removeAttribute('active');
93
- }
94
- });
95
- this.emit('glk-tab-change', { tab: e.target });
96
- };
97
- this.addEventListener('glk-tab-select', this._onTabSelect);
85
+ /**
86
+ * Called by an item the user selected once its glk-tab-select went through
87
+ * uncancelled; the item is active already. Until 1.27.0 the bar did this
88
+ * while the event was still on its way, so a listener further up could not
89
+ * have kept the current tab.
90
+ */
91
+ _selected(tab) {
92
+ for (const item of this.querySelectorAll('glk-tab-item')) {
93
+ if (item !== tab) item.removeAttribute('active');
94
+ }
95
+ this.emit('glk-tab-change', { tab });
96
+ }
98
97
 
98
+ setupEvents() {
99
99
  // The drop's state lives on the nav; the items carry it on their wrappers.
100
100
  this._navObserver = new MutationObserver(() => this._syncItems());
101
101
  this._navObserver.observe(this._nav, { attributes: true, attributeFilter: ['data-droplet', 'data-droplet-drag'] });
@@ -106,12 +106,19 @@ class GlkTabBar extends GlkElement {
106
106
  }
107
107
 
108
108
  teardownEvents() {
109
- this.removeEventListener('glk-tab-select', this._onTabSelect);
110
109
  this._navObserver?.disconnect();
111
110
  this._itemObserver?.disconnect();
112
111
  this._droplet?.destroy();
113
112
  this._droplet = null;
114
113
  }
114
+
115
+ // Properties as well (since 1.28.0): `bar.droplet = true` set an own
116
+ // property before and changed nothing.
117
+ get floating() { return this.getBoolAttr('floating'); }
118
+ set floating(v) { this.setBoolAttr('floating', v); }
119
+
120
+ get droplet() { return this.getBoolAttr('droplet'); }
121
+ set droplet(v) { this.setBoolAttr('droplet', v); }
115
122
  }
116
123
 
117
124
  customElements.define('glk-tab-bar', GlkTabBar);
@@ -1,5 +1,18 @@
1
1
  import { GlkElement } from '../../base.js';
2
2
 
3
+ // The icon: an inline <svg> child is copied into the shadow root, where
4
+ // GlassKit's rules reach it. Anything else — a font icon such as Material
5
+ // Icons, an <img>, an element of the page's own — goes into slot="icon"
6
+ // (since 1.28.0) and takes the state colour from GlassKit's icon box: active,
7
+ // hover, the lens of the drop and the drag. With a slotted icon no SVG is
8
+ // copied.
9
+ //
10
+ // glk-tab-select (cancelable since 1.28.0) comes before `active` changes:
11
+ // preventDefault() keeps the current tab — for an item that opens a drawer
12
+ // or a link — and the bar then neither moves `active` nor its drop, nor
13
+ // emits glk-tab-change. A drag of the drop that ends on the item clicks it,
14
+ // so it takes the same path.
15
+
3
16
  class GlkTabItem extends GlkElement {
4
17
  static get observedAttributes() {
5
18
  return ['label', 'active', 'badge'];
@@ -11,8 +24,10 @@ class GlkTabItem extends GlkElement {
11
24
  this._btn = this.createElement('button', ['glass-tab-bar__item']);
12
25
  if (this.getBoolAttr('active')) this._btn.classList.add('is-active');
13
26
 
14
- // Icon — clone SVG from light DOM into shadow DOM so GlassKit CSS applies
27
+ // Icon — clone SVG from light DOM into shadow DOM so GlassKit CSS applies,
28
+ // or show what is slotted as icon
15
29
  this._iconEl = this.createElement('span', ['glass-tab-bar__icon']);
30
+ this._iconEl.appendChild(this.createElement('slot', [], { name: 'icon' }));
16
31
 
17
32
  // Label
18
33
  this._labelEl = this.createElement('span', ['glass-tab-bar__label']);
@@ -36,7 +51,8 @@ class GlkTabItem extends GlkElement {
36
51
  }
37
52
 
38
53
  projectLightDom() {
39
- const svg = this.querySelector('svg');
54
+ // A slotted icon wins; only without one is an <svg> copied.
55
+ const svg = this.querySelector(':scope > [slot="icon"]') ? null : this.querySelector('svg');
40
56
  const signature = svg ? svg.outerHTML : '';
41
57
  if (signature === this._iconSignature) return;
42
58
  this._iconSignature = signature;
@@ -62,8 +78,9 @@ class GlkTabItem extends GlkElement {
62
78
 
63
79
  setupEvents() {
64
80
  this._onClick = () => {
81
+ if (!this.emit('glk-tab-select', null, { cancelable: true })) return;
65
82
  this.setAttribute('active', '');
66
- this.emit('glk-tab-select');
83
+ this.closest('glk-tab-bar')?._selected?.(this);
67
84
  };
68
85
  this._btn.addEventListener('click', this._onClick);
69
86
  this._tellBar();