studio-engine 0.141.3 → 0.142.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 +22 -1
- data/README.md +103 -1
- data/app/assets/tailwind/studio_engine/engine.css +90 -18
- data/app/components/previews/studio/button_component_preview/disabled.html.erb +12 -0
- data/app/components/previews/studio/button_component_preview/icon_and_link.html.erb +7 -0
- data/app/components/previews/studio/button_component_preview/loading.html.erb +12 -0
- data/app/components/previews/studio/button_component_preview/sizes.html.erb +9 -0
- data/app/components/previews/studio/button_component_preview/tones.html.erb +5 -0
- data/app/components/previews/studio/button_component_preview.rb +34 -0
- data/app/components/previews/studio/empty_state_component_preview/with_icon_and_action.html.erb +5 -0
- data/app/components/previews/studio/empty_state_component_preview.rb +29 -0
- data/app/components/previews/studio/table_component_preview/rows.html.erb +14 -0
- data/app/components/previews/studio/table_component_preview.rb +33 -0
- data/app/components/studio/button_component.html.erb +1 -0
- data/app/components/studio/button_component.rb +151 -0
- data/app/components/studio/empty_state_component.html.erb +12 -0
- data/app/components/studio/empty_state_component.rb +64 -0
- data/app/components/studio/table_component.html.erb +32 -0
- data/app/components/studio/table_component.rb +152 -0
- data/app/controllers/studio/modals_controller.rb +48 -0
- data/app/javascript/studio/carousel.js +52 -0
- data/app/javascript/studio/controllers/carousel_controller.js +7 -13
- data/app/javascript/studio/in_page_links.js +85 -11
- data/app/javascript/studio/modal_host.js +219 -0
- data/app/models/error_log.rb +5 -5
- data/app/views/components/_empty_state.html.erb +2 -6
- data/app/views/studio/modals/_host.html.erb +34 -2
- data/app/views/studio/modals/remote.html.erb +5 -0
- data/app/views/style/_components.html.erb +5 -2
- data/app/views/style/_glass.html.erb +9 -1
- data/app/views/style/_primitives.html.erb +106 -0
- data/config/studio_tricks.yml +6 -2
- data/db/migrate/20261109180000_add_engine_not_null_checks.rb +148 -0
- data/lib/studio/modals.rb +101 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +11 -0
- metadata +22 -1
|
@@ -37,12 +37,24 @@ export const STATE_KEY = "studioInPage"
|
|
|
37
37
|
// The mark on a target that took tabindex=-1 for focus here; engine.css draws no ring on it.
|
|
38
38
|
export const FOCUS_MARK = "data-in-page-focus"
|
|
39
39
|
// No scroll for this long ends the scroll, where the browser has no scrollend event. Where it
|
|
40
|
-
// has one,
|
|
41
|
-
// 8x CPU) a smooth scroll's frames come further apart than
|
|
42
|
-
// jump at its first frame. Then the target moved and nothing
|
|
40
|
+
// has one, scrollend ends a scroll that moved (or the START_CAP_MS backstop below): on a slow
|
|
41
|
+
// main thread (a phone, or CI at 8x CPU) a smooth scroll's frames come further apart than
|
|
42
|
+
// this, and a quiet spell ended the jump at its first frame. Then the target moved and nothing
|
|
43
|
+
// aimed again.
|
|
43
44
|
export const SETTLE_MS = 150
|
|
44
45
|
// A scroll the reader never sees move still ends: the cap for a scroll that never starts.
|
|
46
|
+
// Where the browser has scrollend, it is also the backstop after each scroll event: a scroll
|
|
47
|
+
// that scrollend never follows (a page that shortens, and the browser pulls the scroll back
|
|
48
|
+
// with one scroll event and no scrollend) still ends. A slow frame (260ms at 8x CPU) does not.
|
|
45
49
|
export const START_CAP_MS = 400
|
|
50
|
+
// After the end, the target holds its place on the screen for this long: content above it
|
|
51
|
+
// that grows or shrinks (a lazy frame the scroll passed, whose page lands later) moves the
|
|
52
|
+
// page by the same amount, as scroll anchoring would. The body has anchoring off
|
|
53
|
+
// (studio/_head: with it on, the navbar's collapse made the scroll bounce). The reader's own
|
|
54
|
+
// input or any scroll the module did not make ends the hold at once. Growth later than this
|
|
55
|
+
// is not followed: the window is bounded so a page that reflows long after a jump is the
|
|
56
|
+
// reader's, not the jump's. At 8x CPU the lab's feed landed 0.5 to 1.7s after the end.
|
|
57
|
+
export const HOLD_MS = 3000
|
|
46
58
|
// A scroll aims again at a target that moved more than this, at most this many times.
|
|
47
59
|
export const AIM_SLACK_PX = 2
|
|
48
60
|
export const MAX_AIMS = 6
|
|
@@ -186,13 +198,67 @@ export function installInPageLinks(doc) {
|
|
|
186
198
|
if (margin !== null) target.style.scrollMarginTop = inline
|
|
187
199
|
}
|
|
188
200
|
const pageTop = (target) => target.getBoundingClientRect().top + win.scrollY
|
|
201
|
+
const INPUTS = ["wheel", "touchstart", "keydown", "pointerdown"]
|
|
202
|
+
|
|
203
|
+
// After a jump's end: for HOLD_MS, a target that moved on the page moves the page with it,
|
|
204
|
+
// at once, so its place on the screen stays (scroll anchoring, for this one element). The
|
|
205
|
+
// check runs in the microtask after each change to the page's elements (a lazy frame's
|
|
206
|
+
// render), so no script and no frame sees the target out of place, and in each animation
|
|
207
|
+
// frame, for a size that changed with no new element (an image). It lets go at the first
|
|
208
|
+
// input, at any scroll that is not its own (the reader's scrollbar, an app's scrollTo, a
|
|
209
|
+
// clamp), at the next jump, at a Back or Forward, after MAX_AIMS moves, or at HOLD_MS.
|
|
210
|
+
const hold = (target) => {
|
|
211
|
+
if (!win.requestAnimationFrame) return
|
|
212
|
+
let held = win.scrollY
|
|
213
|
+
let top = target.getBoundingClientRect().top
|
|
214
|
+
let moves = 0
|
|
215
|
+
let frame = null
|
|
216
|
+
let timer = null
|
|
217
|
+
let observer = null
|
|
218
|
+
let done = false
|
|
219
|
+
const release = () => {
|
|
220
|
+
done = true
|
|
221
|
+
win.cancelAnimationFrame(frame)
|
|
222
|
+
win.clearTimeout(timer)
|
|
223
|
+
if (observer) observer.disconnect()
|
|
224
|
+
for (const name of INPUTS) win.removeEventListener(name, release, true)
|
|
225
|
+
if (settling === release) settling = null
|
|
226
|
+
}
|
|
227
|
+
const follow = () => {
|
|
228
|
+
if (done) return
|
|
229
|
+
// A scroll the hold did not make is the reader's or the app's: it lets go first.
|
|
230
|
+
if (Math.abs(win.scrollY - held) > AIM_SLACK_PX) return release()
|
|
231
|
+
// A target a render took out of the page has no place to keep.
|
|
232
|
+
if (target.isConnected === false) return release()
|
|
233
|
+
const now = target.getBoundingClientRect().top
|
|
234
|
+
if (Math.abs(now - top) <= AIM_SLACK_PX) return
|
|
235
|
+
if (moves >= MAX_AIMS) return release()
|
|
236
|
+
moves += 1
|
|
237
|
+
win.scrollBy({ top: now - top, behavior: "instant" })
|
|
238
|
+
held = win.scrollY
|
|
239
|
+
top = target.getBoundingClientRect().top
|
|
240
|
+
}
|
|
241
|
+
const check = () => {
|
|
242
|
+
follow()
|
|
243
|
+
if (!done) frame = win.requestAnimationFrame(check)
|
|
244
|
+
}
|
|
245
|
+
settling = release
|
|
246
|
+
if (win.MutationObserver) {
|
|
247
|
+
observer = new win.MutationObserver(follow)
|
|
248
|
+
observer.observe(doc.documentElement, { childList: true, subtree: true })
|
|
249
|
+
}
|
|
250
|
+
for (const name of INPUTS) win.addEventListener(name, release, { capture: true, passive: true })
|
|
251
|
+
timer = win.setTimeout(release, HOLD_MS)
|
|
252
|
+
frame = win.requestAnimationFrame(check)
|
|
253
|
+
}
|
|
189
254
|
|
|
190
255
|
// One jump, from its start to its end. The browser fixes a smooth scroll's end where the
|
|
191
256
|
// target was at the start. Content that grows on the way (a lazy frame the scroll passes,
|
|
192
257
|
// the navbar's collapse) moves the target, so the scroll aims again, up to MAX_AIMS times,
|
|
193
258
|
// until the reader takes over (a wheel, a touch, a key, a press). The end is the browser's
|
|
194
|
-
// scrollend where it has one
|
|
195
|
-
// that never starts ends at the cap either way.
|
|
259
|
+
// scrollend where it has one (else START_CAP_MS after the last scroll event), a quiet spell
|
|
260
|
+
// where not; then the end event, once. A scroll that never starts ends at the cap either way. Then, unless the reader took over, the
|
|
261
|
+
// target holds its place for HOLD_MS (hold, above).
|
|
196
262
|
const jump = (target, behavior) => {
|
|
197
263
|
if (settling) settling()
|
|
198
264
|
const hasScrollend = "onscrollend" in win
|
|
@@ -201,12 +267,15 @@ export function installInPageLinks(doc) {
|
|
|
201
267
|
let aims = 0
|
|
202
268
|
let reader = false
|
|
203
269
|
let live = true
|
|
270
|
+
// A scroll event has come since the last aim. Chrome can send the scrollend of a scroll an
|
|
271
|
+
// aim replaced before the new scroll's first frame; that scrollend is not this aim's end.
|
|
272
|
+
let scrolled = false
|
|
204
273
|
let aimed = pageTop(target)
|
|
205
274
|
const takeOver = () => { reader = true }
|
|
206
|
-
const INPUTS = ["wheel", "touchstart", "keydown", "pointerdown"]
|
|
207
275
|
const reaim = () => {
|
|
208
276
|
if (reader || aims >= MAX_AIMS || Math.abs(pageTop(target) - aimed) <= AIM_SLACK_PX) return false
|
|
209
277
|
aims += 1
|
|
278
|
+
scrolled = false
|
|
210
279
|
scrollTo(target, behavior)
|
|
211
280
|
aimed = pageTop(target)
|
|
212
281
|
return true
|
|
@@ -220,28 +289,31 @@ export function installInPageLinks(doc) {
|
|
|
220
289
|
// The target moved after the scroll stopped: aim once more and wait for that end.
|
|
221
290
|
if (reaim()) return wait(START_CAP_MS)
|
|
222
291
|
win.removeEventListener("scroll", onScroll)
|
|
223
|
-
win.removeEventListener("scrollend",
|
|
292
|
+
win.removeEventListener("scrollend", onScrollend)
|
|
224
293
|
for (const name of INPUTS) win.removeEventListener(name, takeOver, true)
|
|
225
294
|
win.clearTimeout(quiet)
|
|
226
295
|
win.clearTimeout(cap)
|
|
227
296
|
live = false
|
|
228
297
|
if (settling === stop) settling = null
|
|
298
|
+
if (!reader) hold(target)
|
|
229
299
|
doc.dispatchEvent(new win.CustomEvent(SCROLL_END_EVENT))
|
|
230
300
|
}
|
|
231
301
|
const onScroll = () => {
|
|
232
302
|
win.clearTimeout(cap)
|
|
303
|
+
scrolled = true
|
|
233
304
|
reaim()
|
|
234
|
-
// The scroll has started
|
|
235
|
-
|
|
236
|
-
|
|
305
|
+
// The scroll has started. With scrollend, scrollend ends it, and START_CAP_MS after the
|
|
306
|
+
// last scroll event is the backstop for a scroll that scrollend never follows.
|
|
307
|
+
wait(hasScrollend ? START_CAP_MS : SETTLE_MS)
|
|
237
308
|
}
|
|
309
|
+
const onScrollend = () => { if (scrolled) end() }
|
|
238
310
|
const stop = () => {
|
|
239
311
|
reader = true
|
|
240
312
|
end()
|
|
241
313
|
}
|
|
242
314
|
settling = stop
|
|
243
315
|
win.addEventListener("scroll", onScroll, { passive: true })
|
|
244
|
-
if (hasScrollend) win.addEventListener("scrollend",
|
|
316
|
+
if (hasScrollend) win.addEventListener("scrollend", onScrollend)
|
|
245
317
|
for (const name of INPUTS) win.addEventListener(name, takeOver, { capture: true, passive: true })
|
|
246
318
|
cap = win.setTimeout(end, START_CAP_MS)
|
|
247
319
|
scrollTo(target, behavior)
|
|
@@ -286,6 +358,8 @@ export function installInPageLinks(doc) {
|
|
|
286
358
|
return
|
|
287
359
|
}
|
|
288
360
|
shownUrl = url
|
|
361
|
+
// The traversal's place wins over a jump or a hold still running.
|
|
362
|
+
if (settling) settling()
|
|
289
363
|
// Turbo would start a restore visit; without Turbo the browser restores the scroll itself.
|
|
290
364
|
if (event.state && event.state.turbo) {
|
|
291
365
|
event.stopImmediatePropagation()
|
|
@@ -284,6 +284,170 @@ function focusTrap(env, held) {
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
// ---- on-demand modals (Studio.modals) ---------------------------------------
|
|
288
|
+
//
|
|
289
|
+
// A host whose app registers modals with Studio.modals advertises them on its
|
|
290
|
+
// template:
|
|
291
|
+
//
|
|
292
|
+
// data-modal-host-remote-value='{"url":"/_studio/modals/__id__",
|
|
293
|
+
// "ids":["contest-rules",…],"prefetch":["auth"]}'
|
|
294
|
+
//
|
|
295
|
+
// and marks its card data-modal-host-card. open(id) for an advertised id whose
|
|
296
|
+
// registration is not in the host yet fetches GET /_studio/modals/<id> once,
|
|
297
|
+
// wraps the fragment in the same <template x-if> a layout would have rendered,
|
|
298
|
+
// appends it to the card (in the host template's content, so every later mount
|
|
299
|
+
// has it, and in a card already on screen), then opens. An id the host does not
|
|
300
|
+
// advertise takes the plain open() path untouched.
|
|
301
|
+
//
|
|
302
|
+
// THE WAIT. A fetch that answers inside LOAD_DELAY_MS opens with no loading
|
|
303
|
+
// state at all. A slower one opens the card with $store.modals._remote[id] =
|
|
304
|
+
// 'loading' (the host's processing card), held at least MIN_LOAD_MS so it never
|
|
305
|
+
// flashes, then clears the flag and the content takes its place. A failure sets
|
|
306
|
+
// 'error' (the host's error card) and forgets the fetch, so the next open
|
|
307
|
+
// retries. So for the first LOAD_DELAY_MS of a first open, isOpen(id) is false.
|
|
308
|
+
//
|
|
309
|
+
// THE CACHE lives for the page's lifetime: fragments by id, beside the store,
|
|
310
|
+
// not on it. A Turbo visit brings a fresh host without the inserted templates
|
|
311
|
+
// (or a snapshot clone with them), so insertion checks the host every time and
|
|
312
|
+
// inserts only what is missing; that check is what keeps a Turbo visit from
|
|
313
|
+
// inserting a modal twice. A fetch that resolves after a Turbo visit replaced
|
|
314
|
+
// the body (doc.body changed) opens nothing on the new page.
|
|
315
|
+
|
|
316
|
+
export const REMOTE_MODAL_ATTR = 'data-studio-remote-modal'
|
|
317
|
+
export const LOAD_DELAY_MS = 150
|
|
318
|
+
|
|
319
|
+
// The host template advertising on-demand modals, if this page has one.
|
|
320
|
+
export function remoteHost(doc) {
|
|
321
|
+
return doc.querySelector ? doc.querySelector('template[data-modal-host-remote-value]') : null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The advertised config, or null on a page without one (or with bad JSON).
|
|
325
|
+
export function remoteConfig(doc) {
|
|
326
|
+
var el = remoteHost(doc)
|
|
327
|
+
if (!el) return null
|
|
328
|
+
try {
|
|
329
|
+
var cfg = JSON.parse(el.getAttribute('data-modal-host-remote-value'))
|
|
330
|
+
if (!cfg || typeof cfg.url !== 'string' || !Array.isArray(cfg.ids)) return null
|
|
331
|
+
if (!Array.isArray(cfg.prefetch)) cfg.prefetch = []
|
|
332
|
+
return cfg
|
|
333
|
+
} catch (e) {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The <template x-if> a layout would have rendered around the partial. Ids are
|
|
339
|
+
// held to [a-z0-9_-] by Studio::Modals, so splicing one into the expression
|
|
340
|
+
// needs no escaping; anything else is refused before it gets here.
|
|
341
|
+
export function remoteTemplate(doc, id, markup) {
|
|
342
|
+
var t = doc.createElement('template')
|
|
343
|
+
t.setAttribute(REMOTE_MODAL_ATTR, id)
|
|
344
|
+
t.setAttribute('x-if', "$store.modals.current()?.id === '" + id + "' && !$store.modals._remote['" + id + "']")
|
|
345
|
+
t.innerHTML = markup
|
|
346
|
+
return t
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Every card the fragment belongs in: the one inside the host template's
|
|
350
|
+
// content, and any mounted on screen.
|
|
351
|
+
function remoteCards(doc) {
|
|
352
|
+
var cards = []
|
|
353
|
+
var host = remoteHost(doc)
|
|
354
|
+
var inner = host && host.content && host.content.querySelector('[data-modal-host-card]')
|
|
355
|
+
if (inner) cards.push(inner)
|
|
356
|
+
var live = doc.querySelectorAll ? doc.querySelectorAll('[data-modal-host-card]') : []
|
|
357
|
+
for (var i = 0; i < live.length; i++) cards.push(live[i])
|
|
358
|
+
return cards
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function hasRemote(card, id) {
|
|
362
|
+
var kids = card.children || []
|
|
363
|
+
for (var i = 0; i < kids.length; i++) {
|
|
364
|
+
if (kids[i].getAttribute && kids[i].getAttribute(REMOTE_MODAL_ATTR) === id) return true
|
|
365
|
+
}
|
|
366
|
+
return false
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Appends the registration to every card missing it. Answers whether the host
|
|
370
|
+
// template now carries it (false: no marked card on this page).
|
|
371
|
+
export function insertRemoteModal(doc, id, markup) {
|
|
372
|
+
var cards = remoteCards(doc)
|
|
373
|
+
if (cards.length === 0) return false
|
|
374
|
+
for (var i = 0; i < cards.length; i++) {
|
|
375
|
+
if (!hasRemote(cards[i], id)) cards[i].appendChild(remoteTemplate(doc, id, markup))
|
|
376
|
+
}
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function remoteModalUrl(cfg, id) {
|
|
381
|
+
return cfg.url.replace('__id__', encodeURIComponent(id))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// One fetch. Rejects unless the answer is the fragment the endpoint sends
|
|
385
|
+
// (X-Studio-Modal names the id): a host filter's redirect to a full page, or a
|
|
386
|
+
// 401, is an error, never a modal.
|
|
387
|
+
export function fetchRemoteModal(win, cfg, id) {
|
|
388
|
+
return win.fetch(remoteModalUrl(cfg, id), {
|
|
389
|
+
credentials: 'same-origin',
|
|
390
|
+
headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' }
|
|
391
|
+
}).then(function (res) {
|
|
392
|
+
if (!res.ok || !res.headers || res.headers.get('X-Studio-Modal') !== id) {
|
|
393
|
+
throw new Error('studio/modal_host: ' + id + ' answered ' + res.status)
|
|
394
|
+
}
|
|
395
|
+
return res.text()
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// requestIdleCallback where the browser has it, else a timeout.
|
|
400
|
+
export function whenIdle(win, fn) {
|
|
401
|
+
if (typeof win.requestIdleCallback === 'function') win.requestIdleCallback(fn, { timeout: 4000 })
|
|
402
|
+
else win.setTimeout(fn, 1500)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The loader one shared store keeps in its closure.
|
|
406
|
+
export function createRemoteModals(env) {
|
|
407
|
+
var doc = env.doc
|
|
408
|
+
var win = env.win
|
|
409
|
+
var pending = {} // id -> Promise<markup>, while in flight or resolved
|
|
410
|
+
var ready = {} // id -> markup, once resolved
|
|
411
|
+
|
|
412
|
+
function load(cfg, id) {
|
|
413
|
+
if (ready[id] !== undefined) return Promise.resolve(ready[id])
|
|
414
|
+
if (!pending[id]) {
|
|
415
|
+
pending[id] = fetchRemoteModal(win, cfg, id).then(function (markup) {
|
|
416
|
+
ready[id] = markup
|
|
417
|
+
return markup
|
|
418
|
+
}, function (err) {
|
|
419
|
+
delete pending[id]
|
|
420
|
+
throw err
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
return pending[id]
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
// The config when id is advertised here, else null.
|
|
428
|
+
handles: function (id) {
|
|
429
|
+
if (typeof id !== 'string') return null
|
|
430
|
+
var cfg = remoteConfig(doc)
|
|
431
|
+
return (cfg && cfg.ids.indexOf(id) !== -1) ? cfg : null
|
|
432
|
+
},
|
|
433
|
+
load: load,
|
|
434
|
+
// Synchronous when the fragment is already here: inserts what this host
|
|
435
|
+
// lacks and answers true, so open() needs no wait.
|
|
436
|
+
insertIfReady: function (id) {
|
|
437
|
+
return ready[id] !== undefined && insertRemoteModal(doc, id, ready[id])
|
|
438
|
+
},
|
|
439
|
+
insert: function (id, markup) { return insertRemoteModal(doc, id, markup) },
|
|
440
|
+
prefetch: function () {
|
|
441
|
+
var cfg = remoteConfig(doc)
|
|
442
|
+
if (!cfg || typeof win.fetch !== 'function') return
|
|
443
|
+
cfg.prefetch.forEach(function (id) {
|
|
444
|
+
// A failed prefetch is retried by the open that needs it.
|
|
445
|
+
load(cfg, id).catch(function () {})
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
287
451
|
// ---- the shared host's store, $store.modals --------------------------------
|
|
288
452
|
//
|
|
289
453
|
// env is { doc, win }: the document, and the window that carries setTimeout,
|
|
@@ -293,13 +457,68 @@ export function createModalStore(env) {
|
|
|
293
457
|
var held = { returnFocusTo: null, backdrop: null }
|
|
294
458
|
var store = focusTrap(env, held)
|
|
295
459
|
store.stack = []
|
|
460
|
+
// id -> 'loading' | 'error' for an on-demand modal; read by the host's
|
|
461
|
+
// loading and error cards and by each fetched registration's x-if.
|
|
462
|
+
store._remote = {}
|
|
463
|
+
var remote = createRemoteModals(env)
|
|
296
464
|
|
|
297
465
|
// open(id, props, opts)
|
|
298
466
|
// opts.replace: swap the top entry. With a visible card, a directional
|
|
299
467
|
// slide: it exits right (modal-card-swap-out) and the new one enters
|
|
300
468
|
// left; opts.direction 'back' mirrors both. A plain mount when empty.
|
|
301
469
|
// otherwise: push. Closing the new card reveals the one beneath.
|
|
470
|
+
// An on-demand id not yet in the host loads first (openRemote below).
|
|
302
471
|
store.open = function (id, props, opts) {
|
|
472
|
+
var cfg = remote.handles(id)
|
|
473
|
+
if (cfg && !remote.insertIfReady(id)) return openRemote(this, cfg, id, props, opts)
|
|
474
|
+
return openNow.call(this, id, props, opts)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// The first open of an on-demand id. Never throws and never leaves a promise
|
|
478
|
+
// unhandled: every outcome is the modal, the loading card, or the error card.
|
|
479
|
+
function openRemote(self, cfg, id, props, opts) {
|
|
480
|
+
var body = env.doc.body
|
|
481
|
+
var shown = false
|
|
482
|
+
var hold = null
|
|
483
|
+
var done = false
|
|
484
|
+
if (self._remote[id] === 'error') delete self._remote[id]
|
|
485
|
+
|
|
486
|
+
var timer = win.setTimeout(function () {
|
|
487
|
+
if (done || env.doc.body !== body) return
|
|
488
|
+
shown = true
|
|
489
|
+
hold = holdAtLeast((win.StudioModals && win.StudioModals.MIN_LOAD_MS) || MIN_LOAD_MS)
|
|
490
|
+
self._remote[id] = 'loading'
|
|
491
|
+
openNow.call(self, id, props, opts)
|
|
492
|
+
}, LOAD_DELAY_MS)
|
|
493
|
+
|
|
494
|
+
function fail() {
|
|
495
|
+
if (done) return
|
|
496
|
+
done = true
|
|
497
|
+
win.clearTimeout && win.clearTimeout(timer)
|
|
498
|
+
if (env.doc.body !== body) return
|
|
499
|
+
self._remote[id] = 'error'
|
|
500
|
+
if (!shown) openNow.call(self, id, props, opts)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
remote.load(cfg, id).then(function (markup) {
|
|
504
|
+
if (done) return
|
|
505
|
+
if (env.doc.body !== body) { done = true; win.clearTimeout && win.clearTimeout(timer); return }
|
|
506
|
+
if (!remote.insert(id, markup)) { fail(); return }
|
|
507
|
+
done = true
|
|
508
|
+
win.clearTimeout && win.clearTimeout(timer)
|
|
509
|
+
if (!shown) { openNow.call(self, id, props, opts); return }
|
|
510
|
+
hold.then(function () {
|
|
511
|
+
if (self._remote[id] === 'loading') delete self._remote[id]
|
|
512
|
+
})
|
|
513
|
+
}).catch(fail)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Schedules the advertised prefetches once, when the browser idles.
|
|
517
|
+
if (env.doc && typeof env.doc.querySelector === 'function') {
|
|
518
|
+
whenIdle(win, function () { remote.prefetch() })
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function openNow(id, props, opts) {
|
|
303
522
|
props = props || {}
|
|
304
523
|
opts = opts || {}
|
|
305
524
|
var self = this
|
data/app/models/error_log.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
class ErrorLog < ApplicationRecord
|
|
2
|
-
# `slug` is unique (RequireSlugsOnEngineTables) and
|
|
3
|
-
#
|
|
4
|
-
# `ErrorLog.create!`. Sluggable writes a unique
|
|
5
|
-
# and settles it to `error-log-<id>` right after,
|
|
6
|
-
# id exists.
|
|
2
|
+
# `slug` is unique (RequireSlugsOnEngineTables) and refuses NULL (CHECK
|
|
3
|
+
# error_logs_slug_present, AddEngineNotNullChecks), so every insert carries
|
|
4
|
+
# one, including a host's bare `ErrorLog.create!`. Sluggable writes a unique
|
|
5
|
+
# placeholder before the insert and settles it to `error-log-<id>` right after,
|
|
6
|
+
# inside the create, once the id exists.
|
|
7
7
|
include Sluggable
|
|
8
8
|
|
|
9
9
|
belongs_to :target, polymorphic: true, optional: true
|
|
@@ -1,7 +1,3 @@
|
|
|
1
1
|
<%# locals: (message:, detail: nil) %>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
<% if detail.present? %>
|
|
5
|
-
<p class="text-muted text-sm mt-1"><%= detail %></p>
|
|
6
|
-
<% end %>
|
|
7
|
-
</div>
|
|
2
|
+
<%# Kept for one release as a wrapper: Studio::EmptyStateComponent is the empty state now (docs/FRONT_END_STANDARD.md, "From partial to component"). -%>
|
|
3
|
+
<%= render Studio::EmptyStateComponent.new(message: message, detail: detail) %>
|
|
@@ -212,9 +212,17 @@
|
|
|
212
212
|
bound to this host by the modal-host controller on the template below. The
|
|
213
213
|
store registers before Alpine starts; see that module's header. %>
|
|
214
214
|
|
|
215
|
+
<%# ON-DEMAND MODALS (Studio.modals). Only when the app registers one does the
|
|
216
|
+
host advertise them (data-modal-host-remote-value), mark its card
|
|
217
|
+
(data-modal-host-card) and carry the loading and error cards below; an app
|
|
218
|
+
that registers none renders this partial exactly as before. studio/modal_host
|
|
219
|
+
fetches a registered id the first time it opens and inserts its
|
|
220
|
+
<template x-if> into the card. See the README, "Modals on demand". %>
|
|
221
|
+
<% studio_remote_modals = Studio.modals.any? %>
|
|
215
222
|
<template x-if="$store.modals.current()"
|
|
216
223
|
data-studio-controller="modal-host"
|
|
217
|
-
data-modal-host-store-value="modals"
|
|
224
|
+
data-modal-host-store-value="modals"<% if studio_remote_modals %>
|
|
225
|
+
data-modal-host-remote-value="<%= Studio.modals.client_config(url_template: studio_modal_path(id: "0").delete_suffix("0") + "__id__", logged_in: respond_to?(:logged_in?) && logged_in?).to_json %>"<% end %>>
|
|
218
226
|
<%# Backdrop + card use CSS keyframe animations (NOT Alpine
|
|
219
227
|
x-transition), all shipped in the inline style block above. On
|
|
220
228
|
mount, the .modal-backdrop-mount + card mount classes fire fade-in
|
|
@@ -271,7 +279,8 @@
|
|
|
271
279
|
<%# No max-w-* here — cardClasses() supplies exactly one, per modal id, from
|
|
272
280
|
the CARD_WIDTHS registry (studio/modal_host). max-h/overflow are unrelated and stay. %>
|
|
273
281
|
<div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 w-full max-h-[85dvh] overflow-y-auto"
|
|
274
|
-
:class="$store.modals.cardClasses()"
|
|
282
|
+
:class="$store.modals.cardClasses()"<% if studio_remote_modals %>
|
|
283
|
+
data-modal-host-card<% end %>>
|
|
275
284
|
<%# Consumer-provided content registrations. Each block typically
|
|
276
285
|
contains a <template x-if="$store.modals.current().id === 'X'">
|
|
277
286
|
render "modals/X" </template>.
|
|
@@ -299,6 +308,29 @@
|
|
|
299
308
|
<% if lookup_context.exists?("host_extras", ["modals"], true) %>
|
|
300
309
|
<%= render "modals/host_extras" %>
|
|
301
310
|
<% end %>
|
|
311
|
+
|
|
312
|
+
<%# The on-demand states, keyed on $store.modals._remote[id], which only
|
|
313
|
+
studio/modal_host sets and only for a registered id: 'loading' while a
|
|
314
|
+
slow first fetch runs (the processing card, held MIN_LOAD_MS so it
|
|
315
|
+
never flashes), 'error' when the fetch fails (the error card, whose
|
|
316
|
+
button closes). Fetched registrations are appended after these. %>
|
|
317
|
+
<% if studio_remote_modals %>
|
|
318
|
+
<template x-if="$store.modals._remote && $store.modals._remote[$store.modals.current()?.id] === 'loading'">
|
|
319
|
+
<div data-modal-remote-state="loading">
|
|
320
|
+
<%= render "studio/modals/blocks/processing_card", title: "Loading…" %>
|
|
321
|
+
</div>
|
|
322
|
+
</template>
|
|
323
|
+
<template x-if="$store.modals._remote && $store.modals._remote[$store.modals.current()?.id] === 'error'">
|
|
324
|
+
<div data-modal-remote-state="error">
|
|
325
|
+
<%= render "studio/modals/blocks/error_card",
|
|
326
|
+
title: "Couldn't open this",
|
|
327
|
+
message: "Check your connection and try again.",
|
|
328
|
+
icon_emoji: "⚠️",
|
|
329
|
+
cta_label: "Close",
|
|
330
|
+
cta_close: true %>
|
|
331
|
+
</div>
|
|
332
|
+
</template>
|
|
333
|
+
<% end %>
|
|
302
334
|
</div>
|
|
303
335
|
</div>
|
|
304
336
|
</template>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%# One on-demand modal's content, as Studio::ModalsController serves it: the
|
|
2
|
+
registered partial with its locals, resolved in THIS view, and nothing else.
|
|
3
|
+
No layout, no script: studio/modal_host wraps it in the host's
|
|
4
|
+
<template x-if> on the client. %>
|
|
5
|
+
<%= render @studio_modal.partial, @studio_modal.locals_for(self) %>
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
The previews ARE the specimens, so this list cannot drift from the components.
|
|
5
5
|
An app that does not draw the gallery here (lookbook not in its bundle, or
|
|
6
6
|
production without Studio.lookbook_in_production) says so instead of linking
|
|
7
|
-
to a 404.
|
|
7
|
+
to a 404. Above the list, style/_primitives draws the Button, EmptyState and
|
|
8
|
+
Table components live on every app, gallery or not. -%>
|
|
8
9
|
<section id="components" class="section-target space-y-6">
|
|
9
10
|
<%= render "studio/section_header", title: "Components" %>
|
|
10
|
-
<p class="text-muted text-sm max-w-2xl">
|
|
11
|
+
<p class="text-muted text-sm max-w-2xl">The UI primitives every app renders, live, then one preview per state in the component gallery.</p>
|
|
12
|
+
|
|
13
|
+
<%= render "style/primitives" %>
|
|
11
14
|
|
|
12
15
|
<% if Studio.lookbook_mounted? %>
|
|
13
16
|
<ul class="grid gap-4 sm:grid-cols-2" data-component-previews>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<section class="space-y-4" data-test="style-glass">
|
|
12
12
|
<div>
|
|
13
13
|
<h3 class="type-h4 text-heading">Glass</h3>
|
|
14
|
-
<p class="text-sm text-secondary mt-1">
|
|
14
|
+
<p class="text-sm text-secondary mt-1">The standard for a new app. An existing app turns each switch on itself.</p>
|
|
15
15
|
</div>
|
|
16
16
|
<div class="grid gap-4 md:grid-cols-2">
|
|
17
17
|
<%= render layout: "style/specimen",
|
|
@@ -64,6 +64,14 @@
|
|
|
64
64
|
</div>
|
|
65
65
|
</div>
|
|
66
66
|
<% end %>
|
|
67
|
+
<%= render layout: "style/specimen",
|
|
68
|
+
locals: { trick: "glass", variant: "tile",
|
|
69
|
+
disabled: !surface_on, disabled_label: "surface style is off on this app" } do %>
|
|
70
|
+
<div class="flex flex-wrap items-center gap-3" data-test="style-lit-tiles">
|
|
71
|
+
<a href="#tricks" class="lit-tile inline-flex min-h-12 items-center bg-primary px-5 text-xs font-bold uppercase tracking-wide text-white">.lit-tile</a>
|
|
72
|
+
<a href="#tricks" class="lit-tile-soft inline-flex min-h-12 items-center border border-subtle px-5 text-xs font-bold uppercase tracking-wide text-heading">.lit-tile-soft</a>
|
|
73
|
+
</div>
|
|
74
|
+
<% end %>
|
|
67
75
|
<%= render layout: "style/specimen",
|
|
68
76
|
locals: { trick: "icon" } do %>
|
|
69
77
|
<ul class="grid grid-cols-4 gap-3 w-full text-heading" data-test="style-glass-icons">
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<%# locals: () -%>
|
|
2
|
+
<%# The engine's UI primitives, drawn live: every tone, size and state of
|
|
3
|
+
Studio::ButtonComponent, Studio::EmptyStateComponent and
|
|
4
|
+
Studio::TableComponent. Rendered from the components themselves, never a
|
|
5
|
+
copy of their markup, so a change to a component changes its specimen.
|
|
6
|
+
|
|
7
|
+
LIGHT AND DARK. Each specimen renders once and follows the page's theme: the
|
|
8
|
+
theme tokens are scoped to html and html.dark (Studio::ThemeResolver), and
|
|
9
|
+
nothing scopes a panel back to light inside a dark page. Flip the theme to
|
|
10
|
+
see the other one; e2e/ui_primitives_screenshots.spec.js captures both.
|
|
11
|
+
|
|
12
|
+
e2e/ui_primitives_screenshots.spec.js screenshots each [data-specimen] block
|
|
13
|
+
(the lab page /lab/ui_primitives renders this partial). Renaming one moves a
|
|
14
|
+
baseline. -%>
|
|
15
|
+
<%
|
|
16
|
+
tones = Studio::ButtonComponent::TONES.keys
|
|
17
|
+
sizes = Studio::ButtonComponent::SIZES.keys
|
|
18
|
+
plus = tag.svg(tag.path(d: "M10 4a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H5a1 1 0 1 1 0-2h4V5a1 1 0 0 1 1-1Z"),
|
|
19
|
+
class: "h-4 w-4", viewBox: "0 0 20 20", fill: "currentColor", aria: { hidden: true })
|
|
20
|
+
people = [
|
|
21
|
+
{ name: "Ada Lovelace", role: "Admin", tasks: 12, status: "passed" },
|
|
22
|
+
{ name: "Grace Hopper", role: "Builder", tasks: 7, status: "blocked" },
|
|
23
|
+
{ name: "Katherine Johnson", role: "Reviewer", tasks: 3, status: "waiting" }
|
|
24
|
+
]
|
|
25
|
+
status_tones = { "passed" => :success, "blocked" => :danger, "waiting" => :warning }
|
|
26
|
+
columns = [
|
|
27
|
+
{ key: :name, label: "Member", sortable: true },
|
|
28
|
+
{ key: :role, label: "Role" },
|
|
29
|
+
{ key: :tasks, label: "Tasks", sortable: true, align: :right },
|
|
30
|
+
{ key: :status, label: "Status", align: :center,
|
|
31
|
+
cell: ->(row) { render Studio::BadgeComponent.new(text: row[:status], tone: status_tones.fetch(row[:status])) } }
|
|
32
|
+
]
|
|
33
|
+
sort_url = ->(key, direction) { "?sort=#{key}&direction=#{direction}#components" }
|
|
34
|
+
-%>
|
|
35
|
+
<div class="space-y-6" data-primitives>
|
|
36
|
+
<div class="card p-5 space-y-3" data-specimen="button-tones">
|
|
37
|
+
<h3 class="type-h4 text-heading">Button · tones</h3>
|
|
38
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
39
|
+
<% tones.each do |tone| %>
|
|
40
|
+
<%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone) %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="card p-5 space-y-3" data-specimen="button-sizes">
|
|
46
|
+
<h3 class="type-h4 text-heading">Button · sizes</h3>
|
|
47
|
+
<% tones.each do |tone| %>
|
|
48
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
49
|
+
<% sizes.each do |size| %>
|
|
50
|
+
<%= render Studio::ButtonComponent.new(label: "#{tone.to_s.capitalize} #{size}", tone: tone, size: size) %>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="card p-5 space-y-3" data-specimen="button-states">
|
|
57
|
+
<h3 class="type-h4 text-heading">Button · disabled, loading, icon, link</h3>
|
|
58
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
59
|
+
<% tones.each do |tone| %>
|
|
60
|
+
<%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone, disabled: true) %>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
64
|
+
<% tones.each do |tone| %>
|
|
65
|
+
<%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone, loading: true) %>
|
|
66
|
+
<% end %>
|
|
67
|
+
<%= render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…") %>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
70
|
+
<%= render(Studio::ButtonComponent.new(label: "New task")) { |button| button.with_icon { plus } } %>
|
|
71
|
+
<%= render Studio::ButtonComponent.new(label: "Read the docs", tone: :quiet, href: "#components") %>
|
|
72
|
+
<%= render Studio::ButtonComponent.new(label: "Disabled link", tone: :quiet, href: "#components", disabled: true) %>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="grid gap-6 md:grid-cols-2">
|
|
77
|
+
<div class="space-y-3" data-specimen="empty-state">
|
|
78
|
+
<h3 class="type-h4 text-heading">Empty state · message and detail</h3>
|
|
79
|
+
<%= render Studio::EmptyStateComponent.new(message: "No errors logged yet.",
|
|
80
|
+
detail: "Errors appear here when ErrorLog.capture! records one.") %>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="space-y-3" data-specimen="empty-state-action">
|
|
83
|
+
<h3 class="type-h4 text-heading">Empty state · icon and action</h3>
|
|
84
|
+
<%= render(Studio::EmptyStateComponent.new(detail: "Invite someone to start the team.")) do |empty| %>
|
|
85
|
+
<% empty.with_icon { plus } %>
|
|
86
|
+
<% empty.with_action(label: "Invite a member", href: "#components") %>
|
|
87
|
+
No members yet.
|
|
88
|
+
<% end %>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div class="space-y-3" data-specimen="table">
|
|
93
|
+
<h3 class="type-h4 text-heading">Table · sorted by member, a badge cell</h3>
|
|
94
|
+
<div class="card overflow-hidden">
|
|
95
|
+
<%= render Studio::TableComponent.new(rows: people, columns: columns, sort: "name", direction: "asc",
|
|
96
|
+
sort_url: sort_url, min_width: 520) %>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="space-y-3" data-specimen="table-empty">
|
|
101
|
+
<h3 class="type-h4 text-heading">Table · no rows</h3>
|
|
102
|
+
<div class="card overflow-hidden">
|
|
103
|
+
<%= render Studio::TableComponent.new(rows: [], columns: columns, sort_url: sort_url, empty: "No members yet.") %>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|