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