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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -1
- data/README.md +65 -28
- data/app/assets/tailwind/studio_engine/engine.css +158 -2
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/carousel.js +74 -1
- data/app/javascript/studio/controllers/carousel_controller.js +163 -9
- 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 +54 -11
- 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
|
@@ -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)
|
|
@@ -29,3 +29,20 @@ export function revealBy(box, tab, margin = 32) {
|
|
|
29
29
|
if (tab.right > box.right) return tab.right - box.right + margin
|
|
30
30
|
return 0
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
// Where the quick-links list goes when the browser has no anchor positioning: under the
|
|
34
|
+
// button, its right edge on the button's, and kept on the screen with a gap of `gap`. It
|
|
35
|
+
// opens above the button when there is no room below and more room above. Fixed
|
|
36
|
+
// coordinates, as the list is a popover in the top layer.
|
|
37
|
+
export function menuPlacement(button, list, viewport, gap = 8) {
|
|
38
|
+
const below = viewport.height - button.bottom
|
|
39
|
+
const above = button.top
|
|
40
|
+
const top = below < list.height + gap && above > below
|
|
41
|
+
? Math.max(gap, button.top - list.height - 4)
|
|
42
|
+
: button.bottom + 4
|
|
43
|
+
const left = Math.min(
|
|
44
|
+
Math.max(gap, button.right - list.width),
|
|
45
|
+
Math.max(gap, viewport.width - list.width - gap)
|
|
46
|
+
)
|
|
47
|
+
return { top, left }
|
|
48
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil) %>
|
|
1
|
+
<%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil, start: nil, autoplay: nil, loop: false) %>
|
|
2
2
|
<%# THE CAROUSEL: one sideways row of slides, the apps' standard for a set too
|
|
3
3
|
long to grid. Render it as a layout, so each slide is your own block:
|
|
4
4
|
|
|
@@ -21,8 +21,27 @@
|
|
|
21
21
|
style :standard (slides snap to the start) or :spotlight (the
|
|
22
22
|
centred slide is full size and colour, its neighbours smaller,
|
|
23
23
|
faded and grey, and the arrows and keys move one slide).
|
|
24
|
-
caption a callable that writes a line under a slide from its item.
|
|
25
|
-
|
|
24
|
+
caption a callable that writes a line under a slide from its item. A
|
|
25
|
+
standard row shows a small line under every slide; the
|
|
26
|
+
spotlight shows it under the centred slide only.
|
|
27
|
+
start the slide the row opens on: an index from 0, or :middle (the
|
|
28
|
+
slide at items.size / 2). Defaults to :middle for the spotlight,
|
|
29
|
+
so both halves of the row hold a slide at load, and to 0 for a
|
|
30
|
+
standard row. The browser sets it before the first paint where
|
|
31
|
+
it has scroll-initial-target; elsewhere the controller sets it
|
|
32
|
+
when it connects, with no glide and nothing announced. A link
|
|
33
|
+
to an id inside the row (#fragment) wins over the start.
|
|
34
|
+
loop true wraps the row: Next at the last slide goes to the first,
|
|
35
|
+
Previous at the first to the last, and neither arrow is ever
|
|
36
|
+
disabled. No slide is cloned, so a screen reader reads each once.
|
|
37
|
+
autoplay seconds between moves (a number above 0); nil, the default, is
|
|
38
|
+
off. The row moves one slide each interval and holds still while
|
|
39
|
+
the pointer is over the slides, while keyboard focus is in it,
|
|
40
|
+
while the tab is hidden or the row is off screen, and after a
|
|
41
|
+
swipe or press until the reader leaves it alone a while. A pause
|
|
42
|
+
button sits beside the arrows (WCAG 2.2.2). Under reduced motion
|
|
43
|
+
the row starts stopped, and Play starts it. Without loop it
|
|
44
|
+
stops at the last slide; Play then starts again from the first.
|
|
26
45
|
|
|
27
46
|
THE SPOTLIGHT (engine.css, carousel-spotlight) snaps each slide to the centre.
|
|
28
47
|
Where the browser has scroll-driven animations, each slide's own view timeline
|
|
@@ -44,7 +63,7 @@
|
|
|
44
63
|
slide a group with aria-roledescription slide and its own label, named arrows
|
|
45
64
|
that set aria-disabled at an end (not disabled, so focus stays on the arrow),
|
|
46
65
|
and a polite, atomic status line that speaks only after a press that moves the
|
|
47
|
-
row. A key pressed in a form field, an arrow-key widget (a slider, a tab list,
|
|
66
|
+
row (a looping row's arrows are never disabled; autoplay never speaks). A key pressed in a form field, an arrow-key widget (a slider, a tab list,
|
|
48
67
|
a listbox...) or a nested carousel stays that element's. %>
|
|
49
68
|
<%
|
|
50
69
|
per_view = per_view.to_f.clamp(1, 6)
|
|
@@ -56,13 +75,28 @@
|
|
|
56
75
|
when "spotlight" then true
|
|
57
76
|
else raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard or :spotlight"
|
|
58
77
|
end
|
|
78
|
+
start = spotlight ? :middle : 0 if start.nil?
|
|
79
|
+
start_index = case start
|
|
80
|
+
when :middle, "middle" then total / 2
|
|
81
|
+
when Integer then start.clamp(0, [total - 1, 0].max)
|
|
82
|
+
else raise ArgumentError, "studio/carousel start: #{start.inspect} is not an index or :middle"
|
|
83
|
+
end
|
|
84
|
+
autoplay = nil if autoplay == false
|
|
85
|
+
unless autoplay.nil? || (autoplay.is_a?(Numeric) && autoplay.positive? && autoplay.finite?)
|
|
86
|
+
raise ArgumentError, "studio/carousel autoplay: #{autoplay.inspect} is not a number of seconds above 0 or nil"
|
|
87
|
+
end
|
|
88
|
+
autoplay = autoplay.to_i if autoplay && autoplay == autoplay.floor
|
|
89
|
+
loop = loop ? true : false
|
|
90
|
+
wraps = loop && total > 1
|
|
91
|
+
arrow_noun = spotlight ? "slide" : "slides"
|
|
59
92
|
track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
|
|
60
93
|
%>
|
|
61
94
|
<%= tag.section(class: ["carousel", ("carousel-spotlight" if spotlight), class_name].compact.join(" "),
|
|
62
95
|
role: "region",
|
|
63
96
|
aria: { roledescription: "carousel", label: label },
|
|
64
97
|
style: "--carousel-per-view: #{per_view}",
|
|
65
|
-
data: { studio_controller: "carousel", studio_action: "keydown->carousel#key"
|
|
98
|
+
data: { studio_controller: "carousel", studio_action: "keydown->carousel#key", carousel_start: (start_index.to_s if start_index.positive?),
|
|
99
|
+
carousel_autoplay: (autoplay.to_s if autoplay), carousel_loop: ("" if loop) }.compact.merge(data)) do %>
|
|
66
100
|
<%# The track takes focus for the keys, so it carries a name: a group, since a
|
|
67
101
|
bare div may not be named. %>
|
|
68
102
|
<div id="<%= track_id %>" class="carousel-track" tabindex="0" role="group"
|
|
@@ -70,25 +104,34 @@
|
|
|
70
104
|
<% items.each_with_index do |item, index| %>
|
|
71
105
|
<div class="carousel-slide" role="group" aria-roledescription="slide"
|
|
72
106
|
aria-label="<%= slide_label ? slide_label.call(item) : "#{index + 1} of #{total}" %>"
|
|
73
|
-
data-carousel-target="slide"
|
|
107
|
+
data-carousel-target="slide"<%= ' data-carousel-start-slide'.html_safe if start_index.positive? && index == start_index %><%= ' data-current'.html_safe if spotlight && index == start_index %>><% if spotlight %><div class="carousel-spotlight-body"><%= yield(item) %></div><% else %><%= yield(item) %><% end %><% if caption %><div class="carousel-caption"><%= caption.call(item) %></div><% end %></div>
|
|
74
108
|
<% end %>
|
|
75
109
|
</div>
|
|
76
110
|
<div class="carousel-controls">
|
|
77
111
|
<div class="carousel-dots" role="group" aria-label="Choose a slide">
|
|
78
112
|
<% total.times do |index| %>
|
|
79
113
|
<button type="button" class="carousel-dot" aria-label="Go to slide <%= index + 1 %>"
|
|
80
|
-
aria-controls="<%= track_id %>"<%= ' aria-current="true"'.html_safe if index
|
|
114
|
+
aria-controls="<%= track_id %>"<%= ' aria-current="true"'.html_safe if index == start_index %>
|
|
81
115
|
data-carousel-target="dot" data-carousel-index="<%= index %>"
|
|
82
116
|
data-studio-action="click->carousel#goTo"></button>
|
|
83
117
|
<% end %>
|
|
84
118
|
</div>
|
|
85
119
|
<div class="carousel-arrows">
|
|
86
|
-
|
|
87
|
-
|
|
120
|
+
<% if autoplay %>
|
|
121
|
+
<%# The pause button (WCAG 2.2.2). The controller renames it "Play slideshow"
|
|
122
|
+
and swaps the icon when the reader stops the row. %>
|
|
123
|
+
<button type="button" class="carousel-button carousel-autoplay" aria-label="Pause slideshow" aria-controls="<%= track_id %>"
|
|
124
|
+
data-carousel-target="toggle" data-studio-action="click->carousel#toggle">
|
|
125
|
+
<svg class="carousel-autoplay-pause" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><rect x="5.5" y="4.5" width="3" height="11" rx="1"/><rect x="11.5" y="4.5" width="3" height="11" rx="1"/></svg>
|
|
126
|
+
<svg class="carousel-autoplay-play" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M7 4.8v10.4a.8.8 0 0 0 1.2.7l8.2-5.2a.8.8 0 0 0 0-1.4L8.2 4.1A.8.8 0 0 0 7 4.8Z"/></svg>
|
|
127
|
+
</button>
|
|
128
|
+
<% end %>
|
|
129
|
+
<button type="button" class="carousel-button" aria-label="Previous <%= arrow_noun %>" aria-controls="<%= track_id %>"
|
|
130
|
+
aria-disabled="<%= !wraps && start_index.zero? %>" data-carousel-target="prev" data-studio-action="click->carousel#prev">
|
|
88
131
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12.5 15 7.5 10l5-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
89
132
|
</button>
|
|
90
|
-
<button type="button" class="carousel-button" aria-label="Next
|
|
91
|
-
data-carousel-target="next" data-studio-action="click->carousel#next">
|
|
133
|
+
<button type="button" class="carousel-button" aria-label="Next <%= arrow_noun %>" aria-controls="<%= track_id %>"
|
|
134
|
+
<%= 'aria-disabled="true" '.html_safe if !wraps && total.positive? && start_index == total - 1 %>data-carousel-target="next" data-studio-action="click->carousel#next">
|
|
92
135
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m7.5 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
93
136
|
</button>
|
|
94
137
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (title:, crumbs: [], crumb_label: nil, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, links: [], links_label: "More links", data: {}, row_class: "-mx-4 px-4") %>
|
|
1
|
+
<%# locals: (title:, crumbs: [], crumb_label: nil, meta: nil, meta_title: nil, label: nil, tabs: [], anchors: [], tabs_data: {}, menu: [], menu_label: "Links", action: nil, tools_html: nil, actions_html: nil, links: [], links_label: "More links", data: {}, row_class: "-mx-4 px-4") %>
|
|
2
2
|
<%# THE PAGE HEADER: the second layer of the pinned stack (docs/PAGE_HEADER.md).
|
|
3
3
|
navbar, then this row, then a section's header (studio/_section_header), then a
|
|
4
4
|
table's sticky header, each below the last.
|
|
@@ -19,12 +19,25 @@
|
|
|
19
19
|
meta short text beside the title, such as the version. Hidden on a
|
|
20
20
|
phone, where the tabs need the width.
|
|
21
21
|
meta_title the meta's title attribute.
|
|
22
|
-
label the
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
label the pill group's nav's accessible name. A row with tabs or
|
|
23
|
+
anchors needs it.
|
|
24
|
+
|
|
25
|
+
THE FOUR KINDS OF CONTROL on the right (docs/PAGE_HEADER.md, "The four kinds of
|
|
26
|
+
control"), always in this order and at the one height of .btn-sm:
|
|
27
|
+
|
|
28
|
+
tabs views of the same thing (Deployments | Triage), [{ label:, href:,
|
|
29
|
+
current:, data: }]. One pill group with the gliding pill. `current`
|
|
30
|
+
is the aria-current value ("page" or "true"), blank on the others.
|
|
31
|
+
anchors sections of this page, [{ label:, href: "#id", current:, data: }].
|
|
32
|
+
The same pill group; a same-page link scrolls, with no visit. A row
|
|
33
|
+
takes tabs or anchors, never both: it has one pill group.
|
|
34
|
+
tabs_data data attributes for the pill group's scroller.
|
|
35
|
+
menu related pages elsewhere, [{ label:, href:, data: }]: one neutral
|
|
36
|
+
button, menu_label ("Links"), that opens a list of them.
|
|
37
|
+
action the page's one primary action, { label:, href:, data: }. Rightmost.
|
|
38
|
+
tools_html local-only and dev controls, after the action, muted.
|
|
39
|
+
actions_html DEPRECATED: free HTML after the pill group. It renders for one
|
|
40
|
+
release and warns once per call site in development.
|
|
28
41
|
links the page's other links, [{ label:, href:, current: }], on a line of
|
|
29
42
|
their own at the foot of the row, so they stick with it.
|
|
30
43
|
links_label that line's accessible name.
|
|
@@ -79,13 +92,16 @@
|
|
|
79
92
|
<p class="page-header-meta label-upper"<%= %( title="#{ERB::Util.h(meta_title)}").html_safe if meta_title.present? %>><%= meta %></p>
|
|
80
93
|
<% end %>
|
|
81
94
|
</div>
|
|
82
|
-
<% if tabs.any? %>
|
|
83
|
-
|
|
95
|
+
<% raise ArgumentError, "studio/_page_header takes tabs: or anchors:, not both (one pill group)" if tabs.any? && anchors.any? %>
|
|
96
|
+
<% raise ArgumentError, "studio/_page_header takes one action: a Hash { label:, href: }" unless action.nil? || action.is_a?(Hash) %>
|
|
97
|
+
<% pills, pill_kind = tabs.any? ? [tabs, "tabs"] : [anchors, "anchors"] %>
|
|
98
|
+
<% if pills.any? %>
|
|
99
|
+
<%= tag.nav(class: "page-header-tabs", aria: { label: label.presence }, data: { page_header_kind: pill_kind }.merge(tabs_data)) do %>
|
|
84
100
|
<%# The glide: one pill, drawn once, that CSS anchors to the tab with aria-current and
|
|
85
101
|
slides from tab to tab (engine.css, "The gliding pill"). Each tab keeps its own
|
|
86
102
|
fill where the browser has no anchor positioning. %>
|
|
87
103
|
<div class="page-header-track">
|
|
88
|
-
<%
|
|
104
|
+
<% pills.each do |tab| %>
|
|
89
105
|
<%= link_to tab.fetch(:href),
|
|
90
106
|
class: "page-header-tab btn btn-sm #{tab[:current].present? ? 'btn-primary' : 'btn-neutral'}",
|
|
91
107
|
aria: { current: tab[:current].presence },
|
|
@@ -95,8 +111,44 @@
|
|
|
95
111
|
</div>
|
|
96
112
|
<% end %>
|
|
97
113
|
<% end %>
|
|
98
|
-
<% if actions_html.present? %>
|
|
99
|
-
|
|
114
|
+
<% if actions_html.present? && defined?(Studio::PageHeader) && Studio::PageHeader.warning_enabled? %>
|
|
115
|
+
<% site = caller_locations.find { |loc| loc.path.to_s.end_with?(".erb") && !loc.path.to_s.end_with?("_page_header.html.erb") } %>
|
|
116
|
+
<% Studio::PageHeader.deprecate_actions_html(site: site && "#{site.path}:#{site.lineno}") %>
|
|
117
|
+
<% end %>
|
|
118
|
+
<% if [actions_html, tools_html].any?(&:present?) || menu.any? || action.present? %>
|
|
119
|
+
<%# One cluster, in the fixed order: the old free HTML where it always stood, then
|
|
120
|
+
the menu, then the action, then the tools. Each control is .btn-sm, so all
|
|
121
|
+
four kinds share one height. %>
|
|
122
|
+
<div class="page-header-actions">
|
|
123
|
+
<%= actions_html if actions_html.present? %>
|
|
124
|
+
<% if menu.any? %>
|
|
125
|
+
<%# A disclosure, not role="menu": a list of links that Tab walks. The browser's
|
|
126
|
+
popover opens it from the button, closes it on Escape or a click outside,
|
|
127
|
+
and gives focus back to the button, with no script. The page-header
|
|
128
|
+
controller adds aria-expanded, and places the list where the browser has
|
|
129
|
+
no anchor positioning. %>
|
|
130
|
+
<% menu_id = "page-header-menu-#{SecureRandom.hex(4)}" %>
|
|
131
|
+
<div class="page-header-menu" data-page-header-kind="menu">
|
|
132
|
+
<button type="button" class="page-header-menu-button btn btn-sm btn-neutral"
|
|
133
|
+
popovertarget="<%= menu_id %>" aria-controls="<%= menu_id %>"><%= menu_label %><span class="page-header-menu-caret" aria-hidden="true"></span></button>
|
|
134
|
+
<div id="<%= menu_id %>" class="page-header-menu-list" popover="auto">
|
|
135
|
+
<ul>
|
|
136
|
+
<% menu.each do |item| %>
|
|
137
|
+
<li><%= link_to item.fetch(:label), item.fetch(:href), class: "page-header-menu-item", data: item.fetch(:data, {}) %></li>
|
|
138
|
+
<% end %>
|
|
139
|
+
</ul>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<% end %>
|
|
143
|
+
<% if action.present? %>
|
|
144
|
+
<%= link_to action.fetch(:label), action.fetch(:href),
|
|
145
|
+
class: "page-header-action btn btn-sm btn-primary",
|
|
146
|
+
data: { page_header_kind: "action" }.merge(action.fetch(:data, {})) %>
|
|
147
|
+
<% end %>
|
|
148
|
+
<% if tools_html.present? %>
|
|
149
|
+
<div class="page-header-tools" data-page-header-kind="tools"><%= tools_html %></div>
|
|
150
|
+
<% end %>
|
|
151
|
+
</div>
|
|
100
152
|
<% end %>
|
|
101
153
|
<% if links.any? %>
|
|
102
154
|
<nav class="page-header-links" aria-label="<%= links_label %>">
|