@dazecoop/liquid-glass 0.0.0-stage → 0.1.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/lib/index.cjs ADDED
@@ -0,0 +1,1172 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const SVG_NS = "http://www.w3.org/2000/svg";
4
+ const LIGHT_HEIGHT = 0.5;
5
+ const LUMA = [0.2126, 0.7152, 0.0722];
6
+ const ARC = 0.94;
7
+ const ARC_DROP = 1 - Math.sqrt(1 - ARC * ARC);
8
+ const UI_SPRING = { stiffness: 440, damping: 42 };
9
+ const CLONE_CSS = `
10
+ [data-glass-copy], [data-glass-copy] * {
11
+ backdrop-filter: none !important;
12
+ -webkit-backdrop-filter: none !important;
13
+ will-change: auto !important;
14
+ animation: none !important;
15
+ transition: none !important;
16
+ }`;
17
+ let uid = 0;
18
+ class Spring {
19
+ // `wake` is called whenever the target changes, so a sleeping lens starts moving
20
+ constructor(value, { stiffness = 260, damping = 26 } = {}, wake = null) {
21
+ this.wake = wake;
22
+ this.value = value;
23
+ this.target = value;
24
+ this.velocity = 0;
25
+ this.stiffness = stiffness;
26
+ this.damping = damping;
27
+ }
28
+ get target() {
29
+ return this._target;
30
+ }
31
+ set target(v) {
32
+ this._target = v;
33
+ this.wake?.();
34
+ }
35
+ step(dt) {
36
+ const force = (this.target - this.value) * this.stiffness - this.velocity * this.damping;
37
+ this.velocity += force * dt;
38
+ this.value += this.velocity * dt;
39
+ }
40
+ snap(value) {
41
+ this.value = this.target = value;
42
+ this.velocity = 0;
43
+ }
44
+ get settled() {
45
+ return Math.abs(this.target - this.value) < 0.01 && Math.abs(this.velocity) < 0.01;
46
+ }
47
+ }
48
+ function sdRoundBox(px, py, bx, by, r) {
49
+ const qx = Math.abs(px) - bx + r;
50
+ const qy = Math.abs(py) - by + r;
51
+ return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
52
+ }
53
+ function pathTo(root, el) {
54
+ const path = [];
55
+ for (let n = el; n && n !== root; n = n.parentElement) path.unshift([...n.parentElement.children].indexOf(n));
56
+ return path;
57
+ }
58
+ function follow(root, path) {
59
+ let n = root;
60
+ for (const i of path) n = n?.children[i];
61
+ return n;
62
+ }
63
+ const insideGlass = (node) => (node.nodeType === 1 ? node : node.parentElement)?.closest("[data-glass]");
64
+ class LiquidGlass {
65
+ // Starting looks. Each is plain parameters: glass.set(LiquidGlass.presets.frosted).
66
+ // Dome rounds the bevel all the way to the middle, so it reads best on a circle.
67
+ static presets = {
68
+ clear: { refraction: 40, frost: 2, chroma: 0.4, zoom: 1.08, bevel: 35, specular: 0.35, fresnel: 0.3, brightness: 0, saturation: 0, tint: "", rim: 1 },
69
+ frosted: { refraction: 24, frost: 14, chroma: 0.15, zoom: 1, bevel: 30, specular: 0.5, fresnel: 0.5, brightness: 0.08, saturation: 0.3, tint: "rgb(255 255 255 / 0.08)", rim: 1 },
70
+ dark: { refraction: 32, frost: 6, chroma: 0.2, zoom: 1.04, bevel: 35, specular: 0.3, fresnel: 0.2, brightness: -0.18, saturation: -0.2, tint: "rgb(0 0 0 / 0.25)", rim: 0.6 },
71
+ prism: { refraction: 80, frost: 0, chroma: 1, zoom: 1.12, bevel: 50, specular: 0.25, fresnel: 0.4, brightness: 0.02, saturation: 0.5, tint: "", rim: 0.8 },
72
+ dome: { refraction: 50, frost: 0, chroma: 0.2, zoom: 1.35, bevel: 160, specular: 0.7, fresnel: 0.6, brightness: 0, saturation: 0.1, tint: "", rim: 1 }
73
+ };
74
+ constructor(source, { anchor = null, container = null, button = false, throttle = 0, ...params } = {}) {
75
+ this.source = source;
76
+ this.anchor = anchor;
77
+ this.container = anchor ? null : container;
78
+ this.button = Boolean(button && anchor);
79
+ this.throttle = throttle;
80
+ this.syncedAt = -Infinity;
81
+ this.params = {
82
+ width: 280,
83
+ height: 180,
84
+ radius: 64,
85
+ ...LiquidGlass.presets.clear,
86
+ ...params
87
+ };
88
+ this.reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
89
+ const wake = () => this.#wake();
90
+ this.x = new Spring(innerWidth / 2, void 0, wake);
91
+ this.y = new Spring(innerHeight / 2, void 0, wake);
92
+ this.scale = new Spring(1, { stiffness: 320, damping: 18 }, wake);
93
+ this.stretchX = new Spring(1, { stiffness: 180, damping: 14 });
94
+ this.stretchY = new Spring(1, { stiffness: 180, damping: 14 });
95
+ this.springs = [this.x, this.y, this.scale, this.stretchX, this.stretchY];
96
+ this.hovered = new Spring(0, UI_SPRING, wake);
97
+ this.pressed = new Spring(0, UI_SPRING, wake);
98
+ this.scrolled = /* @__PURE__ */ new Set();
99
+ this.popScale = 1;
100
+ this.#build();
101
+ this.#observe();
102
+ this.shapeDirty = true;
103
+ this.copyDirty = true;
104
+ this.placeDirty = true;
105
+ this.#wake();
106
+ }
107
+ // The frame loop sleeps once nothing is moving or waiting to be redrawn, so an idle or
108
+ // hidden lens costs nothing per frame or per scroll; anything that changes it wakes it
109
+ #awake = false;
110
+ #raf = 0;
111
+ // Everything the glass listens to or watches, so destroy() can let go of it all
112
+ #abort = new AbortController();
113
+ #observers = [];
114
+ // Inline styles set on the anchor or container, to put back on destroy()
115
+ #restore = [];
116
+ #wake() {
117
+ if (this.#awake || this.destroyed) return;
118
+ this.#awake = true;
119
+ this.last = performance.now();
120
+ this.#raf = requestAnimationFrame((t) => this.#frame(t));
121
+ }
122
+ // Sets an inline style on an element that isn't the glass's own, remembering the old value
123
+ #style(el, prop, value) {
124
+ this.#restore.push([el, prop, el.style[prop]]);
125
+ el.style[prop] = value;
126
+ }
127
+ // Shown, or showing: anchored glass always is; a lens while visible or popping
128
+ get #shown() {
129
+ return Boolean(this.anchor || this.visible || this.pop);
130
+ }
131
+ #build() {
132
+ const id = `liquid-glass-${++uid}`;
133
+ this.svg = document.createElementNS(SVG_NS, "svg");
134
+ this.svg.setAttribute("aria-hidden", "true");
135
+ this.svg.style.cssText = "position:absolute;width:0;height:0;overflow:hidden";
136
+ this.svg.innerHTML = `<style>${CLONE_CSS}</style><filter id="${id}" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB" />`;
137
+ this.filter = this.svg.querySelector("filter");
138
+ this.el = document.createElement("div");
139
+ this.el.dataset.glass = "";
140
+ this.el.setAttribute("aria-hidden", "true");
141
+ this.el.inert = true;
142
+ this.el.style.cssText = "pointer-events:none;overflow:hidden;box-shadow:0 12px 34px -6px rgb(0 0 0 / 0.35)";
143
+ if (this.anchor) {
144
+ if (getComputedStyle(this.anchor).position === "static") this.#style(this.anchor, "position", "relative");
145
+ this.#style(this.anchor, "isolation", "isolate");
146
+ this.el.style.cssText += ";position:absolute;inset:0;z-index:-1;border-radius:inherit";
147
+ } else {
148
+ const position = this.container ? "absolute;z-index:1" : "fixed;z-index:2147483000";
149
+ this.el.style.cssText += `;position:${position};left:0;top:0;will-change:transform;opacity:0;transition:opacity .3s`;
150
+ if (this.container && getComputedStyle(this.container).position === "static") this.#style(this.container, "position", "relative");
151
+ }
152
+ this.frost = document.createElement("div");
153
+ this.frost.style.cssText = "position:absolute";
154
+ this.view = document.createElement("div");
155
+ this.view.style.cssText = `position:absolute;inset:0;overflow:hidden;filter:url(#${id})`;
156
+ this.copyHost = document.createElement("div");
157
+ this.copyHost.style.cssText = "position:absolute;left:0;top:0";
158
+ this.rim = document.createElement("canvas");
159
+ this.rim.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
160
+ this.view.append(this.copyHost);
161
+ this.frost.append(this.view);
162
+ this.el.append(this.frost, this.rim);
163
+ document.body.append(this.svg);
164
+ (this.anchor ?? this.container ?? document.body).prepend(this.el);
165
+ const { signal } = this.#abort;
166
+ if (this.anchor) {
167
+ const resize = new ResizeObserver(() => {
168
+ this.set({
169
+ width: this.anchor.offsetWidth,
170
+ height: this.anchor.offsetHeight,
171
+ radius: parseFloat(getComputedStyle(this.anchor).borderTopLeftRadius) || 0
172
+ });
173
+ });
174
+ resize.observe(this.anchor);
175
+ this.#observers.push(resize);
176
+ }
177
+ if (this.button) {
178
+ const a = this.anchor;
179
+ const release = () => this.pressed.target = 0;
180
+ a.addEventListener("pointerenter", () => this.hovered.target = 1, { signal });
181
+ a.addEventListener(
182
+ "pointerleave",
183
+ () => {
184
+ this.hovered.target = 0;
185
+ release();
186
+ },
187
+ { signal }
188
+ );
189
+ a.addEventListener("pointerdown", () => this.pressed.target = 1, { signal });
190
+ a.addEventListener("pointerup", release, { signal });
191
+ a.addEventListener("pointercancel", release, { signal });
192
+ a.addEventListener(
193
+ "keydown",
194
+ (e) => {
195
+ if (e.key === " " || e.key === "Enter") this.pressed.target = 1;
196
+ },
197
+ { signal }
198
+ );
199
+ a.addEventListener("keyup", release, { signal });
200
+ a.addEventListener("blur", release, { signal });
201
+ }
202
+ }
203
+ #observe() {
204
+ const { signal } = this.#abort;
205
+ const dirty = () => {
206
+ this.copyDirty = true;
207
+ if (this.#shown) this.#wake();
208
+ };
209
+ const mutations = new MutationObserver((records) => {
210
+ const counts = (r) => !insideGlass(r.target) && !(r.type === "attributes" && r.target.closest?.("[data-glass-skip]"));
211
+ if (records.some(counts)) dirty();
212
+ });
213
+ mutations.observe(this.source, { subtree: true, childList: true, attributes: true, characterData: true });
214
+ this.#observers.push(mutations);
215
+ this.source.addEventListener("input", dirty, { capture: true, signal });
216
+ this.source.addEventListener(
217
+ "scroll",
218
+ (e) => {
219
+ if (insideGlass(e.target)) return;
220
+ this.scrolled.add(e.target);
221
+ dirty();
222
+ },
223
+ { capture: true, signal }
224
+ );
225
+ addEventListener("resize", dirty, { signal });
226
+ addEventListener("scroll", () => {
227
+ if (!this.#shown) return;
228
+ this.rectDirty = true;
229
+ this.#wake();
230
+ }, { passive: true, signal });
231
+ }
232
+ #syncCopy() {
233
+ const copy = this.source.cloneNode(true);
234
+ copy.dataset.glassCopy = "";
235
+ const live = "input, textarea, select, canvas";
236
+ const to = copy.querySelectorAll(live);
237
+ this.source.querySelectorAll(live).forEach((src, i) => {
238
+ const dst = to[i];
239
+ if ("value" in src && src.type !== "file") dst.value = src.value;
240
+ if ("checked" in src) dst.checked = src.checked;
241
+ if (src instanceof HTMLCanvasElement) dst.getContext("2d")?.drawImage(src, 0, 0);
242
+ });
243
+ const scrolls = [];
244
+ for (const el of this.scrolled) {
245
+ if (!this.source.contains(el)) this.scrolled.delete(el);
246
+ else scrolls.push([follow(copy, pathTo(this.source, el)), el.scrollLeft, el.scrollTop]);
247
+ }
248
+ if (this.anchor && this.source.contains(this.anchor)) follow(copy, pathTo(this.source, this.anchor))?.remove();
249
+ else if (this.source.contains(this.el)) follow(copy, pathTo(this.source, this.el))?.remove();
250
+ for (const glass of copy.querySelectorAll("[data-glass]")) {
251
+ for (const inner of glass.querySelectorAll("[data-glass]")) inner.remove();
252
+ }
253
+ copy.removeAttribute("id");
254
+ for (const el of copy.querySelectorAll("[id]")) el.removeAttribute("id");
255
+ const rect = this.source.getBoundingClientRect();
256
+ this.sourceRect = rect;
257
+ this.containerRect = this.container?.getBoundingClientRect();
258
+ Object.assign(copy.style, {
259
+ position: "absolute",
260
+ inset: "auto",
261
+ left: "0",
262
+ top: "0",
263
+ margin: "0",
264
+ width: `${rect.width}px`,
265
+ height: `${rect.height}px`
266
+ });
267
+ this.copyHost.replaceChildren(copy);
268
+ for (const [el, left, top] of scrolls) el?.scrollTo(left, top);
269
+ this.copyDirty = false;
270
+ }
271
+ #buildShape() {
272
+ const { width, height, radius, bevel: depth, refraction, frost, chroma, zoom, specular, fresnel, tint, rim: rimStrength } = this.params;
273
+ const body = this.params.body ?? 1;
274
+ const dpr = Math.min(devicePixelRatio || 1, 2);
275
+ const hw = width / 2;
276
+ const hh = height / 2;
277
+ const r = Math.min(radius, hw, hh);
278
+ const bevel = Math.max(Math.min(depth, hw, hh), 1);
279
+ const sdf = (x, y) => sdRoundBox(x, y, hw, hh, r);
280
+ const normal = (px, py, step) => {
281
+ const nx = sdf(px + step, py) - sdf(px - step, py);
282
+ const ny = sdf(px, py + step) - sdf(px, py - step);
283
+ const len = Math.hypot(nx, ny) || 1;
284
+ return [nx / len, ny / len];
285
+ };
286
+ const z = zoom;
287
+ const mhw = hw / z;
288
+ const mhh = hh / z;
289
+ const mbevel = bevel / z;
290
+ const mrefraction = refraction / z;
291
+ const msdf = (x, y) => sdRoundBox(x, y, mhw, mhh, r / z);
292
+ const mres = dpr * Math.max(z, 1);
293
+ const pad = Math.ceil(frost * 1.5 / z);
294
+ this.inner = { x: mhw + pad, y: mhh + pad };
295
+ const scale = mrefraction * 2 + Math.sign(mrefraction || 1);
296
+ const mfalloff = (this.params.falloff ?? 0) / z;
297
+ const map = document.createElement("canvas");
298
+ map.width = Math.ceil((mhw + pad) * 2 * mres);
299
+ map.height = Math.ceil((mhh + pad) * 2 * mres);
300
+ const mctx = map.getContext("2d");
301
+ const md = mctx.createImageData(map.width, map.height);
302
+ for (let y = 0; y < map.height; y++) {
303
+ for (let x = 0; x < map.width; x++) {
304
+ const px = (x + 0.5) / mres - mhw - pad;
305
+ const py = (y + 0.5) / mres - mhh - pad;
306
+ let ox = 0;
307
+ let oy = 0;
308
+ const d = msdf(px, py);
309
+ const exp = mfalloff > 0;
310
+ if (d < 0 && (exp ? d > -mfalloff * 6 : d > -mbevel) && refraction !== 0) {
311
+ let bend;
312
+ if (exp) bend = Math.exp(d / mfalloff) * mrefraction;
313
+ else {
314
+ const rim2 = (1 + d / mbevel) * ARC;
315
+ bend = (1 - Math.sqrt(1 - rim2 * rim2)) / ARC_DROP * mrefraction;
316
+ }
317
+ const nx = msdf(px + 0.5 / mres, py) - msdf(px - 0.5 / mres, py);
318
+ const ny = msdf(px, py + 0.5 / mres) - msdf(px, py - 0.5 / mres);
319
+ const len = Math.hypot(nx, ny) || 1;
320
+ ox = nx / len * bend;
321
+ oy = ny / len * bend;
322
+ }
323
+ const i = (y * map.width + x) * 4;
324
+ md.data[i] = (0.5 - ox / scale) * 255;
325
+ md.data[i + 1] = (0.5 - oy / scale) * 255;
326
+ md.data[i + 3] = 255;
327
+ }
328
+ }
329
+ mctx.putImageData(md, 0, 0);
330
+ const rim = this.rim;
331
+ rim.width = Math.ceil(width * dpr);
332
+ rim.height = Math.ceil(height * dpr);
333
+ const rctx = rim.getContext("2d");
334
+ const rd = rctx.createImageData(rim.width, rim.height);
335
+ const angle = (this.params.lightAngle ?? -123.5) * Math.PI / 180;
336
+ const lx = Math.cos(angle);
337
+ const ly = Math.sin(angle);
338
+ const flat = Math.sqrt(1 - LIGHT_HEIGHT ** 2);
339
+ const half = [lx * flat, ly * flat, LIGHT_HEIGHT + 1];
340
+ const halfLen = Math.hypot(...half);
341
+ const [hx, hy, hz] = half.map((v) => v / halfLen);
342
+ for (let y = 0; y < rim.height; y++) {
343
+ const py = (y + 0.5) / dpr - hh;
344
+ const sheen = (1 - (py + hh) / height) * 0.05;
345
+ for (let x = 0; x < rim.width; x++) {
346
+ const px = (x + 0.5) / dpr - hw;
347
+ const d = sdf(px, py);
348
+ if (d > 0) continue;
349
+ let a = (0.06 + sheen) * body;
350
+ if (d > -bevel) {
351
+ const amt = 1 + d / bevel;
352
+ const [nx, ny] = normal(px, py, 1 / dpr);
353
+ const facing = nx * lx + ny * ly;
354
+ const light = amt ** 3 * (0.55 * Math.max(facing, 0) + 0.22 * Math.max(-facing, 0));
355
+ const hairline = (1 - Math.min(-d / 1.5, 1)) * 0.35;
356
+ a += (light + hairline) * rimStrength;
357
+ const nz = Math.sqrt(1 - amt * amt);
358
+ const spec = Math.max(nx * amt * hx + ny * amt * hy + nz * hz, 0) ** 48;
359
+ a += specular * spec * 1.4 + fresnel * (1 - nz) ** 3 * 0.5;
360
+ }
361
+ const i = (y * rim.width + x) * 4;
362
+ rd.data[i] = rd.data[i + 1] = rd.data[i + 2] = 255;
363
+ rd.data[i + 3] = Math.min(a, 1) * 255;
364
+ }
365
+ }
366
+ rctx.putImageData(rd, 0, 0);
367
+ rim.style.background = tint;
368
+ const href = map.toDataURL();
369
+ const c = chroma * 0.35;
370
+ const displace = (k, result) => `<feDisplacementMap in="SourceGraphic" in2="map" scale="${scale * k}" xChannelSelector="R" yChannelSelector="G" result="${result}" />`;
371
+ this.filterBase = `<feImage href="${href}" result="map" preserveAspectRatio="none" />` + (c > 1e-3 ? displace(1 + c, "dr") + displace(1, "dg") + displace(1 - c, "db") + `<feColorMatrix in="dr" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="r" />
372
+ <feColorMatrix in="dg" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="g" />
373
+ <feColorMatrix in="db" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="b" />
374
+ <feComposite in="r" in2="g" operator="arithmetic" k2="1" k3="1" result="rg" />
375
+ <feComposite in="rg" in2="b" operator="arithmetic" k2="1" k3="1" />` : displace(1, "out"));
376
+ this.#writeFilter();
377
+ Object.assign(this.frost.style, {
378
+ left: `${hw - mhw - pad}px`,
379
+ top: `${hh - mhh - pad}px`,
380
+ width: `${(mhw + pad) * 2}px`,
381
+ height: `${(mhh + pad) * 2}px`,
382
+ transform: z === 1 ? "" : `scale(${z})`
383
+ });
384
+ this.#applyButton();
385
+ if (!this.anchor) {
386
+ this.el.style.width = `${width}px`;
387
+ this.el.style.height = `${height}px`;
388
+ this.el.style.borderRadius = `${r}px`;
389
+ }
390
+ this.shapeDirty = false;
391
+ }
392
+ // Saturation around Rec. 709 luma, then brightness as an offset, in one matrix
393
+ #tone() {
394
+ const { brightness: b, saturation } = this.params;
395
+ if (!b && !saturation) return "";
396
+ const s = 1 + saturation;
397
+ const rows = [0, 1, 2].map(
398
+ (i) => [...LUMA.map((l, j) => l * (1 - s) + (i === j ? s : 0)), 0, b].map((v) => +v.toFixed(4)).join(" ")
399
+ );
400
+ return `<feColorMatrix type="matrix" values="${rows.join(" ")} 0 0 0 1 0" />`;
401
+ }
402
+ // The whole filter is rewritten even when only the tone changes: Safari and Firefox can
403
+ // keep showing the old result when a step is removed from a live filter. The map, the
404
+ // expensive part, is reused, so a tone change stays cheap.
405
+ #writeFilter() {
406
+ this.filter.innerHTML = this.filterBase + this.#tone();
407
+ this.displacers = [...this.filter.querySelectorAll("feDisplacementMap")].map((el) => [el, Number(el.getAttribute("scale"))]);
408
+ }
409
+ // Frost blur, plus the button's hover and press state
410
+ #applyButton() {
411
+ const { frost } = this.params;
412
+ const parts = frost > 0 ? [`blur(${frost / 2 / this.params.zoom}px)`] : [];
413
+ if (this.button) {
414
+ const h = this.hovered.value;
415
+ const p = this.pressed.value;
416
+ for (const [el, base] of this.displacers) el.setAttribute("scale", base * (1 - 0.6 * p));
417
+ parts.push(`brightness(${1 + 0.12 * h - 0.04 * p})`);
418
+ this.el.style.boxShadow = `0 ${12 - 8 * p}px ${34 - 18 * p}px -6px rgb(0 0 0 / ${0.35 + 0.15 * p})`;
419
+ }
420
+ this.frost.style.filter = parts.join(" ") || "none";
421
+ }
422
+ // Keep the copy lined up with the original under the glass. Squash and press scale the whole
423
+ // lens, copy included, since Safari can't take a transform on the copy itself.
424
+ #place() {
425
+ const { width, height } = this.params;
426
+ const source = this.sourceRect;
427
+ const dpr = devicePixelRatio || 1;
428
+ const snap = (v) => Math.round(v * dpr) / dpr;
429
+ let left, top;
430
+ if (this.anchor) {
431
+ ({ left, top } = this.anchor.getBoundingClientRect());
432
+ } else {
433
+ const s = this.scale.value * this.popScale;
434
+ left = snap(this.x.value - width / 2);
435
+ top = snap(this.y.value - height / 2);
436
+ this.el.style.transform = `translate(${left}px, ${top}px) scale(${s * this.stretchX.value}, ${s * this.stretchY.value})`;
437
+ if (this.container) {
438
+ left += this.containerRect.left;
439
+ top += this.containerRect.top;
440
+ }
441
+ }
442
+ this.copyHost.style.left = `${snap(this.inner.x + source.left - left - width / 2)}px`;
443
+ this.copyHost.style.top = `${snap(this.inner.y + source.top - top - height / 2)}px`;
444
+ }
445
+ // Moves the lens to a point in viewport (client) coordinates
446
+ moveTo(x, y) {
447
+ if (this.container) {
448
+ const { left, top } = this.container.getBoundingClientRect();
449
+ x -= left;
450
+ y -= top;
451
+ }
452
+ if (this.reducedMotion) {
453
+ this.x.snap(x);
454
+ this.y.snap(y);
455
+ } else {
456
+ this.x.target = x;
457
+ this.y.target = y;
458
+ }
459
+ this.show(true, { pop: true });
460
+ }
461
+ press(down) {
462
+ this.scale.target = down ? 1.12 : 1;
463
+ if (this.reducedMotion) this.scale.snap(this.scale.target);
464
+ }
465
+ set(params) {
466
+ const toneOnly = Object.keys(params).every((k) => k === "brightness" || k === "saturation");
467
+ Object.assign(this.params, params);
468
+ if (toneOnly && !this.shapeDirty && this.filterBase) {
469
+ this.#writeFilter();
470
+ this.#applyButton();
471
+ } else {
472
+ this.shapeDirty = true;
473
+ this.#wake();
474
+ }
475
+ }
476
+ // Removes the glass and everything it added to the page, and stops it listening and
477
+ // watching. Safe to call more than once.
478
+ destroy() {
479
+ if (this.destroyed) return;
480
+ this.destroyed = true;
481
+ cancelAnimationFrame(this.#raf);
482
+ this.#awake = false;
483
+ this.#abort.abort();
484
+ this.#observers.forEach((o) => o.disconnect());
485
+ this.el.remove();
486
+ this.svg.remove();
487
+ for (const [el, prop, value] of this.#restore.reverse()) el.style[prop] = value;
488
+ this.scrolled.clear();
489
+ }
490
+ // Fades the lens in or out. With { pop: true } it pops like a bubble on the way out and
491
+ // swells back in. The pop runs through the frame loop's own transform: a separate CSS
492
+ // `scale` would also scale the lens's position, and it would dart away as it popped.
493
+ show(visible, { pop = false } = {}) {
494
+ if (visible === this.visible) return;
495
+ this.visible = visible;
496
+ if (visible) this.rectDirty = true;
497
+ this.#wake();
498
+ this.el.getAnimations().forEach((a) => a.cancel());
499
+ if (!pop || this.reducedMotion) {
500
+ this.pop = null;
501
+ this.popScale = 1;
502
+ this.el.style.transition = "opacity .3s";
503
+ this.el.style.opacity = visible ? "1" : "0";
504
+ return;
505
+ }
506
+ this.el.style.transition = "none";
507
+ this.pop = { visible, start: performance.now(), burst: !visible };
508
+ }
509
+ // A bubble swells for a moment, then its film is gone at once. Coming back, it grows from
510
+ // smaller with a little overshoot.
511
+ #popFrame(now) {
512
+ const { visible, start } = this.pop;
513
+ const t = now - start;
514
+ let scale, opacity;
515
+ if (visible) {
516
+ const k = Math.min(t / 460, 1);
517
+ const back = 1 + 2.2 * (k - 1) ** 3 + 1.2 * (k - 1) ** 2;
518
+ scale = 0.6 + 0.4 * back;
519
+ opacity = Math.min(t / 140, 1);
520
+ } else {
521
+ const k = Math.min(t / 110, 1);
522
+ scale = 1 + 0.14 * k * k;
523
+ opacity = t < 110 ? 1 : 0;
524
+ if (t >= 110 && this.pop.burst) {
525
+ this.pop.burst = false;
526
+ this.#burst();
527
+ }
528
+ }
529
+ this.popScale = scale;
530
+ this.el.style.opacity = String(opacity);
531
+ if (t >= (visible ? 460 : 110)) {
532
+ this.pop = null;
533
+ this.popScale = 1;
534
+ }
535
+ }
536
+ // A ring flash and droplets flying off the rim
537
+ #burst() {
538
+ const r = this.el.getBoundingClientRect();
539
+ if (!r.width) return;
540
+ const cx = r.left + r.width / 2;
541
+ const cy = r.top + r.height / 2;
542
+ const layer = this.container ?? document.body;
543
+ const origin = this.container ? this.container.getBoundingClientRect() : { left: 0, top: 0 };
544
+ const position = this.container ? "absolute" : "fixed";
545
+ const z = this.container ? 2 : 2147483e3;
546
+ const add = (css, frames, timing) => {
547
+ const el = document.createElement("div");
548
+ el.setAttribute("aria-hidden", "true");
549
+ el.style.cssText = `position:${position};pointer-events:none;z-index:${z};${css}`;
550
+ layer.append(el);
551
+ el.animate(frames, { fill: "forwards", ...timing }).finished.then(() => el.remove());
552
+ };
553
+ add(
554
+ `left:${r.left - origin.left}px;top:${r.top - origin.top}px;width:${r.width}px;height:${r.height}px;border-radius:${this.el.style.borderRadius};box-shadow:inset 0 0 0 1px rgb(255 255 255 / .8)`,
555
+ [
556
+ { opacity: 0.8, transform: "scale(1)" },
557
+ { opacity: 0, transform: "scale(1.18)" }
558
+ ],
559
+ { duration: 200, easing: "cubic-bezier(.2,.7,.3,1)" }
560
+ );
561
+ const count = 22;
562
+ for (let i = 0; i < count; i++) {
563
+ const angle = i / count * Math.PI * 2 + (Math.random() - 0.5) * 0.5;
564
+ const ux = Math.cos(angle);
565
+ const uy = Math.sin(angle);
566
+ const size = 3 + Math.random() * 4;
567
+ const reach = 18 + Math.random() * 46;
568
+ const fall = 14 + Math.random() * 22;
569
+ const x = cx + ux * r.width / 2 - origin.left - size / 2;
570
+ const y = cy + uy * r.height / 2 - origin.top - size / 2;
571
+ const mid = `translate(${ux * reach * 0.75}px, ${uy * reach * 0.75}px)`;
572
+ const end = `translate(${ux * reach}px, ${uy * reach + fall}px) scale(.4)`;
573
+ add(
574
+ `left:${x}px;top:${y}px;width:${size}px;height:${size}px;border-radius:50%;background:radial-gradient(circle at 35% 35%, rgb(255 255 255 / .95), rgb(255 255 255 / .35) 65%, rgb(255 255 255 / .1))`,
575
+ [
576
+ { opacity: 1, transform: "translate(0, 0)", easing: "cubic-bezier(.1,.8,.3,1)" },
577
+ { opacity: 0.9, transform: mid, offset: 0.35, easing: "cubic-bezier(.4,0,.8,.6)" },
578
+ { opacity: 0, transform: end }
579
+ ],
580
+ { duration: 520 + Math.random() * 260 }
581
+ );
582
+ }
583
+ }
584
+ #frame(now) {
585
+ const dt = Math.min((now - this.last) / 1e3, 1 / 30);
586
+ this.last = now;
587
+ let animating = false;
588
+ if (!this.anchor) {
589
+ if (!this.reducedMotion) {
590
+ const vx = Math.max(Math.abs(this.x.velocity) - 150, 0);
591
+ const vy = Math.max(Math.abs(this.y.velocity) - 150, 0);
592
+ const k = this.params.stretch ?? 1;
593
+ this.stretchX.target = 1 + k * (Math.min(vx / 5e3, 0.22) - Math.min(vy / 9e3, 0.1));
594
+ this.stretchY.target = 1 + k * (Math.min(vy / 5e3, 0.22) - Math.min(vx / 9e3, 0.1));
595
+ }
596
+ animating = this.springs.some((s) => !s.settled);
597
+ for (const s of this.springs) {
598
+ s.step(dt);
599
+ if (s.settled) s.snap(s.target);
600
+ }
601
+ if (this.pop) {
602
+ this.#popFrame(now);
603
+ animating = true;
604
+ }
605
+ }
606
+ if (this.button && (!this.hovered.settled || !this.pressed.settled)) {
607
+ for (const s of [this.hovered, this.pressed]) {
608
+ if (this.reducedMotion) s.snap(s.target);
609
+ else s.step(dt);
610
+ }
611
+ this.#applyButton();
612
+ }
613
+ const sync = this.copyDirty && this.#shown && now - this.syncedAt >= this.throttle;
614
+ const changed = sync || this.shapeDirty || this.rectDirty;
615
+ if (sync) {
616
+ this.#syncCopy();
617
+ this.syncedAt = now;
618
+ } else if (this.rectDirty) {
619
+ this.sourceRect = this.source.getBoundingClientRect();
620
+ this.containerRect = this.container?.getBoundingClientRect();
621
+ }
622
+ this.rectDirty = false;
623
+ if (this.shapeDirty && this.params.width >= 1 && this.params.height >= 1) this.#buildShape();
624
+ if (this.inner && this.sourceRect && (animating || changed || this.placeDirty)) this.#place();
625
+ this.placeDirty = animating;
626
+ const buttonMoving = this.button && (!this.hovered.settled || !this.pressed.settled);
627
+ const waiting = this.shapeDirty || this.rectDirty || this.placeDirty || this.copyDirty && this.#shown || !this.inner;
628
+ if (animating || buttonMoving || waiting) this.#raf = requestAnimationFrame((t) => this.#frame(t));
629
+ else this.#awake = false;
630
+ }
631
+ }
632
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
633
+ const LIFT = { stiffness: 632, damping: 50.3 };
634
+ const MOVE = { stiffness: 241, damping: 26.4 };
635
+ const TRAIL = { stiffness: 576, damping: 36 };
636
+ const HANG = 220;
637
+ const springs = (lens, move = MOVE) => {
638
+ lens.scale.stiffness = LIFT.stiffness;
639
+ lens.scale.damping = LIFT.damping;
640
+ lens.x.stiffness = lens.y.stiffness = move.stiffness;
641
+ lens.x.damping = lens.y.damping = move.damping;
642
+ };
643
+ const arrived = (lens) => Math.abs(lens.x.value - lens.x.target) <= 0.5 && Math.abs(lens.x.velocity) <= 30;
644
+ const CLEAR = { frost: 1, fresnel: 0.15, saturation: 0, tint: "", rim: 0.45, body: 0, falloff: 0, lightAngle: -123.5 };
645
+ const LOOKS = {
646
+ switch: {
647
+ dark: { ...CLEAR, refraction: 25, bevel: 6, chroma: 0.21, zoom: 1.12, specular: 0.25, brightness: 0.03 },
648
+ light: { ...CLEAR, refraction: 20, bevel: 5, chroma: 0.12, zoom: 1.1, specular: 0.55, brightness: 0 }
649
+ },
650
+ // UIKit's rim profile: the bend hardest at the very edge, easing off by e every 4.5px
651
+ tabs: {
652
+ dark: { ...CLEAR, refraction: 28, bevel: 5, falloff: 4.5, chroma: 0.1, zoom: 1.1, specular: 0.25, brightness: 0.03, lightAngle: -140 },
653
+ light: { ...CLEAR, refraction: 28, bevel: 5, falloff: 4.5, chroma: 0.1, zoom: 1.1, specular: 0.55, brightness: 0, lightAngle: -140 }
654
+ },
655
+ // A little milk and a brighter rim, so a resting button reads on a flat background
656
+ button: {
657
+ dark: { ...CLEAR, refraction: 28, bevel: 5, chroma: 0.12, zoom: 1.08, specular: 0.25, brightness: 0.03, rim: 0.6, body: 0.12 },
658
+ light: { ...CLEAR, refraction: 28, bevel: 5, chroma: 0.12, zoom: 1.08, specular: 0.55, brightness: 0, rim: 0.6, body: 0.12 }
659
+ }
660
+ };
661
+ const listener = (signal) => (target, type, fn) => target.addEventListener(type, fn, { signal });
662
+ const themeOf = (el) => el.closest("[data-theme]")?.dataset.theme === "light" ? "light" : "dark";
663
+ function addGlow(lens) {
664
+ const glow = document.createElement("span");
665
+ glow.className = "lg-glow";
666
+ lens.el.append(glow);
667
+ return (e) => {
668
+ const r = lens.el.getBoundingClientRect();
669
+ const k = r.width / lens.el.offsetWidth;
670
+ glow.style.left = `${(e.clientX - r.left) / k}px`;
671
+ glow.style.top = `${(e.clientY - r.top) / k}px`;
672
+ };
673
+ }
674
+ class GlassSwitch {
675
+ constructor(root, { grow = 1.55, press = 1.08, lean = 4, look = LOOKS.switch[themeOf(root)] } = {}) {
676
+ this.root = root;
677
+ this.grow = grow;
678
+ this.press = press;
679
+ this.lean = lean;
680
+ this.look = look;
681
+ this.thumb = root.querySelector(":scope > .thumb");
682
+ this.bar = root.querySelector(":scope > .refract");
683
+ this.options = [...root.querySelectorAll(":scope > [data-opt]")];
684
+ this.isSwitch = root.getAttribute("role") === "switch";
685
+ this.value = this.isSwitch ? Number(root.getAttribute("aria-checked") === "true") : Math.max(0, this.options.findIndex((o) => o.getAttribute("aria-checked") === "true"));
686
+ this.up = false;
687
+ this.hovered = false;
688
+ this.pressed = false;
689
+ this.drag = null;
690
+ this.frame = 0;
691
+ this.abort = new AbortController();
692
+ this.lens = new LiquidGlass(this.bar, { container: root, ...look });
693
+ springs(this.lens);
694
+ this.glowAt = addGlow(this.lens);
695
+ root.classList.add("glassy");
696
+ this.size();
697
+ this.lens.scale.snap(1 / (grow * press));
698
+ this.put(this.centre(this.value));
699
+ this.#events();
700
+ }
701
+ centre(i) {
702
+ const o = this.options[i];
703
+ return o.offsetLeft + o.offsetWidth / 2;
704
+ }
705
+ // The thumb's centre, in the root's padding box; --p is how far across, for the colour
706
+ put(x) {
707
+ this.at = x;
708
+ this.thumb.style.translate = `${x - this.thumb.offsetLeft - this.thumb.offsetWidth / 2}px`;
709
+ this.root.style.setProperty("--p", clamp((x - this.centre(0)) / (this.centre(1) - this.centre(0)), 0, 1));
710
+ }
711
+ // The lens is built at its pressed size and scaled down from there. It reaches past the
712
+ // thumb's outer end by its width × grow, as iOS's does, but past its inner end only as
713
+ // far as above and below, so a wide thumb's lens doesn't spill over the other label: it
714
+ // sits `shift` px off the thumb's centre, towards the outside. The bar's ends are cut
715
+ // for the lifted (unpressed) size, with the gaps measured magnified, as the glass shows
716
+ // them.
717
+ size() {
718
+ const tw = this.thumb.offsetWidth;
719
+ const th = this.thumb.offsetHeight;
720
+ const h = th * this.grow;
721
+ const wide = tw * this.grow;
722
+ const w = Math.min(wide, (wide + tw + h - th) / 2);
723
+ this.shift = (wide - w) / 2;
724
+ this.lens.set({ width: w * this.press, height: h * this.press, radius: h * this.press / 2 });
725
+ const gap = h - this.bar.offsetHeight * this.look.zoom;
726
+ const reach = (w - gap) / 2 / this.look.zoom;
727
+ const left = this.lensAt(this.centre(0)) - reach;
728
+ this.bar.style.left = `${left}px`;
729
+ this.bar.style.width = `${this.lensAt(this.centre(1)) + reach - left}px`;
730
+ }
731
+ // Between the lens's centre and the thumb's: shifted outward at either end, in between
732
+ // in proportion
733
+ lensAt(x) {
734
+ const c0 = this.centre(0);
735
+ const d = this.centre(1) - c0;
736
+ return x + this.shift * (2 * clamp((x - c0) / d, 0, 1) - 1);
737
+ }
738
+ thumbAt(x) {
739
+ const c0 = this.centre(0);
740
+ const k = 2 * this.shift / (this.centre(1) - c0);
741
+ return clamp((x + this.shift + k * c0) / (1 + k), c0, this.centre(1));
742
+ }
743
+ select(i) {
744
+ if (i === this.value) return;
745
+ this.value = i;
746
+ if (this.isSwitch) this.root.setAttribute("aria-checked", String(i === 1));
747
+ else this.options.forEach((o, j) => o.setAttribute("aria-checked", String(i === j)));
748
+ this.lift();
749
+ this.lens.x.target = this.lensAt(this.centre(i));
750
+ this.root.dispatchEvent(new CustomEvent("change", { detail: { value: i }, bubbles: true }));
751
+ }
752
+ // Dissolve the thumb into glass; the thumb follows it until it's been still a moment
753
+ lift() {
754
+ const lens = this.lens;
755
+ if (!this.up) {
756
+ this.up = true;
757
+ lens.x.snap(this.lensAt(this.at));
758
+ lens.y.snap(this.thumb.offsetTop + this.thumb.offsetHeight / 2);
759
+ lens.show(true);
760
+ lens.el.style.transition = "opacity 0.18s";
761
+ this.root.classList.add("lifted");
762
+ }
763
+ lens.scale.target = this.pressed ? 1 : 1 / this.press;
764
+ cancelAnimationFrame(this.frame);
765
+ let still = 0;
766
+ const tick = (now) => {
767
+ this.put(this.thumbAt(lens.x.value));
768
+ if (this.drag || this.hovered || this.pressed || !arrived(lens)) still = 0;
769
+ else if (!still) still = now;
770
+ if (still && now - still > HANG) this.drop();
771
+ else this.frame = requestAnimationFrame(tick);
772
+ };
773
+ this.frame = requestAnimationFrame(tick);
774
+ }
775
+ // Shrink back to the thumb as it fades back in over the glass
776
+ drop() {
777
+ if (!this.up) return;
778
+ this.up = false;
779
+ this.lens.scale.target = 1 / (this.grow * this.press);
780
+ this.lens.show(false);
781
+ this.lens.el.style.transition = "opacity 0.3s 0.08s";
782
+ this.root.classList.remove("lifted");
783
+ }
784
+ setPressed(on, e) {
785
+ this.pressed = on;
786
+ this.root.classList.toggle("pressing", on);
787
+ if (on) this.glowAt(e);
788
+ if (on || this.up) this.lift();
789
+ }
790
+ // Stops it and removes its lens; the markup is left as it was found
791
+ destroy() {
792
+ cancelAnimationFrame(this.frame);
793
+ this.abort.abort();
794
+ this.lens.destroy();
795
+ this.root.classList.remove("glassy", "lifted", "pressing");
796
+ this.root.style.removeProperty("--p");
797
+ this.thumb.style.translate = "";
798
+ this.bar.style.left = this.bar.style.width = "";
799
+ }
800
+ #events() {
801
+ const root = this.root;
802
+ const on = listener(this.abort.signal);
803
+ let dragged = false;
804
+ on(root, "click", () => {
805
+ if (dragged) dragged = false;
806
+ else this.select(1 - this.value);
807
+ });
808
+ on(root, "pointerenter", (e) => {
809
+ if (e.pointerType !== "mouse") return;
810
+ this.hovered = true;
811
+ this.lift();
812
+ });
813
+ on(root, "pointerleave", () => {
814
+ this.hovered = false;
815
+ if (!this.drag) this.lens.x.target = this.lensAt(this.centre(this.value));
816
+ else if (!this.drag.moved) {
817
+ this.drag = null;
818
+ this.setPressed(false);
819
+ }
820
+ });
821
+ on(root, "pointerdown", (e) => {
822
+ if (e.button !== 0) return;
823
+ this.drag = { id: e.pointerId, x: e.clientX, from: this.at, moved: false };
824
+ this.setPressed(true, e);
825
+ });
826
+ on(root, "pointermove", (e) => {
827
+ const d = this.drag;
828
+ const lens = this.lens;
829
+ if (this.pressed) this.glowAt(e);
830
+ if (!d) {
831
+ if (!this.hovered) return;
832
+ const home = this.centre(this.value);
833
+ const x = e.clientX - root.getBoundingClientRect().left - root.clientLeft;
834
+ const half = this.options[this.value].offsetWidth / 2;
835
+ lens.x.target = this.lensAt(home) + clamp((x - home) / half, -1, 1) * this.lean;
836
+ return;
837
+ }
838
+ const dx = e.clientX - d.x;
839
+ if (!d.moved && Math.abs(dx) > 4) {
840
+ d.moved = true;
841
+ root.setPointerCapture(d.id);
842
+ }
843
+ if (d.moved) lens.x.target = this.lensAt(clamp(d.from + dx, this.centre(0), this.centre(1)));
844
+ });
845
+ on(root, "pointerup", () => {
846
+ const d = this.drag;
847
+ this.drag = null;
848
+ this.setPressed(false);
849
+ if (!d?.moved) return;
850
+ dragged = true;
851
+ setTimeout(() => dragged = false);
852
+ const lens = this.lens;
853
+ const ahead = this.thumbAt(lens.x.value + lens.x.velocity * 0.12);
854
+ const side = ahead > (this.centre(0) + this.centre(1)) / 2 ? 1 : 0;
855
+ if (side === this.value) {
856
+ this.lift();
857
+ lens.x.target = this.lensAt(this.centre(side));
858
+ } else this.select(side);
859
+ });
860
+ on(root, "pointercancel", () => {
861
+ this.drag = null;
862
+ this.setPressed(false);
863
+ this.lens.x.target = this.lensAt(this.centre(this.value));
864
+ });
865
+ on(window, "resize", () => {
866
+ this.size();
867
+ if (!this.up) this.put(this.centre(this.value));
868
+ });
869
+ }
870
+ }
871
+ class GlassTabBar {
872
+ constructor(root, { grow = 1.3, press = 1.1, look = LOOKS.tabs[themeOf(root)] } = {}) {
873
+ this.root = root;
874
+ this.grow = grow;
875
+ this.press = press;
876
+ this.tabs = [...root.querySelectorAll(":scope > a, :scope > button")];
877
+ this.pill = root.querySelector(":scope > .lg-pill");
878
+ this.glow = root.querySelector(".lg-glow");
879
+ this.pressedId = null;
880
+ this.frame = 0;
881
+ this.abort = new AbortController();
882
+ this.lens = new LiquidGlass(root, { container: root, ...look });
883
+ springs(this.lens);
884
+ this.placePill();
885
+ this.#events();
886
+ }
887
+ get current() {
888
+ return this.tabs.find((t) => t.hasAttribute("aria-current")) ?? null;
889
+ }
890
+ centre(t) {
891
+ return t.offsetLeft + t.offsetWidth / 2;
892
+ }
893
+ choose(tab) {
894
+ if (tab === this.current) return;
895
+ this.tabs.forEach((t) => t.removeAttribute("aria-current"));
896
+ tab.setAttribute("aria-current", "page");
897
+ this.placePill();
898
+ this.root.dispatchEvent(new CustomEvent("change", { detail: { tab }, bubbles: true }));
899
+ }
900
+ placePill() {
901
+ const t = this.current;
902
+ this.pill.hidden = !t;
903
+ if (!t) return;
904
+ Object.assign(this.pill.style, {
905
+ left: `${t.offsetLeft}px`,
906
+ top: `${t.offsetTop}px`,
907
+ width: `${t.offsetWidth}px`,
908
+ height: `${t.offsetHeight}px`
909
+ });
910
+ }
911
+ // Lift the lens over a tab: swelling in there if it's down, sliding there if it's up.
912
+ // It outgrows the tab by 8px a side, and bulges `press` more while pressed (it's built
913
+ // at that size and scaled down from there).
914
+ lift(tab) {
915
+ const lens = this.lens;
916
+ cancelAnimationFrame(this.frame);
917
+ const h = (this.root.offsetHeight + 8) * this.press;
918
+ lens.set({ width: (tab.offsetWidth + 16) * this.press, height: h, radius: h / 2 });
919
+ const x = this.centre(tab);
920
+ const y = tab.offsetTop + tab.offsetHeight / 2;
921
+ if (!lens.visible) {
922
+ lens.x.snap(x);
923
+ lens.y.snap(y);
924
+ lens.scale.snap(1 / (this.grow * this.press));
925
+ lens.show(true);
926
+ lens.el.style.transition = "opacity 0.18s";
927
+ this.root.classList.add("lensing");
928
+ }
929
+ this.bulge();
930
+ lens.x.target = x;
931
+ lens.y.target = y;
932
+ }
933
+ bulge() {
934
+ this.lens.scale.target = this.pressedId === null ? 1 / this.press : 1;
935
+ }
936
+ drop() {
937
+ this.root.classList.remove("lensing");
938
+ this.lens.scale.target = 1 / (this.grow * this.press);
939
+ this.lens.show(false);
940
+ this.lens.el.style.transition = "opacity 0.3s 0.08s";
941
+ }
942
+ // Once the lens has arrived, hang a moment, then set it down
943
+ settle(hang) {
944
+ const lens = this.lens;
945
+ cancelAnimationFrame(this.frame);
946
+ let still = 0;
947
+ const tick = (now) => {
948
+ if (!arrived(lens)) still = 0;
949
+ else if (!still) still = now;
950
+ if (still && now - still > hang) this.drop();
951
+ else this.frame = requestAnimationFrame(tick);
952
+ };
953
+ this.frame = requestAnimationFrame(tick);
954
+ }
955
+ destroy() {
956
+ cancelAnimationFrame(this.frame);
957
+ this.abort.abort();
958
+ this.lens.destroy();
959
+ this.root.classList.remove("lensing", "pressing");
960
+ }
961
+ #events() {
962
+ const root = this.root;
963
+ const on = listener(this.abort.signal);
964
+ const local = (e) => e.clientX - root.getBoundingClientRect().left - root.clientLeft;
965
+ const clampX = (x) => clamp(x, this.centre(this.tabs[0]), this.centre(this.tabs.at(-1)));
966
+ const tabAt = (x) => this.tabs.reduce((a, b) => Math.abs(this.centre(b) - x) < Math.abs(this.centre(a) - x) ? b : a);
967
+ const glowAt = (e) => {
968
+ const r = root.getBoundingClientRect();
969
+ this.glow.style.left = `${e.clientX - r.left}px`;
970
+ this.glow.style.top = `${e.clientY - r.top}px`;
971
+ };
972
+ this.tabs.forEach((t) => on(t, "click", () => this.choose(t)));
973
+ on(root, "pointerover", (e) => {
974
+ const tab = e.target.closest("a, button");
975
+ if (e.pointerType === "mouse" && this.pressedId === null && tab) this.lift(tab);
976
+ });
977
+ on(root, "pointerleave", (e) => {
978
+ if (e.pointerType === "mouse" && this.pressedId === null) this.settle(0);
979
+ });
980
+ let press = null;
981
+ on(root, "pointerdown", (e) => {
982
+ if (e.button !== 0) return;
983
+ e.preventDefault();
984
+ root.setPointerCapture(e.pointerId);
985
+ this.pressedId = e.pointerId;
986
+ const tab = e.target.closest("a, button") ?? tabAt(local(e));
987
+ press = { x: e.clientX, tab, dragging: false };
988
+ this.lift(tab);
989
+ springs(this.lens, TRAIL);
990
+ glowAt(e);
991
+ root.classList.add("pressing");
992
+ });
993
+ on(root, "pointermove", (e) => {
994
+ if (e.pointerId !== this.pressedId) return;
995
+ glowAt(e);
996
+ const dx = e.clientX - press.x;
997
+ if (!press.dragging && Math.abs(dx) > 6) press.dragging = true;
998
+ if (press.dragging) this.lens.x.target = clampX(this.centre(press.tab) + dx);
999
+ });
1000
+ const release = (e, pick) => {
1001
+ if (e.pointerId !== this.pressedId) return;
1002
+ this.pressedId = null;
1003
+ root.classList.remove("pressing");
1004
+ springs(this.lens);
1005
+ this.bulge();
1006
+ if (!pick) return this.settle(0);
1007
+ const tab = press.dragging ? tabAt(this.lens.x.target) : press.tab;
1008
+ this.lens.x.target = this.centre(tab);
1009
+ tab.click();
1010
+ if (!(e.pointerType === "mouse" && root.matches(":hover"))) this.settle(HANG);
1011
+ };
1012
+ on(root, "pointerup", (e) => release(e, true));
1013
+ on(root, "pointercancel", (e) => release(e, false));
1014
+ on(window, "resize", () => this.placePill());
1015
+ }
1016
+ }
1017
+ class GlassButton {
1018
+ constructor(button, {
1019
+ container = button.parentElement,
1020
+ source = container.querySelector(":scope > [data-glass-source]"),
1021
+ grow = 1.1,
1022
+ press = 1.08,
1023
+ lit = 0.95,
1024
+ hang = 120,
1025
+ look = LOOKS.button[themeOf(button)]
1026
+ } = {}) {
1027
+ if (!source) throw new Error("GlassButton: no source to refract. Give the button's parent a [data-glass-source] child, or pass { source }.");
1028
+ this.btn = button;
1029
+ this.container = container;
1030
+ this.grow = grow;
1031
+ this.press = press;
1032
+ this.hang = hang;
1033
+ this.hovered = false;
1034
+ this.pressed = false;
1035
+ this.up = false;
1036
+ this.frame = 0;
1037
+ this.abort = new AbortController();
1038
+ const on = listener(this.abort.signal);
1039
+ button.style.setProperty("--lit-max", lit);
1040
+ const clip = document.createElement("span");
1041
+ clip.className = "lg-clip";
1042
+ clip.setAttribute("aria-hidden", "true");
1043
+ const glow = document.createElement("span");
1044
+ glow.className = "lg-glow";
1045
+ clip.append(glow);
1046
+ button.prepend(clip);
1047
+ this.clip = clip;
1048
+ this.lens = new LiquidGlass(source, { container, ...look });
1049
+ this.lens.el.classList.add("lg-button-lens");
1050
+ springs(this.lens);
1051
+ this.size();
1052
+ this.lens.scale.snap(1 / this.full);
1053
+ this.lens.show(true);
1054
+ this.track();
1055
+ const glowAt = (e) => {
1056
+ const r = button.getBoundingClientRect();
1057
+ const k = r.width / button.offsetWidth;
1058
+ glow.style.left = `${(e.clientX - r.left) / k}px`;
1059
+ glow.style.top = `${(e.clientY - r.top) / k}px`;
1060
+ };
1061
+ on(button, "pointerenter", (e) => {
1062
+ if (e.pointerType !== "mouse") return;
1063
+ this.hovered = true;
1064
+ glowAt(e);
1065
+ button.classList.add("hovering");
1066
+ this.lift();
1067
+ });
1068
+ on(button, "pointerleave", () => {
1069
+ this.hovered = false;
1070
+ button.classList.remove("hovering");
1071
+ this.pressed = false;
1072
+ this.lift();
1073
+ this.settle();
1074
+ });
1075
+ on(button, "pointerdown", (e) => {
1076
+ if (e.button !== 0) return;
1077
+ this.pressed = true;
1078
+ glowAt(e);
1079
+ this.lift();
1080
+ });
1081
+ on(button, "pointermove", (e) => (this.pressed || this.hovered) && glowAt(e));
1082
+ const up = () => {
1083
+ if (!this.pressed) return;
1084
+ this.pressed = false;
1085
+ this.lift();
1086
+ this.settle();
1087
+ };
1088
+ on(button, "pointerup", up);
1089
+ on(button, "pointercancel", up);
1090
+ on(button, "focus", () => button.matches(":focus-visible") && this.lift());
1091
+ on(button, "blur", () => this.settle(0));
1092
+ on(button, "keydown", (e) => {
1093
+ if (e.key !== " " && e.key !== "Enter") return;
1094
+ this.pressed = true;
1095
+ this.lift();
1096
+ });
1097
+ on(button, "keyup", () => {
1098
+ this.pressed = false;
1099
+ this.lift();
1100
+ });
1101
+ on(window, "resize", () => this.size());
1102
+ }
1103
+ destroy() {
1104
+ cancelAnimationFrame(this.frame);
1105
+ cancelAnimationFrame(this.settling);
1106
+ this.abort.abort();
1107
+ this.lens.destroy();
1108
+ this.clip.remove();
1109
+ this.btn.classList.remove("hovering", "pressing");
1110
+ for (const p of ["--lit-max", "--s", "--lit"]) this.btn.style.removeProperty(p);
1111
+ }
1112
+ // The lens is built at its pressed size; rest, hover and press scale it down from there
1113
+ get full() {
1114
+ return this.grow * this.press;
1115
+ }
1116
+ size() {
1117
+ const b = this.btn.getBoundingClientRect();
1118
+ const c = this.container.getBoundingClientRect();
1119
+ const w = this.btn.offsetWidth * this.full;
1120
+ const h = this.btn.offsetHeight * this.full;
1121
+ this.lens.set({ width: w, height: h, radius: h / 2 });
1122
+ this.lens.x.snap(b.left + b.width / 2 - c.left - this.container.clientLeft);
1123
+ this.lens.y.snap(b.top + b.height / 2 - c.top - this.container.clientTop);
1124
+ }
1125
+ lift() {
1126
+ cancelAnimationFrame(this.settling);
1127
+ this.up = true;
1128
+ this.lens.scale.target = this.pressed ? 1 : 1 / this.press;
1129
+ this.btn.classList.toggle("pressing", this.pressed);
1130
+ this.track();
1131
+ }
1132
+ // Once nothing holds it up, hang a moment, then shrink back to clear glass
1133
+ settle(hang = this.hang) {
1134
+ if (!this.up || this.hovered || this.pressed || this.btn.matches(":focus-visible")) return;
1135
+ cancelAnimationFrame(this.settling);
1136
+ const start = performance.now();
1137
+ const tick = (now) => {
1138
+ if (this.hovered || this.pressed) return;
1139
+ if (now - start < hang) this.settling = requestAnimationFrame(tick);
1140
+ else {
1141
+ this.up = false;
1142
+ this.lens.scale.target = 1 / this.full;
1143
+ this.track();
1144
+ }
1145
+ };
1146
+ this.settling = requestAnimationFrame(tick);
1147
+ }
1148
+ // While the lens grows or shrinks, the tint and content follow its size
1149
+ track() {
1150
+ cancelAnimationFrame(this.frame);
1151
+ const tick = () => {
1152
+ const s = this.lens.scale.value * this.full;
1153
+ this.btn.style.setProperty("--s", s.toFixed(4));
1154
+ this.btn.style.setProperty("--lit", clamp((s - 1) / (this.grow - 1 || 1), 0, 1).toFixed(4));
1155
+ if (!this.lens.scale.settled) this.frame = requestAnimationFrame(tick);
1156
+ };
1157
+ this.frame = requestAnimationFrame(tick);
1158
+ }
1159
+ }
1160
+ function initComponents(root = document) {
1161
+ return [
1162
+ ...[...root.querySelectorAll("[data-glass-switch]")].map((el) => new GlassSwitch(el)),
1163
+ ...[...root.querySelectorAll("[data-glass-tabs]")].map((el) => new GlassTabBar(el)),
1164
+ ...[...root.querySelectorAll("[data-glass-button]")].map((el) => new GlassButton(el))
1165
+ ];
1166
+ }
1167
+ exports.GlassButton = GlassButton;
1168
+ exports.GlassSwitch = GlassSwitch;
1169
+ exports.GlassTabBar = GlassTabBar;
1170
+ exports.LOOKS = LOOKS;
1171
+ exports.LiquidGlass = LiquidGlass;
1172
+ exports.initComponents = initComponents;