studio-engine 0.129.0 → 0.131.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 +192 -0
- data/README.md +48 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +322 -56
- data/app/assets/tailwind/studio_engine/engine.css +737 -19
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/fizz_helper.rb +5 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pinned_stack.js +112 -4
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_fizz.html.erb +26 -0
- data/app/views/studio/_fizz_layer.html.erb +4 -1
- data/app/views/studio/_hold_button.html.erb +1 -2
- data/app/views/studio/_page_header.html.erb +55 -13
- data/app/views/studio/_section_header.html.erb +23 -0
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_components.html.erb +3 -5
- data/app/views/style/_host.html.erb +3 -6
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modal_specimen.html.erb +5 -5
- data/app/views/style/_modals.html.erb +15 -5
- data/app/views/style/_site_footer.html.erb +1 -1
- data/app/views/style/_specimen.html.erb +21 -10
- data/app/views/style/_tasks.html.erb +3 -5
- data/app/views/style/_theme.html.erb +2 -2
- data/app/views/style/_tricks.html.erb +218 -217
- data/app/views/style/index.html.erb +21 -11
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- metadata +22 -2
|
@@ -11,10 +11,10 @@ import { activeIndex } from "studio/style_section_nav"
|
|
|
11
11
|
|
|
12
12
|
const ACTIVE = "btn-primary"
|
|
13
13
|
const REST = "btn-neutral"
|
|
14
|
-
// A jump lands a section by its scroll
|
|
15
|
-
// header may still be tall; the header then collapses, so the row rises and the
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// A jump lands a section by its scroll margin, the stack's pinned heights, read while the
|
|
15
|
+
// header may still be tall; the header then collapses, so the row rises and the section
|
|
16
|
+
// does not (76px on the hub at phone width). The slack keeps the clicked section past the
|
|
17
|
+
// line, for any later scroll or resize.
|
|
18
18
|
const LINE_SLACK = 80
|
|
19
19
|
|
|
20
20
|
export default class extends Controller {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// talk-to-agent: the talk-to-agent modal (studio/agent_chat/_talk_modal), on the
|
|
2
|
+
// engine's Stimulus application (studio/stimulus registers it lazily, the first
|
|
3
|
+
// time a page renders the modal). It sits on the partial's wrapper:
|
|
4
|
+
//
|
|
5
|
+
// <div data-studio-controller="talk-to-agent" data-talk-to-agent-soul="alex" ...>
|
|
6
|
+
//
|
|
7
|
+
// The click handling and the panel fill are studio/talk_to_agent.
|
|
8
|
+
import { Controller } from "@hotwired/stimulus"
|
|
9
|
+
import { mountTalkToAgent } from "studio/talk_to_agent"
|
|
10
|
+
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
connect() {
|
|
13
|
+
this.talk = mountTalkToAgent(this.element)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
if (this.talk) this.talk.destroy()
|
|
18
|
+
this.talk = null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// studio/infinite_scroll: the next page loads as its end comes near
|
|
2
|
+
// (docs/LOADER.md, "Infinite scroll"). The markup is studio_infinite_scroll:
|
|
3
|
+
//
|
|
4
|
+
// <div class="studio-infinite-scroll" data-studio-controller="infinite-scroll">
|
|
5
|
+
// ...page 1's items...
|
|
6
|
+
// <turbo-frame id="posts-page-2" data-infinite-scroll-next>
|
|
7
|
+
// <a href="/posts?page=2" data-infinite-scroll-more>Load more</a>
|
|
8
|
+
// </turbo-frame>
|
|
9
|
+
// <p role="status" aria-live="polite" data-infinite-scroll-status></p>
|
|
10
|
+
// </div>
|
|
11
|
+
//
|
|
12
|
+
// THE BUTTON IS THE PRIMITIVE. "Load more" is an ordinary link inside the next
|
|
13
|
+
// page's frame, so a click loads that frame with Turbo, and with no script at all
|
|
14
|
+
// it opens the next page. This module only clicks it early: when the frame comes
|
|
15
|
+
// within NEAR_PX of the viewport (IntersectionObserver, watching the button's
|
|
16
|
+
// row, since the frame itself takes no box), it points the frame at
|
|
17
|
+
// the link's URL. The button stays in view the whole time, for a keyboard, a
|
|
18
|
+
// screen reader or a browser with no observer.
|
|
19
|
+
//
|
|
20
|
+
// announce once a page lands, the status line says how many items came
|
|
21
|
+
// ("12 more loaded"), and that the list is done when no next frame
|
|
22
|
+
// came with them. It is polite: it waits for the reader to pause.
|
|
23
|
+
// focus a page the reader asked for with the keyboard moves focus to
|
|
24
|
+
// its first new item, since the button they pressed is gone.
|
|
25
|
+
//
|
|
26
|
+
// It imports nothing; test/javascript/infinite_scroll.test.mjs loads it as a
|
|
27
|
+
// data: module.
|
|
28
|
+
|
|
29
|
+
export const NEAR_PX = 600
|
|
30
|
+
export const NEXT = "turbo-frame[data-infinite-scroll-next]"
|
|
31
|
+
export const MORE = "[data-infinite-scroll-more]"
|
|
32
|
+
export const STATUS = "[data-infinite-scroll-status]"
|
|
33
|
+
export const DEFAULT_ANNOUNCE = "%{count} more loaded"
|
|
34
|
+
|
|
35
|
+
// The status line for a page of `count` items. `done` adds the end label.
|
|
36
|
+
export function announcement(template, count, done, endLabel) {
|
|
37
|
+
const said = String(template || DEFAULT_ANNOUNCE).replace("%{count}", String(count))
|
|
38
|
+
return done && endLabel ? `${said}. ${endLabel}` : said
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The items a landed frame brought: its element children, less the next frame
|
|
42
|
+
// and the end line, which are the list's machinery rather than its content.
|
|
43
|
+
export function newItems(frame) {
|
|
44
|
+
return Array.from(frame.children || []).filter((el) =>
|
|
45
|
+
!(el.matches && (el.matches(NEXT) || el.matches(".studio-infinite-scroll-end") || el.matches(".studio-infinite-scroll-more"))))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The one frame still waiting for its page: not loaded, not loading.
|
|
49
|
+
export function waitingFrame(root) {
|
|
50
|
+
const frames = Array.from(root.querySelectorAll(NEXT))
|
|
51
|
+
return frames.find((frame) => !frame.hasAttribute("src") && !frame.hasAttribute("complete")) || null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// What the observer watches for a frame: its button row, else its link.
|
|
55
|
+
export function sentinelOf(frame) {
|
|
56
|
+
return frame.querySelector(".studio-infinite-scroll-more") || frame.querySelector(MORE)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Points the frame at its link's URL, once. Answers whether it started a load.
|
|
60
|
+
export function loadNext(frame) {
|
|
61
|
+
if (!frame || frame.hasAttribute("src")) return false
|
|
62
|
+
const link = frame.querySelector(MORE)
|
|
63
|
+
const url = link && link.getAttribute("href")
|
|
64
|
+
if (!url) return false
|
|
65
|
+
frame.setAttribute("src", url)
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function say(status, text, win) {
|
|
70
|
+
if (!status) return
|
|
71
|
+
// Emptied first, so the same words said twice are heard twice.
|
|
72
|
+
status.textContent = ""
|
|
73
|
+
const put = () => { status.textContent = text }
|
|
74
|
+
if (win && typeof win.requestAnimationFrame === "function") win.requestAnimationFrame(put)
|
|
75
|
+
else put()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Wires one list. Answers a stop function for the controller's disconnect.
|
|
79
|
+
export function watchInfiniteScroll(root, win) {
|
|
80
|
+
win = win || window
|
|
81
|
+
let watcher = null
|
|
82
|
+
let watched = null
|
|
83
|
+
let byKeyboard = false
|
|
84
|
+
|
|
85
|
+
// The frame takes no box (display: contents), and an observer sees only
|
|
86
|
+
// boxes, so the watch is on its sentinel: the button's row inside it.
|
|
87
|
+
function arm() {
|
|
88
|
+
const frame = waitingFrame(root)
|
|
89
|
+
const sentinel = frame && sentinelOf(frame)
|
|
90
|
+
if (sentinel === watched) return
|
|
91
|
+
if (watcher && watched) watcher.unobserve(watched)
|
|
92
|
+
watched = sentinel
|
|
93
|
+
if (!sentinel || !watcher) return
|
|
94
|
+
watcher.observe(sentinel)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof win.IntersectionObserver === "function") {
|
|
98
|
+
watcher = new win.IntersectionObserver((entries) => {
|
|
99
|
+
entries.forEach((entry) => {
|
|
100
|
+
if (!entry.isIntersecting) return
|
|
101
|
+
watcher.unobserve(entry.target)
|
|
102
|
+
const frame = entry.target.closest(NEXT)
|
|
103
|
+
// A Tab onto "Load more" scrolls it into view, so the observer loads the
|
|
104
|
+
// page before any click: focus inside the frame is that keyboard user.
|
|
105
|
+
const active = win.document && win.document.activeElement
|
|
106
|
+
byKeyboard = !!(frame && active && frame.contains(active))
|
|
107
|
+
loadNext(frame)
|
|
108
|
+
})
|
|
109
|
+
}, { rootMargin: `0px 0px ${NEAR_PX}px 0px` })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A click on the button with no pointer behind it is a keyboard press.
|
|
113
|
+
function onClick(event) {
|
|
114
|
+
const link = event.target && event.target.closest && event.target.closest(MORE)
|
|
115
|
+
if (link && root.contains(link)) byKeyboard = event.detail === 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onLoad(event) {
|
|
119
|
+
const frame = event.target
|
|
120
|
+
if (!frame || !frame.matches || !frame.matches(NEXT) || !root.contains(frame)) return
|
|
121
|
+
const items = newItems(frame)
|
|
122
|
+
const done = !frame.querySelector(NEXT)
|
|
123
|
+
say(root.querySelector(STATUS), announcement(root.getAttribute("data-infinite-scroll-announce"), items.length, done,
|
|
124
|
+
root.getAttribute("data-infinite-scroll-end")), win)
|
|
125
|
+
if (byKeyboard && items[0]) {
|
|
126
|
+
if (!items[0].hasAttribute("tabindex")) items[0].setAttribute("tabindex", "-1")
|
|
127
|
+
items[0].focus({ preventScroll: false })
|
|
128
|
+
}
|
|
129
|
+
byKeyboard = false
|
|
130
|
+
arm()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
root.addEventListener("click", onClick, true)
|
|
134
|
+
root.addEventListener("turbo:frame-load", onLoad)
|
|
135
|
+
arm()
|
|
136
|
+
|
|
137
|
+
return function stop() {
|
|
138
|
+
root.removeEventListener("click", onClick, true)
|
|
139
|
+
root.removeEventListener("turbo:frame-load", onLoad)
|
|
140
|
+
if (watcher) watcher.disconnect()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -270,8 +270,13 @@ export function setPercent(el, value) {
|
|
|
270
270
|
if (!el) return
|
|
271
271
|
var loader = el.classList && el.classList.contains('studio-loader') ? el : el.querySelector('.studio-loader')
|
|
272
272
|
if (!loader) return
|
|
273
|
+
var percent = clampPercent(value)
|
|
273
274
|
loader.setAttribute('data-loader-percent', '')
|
|
274
|
-
loader.style.setProperty('--studio-loader-percent', String(
|
|
275
|
+
loader.style.setProperty('--studio-loader-percent', String(percent))
|
|
276
|
+
// A bar is a progressbar: a percent makes it determinate and is its value.
|
|
277
|
+
if (loader.getAttribute && loader.getAttribute('role') === 'progressbar') loader.setAttribute('aria-valuenow', String(percent))
|
|
278
|
+
var sweep = loader.querySelector && loader.querySelector('.progress-meter--indeterminate')
|
|
279
|
+
if (sweep) sweep.classList.remove('progress-meter--indeterminate')
|
|
275
280
|
}
|
|
276
281
|
|
|
277
282
|
// A submitter that carries a pending loader.
|
|
@@ -20,6 +20,11 @@ export const STYLES = Object.freeze({
|
|
|
20
20
|
slide: Object.freeze({ left: "slide-left", right: "slide-right", top: "slide-top", bottom: "slide-bottom" }),
|
|
21
21
|
unfold: Object.freeze({ left: "unfold-right", right: "unfold-left", top: "unfold-bottom", bottom: "unfold-top" }),
|
|
22
22
|
})
|
|
23
|
+
// A Back plays the app's move the other way: the reverse direction, in the same style. So a
|
|
24
|
+
// slide-left goes back as a slide-right, and an unfold-right as an unfold-left.
|
|
25
|
+
export const REVERSE = Object.freeze({ left: "right", right: "left", top: "bottom", bottom: "top" })
|
|
26
|
+
// A link that leads back up the trail: an ancestor in the crumbs, or the phone's parent link.
|
|
27
|
+
export const BACK_LINK = "a[data-page-transition-back]"
|
|
23
28
|
// Mirrors the default in lib/studio.rb, for a page with no style meta.
|
|
24
29
|
export const DEFAULT_STYLE = "slide"
|
|
25
30
|
|
|
@@ -32,17 +37,48 @@ export const FULL_LOAD_STYLE_KEY = "studio-page-transition-style"
|
|
|
32
37
|
const MAX_READY_IMAGES = 12
|
|
33
38
|
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
|
|
34
39
|
|
|
35
|
-
export function styleFor(direction, style) {
|
|
40
|
+
export function styleFor(direction, style, back = false) {
|
|
36
41
|
const moves = STYLES[Object.hasOwn(STYLES, style) ? style : DEFAULT_STYLE]
|
|
37
|
-
|
|
42
|
+
if (!Object.hasOwn(moves, direction)) return null
|
|
43
|
+
return moves[back ? REVERSE[direction] : direction]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Whether a visit goes back. Turbo marks <html> with data-turbo-visit-direction for each visit,
|
|
47
|
+
// before turbo:visit fires: "back" for a restore to an earlier history entry, and "forward"
|
|
48
|
+
// for a restore to a later one. So the browser's Forward keeps the app's direction. An advance
|
|
49
|
+
// goes back only when the reader clicked a back link to that same address.
|
|
50
|
+
export function visitIsBack({ action, turboDirection, url, backHref }) {
|
|
51
|
+
if (action === "restore") return turboDirection === "back"
|
|
52
|
+
return sameAddress(url, backHref)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Whether a full load goes back: a traverse to an earlier history entry, or a back link.
|
|
56
|
+
export function fullLoadIsBack(activation, backHref) {
|
|
57
|
+
if (!activation || !activation.entry) return false
|
|
58
|
+
if (activation.navigationType === "traverse") {
|
|
59
|
+
const from = activation.from
|
|
60
|
+
return !!from && typeof from.index === "number" && from.index >= 0 && activation.entry.index < from.index
|
|
61
|
+
}
|
|
62
|
+
return sameAddress(activation.entry.url, backHref)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sameAddress(a, b) {
|
|
66
|
+
if (!a || !b) return false
|
|
67
|
+
try {
|
|
68
|
+
const one = new URL(a, "https://x.invalid")
|
|
69
|
+
const two = new URL(b, "https://x.invalid")
|
|
70
|
+
return one.origin === two.origin && one.pathname === two.pathname && one.search === two.search
|
|
71
|
+
} catch {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
38
74
|
}
|
|
39
75
|
|
|
40
76
|
// The style for one visit, STILL, or null to leave the swap alone (the engine's fade plays,
|
|
41
77
|
// and a morph between two pages stands). An advance visit to the same path can bring a new
|
|
42
78
|
// page, as a launch panel does when it gives way to the chat at its own address, so a name
|
|
43
79
|
// the two share still morphs. A refresh replaces, and stays silent.
|
|
44
|
-
export function planVisit({ direction, style, from, to, transitions, sharedName, advance }) {
|
|
45
|
-
const move = styleFor(direction, style)
|
|
80
|
+
export function planVisit({ direction, style, from, to, transitions, sharedName, advance, back }) {
|
|
81
|
+
const move = styleFor(direction, style, back)
|
|
46
82
|
if (!transitions || !from || !to || !move) return null
|
|
47
83
|
if (from === to) return advance && sharedName ? null : STILL
|
|
48
84
|
return sharedName ? null : move
|
|
@@ -51,11 +87,11 @@ export function planVisit({ direction, style, from, to, transitions, sharedName,
|
|
|
51
87
|
// The style a full load should play, or null for a plain load: planVisit's own rule, so the
|
|
52
88
|
// two paths cannot differ. No morph, since the old page cannot read the new page's names
|
|
53
89
|
// before its picture, and a browser with no activation entry has nothing to compare.
|
|
54
|
-
export function planFullLoad({ direction, style, from, activation }) {
|
|
90
|
+
export function planFullLoad({ direction, style, from, activation, back = false }) {
|
|
55
91
|
const url = activation && activation.entry && activation.entry.url
|
|
56
92
|
if (!url) return null
|
|
57
93
|
const to = new URL(url).pathname
|
|
58
|
-
const move = planVisit({ direction, style, from, to, transitions: true, sharedName: false })
|
|
94
|
+
const move = planVisit({ direction, style, from, to, transitions: true, sharedName: false, back })
|
|
59
95
|
return move === STILL ? null : move
|
|
60
96
|
}
|
|
61
97
|
|
|
@@ -150,6 +186,11 @@ export function installPageTransitions(doc) {
|
|
|
150
186
|
let target = null
|
|
151
187
|
let advance = false
|
|
152
188
|
let rendered = false
|
|
189
|
+
// The address of the back link the reader clicked last, if any. Matched by address, so a
|
|
190
|
+
// click that never became a visit (a modifier click, a cancelled visit) cannot turn the
|
|
191
|
+
// next visit around.
|
|
192
|
+
let backHref = null
|
|
193
|
+
let back = false
|
|
153
194
|
|
|
154
195
|
const nameTheMorph = (shared) => {
|
|
155
196
|
if (shared) root.dataset.pageTransitionMorph = ""
|
|
@@ -161,6 +202,12 @@ export function installPageTransitions(doc) {
|
|
|
161
202
|
markHeld(doc)
|
|
162
203
|
known.set(from, morphNames(doc))
|
|
163
204
|
|
|
205
|
+
// In capture, ahead of Turbo's own click listener, so the mark is down before the visit.
|
|
206
|
+
doc.addEventListener("click", (event) => {
|
|
207
|
+
const link = event.target && event.target.closest ? event.target.closest("a[href]") : null
|
|
208
|
+
backHref = link && link.matches(BACK_LINK) ? link.href : null
|
|
209
|
+
}, { capture: true })
|
|
210
|
+
|
|
164
211
|
doc.addEventListener("turbo:visit", (event) => {
|
|
165
212
|
root.dataset.pageTransitionVisit = ""
|
|
166
213
|
// Turbo opens its view transition before it reads turbo-visit-control. A response it will
|
|
@@ -171,7 +218,12 @@ export function installPageTransitions(doc) {
|
|
|
171
218
|
// A GET that redirects: Turbo renders the response, then replaces to the final address
|
|
172
219
|
// with that same response, all before turbo:load. The page has landed, so nothing moves.
|
|
173
220
|
const action = event.detail.action
|
|
174
|
-
|
|
221
|
+
const redirected = rendered && action === "replace"
|
|
222
|
+
if (redirected) from = target
|
|
223
|
+
// No guard keeps the first half's way: Turbo's replace (willRender: false) still fires
|
|
224
|
+
// turbo:before-render, but `from` is now `target`, so planVisit plays STILL either way.
|
|
225
|
+
back = visitIsBack({ action, turboDirection: root.dataset.turboVisitDirection, url: event.detail.url, backHref })
|
|
226
|
+
backHref = null
|
|
175
227
|
rendered = false
|
|
176
228
|
advance = action === "advance"
|
|
177
229
|
predict(known.get(target))
|
|
@@ -207,6 +259,7 @@ export function installPageTransitions(doc) {
|
|
|
207
259
|
transitions: !!doc.startViewTransition && !win.matchMedia(REDUCED_MOTION).matches,
|
|
208
260
|
sharedName,
|
|
209
261
|
advance,
|
|
262
|
+
back,
|
|
210
263
|
})
|
|
211
264
|
nameTheMorph(sharedName && style !== STILL)
|
|
212
265
|
if (style) root.dataset.pageTransition = style
|
|
@@ -226,6 +279,7 @@ export function installPageTransitions(doc) {
|
|
|
226
279
|
target = null
|
|
227
280
|
advance = false
|
|
228
281
|
rendered = false
|
|
282
|
+
back = false
|
|
229
283
|
known.set(from, morphNames(doc))
|
|
230
284
|
if (!visited) return
|
|
231
285
|
delete root.dataset.pageTransition
|
|
@@ -244,7 +298,10 @@ export function installPageTransitions(doc) {
|
|
|
244
298
|
// The last moment on the old document. The browser takes its picture after this.
|
|
245
299
|
win.addEventListener("pageswap", (event) => {
|
|
246
300
|
const transition = event.viewTransition
|
|
247
|
-
const
|
|
301
|
+
const activation = event.activation
|
|
302
|
+
const style = transition && planFullLoad({
|
|
303
|
+
direction: readDirection(doc), style: readStyle(doc), from, activation, back: fullLoadIsBack(activation, backHref),
|
|
304
|
+
})
|
|
248
305
|
const left = leaveStyle(win, style)
|
|
249
306
|
if (!transition) return
|
|
250
307
|
if (!left) {
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
// SIZE something as tall as the header; use --nav-bottom to START something
|
|
38
38
|
// underneath it. Same distinction for --pin-<name>-h vs --pin-<name>-bottom.
|
|
39
39
|
//
|
|
40
|
+
// THE SECTION LAYER (studio/_section_header, docs/PAGE_HEADER.md) IS THE ONE LAYER
|
|
41
|
+
// THAT IS NOT A data-pin. A page has many section headers and only one is stuck, so
|
|
42
|
+
// each carries `data-pin-section`, and together they publish as one layer, `section`.
|
|
43
|
+
// See stuckSection below.
|
|
44
|
+
//
|
|
40
45
|
// Started once per document by studio/application. The listeners it adds live
|
|
41
46
|
// for the document, and a Turbo visit republishes through them rather than
|
|
42
47
|
// starting it again.
|
|
@@ -152,6 +157,62 @@ export function documentIndex(list, el) {
|
|
|
152
157
|
return list.length;
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
// THE SECTION LAYER: MANY HEADERS, ONE LAYER, ONLY THE STUCK ONE COUNTS.
|
|
161
|
+
//
|
|
162
|
+
// A section header is sticky inside its own <section>, so the browser does the
|
|
163
|
+
// swap: the next section's header arrives as the last one is pushed out. It joins
|
|
164
|
+
// the stack so that a table's sticky header and any other reader of
|
|
165
|
+
// --pin-stack-bottom sit under it. It cannot be a plain [data-pin]: composed() takes
|
|
166
|
+
// the lowest edge of every layer, so a header waiting below the fold would drag
|
|
167
|
+
// --pin-stack-bottom down the page, and headers sharing a name would overwrite each
|
|
168
|
+
// other's values.
|
|
169
|
+
//
|
|
170
|
+
// So all of them are one layer named `section`, always in the registry while any is
|
|
171
|
+
// on the page. The roster never changes per swap, so a swap is not a structural write,
|
|
172
|
+
// and --pin-section-top, which every header positions off, stays published when none
|
|
173
|
+
// is stuck. That layer's height is 0 until one is stuck, and then it is the part of the
|
|
174
|
+
// bar still below the layers above, so a header being pushed out gives its room back
|
|
175
|
+
// frame by frame.
|
|
176
|
+
//
|
|
177
|
+
// rects are { top, bottom, parentTop, parentBottom }, in page order; line is
|
|
178
|
+
// --pin-section-top. A header is stuck once it has left its place in the flow and part
|
|
179
|
+
// of it still shows below the line. Its section's top is that place, since it is the
|
|
180
|
+
// section's first child and the section has no top padding.
|
|
181
|
+
//
|
|
182
|
+
// NEITHER FACT READS THE HEADER'S OWN EDGE AGAINST THE LINE. While the navbar collapses
|
|
183
|
+
// the line moves first and the header's `top` follows a publish later, so a comparison
|
|
184
|
+
// would drop the bar for the whole collapse, and a bottom read off the rect would be
|
|
185
|
+
// published one step stale, with nothing left to correct it once the page is still. So
|
|
186
|
+
// the bar's bottom is what sticky will make it: one height under the line, or the end of
|
|
187
|
+
// its section while that pushes it out.
|
|
188
|
+
export function sectionBottom(r, line) {
|
|
189
|
+
return Math.min(line + (r.bottom - r.top), r.parentBottom);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function stuckSection(rects, line) {
|
|
193
|
+
var found = -1;
|
|
194
|
+
for (var i = 0; i < rects.length; i++) {
|
|
195
|
+
var r = rects[i];
|
|
196
|
+
if (r.top - r.parentTop > 0.5 && sectionBottom(r, line) > line + 0.5) found = i;
|
|
197
|
+
}
|
|
198
|
+
return found;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function measureSections(nodes, line) {
|
|
202
|
+
// Below the short-screen floor, or before the publisher runs, the CSS leaves these in
|
|
203
|
+
// the flow (engine.css, @utility section-header). A header scrolling past the line
|
|
204
|
+
// there is not stuck, so it must not count.
|
|
205
|
+
if (window.getComputedStyle(nodes[0]).position !== 'sticky') return null;
|
|
206
|
+
var rects = [];
|
|
207
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
208
|
+
var r = nodes[i].getBoundingClientRect();
|
|
209
|
+
var parent = nodes[i].parentElement.getBoundingClientRect();
|
|
210
|
+
rects.push({ top: r.top, bottom: r.bottom, parentTop: parent.top, parentBottom: parent.bottom });
|
|
211
|
+
}
|
|
212
|
+
var at = stuckSection(rects, line);
|
|
213
|
+
return at < 0 ? null : { el: nodes[at], bottom: sectionBottom(rects[at], line) };
|
|
214
|
+
}
|
|
215
|
+
|
|
155
216
|
// THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
|
|
156
217
|
//
|
|
157
218
|
// This used to build a `pins` array once and hold each element by reference
|
|
@@ -168,8 +229,9 @@ export function documentIndex(list, el) {
|
|
|
168
229
|
// reference IMPOSSIBLE rather than merely unlikely: there is no window between
|
|
169
230
|
// a DOM patch and a re-scan in which this can be pointed at the wrong node.
|
|
170
231
|
//
|
|
171
|
-
// Returns { pins, stack, timeline }: every layer measured and
|
|
172
|
-
// stack's bottom,
|
|
232
|
+
// Returns { pins, stack, timeline, travel, sections, stuck }: every layer measured and
|
|
233
|
+
// composed, the stack's bottom, whether the header is on the scroll timeline and how
|
|
234
|
+
// far it travels, the section headers, and which of them is stuck.
|
|
173
235
|
export function readStack() {
|
|
174
236
|
var header = document.querySelector('header');
|
|
175
237
|
var timeline = onTimeline(header);
|
|
@@ -191,11 +253,28 @@ export function readStack() {
|
|
|
191
253
|
// never publish at all. Both live consumers own their header, so it is
|
|
192
254
|
// adopted as a pin even when it does not ask to be.
|
|
193
255
|
if (header && !headerPinned) list.splice(documentIndex(list, header), 0, measurePin(header, 'nav', true, timeline));
|
|
256
|
+
// In the registry at height 0 first, so the composition says where its line is.
|
|
257
|
+
var sections = document.querySelectorAll('[data-pin-section]');
|
|
258
|
+
var section = null;
|
|
259
|
+
if (sections.length) {
|
|
260
|
+
section = { el: sections[0], name: 'section', legacy: false, h: 0, bottom: 0 };
|
|
261
|
+
list.splice(documentIndex(list, sections[0]), 0, section);
|
|
262
|
+
}
|
|
263
|
+
var stack = timeline ? pinnedAt(list) : composed(list);
|
|
264
|
+
var stuck = section ? measureSections(sections, section.top) : null;
|
|
265
|
+
if (stuck) {
|
|
266
|
+
section.el = stuck.el;
|
|
267
|
+
section.h = stuck.bottom - section.top;
|
|
268
|
+
section.bottom = stuck.bottom;
|
|
269
|
+
stack = timeline ? pinnedAt(list) : composed(list);
|
|
270
|
+
}
|
|
194
271
|
return {
|
|
195
272
|
pins: list,
|
|
196
|
-
stack:
|
|
273
|
+
stack: stack,
|
|
197
274
|
timeline: timeline,
|
|
198
|
-
travel: timeline ? timelineTravel(header) : 0
|
|
275
|
+
travel: timeline ? timelineTravel(header) : 0,
|
|
276
|
+
sections: sections,
|
|
277
|
+
stuck: stuck ? stuck.el : null
|
|
199
278
|
};
|
|
200
279
|
}
|
|
201
280
|
|
|
@@ -414,6 +493,9 @@ export function startPinnedStack() {
|
|
|
414
493
|
settleTimer = null;
|
|
415
494
|
publishAll();
|
|
416
495
|
releaseFollowers();
|
|
496
|
+
// That publish measured the layers while they still held their last copies. Given back,
|
|
497
|
+
// they move to the root's values, so their edges are read once more.
|
|
498
|
+
schedule();
|
|
417
499
|
}
|
|
418
500
|
|
|
419
501
|
// The registry and the measurement are module-level (readStack, measurePin
|
|
@@ -505,12 +587,38 @@ export function startPinnedStack() {
|
|
|
505
587
|
}
|
|
506
588
|
}
|
|
507
589
|
|
|
590
|
+
// WHO WEARS THE BAR, written after every read of the publish. The box does not change
|
|
591
|
+
// with the attribute, so this resizes nothing and moves nothing. The root's attribute
|
|
592
|
+
// squares the page row's corners against the bar under it (engine.css, glass).
|
|
593
|
+
function syncSections(reading) {
|
|
594
|
+
for (var i = 0; i < reading.sections.length; i++) {
|
|
595
|
+
var node = reading.sections[i];
|
|
596
|
+
var on = node === reading.stuck;
|
|
597
|
+
if (node.hasAttribute('data-stuck') !== on) node.toggleAttribute('data-stuck', on);
|
|
598
|
+
}
|
|
599
|
+
var root = document.documentElement;
|
|
600
|
+
if (root.hasAttribute('data-pin-section-stuck') !== !!reading.stuck) root.toggleAttribute('data-pin-section-stuck', !!reading.stuck);
|
|
601
|
+
}
|
|
602
|
+
|
|
508
603
|
var lastPublished = null;
|
|
604
|
+
var lastTops = null;
|
|
509
605
|
function publishAll() {
|
|
606
|
+
// What lets a section header stick at all (engine.css): with no publisher its bar
|
|
607
|
+
// would never come, and it would lay a clear title over the page. Before the read,
|
|
608
|
+
// so the first publish on a restored scroll already sees it sticky.
|
|
609
|
+
var html = document.documentElement;
|
|
610
|
+
if (!html.hasAttribute('data-pin-live')) html.setAttribute('data-pin-live', '');
|
|
510
611
|
var reading = readStack();
|
|
511
612
|
var pins = reading.pins;
|
|
512
613
|
write(pins, reading.stack, reading.timeline, reading.travel);
|
|
614
|
+
syncSections(reading);
|
|
513
615
|
syncObserver(pins);
|
|
616
|
+
// A LAYER UNDER A LAYER SETTLES ONE PUBLISH LATER. A new --pin-page-top moves the page
|
|
617
|
+
// row without resizing it, so nothing wakes the publisher to read the row's new edge,
|
|
618
|
+
// and the layer under the row would keep a stale line once the page is still. So a
|
|
619
|
+
// publish that moved any layer's top asks for one more frame. It ends when none moved.
|
|
620
|
+
var tops = pins.map(function (p) { return p.top; }).join('|');
|
|
621
|
+
if (tops !== lastTops) { lastTops = tops; schedule(); }
|
|
514
622
|
var key = publishedKey(reading);
|
|
515
623
|
if (key !== lastPublished) {
|
|
516
624
|
lastPublished = key;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// studio/pull_to_refresh: pull the page down from its top to reload it
|
|
2
|
+
// (docs/LOADER.md, "Pull to refresh"). Phone widths and touch only: the
|
|
3
|
+
// controller arms itself where PHONE_QUERY matches and does nothing anywhere
|
|
4
|
+
// else, so a desktop, a mouse or a keyboard never meets it. The markup is
|
|
5
|
+
// studio_pull_to_refresh:
|
|
6
|
+
//
|
|
7
|
+
// <div class="studio-pull-refresh" data-studio-controller="pull-to-refresh"
|
|
8
|
+
// data-pull-to-refresh-frame="feed"> (optional: reload one frame)
|
|
9
|
+
// <span class="studio-pull-refresh-indicator" hidden>...</span>
|
|
10
|
+
// <p class="sr-only" role="status" data-pull-to-refresh-status></p>
|
|
11
|
+
// ...the page...
|
|
12
|
+
// </div>
|
|
13
|
+
//
|
|
14
|
+
// the pull a touch that starts with the page at its top and moves down
|
|
15
|
+
// drags the indicator; --studio-pull is the distance over
|
|
16
|
+
// THRESHOLD_PX, eased so it resists the finger.
|
|
17
|
+
// the release past the threshold it refreshes: the named frame's reload(),
|
|
18
|
+
// else a Turbo visit to this URL with action "replace" (which the
|
|
19
|
+
// page-load overlay never shows for). Short of it, it settles back.
|
|
20
|
+
// the end turbo:frame-load or turbo:load clears the indicator; so does
|
|
21
|
+
// MAX_SPIN_MS, so a refresh that fails never leaves it spinning.
|
|
22
|
+
//
|
|
23
|
+
// The browser's own pull (Chrome on Android reloads on overscroll) is turned
|
|
24
|
+
// off with overscroll-behavior-y while the controller is armed, so one pull
|
|
25
|
+
// never refreshes twice.
|
|
26
|
+
//
|
|
27
|
+
// It imports nothing; test/javascript/pull_to_refresh.test.mjs loads it as a
|
|
28
|
+
// data: module.
|
|
29
|
+
|
|
30
|
+
export const PHONE_QUERY = "(pointer: coarse) and (max-width: 640px)"
|
|
31
|
+
export const THRESHOLD_PX = 72
|
|
32
|
+
export const MAX_SPIN_MS = 10000
|
|
33
|
+
|
|
34
|
+
// How far a finger `dy` pixels down has pulled, 0..1.2 of the threshold. The
|
|
35
|
+
// first half follows the finger; past it the pull resists.
|
|
36
|
+
export function pullProgress(dy) {
|
|
37
|
+
if (!(dy > 0)) return 0
|
|
38
|
+
const raw = dy / THRESHOLD_PX
|
|
39
|
+
const eased = raw <= 0.5 ? raw : 0.5 + (raw - 0.5) * 0.55
|
|
40
|
+
return Math.min(1.2, Math.round(eased * 1000) / 1000)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A release refreshes once the eased pull reaches the threshold.
|
|
44
|
+
export function shouldRefresh(progress) {
|
|
45
|
+
return progress >= 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function phoneTouch(win) {
|
|
49
|
+
return !!(win && typeof win.matchMedia === "function" && win.matchMedia(PHONE_QUERY).matches)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Whether a touch on `target` began inside a scroller below `root` that is not
|
|
53
|
+
// at its top: that drag scrolls the scroller back up, so it is not a pull.
|
|
54
|
+
export function insideScroller(target, root) {
|
|
55
|
+
for (let el = target; el && el !== root; el = el.parentElement) if (el.scrollTop > 0) return true
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Wires one container. Answers a stop function, or null where it does not arm.
|
|
60
|
+
export function watchPullToRefresh(root, win, doc) {
|
|
61
|
+
win = win || window
|
|
62
|
+
doc = doc || document
|
|
63
|
+
if (!phoneTouch(win)) return null
|
|
64
|
+
|
|
65
|
+
const indicator = root.querySelector(".studio-pull-refresh-indicator")
|
|
66
|
+
const status = root.querySelector("[data-pull-to-refresh-status]")
|
|
67
|
+
const label = root.getAttribute("data-pull-to-refresh-label") || "Refreshing"
|
|
68
|
+
const html = doc.documentElement
|
|
69
|
+
const previousOverscroll = html.style.overscrollBehaviorY
|
|
70
|
+
html.style.overscrollBehaviorY = "contain"
|
|
71
|
+
|
|
72
|
+
let startY = null
|
|
73
|
+
let progress = 0
|
|
74
|
+
let spinTimer = null
|
|
75
|
+
|
|
76
|
+
function setPull(value) {
|
|
77
|
+
progress = value
|
|
78
|
+
if (indicator) {
|
|
79
|
+
indicator.hidden = value === 0 && root.getAttribute("data-pull-state") !== "refreshing"
|
|
80
|
+
indicator.style.setProperty("--studio-pull", String(Math.min(1, value)))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function finish(event) {
|
|
85
|
+
const frameId = root.getAttribute("data-pull-to-refresh-frame")
|
|
86
|
+
if (event && event.type === "turbo:frame-load" && (!frameId || event.target.id !== frameId)) return
|
|
87
|
+
if (root.getAttribute("data-pull-state") !== "refreshing") return
|
|
88
|
+
clearTimeout(spinTimer)
|
|
89
|
+
root.setAttribute("data-pull-state", "settling")
|
|
90
|
+
if (status) status.textContent = ""
|
|
91
|
+
setPull(0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function refresh() {
|
|
95
|
+
root.setAttribute("data-pull-state", "refreshing")
|
|
96
|
+
if (indicator) indicator.hidden = false
|
|
97
|
+
if (status) status.textContent = label
|
|
98
|
+
spinTimer = setTimeout(finish, MAX_SPIN_MS)
|
|
99
|
+
const frameId = root.getAttribute("data-pull-to-refresh-frame")
|
|
100
|
+
const frame = frameId && doc.getElementById(frameId)
|
|
101
|
+
if (frame && typeof frame.reload === "function") frame.reload()
|
|
102
|
+
else if (win.Turbo && typeof win.Turbo.visit === "function") win.Turbo.visit(win.location.href, { action: "replace" })
|
|
103
|
+
else win.location.reload()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function onStart(event) {
|
|
107
|
+
if (root.getAttribute("data-pull-state") === "refreshing") return
|
|
108
|
+
if (event.touches.length !== 1 || (win.scrollY || 0) > 0 || insideScroller(event.target, root)) { startY = null; return }
|
|
109
|
+
startY = event.touches[0].clientY
|
|
110
|
+
root.setAttribute("data-pull-state", "pulling")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onMove(event) {
|
|
114
|
+
if (startY === null) return
|
|
115
|
+
const dy = event.touches[0].clientY - startY
|
|
116
|
+
if (dy <= 0 || (win.scrollY || 0) > 0) { setPull(0); return }
|
|
117
|
+
// Holding the page still while the indicator follows the finger.
|
|
118
|
+
if (event.cancelable) event.preventDefault()
|
|
119
|
+
setPull(pullProgress(dy))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onEnd() {
|
|
123
|
+
if (startY === null) return
|
|
124
|
+
startY = null
|
|
125
|
+
if (shouldRefresh(progress)) { refresh(); return }
|
|
126
|
+
root.setAttribute("data-pull-state", "settling")
|
|
127
|
+
setPull(0)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
root.addEventListener("touchstart", onStart, { passive: true })
|
|
131
|
+
root.addEventListener("touchmove", onMove, { passive: false })
|
|
132
|
+
root.addEventListener("touchend", onEnd)
|
|
133
|
+
root.addEventListener("touchcancel", onEnd)
|
|
134
|
+
doc.addEventListener("turbo:frame-load", finish)
|
|
135
|
+
doc.addEventListener("turbo:load", finish)
|
|
136
|
+
|
|
137
|
+
return function stop() {
|
|
138
|
+
clearTimeout(spinTimer)
|
|
139
|
+
html.style.overscrollBehaviorY = previousOverscroll
|
|
140
|
+
root.removeEventListener("touchstart", onStart)
|
|
141
|
+
root.removeEventListener("touchmove", onMove)
|
|
142
|
+
root.removeEventListener("touchend", onEnd)
|
|
143
|
+
root.removeEventListener("touchcancel", onEnd)
|
|
144
|
+
doc.removeEventListener("turbo:frame-load", finish)
|
|
145
|
+
doc.removeEventListener("turbo:load", finish)
|
|
146
|
+
}
|
|
147
|
+
}
|