studio-engine 0.96.2.rc1 → 0.97.0.rc1

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,138 @@
1
+ // studio/lazy_controllers: registers a page-specific controller the first time
2
+ // an element names it, so its module is fetched only by a page that uses it.
3
+ //
4
+ // watchLazyControllers({
5
+ // root: document.documentElement,
6
+ // attribute: "data-studio-controller",
7
+ // loaders: { board: () => import("studio/controllers/board_controller") },
8
+ // register: (name, controller) => application.register(name, controller)
9
+ // })
10
+ //
11
+ // An every-page controller is imported statically, which puts it in the
12
+ // preloaded boot graph. A lazy one is named here by a dynamic import, which the
13
+ // boot graph does not follow.
14
+ //
15
+ // UNTIL A LAZY CONTROLLER REGISTERS its element's actions do nothing: Stimulus
16
+ // ignores an action whose controller it does not know.
17
+ //
18
+ // A LOAD THAT FAILS IS FINAL FOR THE DOCUMENT. A browser keeps a failed module
19
+ // fetch in the document's module map and rejects every later import() of it
20
+ // without a new request, across DOM changes and Turbo visits; only a full page
21
+ // load fetches it again. So the registry imports a controller once. When the
22
+ // import fails it reports the failure once and marks every element that names
23
+ // the controller, those on the page and those that arrive later:
24
+ //
25
+ // <div data-studio-controller="board" data-studio-controller-failed="board">
26
+ //
27
+ // A control that must work whenever its page does is therefore not lazy: the
28
+ // hold button is imported statically by studio/stimulus.
29
+ //
30
+ // It imports nothing; test/javascript/lazy_controllers.test.mjs loads it as a
31
+ // data: module.
32
+
33
+ // The attribute a failed controller's elements carry: the identifiers, among
34
+ // those the element names, whose module failed to load.
35
+ export const FAILED_ATTRIBUTE = "data-studio-controller-failed"
36
+
37
+ // The identifiers in one data-studio-controller value.
38
+ export function controllerNames(value) {
39
+ return String(value || "").split(/\s+/).filter(Boolean)
40
+ }
41
+
42
+ // `root` itself, when it carries `attribute`, and every element beneath it
43
+ // that does.
44
+ function elementsWith(root, attribute) {
45
+ const elements = []
46
+ if (!root) return elements
47
+ if (typeof root.getAttribute === "function" && root.getAttribute(attribute) != null) elements.push(root)
48
+ if (typeof root.querySelectorAll === "function") elements.push(...root.querySelectorAll(`[${attribute}]`))
49
+ return elements
50
+ }
51
+
52
+ // The identifiers from `names` that an element in `root`, or `root` itself,
53
+ // carries in `attribute`.
54
+ export function lazyNamesIn(root, names, attribute) {
55
+ const wanted = new Set(names)
56
+ const found = new Set()
57
+ if (wanted.size === 0) return []
58
+
59
+ for (const element of elementsWith(root, attribute)) {
60
+ for (const name of controllerNames(element.getAttribute(attribute))) {
61
+ if (wanted.has(name)) found.add(name)
62
+ }
63
+ }
64
+ return [...found]
65
+ }
66
+
67
+ // Writes `failedAttribute` on every element in `root` that names a controller
68
+ // in `failed`. Returns the elements it marked on this pass.
69
+ export function markFailed(root, failed, attribute, failedAttribute = FAILED_ATTRIBUTE) {
70
+ const lost = new Set(failed)
71
+ const marked = []
72
+ if (lost.size === 0) return marked
73
+
74
+ for (const element of elementsWith(root, attribute)) {
75
+ const names = controllerNames(element.getAttribute(attribute)).filter((name) => lost.has(name))
76
+ if (names.length === 0 || typeof element.setAttribute !== "function") continue
77
+
78
+ const value = [...new Set(names)].join(" ")
79
+ if (element.getAttribute(failedAttribute) === value) continue
80
+ element.setAttribute(failedAttribute, value)
81
+ marked.push(element)
82
+ }
83
+ return marked
84
+ }
85
+
86
+ // Loads and registers each lazy controller once, on first sight: now, for the
87
+ // elements already in `root`, and after any change beneath it. A controller
88
+ // whose load fails is never loaded again; its elements are marked instead.
89
+ // Returns { pending, failed, scan, stop, started }. The observer stops by
90
+ // itself once every controller has registered; while one has failed it keeps
91
+ // watching, to mark the elements that arrive later.
92
+ export function watchLazyControllers({ root, attribute, loaders, register, Observer, report, failedAttribute }) {
93
+ const pending = new Set(Object.keys(loaders || {}))
94
+ const loading = new Set()
95
+ const failed = new Map()
96
+ const complain = report || ((name, error) => console.error(
97
+ `[studio] the ${name} controller failed to load and stays unavailable until the page is reloaded`, error
98
+ ))
99
+ let observer = null
100
+
101
+ const stop = () => {
102
+ if (observer) observer.disconnect()
103
+ observer = null
104
+ }
105
+
106
+ const mark = () => markFailed(root, failed.keys(), attribute, failedAttribute || FAILED_ATTRIBUTE)
107
+
108
+ const load = (name) => {
109
+ pending.delete(name)
110
+ loading.add(name)
111
+ return Promise.resolve()
112
+ .then(() => loaders[name]())
113
+ .then((module) => {
114
+ register(name, module && module.default ? module.default : module)
115
+ loading.delete(name)
116
+ if (pending.size === 0 && loading.size === 0 && failed.size === 0) stop()
117
+ })
118
+ .catch((error) => {
119
+ loading.delete(name)
120
+ failed.set(name, error)
121
+ mark()
122
+ complain(name, error)
123
+ })
124
+ }
125
+
126
+ const scan = () => {
127
+ mark()
128
+ return Promise.all(lazyNamesIn(root, [...pending], attribute).map(load))
129
+ }
130
+
131
+ const ObserverClass = Observer || (typeof MutationObserver !== "undefined" ? MutationObserver : null)
132
+ if (ObserverClass && pending.size > 0) {
133
+ observer = new ObserverClass(() => { if (pending.size > 0 || failed.size > 0) scan() })
134
+ observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: [attribute] })
135
+ }
136
+
137
+ return { pending, failed, scan, stop, started: scan() }
138
+ }
@@ -0,0 +1,252 @@
1
+ // studio/leveling_activity: the Alpine scope behind the leveling-activity modals
2
+ // (studio/modals/blocks/_leveling_activity and its _change_username
3
+ // specialisation): one action, with or without a single-line input, that an app
4
+ // endpoint saves.
5
+ //
6
+ // <div x-data="levelingActionModal({ hasInput: true, submitUrl: '/account/username' })">
7
+ //
8
+ // The modal mounts inside a modal host's template, so Alpine evaluates the
9
+ // x-data when the modal opens. studio/alpine_shims imports this module and
10
+ // publishes the factory as window.levelingActionModal on every page.
11
+ //
12
+ // UI ONLY. It POSTs to an app-supplied submitUrl and reads a neutral JSON
13
+ // contract. A second step some apps need is delegated opaquely: the engine
14
+ // hands the app's "challenge" blob to the app's window[finalizeHook] and POSTs
15
+ // back whatever "proof" the hook returns. It inspects neither.
16
+ //
17
+ // The contract (submitUrl and finalizeUrl answer JSON):
18
+ // POST submitUrl { value } answers one of
19
+ // { status: "saved", seeds_earned?, seeds_total? } done
20
+ // { status: "needs_step", challenge, token } the app's step is required
21
+ // { status: "error", message } the message is shown
22
+ // on needs_step, with a finalizeHook:
23
+ // proof = await window[finalizeHook](challenge, { token, onProgress })
24
+ // POST finalizeUrl { token, proof } answers { status: "saved" | "error", ... }
25
+ //
26
+ // opts:
27
+ // hasInput true renders and validates a single-line input; false is a
28
+ // plain "Complete" action.
29
+ // initialValue the input's starting value, a string.
30
+ // minLength the input's minimum length, checked in the browser (0 = none).
31
+ // submitUrl the app endpoint the action POSTs to; the app owns the save.
32
+ // finalizeUrl the app endpoint for the second step, when there is one.
33
+ // finalizeHook the name of the window async function that runs that step.
34
+ // savedEvent the window event dispatched on success, carrying the app's
35
+ // payload. Default "studio:activity-saved". A caller that names
36
+ // its own event runs the follow-on itself (see appDrivenFollowOn).
37
+ // store the Alpine modal store behind close(). Default "modals".
38
+ // leveling the default for the seeds celebration. The live value is the
39
+ // open modal's props.leveling (see the leveling getter).
40
+ // hasConsent true renders a consent checkbox that gates the action.
41
+ // demo the style-guide preview: it resolves locally, POSTs nothing
42
+ // and dispatches no saved event.
43
+ // seedsPerLevel seeds per level for the celebration bar. Default 100.
44
+ // demoSeedsEarned, demoSeedsTotal the demo's seed payload. Default 30 and
45
+ // seedsPerLevel, which crosses a level.
46
+ //
47
+ // It imports nothing; test/javascript/leveling_activity.test.mjs loads it as a
48
+ // data: module.
49
+
50
+ export const DEFAULT_SAVED_EVENT = "studio:activity-saved"
51
+
52
+ const GENERIC_ERROR = "Something went wrong. Please try again."
53
+ const STEP_ERROR = "Couldn't complete the step."
54
+
55
+ // Whether the action can run. An input activity needs a real change of at
56
+ // least minLength; a plain action runs once. An unticked consent box gates both.
57
+ export function actionable({ hasConsent, consent, hasInput, saved, value, original, minLength }) {
58
+ if (hasConsent && !consent) return false
59
+ if (!hasInput) return !saved
60
+
61
+ const trimmed = (value || "").trim()
62
+ if (trimmed.length < minLength) return false
63
+ return trimmed !== (original || "").trim()
64
+ }
65
+
66
+ // A response's status: the body's own, or "saved" for a 2xx that names none.
67
+ export function responseStatus(result) {
68
+ const body = (result && result.body) || {}
69
+ return body.status || (result && result.ok ? "saved" : "error")
70
+ }
71
+
72
+ // The demo's seed payload: the pair the caller chose, or one that crosses a level.
73
+ export function demoSeeds(earned, total, seedsPerLevel) {
74
+ return {
75
+ seeds_earned: Number.isNaN(earned) ? 30 : earned,
76
+ seeds_total: Number.isNaN(total) ? seedsPerLevel : total
77
+ }
78
+ }
79
+
80
+ export function levelingActionModal(opts, { win = window, doc = document } = {}) {
81
+ opts = opts || {}
82
+ return {
83
+ hasInput: !!opts.hasInput,
84
+ value: opts.initialValue || "",
85
+ original: opts.initialValue || "",
86
+ minLength: parseInt(opts.minLength, 10) || 0,
87
+ submitUrl: opts.submitUrl || "",
88
+ finalizeUrl: opts.finalizeUrl || "",
89
+ finalizeHook: opts.finalizeHook || "",
90
+ savedEvent: opts.savedEvent || DEFAULT_SAVED_EVENT,
91
+ store: opts.store || "modals",
92
+ _levelingDefault: !!opts.leveling,
93
+ hasConsent: !!opts.hasConsent,
94
+ consent: false,
95
+ demo: !!opts.demo,
96
+ seedsPerLevel: parseInt(opts.seedsPerLevel, 10) || 100,
97
+ demoSeedsEarned: parseInt(opts.demoSeedsEarned, 10),
98
+ demoSeedsTotal: parseInt(opts.demoSeedsTotal, 10),
99
+ saving: false,
100
+ error: "",
101
+ saved: false,
102
+ celebrate: false,
103
+ progressLabel: "",
104
+ seedsEarned: 0,
105
+ seedsTotal: 0,
106
+
107
+ // The props of the modal that is open on this scope's store, or null.
108
+ _props() {
109
+ const store = (win.Alpine && win.Alpine.store) ? win.Alpine.store(this.store) : null
110
+ const current = (store && typeof store.current === "function") ? store.current() : null
111
+ return (current && current.props) || null
112
+ },
113
+
114
+ // props.celebrate opens the modal already saved, at the updated state, with
115
+ // the seeds the celebration animates from.
116
+ init() {
117
+ const props = this._props() || {}
118
+ if (!props.celebrate) return
119
+
120
+ this.seedsEarned = parseInt(props.seedsEarned, 10) || 0
121
+ this.seedsTotal = parseInt(props.seedsTotal, 10) || 0
122
+ this.saved = true
123
+ this.celebrate = true
124
+ },
125
+
126
+ // Read from the open modal's props on every access, so one modal id shows
127
+ // the seeds celebration or the plain confirmation as a toggle moves. A modal
128
+ // whose props do not say keeps the default it was rendered with.
129
+ get leveling() {
130
+ const props = this._props()
131
+ if (props && typeof props.leveling === "boolean") return props.leveling
132
+ return this._levelingDefault
133
+ },
134
+
135
+ get changed() {
136
+ return actionable(this)
137
+ },
138
+
139
+ // A caller that is not a demo and names its own saved event owns what
140
+ // follows a save: its listener closes, swaps or advances the modal. The
141
+ // engine then dispatches the event and stops, so the app's next step
142
+ // renders once. A demo, and a caller on the default event, get the engine's
143
+ // own confirmation or celebration.
144
+ get appDrivenFollowOn() {
145
+ if (this.demo) return false
146
+ return this.savedEvent !== DEFAULT_SAVED_EVENT
147
+ },
148
+
149
+ // A demo dispatches nothing: a host listening for the saved event would
150
+ // open its own follow-on modal over the preview.
151
+ _dispatch(payload) {
152
+ if (this.demo) return
153
+ try {
154
+ win.dispatchEvent(new win.CustomEvent(this.savedEvent, { detail: payload || {} }))
155
+ } catch (error) {}
156
+ },
157
+
158
+ _finishSaved(payload) {
159
+ payload = payload || {}
160
+ this.saving = false
161
+ this.saved = true
162
+ this.progressLabel = ""
163
+ if (this.hasInput) this.original = (this.value || "").trim()
164
+ this._dispatch(payload)
165
+ // The app's listener owns close against swap, so the engine closes nothing.
166
+ if (this.appDrivenFollowOn) return
167
+
168
+ // The updated state. Which view shows it (the seeds celebration or the
169
+ // plain confirmation) is the leveling getter's call.
170
+ this.seedsEarned = parseInt(payload.seeds_earned, 10) || 0
171
+ this.seedsTotal = parseInt(payload.seeds_total, 10) || 0
172
+ this.celebrate = true
173
+ this._syncCelebrate(true)
174
+ },
175
+
176
+ // Mirrors celebrate onto the open modal's props, which the style guide's
177
+ // glow reads, so the glow follows the modal from its input card to its
178
+ // updated card.
179
+ _syncCelebrate(value) {
180
+ const props = this._props()
181
+ if (props) props.celebrate = !!value
182
+ },
183
+
184
+ _post(url, body) {
185
+ const csrf = (doc.querySelector('meta[name="csrf-token"]') || {}).content || ""
186
+ const fetcher = win.authedFetch || win.fetch
187
+ return fetcher(url, {
188
+ method: "POST",
189
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
190
+ body: JSON.stringify(body || {})
191
+ }).then((response) => response.json().then((json) => ({ ok: response.ok, body: json || {} })))
192
+ },
193
+
194
+ save() {
195
+ if (this.saving || !this.changed) return
196
+ this.saving = true
197
+ this.error = ""
198
+
199
+ if (this.demo) {
200
+ const seeds = demoSeeds(this.demoSeedsEarned, this.demoSeedsTotal, this.seedsPerLevel)
201
+ setTimeout(() => { this._finishSaved(seeds) }, 600)
202
+ return
203
+ }
204
+
205
+ const body = this.hasInput ? { value: (this.value || "").trim() } : {}
206
+ this._post(this.submitUrl, body)
207
+ .then((result) => this._handle(result))
208
+ .catch((error) => {
209
+ this.error = (error && error.message) || GENERIC_ERROR
210
+ this.saving = false
211
+ })
212
+ },
213
+
214
+ _handle(result) {
215
+ const body = (result && result.body) || {}
216
+ const status = responseStatus(result)
217
+ if (status === "saved") { this._finishSaved(body); return }
218
+ if (status === "needs_step") return this._step(body)
219
+ this.error = body.message || GENERIC_ERROR
220
+ this.saving = false
221
+ },
222
+
223
+ // The second step. The app's hook turns the challenge into a proof, and the
224
+ // proof goes back to finalizeUrl with the token.
225
+ _step(body) {
226
+ const hook = this.finalizeHook && win[this.finalizeHook]
227
+ if (typeof hook !== "function") {
228
+ this.error = "This step isn't available."
229
+ this.saving = false
230
+ return
231
+ }
232
+
233
+ return Promise.resolve(
234
+ hook(body.challenge, {
235
+ token: body.token,
236
+ onProgress: (label) => { this.progressLabel = label || "" }
237
+ })
238
+ ).then((proof) => this._post(this.finalizeUrl, { token: body.token, proof })).then((result) => {
239
+ const answer = (result && result.body) || {}
240
+ if (responseStatus(result) === "saved") {
241
+ this._finishSaved(answer)
242
+ } else {
243
+ this.error = answer.message || STEP_ERROR
244
+ this.saving = false
245
+ }
246
+ }).catch((error) => {
247
+ this.error = (error && error.message) || STEP_ERROR
248
+ this.saving = false
249
+ })
250
+ }
251
+ }
252
+ }
@@ -0,0 +1,34 @@
1
+ // studio/link_preview_card: the live card on the site identity manager
2
+ // (/admin/link_preview). Typing in the title or the description repaints the
3
+ // card before anything is saved, falling back to what a blank field really
4
+ // sends.
5
+ //
6
+ // It imports nothing; test/javascript/link_preview_card.test.mjs loads it as a
7
+ // data: module. Bound to the page by the link-preview-card controller.
8
+
9
+ // What the card shows for a field: its trimmed value, or the fallback when the
10
+ // field is blank.
11
+ export function cardText(value, fallback) {
12
+ const trimmed = String(value == null ? "" : value).trim()
13
+ return trimmed || fallback || ""
14
+ }
15
+
16
+ // The page's fallbacks, from data-fallback-title and data-fallback-description.
17
+ export function fallbacksOf(page) {
18
+ return {
19
+ title: page.dataset.fallbackTitle || "",
20
+ description: page.dataset.fallbackDescription || ""
21
+ }
22
+ }
23
+
24
+ // Repaints the card node that `input` (a [data-link-preview-input]) feeds.
25
+ // Answers the text it wrote, or null when the input feeds no card node.
26
+ export function paintCard(page, input) {
27
+ const key = input && input.dataset ? input.dataset.linkPreviewInput : null
28
+ if (!key) return null
29
+ const target = page.querySelector("[data-link-preview-card-" + key + "]")
30
+ if (!target) return null
31
+ const text = cardText(input.value, fallbacksOf(page)[key])
32
+ target.textContent = text
33
+ return text
34
+ }
@@ -0,0 +1,157 @@
1
+ // studio/link_sidebar: the open flag behind components/_link_sidebar
2
+ // ($store.sidebars.linkTreeOpen) and the document handlers that flip it.
3
+ //
4
+ // The panel's open and closed state is the store flag alone, through x-show and
5
+ // x-transition. Nothing here sets `display`: an inline display:none makes
6
+ // Alpine skip the enter transition, and the panel pops instead of sliding.
7
+ //
8
+ // The partial renders markup only, plus one anchor for its controller:
9
+ //
10
+ // <template data-studio-controller="link-sidebar"></template>
11
+ //
12
+ // WHEN THE STORE REGISTERS. The panel's x-show reads $store.sidebars as Alpine
13
+ // starts, before Stimulus connects a controller, so the flag is registered from
14
+ // this module, on a page that renders the panel and only there:
15
+ //
16
+ // 1. alpine:init, on a full load.
17
+ // 2. turbo:before-render, for the incoming body, before Alpine sees it.
18
+ // 3. turbo:load, and the controller's connect(), for markup that arrived any
19
+ // other way.
20
+ //
21
+ // A host with its own `sidebars` store keeps it and gains the flag
22
+ // (turf-monster's holds gearOpen).
23
+ //
24
+ // THE DOCUMENT HANDLERS run in the capture phase, so the trigger works for a
25
+ // click that lands before Alpine has bound it. They are installed once, on
26
+ // every page, and do nothing where no link sidebar is rendered.
27
+ //
28
+ // WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
29
+ // its own nonced module tag as well as through the boot graph, like
30
+ // studio/alpine_stores. It imports nothing, and
31
+ // test/javascript/link_sidebar.test.mjs holds it to that.
32
+
33
+ // The panels components/_link_sidebar renders: the scope of every click this
34
+ // module claims.
35
+ export const LINK_SIDEBAR_PANELS = "#studio-link-sidebar, #studio-link-sidebar-mobile"
36
+ // A control that may toggle the sidebar, when its aria-controls names the panel.
37
+ export const LINK_SIDEBAR_TRIGGERS = "[data-link-sidebar-trigger], [data-username-display], [data-profile-image-toggle]"
38
+ const CONTROLS_PANEL = '[aria-controls~="studio-link-sidebar"]'
39
+ const CLOSE_BUTTON = "[data-link-sidebar-close]"
40
+
41
+ // Registers the flag: a new `sidebars` store, or one more key on the host's.
42
+ // Null before Alpine exists.
43
+ export function ensureLinkSidebarStore(Alpine) {
44
+ if (!Alpine || typeof Alpine.store !== "function") return null
45
+
46
+ const sidebars = Alpine.store("sidebars")
47
+ if (!sidebars) {
48
+ Alpine.store("sidebars", { linkTreeOpen: false })
49
+ return Alpine.store("sidebars")
50
+ }
51
+ if (typeof sidebars.linkTreeOpen === "undefined") sidebars.linkTreeOpen = false
52
+ return sidebars
53
+ }
54
+
55
+ // The store as it stands. Reading it registers nothing, so a page with no link
56
+ // sidebar gains no store from a click or a key press.
57
+ export function linkSidebarStore(Alpine) {
58
+ if (!Alpine || typeof Alpine.store !== "function") return null
59
+ return Alpine.store("sidebars") || null
60
+ }
61
+
62
+ export function closeLinkSidebar(Alpine) {
63
+ const sidebars = linkSidebarStore(Alpine)
64
+ if (sidebars && sidebars.linkTreeOpen) sidebars.linkTreeOpen = false
65
+ }
66
+
67
+ export function rendersLinkSidebar(root) {
68
+ return !!(root && typeof root.querySelector === "function" && root.querySelector(LINK_SIDEBAR_PANELS))
69
+ }
70
+
71
+ // Path 1, 2 and 3 above: the flag for a document or body that renders the panel.
72
+ export function registerLinkSidebar(Alpine, root) {
73
+ return rendersLinkSidebar(root) ? ensureLinkSidebarStore(Alpine) : null
74
+ }
75
+
76
+ // What a click on `target` means to the link sidebar:
77
+ //
78
+ // "close" the close button of one of ITS panels. components/_sidebar_panel
79
+ // is shared and stamps every panel's close button
80
+ // data-link-sidebar-close, so the button counts only inside a panel
81
+ // this component renders; another panel's is left to that panel.
82
+ // "toggle" a trigger whose aria-controls names the panel.
83
+ // "inside" anywhere else in one of its panels.
84
+ // "outside" the rest of the page.
85
+ export function clickIntent(target) {
86
+ if (!target || typeof target.closest !== "function") return "outside"
87
+
88
+ const closeButton = target.closest(CLOSE_BUTTON)
89
+ if (closeButton && closeButton.closest(LINK_SIDEBAR_PANELS)) return "close"
90
+
91
+ const trigger = target.closest(LINK_SIDEBAR_TRIGGERS)
92
+ if (trigger && trigger.matches(CONTROLS_PANEL)) return "toggle"
93
+
94
+ return target.closest(LINK_SIDEBAR_PANELS) ? "inside" : "outside"
95
+ }
96
+
97
+ const claim = (event) => {
98
+ event.preventDefault()
99
+ event.stopPropagation()
100
+ event.stopImmediatePropagation()
101
+ }
102
+
103
+ // The capture-phase click. A claimed click reaches no other listener, so the
104
+ // trigger's own Alpine @click cannot toggle the flag a second time.
105
+ export function handleLinkSidebarClick(event, Alpine) {
106
+ const intent = clickIntent(event.target)
107
+
108
+ if (intent === "close") {
109
+ claim(event)
110
+ closeLinkSidebar(Alpine)
111
+ return intent
112
+ }
113
+
114
+ if (intent === "toggle") {
115
+ const sidebars = ensureLinkSidebarStore(Alpine)
116
+ if (!sidebars) return null
117
+ claim(event)
118
+ sidebars.linkTreeOpen = !sidebars.linkTreeOpen
119
+ return intent
120
+ }
121
+
122
+ if (intent === "outside") closeLinkSidebar(Alpine)
123
+ return intent
124
+ }
125
+
126
+ export function handleLinkSidebarKeydown(event, Alpine) {
127
+ if (event.key === "Escape") closeLinkSidebar(Alpine)
128
+ }
129
+
130
+ const installed = new WeakSet()
131
+
132
+ // Once per window: a second call adds no second listener.
133
+ export function installLinkSidebar({ win, doc } = {}) {
134
+ const host = win || globalThis
135
+ const root = doc || host.document
136
+ if (!root || typeof root.addEventListener !== "function") return false
137
+ if (installed.has(host)) return false
138
+ installed.add(host)
139
+
140
+ const register = () => registerLinkSidebar(host.Alpine, root)
141
+ root.addEventListener("alpine:init", register)
142
+ root.addEventListener("turbo:load", register)
143
+ root.addEventListener("turbo:before-render", (event) => {
144
+ registerLinkSidebar(host.Alpine, event.detail && event.detail.newBody)
145
+ })
146
+ // A page restored from Turbo's cache or the bfcache comes back closed.
147
+ root.addEventListener("turbo:before-cache", () => closeLinkSidebar(host.Alpine))
148
+ host.addEventListener("pageshow", (event) => { if (event.persisted) closeLinkSidebar(host.Alpine) })
149
+ root.addEventListener("click", (event) => handleLinkSidebarClick(event, host.Alpine), true)
150
+ root.addEventListener("keydown", (event) => handleLinkSidebarKeydown(event, host.Alpine), true)
151
+
152
+ // Alpine already started (a host that loads it ahead of the module tags).
153
+ if (host.Alpine && host.Alpine.version) register()
154
+ return true
155
+ }
156
+
157
+ if (typeof window !== "undefined") installLinkSidebar({ win: window, doc: document })
@@ -0,0 +1,72 @@
1
+ // studio/stimulus: the engine's Stimulus application and its lazy controllers.
2
+ //
3
+ // The engine runs its own Stimulus application on its OWN attributes:
4
+ //
5
+ // data-studio-controller="nav-collapse" data-studio-action data-studio-target
6
+ //
7
+ // so a host's Stimulus application never sees an engine controller. That is not
8
+ // tidiness: stimulus-loading's lazy loader (cyvasse) imports
9
+ // "controllers/<identifier>_controller" for every data-controller it meets, and
10
+ // would log "Failed to autoload controller" for each engine element. Values and
11
+ // classes keep Stimulus' own naming (data-nav-collapse-scrolled-class).
12
+ // "@hotwired/stimulus" resolves to the engine's vendored copy unless the host
13
+ // pins its own.
14
+ //
15
+ // THREE WAYS A CONTROLLER REGISTERS. The every-page chrome (nav collapse, modal
16
+ // host, toast, link sidebar) is registered by studio/application, which imports
17
+ // it statically. The hold button is registered HERE, statically, because it
18
+ // confirms real actions (see below). A PAGE-SPECIFIC CONTROLLER is listed in
19
+ // LAZY below and registered by studio/lazy_controllers the first time an
20
+ // element names one.
21
+ //
22
+ // WHY THE HOLD BUTTON IS STATIC. A lazy controller is one more request, made
23
+ // when its element first appears: for the token modal, minutes after the page
24
+ // loaded. If that request fails once (a dropped connection, or a deploy that
25
+ // retired the digested file) the browser never fetches the module again in
26
+ // that document, and the button would render and take a press that does
27
+ // nothing. Imported here it arrives with the page, in the preloaded boot graph,
28
+ // and no later request stands between a press and its hold.
29
+ //
30
+ // WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
31
+ // its own nonced module tag as well as through the boot graph. Its only imports
32
+ // are Stimulus, studio/lazy_controllers and the hold button's controller (which
33
+ // imports studio/hold_button and studio/hold_button_hooks), so a throw or a
34
+ // missing file elsewhere in studio/application's graph still leaves a page its
35
+ // hold button.
36
+ import { Application, defaultSchema } from "@hotwired/stimulus"
37
+ import { watchLazyControllers } from "studio/lazy_controllers"
38
+ import HoldButtonController from "studio/controllers/hold_button_controller"
39
+
40
+ export const schema = {
41
+ ...defaultSchema,
42
+ controllerAttribute: "data-studio-controller",
43
+ actionAttribute: "data-studio-action",
44
+ targetAttribute: "data-studio-target"
45
+ }
46
+
47
+ // identifier -> its controller module. Dynamic imports, so none of these joins
48
+ // the every-page boot graph (Studio::Engine.javascript_boot_graph). A control
49
+ // that must work whenever its page does is not listed here: a failed load is
50
+ // final for the document (studio/lazy_controllers). The board is listed: a
51
+ // board whose controller failed still renders, counts and toasts, and says that
52
+ // its cards do not drag (data-studio-controller-failed="board").
53
+ export const LAZY = {
54
+ "board": () => import("studio/controllers/board_controller"),
55
+ "geo-settings": () => import("studio/controllers/geo_settings_controller"),
56
+ "link-preview-card": () => import("studio/controllers/link_preview_card_controller"),
57
+ "email-banner-scale": () => import("studio/controllers/email_banner_scale_controller"),
58
+ "booking": () => import("studio/controllers/booking_controller"),
59
+ "footer-map": () => import("studio/controllers/footer_map_controller"),
60
+ "survey": () => import("studio/controllers/survey_controller")
61
+ }
62
+
63
+ export const application = Application.start(document.documentElement, schema)
64
+
65
+ application.register("hold-button", HoldButtonController)
66
+
67
+ export const lazy = watchLazyControllers({
68
+ root: document.documentElement,
69
+ attribute: schema.controllerAttribute,
70
+ loaders: LAZY,
71
+ register: (name, controller) => application.register(name, controller)
72
+ })