@jungherz-de/glasskit-elements 1.26.0 → 1.28.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungherz-de/glasskit-elements",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
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.26.0"
52
+ "@jungherz-de/glasskit": ">=1.28.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@jungherz-de/glasskit": "^1.26.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.
@@ -1,5 +1,6 @@
1
1
  import { GlkFormElement } from '../../base.js';
2
2
  import { revealCentered } from '../../reveal.js';
3
+ import { attachDroplet } from '@jungherz-de/glasskit/glasskit-droplet.js';
3
4
 
4
5
  // One button per option, the chosen one marked aria-pressed="true" — GlassKit
5
6
  // styles exactly that attribute. Came back from EhrenPfoten in 1.15.0.
@@ -9,9 +10,18 @@ import { revealCentered } from '../../reveal.js';
9
10
  // do not fit — without it the row stays one line and runs past
10
11
  // the edge), label (aria-label of the group), name (form field),
11
12
  // disabled (the whole row; since 1.22.3, as is a disabled
12
- // fieldset around it)
13
+ // fieldset around it), droplet (since 1.27.0: the chosen option
14
+ // sits on a drop of glass that glides to the next one and can be
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)
13
19
  // Properties: value, options (array or JSON text), full, overflow, label,
14
- // disabled
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.
15
25
  // Event: glk-change { value } — only on a change made by the user
16
26
  // Part: group
17
27
  //
@@ -40,7 +50,7 @@ function parseList(json) {
40
50
  }
41
51
 
42
52
  class GlkSegmented extends GlkFormElement {
43
- static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label']; }
53
+ static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label', 'droplet', 'icon-only']; }
44
54
 
45
55
  // A row of buttons with one tab stop: delegated focus would land on the
46
56
  // first button, not the chosen one.
@@ -57,20 +67,39 @@ class GlkSegmented extends GlkFormElement {
57
67
 
58
68
  _build() {
59
69
  this._group.replaceChildren();
70
+ const iconOnly = this.iconOnly;
60
71
  for (const option of parseList(this.getAttribute('options'))) {
61
72
  const value = String(option.value ?? '');
73
+ const label = String(option.label ?? value);
62
74
  const button = this.createElement('button', ['glass-segmented__item'], { type: 'button', 'data-value': value });
63
75
  if (TONES.includes(option.tone)) {
64
76
  button.classList.add(`glass-segmented__item--${option.tone}`);
65
77
  button.appendChild(this.createElement('span', ['glass-segmented__dot']));
66
78
  }
67
- 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
+ }
68
89
  this._group.appendChild(button);
69
90
  }
91
+ this._syncIcons();
70
92
  this.applyDisabled(this._actuallyDisabled);
71
93
  this._syncValue();
72
94
  }
73
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
+
74
103
  /** The whole row is disabled, or an option by its own `disabled`. */
75
104
  applyDisabled(disabled) {
76
105
  const options = this.options;
@@ -91,6 +120,8 @@ class GlkSegmented extends GlkFormElement {
91
120
  _applyLayout() {
92
121
  const overflow = this.overflow;
93
122
  this._group.classList.toggle('glass-segmented--full', this.getBoolAttr('full'));
123
+ this._group.classList.toggle('glass-segmented--droplet', this.getBoolAttr('droplet'));
124
+ this._group.classList.toggle('glass-segmented--icons', this.iconOnly);
94
125
  for (const mode of OVERFLOWS) this._group.classList.toggle(`glass-segmented--${mode}`, overflow === mode);
95
126
  this._reveal('instant');
96
127
  }
@@ -117,15 +148,30 @@ class GlkSegmented extends GlkFormElement {
117
148
  this.emit('glk-change', { value });
118
149
  };
119
150
  this._onResize = () => { if (this._group.clientWidth !== this._revealedAt) this._reveal('instant'); };
151
+ this._onSlotChange = () => this._syncIcons();
120
152
  this._group.addEventListener('click', this._onClick);
153
+ this._group.addEventListener('slotchange', this._onSlotChange);
121
154
  this._resizeObserver = new ResizeObserver(this._onResize);
122
155
  this._resizeObserver.observe(this._group);
156
+ this._syncIcons();
157
+ this._applyDroplet();
123
158
  }
124
159
 
125
160
  teardownEvents() {
126
161
  this._group?.removeEventListener('click', this._onClick);
162
+ this._group?.removeEventListener('slotchange', this._onSlotChange);
127
163
  this._resizeObserver?.disconnect();
128
164
  this._resizeObserver = null;
165
+ this._droplet?.destroy();
166
+ this._droplet = null;
167
+ }
168
+
169
+ // The drop follows aria-pressed inside this shadow root by itself; a drag
170
+ // that ends on an option clicks it, the same path as a tap.
171
+ _applyDroplet() {
172
+ const on = this.getBoolAttr('droplet') && this.isConnected;
173
+ if (on && !this._droplet) this._droplet = attachDroplet(this._group);
174
+ else if (!on && this._droplet) { this._droplet.destroy(); this._droplet = null; }
129
175
  }
130
176
 
131
177
  onAttributeChanged(name) {
@@ -135,6 +181,8 @@ class GlkSegmented extends GlkFormElement {
135
181
  case 'options': this._build(); break;
136
182
  case 'full':
137
183
  case 'overflow': this._applyLayout(); break;
184
+ case 'droplet': this._applyLayout(); this._applyDroplet(); break;
185
+ case 'icon-only': this._build(); this._applyLayout(); break;
138
186
  case 'label': this._applyLabel(); break;
139
187
  }
140
188
  }
@@ -154,6 +202,12 @@ class GlkSegmented extends GlkFormElement {
154
202
  get full() { return this.getBoolAttr('full'); }
155
203
  set full(v) { this.setBoolAttr('full', v); }
156
204
 
205
+ get droplet() { return this.getBoolAttr('droplet'); }
206
+ set droplet(v) { this.setBoolAttr('droplet', v); }
207
+
208
+ get iconOnly() { return this.getBoolAttr('icon-only'); }
209
+ set iconOnly(v) { this.setBoolAttr('icon-only', v); }
210
+
157
211
  get overflow() {
158
212
  const v = this.getAttribute('overflow');
159
213
  return OVERFLOWS.includes(v) ? v : '';
@@ -1,8 +1,15 @@
1
1
  import { GlkElement } from '../../base.js';
2
+ import { attachDroplet } from '@jungherz-de/glasskit/glasskit-droplet.js';
3
+
4
+ // droplet (since 1.27.0): the active item sits on a drop of glass that glides
5
+ // to the next one and can be dragged — GlassKit's .glass-tab-bar--droplet,
6
+ // moved by its glasskit-droplet.js. The items live in shadow roots of their
7
+ // own, so the bar hands its state down to them (see glk-tab-item _syncBar)
8
+ // and tells the drop when the active item changed.
2
9
 
3
10
  class GlkTabBar extends GlkElement {
4
11
  static get observedAttributes() {
5
- return ['static', 'floating'];
12
+ return ['static', 'floating', 'droplet'];
6
13
  }
7
14
 
8
15
  render() {
@@ -14,6 +21,7 @@ class GlkTabBar extends GlkElement {
14
21
  if (this.getBoolAttr('floating')) {
15
22
  nav.classList.add('glass-tab-bar--floating');
16
23
  }
24
+ nav.classList.toggle('glass-tab-bar--droplet', this.getBoolAttr('droplet'));
17
25
 
18
26
  if (this.getBoolAttr('static')) {
19
27
  this._applyStatic();
@@ -26,6 +34,10 @@ class GlkTabBar extends GlkElement {
26
34
  this._applyStatic();
27
35
  } else if (name === 'floating') {
28
36
  this._nav.classList.toggle('glass-tab-bar--floating', this.getBoolAttr('floating'));
37
+ this._syncItems();
38
+ } else if (name === 'droplet') {
39
+ this._nav.classList.toggle('glass-tab-bar--droplet', this.getBoolAttr('droplet'));
40
+ this._applyDroplet();
29
41
  }
30
42
  }
31
43
 
@@ -34,24 +46,79 @@ class GlkTabBar extends GlkElement {
34
46
  this._nav.style.borderRadius = '16px';
35
47
  }
36
48
 
37
- setupEvents() {
38
- // Listen for tab selection events from child glk-tab-item elements
39
- this._onTabSelect = (e) => {
40
- // Deactivate all other tabs
41
- const items = this.querySelectorAll('glk-tab-item');
42
- items.forEach(item => {
43
- if (item !== e.target) {
44
- item.removeAttribute('active');
45
- }
46
- });
47
- this.emit('glk-tab-change', { tab: e.target });
49
+ _items() {
50
+ return [...this.children].filter(el => el.localName === 'glk-tab-item');
51
+ }
52
+
53
+ /** Hands the bar's floating and droplet state to every item's wrapper. */
54
+ _syncItems() {
55
+ const state = {
56
+ floating: this.getBoolAttr('floating'),
57
+ droplet: this.getBoolAttr('droplet'),
58
+ placed: Boolean(this._nav?.hasAttribute('data-droplet')),
59
+ drag: Boolean(this._nav?.hasAttribute('data-droplet-drag')),
48
60
  };
49
- this.addEventListener('glk-tab-select', this._onTabSelect);
61
+ for (const item of this._items()) item._syncBar?.(state);
62
+ }
63
+
64
+ /** Called by an item that rendered or changed its active state. */
65
+ _itemChanged() {
66
+ this._syncItems();
67
+ this._droplet?.update();
68
+ }
69
+
70
+ _applyDroplet() {
71
+ const on = this.getBoolAttr('droplet') && this.isConnected;
72
+ if (on && !this._droplet) {
73
+ // The drop measures and marks the items' own buttons; picking one clicks
74
+ // it, which is what a tap does.
75
+ this._droplet = attachDroplet(this._nav, {
76
+ items: () => this._items().map(item => item._btn).filter(Boolean),
77
+ });
78
+ } else if (!on && this._droplet) {
79
+ this._droplet.destroy();
80
+ this._droplet = null;
81
+ }
82
+ this._syncItems();
83
+ }
84
+
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
+ }
97
+
98
+ setupEvents() {
99
+ // The drop's state lives on the nav; the items carry it on their wrappers.
100
+ this._navObserver = new MutationObserver(() => this._syncItems());
101
+ this._navObserver.observe(this._nav, { attributes: true, attributeFilter: ['data-droplet', 'data-droplet-drag'] });
102
+ // Items added or removed.
103
+ this._itemObserver = new MutationObserver(() => this._itemChanged());
104
+ this._itemObserver.observe(this, { childList: true });
105
+ this._applyDroplet();
50
106
  }
51
107
 
52
108
  teardownEvents() {
53
- this.removeEventListener('glk-tab-select', this._onTabSelect);
109
+ this._navObserver?.disconnect();
110
+ this._itemObserver?.disconnect();
111
+ this._droplet?.destroy();
112
+ this._droplet = null;
54
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); }
55
122
  }
56
123
 
57
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,10 +78,32 @@ 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);
86
+ this._tellBar();
87
+ }
88
+
89
+ /**
90
+ * The bar's look reaches the item through its wrapper. GlassKit's rules for
91
+ * floating and droplet items are descendant selectors of the bar's classes,
92
+ * and this button sits in a shadow root of its own, so <glk-tab-bar> hands
93
+ * its state down and the wrapper carries it. Before 1.27.0 the items of a
94
+ * floating bar kept the fixed bar's look: no spotlight, no pill, the dot.
95
+ */
96
+ _syncBar({ floating, droplet, placed, drag }) {
97
+ if (!this._wrapper) return;
98
+ this._wrapper.classList.toggle('glass-tab-bar--floating', floating);
99
+ this._wrapper.classList.toggle('glass-tab-bar--droplet', droplet);
100
+ this._wrapper.toggleAttribute('data-droplet', placed);
101
+ this._wrapper.toggleAttribute('data-droplet-drag', drag);
102
+ }
103
+
104
+ /** Tells the bar that this item rendered or changed its active state. */
105
+ _tellBar() {
106
+ this.closest('glk-tab-bar')?._itemChanged?.();
69
107
  }
70
108
 
71
109
  teardownEvents() {
@@ -77,6 +115,7 @@ class GlkTabItem extends GlkElement {
77
115
  switch (name) {
78
116
  case 'active':
79
117
  this._btn.classList.toggle('is-active', this.getBoolAttr('active'));
118
+ this._tellBar();
80
119
  break;
81
120
  case 'label':
82
121
  this._labelEl.textContent = this.getAttribute('label') || '';