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,176 @@
1
+ // studio/booking: the booking frame and the booking popup (docs/BOOKING.md).
2
+ //
3
+ // installBookingFrames the inline frame: assign src after `load`, once the
4
+ // frame is near the viewport, and open the crop when
5
+ // focus moves into the frame.
6
+ // installBookingPopup any a[data-booking-popup] link opens the booking
7
+ // dialog instead of leaving the page.
8
+ //
9
+ // Both install document-level behaviour once per document, however many
10
+ // frames, dialogs or Turbo visits follow. Started by the booking controller,
11
+ // which studio/booking/_frame and studio/booking/_popup name.
12
+ //
13
+ // THE GUARDS ARE THE ENGINE'S OWN NAMES, AND THE ELEMENTS ARE THE ENGINE'S OWN
14
+ // (data-studio-booking), for the reason studio/footer_map gives for the map: an
15
+ // app that still renders a local booking script guards it with the unprefixed
16
+ // names and marks its frame, dialog and links with the same data-booking-*
17
+ // attributes. Sharing either lets one script stand the other down across a
18
+ // Turbo visit, or act on elements that are not its own.
19
+ //
20
+ // It imports nothing; test/javascript/booking.test.mjs loads it as a data:
21
+ // module.
22
+
23
+ export const FRAME = "iframe[data-booking-frame][data-studio-booking]"
24
+ export const WAITING_FRAMES = "iframe[data-booking-frame][data-studio-booking][data-src]"
25
+ export const WRAP = "[data-booking-wrap][data-studio-booking]"
26
+ export const SHUT_WRAP = "[data-booking-wrap][data-studio-booking][data-booking-crop]:not(.is-open)"
27
+ export const POPUP_LINK = "a[data-booking-popup][data-studio-booking]"
28
+ export const DIALOG = "dialog[data-booking-dialog][data-studio-booking]"
29
+ export const POPUP_FRAME = "iframe[data-booking-popup-frame]"
30
+
31
+ // ASK GOOGLE ONCE. Two things can ask for a frame: scrolling near it, and a
32
+ // booking link that jumps to it. Assigning src a second time, even to the same
33
+ // URL, is a second request, so whoever comes second finds it set.
34
+ export function loadBookingFrame(frame) {
35
+ if (!frame.getAttribute("src")) frame.src = frame.dataset.src
36
+ }
37
+
38
+ // A plain primary click: not already handled, and not one the visitor modified
39
+ // to open a new tab or window.
40
+ export function plainClick(event) {
41
+ return !(event.defaultPrevented || event.button !== 0 ||
42
+ event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
43
+ }
44
+
45
+ // Watches every waiting frame in `doc`, and loads each once it comes within
46
+ // 200px of the viewport. A frame is watched once.
47
+ export function armBookingFrames(win, doc) {
48
+ doc.querySelectorAll(WAITING_FRAMES).forEach((frame) => {
49
+ if (frame.__bookingArmed) return
50
+ frame.__bookingArmed = true
51
+ if (typeof win.IntersectionObserver !== "function") { loadBookingFrame(frame); return }
52
+ const watcher = new win.IntersectionObserver((entries) => {
53
+ if (!entries.some((entry) => entry.isIntersecting)) return
54
+ watcher.disconnect()
55
+ loadBookingFrame(frame)
56
+ }, { rootMargin: "200px" })
57
+ watcher.observe(frame)
58
+ })
59
+ }
60
+
61
+ // Arms the frames once the window has loaded: a frame that starts loading
62
+ // before `load` holds that event open for as long as Google takes to answer.
63
+ export function armBookingFramesAfterLoad(win, doc) {
64
+ if (doc.readyState === "complete") armBookingFrames(win, doc)
65
+ else win.addEventListener("load", () => armBookingFrames(win, doc), { once: true })
66
+ }
67
+
68
+ // Focus moving into the frame is the one signal a cross-origin frame gives its
69
+ // parent. It means the visitor has started using the calendar.
70
+ function openActive(doc) {
71
+ const active = doc.activeElement
72
+ if (!active || !active.matches || !active.matches(FRAME)) return
73
+ const wrap = active.closest(WRAP)
74
+ if (wrap) wrap.classList.add("is-open")
75
+ }
76
+
77
+ // Installs the inline frame's behaviour, once per document. Answers false when
78
+ // it was already installed.
79
+ export function installBookingFrames(win, doc) {
80
+ if (win.__studioBookingFramesArmed) return false
81
+ win.__studioBookingFramesArmed = true
82
+ win.__studioBookingLoad = loadBookingFrame
83
+
84
+ // THE WINDOW BLURS ONCE. Focus that moves from one frame straight into
85
+ // another never comes back through this window, so no second blur announces
86
+ // the second frame, and on a page with two cropped frames it would stay
87
+ // cropped under the visitor's cursor. So while focus is away in a frame and a
88
+ // cropped wrapper is still shut, look again a few times a second. It stops
89
+ // when focus returns here or nothing is left to open, and a page with one
90
+ // frame never starts it: that frame is open by then.
91
+ let watching = null
92
+ const shut = () => doc.querySelector(SHUT_WRAP)
93
+ const stopWatching = () => { if (watching) { win.clearInterval(watching); watching = null } }
94
+
95
+ win.addEventListener("blur", () => {
96
+ openActive(doc)
97
+ const active = doc.activeElement
98
+ if (watching || !active || active.tagName !== "IFRAME" || !shut()) return
99
+ watching = win.setInterval(() => {
100
+ openActive(doc)
101
+ if (!shut()) stopWatching()
102
+ }, 250)
103
+ })
104
+ win.addEventListener("focus", stopWatching)
105
+
106
+ doc.addEventListener("turbo:load", () => armBookingFramesAfterLoad(win, doc))
107
+ armBookingFramesAfterLoad(win, doc)
108
+ return true
109
+ }
110
+
111
+ // What a click on a booking link does. Delegated from the document, so it
112
+ // survives Turbo body swaps. A modified click (new tab, new window) is left
113
+ // alone, and so is everything when the dialog is missing or <dialog> is
114
+ // unsupported: the link's own href then takes the visitor to the booking page.
115
+ //
116
+ // ON A PAGE THAT ALREADY SHOWS THE INLINE FRAME the link goes to that frame
117
+ // instead: it scrolls there, opens the crop and moves focus in. A popup would
118
+ // be a second copy of the calendar on top of the first.
119
+ //
120
+ // Answers "frame", "dialog", or null when the click is left to the browser.
121
+ export function handleBookingLinkClick(event, win, doc) {
122
+ const link = event.target.closest && event.target.closest(POPUP_LINK)
123
+ if (!link) return null
124
+ if (!plainClick(event)) return null
125
+
126
+ const inline = doc.querySelector(FRAME)
127
+ if (inline) {
128
+ event.preventDefault()
129
+ const wrap = inline.closest(WRAP)
130
+ if (wrap) wrap.classList.add("is-open")
131
+ // Before `load` the frame keeps waiting: installBookingFrames assigns src then.
132
+ if (doc.readyState === "complete" && win.__studioBookingLoad) win.__studioBookingLoad(inline)
133
+ const calm = win.matchMedia && win.matchMedia("(prefers-reduced-motion: reduce)").matches
134
+ const anchor = wrap || inline
135
+ anchor.scrollIntoView({ behavior: calm ? "auto" : "smooth", block: "start" })
136
+ inline.focus({ preventScroll: true })
137
+ return "frame"
138
+ }
139
+
140
+ const dialog = doc.querySelector(DIALOG)
141
+ if (!dialog || typeof dialog.showModal !== "function") return null
142
+
143
+ event.preventDefault()
144
+ const frame = dialog.querySelector(POPUP_FRAME)
145
+ if (frame && !frame.getAttribute("src")) frame.src = frame.dataset.src
146
+ if (!dialog.open) dialog.showModal()
147
+ return "dialog"
148
+ }
149
+
150
+ // Never snapshot the dialog open: a restored page would show it stuck. And
151
+ // never snapshot its frame loaded: a restored page would ask Google for a popup
152
+ // nobody has opened. The src goes back to waiting in data-src.
153
+ export function resetBookingDialog(doc) {
154
+ const dialog = doc.querySelector(DIALOG)
155
+ if (!dialog) return
156
+ if (dialog.open) dialog.close()
157
+ const frame = dialog.querySelector(POPUP_FRAME)
158
+ if (frame) frame.removeAttribute("src")
159
+ }
160
+
161
+ // Installs the popup's behaviour, once per document. Answers false when it was
162
+ // already installed.
163
+ export function installBookingPopup(win, doc) {
164
+ if (win.__studioBookingPopupArmed) return false
165
+ win.__studioBookingPopupArmed = true
166
+
167
+ doc.addEventListener("click", (event) => { handleBookingLinkClick(event, win, doc) })
168
+
169
+ // A click on the backdrop lands on the dialog element itself.
170
+ doc.addEventListener("click", (event) => {
171
+ if (event.target.matches && event.target.matches(DIALOG)) event.target.close()
172
+ })
173
+
174
+ doc.addEventListener("turbo:before-cache", () => resetBookingDialog(doc))
175
+ return true
176
+ }
@@ -0,0 +1,41 @@
1
+ // board: one board primitive (studio/board/_board), on the engine's Stimulus
2
+ // application, which reads data-studio-controller and registers this controller
3
+ // lazily, the first time a page renders a board. It sits on the element that
4
+ // carries the board's Alpine scope:
5
+ //
6
+ // <section x-data="studioBoard({...})" data-studio-controller="board">
7
+ //
8
+ // The scope (studio/board) owns the state, the requests and the toasts. This
9
+ // controller owns what needs the page: it loads SortableJS, so only a page with
10
+ // a board fetches it, and wires the zones when both the library and the scope
11
+ // are there. Until it connects the board renders and its chrome works; a card
12
+ // does not drag, and the element carries no data-alpine-ready.
13
+ import { Controller } from "@hotwired/stimulus"
14
+ import { loadSortable, scopeFor } from "studio/board"
15
+
16
+ export default class extends Controller {
17
+ connect() {
18
+ const connection = this.connection = {}
19
+ // A board whose SortableJS fails to load is wired without it: it still
20
+ // counts, toasts and follows live updates.
21
+ const sortable = loadSortable().catch((error) => {
22
+ console.error("[studio] SortableJS failed to load; the board does not drag", error)
23
+ return null
24
+ })
25
+
26
+ Promise.all([scopeFor(this.element), sortable]).then(([scope, Sortable]) => {
27
+ if (this.connection !== connection) return
28
+ connection.unwire = scope.wire(Sortable)
29
+ })
30
+ }
31
+
32
+ disconnect() {
33
+ this.unwire()
34
+ }
35
+
36
+ unwire() {
37
+ const connection = this.connection
38
+ this.connection = null
39
+ if (connection && connection.unwire) connection.unwire()
40
+ }
41
+ }
@@ -0,0 +1,24 @@
1
+ // booking: the booking frame and the booking popup, on the engine's Stimulus
2
+ // application (studio/stimulus, which reads data-studio-controller and
3
+ // registers this controller lazily). It sits on the frame's wrapper
4
+ // (studio/booking/_frame) and on the popup's dialog (studio/booking/_popup):
5
+ //
6
+ // <div class="booking-frame" data-booking-wrap data-studio-booking
7
+ // data-studio-controller="booking">
8
+ // <dialog class="booking-popup" data-booking-dialog data-studio-booking
9
+ // data-studio-controller="booking">
10
+ //
11
+ // The behaviour is studio/booking, installed once per document however many of
12
+ // these connect. Until it is installed a booking link is an ordinary link to
13
+ // the booking page.
14
+ import { Controller } from "@hotwired/stimulus"
15
+ import { installBookingFrames, installBookingPopup, armBookingFramesAfterLoad } from "studio/booking"
16
+
17
+ export default class extends Controller {
18
+ connect() {
19
+ const first = installBookingFrames(window, document)
20
+ installBookingPopup(window, document)
21
+ // A frame that arrives after the install (a stream, a second wrapper).
22
+ if (!first) armBookingFramesAfterLoad(window, document)
23
+ }
24
+ }
@@ -0,0 +1,22 @@
1
+ // email-banner-scale: fits every email banner preview on the page to its frame,
2
+ // on the engine's Stimulus application (studio/stimulus, which reads
3
+ // data-studio-controller and registers this controller lazily). It sits on the
4
+ // anchor studio/emails/_banner_scale renders, once per page:
5
+ //
6
+ // <template data-studio-controller="email-banner-scale"></template>
7
+ //
8
+ // The fit is studio/email_banner_scale. Connecting again after a Turbo visit
9
+ // fits the new body's previews; disconnecting drops the observer.
10
+ import { Controller } from "@hotwired/stimulus"
11
+ import { startBannerScale } from "studio/email_banner_scale"
12
+
13
+ export default class extends Controller {
14
+ connect() {
15
+ this.stop = startBannerScale(document, window.ResizeObserver)
16
+ }
17
+
18
+ disconnect() {
19
+ if (this.stop) this.stop()
20
+ this.stop = null
21
+ }
22
+ }
@@ -0,0 +1,22 @@
1
+ // footer-map: one live map of an address, on the engine's Stimulus application
2
+ // (studio/stimulus, which reads data-studio-controller and registers this
3
+ // controller lazily). It sits on the map element studio/site_footer/_map
4
+ // renders:
5
+ //
6
+ // <div class="ftr-map" data-footer-map data-studio-controller="footer-map"
7
+ // data-lat="..." data-lng="..." data-leaflet-js="..." data-leaflet-css="...">
8
+ //
9
+ // The mount is studio/footer_map, installed once per document however many maps
10
+ // connect. Until Leaflet mounts, and if it never does, the link inside the
11
+ // element is the whole map.
12
+ import { Controller } from "@hotwired/stimulus"
13
+ import { installFooterMaps } from "studio/footer_map"
14
+
15
+ export default class extends Controller {
16
+ connect() {
17
+ const installed = !window.__studioFooterMapsArmed
18
+ const arm = installFooterMaps(window, document)
19
+ // A map that arrives after the install (a stream, a second map).
20
+ if (!installed) arm()
21
+ }
22
+ }
@@ -0,0 +1,24 @@
1
+ // geo-settings: the geo manager's live preview, on the engine's Stimulus
2
+ // application (studio/stimulus, which reads data-studio-controller and
3
+ // registers this controller lazily). It sits on the page's root:
4
+ //
5
+ // <div data-geo-page data-studio-controller="geo-settings"
6
+ // data-geo-home="US" data-geo-country="US" data-geo-subdivision="CO"
7
+ // data-geo-fail-closed="true">
8
+ //
9
+ // The rules and the painting are studio/geo_settings. Any change inside the
10
+ // page repaints.
11
+ import { Controller } from "@hotwired/stimulus"
12
+ import { paintGeo } from "studio/geo_settings"
13
+
14
+ export default class extends Controller {
15
+ connect() {
16
+ this.paint = () => paintGeo(this.element, document)
17
+ this.element.addEventListener("change", this.paint)
18
+ this.paint()
19
+ }
20
+
21
+ disconnect() {
22
+ this.element.removeEventListener("change", this.paint)
23
+ }
24
+ }
@@ -0,0 +1,64 @@
1
+ // hold-button: one hold-to-confirm button, on the engine's Stimulus application
2
+ // (studio/stimulus, which reads data-studio-controller and imports this
3
+ // controller statically, so it arrives with the page). It sits on the
4
+ // stack studio/_hold_button renders, and the button carries the presses:
5
+ //
6
+ // <span class="hold-stack" data-studio-controller="hold-button">
7
+ // <button class="hold-btn" data-hold-id="confirm" data-duration="2000"
8
+ // data-studio-action="mousedown->hold-button#start mouseup->hold-button#end
9
+ // mouseleave->hold-button#end touchstart->hold-button#press
10
+ // touchend->hold-button#end touchcancel->hold-button#end">
11
+ //
12
+ // The timeline, the idle nudge and the fizz portal are studio/hold_button; the
13
+ // events and string locals the timeline asks are studio/hold_button_hooks.
14
+ import { Controller } from "@hotwired/stimulus"
15
+ import { Hold, mountPortal } from "studio/hold_button"
16
+ import { holdHooks } from "studio/hold_button_hooks"
17
+
18
+ const NUDGES = ["hold-nudge", "hold-nudge-soft"]
19
+
20
+ // What a connected stack carries. The guard in the head
21
+ // (studio/_hold_button_guard) marks a stack that never gets it as unavailable,
22
+ // and takes that mark back here when the controller connects late.
23
+ const CONNECTED = "data-hold-button-connected"
24
+
25
+ export default class extends Controller {
26
+ connect() {
27
+ this.element.setAttribute(CONNECTED, "")
28
+ if (window.studioHoldButtonGuard) window.studioHoldButtonGuard.clear(this.element)
29
+
30
+ this.button = this.element.querySelector(":scope > .hold-btn")
31
+ if (!this.button) return
32
+
33
+ this.hold = new Hold(this.button, holdHooks(this.button, { win: window }))
34
+ this.clearNudge = (event) => {
35
+ if (NUDGES.includes(event.animationName)) this.button.classList.remove("nudge", "nudge-soft")
36
+ }
37
+ this.button.addEventListener("animationend", this.clearNudge)
38
+ this.hold.nudge.start()
39
+ if (this.element.hasAttribute("data-fizz-portal")) mountPortal(this.element)
40
+ }
41
+
42
+ disconnect() {
43
+ this.element.removeAttribute(CONNECTED)
44
+ if (!this.hold) return
45
+ this.hold.stop()
46
+ this.hold = null
47
+ this.button.removeEventListener("animationend", this.clearNudge)
48
+ // The portal notices its stack leaving by itself (studio/hold_button).
49
+ }
50
+
51
+ start() {
52
+ if (this.hold) this.hold.start()
53
+ }
54
+
55
+ // A touch: no synthetic mouse events, no long-press menu.
56
+ press(event) {
57
+ event.preventDefault()
58
+ this.start()
59
+ }
60
+
61
+ end() {
62
+ if (this.hold) this.hold.end()
63
+ }
64
+ }
@@ -0,0 +1,25 @@
1
+ // link-preview-card: the site identity manager's live card, on the engine's
2
+ // Stimulus application (studio/stimulus, which reads data-studio-controller and
3
+ // registers this controller lazily). It sits on the page's root:
4
+ //
5
+ // <div data-link-preview-page data-studio-controller="link-preview-card"
6
+ // data-fallback-title="..." data-fallback-description="...">
7
+ //
8
+ // The repaint is studio/link_preview_card. One listener on the root takes the
9
+ // `input` of every [data-link-preview-input] inside it.
10
+ import { Controller } from "@hotwired/stimulus"
11
+ import { paintCard } from "studio/link_preview_card"
12
+
13
+ export default class extends Controller {
14
+ connect() {
15
+ this.repaint = (event) => {
16
+ const input = event.target
17
+ if (input && input.matches && input.matches("[data-link-preview-input]")) paintCard(this.element, input)
18
+ }
19
+ this.element.addEventListener("input", this.repaint)
20
+ }
21
+
22
+ disconnect() {
23
+ this.element.removeEventListener("input", this.repaint)
24
+ }
25
+ }
@@ -0,0 +1,18 @@
1
+ // link-sidebar: the link sidebar's presence on the page, on the engine's
2
+ // Stimulus application (studio/application, which reads data-studio-controller).
3
+ // It sits on the anchor components/_link_sidebar renders beside its panels:
4
+ //
5
+ // <template data-studio-controller="link-sidebar"></template>
6
+ //
7
+ // The open flag and the document handlers are studio/link_sidebar, which
8
+ // registers $store.sidebars.linkTreeOpen on alpine:init and before a Turbo
9
+ // render. connect() registers it too, for a sidebar that arrived any other way.
10
+ // Registered by studio/application.
11
+ import { Controller } from "@hotwired/stimulus"
12
+ import { ensureLinkSidebarStore } from "studio/link_sidebar"
13
+
14
+ export default class extends Controller {
15
+ connect() {
16
+ ensureLinkSidebarStore(window.Alpine)
17
+ }
18
+ }
@@ -0,0 +1,18 @@
1
+ // survey: the survey stepper, on the engine's Stimulus application
2
+ // (studio/stimulus, which reads data-studio-controller and registers this
3
+ // controller lazily). It sits on the survey's root (studio/surveys/show):
4
+ //
5
+ // <main class="studio-survey" data-studio-survey data-studio-controller="survey"
6
+ // data-answer-base="..." data-resume-index="..." data-csrf="...">
7
+ //
8
+ // The stepper is studio/survey, which binds a root once. A page restored from
9
+ // Turbo's cache is a new element, so it connects and is bound again. Until the
10
+ // controller connects, and if it never does, the page is the plain form.
11
+ import { Controller } from "@hotwired/stimulus"
12
+ import { enhanceSurvey } from "studio/survey"
13
+
14
+ export default class extends Controller {
15
+ connect() {
16
+ enhanceSurvey(this.element, window, document)
17
+ }
18
+ }
@@ -0,0 +1,26 @@
1
+ // toast: the toast root's lifetime, on the engine's Stimulus application
2
+ // (studio/application, which reads data-studio-controller). It sits on the
3
+ // root of layouts/studio/_flash:
4
+ //
5
+ // <div x-data data-studio-controller="toast"
6
+ // data-toast-initial-value='[{"type":"notice","message":"Saved"}]'>
7
+ //
8
+ // The queue is studio/toast ($store.toasts), which also seeds a rendered root
9
+ // on a full load and on turbo:load. connect() seeds this root too, for markup
10
+ // that arrived any other way; an element is seeded once. disconnect() empties
11
+ // the queue when the root leaves the page with no other root left to show it.
12
+ // Registered by studio/application.
13
+ import { Controller } from "@hotwired/stimulus"
14
+ import { seedToasts, TOAST_ROOT } from "studio/toast"
15
+
16
+ export default class extends Controller {
17
+ connect() {
18
+ seedToasts(this.element, { win: window })
19
+ }
20
+
21
+ disconnect() {
22
+ if (document.querySelector(TOAST_ROOT)) return
23
+ const store = window.Alpine && window.Alpine.store("toasts")
24
+ if (store && typeof store.clear === "function") store.clear()
25
+ }
26
+ }