studio-engine 0.131.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 +96 -1
  3. data/README.md +65 -28
  4. data/app/assets/tailwind/studio_engine/engine.css +158 -2
  5. data/app/javascript/studio/application.js +1 -0
  6. data/app/javascript/studio/carousel.js +74 -1
  7. data/app/javascript/studio/controllers/carousel_controller.js +163 -9
  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 +54 -11
  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
@@ -19,22 +19,65 @@
19
19
  // slide is a stop, an arrow moves one slide, and the slide nearest the centre
20
20
  // carries data-current, which the CSS styles where scroll-driven animations are
21
21
  // missing and under reduced motion.
22
- import { centerStops } from "studio/carousel"
22
+ //
23
+ // THE START (the partial's start:, data-carousel-start): a row that opens past its
24
+ // first slide. The browser lands there before the first paint where it has
25
+ // scroll-initial-target (engine.css); elsewhere connect() jumps there, with no glide
26
+ // and nothing in the status line. A row already moved (the browser's own start, a
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.
23
41
  import { Controller } from "@hotwired/stimulus"
24
- import { currentStop, hiddenEnds, keyAction, ownsKey, perPage, positionText, 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)"
25
48
 
26
49
  export default class extends Controller {
27
- static targets = ["track", "slide", "prev", "next", "dot", "status"]
50
+ static targets = ["track", "slide", "prev", "next", "dot", "status", "toggle"]
28
51
 
29
52
  connect() {
30
53
  this.schedule = this.schedule.bind(this)
54
+ this.loop = this.element.hasAttribute("data-carousel-loop")
31
55
  this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
32
56
  if (typeof ResizeObserver === "function") {
33
57
  this.observer = new ResizeObserver(this.schedule)
34
58
  this.observer.observe(this.trackTarget)
35
59
  }
36
60
  this.element.setAttribute("data-carousel-ready", "")
61
+ this.openAtStart()
37
62
  this.sync()
63
+ this.startAutoplay()
64
+ }
65
+
66
+ openAtStart() {
67
+ const start = Number(this.element.dataset.carouselStart)
68
+ if (!(start > 0)) return
69
+ const track = this.trackTarget
70
+ if (Math.abs(track.scrollLeft) > TOLERANCE) return
71
+ const hash = window.location?.hash
72
+ if (hash && hash.length > 1) {
73
+ let target = null
74
+ try { target = document.getElementById(decodeURIComponent(hash.slice(1))) } catch { target = null }
75
+ if (target && track.contains(target)) return
76
+ }
77
+ const { positions } = this.measure()
78
+ const left = positions[startStop(start, positions.length)]
79
+ if (left === undefined) return
80
+ track.scrollTo({ left, behavior: "instant" })
38
81
  }
39
82
 
40
83
  disconnect() {
@@ -42,6 +85,112 @@ export default class extends Controller {
42
85
  this.observer?.disconnect()
43
86
  if (this.frame) cancelAnimationFrame(this.frame)
44
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()
45
194
  }
46
195
 
47
196
  schedule() {
@@ -81,8 +230,10 @@ export default class extends Controller {
81
230
  this.element.toggleAttribute("data-carousel-static", positions.length < 2)
82
231
  track.toggleAttribute("data-fade-start", ends.start)
83
232
  track.toggleAttribute("data-fade-end", ends.end)
84
- if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!ends.start))
85
- 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))
86
237
  if (this.element.classList.contains("carousel-spotlight")) {
87
238
  this.slideTargets.forEach((slide, index) => slide.toggleAttribute("data-current", index === this.current))
88
239
  }
@@ -98,6 +249,7 @@ export default class extends Controller {
98
249
  next() { this.move("next") }
99
250
 
100
251
  goTo(event) {
252
+ this.interacted()
101
253
  this.sync()
102
254
  this.scrollToStop(Number(event.currentTarget.dataset.carouselIndex))
103
255
  }
@@ -116,20 +268,22 @@ export default class extends Controller {
116
268
  // A press that goes nowhere (an arrow at its end, Home at the start) scrolls
117
269
  // nothing and says nothing: the status line speaks only when the row moves.
118
270
  move(action) {
271
+ this.interacted()
119
272
  this.sync()
120
- 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)
121
274
  if (to === this.current) return
122
275
  this.scrollToStop(to)
123
276
  }
124
277
 
125
- scrollToStop(index) {
278
+ // announce: false for autoplay, which moves the row without a word.
279
+ scrollToStop(index, { announce = true } = {}) {
126
280
  const left = this.positions[index]
127
281
  if (left === undefined) return
128
282
 
129
- const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
283
+ const reduce = window.matchMedia?.(REDUCE).matches
130
284
  this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
131
285
 
132
- if (!this.hasStatusTarget) return
286
+ if (!announce || !this.hasStatusTarget) return
133
287
  const total = this.slideTargets.length
134
288
  const last = index === this.positions.length - 1
135
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))