Packages
noora
0.82.6
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
js/IconTransition.js
// Animated icon transitions for the `.icon` component. Two strategies share
// one hook and one state-watcher:
// - "morph": sample both icon outlines into point rings and tween the path
// (flubber-style). Only valid for filled, compatible icons.
// - "crossfade_rotate": toggle a `data-active` attribute; CSS crossfades and
// rotates the two stacked icons. Works for any icons.
// The hook watches the `data-state` of an ancestor (the trigger by default) via
// an observer bound to the stable hook root, so it survives LiveView patches.
const N = 64;
const SVGNS = "http://www.w3.org/2000/svg";
const ringCache = new Map();
let _measure = null;
function measurer() {
if (!_measure) {
const svg = document.createElementNS(SVGNS, "svg");
svg.setAttribute("width", "0");
svg.setAttribute("height", "0");
svg.style.cssText = "position:absolute;left:-9999px;visibility:hidden";
const p = document.createElementNS(SVGNS, "path");
svg.appendChild(p);
document.body.appendChild(svg);
_measure = p;
}
return _measure;
}
const splitSubpaths = (d) => d.match(/M[^M]*/g) || [d];
function sampleSubpath(d, n) {
const p = measurer();
p.setAttribute("d", d);
let len = 0;
try {
len = p.getTotalLength();
} catch (e) {
len = 0;
}
const pts = new Array(n);
if (len === 0) {
let pt;
try {
pt = p.getPointAtLength(0);
} catch (e) {
pt = { x: 12, y: 12 };
}
for (let i = 0; i < n; i++) pts[i] = [pt.x, pt.y];
return pts;
}
for (let i = 0; i < n; i++) {
const pt = p.getPointAtLength((i / n) * len);
pts[i] = [pt.x, pt.y];
}
return pts;
}
function ringsFor(d) {
let r = ringCache.get(d);
if (r) return r;
r = splitSubpaths(d).map((s) => sampleSubpath(s, N));
ringCache.set(d, r);
return r;
}
const cOf = (ring) => {
let x = 0;
let y = 0;
for (const p of ring) {
x += p[0];
y += p[1];
}
return [x / ring.length, y / ring.length];
};
const iconCentroid = (rings) => {
let x = 0;
let y = 0;
for (const r of rings) {
const c = cOf(r);
x += c[0];
y += c[1];
}
return [x / rings.length, y / rings.length];
};
function pad(rings, count, c) {
const out = rings.map((r) => r.map((p) => p.slice()));
while (out.length < count) {
const deg = new Array(N);
for (let i = 0; i < N; i++) deg[i] = c.slice();
out.push(deg);
}
return out;
}
// Mean distance of a ring's points from its centroid — a scale signature for matching.
const sizeOf = (ring, c) => {
let s = 0;
for (const p of ring) {
const dx = p[0] - c[0];
const dy = p[1] - c[1];
s += Math.sqrt(dx * dx + dy * dy);
}
return s / ring.length;
};
// Pairs rings between the two icons. Centroid distance alone mispairs concentric subpaths —
// e.g. an arrow inside a boundary ring sits closer to the (identically centered) boundary of the
// other icon than to its own moved counterpart — so ring size is part of the cost, which keeps
// small glyphs matched to small glyphs and boundaries to boundaries.
function matchRings(A, B) {
const used = new Array(B.length).fill(false);
const bInfo = B.map((rb) => {
const c = cOf(rb);
return { c, size: sizeOf(rb, c) };
});
const order = [];
for (const ra of A) {
const ca = cOf(ra);
const sa = sizeOf(ra, ca);
let best = -1;
let bd = Infinity;
for (let j = 0; j < B.length; j++) {
if (used[j]) continue;
const dx = ca[0] - bInfo[j].c[0];
const dy = ca[1] - bInfo[j].c[1];
const ds = sa - bInfo[j].size;
const dd = dx * dx + dy * dy + ds * ds;
if (dd < bd) {
bd = dd;
best = j;
}
}
used[best] = true;
order.push(B[best]);
}
return order;
}
function bestAlign(avecs, bvecs, allowReverse) {
let best = { off: 0, rev: false, cost: Infinity };
const search = (bv, rev) => {
for (let off = 0; off < N; off++) {
let cost = 0;
for (let k = 0; k < N; k++) {
const a = avecs[k];
const b = bv[(k + off) % N];
const dx = a[0] - b[0];
const dy = a[1] - b[1];
cost += dx * dx + dy * dy;
}
if (cost < best.cost) best = { off, rev, cost };
}
};
search(bvecs, false);
if (allowReverse) search(bvecs.slice().reverse(), true);
return best;
}
function buildMorph(A0, B0) {
const R = Math.max(A0.length, B0.length);
const A = pad(A0, R, iconCentroid(A0));
const B = matchRings(A, pad(B0, R, iconCentroid(B0)));
const allowReverse = R === 1;
const rings = [];
for (let i = 0; i < R; i++) {
const a = A[i];
const braw = B[i];
const cA = cOf(a);
const cB = cOf(braw);
const avecs = new Array(N);
const bvecs = new Array(N);
let aa = 0;
for (let k = 0; k < N; k++) {
avecs[k] = [a[k][0] - cA[0], a[k][1] - cA[1]];
bvecs[k] = [braw[k][0] - cB[0], braw[k][1] - cB[1]];
aa += avecs[k][0] * avecs[k][0] + avecs[k][1] * avecs[k][1];
}
const al =
aa > 1e-6
? bestAlign(avecs, bvecs, allowReverse)
: { off: 0, rev: false };
const bv = al.rev ? bvecs.slice().reverse() : bvecs;
const res = new Array(N);
for (let k = 0; k < N; k++) {
const bk = bv[(k + al.off) % N];
res[k] = [bk[0] - avecs[k][0], bk[1] - avecs[k][1]];
}
rings.push({ a: avecs, cA, cB, res });
}
return rings;
}
function ringsAt(descs, t) {
return descs.map((r) => {
const cx = r.cA[0] + (r.cB[0] - r.cA[0]) * t;
const cy = r.cA[1] + (r.cB[1] - r.cA[1]) * t;
const out = new Array(N);
for (let i = 0; i < N; i++) {
out[i] = [
cx + r.a[i][0] + t * r.res[i][0],
cy + r.a[i][1] + t * r.res[i][1],
];
}
return out;
});
}
const r2 = (v) => Math.round(v * 100) / 100;
function ringsToPath(rings) {
let d = "";
for (const ring of rings) {
d += "M" + r2(ring[0][0]) + " " + r2(ring[0][1]);
for (let i = 1; i < ring.length; i++)
d += "L" + r2(ring[i][0]) + " " + r2(ring[i][1]);
d += "Z";
}
return d;
}
const copyRings = (rings) => rings.map((r) => r.map((p) => p.slice()));
export default {
mounted() {
this.transition = this.el.dataset.transition || "morph";
this.activeState = this.el.dataset.activeState || "open";
this.watchSel = this.el.dataset.watch || '[data-part="trigger"]';
this.reduced =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Observe a stable ancestor (the LiveView hook root), not the trigger,
// which LiveView can replace on a patch and silently detach the observer.
this.rootEl =
(this.el.parentElement && this.el.parentElement.closest("[phx-hook]")) ||
this.el.parentElement ||
this.el;
if (this.transition === "morph") this.setupMorph();
this.lastActive = this.isActive();
if (this.lastActive) {
this.el.setAttribute("data-active", "");
if (this.transition === "morph" && this.toRings) {
this.disp = copyRings(this.toRings);
this.pathEl.setAttribute("d", ringsToPath(this.disp));
}
}
this.observer = new MutationObserver(() => this.sync());
this.observer.observe(this.rootEl, {
attributes: true,
subtree: true,
attributeFilter: ["data-state"],
});
// Enable CSS transitions only after the initial state has painted, so a
// state established during mount (e.g. a sibling collapsible opening)
// lands without animating. Double rAF clears the first paint reliably.
this.readyRaf = requestAnimationFrame(() => {
this.readyRaf = requestAnimationFrame(() => {
this.el.setAttribute("data-ready", "");
});
});
},
setupMorph() {
this.pathEl = this.el.querySelector("path");
this.fromD = this.el.dataset.morphFrom;
this.toD = this.el.dataset.morphTo;
if (!this.pathEl || !this.fromD || !this.toD) {
this.transition = "none";
return;
}
this.fromRings = ringsFor(this.fromD);
this.toRings = ringsFor(this.toD);
this.disp = null;
this.anim = null;
this.raf = 0;
},
isActive() {
const trigger = this.el.closest(this.watchSel);
return !!trigger && trigger.getAttribute("data-state") === this.activeState;
},
sync() {
const active = this.isActive();
if (active === this.lastActive) return;
this.lastActive = active;
if (active) this.el.setAttribute("data-active", "");
else this.el.removeAttribute("data-active");
if (this.transition === "morph") {
this.morphTo(active ? this.toRings : this.fromRings);
}
},
morphTo(targetRings) {
if (!this.pathEl) return;
if (this.reduced) {
this.disp = copyRings(targetRings);
this.pathEl.setAttribute("d", ringsToPath(this.disp));
this.anim = null;
return;
}
const fromRings = this.disp || this.fromRings;
const rings = buildMorph(copyRings(fromRings), targetRings);
this.anim = { p: 0, v: 0, rings };
const start = ringsAt(rings, 0);
this.disp = start;
this.pathEl.setAttribute("d", ringsToPath(start));
this.loop();
},
loop() {
cancelAnimationFrame(this.raf);
let last = performance.now();
const step = (now) => {
const a = this.anim;
if (!a) return;
const dt = Math.min((now - last) / 1000, 0.032);
last = now;
a.v += (-650 * (a.p - 1) - 51 * a.v) * dt;
a.p += a.v * dt;
const done = Math.abs(a.p - 1) < 0.001 && Math.abs(a.v) < 0.02;
const p = done ? 1 : a.p;
const rings = ringsAt(a.rings, p);
this.disp = rings;
this.pathEl.setAttribute("d", ringsToPath(rings));
if (done) {
this.anim = null;
return;
}
this.raf = requestAnimationFrame(step);
};
this.raf = requestAnimationFrame(step);
},
cleanup() {
cancelAnimationFrame(this.raf);
cancelAnimationFrame(this.readyRaf);
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
},
beforeDestroy() {
this.cleanup();
},
destroyed() {
this.cleanup();
},
};