studio-engine 0.96.2 → 0.97.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 +183 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
- data/app/assets/tailwind/studio_engine/engine.css +8 -0
- data/app/javascript/studio/alpine_shims.js +23 -0
- data/app/javascript/studio/application.js +9 -21
- data/app/javascript/studio/board.js +593 -0
- data/app/javascript/studio/booking.js +176 -0
- data/app/javascript/studio/controllers/board_controller.js +41 -0
- data/app/javascript/studio/controllers/booking_controller.js +24 -0
- data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
- data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
- data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
- data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
- data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
- data/app/javascript/studio/controllers/survey_controller.js +18 -0
- data/app/javascript/studio/controllers/toast_controller.js +26 -0
- data/app/javascript/studio/email_banner.js +277 -0
- data/app/javascript/studio/email_banner_scale.js +60 -0
- data/app/javascript/studio/footer_map.js +143 -0
- data/app/javascript/studio/geo_settings.js +105 -0
- data/app/javascript/studio/hold_button.js +416 -0
- data/app/javascript/studio/hold_button_hooks.js +162 -0
- data/app/javascript/studio/lazy_controllers.js +138 -0
- data/app/javascript/studio/leveling_activity.js +252 -0
- data/app/javascript/studio/link_preview_card.js +34 -0
- data/app/javascript/studio/link_sidebar.js +157 -0
- data/app/javascript/studio/stimulus.js +72 -0
- data/app/javascript/studio/survey.js +316 -0
- data/app/javascript/studio/theme_editor.js +197 -0
- data/app/javascript/studio/toast.js +270 -0
- data/app/views/components/_link_sidebar.html.erb +5 -100
- data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
- data/app/views/layouts/studio/_flash.html.erb +13 -118
- data/app/views/layouts/studio/_head.html.erb +30 -14
- data/app/views/studio/_board_assets.html.erb +9 -452
- data/app/views/studio/_hold_button.html.erb +38 -445
- data/app/views/studio/_hold_button_guard.html.erb +172 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
- data/app/views/studio/board/_board.html.erb +16 -5
- data/app/views/studio/booking/_assets.html.erb +5 -139
- data/app/views/studio/booking/_frame.html.erb +3 -3
- data/app/views/studio/booking/_popup.html.erb +4 -4
- data/app/views/studio/emails/_banner_editor.html.erb +4 -152
- data/app/views/studio/emails/_banner_scale.html.erb +5 -56
- data/app/views/studio/emails/index.html.erb +0 -1
- data/app/views/studio/geo_settings/edit.html.erb +1 -97
- data/app/views/studio/site_footer/_map.html.erb +7 -5
- data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
- data/app/views/studio/site_identities/edit.html.erb +1 -24
- data/app/views/studio/surveys/show.html.erb +4 -5
- data/app/views/style/_tasks.html.erb +3 -8
- data/app/views/style/_theme.html.erb +2 -52
- data/app/views/theme_settings/edit.html.erb +3 -82
- data/config/importmap.rb +6 -0
- data/docs/BOOKING.md +6 -5
- data/docs/SITE_FOOTER.md +16 -12
- data/lib/studio/version.rb +1 -1
- metadata +28 -3
- data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
- data/app/views/studio/surveys/_script.html.erb +0 -309
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// studio/hold_button: the hold-to-confirm button behind studio/_hold_button.
|
|
2
|
+
// The hold's timeline, the idle nudge, and the fizz portal.
|
|
3
|
+
//
|
|
4
|
+
// The partial renders markup only. Its stack carries the controller and its
|
|
5
|
+
// button the presses:
|
|
6
|
+
//
|
|
7
|
+
// <span class="hold-stack" data-studio-controller="hold-button">
|
|
8
|
+
// <button class="hold-btn" data-hold-id="confirm" data-duration="2000"
|
|
9
|
+
// data-studio-action="mousedown->hold-button#start ...">
|
|
10
|
+
//
|
|
11
|
+
// studio/controllers/hold_button_controller binds one Hold to the button and
|
|
12
|
+
// answers its hooks; studio/stimulus imports and registers that controller.
|
|
13
|
+
//
|
|
14
|
+
// THE TIMELINE of one press, in ms from the press:
|
|
15
|
+
//
|
|
16
|
+
// 0 guard: refused, and nothing below happens.
|
|
17
|
+
// started.
|
|
18
|
+
// validateAt validate: a false or failed answer aborts the hold.
|
|
19
|
+
// earlyAt early: an answer of true takes the action over, and the hold
|
|
20
|
+
// never completes.
|
|
21
|
+
// duration completed. The confirm is committed here: a release after
|
|
22
|
+
// this point does not take it back.
|
|
23
|
+
//
|
|
24
|
+
// A release before `duration` ends the hold and nothing fires. A hook that
|
|
25
|
+
// throws never confirms by accident: a throwing guard refuses the hold, and a
|
|
26
|
+
// throwing validate aborts it.
|
|
27
|
+
//
|
|
28
|
+
// It imports nothing; test/javascript/hold_button.test.mjs loads it as a data:
|
|
29
|
+
// module.
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_DURATION_MS = 2000
|
|
32
|
+
export const DEFAULT_VALIDATE_AT_MS = 750
|
|
33
|
+
export const DEFAULT_EARLY_AT_MS = 1500
|
|
34
|
+
// The idle nudge: the first after NUDGE_FIRST_S of rest, then a soft one every
|
|
35
|
+
// NUDGE_REPEAT_S. After a release only the soft ones run.
|
|
36
|
+
export const NUDGE_FIRST_S = 3
|
|
37
|
+
export const NUDGE_REPEAT_S = 10
|
|
38
|
+
// Studio::FizzHelper::SLOTS: the --fizz-c-<n> custom properties a stack may
|
|
39
|
+
// carry. test/views/hold_button_test.rb holds the two equal.
|
|
40
|
+
export const FIZZ_SLOTS = 18
|
|
41
|
+
// The nudge ring's circumference, for r=9 in the partial's svg.
|
|
42
|
+
const CIRCUMFERENCE = 2 * Math.PI * 9
|
|
43
|
+
|
|
44
|
+
const number = (value, fallback) => {
|
|
45
|
+
const parsed = parseInt(value, 10)
|
|
46
|
+
return Number.isNaN(parsed) ? fallback : parsed
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A button's timing, from the data attributes the partial writes.
|
|
50
|
+
export function holdConfig(dataset) {
|
|
51
|
+
const data = dataset || {}
|
|
52
|
+
return {
|
|
53
|
+
id: data.holdId || "hold",
|
|
54
|
+
duration: number(data.duration, DEFAULT_DURATION_MS),
|
|
55
|
+
validateAt: number(data.validateAt, DEFAULT_VALIDATE_AT_MS),
|
|
56
|
+
earlyAt: number(data.earlyActionAt, DEFAULT_EARLY_AT_MS)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const systemTimers = () => ({
|
|
61
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
62
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
63
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
64
|
+
clearInterval: (id) => clearInterval(id)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Snap the progress ring to empty with no reverse animation, running `change`
|
|
68
|
+
// (a class change) while its transition is off.
|
|
69
|
+
function snapProgress(button, change) {
|
|
70
|
+
const circle = button.querySelector("svg.progress circle")
|
|
71
|
+
if (!circle) { change(); return }
|
|
72
|
+
circle.style.transition = "none"
|
|
73
|
+
change()
|
|
74
|
+
void circle.offsetWidth
|
|
75
|
+
circle.style.transition = ""
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The idle nudge on one button: a countdown ring and a wiggle when it runs out.
|
|
79
|
+
export class NudgeCycle {
|
|
80
|
+
constructor(button, timers) {
|
|
81
|
+
this.button = button
|
|
82
|
+
this.timers = timers || systemTimers()
|
|
83
|
+
this.tick = null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
start(softOnly = false) {
|
|
87
|
+
this.stop()
|
|
88
|
+
let total = softOnly ? NUDGE_REPEAT_S : NUDGE_FIRST_S
|
|
89
|
+
let left = total
|
|
90
|
+
let first = !softOnly
|
|
91
|
+
this.ring(left, total)
|
|
92
|
+
|
|
93
|
+
this.tick = this.timers.setInterval(() => {
|
|
94
|
+
left -= 1
|
|
95
|
+
if (left <= 0) {
|
|
96
|
+
this.nudge(first ? "nudge" : "nudge-soft")
|
|
97
|
+
first = false
|
|
98
|
+
total = NUDGE_REPEAT_S
|
|
99
|
+
left = NUDGE_REPEAT_S
|
|
100
|
+
}
|
|
101
|
+
this.ring(left, total)
|
|
102
|
+
}, 1000)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
stop() {
|
|
106
|
+
if (this.tick !== null) this.timers.clearInterval(this.tick)
|
|
107
|
+
this.tick = null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
nudge(name) {
|
|
111
|
+
const classes = this.button.classList
|
|
112
|
+
if (classes.contains("process") || classes.contains("success")) return
|
|
113
|
+
classes.remove("nudge", "nudge-soft")
|
|
114
|
+
void this.button.offsetWidth
|
|
115
|
+
classes.add(name)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ring(left, total) {
|
|
119
|
+
const debug = this.button.querySelector(".nudge-debug")
|
|
120
|
+
if (!debug) return
|
|
121
|
+
debug.querySelector("circle.fill").style.strokeDashoffset = (CIRCUMFERENCE * (left / total)).toFixed(2)
|
|
122
|
+
debug.querySelector(".countdown-num").textContent = left
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// One button's hold. `hooks` answers the timeline (see the header); each is
|
|
127
|
+
// optional:
|
|
128
|
+
//
|
|
129
|
+
// guard() false refuses the press.
|
|
130
|
+
// started() the hold began.
|
|
131
|
+
// validate() a boolean or a promise of one; false aborts.
|
|
132
|
+
// early() true takes the action over.
|
|
133
|
+
// completed() true when the caller owns the button's state from here;
|
|
134
|
+
// otherwise the button shows `success`.
|
|
135
|
+
export class Hold {
|
|
136
|
+
constructor(button, hooks = {}, { timers, report } = {}) {
|
|
137
|
+
this.button = button
|
|
138
|
+
this.hooks = hooks
|
|
139
|
+
this.timers = timers || systemTimers()
|
|
140
|
+
this.report = report || ((what, error) => console.error(`[hold:${this.config().id}] ${what}`, error))
|
|
141
|
+
this.nudge = new NudgeCycle(button, this.timers)
|
|
142
|
+
this.pending = {}
|
|
143
|
+
this.run = 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
config() { return holdConfig(this.button.dataset) }
|
|
147
|
+
|
|
148
|
+
get holding() { return "main" in this.pending }
|
|
149
|
+
|
|
150
|
+
// A press. False when the guard refused it.
|
|
151
|
+
start() {
|
|
152
|
+
if (!this.allowed()) return false
|
|
153
|
+
|
|
154
|
+
// A second press without a release starts over: the first press's timers
|
|
155
|
+
// go, so one hold never confirms twice.
|
|
156
|
+
this.clear()
|
|
157
|
+
const run = ++this.run
|
|
158
|
+
const { duration, validateAt, earlyAt } = this.config()
|
|
159
|
+
|
|
160
|
+
this.attempt("started", () => this.hooks.started && this.hooks.started())
|
|
161
|
+
|
|
162
|
+
this.button.classList.remove("nudge", "nudge-soft")
|
|
163
|
+
this.nudge.start()
|
|
164
|
+
snapProgress(this.button, () => this.button.classList.remove("process", "success"))
|
|
165
|
+
this.button.style.setProperty("--duration", duration + "ms")
|
|
166
|
+
this.button.classList.add("process")
|
|
167
|
+
|
|
168
|
+
this.pending.main = this.timers.setTimeout(() => this.complete(run), duration)
|
|
169
|
+
this.pending.early = this.timers.setTimeout(() => this.early(run), earlyAt)
|
|
170
|
+
this.pending.validate = this.timers.setTimeout(() => this.validate(run), validateAt)
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A release, or the pointer leaving. A finished button stays finished.
|
|
175
|
+
end() {
|
|
176
|
+
const classes = this.button.classList
|
|
177
|
+
if (classes.contains("success") || classes.contains("error")) return
|
|
178
|
+
this.run += 1
|
|
179
|
+
snapProgress(this.button, () => classes.remove("process"))
|
|
180
|
+
this.clear()
|
|
181
|
+
this.nudge.start(true)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The button is leaving the page.
|
|
185
|
+
stop() {
|
|
186
|
+
this.run += 1
|
|
187
|
+
this.clear()
|
|
188
|
+
this.nudge.stop()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
allowed() {
|
|
192
|
+
if (!this.hooks.guard) return true
|
|
193
|
+
try {
|
|
194
|
+
return !!this.hooks.guard()
|
|
195
|
+
} catch (error) {
|
|
196
|
+
this.report("guard threw; the hold is refused", error)
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
complete(run) {
|
|
202
|
+
if (run !== this.run) return
|
|
203
|
+
this.clear()
|
|
204
|
+
const owned = this.attempt("completed", () => this.hooks.completed && this.hooks.completed())
|
|
205
|
+
if (owned === true) return
|
|
206
|
+
this.button.classList.remove("process")
|
|
207
|
+
this.button.classList.add("success")
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
early(run) {
|
|
211
|
+
if (run !== this.run) return
|
|
212
|
+
delete this.pending.early
|
|
213
|
+
const taken = this.attempt("early", () => this.hooks.early && this.hooks.early())
|
|
214
|
+
if (taken !== true) return
|
|
215
|
+
this.timers.clearTimeout(this.pending.main)
|
|
216
|
+
delete this.pending.main
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
validate(run) {
|
|
220
|
+
if (run !== this.run) return
|
|
221
|
+
delete this.pending.validate
|
|
222
|
+
if (!this.hooks.validate) return
|
|
223
|
+
|
|
224
|
+
const refuse = (error) => {
|
|
225
|
+
if (error) this.report("validate failed; the hold is aborted", error)
|
|
226
|
+
// An answer for a press that has since been released, or has already
|
|
227
|
+
// completed, aborts nothing.
|
|
228
|
+
if (run === this.run && this.holding) this.abort()
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
Promise.resolve(this.hooks.validate()).then((ok) => { if (!ok) refuse() }, refuse)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
refuse(error)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Validation said no: the hold stops where it is and the ring empties.
|
|
238
|
+
abort() {
|
|
239
|
+
this.clear()
|
|
240
|
+
snapProgress(this.button, () => this.button.classList.remove("process"))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
clear() {
|
|
244
|
+
for (const id of Object.values(this.pending)) this.timers.clearTimeout(id)
|
|
245
|
+
this.pending = {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// A hook that throws is reported and answers undefined: it never stops the
|
|
249
|
+
// timeline, and never counts as a yes.
|
|
250
|
+
attempt(what, hook) {
|
|
251
|
+
try {
|
|
252
|
+
return hook()
|
|
253
|
+
} catch (error) {
|
|
254
|
+
this.report(`${what} threw`, error)
|
|
255
|
+
return undefined
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── The fizz portal (fizz_portal: true) ─────────────────────────────────────
|
|
261
|
+
// Moves a stack's bubble layers to the document body in one fixed box that
|
|
262
|
+
// tracks the stack's rect, so a card's overflow clip cannot cut them off. The
|
|
263
|
+
// box mirrors the button's state classes and the stack's palette, because the
|
|
264
|
+
// CSS can no longer read either through the stack. engine-motion.css, THE
|
|
265
|
+
// PORTAL, holds the other half.
|
|
266
|
+
export const PORTAL_STATES = ["process", "loading", "success", "error", "nudge", "nudge-soft"]
|
|
267
|
+
let portals = []
|
|
268
|
+
|
|
269
|
+
// One rung above the highest z-index among the stack's ancestors, so the
|
|
270
|
+
// bubbles clear the card, the panel or the modal the button sits in, and never
|
|
271
|
+
// below --z-raised.
|
|
272
|
+
function portalZ(stack) {
|
|
273
|
+
const floor = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--z-raised"), 10)
|
|
274
|
+
let top = Number.isNaN(floor) ? 20 : floor
|
|
275
|
+
for (let node = stack.parentElement; node && node !== document.body; node = node.parentElement) {
|
|
276
|
+
const z = parseInt(getComputedStyle(node).zIndex, 10)
|
|
277
|
+
if (!Number.isNaN(z) && z + 1 > top) top = z + 1
|
|
278
|
+
}
|
|
279
|
+
return top
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function syncPortalState(portal) {
|
|
283
|
+
for (const state of PORTAL_STATES) {
|
|
284
|
+
portal.box.classList.toggle("is-" + state, portal.button.classList.contains(state))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function syncPortalPalette(portal) {
|
|
289
|
+
const styles = getComputedStyle(portal.stack)
|
|
290
|
+
for (let slot = 1; slot <= FIZZ_SLOTS; slot += 1) {
|
|
291
|
+
const value = styles.getPropertyValue("--fizz-c-" + slot).trim()
|
|
292
|
+
if (value) portal.box.style.setProperty("--fizz-c-" + slot, value)
|
|
293
|
+
else portal.box.style.removeProperty("--fizz-c-" + slot)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function placePortal(portal) {
|
|
298
|
+
if (!portal.stack.isConnected) { unmountPortal(portal, false); return }
|
|
299
|
+
const rect = portal.stack.getBoundingClientRect()
|
|
300
|
+
const shown = portal.visible && rect.width > 0 && rect.height > 0
|
|
301
|
+
portal.box.style.visibility = shown ? "" : "hidden"
|
|
302
|
+
if (shown) {
|
|
303
|
+
portal.box.style.transform = "translate(" + rect.left + "px, " + rect.top + "px)"
|
|
304
|
+
portal.box.style.width = rect.width + "px"
|
|
305
|
+
portal.box.style.height = rect.height + "px"
|
|
306
|
+
if (portal.parked) { clearInterval(portal.parked); portal.parked = null }
|
|
307
|
+
portal.frame = requestAnimationFrame(() => placePortal(portal))
|
|
308
|
+
} else {
|
|
309
|
+
portal.frame = null
|
|
310
|
+
// Parked: no frames run, so nothing would notice the stack leaving the DOM
|
|
311
|
+
// (an x-if, a Turbo stream, a closed modal). A slow check tears the box and
|
|
312
|
+
// its observers down when it does.
|
|
313
|
+
if (!portal.parked) {
|
|
314
|
+
portal.parked = setInterval(() => {
|
|
315
|
+
if (!portal.stack.isConnected) unmountPortal(portal, false)
|
|
316
|
+
}, 1000)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// `restore` puts the box back where the server rendered it, with nothing the
|
|
322
|
+
// script added, so a cached snapshot restores to the markup it came from.
|
|
323
|
+
export function unmountPortal(portal, restore) {
|
|
324
|
+
if (!portal || !portals.includes(portal)) return
|
|
325
|
+
if (portal.frame) cancelAnimationFrame(portal.frame)
|
|
326
|
+
if (portal.parked) clearInterval(portal.parked)
|
|
327
|
+
portal.observers.forEach((observer) => observer.disconnect())
|
|
328
|
+
portal.button.removeEventListener("mouseenter", portal.enter)
|
|
329
|
+
portal.button.removeEventListener("mouseleave", portal.leave)
|
|
330
|
+
if (restore && portal.stack.isConnected) {
|
|
331
|
+
portal.box.className = portal.box.className.replace(/\s*\bis-[\w-]+/g, "")
|
|
332
|
+
portal.box.removeAttribute("style")
|
|
333
|
+
portal.box.removeAttribute("data-fizz-portal-for")
|
|
334
|
+
portal.stack.insertBefore(portal.box, portal.stack.firstChild)
|
|
335
|
+
} else {
|
|
336
|
+
portal.box.remove()
|
|
337
|
+
}
|
|
338
|
+
delete portal.stack._fizzPortal
|
|
339
|
+
portals = portals.filter((other) => other !== portal)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function mountPortal(stack) {
|
|
343
|
+
if (stack._fizzPortal) return stack._fizzPortal
|
|
344
|
+
const box = stack.querySelector(":scope > .hold-fizz-portal")
|
|
345
|
+
const button = stack.querySelector(":scope > .hold-btn")
|
|
346
|
+
if (!box || !button) return null
|
|
347
|
+
|
|
348
|
+
const portal = { stack, box, button, visible: true, frame: null, parked: null, observers: [] }
|
|
349
|
+
stack._fizzPortal = portal
|
|
350
|
+
portals.push(portal)
|
|
351
|
+
|
|
352
|
+
document.body.appendChild(box)
|
|
353
|
+
box.classList.add("is-portaled")
|
|
354
|
+
box.setAttribute("data-fizz-portal-for", button.dataset.holdId || "hold")
|
|
355
|
+
box.style.zIndex = portalZ(stack)
|
|
356
|
+
syncPortalState(portal)
|
|
357
|
+
syncPortalPalette(portal)
|
|
358
|
+
|
|
359
|
+
portal.enter = () => box.classList.add("is-hover")
|
|
360
|
+
portal.leave = () => box.classList.remove("is-hover")
|
|
361
|
+
button.addEventListener("mouseenter", portal.enter)
|
|
362
|
+
button.addEventListener("mouseleave", portal.leave)
|
|
363
|
+
|
|
364
|
+
const states = new MutationObserver(() => syncPortalState(portal))
|
|
365
|
+
states.observe(button, { attributes: true, attributeFilter: ["class"] })
|
|
366
|
+
const palette = new MutationObserver(() => syncPortalPalette(portal))
|
|
367
|
+
palette.observe(stack, { attributes: true, attributeFilter: ["style"] })
|
|
368
|
+
portal.observers.push(states, palette)
|
|
369
|
+
|
|
370
|
+
// Track the rect every frame while the stack is on screen. Park the box while
|
|
371
|
+
// it is not (scrolled away, inside a closed panel, clipped out of a scroll
|
|
372
|
+
// container): no frames, only a once-a-second check that the stack is still
|
|
373
|
+
// in the DOM.
|
|
374
|
+
if ("IntersectionObserver" in window) {
|
|
375
|
+
const seen = new IntersectionObserver((entries) => {
|
|
376
|
+
portal.visible = entries[entries.length - 1].isIntersecting
|
|
377
|
+
if (!portal.stack.isConnected) { unmountPortal(portal, false); return }
|
|
378
|
+
if (portal.visible && !portal.frame) placePortal(portal)
|
|
379
|
+
if (!portal.visible) placePortal(portal)
|
|
380
|
+
})
|
|
381
|
+
seen.observe(stack)
|
|
382
|
+
portal.observers.push(seen)
|
|
383
|
+
}
|
|
384
|
+
placePortal(portal)
|
|
385
|
+
return portal
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function mountAllPortals() {
|
|
389
|
+
portals.slice().forEach((portal) => { if (!portal.stack.isConnected) unmountPortal(portal, false) })
|
|
390
|
+
document.querySelectorAll(".hold-stack[data-fizz-portal]").forEach(mountPortal)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function restoreAllPortals() {
|
|
394
|
+
portals.slice().forEach((portal) => unmountPortal(portal, true))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export const portalCount = () => portals.length
|
|
398
|
+
|
|
399
|
+
const installed = new WeakSet()
|
|
400
|
+
|
|
401
|
+
// Once per window. window.studioFizzPortal is the portal's handle for a page
|
|
402
|
+
// that inserts a stack by hand and for the engine's own browser specs.
|
|
403
|
+
export function installHoldButton({ win, doc } = {}) {
|
|
404
|
+
const host = win || globalThis
|
|
405
|
+
const root = doc || host.document
|
|
406
|
+
if (!root || typeof root.addEventListener !== "function") return false
|
|
407
|
+
if (installed.has(host)) return false
|
|
408
|
+
installed.add(host)
|
|
409
|
+
|
|
410
|
+
host.studioFizzPortal = { mount: mountPortal, mountAll: mountAllPortals, count: portalCount }
|
|
411
|
+
// Before Turbo caches the page every box goes back into its stack.
|
|
412
|
+
root.addEventListener("turbo:before-cache", restoreAllPortals)
|
|
413
|
+
return true
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (typeof window !== "undefined") installHoldButton({ win: window, doc: document })
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// studio/hold_button_hooks: how one hold button asks its page the timeline's
|
|
2
|
+
// questions (studio/hold_button). Two voices, both heard on every press:
|
|
3
|
+
//
|
|
4
|
+
// 1. EVENTS, dispatched on the button and bubbling. This is the API:
|
|
5
|
+
//
|
|
6
|
+
// hold-button:guard at the press. preventDefault() refuses the hold.
|
|
7
|
+
// hold-button:start the hold began.
|
|
8
|
+
// hold-button:validate at validate_at. event.detail.waitUntil(answer)
|
|
9
|
+
// takes a boolean or a promise of one; a false or
|
|
10
|
+
// failed answer aborts the hold.
|
|
11
|
+
// hold-button:early at early_action_at. preventDefault() takes the
|
|
12
|
+
// action over: the hold never completes.
|
|
13
|
+
// hold-button:success at the full duration. preventDefault() keeps the
|
|
14
|
+
// button in its holding state for the listener to
|
|
15
|
+
// resolve; otherwise it shows its success face.
|
|
16
|
+
//
|
|
17
|
+
// Every event's detail carries { id }, the button's hold_id.
|
|
18
|
+
//
|
|
19
|
+
// <div @hold-button:guard="ready || $event.preventDefault()"
|
|
20
|
+
// @hold-button:success="submit()">
|
|
21
|
+
// <%= render "studio/hold_button", hold_id: "confirm" %>
|
|
22
|
+
// </div>
|
|
23
|
+
//
|
|
24
|
+
// 2. STRING LOCALS, the partial's guard:, on_hold_start:, validate:,
|
|
25
|
+
// early_action:, early_action_guard: and on_success:, which arrive as
|
|
26
|
+
// data-* attributes and are evaluated against the nearest [x-data] scope
|
|
27
|
+
// with `d` and `data` aliased to its data. They stay while a consumer
|
|
28
|
+
// passes them (turf-monster does), and go when none does.
|
|
29
|
+
//
|
|
30
|
+
// Either voice can refuse: a hold needs every guard to pass. With an [x-data]
|
|
31
|
+
// ancestor a press is also refused while that scope's `submitting` is true, so
|
|
32
|
+
// a second hold cannot double-submit.
|
|
33
|
+
//
|
|
34
|
+
// It imports nothing; test/javascript/hold_button_hooks.test.mjs loads it as a
|
|
35
|
+
// data: module.
|
|
36
|
+
|
|
37
|
+
// How long the button stays in its holding state before a string on_success
|
|
38
|
+
// runs.
|
|
39
|
+
export const SUCCESS_SETTLE_MS = 500
|
|
40
|
+
|
|
41
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
42
|
+
|
|
43
|
+
// An expression against an Alpine scope, synchronously.
|
|
44
|
+
function evaluate(Alpine, scope, expression) {
|
|
45
|
+
const data = Alpine.$data(scope)
|
|
46
|
+
return Alpine.evaluate(scope, expression, { scope: { d: data, data } })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The same for an expression that may return a promise. Alpine.evaluate
|
|
50
|
+
// returns before an async expression settles, so this compiles its own
|
|
51
|
+
// function with the names an expression may use.
|
|
52
|
+
function evaluateAsync(Alpine, win, scope, expression) {
|
|
53
|
+
const data = Alpine.$data(scope)
|
|
54
|
+
try {
|
|
55
|
+
const fn = new AsyncFunction("d", "data", "Alpine", "window", '"use strict"; return (' + expression + ");")
|
|
56
|
+
return fn(data, data, Alpine, win)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return Promise.reject(error)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The hooks a Hold takes, for one button.
|
|
63
|
+
export function holdHooks(button, { win } = {}) {
|
|
64
|
+
const host = win || globalThis
|
|
65
|
+
const id = () => button.dataset.holdId || "hold"
|
|
66
|
+
const warn = (what, error) => host.console.warn(`[hold:${id()}] ${what}`, error || "")
|
|
67
|
+
const fail = (what, error) => host.console.error(`[hold:${id()}] ${what}`, error)
|
|
68
|
+
|
|
69
|
+
const fire = (name, detail = {}) => {
|
|
70
|
+
const event = new host.CustomEvent(`hold-button:${name}`, {
|
|
71
|
+
bubbles: true, cancelable: true, detail: { id: id(), ...detail }
|
|
72
|
+
})
|
|
73
|
+
button.dispatchEvent(event)
|
|
74
|
+
return event
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const scope = () => button.closest("[x-data]")
|
|
78
|
+
// A string local with nothing to evaluate it against cannot answer.
|
|
79
|
+
const scopeFor = (name) => {
|
|
80
|
+
const found = host.Alpine ? scope() : null
|
|
81
|
+
if (!found) warn(`${name}: no Alpine scope to evaluate it in`)
|
|
82
|
+
return found
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
// Refused by the scope's `submitting`, by the guard string, or by a
|
|
87
|
+
// hold-button:guard listener. A guard string that cannot be evaluated
|
|
88
|
+
// refuses: the caller asked for a check that did not run.
|
|
89
|
+
guard() {
|
|
90
|
+
const found = host.Alpine ? scope() : null
|
|
91
|
+
if (found) {
|
|
92
|
+
const data = host.Alpine.$data(found)
|
|
93
|
+
if (data && data.submitting) return false
|
|
94
|
+
}
|
|
95
|
+
const expression = button.dataset.guard
|
|
96
|
+
if (expression) {
|
|
97
|
+
if (!found) { warn("guard: no Alpine scope to evaluate it in; the hold is refused"); return false }
|
|
98
|
+
if (!evaluate(host.Alpine, found, expression)) return false
|
|
99
|
+
}
|
|
100
|
+
return !fire("guard").defaultPrevented
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
started() {
|
|
104
|
+
const expression = button.dataset.onHoldStart
|
|
105
|
+
const found = expression ? scopeFor("on_hold_start") : null
|
|
106
|
+
if (found) {
|
|
107
|
+
try { evaluate(host.Alpine, found, expression) } catch (error) { fail("on_hold_start threw", error) }
|
|
108
|
+
}
|
|
109
|
+
fire("start")
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// True unless the validate string or a hold-button:validate listener says
|
|
113
|
+
// no. Rejects when either fails, which aborts the hold.
|
|
114
|
+
validate() {
|
|
115
|
+
const answers = []
|
|
116
|
+
const expression = button.dataset.validate
|
|
117
|
+
if (expression) {
|
|
118
|
+
const found = scopeFor("validate")
|
|
119
|
+
if (found) answers.push(evaluateAsync(host.Alpine, host, found, expression))
|
|
120
|
+
}
|
|
121
|
+
fire("validate", { waitUntil: (answer) => { answers.push(answer) } })
|
|
122
|
+
return Promise.all(answers).then((all) => all.every(Boolean))
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// True when the early action ran, or a hold-button:early listener took
|
|
126
|
+
// the action over.
|
|
127
|
+
early() {
|
|
128
|
+
let taken = false
|
|
129
|
+
const expression = button.dataset.earlyAction
|
|
130
|
+
const found = expression ? scopeFor("early_action") : null
|
|
131
|
+
if (found) {
|
|
132
|
+
const guard = button.dataset.earlyActionGuard
|
|
133
|
+
if (!guard || evaluate(host.Alpine, found, guard)) {
|
|
134
|
+
taken = true
|
|
135
|
+
try { evaluate(host.Alpine, found, expression) } catch (error) { fail("early_action threw", error) }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return fire("early").defaultPrevented || taken
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// True when a caller owns the button's state from here. A string
|
|
142
|
+
// on_success runs SUCCESS_SETTLE_MS later, with the button still in its
|
|
143
|
+
// holding state; if it throws, the button shows its success face.
|
|
144
|
+
completed() {
|
|
145
|
+
const event = fire("success")
|
|
146
|
+
const expression = button.dataset.onSuccess
|
|
147
|
+
if (!expression) return event.defaultPrevented
|
|
148
|
+
|
|
149
|
+
host.setTimeout(() => {
|
|
150
|
+
try {
|
|
151
|
+
const found = scopeFor("on_success")
|
|
152
|
+
if (found) evaluate(host.Alpine, found, expression)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
fail("on_success threw", error)
|
|
155
|
+
button.classList.remove("process")
|
|
156
|
+
button.classList.add("success")
|
|
157
|
+
}
|
|
158
|
+
}, SUCCESS_SETTLE_MS)
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|