@jungherz-de/glasskit-elements 1.25.0 → 1.27.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.
@@ -0,0 +1,217 @@
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
+ // An item's box in the row's padding box; in a row that scrolls, in its
55
+ // scrolled content, where the drop scrolls along with the items.
56
+ const boxOf = it => {
57
+ const n = row.getBoundingClientRect(), r = it.getBoundingClientRect();
58
+ const l = r.left - n.left - row.clientLeft + row.scrollLeft;
59
+ const t = r.top - n.top - row.clientTop + row.scrollTop;
60
+ return { left: l, width: r.width, top: t, bottom: row.clientHeight - t - r.height };
61
+ };
62
+ const put = (l, width, b) => {
63
+ left = l;
64
+ set('left', l); set('right', row.clientWidth - l - width);
65
+ set('top', b.top); set('bottom', b.bottom);
66
+ };
67
+
68
+ // The usable item nearest a point of the row lights up as under a lens.
69
+ const lens = centre => {
70
+ let below = null, best = Infinity;
71
+ if (centre != null) for (const it of items()) {
72
+ if (!usable(it)) continue;
73
+ const b = boxOf(it), d = Math.abs(b.left + b.width / 2 - centre);
74
+ if (d < best) { best = d; below = it; }
75
+ }
76
+ if (below !== under) {
77
+ if (under) mark(under, false);
78
+ if (below) mark(below, true);
79
+ under = below;
80
+ }
81
+ return below;
82
+ };
83
+ // While the drop travels, the lens follows its centre.
84
+ const follow = () => {
85
+ cancelAnimationFrame(lensFrame);
86
+ const start = performance.now();
87
+ const step = now => {
88
+ if (now - start > 620) { lens(null); return; }
89
+ const cs = getComputedStyle(row, '::before');
90
+ const l = parseFloat(cs.left), r = parseFloat(cs.right);
91
+ lens(l + (row.clientWidth - l - r) / 2);
92
+ lensFrame = requestAnimationFrame(step);
93
+ };
94
+ lensFrame = requestAnimationFrame(step);
95
+ };
96
+
97
+ const place = (it, travel) => {
98
+ const b = boxOf(it);
99
+ row.dataset.droplet = '';
100
+ if (travel && motion.matches && Math.abs(b.left - left) > 1) {
101
+ row.dataset.dropletDir = b.left > left ? 'right' : 'left';
102
+ row.dataset.dropletLift = '';
103
+ clearTimeout(liftTimer);
104
+ liftTimer = setTimeout(() => { delete row.dataset.dropletLift; }, 280);
105
+ follow();
106
+ } else {
107
+ delete row.dataset.dropletDir;
108
+ }
109
+ put(b.left, b.width, b);
110
+ placed = { ...b, rowWidth: row.clientWidth, rowHeight: row.clientHeight };
111
+ chosen = it;
112
+ };
113
+
114
+ // Follow the choice. Nothing chosen, no drop. A new item is watched for
115
+ // its size too; a size that changed moves the drop without a journey.
116
+ const update = () => {
117
+ for (const it of items()) if (!watched.has(it)) { watched.add(it); ro.observe(it); }
118
+ const it = items().find(isChosen);
119
+ if (!it) {
120
+ if (chosen) { chosen = null; placed = null; delete row.dataset.droplet; }
121
+ return;
122
+ }
123
+ if (it !== chosen) place(it, chosen !== null && row.dataset.droplet !== undefined);
124
+ };
125
+ const ro = new ResizeObserver(() => {
126
+ if (!chosen || drag?.moved) return;
127
+ const b = { ...boxOf(chosen), rowWidth: row.clientWidth, rowHeight: row.clientHeight };
128
+ if (!placed || Object.keys(b).some(k => Math.abs(b[k] - placed[k]) > 0.5)) place(chosen, false);
129
+ });
130
+ const watched = new Set();
131
+ ro.observe(row);
132
+ const mo = new MutationObserver(update);
133
+ mo.observe(row, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'aria-pressed'] });
134
+
135
+ // Dragging: past 8px the drop slides under the finger and grows into a
136
+ // lens; let go, the item below it is chosen and the drop settles on it.
137
+ const at = clientX => {
138
+ const n = row.getBoundingClientRect();
139
+ const x = clientX - n.left - row.clientLeft + row.scrollLeft;
140
+ const boxes = items().map(boxOf);
141
+ const min = Math.min(...boxes.map(b => b.left));
142
+ const max = Math.max(...boxes.map(b => b.left + b.width)) - drag.width;
143
+ return Math.min(max, Math.max(min, x - drag.width / 2));
144
+ };
145
+ const track = () => {
146
+ const l = at(drag.clientX);
147
+ put(l, drag.width, boxOf(chosen));
148
+ drag.below = lens(l + drag.width / 2);
149
+ cancelAnimationFrame(edgeFrame);
150
+ if (!scrolls()) return;
151
+ const n = row.getBoundingClientRect(), zone = 32;
152
+ const v = drag.clientX < n.left + zone ? -1 : drag.clientX > n.right - zone ? 1 : 0;
153
+ if (v) edgeFrame = requestAnimationFrame(() => { row.scrollLeft += v * 6; if (drag?.moved) track(); });
154
+ };
155
+ const down = e => {
156
+ if (!chosen || drag || !usable(chosen) || (e.pointerType === 'mouse' && e.button !== 0)) return;
157
+ if (scrolls() && !e.composedPath().includes(chosen)) return;
158
+ drag = { id: e.pointerId, x: e.clientX, clientX: e.clientX, width: boxOf(chosen).width, moved: false, below: null };
159
+ };
160
+ const move = e => {
161
+ if (!drag || e.pointerId !== drag.id) return;
162
+ drag.clientX = e.clientX;
163
+ if (!drag.moved) {
164
+ if (Math.abs(e.clientX - drag.x) < 8) return;
165
+ drag.moved = true;
166
+ row.setPointerCapture(e.pointerId);
167
+ delete row.dataset.dropletDir;
168
+ row.dataset.dropletDrag = '';
169
+ }
170
+ track();
171
+ };
172
+ const up = e => {
173
+ if (!drag || e.pointerId !== drag.id) return;
174
+ const d = drag;
175
+ drag = null;
176
+ cancelAnimationFrame(edgeFrame);
177
+ if (!d.moved) return; // a tap: the item's own click does it
178
+ delete row.dataset.dropletDrag;
179
+ lens(null);
180
+ const target = d.below || chosen;
181
+ if (e.type === 'pointerup' && target !== chosen) choose(target);
182
+ // Settle where the choice is, unless it already set the drop on its way.
183
+ const next = items().find(isChosen) || chosen;
184
+ if (next !== chosen || row.dataset.dropletDir === undefined) place(next, true);
185
+ eatClick = true; // the click the drag ends in
186
+ setTimeout(() => { eatClick = false; }, 400);
187
+ };
188
+ const click = e => { if (eatClick) { eatClick = false; e.stopPropagation(); e.preventDefault(); } };
189
+ row.addEventListener('pointerdown', down);
190
+ row.addEventListener('pointermove', move);
191
+ row.addEventListener('pointerup', up);
192
+ row.addEventListener('pointercancel', up);
193
+ row.addEventListener('click', click, true);
194
+
195
+ update();
196
+
197
+ const handle = {
198
+ update,
199
+ destroy() {
200
+ mo.disconnect(); ro.disconnect();
201
+ clearTimeout(liftTimer); cancelAnimationFrame(lensFrame); cancelAnimationFrame(edgeFrame);
202
+ row.removeEventListener('pointerdown', down);
203
+ row.removeEventListener('pointermove', move);
204
+ row.removeEventListener('pointerup', up);
205
+ row.removeEventListener('pointercancel', up);
206
+ row.removeEventListener('click', click, true);
207
+ lens(null);
208
+ for (const k of ['droplet', 'dropletDir', 'dropletLift', 'dropletDrag']) delete row.dataset[k];
209
+ for (const k of ['left', 'right', 'top', 'bottom']) row.style.removeProperty(`--gl-droplet-${k}`);
210
+ attached.delete(row);
211
+ },
212
+ };
213
+ attached.set(row, handle);
214
+ return handle;
215
+ }
216
+
217
+ export { attachDroplet as a };