studio-engine 0.90.0 → 0.91.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +61 -11
- data/app/views/studio/_hold_button.html.erb +163 -1
- data/app/views/style/_tricks.html.erb +7 -3
- data/app/views/style/index.html.erb +5 -3
- data/lib/studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0cf7b3c1fa31a2eb5f9746838c8c4976d026920c986504c674f760c57c73a33d
|
|
4
|
+
data.tar.gz: c39625c99d384e68eedfee1a31dde4816e6f3aa2f004f6ab2f88c4e10d447b00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfeb555efeae9e981014cf5afb73c8e1eb7b01c90d92dc0c1ba6838d5c1acc950ddc908846be93d15d23ac07a9f5acf277c103c3d95b4611e85b758de5c940f0
|
|
7
|
+
data.tar.gz: 728932ef4510055ab9b8cd3d6f472abe228ee632febfe007251beb242aec114a1e11c77381a63e44167fb7817c79d8d6716d24687e4261b4c660968e23ffef8c
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.91.0 — 2026-10-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`fizz_portal: true` on `studio/hold_button`** lifts the bubbles out of a card
|
|
12
|
+
that clips them: the layers move to `<body>` in a fixed box that tracks the
|
|
13
|
+
button through scroll and resize, paints one rung above the button's highest
|
|
14
|
+
ancestor z-index, and mirrors its state and palette. The default is off, so
|
|
15
|
+
every existing caller renders as before. The style guide's live hold button
|
|
16
|
+
uses it.
|
|
17
|
+
|
|
7
18
|
## 0.90.0 — 2026-10-06
|
|
8
19
|
|
|
9
20
|
### Removed
|
|
@@ -1315,6 +1315,8 @@ body.modal-open { overflow: visible; }
|
|
|
1315
1315
|
span.hold-stack positioning + z-order context
|
|
1316
1316
|
span.hold-fizz resting bubble layer (behind the button)
|
|
1317
1317
|
span.hold-fizz.hold-fizz-extra hover-only layer (lively level only)
|
|
1318
|
+
span.hold-fizz-portal wraps both layers with fizz_portal: true, and
|
|
1319
|
+
moves to <body> (see THE PORTAL below)
|
|
1318
1320
|
button.hold-btn the button itself, painted OVER the bubbles
|
|
1319
1321
|
div.hold-icon progress ring + tick
|
|
1320
1322
|
ul.hold-text four sliding labels: idle/hold/done/blocked
|
|
@@ -1731,14 +1733,17 @@ body.modal-open { overflow: visible; }
|
|
|
1731
1733
|
z-index: 1;
|
|
1732
1734
|
}
|
|
1733
1735
|
|
|
1734
|
-
.hold-stack > .hold-fizz
|
|
1736
|
+
.hold-stack > .hold-fizz,
|
|
1737
|
+
.hold-stack > .hold-fizz-portal,
|
|
1738
|
+
.hold-fizz-portal > .hold-fizz {
|
|
1735
1739
|
position: absolute;
|
|
1736
1740
|
inset: 0;
|
|
1737
1741
|
z-index: 0;
|
|
1738
1742
|
pointer-events: none;
|
|
1739
1743
|
}
|
|
1740
1744
|
|
|
1741
|
-
.hold-stack .fizz-bit
|
|
1745
|
+
.hold-stack .fizz-bit,
|
|
1746
|
+
.hold-fizz-portal .fizz-bit {
|
|
1742
1747
|
position: absolute;
|
|
1743
1748
|
width: var(--fs, 4px);
|
|
1744
1749
|
height: var(--fs, 4px);
|
|
@@ -1760,17 +1765,20 @@ body.modal-open { overflow: visible; }
|
|
|
1760
1765
|
/* Hover and the nudge shake both mean "look at me" — boil for the moment.
|
|
1761
1766
|
Calm only: lively rests at this boil already and answers hover its own way,
|
|
1762
1767
|
below, so it must not pick this rule up (it outranks lively's rest rule). */
|
|
1763
|
-
.hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit
|
|
1768
|
+
.hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit,
|
|
1769
|
+
.hold-fizz-portal:not(.fizz-lively).is-hover:not(.is-process):not(.is-loading):not(.is-success):not(.is-error) .fizz-bit {
|
|
1764
1770
|
animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
|
|
1765
1771
|
}
|
|
1766
1772
|
|
|
1767
|
-
.hold-stack.fizz-lively .fizz-bit
|
|
1773
|
+
.hold-stack.fizz-lively .fizz-bit,
|
|
1774
|
+
.hold-fizz-portal.fizz-lively .fizz-bit {
|
|
1768
1775
|
animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
|
|
1769
1776
|
}
|
|
1770
1777
|
|
|
1771
1778
|
/* The hover layer keeps running while hidden. It has to: killing the animation
|
|
1772
1779
|
would snap the bubbles to their base opacity: 0 and leave nothing to fade. */
|
|
1773
|
-
.hold-stack > .hold-fizz-extra
|
|
1780
|
+
.hold-stack > .hold-fizz-extra,
|
|
1781
|
+
.hold-fizz-portal > .hold-fizz-extra {
|
|
1774
1782
|
opacity: 0;
|
|
1775
1783
|
transition: opacity 0.45s ease;
|
|
1776
1784
|
}
|
|
@@ -1778,29 +1786,69 @@ body.modal-open { overflow: visible; }
|
|
|
1778
1786
|
/* The guard list is as much the point as the hover: a held, submitting,
|
|
1779
1787
|
confirmed or blocked button speaks for itself, so the hover crowd fades away
|
|
1780
1788
|
and leaves the ring and the burst to read cleanly. */
|
|
1781
|
-
.hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra
|
|
1789
|
+
.hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra,
|
|
1790
|
+
.hold-fizz-portal.fizz-lively.is-hover:not(.is-process):not(.is-loading):not(.is-success):not(.is-error) > .hold-fizz-extra {
|
|
1782
1791
|
opacity: 1;
|
|
1783
1792
|
}
|
|
1784
1793
|
|
|
1785
1794
|
.hold-stack:has(> .hold-btn.nudge) .fizz-bit,
|
|
1786
|
-
.hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit
|
|
1795
|
+
.hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit,
|
|
1796
|
+
.hold-fizz-portal.is-nudge .fizz-bit,
|
|
1797
|
+
.hold-fizz-portal.is-nudge-soft .fizz-bit {
|
|
1787
1798
|
animation: fizz-boil calc(var(--ft, 1.8s) * 0.55) linear calc(var(--fd, 0s) * 0.25) infinite;
|
|
1788
1799
|
}
|
|
1789
1800
|
|
|
1790
1801
|
.hold-stack:has(> .hold-btn.process) .fizz-bit,
|
|
1791
|
-
.hold-stack:has(> .hold-btn.loading) .fizz-bit
|
|
1802
|
+
.hold-stack:has(> .hold-btn.loading) .fizz-bit,
|
|
1803
|
+
.hold-fizz-portal.is-process .fizz-bit,
|
|
1804
|
+
.hold-fizz-portal.is-loading .fizz-bit {
|
|
1792
1805
|
animation: fizz-boil calc(var(--ft, 1.8s) * 0.45) linear calc(var(--fd, 0s) * 0.2) infinite;
|
|
1793
1806
|
}
|
|
1794
1807
|
|
|
1795
|
-
.hold-stack:has(> .hold-btn.success) .fizz-bit
|
|
1808
|
+
.hold-stack:has(> .hold-btn.success) .fizz-bit,
|
|
1809
|
+
.hold-fizz-portal.is-success .fizz-bit {
|
|
1796
1810
|
animation: fizz-burst 1.1s cubic-bezier(0.12, 0.72, 0.24, 1) forwards;
|
|
1797
1811
|
}
|
|
1798
1812
|
|
|
1799
|
-
.hold-stack:has(> .hold-btn.error) .fizz-bit
|
|
1813
|
+
.hold-stack:has(> .hold-btn.error) .fizz-bit,
|
|
1814
|
+
.hold-fizz-portal.is-error .fizz-bit {
|
|
1800
1815
|
animation: none;
|
|
1801
1816
|
opacity: 0;
|
|
1802
1817
|
}
|
|
1803
1818
|
|
|
1819
|
+
/* THE PORTAL (studio/hold_button, fizz_portal: true). A button inside an
|
|
1820
|
+
overflow-hidden card has its bubbles cut off at the card's edge, because the
|
|
1821
|
+
layer is absolutely positioned inside the card's clip. With the option set,
|
|
1822
|
+
the button's script moves the layers out of the stack into one fixed box on
|
|
1823
|
+
<body> that tracks the stack's rect, so the bubbles rise past the card's edge
|
|
1824
|
+
instead.
|
|
1825
|
+
|
|
1826
|
+
Out of the stack, the state rules above cannot read the button through
|
|
1827
|
+
:has(), so the script mirrors its state onto the box as .is-process,
|
|
1828
|
+
.is-loading, .is-success, .is-error, .is-nudge, .is-nudge-soft and .is-hover,
|
|
1829
|
+
and copies the stack's --fizz-c-* palette onto it.
|
|
1830
|
+
|
|
1831
|
+
It paints one rung above the highest z-index among the button's ancestors
|
|
1832
|
+
(never below --z-raised), which the script sets inline. A page's own sticky
|
|
1833
|
+
or fixed bar must therefore sit ABOVE --z-raised (--z-sticky is the rung for
|
|
1834
|
+
one); a bar at z 20 or lower, such as Tailwind's sticky z-10 or z-20, ties or
|
|
1835
|
+
loses to the box, and the bubbles paint over it. Painting above the
|
|
1836
|
+
button would put bubbles on its face, so the clip path cuts the button's own
|
|
1837
|
+
rect out of the box: the bubbles show only outside it, as they do in the
|
|
1838
|
+
stack. The 50vmax margin is the room they have to rise. */
|
|
1839
|
+
.hold-fizz-portal.is-portaled {
|
|
1840
|
+
position: fixed;
|
|
1841
|
+
top: 0;
|
|
1842
|
+
left: 0;
|
|
1843
|
+
z-index: var(--z-raised, 20);
|
|
1844
|
+
pointer-events: none;
|
|
1845
|
+
clip-path: polygon(evenodd,
|
|
1846
|
+
-50vmax -50vmax, calc(100% + 50vmax) -50vmax,
|
|
1847
|
+
calc(100% + 50vmax) calc(100% + 50vmax), -50vmax calc(100% + 50vmax),
|
|
1848
|
+
-50vmax -50vmax,
|
|
1849
|
+
0 0, 100% 0, 100% 100%, 0 100%, 0 0);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1804
1852
|
@keyframes hold-tick {
|
|
1805
1853
|
100% { transform: rotate(-90deg) translate(0, -5px) scale(var(--progress-scale)); }
|
|
1806
1854
|
}
|
|
@@ -1867,7 +1915,9 @@ body.modal-open { overflow: visible; }
|
|
|
1867
1915
|
it not to. */
|
|
1868
1916
|
@media (prefers-reduced-motion: reduce) {
|
|
1869
1917
|
.hold-stack .fizz-bit,
|
|
1870
|
-
.hold-stack .hold-fizz-extra
|
|
1918
|
+
.hold-stack .hold-fizz-extra,
|
|
1919
|
+
.hold-fizz-portal .fizz-bit,
|
|
1920
|
+
.hold-fizz-portal .hold-fizz-extra {
|
|
1871
1921
|
animation: none !important;
|
|
1872
1922
|
opacity: 0 !important;
|
|
1873
1923
|
transition: none !important;
|
|
@@ -39,6 +39,13 @@
|
|
|
39
39
|
layer, for a button that should not compete.
|
|
40
40
|
fizz_colors - Up to 12 CSS colors the bubbles wear, as a static
|
|
41
41
|
array (default: none = the built-in candy palette).
|
|
42
|
+
fizz_portal - Lift the bubbles out of the stack into a fixed layer on
|
|
43
|
+
<body> that tracks the button (default: false). For a
|
|
44
|
+
button inside an overflow-hidden card, whose bubbles
|
|
45
|
+
are otherwise cut off at the card's edge: with it set
|
|
46
|
+
they rise past the edge. The button, its timers and its
|
|
47
|
+
states are the same either way. See THE PORTAL in
|
|
48
|
+
engine-motion.css.
|
|
42
49
|
fizz_bind - Alpine expression bound to the stack's :style, for a
|
|
43
50
|
palette that changes at runtime (default: none). The
|
|
44
51
|
turf-monster's board passes "fizzPalette", mapping
|
|
@@ -65,6 +72,7 @@
|
|
|
65
72
|
<% fizz_colors ||= nil %>
|
|
66
73
|
<% fizz_bind ||= nil %>
|
|
67
74
|
<% fizz_level ||= :lively %>
|
|
75
|
+
<% fizz_portal = fizz && local_assigns[:fizz_portal] %>
|
|
68
76
|
<%# Static palette (gallery, or any call site with fixed colors). A runtime
|
|
69
77
|
palette rides fizz_bind instead. %>
|
|
70
78
|
<% fizz_style = Array(fizz_colors).first(Studio::FizzHelper::SLOTS)
|
|
@@ -75,11 +83,17 @@
|
|
|
75
83
|
transform makes it a stacking context), so the bubbles have to be a
|
|
76
84
|
sibling the button paints over — they only show where they escape its
|
|
77
85
|
edges, like carbonation coming off the sides. %>
|
|
78
|
-
<span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %>>
|
|
86
|
+
<span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %><% if fizz_portal %> data-fizz-portal x-init="window.studioFizzPortal && window.studioFizzPortal.mount($el)"<% end %>>
|
|
79
87
|
<%# Fizz layer — bubbles that simmer at rest, boil while the button is held,
|
|
80
88
|
and burst outward on success. Purely decorative, so it is aria-hidden and
|
|
81
89
|
pointer-events:none; the table behind it is seeded per hold_id (see Studio::FizzHelper). %>
|
|
82
90
|
<% if fizz %>
|
|
91
|
+
<%# With fizz_portal the layers ride one wrapper, which the script below
|
|
92
|
+
moves to <body>. Until it does, the wrapper sits in the stack exactly
|
|
93
|
+
where the layers would. %>
|
|
94
|
+
<% if fizz_portal %>
|
|
95
|
+
<span class="hold-fizz-portal<%= " fizz-lively" if fizz_level.to_s == "lively" %>" aria-hidden="true">
|
|
96
|
+
<% end %>
|
|
83
97
|
<%= render "studio/fizz_layer", seed: hold_id %>
|
|
84
98
|
<%# Lively's hover doubles the COUNT, not the speed: a second scatter that
|
|
85
99
|
rests invisible, fades in over the gaps in the first, and fades back
|
|
@@ -87,6 +101,9 @@
|
|
|
87
101
|
<% if fizz_level.to_s == "lively" %>
|
|
88
102
|
<%= render "studio/fizz_layer", seed: "#{hold_id}~extra", extra: true %>
|
|
89
103
|
<% end %>
|
|
104
|
+
<% if fizz_portal %>
|
|
105
|
+
</span>
|
|
106
|
+
<% end %>
|
|
90
107
|
<% end %>
|
|
91
108
|
<button class="hold-btn"
|
|
92
109
|
data-hold-id="<%= hold_id %>"
|
|
@@ -236,6 +253,151 @@
|
|
|
236
253
|
Object.keys(nudgeTimers).forEach(function(id) { stopNudgeCycle(id); });
|
|
237
254
|
});
|
|
238
255
|
|
|
256
|
+
// ── The fizz portal (fizz_portal: true) ─────────────────────────────
|
|
257
|
+
// Moves a stack's bubble layers to the document body in one fixed box that tracks the
|
|
258
|
+
// stack's rect, so a card's overflow clip cannot cut them off. The box
|
|
259
|
+
// mirrors the button's state classes and the stack's palette, because the
|
|
260
|
+
// CSS can no longer read either through the stack. engine-motion.css,
|
|
261
|
+
// THE PORTAL, holds the other half.
|
|
262
|
+
var PORTAL_STATES = ['process', 'loading', 'success', 'error', 'nudge', 'nudge-soft'];
|
|
263
|
+
var portals = [];
|
|
264
|
+
|
|
265
|
+
// One rung above the highest z-index among the stack's ancestors, so the
|
|
266
|
+
// bubbles clear the card, the panel or the modal the button sits in,
|
|
267
|
+
// and never below --z-raised.
|
|
268
|
+
function portalZ(stack) {
|
|
269
|
+
var floor = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-raised'), 10);
|
|
270
|
+
var top = isNaN(floor) ? 20 : floor;
|
|
271
|
+
for (var n = stack.parentElement; n && n !== document.body; n = n.parentElement) {
|
|
272
|
+
var z = parseInt(getComputedStyle(n).zIndex, 10);
|
|
273
|
+
if (!isNaN(z) && z + 1 > top) top = z + 1;
|
|
274
|
+
}
|
|
275
|
+
return top;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function syncPortalState(p) {
|
|
279
|
+
PORTAL_STATES.forEach(function(s) {
|
|
280
|
+
p.box.classList.toggle('is-' + s, p.button.classList.contains(s));
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function syncPortalPalette(p) {
|
|
285
|
+
var cs = getComputedStyle(p.stack);
|
|
286
|
+
for (var i = 1; i <= <%= Studio::FizzHelper::SLOTS %>; i++) {
|
|
287
|
+
var v = cs.getPropertyValue('--fizz-c-' + i).trim();
|
|
288
|
+
if (v) p.box.style.setProperty('--fizz-c-' + i, v);
|
|
289
|
+
else p.box.style.removeProperty('--fizz-c-' + i);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function placePortal(p) {
|
|
294
|
+
if (!p.stack.isConnected) { unmountPortal(p, false); return; }
|
|
295
|
+
var r = p.stack.getBoundingClientRect();
|
|
296
|
+
var shown = p.visible && r.width > 0 && r.height > 0;
|
|
297
|
+
p.box.style.visibility = shown ? '' : 'hidden';
|
|
298
|
+
if (shown) {
|
|
299
|
+
p.box.style.transform = 'translate(' + r.left + 'px, ' + r.top + 'px)';
|
|
300
|
+
p.box.style.width = r.width + 'px';
|
|
301
|
+
p.box.style.height = r.height + 'px';
|
|
302
|
+
if (p.parked) { clearInterval(p.parked); p.parked = null; }
|
|
303
|
+
p.frame = requestAnimationFrame(function() { placePortal(p); });
|
|
304
|
+
} else {
|
|
305
|
+
p.frame = null;
|
|
306
|
+
// Parked: no frames run, so nothing would notice the stack leaving the
|
|
307
|
+
// DOM (an x-if, a Turbo stream, a closed modal) until the next page
|
|
308
|
+
// load. A slow check tears the box and its observers down when it does.
|
|
309
|
+
if (!p.parked) {
|
|
310
|
+
p.parked = setInterval(function() {
|
|
311
|
+
if (!p.stack.isConnected) unmountPortal(p, false);
|
|
312
|
+
}, 1000);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function unmountPortal(p, restore) {
|
|
318
|
+
if (p.frame) cancelAnimationFrame(p.frame);
|
|
319
|
+
if (p.parked) clearInterval(p.parked);
|
|
320
|
+
p.observers.forEach(function(o) { o.disconnect(); });
|
|
321
|
+
p.button.removeEventListener('mouseenter', p.enter);
|
|
322
|
+
p.button.removeEventListener('mouseleave', p.leave);
|
|
323
|
+
if (restore && p.stack.isConnected) {
|
|
324
|
+
// Back where the server rendered it, with nothing the script added,
|
|
325
|
+
// so a cached snapshot restores to the markup it came from.
|
|
326
|
+
p.box.className = p.box.className.replace(/\s*\bis-[\w-]+/g, '');
|
|
327
|
+
p.box.removeAttribute('style');
|
|
328
|
+
p.box.removeAttribute('data-fizz-portal-for');
|
|
329
|
+
p.stack.insertBefore(p.box, p.stack.firstChild);
|
|
330
|
+
} else {
|
|
331
|
+
p.box.remove();
|
|
332
|
+
}
|
|
333
|
+
delete p.stack._fizzPortal;
|
|
334
|
+
portals = portals.filter(function(q) { return q !== p; });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function mountPortal(stack) {
|
|
338
|
+
if (stack._fizzPortal) return stack._fizzPortal;
|
|
339
|
+
var box = stack.querySelector(':scope > .hold-fizz-portal');
|
|
340
|
+
var button = stack.querySelector(':scope > .hold-btn');
|
|
341
|
+
if (!box || !button) return null;
|
|
342
|
+
|
|
343
|
+
var p = { stack: stack, box: box, button: button, visible: true, frame: null, parked: null, observers: [] };
|
|
344
|
+
stack._fizzPortal = p;
|
|
345
|
+
portals.push(p);
|
|
346
|
+
|
|
347
|
+
document.body.appendChild(box);
|
|
348
|
+
box.classList.add('is-portaled');
|
|
349
|
+
box.setAttribute('data-fizz-portal-for', button.dataset.holdId || 'hold');
|
|
350
|
+
box.style.zIndex = portalZ(stack);
|
|
351
|
+
syncPortalState(p);
|
|
352
|
+
syncPortalPalette(p);
|
|
353
|
+
|
|
354
|
+
p.enter = function() { box.classList.add('is-hover'); };
|
|
355
|
+
p.leave = function() { box.classList.remove('is-hover'); };
|
|
356
|
+
button.addEventListener('mouseenter', p.enter);
|
|
357
|
+
button.addEventListener('mouseleave', p.leave);
|
|
358
|
+
|
|
359
|
+
var states = new MutationObserver(function() { syncPortalState(p); });
|
|
360
|
+
states.observe(button, { attributes: true, attributeFilter: ['class'] });
|
|
361
|
+
var palette = new MutationObserver(function() { syncPortalPalette(p); });
|
|
362
|
+
palette.observe(stack, { attributes: true, attributeFilter: ['style'] });
|
|
363
|
+
p.observers.push(states, palette);
|
|
364
|
+
|
|
365
|
+
// Track the rect every frame while the stack is on screen: a visible
|
|
366
|
+
// portal reads one rect per frame. Park the box while the stack is not
|
|
367
|
+
// on screen (scrolled away, inside a closed panel, or clipped out of a
|
|
368
|
+
// scroll container): no frames, only a once-a-second check that the
|
|
369
|
+
// stack is still in the DOM.
|
|
370
|
+
if ('IntersectionObserver' in window) {
|
|
371
|
+
var seen = new IntersectionObserver(function(entries) {
|
|
372
|
+
p.visible = entries[entries.length - 1].isIntersecting;
|
|
373
|
+
if (!p.stack.isConnected) { unmountPortal(p, false); return; }
|
|
374
|
+
if (p.visible && !p.frame) placePortal(p);
|
|
375
|
+
if (!p.visible) placePortal(p);
|
|
376
|
+
});
|
|
377
|
+
seen.observe(stack);
|
|
378
|
+
p.observers.push(seen);
|
|
379
|
+
}
|
|
380
|
+
placePortal(p);
|
|
381
|
+
return p;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function mountAllPortals() {
|
|
385
|
+
portals.slice().forEach(function(p) { if (!p.stack.isConnected) unmountPortal(p, false); });
|
|
386
|
+
document.querySelectorAll('.hold-stack[data-fizz-portal]').forEach(mountPortal);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
window.studioFizzPortal = {
|
|
390
|
+
mount: mountPortal,
|
|
391
|
+
mountAll: mountAllPortals,
|
|
392
|
+
count: function() { return portals.length; }
|
|
393
|
+
};
|
|
394
|
+
document.addEventListener('turbo:load', mountAllPortals);
|
|
395
|
+
document.addEventListener('turbo:before-cache', function() {
|
|
396
|
+
portals.slice().forEach(function(p) { unmountPortal(p, true); });
|
|
397
|
+
});
|
|
398
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAllPortals);
|
|
399
|
+
else mountAllPortals();
|
|
400
|
+
|
|
239
401
|
window.holdBtnStart = function(el) {
|
|
240
402
|
var holdId = el.dataset.holdId || 'hold';
|
|
241
403
|
var scope = el.closest('[x-data]');
|
|
@@ -344,6 +344,8 @@
|
|
|
344
344
|
brand — and every color also reads a <code class="font-mono text-2xs">--hold-*</code>
|
|
345
345
|
input, so one instance can be re-themed from any ancestor.
|
|
346
346
|
<code class="font-mono text-2xs">prefers-reduced-motion</code> switches the bubbles off.
|
|
347
|
+
Inside a card that clips, pass <code class="font-mono text-2xs">fizz_portal: true</code>
|
|
348
|
+
and the bubbles rise past the card’s edge instead of stopping at it.
|
|
347
349
|
</p>
|
|
348
350
|
</div>
|
|
349
351
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
@@ -360,12 +362,14 @@
|
|
|
360
362
|
</div>
|
|
361
363
|
<% end %>
|
|
362
364
|
|
|
363
|
-
<%# 2. The button itself, live: press and hold for two seconds.
|
|
365
|
+
<%# 2. The button itself, live: press and hold for two seconds. The card
|
|
366
|
+
clips, so this one sets fizz_portal and its bubbles rise past the
|
|
367
|
+
card's edge. %>
|
|
364
368
|
<%= render layout: "style/specimen",
|
|
365
369
|
locals: { klass: ".hold-btn (studio/hold_button)",
|
|
366
|
-
usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000) } do %>
|
|
370
|
+
usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000, fizz_portal: true) } do %>
|
|
367
371
|
<div class="w-full max-w-xs" x-data="{ submitting: false }">
|
|
368
|
-
<%= render "studio/hold_button", hold_id: "style-hold", duration: 2000,
|
|
372
|
+
<%= render "studio/hold_button", hold_id: "style-hold", duration: 2000, fizz_portal: true,
|
|
369
373
|
default_text: "Hold to Confirm", hold_text: "Almost there…", success_text: "Confirmed!" %>
|
|
370
374
|
<button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
|
|
371
375
|
@click="document.querySelector('.hold-btn[data-hold-id=style-hold]').classList.remove('process','success','error','loading')">Reset</button>
|
|
@@ -65,9 +65,11 @@
|
|
|
65
65
|
|
|
66
66
|
<%# Section nav — sticky under the host navbar (--nav-h, 0 fallback). A pill per
|
|
67
67
|
section; the row scrolls horizontally on narrow screens, its edges softened
|
|
68
|
-
by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
|
|
69
|
+
It sits at --z-sticky, above --z-raised, so the live hold button's portal
|
|
70
|
+
bubbles scroll under it rather than over it. %>
|
|
71
|
+
<nav class="sticky -mx-4 px-4 py-3 bg-surface border-y border-subtle"
|
|
72
|
+
style="top: var(--nav-h, 0px); z-index: var(--z-sticky, 30)" aria-label="Style guide sections">
|
|
71
73
|
<div class="fade-edge-x overflow-x-auto">
|
|
72
74
|
<div class="flex items-center gap-2 min-w-max">
|
|
73
75
|
<% sections.each do |anchor, label| %>
|
data/lib/studio/version.rb
CHANGED