@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.
@@ -1,9 +1,16 @@
1
1
  import { GlkElement } from './base.js';
2
+ import { a as attachDroplet } from './shared/glasskit-droplet-3vJVXB3u.js';
2
3
  import '@jungherz-de/glasskit/glasskit-styles.js';
3
4
 
5
+ // droplet (since 1.27.0): the active item sits on a drop of glass that glides
6
+ // to the next one and can be dragged — GlassKit's .glass-tab-bar--droplet,
7
+ // moved by its glasskit-droplet.js. The items live in shadow roots of their
8
+ // own, so the bar hands its state down to them (see glk-tab-item _syncBar)
9
+ // and tells the drop when the active item changed.
10
+
4
11
  class GlkTabBar extends GlkElement {
5
12
  static get observedAttributes() {
6
- return ['static', 'floating'];
13
+ return ['static', 'floating', 'droplet'];
7
14
  }
8
15
 
9
16
  render() {
@@ -15,6 +22,7 @@ class GlkTabBar extends GlkElement {
15
22
  if (this.getBoolAttr('floating')) {
16
23
  nav.classList.add('glass-tab-bar--floating');
17
24
  }
25
+ nav.classList.toggle('glass-tab-bar--droplet', this.getBoolAttr('droplet'));
18
26
 
19
27
  if (this.getBoolAttr('static')) {
20
28
  this._applyStatic();
@@ -27,6 +35,10 @@ class GlkTabBar extends GlkElement {
27
35
  this._applyStatic();
28
36
  } else if (name === 'floating') {
29
37
  this._nav.classList.toggle('glass-tab-bar--floating', this.getBoolAttr('floating'));
38
+ this._syncItems();
39
+ } else if (name === 'droplet') {
40
+ this._nav.classList.toggle('glass-tab-bar--droplet', this.getBoolAttr('droplet'));
41
+ this._applyDroplet();
30
42
  }
31
43
  }
32
44
 
@@ -35,24 +47,79 @@ class GlkTabBar extends GlkElement {
35
47
  this._nav.style.borderRadius = '16px';
36
48
  }
37
49
 
38
- setupEvents() {
39
- // Listen for tab selection events from child glk-tab-item elements
40
- this._onTabSelect = (e) => {
41
- // Deactivate all other tabs
42
- const items = this.querySelectorAll('glk-tab-item');
43
- items.forEach(item => {
44
- if (item !== e.target) {
45
- item.removeAttribute('active');
46
- }
47
- });
48
- this.emit('glk-tab-change', { tab: e.target });
50
+ _items() {
51
+ return [...this.children].filter(el => el.localName === 'glk-tab-item');
52
+ }
53
+
54
+ /** Hands the bar's floating and droplet state to every item's wrapper. */
55
+ _syncItems() {
56
+ const state = {
57
+ floating: this.getBoolAttr('floating'),
58
+ droplet: this.getBoolAttr('droplet'),
59
+ placed: Boolean(this._nav?.hasAttribute('data-droplet')),
60
+ drag: Boolean(this._nav?.hasAttribute('data-droplet-drag')),
49
61
  };
50
- this.addEventListener('glk-tab-select', this._onTabSelect);
62
+ for (const item of this._items()) item._syncBar?.(state);
63
+ }
64
+
65
+ /** Called by an item that rendered or changed its active state. */
66
+ _itemChanged() {
67
+ this._syncItems();
68
+ this._droplet?.update();
69
+ }
70
+
71
+ _applyDroplet() {
72
+ const on = this.getBoolAttr('droplet') && this.isConnected;
73
+ if (on && !this._droplet) {
74
+ // The drop measures and marks the items' own buttons; picking one clicks
75
+ // it, which is what a tap does.
76
+ this._droplet = attachDroplet(this._nav, {
77
+ items: () => this._items().map(item => item._btn).filter(Boolean),
78
+ });
79
+ } else if (!on && this._droplet) {
80
+ this._droplet.destroy();
81
+ this._droplet = null;
82
+ }
83
+ this._syncItems();
84
+ }
85
+
86
+ /**
87
+ * Called by an item the user selected once its glk-tab-select went through
88
+ * uncancelled; the item is active already. Until 1.27.0 the bar did this
89
+ * while the event was still on its way, so a listener further up could not
90
+ * have kept the current tab.
91
+ */
92
+ _selected(tab) {
93
+ for (const item of this.querySelectorAll('glk-tab-item')) {
94
+ if (item !== tab) item.removeAttribute('active');
95
+ }
96
+ this.emit('glk-tab-change', { tab });
97
+ }
98
+
99
+ setupEvents() {
100
+ // The drop's state lives on the nav; the items carry it on their wrappers.
101
+ this._navObserver = new MutationObserver(() => this._syncItems());
102
+ this._navObserver.observe(this._nav, { attributes: true, attributeFilter: ['data-droplet', 'data-droplet-drag'] });
103
+ // Items added or removed.
104
+ this._itemObserver = new MutationObserver(() => this._itemChanged());
105
+ this._itemObserver.observe(this, { childList: true });
106
+ this._applyDroplet();
51
107
  }
52
108
 
53
109
  teardownEvents() {
54
- this.removeEventListener('glk-tab-select', this._onTabSelect);
110
+ this._navObserver?.disconnect();
111
+ this._itemObserver?.disconnect();
112
+ this._droplet?.destroy();
113
+ this._droplet = null;
55
114
  }
115
+
116
+ // Properties as well (since 1.28.0): `bar.droplet = true` set an own
117
+ // property before and changed nothing.
118
+ get floating() { return this.getBoolAttr('floating'); }
119
+ set floating(v) { this.setBoolAttr('floating', v); }
120
+
121
+ get droplet() { return this.getBoolAttr('droplet'); }
122
+ set droplet(v) { this.setBoolAttr('droplet', v); }
56
123
  }
57
124
 
58
125
  customElements.define('glk-tab-bar', GlkTabBar);
@@ -1,6 +1,19 @@
1
1
  import { GlkElement } from './base.js';
2
2
  import '@jungherz-de/glasskit/glasskit-styles.js';
3
3
 
4
+ // The icon: an inline <svg> child is copied into the shadow root, where
5
+ // GlassKit's rules reach it. Anything else — a font icon such as Material
6
+ // Icons, an <img>, an element of the page's own — goes into slot="icon"
7
+ // (since 1.28.0) and takes the state colour from GlassKit's icon box: active,
8
+ // hover, the lens of the drop and the drag. With a slotted icon no SVG is
9
+ // copied.
10
+ //
11
+ // glk-tab-select (cancelable since 1.28.0) comes before `active` changes:
12
+ // preventDefault() keeps the current tab — for an item that opens a drawer
13
+ // or a link — and the bar then neither moves `active` nor its drop, nor
14
+ // emits glk-tab-change. A drag of the drop that ends on the item clicks it,
15
+ // so it takes the same path.
16
+
4
17
  class GlkTabItem extends GlkElement {
5
18
  static get observedAttributes() {
6
19
  return ['label', 'active', 'badge'];
@@ -12,8 +25,10 @@ class GlkTabItem extends GlkElement {
12
25
  this._btn = this.createElement('button', ['glass-tab-bar__item']);
13
26
  if (this.getBoolAttr('active')) this._btn.classList.add('is-active');
14
27
 
15
- // Icon — clone SVG from light DOM into shadow DOM so GlassKit CSS applies
28
+ // Icon — clone SVG from light DOM into shadow DOM so GlassKit CSS applies,
29
+ // or show what is slotted as icon
16
30
  this._iconEl = this.createElement('span', ['glass-tab-bar__icon']);
31
+ this._iconEl.appendChild(this.createElement('slot', [], { name: 'icon' }));
17
32
 
18
33
  // Label
19
34
  this._labelEl = this.createElement('span', ['glass-tab-bar__label']);
@@ -37,7 +52,8 @@ class GlkTabItem extends GlkElement {
37
52
  }
38
53
 
39
54
  projectLightDom() {
40
- const svg = this.querySelector('svg');
55
+ // A slotted icon wins; only without one is an <svg> copied.
56
+ const svg = this.querySelector(':scope > [slot="icon"]') ? null : this.querySelector('svg');
41
57
  const signature = svg ? svg.outerHTML : '';
42
58
  if (signature === this._iconSignature) return;
43
59
  this._iconSignature = signature;
@@ -63,10 +79,32 @@ class GlkTabItem extends GlkElement {
63
79
 
64
80
  setupEvents() {
65
81
  this._onClick = () => {
82
+ if (!this.emit('glk-tab-select', null, { cancelable: true })) return;
66
83
  this.setAttribute('active', '');
67
- this.emit('glk-tab-select');
84
+ this.closest('glk-tab-bar')?._selected?.(this);
68
85
  };
69
86
  this._btn.addEventListener('click', this._onClick);
87
+ this._tellBar();
88
+ }
89
+
90
+ /**
91
+ * The bar's look reaches the item through its wrapper. GlassKit's rules for
92
+ * floating and droplet items are descendant selectors of the bar's classes,
93
+ * and this button sits in a shadow root of its own, so <glk-tab-bar> hands
94
+ * its state down and the wrapper carries it. Before 1.27.0 the items of a
95
+ * floating bar kept the fixed bar's look: no spotlight, no pill, the dot.
96
+ */
97
+ _syncBar({ floating, droplet, placed, drag }) {
98
+ if (!this._wrapper) return;
99
+ this._wrapper.classList.toggle('glass-tab-bar--floating', floating);
100
+ this._wrapper.classList.toggle('glass-tab-bar--droplet', droplet);
101
+ this._wrapper.toggleAttribute('data-droplet', placed);
102
+ this._wrapper.toggleAttribute('data-droplet-drag', drag);
103
+ }
104
+
105
+ /** Tells the bar that this item rendered or changed its active state. */
106
+ _tellBar() {
107
+ this.closest('glk-tab-bar')?._itemChanged?.();
70
108
  }
71
109
 
72
110
  teardownEvents() {
@@ -78,6 +116,7 @@ class GlkTabItem extends GlkElement {
78
116
  switch (name) {
79
117
  case 'active':
80
118
  this._btn.classList.toggle('is-active', this.getBoolAttr('active'));
119
+ this._tellBar();
81
120
  break;
82
121
  case 'label':
83
122
  this._labelEl.textContent = this.getAttribute('label') || '';
@@ -0,0 +1,237 @@
1
+ // ============================================================
2
+ // GlassKit – Droplet
3
+ //
4
+ // The motion behind .glass-tab-bar--droplet and .glass-segmented--droplet.
5
+ // The stylesheet draws the drop of glass; this module puts it on the chosen
6
+ // item, lets it run to a new one, and lets a finger drag it along the row.
7
+ // Optional and dependency-free: without it a --droplet row keeps its usual
8
+ // look, the tab bar its spotlight and the segmented row its raised option.
9
+ //
10
+ // import { attachDroplets } from '@jungherz-de/glasskit/glasskit-droplet.js';
11
+ // attachDroplets(); // every --droplet row in the page
12
+ //
13
+ // import { attachDroplet } from '@jungherz-de/glasskit/glasskit-droplet.js';
14
+ // const drop = attachDroplet(row); // one row; drop.update(), drop.destroy()
15
+ //
16
+ // The chosen item is the tab with .is-active or the option with
17
+ // aria-pressed="true". The page keeps setting them its own way and the drop
18
+ // follows. Dragged onto another item, the drop clicks it, so the page's own
19
+ // click handler makes the choice. In a row that scrolls sideways a swipe
20
+ // still scrolls: only the drop itself is dragged, and the row scrolls along
21
+ // at its edges.
22
+ //
23
+ // It writes what glasskit.css reads: data-droplet on the row, the chosen
24
+ // item's box as --gl-droplet-left, -right, -top and -bottom, data-droplet-dir
25
+ // and data-droplet-lift while the drop travels, data-droplet-drag while a
26
+ // finger holds it, and .is-under-droplet on the item below it.
27
+ //
28
+ // Options of attachDroplet, for rows whose items live elsewhere (GlassKit
29
+ // Elements uses them): items() returns the items, isChosen(item) tells the
30
+ // chosen one, choose(item) picks one, lens(item, on) marks the item below
31
+ // the drop. Without a MutationObserver reaching the items, call update()
32
+ // after the choice changed.
33
+ // ============================================================
34
+
35
+ const ITEMS = '.glass-tab-bar__item, .glass-segmented__item';
36
+ const attached = new WeakMap();
37
+
38
+ function attachDroplet(row, options = {}) {
39
+ if (attached.has(row)) return attached.get(row);
40
+
41
+ const items = options.items || (() => [...row.querySelectorAll(ITEMS)]);
42
+ const isChosen = options.isChosen
43
+ || (it => it.classList.contains('is-active') || it.getAttribute('aria-pressed') === 'true');
44
+ const choose = options.choose || (it => it.click());
45
+ const mark = options.lens || ((it, on) => it.classList.toggle('is-under-droplet', on));
46
+ const usable = it => !it.disabled && it.getAttribute('aria-disabled') !== 'true';
47
+ const scrolls = () => row.scrollWidth > row.clientWidth + 1;
48
+ const motion = matchMedia('(prefers-reduced-motion: no-preference)');
49
+ const set = (k, px) => row.style.setProperty(`--gl-droplet-${k}`, `${px}px`);
50
+
51
+ let chosen = null, left = 0, placed = null, liftTimer = 0, lensFrame = 0, edgeFrame = 0;
52
+ let drag = null, eatClick = false, under = null;
53
+
54
+ // The row on screen and its scale there. Rects are viewport pixels, scaled
55
+ // by every transform on an ancestor — an app that zooms in on start, a
56
+ // popover that opens from scale(0.96) — while the drop's lengths are the
57
+ // row's own. So distances on screen are divided by the scale: the drop
58
+ // sits right at rest and while such a transform runs, and nothing needs
59
+ // measuring again when it ends (before 1.28.0 it stayed beside the item).
60
+ // The layout size is the computed one under GlassKit's border-box,
61
+ // unscaled and nearly unrounded, else the offset size, rounded to whole
62
+ // pixels. A difference within that rounding is no transform: the rects
63
+ // are then taken as they are, as before.
64
+ const frame = () => {
65
+ const n = row.getBoundingClientRect(), cs = getComputedStyle(row);
66
+ const exact = cs.boxSizing === 'border-box', tolerance = exact ? 0.05 : 0.5;
67
+ const w = exact ? parseFloat(cs.width) : row.offsetWidth;
68
+ const h = exact ? parseFloat(cs.height) : row.offsetHeight;
69
+ const scale = (onScreen, own) => onScreen > 0 && own > 0 && Math.abs(onScreen - own) > tolerance ? onScreen / own : 1;
70
+ return { n, sx: scale(n.width, w), sy: scale(n.height, h) };
71
+ };
72
+ // An item's box in the row's padding box; in a row that scrolls, in its
73
+ // scrolled content, where the drop scrolls along with the items.
74
+ const boxOf = (it, f = frame()) => {
75
+ const r = it.getBoundingClientRect();
76
+ const l = (r.left - f.n.left) / f.sx - row.clientLeft + row.scrollLeft;
77
+ const t = (r.top - f.n.top) / f.sy - row.clientTop + row.scrollTop;
78
+ const height = r.height / f.sy;
79
+ return { left: l, width: r.width / f.sx, top: t, bottom: row.clientHeight - t - height };
80
+ };
81
+ const put = (l, width, b) => {
82
+ left = l;
83
+ set('left', l); set('right', row.clientWidth - l - width);
84
+ set('top', b.top); set('bottom', b.bottom);
85
+ };
86
+
87
+ // The usable item nearest a point of the row lights up as under a lens.
88
+ const lens = centre => {
89
+ let below = null, best = Infinity;
90
+ const f = centre == null ? null : frame();
91
+ if (f) for (const it of items()) {
92
+ if (!usable(it)) continue;
93
+ const b = boxOf(it, f), d = Math.abs(b.left + b.width / 2 - centre);
94
+ if (d < best) { best = d; below = it; }
95
+ }
96
+ if (below !== under) {
97
+ if (under) mark(under, false);
98
+ if (below) mark(below, true);
99
+ under = below;
100
+ }
101
+ return below;
102
+ };
103
+ // While the drop travels, the lens follows its centre.
104
+ const follow = () => {
105
+ cancelAnimationFrame(lensFrame);
106
+ const start = performance.now();
107
+ const step = now => {
108
+ if (now - start > 620) { lens(null); return; }
109
+ const cs = getComputedStyle(row, '::before');
110
+ const l = parseFloat(cs.left), r = parseFloat(cs.right);
111
+ lens(l + (row.clientWidth - l - r) / 2);
112
+ lensFrame = requestAnimationFrame(step);
113
+ };
114
+ lensFrame = requestAnimationFrame(step);
115
+ };
116
+
117
+ const place = (it, travel) => {
118
+ const b = boxOf(it);
119
+ row.dataset.droplet = '';
120
+ if (travel && motion.matches && Math.abs(b.left - left) > 1) {
121
+ row.dataset.dropletDir = b.left > left ? 'right' : 'left';
122
+ row.dataset.dropletLift = '';
123
+ clearTimeout(liftTimer);
124
+ liftTimer = setTimeout(() => { delete row.dataset.dropletLift; }, 280);
125
+ follow();
126
+ } else {
127
+ delete row.dataset.dropletDir;
128
+ }
129
+ put(b.left, b.width, b);
130
+ placed = { ...b, rowWidth: row.clientWidth, rowHeight: row.clientHeight };
131
+ chosen = it;
132
+ };
133
+
134
+ // Follow the choice. Nothing chosen, no drop. A new item is watched for
135
+ // its size too; a size that changed moves the drop without a journey.
136
+ const update = () => {
137
+ for (const it of items()) if (!watched.has(it)) { watched.add(it); ro.observe(it); }
138
+ const it = items().find(isChosen);
139
+ if (!it) {
140
+ if (chosen) { chosen = null; placed = null; delete row.dataset.droplet; }
141
+ return;
142
+ }
143
+ if (it !== chosen) place(it, chosen !== null && row.dataset.droplet !== undefined);
144
+ };
145
+ const ro = new ResizeObserver(() => {
146
+ if (!chosen || drag?.moved) return;
147
+ const b = { ...boxOf(chosen), rowWidth: row.clientWidth, rowHeight: row.clientHeight };
148
+ if (!placed || Object.keys(b).some(k => Math.abs(b[k] - placed[k]) > 0.5)) place(chosen, false);
149
+ });
150
+ const watched = new Set();
151
+ ro.observe(row);
152
+ const mo = new MutationObserver(update);
153
+ mo.observe(row, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'aria-pressed'] });
154
+
155
+ // Dragging: past 8px the drop slides under the finger and grows into a
156
+ // lens; let go, the item below it is chosen and the drop settles on it.
157
+ const at = clientX => {
158
+ const f = frame();
159
+ const x = (clientX - f.n.left) / f.sx - row.clientLeft + row.scrollLeft;
160
+ const boxes = items().map(it => boxOf(it, f));
161
+ const min = Math.min(...boxes.map(b => b.left));
162
+ const max = Math.max(...boxes.map(b => b.left + b.width)) - drag.width;
163
+ return Math.min(max, Math.max(min, x - drag.width / 2));
164
+ };
165
+ const track = () => {
166
+ const l = at(drag.clientX);
167
+ put(l, drag.width, boxOf(chosen));
168
+ drag.below = lens(l + drag.width / 2);
169
+ cancelAnimationFrame(edgeFrame);
170
+ if (!scrolls()) return;
171
+ const n = row.getBoundingClientRect(), zone = 32;
172
+ const v = drag.clientX < n.left + zone ? -1 : drag.clientX > n.right - zone ? 1 : 0;
173
+ if (v) edgeFrame = requestAnimationFrame(() => { row.scrollLeft += v * 6; if (drag?.moved) track(); });
174
+ };
175
+ const down = e => {
176
+ if (!chosen || drag || !usable(chosen) || (e.pointerType === 'mouse' && e.button !== 0)) return;
177
+ if (scrolls() && !e.composedPath().includes(chosen)) return;
178
+ drag = { id: e.pointerId, x: e.clientX, clientX: e.clientX, width: boxOf(chosen).width, moved: false, below: null };
179
+ };
180
+ const move = e => {
181
+ if (!drag || e.pointerId !== drag.id) return;
182
+ drag.clientX = e.clientX;
183
+ if (!drag.moved) {
184
+ if (Math.abs(e.clientX - drag.x) < 8) return;
185
+ drag.moved = true;
186
+ row.setPointerCapture(e.pointerId);
187
+ delete row.dataset.dropletDir;
188
+ row.dataset.dropletDrag = '';
189
+ }
190
+ track();
191
+ };
192
+ const up = e => {
193
+ if (!drag || e.pointerId !== drag.id) return;
194
+ const d = drag;
195
+ drag = null;
196
+ cancelAnimationFrame(edgeFrame);
197
+ if (!d.moved) return; // a tap: the item's own click does it
198
+ delete row.dataset.dropletDrag;
199
+ lens(null);
200
+ const target = d.below || chosen;
201
+ if (e.type === 'pointerup' && target !== chosen) choose(target);
202
+ // Settle where the choice is, unless it already set the drop on its way.
203
+ const next = items().find(isChosen) || chosen;
204
+ if (next !== chosen || row.dataset.dropletDir === undefined) place(next, true);
205
+ eatClick = true; // the click the drag ends in
206
+ setTimeout(() => { eatClick = false; }, 400);
207
+ };
208
+ const click = e => { if (eatClick) { eatClick = false; e.stopPropagation(); e.preventDefault(); } };
209
+ row.addEventListener('pointerdown', down);
210
+ row.addEventListener('pointermove', move);
211
+ row.addEventListener('pointerup', up);
212
+ row.addEventListener('pointercancel', up);
213
+ row.addEventListener('click', click, true);
214
+
215
+ update();
216
+
217
+ const handle = {
218
+ update,
219
+ destroy() {
220
+ mo.disconnect(); ro.disconnect();
221
+ clearTimeout(liftTimer); cancelAnimationFrame(lensFrame); cancelAnimationFrame(edgeFrame);
222
+ row.removeEventListener('pointerdown', down);
223
+ row.removeEventListener('pointermove', move);
224
+ row.removeEventListener('pointerup', up);
225
+ row.removeEventListener('pointercancel', up);
226
+ row.removeEventListener('click', click, true);
227
+ lens(null);
228
+ for (const k of ['droplet', 'dropletDir', 'dropletLift', 'dropletDrag']) delete row.dataset[k];
229
+ for (const k of ['left', 'right', 'top', 'bottom']) row.style.removeProperty(`--gl-droplet-${k}`);
230
+ attached.delete(row);
231
+ },
232
+ };
233
+ attached.set(row, handle);
234
+ return handle;
235
+ }
236
+
237
+ export { attachDroplet as a };