studio-engine 0.132.0 → 0.133.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 +77 -0
- data/README.md +52 -24
- data/app/assets/tailwind/studio_engine/engine.css +141 -0
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/carousel.js +66 -1
- data/app/javascript/studio/controllers/carousel_controller.js +139 -8
- data/app/javascript/studio/controllers/page_header_controller.js +64 -1
- data/app/javascript/studio/controllers/style_section_nav_controller.js +26 -0
- data/app/javascript/studio/in_page_links.js +308 -0
- data/app/javascript/studio/page_header.js +17 -0
- data/app/views/studio/_carousel.html.erb +33 -5
- data/app/views/studio/_page_header.html.erb +64 -12
- data/app/views/studio/auth/_sign_in_body.html.erb +236 -0
- data/app/views/studio/auth/_sign_in_card.html.erb +50 -0
- data/app/views/studio/auth/_sign_in_modal.html.erb +36 -0
- data/app/views/studio/modals/auth/_resend_footer.html.erb +6 -0
- data/app/views/style/_glass.html.erb +4 -8
- data/app/views/style/_loaders.html.erb +11 -35
- data/app/views/style/_modals.html.erb +29 -39
- data/app/views/style/_scrims.html.erb +10 -20
- data/app/views/style/_site_footer.html.erb +10 -18
- data/app/views/style/_specimen.html.erb +13 -41
- data/app/views/style/_tasks.html.erb +4 -22
- data/app/views/style/_trick_label.html.erb +33 -0
- data/app/views/style/_tricks.html.erb +127 -207
- data/app/views/style/index.html.erb +2 -2
- data/app/views/style/modals/_wallet_connect.html.erb +2 -1
- data/config/studio_tricks.yml +470 -0
- data/lib/studio/page_header.rb +53 -0
- data/lib/studio/tricks.rb +110 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +6 -4
- metadata +9 -3
- data/app/views/style/modals/_ds_email_field.html.erb +0 -58
- data/app/views/style/modals/_ds_resend_footer.html.erb +0 -70
|
@@ -25,14 +25,33 @@
|
|
|
25
25
|
// scroll-initial-target (engine.css); elsewhere connect() jumps there, with no glide
|
|
26
26
|
// and nothing in the status line. A row already moved (the browser's own start, a
|
|
27
27
|
// restored scroll, a #fragment inside it) is left where it is.
|
|
28
|
+
//
|
|
29
|
+
// A LOOPING row (loop:, data-carousel-loop) wraps: Next at the last stop goes back
|
|
30
|
+
// to the first, Previous at the first to the last, and neither arrow is ever
|
|
31
|
+
// aria-disabled. There are no cloned slides, so a screen reader reads each once:
|
|
32
|
+
// the row glides back across itself.
|
|
33
|
+
//
|
|
34
|
+
// AUTOPLAY (autoplay: seconds, data-carousel-autoplay) moves the row one stop each
|
|
35
|
+
// interval. It holds still while the pointer is over the slides, while keyboard focus
|
|
36
|
+
// is in it, while the tab is hidden or the row is off screen, and after a swipe,
|
|
37
|
+
// wheel, arrow, dot or key until the reader has left it alone a while (idleDelay).
|
|
38
|
+
// The pause button (WCAG 2.2.2) stops it until pressed again; under reduced motion
|
|
39
|
+
// the row starts stopped. A move by autoplay writes nothing to the status line
|
|
40
|
+
// (APG: a rotating carousel does not announce). Without loop it stops at the end.
|
|
28
41
|
import { Controller } from "@hotwired/stimulus"
|
|
29
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, centerStops, currentStop, hiddenEnds, idleDelay,
|
|
44
|
+
initialReasons, keyAction, ownsKey, perPage, positionText, startStop, stops, targetStop, toggleLabel, withReason
|
|
45
|
+
} from "studio/carousel"
|
|
46
|
+
|
|
47
|
+
const REDUCE = "(prefers-reduced-motion: reduce)"
|
|
30
48
|
|
|
31
49
|
export default class extends Controller {
|
|
32
|
-
static targets = ["track", "slide", "prev", "next", "dot", "status"]
|
|
50
|
+
static targets = ["track", "slide", "prev", "next", "dot", "status", "toggle"]
|
|
33
51
|
|
|
34
52
|
connect() {
|
|
35
53
|
this.schedule = this.schedule.bind(this)
|
|
54
|
+
this.loop = this.element.hasAttribute("data-carousel-loop")
|
|
36
55
|
this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
|
|
37
56
|
if (typeof ResizeObserver === "function") {
|
|
38
57
|
this.observer = new ResizeObserver(this.schedule)
|
|
@@ -41,6 +60,7 @@ export default class extends Controller {
|
|
|
41
60
|
this.element.setAttribute("data-carousel-ready", "")
|
|
42
61
|
this.openAtStart()
|
|
43
62
|
this.sync()
|
|
63
|
+
this.startAutoplay()
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
openAtStart() {
|
|
@@ -65,6 +85,112 @@ export default class extends Controller {
|
|
|
65
85
|
this.observer?.disconnect()
|
|
66
86
|
if (this.frame) cancelAnimationFrame(this.frame)
|
|
67
87
|
this.frame = null
|
|
88
|
+
this.stopAutoplay()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// -- autoplay ---------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
startAutoplay() {
|
|
94
|
+
this.seconds = autoplaySeconds(this.element.dataset.carouselAutoplay)
|
|
95
|
+
if (this.seconds === null) return
|
|
96
|
+
|
|
97
|
+
this.motion = window.matchMedia?.(REDUCE)
|
|
98
|
+
this.reasons = initialReasons({ reducedMotion: !!this.motion?.matches, hidden: document.hidden })
|
|
99
|
+
this.listeners = [
|
|
100
|
+
[this.trackTarget, "pointerenter", (event) => { if (event.pointerType !== "touch") this.pause("hover", true) }],
|
|
101
|
+
[this.trackTarget, "pointerleave", (event) => { if (event.pointerType !== "touch") this.pause("hover", false) }],
|
|
102
|
+
[this.element, "focusin", (event) => this.pause("focus", this.keyboardFocus(event.target))],
|
|
103
|
+
[this.element, "focusout", (event) => { if (!this.element.contains(event.relatedTarget)) this.pause("focus", false) }],
|
|
104
|
+
[this.trackTarget, "pointerdown", () => this.interacted()],
|
|
105
|
+
[this.trackTarget, "wheel", () => this.interacted()],
|
|
106
|
+
[this.trackTarget, "touchstart", () => this.interacted()],
|
|
107
|
+
[document, "visibilitychange", () => this.pause("hidden", document.hidden)],
|
|
108
|
+
[this.motion, "change", (event) => { if (event.matches) this.pause("stopped", true) }]
|
|
109
|
+
].filter(([target]) => target?.addEventListener)
|
|
110
|
+
this.listeners.forEach(([target, type, handler]) => target.addEventListener(type, handler, { passive: true }))
|
|
111
|
+
|
|
112
|
+
if (typeof IntersectionObserver === "function") {
|
|
113
|
+
this.viewport = new IntersectionObserver(([entry]) => this.pause("offscreen", !entry.isIntersecting))
|
|
114
|
+
this.viewport.observe(this.element)
|
|
115
|
+
}
|
|
116
|
+
this.refresh()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
stopAutoplay() {
|
|
120
|
+
this.listeners?.forEach(([target, type, handler]) => target.removeEventListener(type, handler))
|
|
121
|
+
this.listeners = null
|
|
122
|
+
this.viewport?.disconnect()
|
|
123
|
+
this.viewport = null
|
|
124
|
+
clearTimeout(this.timer)
|
|
125
|
+
clearTimeout(this.idle)
|
|
126
|
+
this.timer = this.idle = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Focus by keyboard holds the row; a mouse click that focuses an arrow does not.
|
|
130
|
+
// The pause button is the exception: a reader who tabs to it and presses Play
|
|
131
|
+
// means it to play.
|
|
132
|
+
keyboardFocus(target) {
|
|
133
|
+
if (this.hasToggleTarget && this.toggleTarget.contains(target)) return false
|
|
134
|
+
try { return target.matches(":focus-visible") } catch { return true }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
pause(reason, on) {
|
|
138
|
+
if (this.seconds === null || !this.reasons) return
|
|
139
|
+
this.reasons = withReason(this.reasons, reason, on)
|
|
140
|
+
this.refresh()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A swipe, a wheel, an arrow, a dot or a key: hold still until the reader has
|
|
144
|
+
// left the row alone for idleDelay.
|
|
145
|
+
interacted() {
|
|
146
|
+
if (this.seconds === null || !this.reasons) return
|
|
147
|
+
clearTimeout(this.idle)
|
|
148
|
+
this.idle = setTimeout(() => { this.idle = null; this.pause("interaction", false) }, idleDelay(this.seconds))
|
|
149
|
+
this.pause("interaction", true)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The pause button.
|
|
153
|
+
toggle() {
|
|
154
|
+
if (this.seconds === null || !this.reasons) return
|
|
155
|
+
const stopped = !this.reasons.has("stopped")
|
|
156
|
+
if (!stopped) {
|
|
157
|
+
// Play at the end of a row that does not loop starts it again from the first.
|
|
158
|
+
this.sync()
|
|
159
|
+
if (!this.loop && autoplayStop(this.current, this.positions.length, false) === null && this.current > 0) {
|
|
160
|
+
this.scrollToStop(0, { announce: false })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
this.pause("stopped", stopped)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
refresh() {
|
|
167
|
+
const running = autoplayRunning(this.seconds, this.reasons)
|
|
168
|
+
const stopped = this.reasons.has("stopped")
|
|
169
|
+
this.element.toggleAttribute("data-carousel-playing", running)
|
|
170
|
+
if (this.hasToggleTarget) {
|
|
171
|
+
this.toggleTarget.setAttribute("aria-label", toggleLabel(stopped))
|
|
172
|
+
this.toggleTarget.toggleAttribute("data-carousel-stopped", stopped)
|
|
173
|
+
}
|
|
174
|
+
if (!running) {
|
|
175
|
+
clearTimeout(this.timer)
|
|
176
|
+
this.timer = null
|
|
177
|
+
} else if (!this.timer) {
|
|
178
|
+
this.timer = setTimeout(() => this.tick(), this.seconds * 1000)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
tick() {
|
|
183
|
+
this.timer = null
|
|
184
|
+
this.sync()
|
|
185
|
+
const count = this.positions.length
|
|
186
|
+
const to = autoplayStop(this.current, count, this.loop)
|
|
187
|
+
if (to === null && count > 1) {
|
|
188
|
+
// The end of a row that does not loop: autoplay is done until Play.
|
|
189
|
+
this.pause("stopped", true)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (to !== null) this.scrollToStop(to, { announce: false })
|
|
193
|
+
this.refresh()
|
|
68
194
|
}
|
|
69
195
|
|
|
70
196
|
schedule() {
|
|
@@ -104,8 +230,10 @@ export default class extends Controller {
|
|
|
104
230
|
this.element.toggleAttribute("data-carousel-static", positions.length < 2)
|
|
105
231
|
track.toggleAttribute("data-fade-start", ends.start)
|
|
106
232
|
track.toggleAttribute("data-fade-end", ends.end)
|
|
107
|
-
|
|
108
|
-
|
|
233
|
+
// A looping row never disables an arrow: each end leads round to the other.
|
|
234
|
+
const wraps = this.loop && positions.length > 1
|
|
235
|
+
if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!wraps && !ends.start))
|
|
236
|
+
if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!wraps && !ends.end))
|
|
109
237
|
if (this.element.classList.contains("carousel-spotlight")) {
|
|
110
238
|
this.slideTargets.forEach((slide, index) => slide.toggleAttribute("data-current", index === this.current))
|
|
111
239
|
}
|
|
@@ -121,6 +249,7 @@ export default class extends Controller {
|
|
|
121
249
|
next() { this.move("next") }
|
|
122
250
|
|
|
123
251
|
goTo(event) {
|
|
252
|
+
this.interacted()
|
|
124
253
|
this.sync()
|
|
125
254
|
this.scrollToStop(Number(event.currentTarget.dataset.carouselIndex))
|
|
126
255
|
}
|
|
@@ -139,20 +268,22 @@ export default class extends Controller {
|
|
|
139
268
|
// A press that goes nowhere (an arrow at its end, Home at the start) scrolls
|
|
140
269
|
// nothing and says nothing: the status line speaks only when the row moves.
|
|
141
270
|
move(action) {
|
|
271
|
+
this.interacted()
|
|
142
272
|
this.sync()
|
|
143
|
-
const to = targetStop(action, this.current, this.page, this.positions.length)
|
|
273
|
+
const to = targetStop(action, this.current, this.page, this.positions.length, this.loop)
|
|
144
274
|
if (to === this.current) return
|
|
145
275
|
this.scrollToStop(to)
|
|
146
276
|
}
|
|
147
277
|
|
|
148
|
-
|
|
278
|
+
// announce: false for autoplay, which moves the row without a word.
|
|
279
|
+
scrollToStop(index, { announce = true } = {}) {
|
|
149
280
|
const left = this.positions[index]
|
|
150
281
|
if (left === undefined) return
|
|
151
282
|
|
|
152
|
-
const reduce = window.matchMedia?.(
|
|
283
|
+
const reduce = window.matchMedia?.(REDUCE).matches
|
|
153
284
|
this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
|
|
154
285
|
|
|
155
|
-
if (!this.hasStatusTarget) return
|
|
286
|
+
if (!announce || !this.hasStatusTarget) return
|
|
156
287
|
const total = this.slideTargets.length
|
|
157
288
|
const last = index === this.positions.length - 1
|
|
158
289
|
const first = last ? Math.max(0, total - this.page) : index
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
// row's tabs are links, so they work without it. If it fails to load, the sentinel
|
|
10
10
|
// carries data-studio-controller-failed and engine.css gives the row its bar at
|
|
11
11
|
// all times, so a stuck row never lays a transparent title over the page.
|
|
12
|
+
//
|
|
13
|
+
// It also keeps each quick-links menu's button honest: aria-expanded follows the
|
|
14
|
+
// popover, ArrowDown from the button walks into the list, and where the browser has
|
|
15
|
+
// no anchor positioning it places the list under the button. The popover itself
|
|
16
|
+
// opens, closes on Escape and gives focus back with no script, so a failed load
|
|
17
|
+
// leaves a menu that still works.
|
|
12
18
|
import { Controller } from "@hotwired/stimulus"
|
|
13
|
-
import { hiddenEnds, isStuck, revealBy, sentinelMarksThePlace } from "studio/page_header"
|
|
19
|
+
import { hiddenEnds, isStuck, menuPlacement, revealBy, sentinelMarksThePlace } from "studio/page_header"
|
|
14
20
|
|
|
15
21
|
export default class extends Controller {
|
|
16
22
|
connect() {
|
|
@@ -22,6 +28,7 @@ export default class extends Controller {
|
|
|
22
28
|
window.addEventListener("resize", this.schedule, { passive: true })
|
|
23
29
|
this.tabs?.addEventListener("scroll", this.schedule, { passive: true })
|
|
24
30
|
this.revealOpenTab()
|
|
31
|
+
this.connectMenus()
|
|
25
32
|
this.sync()
|
|
26
33
|
}
|
|
27
34
|
|
|
@@ -31,6 +38,53 @@ export default class extends Controller {
|
|
|
31
38
|
this.tabs?.removeEventListener("scroll", this.schedule)
|
|
32
39
|
if (this.frame) cancelAnimationFrame(this.frame)
|
|
33
40
|
this.frame = null
|
|
41
|
+
this.disconnectMenus()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
connectMenus() {
|
|
45
|
+
this.menus = [...this.row.querySelectorAll(".page-header-menu")].map((menu) => {
|
|
46
|
+
const button = menu.querySelector("[popovertarget]")
|
|
47
|
+
const list = menu.querySelector("[popover]")
|
|
48
|
+
if (!button || !list) return null
|
|
49
|
+
button.setAttribute("aria-expanded", "false")
|
|
50
|
+
const onToggle = (event) => this.menuToggled(button, list, event.newState === "open")
|
|
51
|
+
const onKey = (event) => {
|
|
52
|
+
if (event.key !== "ArrowDown") return
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
if (!list.matches(":popover-open")) list.showPopover?.()
|
|
55
|
+
list.querySelector("a")?.focus()
|
|
56
|
+
}
|
|
57
|
+
list.addEventListener("toggle", onToggle)
|
|
58
|
+
button.addEventListener("keydown", onKey)
|
|
59
|
+
return { button, list, onToggle, onKey }
|
|
60
|
+
}).filter(Boolean)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
disconnectMenus() {
|
|
64
|
+
for (const { button, list, onToggle, onKey } of this.menus || []) {
|
|
65
|
+
list.removeEventListener("toggle", onToggle)
|
|
66
|
+
button.removeEventListener("keydown", onKey)
|
|
67
|
+
if (list.matches?.(":popover-open")) list.hidePopover?.()
|
|
68
|
+
}
|
|
69
|
+
this.menus = []
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
menuToggled(button, list, open) {
|
|
73
|
+
button.setAttribute("aria-expanded", String(open))
|
|
74
|
+
if (open) {
|
|
75
|
+
if (!anchorsSupported()) this.placeMenu(button, list)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
// The browser gives focus back on Escape; a focus left on nothing comes back too.
|
|
79
|
+
if (document.activeElement === document.body || list.contains(document.activeElement)) button.focus()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
placeMenu(button, list) {
|
|
83
|
+
const place = menuPlacement(button.getBoundingClientRect(), list.getBoundingClientRect(),
|
|
84
|
+
{ width: window.innerWidth, height: window.innerHeight })
|
|
85
|
+
list.style.top = `${place.top}px`
|
|
86
|
+
list.style.left = `${place.left}px`
|
|
87
|
+
list.style.right = "auto"
|
|
34
88
|
}
|
|
35
89
|
|
|
36
90
|
schedule() {
|
|
@@ -46,6 +100,11 @@ export default class extends Controller {
|
|
|
46
100
|
const ends = this.tabs ? hiddenEnds(this.tabs) : null
|
|
47
101
|
|
|
48
102
|
this.row.toggleAttribute("data-stuck", stuck)
|
|
103
|
+
if (!anchorsSupported()) {
|
|
104
|
+
for (const { button, list } of this.menus || []) {
|
|
105
|
+
if (list.matches(":popover-open")) this.placeMenu(button, list)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
49
108
|
if (!ends) return
|
|
50
109
|
this.tabs.toggleAttribute("data-fade-start", ends.start)
|
|
51
110
|
this.tabs.toggleAttribute("data-fade-end", ends.end)
|
|
@@ -72,3 +131,7 @@ export default class extends Controller {
|
|
|
72
131
|
this.tabs.scrollLeft += revealBy(this.tabs.getBoundingClientRect(), open.getBoundingClientRect())
|
|
73
132
|
}
|
|
74
133
|
}
|
|
134
|
+
|
|
135
|
+
function anchorsSupported() {
|
|
136
|
+
return typeof CSS !== "undefined" && CSS.supports?.("anchor-name: --a") && CSS.supports("position-anchor: --a")
|
|
137
|
+
}
|
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
// <a href="#theme" data-style-section-nav-target="link">Theme</a>
|
|
7
7
|
//
|
|
8
8
|
// Registered by studio/style_guide, which only the style guide loads.
|
|
9
|
+
//
|
|
10
|
+
// A tab's click is studio/in_page_links': a smooth scroll down the page. The clicked tab
|
|
11
|
+
// holds the highlight until that scroll settles, so the row does not run through every tab
|
|
12
|
+
// the scroll passes on its way.
|
|
9
13
|
import { Controller } from "@hotwired/stimulus"
|
|
10
14
|
import { activeIndex } from "studio/style_section_nav"
|
|
15
|
+
import { SCROLL_EVENT, SCROLL_END_EVENT } from "studio/in_page_links"
|
|
11
16
|
|
|
12
17
|
const ACTIVE = "btn-primary"
|
|
13
18
|
const REST = "btn-neutral"
|
|
@@ -22,14 +27,21 @@ export default class extends Controller {
|
|
|
22
27
|
|
|
23
28
|
connect() {
|
|
24
29
|
this.schedule = this.schedule.bind(this)
|
|
30
|
+
this.hold = this.hold.bind(this)
|
|
31
|
+
this.release = this.release.bind(this)
|
|
25
32
|
window.addEventListener("scroll", this.schedule, { passive: true })
|
|
26
33
|
window.addEventListener("resize", this.schedule)
|
|
34
|
+
this.element.addEventListener(SCROLL_EVENT, this.hold)
|
|
35
|
+
document.addEventListener(SCROLL_END_EVENT, this.release)
|
|
27
36
|
this.sync()
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
disconnect() {
|
|
31
40
|
window.removeEventListener("scroll", this.schedule)
|
|
32
41
|
window.removeEventListener("resize", this.schedule)
|
|
42
|
+
this.element.removeEventListener(SCROLL_EVENT, this.hold)
|
|
43
|
+
document.removeEventListener(SCROLL_END_EVENT, this.release)
|
|
44
|
+
this.held = null
|
|
33
45
|
if (this.frame) cancelAnimationFrame(this.frame)
|
|
34
46
|
this.frame = null
|
|
35
47
|
}
|
|
@@ -42,7 +54,21 @@ export default class extends Controller {
|
|
|
42
54
|
})
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
hold(event) {
|
|
58
|
+
const link = event.detail && event.detail.link
|
|
59
|
+
if (!this.linkTargets.includes(link)) return
|
|
60
|
+
this.held = link
|
|
61
|
+
this.highlight(link)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
release() {
|
|
65
|
+
if (!this.held) return
|
|
66
|
+
this.held = null
|
|
67
|
+
this.sync()
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
sync() {
|
|
71
|
+
if (this.held) return
|
|
46
72
|
const links = this.linkTargets
|
|
47
73
|
const tops = links.map((link) => {
|
|
48
74
|
const section = document.getElementById(link.getAttribute("href").slice(1))
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// studio/in_page_links: a link to a place on the page the reader is on scrolls there. It
|
|
2
|
+
// does not load the page again (docs/PAGE_HEADER.md, "In-page links").
|
|
3
|
+
//
|
|
4
|
+
// Alex McRitchie, 2026-11-06, on the style guide's section pills: "The system needs to
|
|
5
|
+
// understand when a scroll down is the solution rather than a page change."
|
|
6
|
+
//
|
|
7
|
+
// WHY THE ENGINE HAS TO DO THIS. Turbo 8 does not treat a link to "#section" on the same
|
|
8
|
+
// path as a jump: its Navigator.locationWithActionIsSamePage returns false, so the click is a
|
|
9
|
+
// full Turbo visit. It fetches the page, renders it again, fires turbo:visit (the loader and
|
|
10
|
+
// the page transition hear it) and lands with a cut. That reads as a page load.
|
|
11
|
+
//
|
|
12
|
+
// So a plain click on a link whose address is this page (origin, path and query) with a
|
|
13
|
+
// #fragment whose element exists is taken here: no visit, a smooth scroll to the element
|
|
14
|
+
// under the pinned stack, the fragment pushed onto history, and focus moved to the target.
|
|
15
|
+
// Reduced motion jumps. Every other link is left alone, for Turbo or the browser.
|
|
16
|
+
//
|
|
17
|
+
// THE LISTENER BUBBLES, ON THE DOCUMENT. Turbo follows a link from a bubble listener on the
|
|
18
|
+
// window, which runs after this one and skips a click whose default is prevented. A
|
|
19
|
+
// component's own handler on the link (a tab that switches a panel by its hash, an Alpine
|
|
20
|
+
// @click.prevent) runs before this one, so a click it took is never taken here.
|
|
21
|
+
//
|
|
22
|
+
// BACK. Turbo turns history's scroll restoration to "manual", and on a popstate with Turbo's
|
|
23
|
+
// state it starts a restore visit: a second page load. So an entry this module pushed carries
|
|
24
|
+
// Turbo's own state, with the same restoration id, and a popstate between two entries of the
|
|
25
|
+
// page on screen is taken here first (capture, at the window) and scrolls to the place the
|
|
26
|
+
// reader left. A popstate to the page from another page is Turbo's, as before.
|
|
27
|
+
//
|
|
28
|
+
// It imports nothing; test/javascript/in_page_links.test.mjs loads it as a data: module.
|
|
29
|
+
|
|
30
|
+
// The event a link's own controller hears: the scroll began, and where it is going.
|
|
31
|
+
// detail: { link, target, hash }. It bubbles from the link.
|
|
32
|
+
export const SCROLL_EVENT = "studio:in-page-scroll"
|
|
33
|
+
// The scroll this module started has settled, or the reader took over. On the document.
|
|
34
|
+
export const SCROLL_END_EVENT = "studio:in-page-scroll-end"
|
|
35
|
+
// The state key on a history entry this module wrote: { scrollY } where the reader was.
|
|
36
|
+
export const STATE_KEY = "studioInPage"
|
|
37
|
+
// The mark on a target that took tabindex=-1 for focus here; engine.css draws no ring on it.
|
|
38
|
+
export const FOCUS_MARK = "data-in-page-focus"
|
|
39
|
+
// No scroll for this long ends the scroll, where the browser has no scrollend event.
|
|
40
|
+
export const SETTLE_MS = 150
|
|
41
|
+
// A scroll the reader never sees move still ends: the cap for a scroll that never starts.
|
|
42
|
+
export const START_CAP_MS = 400
|
|
43
|
+
// A scroll aims again at a target that moved more than this, at most this many times.
|
|
44
|
+
export const AIM_SLACK_PX = 2
|
|
45
|
+
export const MAX_AIMS = 6
|
|
46
|
+
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
|
|
47
|
+
const FOCUSABLE = "a[href], area[href], button, input, select, textarea, summary, iframe, [contenteditable], [tabindex]"
|
|
48
|
+
|
|
49
|
+
// The fragment of `href` when it points into the page at `here`: same origin, path and query,
|
|
50
|
+
// and a fragment that names something. Decoded, as the browser looks it up. Else null.
|
|
51
|
+
export function samePageFragment(href, here) {
|
|
52
|
+
if (!href || !here) return null
|
|
53
|
+
let url, page
|
|
54
|
+
try {
|
|
55
|
+
page = new URL(here)
|
|
56
|
+
url = new URL(href, page)
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
if (url.origin !== page.origin || url.pathname !== page.pathname || url.search !== page.search) return null
|
|
61
|
+
if (url.hash.length < 2) return null
|
|
62
|
+
const raw = url.hash.slice(1)
|
|
63
|
+
try {
|
|
64
|
+
return decodeURIComponent(raw)
|
|
65
|
+
} catch {
|
|
66
|
+
return raw
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A click the browser would follow in place: the main button, no modifier, not already taken.
|
|
71
|
+
export function plainClick(event) {
|
|
72
|
+
if (!event || event.defaultPrevented) return false
|
|
73
|
+
if (event.button !== undefined && event.button !== 0) return false
|
|
74
|
+
return !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A link that asks for something other than a jump in this window: a new window or frame, a
|
|
78
|
+
// download, Turbo off, a method or stream link, or a named Turbo frame.
|
|
79
|
+
export function linkOptsOut(link) {
|
|
80
|
+
const target = link.getAttribute("target")
|
|
81
|
+
if (target && target.toLowerCase() !== "_self") return true
|
|
82
|
+
if (link.hasAttribute("download")) return true
|
|
83
|
+
if (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-method")) return true
|
|
84
|
+
if (link.hasAttribute("data-turbo-stream")) return true
|
|
85
|
+
const frame = link.getAttribute("data-turbo-frame")
|
|
86
|
+
if (frame && frame !== "_top") return true
|
|
87
|
+
const drive = link.closest ? link.closest("[data-turbo]") : null
|
|
88
|
+
return !!drive && drive.getAttribute("data-turbo") === "false"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The element a fragment names: its id, else an <a name>, as the browser finds it.
|
|
92
|
+
export function findTarget(doc, fragment) {
|
|
93
|
+
if (!fragment) return null
|
|
94
|
+
const byId = doc.getElementById(fragment)
|
|
95
|
+
if (byId) return byId
|
|
96
|
+
const named = doc.getElementsByName ? [...doc.getElementsByName(fragment)] : []
|
|
97
|
+
return named.find((el) => el.tagName === "A") || null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// THE DECISION: the target to scroll to for this click on this link, or null to leave it.
|
|
101
|
+
export function inPageTarget(event, link, doc, here) {
|
|
102
|
+
if (!link || !plainClick(event) || linkOptsOut(link)) return null
|
|
103
|
+
const fragment = samePageFragment(link.getAttribute("href"), here)
|
|
104
|
+
return fragment === null ? null : findTarget(doc, fragment)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The history state for the entry a jump pushes. It keeps Turbo's restoration id, so a
|
|
108
|
+
// restore from another page finds this page's snapshot, and moves its index one on.
|
|
109
|
+
export function pushedState(current) {
|
|
110
|
+
const turbo = current && current.turbo
|
|
111
|
+
if (!turbo) return { [STATE_KEY]: {} }
|
|
112
|
+
return { turbo: { ...turbo, restorationIndex: (turbo.restorationIndex || 0) + 1 }, [STATE_KEY]: {} }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Whether a popstate moves between two entries of the page on screen: the same address,
|
|
116
|
+
// bar the fragment, and, under Turbo, the same restoration id as the page Turbo shows.
|
|
117
|
+
export function samePagePop(state, shownId, url, shownUrl) {
|
|
118
|
+
if (!sameDocumentAddress(url, shownUrl)) return false
|
|
119
|
+
const turbo = state && state.turbo
|
|
120
|
+
if (!turbo) return !!(state && state[STATE_KEY])
|
|
121
|
+
return !!shownId && turbo.restorationIdentifier === shownId
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function sameDocumentAddress(a, b) {
|
|
125
|
+
try {
|
|
126
|
+
const one = new URL(a)
|
|
127
|
+
const two = new URL(b)
|
|
128
|
+
return one.origin === two.origin && one.pathname === two.pathname && one.search === two.search
|
|
129
|
+
} catch {
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// How far above the target the scroll stops. Its own scroll-margin-top first: the engine's
|
|
135
|
+
// section-target and section-subtarget already know the stack. Without one, the bottom of the
|
|
136
|
+
// pinned stack, measured now, so the target never lands under the chrome.
|
|
137
|
+
export function marginFor(declared, stackBottom) {
|
|
138
|
+
if (declared > 0) return null
|
|
139
|
+
return stackBottom > 0 ? Math.round(stackBottom) : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function focusTarget(target) {
|
|
143
|
+
if (!target.matches(FOCUSABLE)) {
|
|
144
|
+
target.setAttribute("tabindex", "-1")
|
|
145
|
+
target.setAttribute(FOCUS_MARK, "")
|
|
146
|
+
target.addEventListener("blur", () => {
|
|
147
|
+
target.removeAttribute("tabindex")
|
|
148
|
+
target.removeAttribute(FOCUS_MARK)
|
|
149
|
+
}, { once: true })
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
target.focus({ preventScroll: true })
|
|
153
|
+
} catch {
|
|
154
|
+
// An element that cannot take focus leaves it where it was.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let installed = false
|
|
159
|
+
|
|
160
|
+
export function installInPageLinks(doc) {
|
|
161
|
+
if (installed || !doc || !doc.defaultView) return false
|
|
162
|
+
installed = true
|
|
163
|
+
const win = doc.defaultView
|
|
164
|
+
let shownUrl = win.location.href
|
|
165
|
+
let settling = null
|
|
166
|
+
|
|
167
|
+
const turboHistory = () => (win.Turbo && win.Turbo.session && win.Turbo.session.history) || null
|
|
168
|
+
const shownId = () => {
|
|
169
|
+
const history = turboHistory()
|
|
170
|
+
if (history && history.restorationIdentifier) return history.restorationIdentifier
|
|
171
|
+
const state = win.history.state
|
|
172
|
+
return (state && state.turbo && state.turbo.restorationIdentifier) || null
|
|
173
|
+
}
|
|
174
|
+
const reduced = () => !!(win.matchMedia && win.matchMedia(REDUCED_MOTION).matches)
|
|
175
|
+
|
|
176
|
+
const scrollTo = (target, behavior) => {
|
|
177
|
+
const declared = parseFloat(win.getComputedStyle(target).scrollMarginTop) || 0
|
|
178
|
+
const stack = win.studioPinnedStack && win.studioPinnedStack.bottom ? win.studioPinnedStack.bottom() : 0
|
|
179
|
+
const margin = marginFor(declared, stack)
|
|
180
|
+
const inline = target.style.scrollMarginTop
|
|
181
|
+
if (margin !== null) target.style.scrollMarginTop = `${margin}px`
|
|
182
|
+
target.scrollIntoView({ behavior, block: "start" })
|
|
183
|
+
if (margin !== null) target.style.scrollMarginTop = inline
|
|
184
|
+
}
|
|
185
|
+
const pageTop = (target) => target.getBoundingClientRect().top + win.scrollY
|
|
186
|
+
|
|
187
|
+
// One jump, from its start to its end. The browser fixes a smooth scroll's end where the
|
|
188
|
+
// target was at the start. Content that grows on the way (a lazy frame the scroll passes,
|
|
189
|
+
// the navbar's collapse) moves the target, so the scroll aims again, up to MAX_AIMS times,
|
|
190
|
+
// until the reader takes over (a wheel, a touch, a key, a press). The end is the browser's
|
|
191
|
+
// scrollend where it has one, a quiet spell where not; then the end event, once.
|
|
192
|
+
const jump = (target, behavior) => {
|
|
193
|
+
if (settling) settling()
|
|
194
|
+
let quiet = null
|
|
195
|
+
let cap = null
|
|
196
|
+
let aims = 0
|
|
197
|
+
let reader = false
|
|
198
|
+
let live = true
|
|
199
|
+
let aimed = pageTop(target)
|
|
200
|
+
const takeOver = () => { reader = true }
|
|
201
|
+
const INPUTS = ["wheel", "touchstart", "keydown", "pointerdown"]
|
|
202
|
+
const reaim = () => {
|
|
203
|
+
if (reader || aims >= MAX_AIMS || Math.abs(pageTop(target) - aimed) <= AIM_SLACK_PX) return false
|
|
204
|
+
aims += 1
|
|
205
|
+
scrollTo(target, behavior)
|
|
206
|
+
aimed = pageTop(target)
|
|
207
|
+
return true
|
|
208
|
+
}
|
|
209
|
+
const wait = (ms) => {
|
|
210
|
+
win.clearTimeout(quiet)
|
|
211
|
+
quiet = win.setTimeout(end, ms)
|
|
212
|
+
}
|
|
213
|
+
const end = () => {
|
|
214
|
+
if (!live) return
|
|
215
|
+
// The target moved after the scroll stopped: aim once more and wait for that end.
|
|
216
|
+
if (reaim()) return wait(START_CAP_MS)
|
|
217
|
+
win.removeEventListener("scroll", onScroll)
|
|
218
|
+
win.removeEventListener("scrollend", end)
|
|
219
|
+
for (const name of INPUTS) win.removeEventListener(name, takeOver, true)
|
|
220
|
+
win.clearTimeout(quiet)
|
|
221
|
+
win.clearTimeout(cap)
|
|
222
|
+
live = false
|
|
223
|
+
if (settling === stop) settling = null
|
|
224
|
+
doc.dispatchEvent(new win.CustomEvent(SCROLL_END_EVENT))
|
|
225
|
+
}
|
|
226
|
+
const onScroll = () => {
|
|
227
|
+
win.clearTimeout(cap)
|
|
228
|
+
reaim()
|
|
229
|
+
wait(SETTLE_MS)
|
|
230
|
+
}
|
|
231
|
+
const stop = () => {
|
|
232
|
+
reader = true
|
|
233
|
+
end()
|
|
234
|
+
}
|
|
235
|
+
settling = stop
|
|
236
|
+
win.addEventListener("scroll", onScroll, { passive: true })
|
|
237
|
+
if ("onscrollend" in win) win.addEventListener("scrollend", end)
|
|
238
|
+
for (const name of INPUTS) win.addEventListener(name, takeOver, { capture: true, passive: true })
|
|
239
|
+
cap = win.setTimeout(end, START_CAP_MS)
|
|
240
|
+
scrollTo(target, behavior)
|
|
241
|
+
aimed = pageTop(target)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
doc.addEventListener("click", (event) => {
|
|
245
|
+
const link = event.target && event.target.closest ? event.target.closest("a[href]") : null
|
|
246
|
+
const target = inPageTarget(event, link, doc, win.location.href)
|
|
247
|
+
if (!target) return
|
|
248
|
+
event.preventDefault()
|
|
249
|
+
|
|
250
|
+
const oldURL = win.location.href
|
|
251
|
+
const href = new URL(link.getAttribute("href"), oldURL).href
|
|
252
|
+
const behavior = reduced() ? "instant" : "smooth"
|
|
253
|
+
if (href !== oldURL) {
|
|
254
|
+
// The entry the reader leaves keeps where they were, for Back.
|
|
255
|
+
win.history.replaceState({ ...(win.history.state || {}), [STATE_KEY]: { scrollY: win.scrollY } }, "", oldURL)
|
|
256
|
+
const state = pushedState(win.history.state)
|
|
257
|
+
win.history.pushState(state, "", href)
|
|
258
|
+
const history = turboHistory()
|
|
259
|
+
if (history && state.turbo) {
|
|
260
|
+
history.currentIndex = state.turbo.restorationIndex
|
|
261
|
+
history.location = new URL(href)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
shownUrl = win.location.href
|
|
265
|
+
|
|
266
|
+
// A scroll still running from an earlier click ends first, so its end cannot release this one.
|
|
267
|
+
if (settling) settling()
|
|
268
|
+
link.dispatchEvent(new win.CustomEvent(SCROLL_EVENT, { bubbles: true, detail: { link, target, hash: new URL(href).hash } }))
|
|
269
|
+
jump(target, behavior)
|
|
270
|
+
focusTarget(target)
|
|
271
|
+
if (href !== oldURL) win.dispatchEvent(new win.HashChangeEvent("hashchange", { oldURL, newURL: href }))
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// Capture at the window runs ahead of Turbo's own popstate listener there.
|
|
275
|
+
win.addEventListener("popstate", (event) => {
|
|
276
|
+
const url = win.location.href
|
|
277
|
+
if (!samePagePop(event.state, shownId(), url, shownUrl)) {
|
|
278
|
+
shownUrl = url
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
shownUrl = url
|
|
282
|
+
// Turbo would start a restore visit; without Turbo the browser restores the scroll itself.
|
|
283
|
+
if (event.state && event.state.turbo) {
|
|
284
|
+
event.stopImmediatePropagation()
|
|
285
|
+
const history = turboHistory()
|
|
286
|
+
if (history) {
|
|
287
|
+
history.location = new URL(url)
|
|
288
|
+
history.currentIndex = event.state.turbo.restorationIndex
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (win.history.scrollRestoration !== "manual") return
|
|
292
|
+
const saved = event.state && event.state[STATE_KEY]
|
|
293
|
+
if (saved && typeof saved.scrollY === "number") {
|
|
294
|
+
win.scrollTo({ top: saved.scrollY, behavior: "instant" })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const fragment = samePageFragment(url, url)
|
|
298
|
+
const target = findTarget(doc, fragment)
|
|
299
|
+
if (target) scrollTo(target, "instant")
|
|
300
|
+
else if (!fragment) win.scrollTo({ top: 0, behavior: "instant" })
|
|
301
|
+
}, { capture: true })
|
|
302
|
+
|
|
303
|
+
// A Turbo visit shows another page, or this one anew.
|
|
304
|
+
doc.addEventListener("turbo:load", () => { shownUrl = win.location.href })
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (typeof document !== "undefined") installInPageLinks(document)
|