@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.
- package/CHANGELOG.md +29 -0
- package/README.md +36 -5
- package/SKILL.md +38 -4
- package/dist/components/glk-segmented.js +22 -3
- package/dist/components/glk-tab-bar.js +61 -1
- package/dist/components/glk-tab-item.js +22 -0
- package/dist/components/shared/glasskit-droplet-C3k1mxn4.js +217 -0
- package/dist/glasskit-elements.esm.js +322 -7
- package/dist/glasskit-elements.js +322 -7
- package/dist/glasskit-elements.min.js +1 -1
- package/package.json +3 -3
- package/src/components/forms/glk-segmented.js +22 -3
- package/src/components/navigation/glk-tab-bar.js +61 -1
- package/src/components/navigation/glk-tab-item.js +22 -0
|
@@ -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 };
|