studio-engine 0.97.0 → 0.98.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 +89 -0
- data/app/javascript/studio/alpine_scopes.js +66 -0
- data/app/javascript/studio/alpine_shims.js +4 -0
- data/app/javascript/studio/application.js +4 -1
- data/app/javascript/studio/birthday.js +351 -0
- data/app/javascript/studio/confirm_interstitial.js +44 -0
- data/app/javascript/studio/controllers/identity_mini_controller.js +32 -0
- data/app/javascript/studio/controllers/style_modals_controller.js +41 -0
- data/app/javascript/studio/cropper.js +281 -0
- data/app/javascript/studio/identity_mini.js +74 -0
- data/app/javascript/studio/image_upload.js +189 -0
- data/app/javascript/studio/profile_form.js +88 -0
- data/app/javascript/studio/stimulus.js +4 -0
- data/app/javascript/studio/style_guide.js +13 -0
- data/app/javascript/studio/style_modals.js +214 -0
- data/app/views/components/_avatar_cropper.html.erb +3 -38
- data/app/views/layouts/studio/_head.html.erb +12 -2
- data/app/views/studio/_alpine_scopes_guard.html.erb +178 -0
- data/app/views/studio/_birthday_assets.html.erb +6 -240
- data/app/views/studio/_confirm_interstitial.html.erb +31 -18
- data/app/views/studio/_cropper_assets.html.erb +9 -8
- data/app/views/studio/modals/_crop_photo.html.erb +1 -1
- data/app/views/studio/modals/_image_upload.html.erb +8 -321
- data/app/views/studio/modals/blocks/_birthday.html.erb +2 -3
- data/app/views/studio/profiles/_birthday_fields.html.erb +0 -1
- data/app/views/studio/profiles/_identity_mini.html.erb +5 -62
- data/app/views/studio/profiles/_plain_save.html.erb +11 -0
- data/app/views/studio/profiles/edit.html.erb +1 -6
- data/app/views/style/_modals.html.erb +36 -420
- data/lib/studio/version.rb +1 -1
- metadata +14 -3
- data/app/views/studio/profiles/_birthday_picker_script.html.erb +0 -146
- data/app/views/studio/profiles/_form_script.html.erb +0 -65
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// studio/style_guide: the living style guide's own controllers, registered on
|
|
2
|
+
// the engine's Stimulus application. style/_modals loads this module by its
|
|
3
|
+
// own nonced module tag, so only the style guide fetches it and it stays out of
|
|
4
|
+
// the every-page boot graph.
|
|
5
|
+
//
|
|
6
|
+
// A module tag and not an entry in studio/stimulus's LAZY table: the tag runs
|
|
7
|
+
// with the page, in document order, so the section's controller is connected
|
|
8
|
+
// by the time the document has loaded, and a script or a spec that drives the
|
|
9
|
+
// demos the moment the page is up finds them there.
|
|
10
|
+
import { application } from "studio/stimulus"
|
|
11
|
+
import StyleModalsController from "studio/controllers/style_modals_controller"
|
|
12
|
+
|
|
13
|
+
application.register("style-modals", StyleModalsController)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// studio/style_modals: what drives the living style guide's Modals section
|
|
2
|
+
// (style/_modals): the stack-behaviour demos, the enter/leave simulator whose
|
|
3
|
+
// controls are built from the live animation registry, and the stubs the
|
|
4
|
+
// wallet specimens need to run with no wallet.
|
|
5
|
+
//
|
|
6
|
+
// The section's own modal host is the engine's stack under the name dsModals
|
|
7
|
+
// (studio/modal_host registers it from the overlay's data attributes), so what
|
|
8
|
+
// the guide shows is the store an app runs. The style-modals controller binds
|
|
9
|
+
// this module to the section.
|
|
10
|
+
//
|
|
11
|
+
// It imports nothing; test/javascript/style_modals.test.mjs loads it as a
|
|
12
|
+
// data: module.
|
|
13
|
+
|
|
14
|
+
// The card the stack demos drive: style/modals/_ds_stack_demo, which belongs
|
|
15
|
+
// to the guide and to nothing that ships.
|
|
16
|
+
export const VEHICLE = "ds-stack-demo"
|
|
17
|
+
|
|
18
|
+
// The card the enter/leave simulator opens, so only the motion varies.
|
|
19
|
+
export const ANIM_DEMO_ID = "email-change-pending"
|
|
20
|
+
export const ANIM_DEMO_PROPS = { currentEmail: "you@example.com", newEmail: "new@example.com" }
|
|
21
|
+
|
|
22
|
+
// The four containers the simulator fills.
|
|
23
|
+
export const ANIM_CONTROL_IDS = {
|
|
24
|
+
enterButtons: "modal-anim-enter-buttons",
|
|
25
|
+
exitButtons: "modal-anim-exit-buttons",
|
|
26
|
+
enterSelect: "modal-anim-enter-select",
|
|
27
|
+
exitSelect: "modal-anim-exit-select"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A pending operation, as the vehicle opens: not dismissible, like a real one.
|
|
31
|
+
export const PENDING = {
|
|
32
|
+
state: "processing",
|
|
33
|
+
title: "Submitting entry",
|
|
34
|
+
message: "Awaiting confirmation",
|
|
35
|
+
dismissible: false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const SUCCESS = {
|
|
39
|
+
state: "success",
|
|
40
|
+
title: "Entry confirmed",
|
|
41
|
+
message: "Resolved in place — this is the same stack entry you opened.",
|
|
42
|
+
dismissible: true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const FAILURE = {
|
|
46
|
+
state: "error",
|
|
47
|
+
title: "Submission failed",
|
|
48
|
+
message: "The error face of that same entry, patched in by advance().",
|
|
49
|
+
dismissible: true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// How long the wallet stub takes to answer, so the picker's connecting state
|
|
53
|
+
// is visible.
|
|
54
|
+
export const WALLET_CONNECT_MS = 1100
|
|
55
|
+
|
|
56
|
+
// "Pop (default)", "Shake": a registry key as a control's label.
|
|
57
|
+
export function animationLabel(key) {
|
|
58
|
+
return key.charAt(0).toUpperCase() + key.slice(1) + (key === "pop" ? " (default)" : "")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The registry the page holds, read when asked and never copied: a key an app
|
|
62
|
+
// registers later grows a control on the next build.
|
|
63
|
+
export function animationRegistry(win) {
|
|
64
|
+
const registry = win.ModalAnimations || {}
|
|
65
|
+
return { enter: registry.enter || {}, exit: registry.exit || {} }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// How long a round trip holds the card before closing it: its entrance's own
|
|
69
|
+
// registry duration plus a beat.
|
|
70
|
+
export function roundTripHoldMs(win, enterAnim) {
|
|
71
|
+
const entrance = animationRegistry(win).enter[enterAnim] || {}
|
|
72
|
+
return (entrance.ms || 320) + 700
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The stubs solana-studio's wallet picker needs on a page with no wallet:
|
|
76
|
+
// a connect that performs none and answers success after a beat, and a
|
|
77
|
+
// provider that lists two wallets and has neither. A page that ships a real
|
|
78
|
+
// one keeps it.
|
|
79
|
+
export function installWalletStubs(win) {
|
|
80
|
+
win.dsWalletConnectDemo = win.dsWalletConnectDemo || function () {
|
|
81
|
+
return new Promise(function (resolve) {
|
|
82
|
+
win.setTimeout(function () { resolve({ success: true }) }, WALLET_CONNECT_MS)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
win.walletProvider = win.walletProvider || {
|
|
86
|
+
available: function () { return [{ name: "Phantom" }, { name: "Solflare" }] },
|
|
87
|
+
isMobile: function () { return false },
|
|
88
|
+
isAvailable: function () { return false }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The demo drivers. env: { win, doc, store } — the window, the document the
|
|
93
|
+
// simulator's controls live in, and the name of the section's modal store.
|
|
94
|
+
export function createModalDemos(env) {
|
|
95
|
+
const win = env.win
|
|
96
|
+
const doc = env.doc
|
|
97
|
+
const storeName = env.store || "dsModals"
|
|
98
|
+
const store = () => win.Alpine.store(storeName)
|
|
99
|
+
const element = (id) => doc.getElementById(id)
|
|
100
|
+
|
|
101
|
+
function openDemo(props) {
|
|
102
|
+
store().open(VEHICLE, Object.assign({}, PENDING, props || {}))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A state change on the card already open. advance() patches props without
|
|
106
|
+
// replacing the stack entry, so the vehicle's scope survives. Only a
|
|
107
|
+
// still-open vehicle is patched: a timer that fires after the card closed
|
|
108
|
+
// does nothing to whatever is on top now.
|
|
109
|
+
function advanceDemo(patch) {
|
|
110
|
+
const current = store().current()
|
|
111
|
+
if (!current || current.id !== VEHICLE || current._closing) return
|
|
112
|
+
store().advance(patch)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function animOpen(enterAnim, exitAnim) {
|
|
116
|
+
store().open(ANIM_DEMO_ID, Object.assign({}, ANIM_DEMO_PROPS, { enterAnim, exitAnim }))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Open, let the entrance settle, then close, so the chosen exit is what
|
|
120
|
+
// plays.
|
|
121
|
+
function animRoundTrip(enterAnim, exitAnim) {
|
|
122
|
+
animOpen(enterAnim, exitAnim)
|
|
123
|
+
win.setTimeout(function () { store().close() }, roundTripHoldMs(win, enterAnim))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Builds the quick buttons and the select options from the live registry.
|
|
127
|
+
// Each container is emptied first, so building again doubles nothing.
|
|
128
|
+
function buildAnimControls() {
|
|
129
|
+
const registry = animationRegistry(win)
|
|
130
|
+
const enterButtons = element(ANIM_CONTROL_IDS.enterButtons)
|
|
131
|
+
const exitButtons = element(ANIM_CONTROL_IDS.exitButtons)
|
|
132
|
+
const enterSelect = element(ANIM_CONTROL_IDS.enterSelect)
|
|
133
|
+
const exitSelect = element(ANIM_CONTROL_IDS.exitSelect)
|
|
134
|
+
|
|
135
|
+
const addOption = function (select, key) {
|
|
136
|
+
if (!select) return
|
|
137
|
+
const option = doc.createElement("option")
|
|
138
|
+
option.value = key
|
|
139
|
+
option.textContent = animationLabel(key)
|
|
140
|
+
if (key === "pop") option.selected = true
|
|
141
|
+
select.appendChild(option)
|
|
142
|
+
}
|
|
143
|
+
const addButton = function (container, key, onClick, suffix) {
|
|
144
|
+
if (!container) return
|
|
145
|
+
const button = doc.createElement("button")
|
|
146
|
+
button.type = "button"
|
|
147
|
+
button.className = "btn btn-outline btn-sm"
|
|
148
|
+
button.textContent = animationLabel(key) + (suffix || "")
|
|
149
|
+
button.onclick = onClick
|
|
150
|
+
container.appendChild(button)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
;[enterButtons, exitButtons, enterSelect, exitSelect].forEach(function (node) {
|
|
154
|
+
if (node) node.innerHTML = ""
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
Object.keys(registry.enter).forEach(function (key) {
|
|
158
|
+
addButton(enterButtons, key, function () { animOpen(key, "pop") }, " ↗")
|
|
159
|
+
addOption(enterSelect, key)
|
|
160
|
+
})
|
|
161
|
+
Object.keys(registry.exit).forEach(function (key) {
|
|
162
|
+
addButton(exitButtons, key, function () { animRoundTrip("pop", key) })
|
|
163
|
+
addOption(exitSelect, key)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
buildAnimControls,
|
|
169
|
+
|
|
170
|
+
// Stack behaviour
|
|
171
|
+
processing: function () { openDemo({}) },
|
|
172
|
+
success: function () { openDemo(SUCCESS) },
|
|
173
|
+
error: function () { openDemo(FAILURE) },
|
|
174
|
+
processThenSuccess: function () {
|
|
175
|
+
openDemo({ message: "Awaiting RPC confirmation" })
|
|
176
|
+
win.setTimeout(function () { advanceDemo(SUCCESS) }, 3000)
|
|
177
|
+
},
|
|
178
|
+
processThenError: function () {
|
|
179
|
+
openDemo({ message: "Awaiting RPC confirmation" })
|
|
180
|
+
win.setTimeout(function () { advanceDemo(FAILURE) }, 3000)
|
|
181
|
+
},
|
|
182
|
+
dismissible: function () {
|
|
183
|
+
openDemo({
|
|
184
|
+
dismissible: true,
|
|
185
|
+
title: "Dismissible processing",
|
|
186
|
+
message: "Escape and click-outside both close this one."
|
|
187
|
+
})
|
|
188
|
+
},
|
|
189
|
+
fastWithHold: function () {
|
|
190
|
+
openDemo({ title: "Fast operation", message: "Holding the spinner for at least 1500ms" })
|
|
191
|
+
win.StudioModals.holdAtLeast(1500).then(function () { advanceDemo(SUCCESS) })
|
|
192
|
+
},
|
|
193
|
+
fastNoHold: function () {
|
|
194
|
+
openDemo({ title: "Fast operation", message: "No hold, so this flashes past" })
|
|
195
|
+
win.setTimeout(function () { advanceDemo(SUCCESS) }, 50)
|
|
196
|
+
},
|
|
197
|
+
stackTwo: function () {
|
|
198
|
+
openDemo({
|
|
199
|
+
dismissible: true,
|
|
200
|
+
title: "Bottom of the stack",
|
|
201
|
+
message: "A second card is about to push in front of this one."
|
|
202
|
+
})
|
|
203
|
+
win.setTimeout(function () { store().open(ANIM_DEMO_ID, ANIM_DEMO_PROPS) }, 700)
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// Enter / leave simulator
|
|
207
|
+
animOpenSelected: function () {
|
|
208
|
+
animOpen(element(ANIM_CONTROL_IDS.enterSelect).value, element(ANIM_CONTROL_IDS.exitSelect).value)
|
|
209
|
+
},
|
|
210
|
+
animRoundTripSelected: function () {
|
|
211
|
+
animRoundTrip(element(ANIM_CONTROL_IDS.enterSelect).value, element(ANIM_CONTROL_IDS.exitSelect).value)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
Distinct from imageUploadHost (immediate-save): this STAGES the file and
|
|
9
9
|
lets the enclosing form own the submit (signup / profile edit).
|
|
10
10
|
|
|
11
|
+
The scope, avatarCropperHost(), is studio/image_upload, published on every
|
|
12
|
+
page by studio/alpine_scopes.
|
|
13
|
+
|
|
11
14
|
Locals: form_field_name (e.g. "user[avatar]"). %>
|
|
12
15
|
|
|
13
16
|
<%= render "studio/cropper_assets" %>
|
|
@@ -41,41 +44,3 @@
|
|
|
41
44
|
<input type="file" x-ref="filePicker" @change="onFileSelected($event)" accept="image/*" class="hidden">
|
|
42
45
|
</div>
|
|
43
46
|
|
|
44
|
-
<script>
|
|
45
|
-
function avatarCropperHost() {
|
|
46
|
-
return {
|
|
47
|
-
croppedUrl: null,
|
|
48
|
-
|
|
49
|
-
onFileSelected(event) {
|
|
50
|
-
var file = event.target.files[0];
|
|
51
|
-
if (!file) return;
|
|
52
|
-
var self = this;
|
|
53
|
-
var reader = new FileReader();
|
|
54
|
-
reader.onload = function(e) {
|
|
55
|
-
// Hand the raw image to the shared modal host; the crop-photo modal
|
|
56
|
-
// owns the cropper UI and posts back via 'crop-photo-confirmed'.
|
|
57
|
-
Alpine.store('modals').open('crop-photo', { imageUrl: e.target.result });
|
|
58
|
-
};
|
|
59
|
-
reader.readAsDataURL(file);
|
|
60
|
-
event.target.value = ''; // allow re-selecting the same file
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
applyCrop(blob) {
|
|
64
|
-
if (!blob) return;
|
|
65
|
-
if (this.croppedUrl) URL.revokeObjectURL(this.croppedUrl);
|
|
66
|
-
this.croppedUrl = URL.createObjectURL(blob);
|
|
67
|
-
var file = new File([blob], 'avatar.png', { type: 'image/png' });
|
|
68
|
-
var dt = new DataTransfer();
|
|
69
|
-
dt.items.add(file);
|
|
70
|
-
this.$refs.hiddenFileInput.files = dt.files;
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
removePhoto() {
|
|
74
|
-
if (this.croppedUrl) URL.revokeObjectURL(this.croppedUrl);
|
|
75
|
-
this.croppedUrl = null;
|
|
76
|
-
var dt = new DataTransfer();
|
|
77
|
-
this.$refs.hiddenFileInput.files = dt.files;
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
</script>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# The request's CSP nonce, for the
|
|
1
|
+
<%# The request's CSP nonce, for the three inline scripts left in this head and
|
|
2
2
|
for javascript_import_module_tag below (importmap-rails nonces its own tags).
|
|
3
3
|
nil when the host sets no nonce generator, and then no attribute renders. %>
|
|
4
4
|
<% studio_script_nonce = (content_security_policy_nonce if respond_to?(:content_security_policy_nonce)) %>
|
|
@@ -12,10 +12,14 @@
|
|
|
12
12
|
}
|
|
13
13
|
})();
|
|
14
14
|
<% end %>
|
|
15
|
-
<%# THE HOLD BUTTON'S GUARD. Inline,
|
|
15
|
+
<%# THE HOLD BUTTON'S GUARD. Inline, one of the two guards this head keeps:
|
|
16
16
|
it reports a hold button whose modules failed to load, so it can share no
|
|
17
17
|
request with them (studio/_hold_button_guard). %>
|
|
18
18
|
<%= render "studio/hold_button_guard", nonce: studio_script_nonce %>
|
|
19
|
+
<%# THE X-DATA FACTORIES' GUARD. Inline for the same reason: it reports a profile
|
|
20
|
+
form, a birthday card or an upload whose factory failed to load
|
|
21
|
+
(studio/_alpine_scopes_guard), and shows the form's plain Save. %>
|
|
22
|
+
<%= render "studio/alpine_scopes_guard", nonce: studio_script_nonce %>
|
|
19
23
|
|
|
20
24
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
21
25
|
<%# Studio.nav_spinner_min_ms, read by the nav spinner in studio/head_chrome. %>
|
|
@@ -198,6 +202,12 @@
|
|
|
198
202
|
the document click and Escape handlers. It imports nothing, and the
|
|
199
203
|
link-sidebar controller imports it. %>
|
|
200
204
|
<%= javascript_import_module_tag "studio/link_sidebar" %>
|
|
205
|
+
<%# THE X-DATA FACTORIES, BY THEIR OWN DOOR, for the same reason.
|
|
206
|
+
studio/alpine_scopes publishes the factories a page binds by name
|
|
207
|
+
(cropPhotoModal, imageUploadHost, submitFormWithProgress), each from the
|
|
208
|
+
module that owns it. Its imports import nothing, and studio/alpine_shims
|
|
209
|
+
imports it, so a failed boot leaves a page its uploads. %>
|
|
210
|
+
<%= javascript_import_module_tag "studio/alpine_scopes" %>
|
|
201
211
|
<%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Deferred
|
|
202
212
|
classic scripts and module scripts execute in document order, so by the time
|
|
203
213
|
Alpine starts, studio/application has defined the shims it evaluates
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
THE X-DATA FACTORIES' GUARD: says so when a page's factories did not load.
|
|
3
|
+
|
|
4
|
+
The photo crop, the upload hosts, the birthday card and row and the profile
|
|
5
|
+
form bind an x-data factory by name (cropPhotoModal, imageUploadHost,
|
|
6
|
+
avatarCropperHost, birthdayModal, studioBirthdayFields, studioProfileForm),
|
|
7
|
+
and all six are published by studio/alpine_scopes from four modules
|
|
8
|
+
(studio/cropper, studio/image_upload, studio/birthday, studio/profile_form).
|
|
9
|
+
A browser keeps a failed module request for the life of the document, so one
|
|
10
|
+
failure among the five at page load leaves every name undefined until a
|
|
11
|
+
reload: the profile page with no Save, the birthday card with nothing to
|
|
12
|
+
press. This script is what speaks then, so it is INLINE and imports nothing:
|
|
13
|
+
it shares no request with the modules whose failure it reports.
|
|
14
|
+
|
|
15
|
+
Once the page's boot is over, an element whose x-data names a factory that is
|
|
16
|
+
not on window is marked, and a line is put first inside it:
|
|
17
|
+
|
|
18
|
+
<div x-data="studioProfileForm({...})" data-studio-scope-failed="studioProfileForm">
|
|
19
|
+
<p class="studio-scope-notice" role="status">This did not load. Reload the page.</p>
|
|
20
|
+
|
|
21
|
+
The line carries its own few styles (the danger ink, a border, weight), set
|
|
22
|
+
from here, so it reads as a notice in a host whose stylesheet has never heard
|
|
23
|
+
of it; .studio-scope-notice is there for a host that wants to restyle it. A
|
|
24
|
+
card a modal mounts later is marked when it arrives. A factory that turns up
|
|
25
|
+
afterwards takes the mark and the line away again.
|
|
26
|
+
|
|
27
|
+
A FORM IS NOT LEFT WITH NO WAY TO SUBMIT. Inside a marked element:
|
|
28
|
+
- every [data-studio-scope-fallback] is shown (its x-cloak and hidden come
|
|
29
|
+
off): the profile form's plain Save button is one, so a person can still
|
|
30
|
+
save;
|
|
31
|
+
- a text field Alpine emptied goes back to the value the server rendered,
|
|
32
|
+
unless the person is in it, so that Save sends what was on file and not
|
|
33
|
+
blanks.
|
|
34
|
+
The birthday card has no plain path (its submit is a fetch the factory
|
|
35
|
+
makes), so its line is the whole of what it offers: reload.
|
|
36
|
+
|
|
37
|
+
WHEN THE BOOT IS OVER. The engine's modules are module scripts in the head,
|
|
38
|
+
so DOMContentLoaded waits for them, however slow, and each has run or failed
|
|
39
|
+
by then. The 1.5 s after it is margin for a host that defines a factory of
|
|
40
|
+
the same name from a script of its own, not a race the page has to win.
|
|
41
|
+
|
|
42
|
+
Locals:
|
|
43
|
+
nonce - the request's CSP nonce, or nil (layouts/studio/_head passes it)
|
|
44
|
+
%>
|
|
45
|
+
<%= tag.script(nonce: local_assigns[:nonce]) do %>
|
|
46
|
+
(function () {
|
|
47
|
+
if (window.studioScopesGuard) return;
|
|
48
|
+
|
|
49
|
+
var NAMES = ["cropPhotoModal", "imageUploadHost", "avatarCropperHost", "birthdayModal", "studioBirthdayFields", "studioProfileForm"];
|
|
50
|
+
var BOUND = "[x-data]";
|
|
51
|
+
var FAILED = "data-studio-scope-failed";
|
|
52
|
+
var FALLBACK = "data-studio-scope-fallback";
|
|
53
|
+
var NOTICE_CLASS = "studio-scope-notice";
|
|
54
|
+
var NOTICE = "This did not load. Reload the page.";
|
|
55
|
+
// The danger ink is the theme's, held to AA on every surface; a host with
|
|
56
|
+
// no such token keeps the text colour around the line.
|
|
57
|
+
var NOTICE_STYLE = "display:block;margin:0 0 0.75rem;padding:0.5rem 0.75rem;" +
|
|
58
|
+
"border:1px solid currentColor;border-radius:0.5rem;font-size:0.875rem;font-weight:600;" +
|
|
59
|
+
"line-height:1.35;text-align:center;color:var(--color-danger-ink, var(--color-danger, inherit))";
|
|
60
|
+
var CALL = /^\s*([A-Za-z_$][\w$]*)\s*\(/;
|
|
61
|
+
var GRACE_MS = 1500;
|
|
62
|
+
var SETTLE_MS = 250;
|
|
63
|
+
|
|
64
|
+
var deadline = null;
|
|
65
|
+
var reported = false;
|
|
66
|
+
|
|
67
|
+
// The factory an x-data expression calls, when it is one of ours.
|
|
68
|
+
function scopeName(expression) {
|
|
69
|
+
var match = CALL.exec(expression || "");
|
|
70
|
+
return match && NAMES.indexOf(match[1]) !== -1 ? match[1] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// What to do with one element. `deadline` is null until the page's boot has
|
|
74
|
+
// an end, and nothing is marked before it.
|
|
75
|
+
function verdict(state) {
|
|
76
|
+
if (!state.name) return "leave";
|
|
77
|
+
if (state.defined) return state.marked ? "clear" : "leave";
|
|
78
|
+
if (state.marked) return "leave";
|
|
79
|
+
if (state.deadline == null || state.now < state.deadline) return "wait";
|
|
80
|
+
return "mark";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function notice(element) {
|
|
84
|
+
for (var i = 0; i < element.children.length; i++) {
|
|
85
|
+
if (element.children[i].classList.contains(NOTICE_CLASS)) return element.children[i];
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function each(element, selector, visit) {
|
|
91
|
+
var found = element.querySelectorAll(selector);
|
|
92
|
+
for (var i = 0; i < found.length; i++) visit(found[i]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mark(element, name) {
|
|
96
|
+
element.setAttribute(FAILED, name);
|
|
97
|
+
if (!notice(element)) {
|
|
98
|
+
var line = document.createElement("p");
|
|
99
|
+
line.className = NOTICE_CLASS;
|
|
100
|
+
line.setAttribute("role", "status");
|
|
101
|
+
line.style.cssText = NOTICE_STYLE;
|
|
102
|
+
line.textContent = NOTICE;
|
|
103
|
+
element.insertBefore(line, element.firstChild);
|
|
104
|
+
}
|
|
105
|
+
each(element, "[" + FALLBACK + "]", function (fallback) {
|
|
106
|
+
fallback.removeAttribute("x-cloak");
|
|
107
|
+
fallback.hidden = false;
|
|
108
|
+
fallback.style.display = "";
|
|
109
|
+
fallback.setAttribute(FALLBACK, "shown");
|
|
110
|
+
});
|
|
111
|
+
each(element, "input, textarea", function (field) {
|
|
112
|
+
if (field === document.activeElement) return;
|
|
113
|
+
if (field.type === "file" || field.type === "password" || field.type === "hidden") return;
|
|
114
|
+
if (field.type === "checkbox" || field.type === "radio") return;
|
|
115
|
+
if (field.value === "" && field.defaultValue !== "") field.value = field.defaultValue;
|
|
116
|
+
});
|
|
117
|
+
if (!reported) {
|
|
118
|
+
reported = true;
|
|
119
|
+
console.error("[studio] the page's x-data factories failed to load and stay unavailable until the page is reloaded");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clear(element) {
|
|
124
|
+
var line = notice(element);
|
|
125
|
+
element.removeAttribute(FAILED);
|
|
126
|
+
if (line) element.removeChild(line);
|
|
127
|
+
each(element, "[" + FALLBACK + '="shown"]', function (fallback) {
|
|
128
|
+
fallback.setAttribute(FALLBACK, "");
|
|
129
|
+
fallback.setAttribute("x-cloak", "");
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function judge(element) {
|
|
134
|
+
var name = scopeName(element.getAttribute("x-data"));
|
|
135
|
+
var answer = verdict({
|
|
136
|
+
name: name,
|
|
137
|
+
defined: !!name && typeof window[name] === "function",
|
|
138
|
+
marked: element.hasAttribute(FAILED),
|
|
139
|
+
now: Date.now(),
|
|
140
|
+
deadline: deadline
|
|
141
|
+
});
|
|
142
|
+
if (answer === "mark") mark(element, name);
|
|
143
|
+
if (answer === "clear") clear(element);
|
|
144
|
+
return answer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function sweep() {
|
|
148
|
+
var bound = document.querySelectorAll(BOUND);
|
|
149
|
+
for (var i = 0; i < bound.length; i++) judge(bound[i]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function binds(node) {
|
|
153
|
+
return node.nodeType === 1 && (node.matches(BOUND) || node.querySelector(BOUND) !== null);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function booted() {
|
|
157
|
+
deadline = Date.now() + GRACE_MS;
|
|
158
|
+
setTimeout(sweep, GRACE_MS);
|
|
159
|
+
// Read off window under a local name: a host page that counts the
|
|
160
|
+
// observers its own inline scripts construct does not count this one.
|
|
161
|
+
var Observer = window.MutationObserver;
|
|
162
|
+
if (!Observer) return;
|
|
163
|
+
new Observer(function (records) {
|
|
164
|
+
for (var i = 0; i < records.length; i++) {
|
|
165
|
+
for (var j = 0; j < records[i].addedNodes.length; j++) {
|
|
166
|
+
if (binds(records[i].addedNodes[j])) return void setTimeout(sweep, SETTLE_MS);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", booted);
|
|
173
|
+
else booted();
|
|
174
|
+
document.addEventListener("turbo:load", function () { setTimeout(sweep, SETTLE_MS); });
|
|
175
|
+
|
|
176
|
+
window.studioScopesGuard = { names: NAMES, scopeName: scopeName, verdict: verdict, sweep: sweep, graceMs: GRACE_MS };
|
|
177
|
+
})();
|
|
178
|
+
<% end %>
|