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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +183 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
  4. data/app/assets/tailwind/studio_engine/engine.css +8 -0
  5. data/app/javascript/studio/alpine_shims.js +23 -0
  6. data/app/javascript/studio/application.js +9 -21
  7. data/app/javascript/studio/board.js +593 -0
  8. data/app/javascript/studio/booking.js +176 -0
  9. data/app/javascript/studio/controllers/board_controller.js +41 -0
  10. data/app/javascript/studio/controllers/booking_controller.js +24 -0
  11. data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
  12. data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
  13. data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
  14. data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
  15. data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
  16. data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
  17. data/app/javascript/studio/controllers/survey_controller.js +18 -0
  18. data/app/javascript/studio/controllers/toast_controller.js +26 -0
  19. data/app/javascript/studio/email_banner.js +277 -0
  20. data/app/javascript/studio/email_banner_scale.js +60 -0
  21. data/app/javascript/studio/footer_map.js +143 -0
  22. data/app/javascript/studio/geo_settings.js +105 -0
  23. data/app/javascript/studio/hold_button.js +416 -0
  24. data/app/javascript/studio/hold_button_hooks.js +162 -0
  25. data/app/javascript/studio/lazy_controllers.js +138 -0
  26. data/app/javascript/studio/leveling_activity.js +252 -0
  27. data/app/javascript/studio/link_preview_card.js +34 -0
  28. data/app/javascript/studio/link_sidebar.js +157 -0
  29. data/app/javascript/studio/stimulus.js +72 -0
  30. data/app/javascript/studio/survey.js +316 -0
  31. data/app/javascript/studio/theme_editor.js +197 -0
  32. data/app/javascript/studio/toast.js +270 -0
  33. data/app/views/components/_link_sidebar.html.erb +5 -100
  34. data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
  35. data/app/views/layouts/studio/_flash.html.erb +13 -118
  36. data/app/views/layouts/studio/_head.html.erb +30 -14
  37. data/app/views/studio/_board_assets.html.erb +9 -452
  38. data/app/views/studio/_hold_button.html.erb +38 -445
  39. data/app/views/studio/_hold_button_guard.html.erb +172 -0
  40. data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
  41. data/app/views/studio/board/_board.html.erb +16 -5
  42. data/app/views/studio/booking/_assets.html.erb +5 -139
  43. data/app/views/studio/booking/_frame.html.erb +3 -3
  44. data/app/views/studio/booking/_popup.html.erb +4 -4
  45. data/app/views/studio/emails/_banner_editor.html.erb +4 -152
  46. data/app/views/studio/emails/_banner_scale.html.erb +5 -56
  47. data/app/views/studio/emails/index.html.erb +0 -1
  48. data/app/views/studio/geo_settings/edit.html.erb +1 -97
  49. data/app/views/studio/site_footer/_map.html.erb +7 -5
  50. data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
  51. data/app/views/studio/site_identities/edit.html.erb +1 -24
  52. data/app/views/studio/surveys/show.html.erb +4 -5
  53. data/app/views/style/_tasks.html.erb +3 -8
  54. data/app/views/style/_theme.html.erb +2 -52
  55. data/app/views/theme_settings/edit.html.erb +3 -82
  56. data/config/importmap.rb +6 -0
  57. data/docs/BOOKING.md +6 -5
  58. data/docs/SITE_FOOTER.md +16 -12
  59. data/lib/studio/version.rb +1 -1
  60. metadata +28 -3
  61. data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
  62. 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
+ }