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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +52 -24
  4. data/app/assets/tailwind/studio_engine/engine.css +141 -0
  5. data/app/javascript/studio/application.js +1 -0
  6. data/app/javascript/studio/carousel.js +66 -1
  7. data/app/javascript/studio/controllers/carousel_controller.js +139 -8
  8. data/app/javascript/studio/controllers/page_header_controller.js +64 -1
  9. data/app/javascript/studio/controllers/style_section_nav_controller.js +26 -0
  10. data/app/javascript/studio/in_page_links.js +308 -0
  11. data/app/javascript/studio/page_header.js +17 -0
  12. data/app/views/studio/_carousel.html.erb +33 -5
  13. data/app/views/studio/_page_header.html.erb +64 -12
  14. data/app/views/studio/auth/_sign_in_body.html.erb +236 -0
  15. data/app/views/studio/auth/_sign_in_card.html.erb +50 -0
  16. data/app/views/studio/auth/_sign_in_modal.html.erb +36 -0
  17. data/app/views/studio/modals/auth/_resend_footer.html.erb +6 -0
  18. data/app/views/style/_glass.html.erb +4 -8
  19. data/app/views/style/_loaders.html.erb +11 -35
  20. data/app/views/style/_modals.html.erb +29 -39
  21. data/app/views/style/_scrims.html.erb +10 -20
  22. data/app/views/style/_site_footer.html.erb +10 -18
  23. data/app/views/style/_specimen.html.erb +13 -41
  24. data/app/views/style/_tasks.html.erb +4 -22
  25. data/app/views/style/_trick_label.html.erb +33 -0
  26. data/app/views/style/_tricks.html.erb +127 -207
  27. data/app/views/style/index.html.erb +2 -2
  28. data/app/views/style/modals/_wallet_connect.html.erb +2 -1
  29. data/config/studio_tricks.yml +470 -0
  30. data/lib/studio/page_header.rb +53 -0
  31. data/lib/studio/tricks.rb +110 -0
  32. data/lib/studio/version.rb +1 -1
  33. data/lib/studio.rb +6 -4
  34. metadata +9 -3
  35. data/app/views/style/modals/_ds_email_field.html.erb +0 -58
  36. 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 { TOLERANCE, centerStops, currentStop, hiddenEnds, keyAction, ownsKey, perPage, positionText, startStop, stops, targetStop } from "studio/carousel"
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
- if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!ends.start))
108
- if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!ends.end))
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
- scrollToStop(index) {
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?.("(prefers-reduced-motion: reduce)").matches
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)