studio-engine 0.133.2 → 0.134.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 +8 -0
- data/README.md +14 -6
- data/app/assets/tailwind/studio_engine/engine.css +9 -0
- data/app/javascript/studio/carousel.js +76 -0
- data/app/javascript/studio/controllers/carousel_controller.js +162 -17
- data/app/views/studio/_carousel.html.erb +21 -9
- data/app/views/studio/site_footer/_assets.html.erb +26 -3
- data/docs/SITE_FOOTER.md +2 -1
- data/lib/studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28427df3b5517371576bbd0cc5c159220393748f14c7a31ed39214a223fd0b86
|
|
4
|
+
data.tar.gz: 2268ea35dc8219f05a5dc2f1e28c639478538782ba664d69125f7e63556ddfac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c48c6796c363df39d913088657f89ac9ee7ffec99e3b3f685095006890c405ec3de5f8aaee936a50d3aa1a4c4250a445de484c0debff38166917bd6a3f08f34
|
|
7
|
+
data.tar.gz: 3fe116b2ea6ebfd22e920cacb672ead1e4f999e7c0d5f15b31ad208653e104627d6305c65d2cc24b021041bff9f3e31d9d3569841d8fc51f88daae1b1d6e18a7
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.134.0 — 2026-11-07
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **A looping carousel now wraps visually.** `studio/_carousel` with `loop: true` and more than one slide copies its last slides before the first and its first slides after the last, so a spotlight opening on its first slide shows the last one beside it (the hub's `/websites` row). Next at the last slide, Previous at the first and autoplay glide across the seam and then jump silently onto the real slide; reduced motion goes straight there. The copies are `aria-hidden` and `inert` and drop ids, names, `data-test` hooks and Stimulus wiring, so a screen reader and Tab meet each slide once and the dots count real slides. Every slide of a looping standard row is now a stop (six slides three to a view show six dots, not four). Rows without `loop:` are unchanged. Nothing to do in a host; a host spec that counts `.carousel-slide` in a looping row should count `[data-carousel-target='slide']`.
|
|
12
|
+
- **A looping spotlight opens on its first slide.** With `style: :spotlight`, `loop: true`, more than one slide and no `start:`, the row now opens on slide 0, the host's first item, with the last slide showing on its left. It used to open on `:middle`. A spotlight that does not loop still opens on `:middle`, and an explicit `start:` still wins. A host that wants the old opening passes `start: :middle`.
|
|
13
|
+
- **Footer social icons are lit like the buttons.** A linked `.ftr-social` disc in the site footer now wears the glass lit button's family in every app, glass surface or not: a top highlight, a bottom edge, a contact shadow and a soft glow in its own fill, with the dark-mode values in dark mode. Hover still lifts and now deepens (never brightens, so the white glyph keeps its contrast). A pending (dashed) icon stays flat. No consumer change: it arrives with the engine bump.
|
|
14
|
+
|
|
7
15
|
## 0.133.2 — 2026-11-06
|
|
8
16
|
|
|
9
17
|
### Changed
|
data/README.md
CHANGED
|
@@ -343,15 +343,23 @@ keys move one slide. Where the browser has scroll-driven animations the look fol
|
|
|
343
343
|
scroll; elsewhere the controller marks the centred slide (`data-current`) and CSS
|
|
344
344
|
transitions it. Reduced motion only dims.
|
|
345
345
|
|
|
346
|
-
`start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight
|
|
347
|
-
on its middle slide by default, so neither half of the row is empty at
|
|
348
|
-
|
|
346
|
+
`start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight that
|
|
347
|
+
does not loop opens on its middle slide by default, so neither half of the row is empty at
|
|
348
|
+
load; a standard row and a looping spotlight open on their first (a looping row's wrap
|
|
349
|
+
already shows the last slide left of the first). An explicit `start:` wins. The browser sets it before the first paint where it has
|
|
349
350
|
`scroll-initial-target`; elsewhere the controller jumps there when it connects, with no
|
|
350
351
|
glide and nothing announced. A `#fragment` link to an element inside the row wins.
|
|
351
352
|
|
|
352
|
-
`loop: true` wraps the row
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
`loop: true` wraps the row, and a row of more than one slide is visually continuous: the
|
|
354
|
+
controller copies the last slides before the first and the first slides after the last, so
|
|
355
|
+
with the first slide centred the last one shows beside it. Next at the last slide glides on
|
|
356
|
+
to the first and Previous at the first back to the last; once the row rests, it jumps with no
|
|
357
|
+
glide onto the real slide, which looks the same. Under reduced motion it goes straight there.
|
|
358
|
+
Neither arrow is ever disabled, and every slide of a looping row is a stop. A copy is
|
|
359
|
+
`aria-hidden` and `inert`, and drops its ids, names, test hooks (`data-test`, `data-testid`)
|
|
360
|
+
and Stimulus wiring, so a screen reader and Tab meet each slide once, a `#fragment` finds the
|
|
361
|
+
real slide, and the dots and the status line count the real slides. A slide that a controller
|
|
362
|
+
fills after load shows its server-rendered copy beside the seam.
|
|
355
363
|
|
|
356
364
|
`autoplay:` is the seconds between moves (default `nil`, off). The row moves one slide each
|
|
357
365
|
interval, in both styles. It holds still while the pointer is over the slides, while keyboard
|
|
@@ -1064,6 +1064,15 @@ body.modal-open .studio-app-banner {
|
|
|
1064
1064
|
opacity: 0;
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
|
+
/* A looping row's jump across the seam (data-carousel-wrapping, one frame): the
|
|
1068
|
+
mark moves from a clone to the real slide it copies, in the same place, so
|
|
1069
|
+
nothing may transition. A clone is a carousel-slide, so every other rule here
|
|
1070
|
+
(the scroll-driven look, the mark) styles it as it styles a real slide. */
|
|
1071
|
+
&[data-carousel-wrapping] .carousel-slide .carousel-spotlight-body,
|
|
1072
|
+
&[data-carousel-wrapping] .carousel-slide .carousel-caption {
|
|
1073
|
+
transition: none;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1067
1076
|
/* A neighbour holding focus drops the neighbour look on every path, so its focus
|
|
1068
1077
|
ring shows at full strength (WCAG 2.4.7). It outranks the animation and the mark. */
|
|
1069
1078
|
& .carousel-slide:focus-within .carousel-spotlight-body,
|
|
@@ -51,6 +51,8 @@ export function perPage(clientWidth, slideWidth, gap) {
|
|
|
51
51
|
// A LOOPING row (loop: true) wraps instead: Next at the last stop goes to the
|
|
52
52
|
// first, Previous at the first goes to the last. A page that would overshoot an
|
|
53
53
|
// end still lands on that end first, so no slide is skipped on the way round.
|
|
54
|
+
// The controller uses this wrap only for a looping row it has not cloned; a cloned
|
|
55
|
+
// row (THE LOOP'S CLONES, below) moves by loopStop instead, round the seam.
|
|
54
56
|
export function targetStop(action, current, page, count, loop = false) {
|
|
55
57
|
const last = Math.max(0, count - 1)
|
|
56
58
|
if (loop && last > 0) {
|
|
@@ -170,3 +172,77 @@ export function startStop(start, count) {
|
|
|
170
172
|
const index = Math.floor(Number(start)) || 0
|
|
171
173
|
return Math.min(count - 1, Math.max(0, index))
|
|
172
174
|
}
|
|
175
|
+
|
|
176
|
+
// THE LOOP'S CLONES. A looping row (loop: true, more than one slide) is visually
|
|
177
|
+
// continuous: the controller copies the last slides before the first and the first
|
|
178
|
+
// slides after the last, so the space either side of an end shows the slides round
|
|
179
|
+
// the seam. A clone is aria-hidden and inert, with no id and no test hook, so a
|
|
180
|
+
// screen reader and Tab meet each slide once; the dots and the status line count the
|
|
181
|
+
// real slides only.
|
|
182
|
+
//
|
|
183
|
+
// Each place in that longer row has a VIRTUAL index: the real slides are 0 to
|
|
184
|
+
// total - 1, the clones before them -count to -1, the clones after total to
|
|
185
|
+
// total + count - 1. A move across the seam glides to a clone, and once the row
|
|
186
|
+
// rests there the controller jumps, with no glide, to the real slide it copies.
|
|
187
|
+
|
|
188
|
+
// The index of a real slide from a virtual one.
|
|
189
|
+
export function wrapIndex(index, total) {
|
|
190
|
+
if (!(total > 0)) return 0
|
|
191
|
+
return ((index % total) + total) % total
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// How many clones each side: a wide row's slides plus one, enough to fill the view
|
|
195
|
+
// beside either end in both styles. None for a row of one slide.
|
|
196
|
+
export function cloneCount(perView, total) {
|
|
197
|
+
if (!(total > 1)) return 0
|
|
198
|
+
const view = Number(perView)
|
|
199
|
+
return Math.ceil(Number.isFinite(view) && view > 0 ? Math.min(view, 6) : 3) + 1
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The real slide each clone copies, in row order: `before` sits ahead of the first
|
|
203
|
+
// slide, `after` behind the last. A short row repeats round itself.
|
|
204
|
+
export function cloneLayout(total, count) {
|
|
205
|
+
if (!(total > 1) || !(count > 0)) return { before: [], after: [] }
|
|
206
|
+
const before = []
|
|
207
|
+
const after = []
|
|
208
|
+
for (let k = count; k >= 1; k -= 1) before.push(wrapIndex(-k, total))
|
|
209
|
+
for (let k = 0; k < count; k += 1) after.push(wrapIndex(k, total))
|
|
210
|
+
return { before, after }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The virtual index an arrow, a key or autoplay takes a cloned row to: a page on
|
|
214
|
+
// either way, round the seam with no end. Home and End go to the first and last
|
|
215
|
+
// real slide. Held to the clones, so a press mid-glide never runs off the row.
|
|
216
|
+
export function loopStop(action, current, page, total, count) {
|
|
217
|
+
const to = { prev: current - page, next: current + page, first: 0, last: total - 1 }[action]
|
|
218
|
+
if (to === undefined) return current
|
|
219
|
+
return Math.min(total - 1 + count, Math.max(-count, to))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The virtual index nearest `current` that shows real slide `index`: a dot takes
|
|
223
|
+
// the short way round the seam.
|
|
224
|
+
export function nearestVirtual(index, current, total, count) {
|
|
225
|
+
const options = [index - total, index, index + total].filter((v) => v >= -count && v <= total - 1 + count)
|
|
226
|
+
return options.reduce((best, v) => (Math.abs(v - current) < Math.abs(best - current) ? v : best), index)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The real slide a row resting on a clone jumps to, or null when it rests on a real one.
|
|
230
|
+
export function seamJump(virtual, total) {
|
|
231
|
+
if (!(total > 0)) return null
|
|
232
|
+
return virtual < 0 || virtual >= total ? wrapIndex(virtual, total) : null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The attributes a clone drops: ids and names (a clone must not answer a #fragment or
|
|
236
|
+
// join a form), test hooks, and Stimulus wiring (a clone runs no controller and is
|
|
237
|
+
// no one's target), and the carousel's own marks.
|
|
238
|
+
export const CLONE_STRIP = /^(id|name|data-(test|testid|test-id|qa|cy)|data-(studio-)?(controller|action)|data-[\w-]+-target|data-carousel-start-slide|data-current)$/
|
|
239
|
+
|
|
240
|
+
// The status line of a looping row, whose view may run round the seam:
|
|
241
|
+
// "Slides 5 to 6 and 1 of 6".
|
|
242
|
+
export function loopPositionText(first, shown, total) {
|
|
243
|
+
const count = Math.min(shown, total)
|
|
244
|
+
if (first + count <= total) return positionText(first, count, total)
|
|
245
|
+
const over = first + count - total
|
|
246
|
+
const head = first + 1 === total ? `Slides ${total}` : `Slides ${first + 1} to ${total}`
|
|
247
|
+
return `${head} and ${over === 1 ? "1" : `1 to ${over}`} of ${total}`
|
|
248
|
+
}
|
|
@@ -26,10 +26,21 @@
|
|
|
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
28
|
//
|
|
29
|
-
// A LOOPING row (loop:, data-carousel-loop)
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// the
|
|
29
|
+
// A LOOPING row (loop:, data-carousel-loop) of more than one slide is visually
|
|
30
|
+
// continuous. connect() copies the last slides before the first and the first slides
|
|
31
|
+
// after the last (cloneLayout), so the space beside either end shows the slides
|
|
32
|
+
// round the seam. Next at the last slide glides on to the clone of the first;
|
|
33
|
+
// once the row rests there (scrollend, or a short quiet spell where the browser has
|
|
34
|
+
// no scrollend) it jumps, with no glide and no transition, to the real first slide,
|
|
35
|
+
// whose neighbours look the same. Previous at the first does the same backwards, and
|
|
36
|
+
// a swipe that rests on a clone too. Under reduced motion the row goes straight to
|
|
37
|
+
// the real slide. Neither arrow is ever aria-disabled.
|
|
38
|
+
//
|
|
39
|
+
// A clone is aria-hidden and inert, keeps no id, name, test hook or Stimulus wiring
|
|
40
|
+
// (CLONE_STRIP), and is no slide target, so a screen reader and Tab meet each real
|
|
41
|
+
// slide once, a #fragment finds the real slide, and the dots and the status line
|
|
42
|
+
// count real slides. A click on a clone moves the row to it. disconnect() removes
|
|
43
|
+
// the clones, and connect() clears any a page snapshot kept.
|
|
33
44
|
//
|
|
34
45
|
// AUTOPLAY (autoplay: seconds, data-carousel-autoplay) moves the row one stop each
|
|
35
46
|
// interval. It holds still while the pointer is over the slides, while keyboard focus
|
|
@@ -40,8 +51,9 @@
|
|
|
40
51
|
// (APG: a rotating carousel does not announce). Without loop it stops at the end.
|
|
41
52
|
import { Controller } from "@hotwired/stimulus"
|
|
42
53
|
import {
|
|
43
|
-
TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, centerStops,
|
|
44
|
-
|
|
54
|
+
CLONE_STRIP, TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, centerStops, cloneCount, cloneLayout,
|
|
55
|
+
currentStop, hiddenEnds, idleDelay, initialReasons, keyAction, loopPositionText, loopStop, nearestVirtual, ownsKey,
|
|
56
|
+
perPage, positionText, seamJump, startStop, stops, targetStop, toggleLabel, withReason, wrapIndex
|
|
45
57
|
} from "studio/carousel"
|
|
46
58
|
|
|
47
59
|
const REDUCE = "(prefers-reduced-motion: reduce)"
|
|
@@ -51,14 +63,22 @@ export default class extends Controller {
|
|
|
51
63
|
|
|
52
64
|
connect() {
|
|
53
65
|
this.schedule = this.schedule.bind(this)
|
|
66
|
+
this.settle = this.settle.bind(this)
|
|
67
|
+
this.clickClone = this.clickClone.bind(this)
|
|
54
68
|
this.loop = this.element.hasAttribute("data-carousel-loop")
|
|
69
|
+
this.cloned = 0
|
|
70
|
+
this.removeClones()
|
|
71
|
+
this.scrollEnd = "onscrollend" in window
|
|
55
72
|
this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
|
|
73
|
+
if (this.scrollEnd) this.trackTarget.addEventListener("scrollend", this.settle, { passive: true })
|
|
74
|
+
this.trackTarget.addEventListener("click", this.clickClone)
|
|
56
75
|
if (typeof ResizeObserver === "function") {
|
|
57
76
|
this.observer = new ResizeObserver(this.schedule)
|
|
58
77
|
this.observer.observe(this.trackTarget)
|
|
59
78
|
}
|
|
60
79
|
this.element.setAttribute("data-carousel-ready", "")
|
|
61
80
|
this.openAtStart()
|
|
81
|
+
this.addClones()
|
|
62
82
|
this.sync()
|
|
63
83
|
this.startAutoplay()
|
|
64
84
|
}
|
|
@@ -82,10 +102,91 @@ export default class extends Controller {
|
|
|
82
102
|
|
|
83
103
|
disconnect() {
|
|
84
104
|
this.trackTarget.removeEventListener("scroll", this.schedule)
|
|
105
|
+
this.trackTarget.removeEventListener("scrollend", this.settle)
|
|
106
|
+
this.trackTarget.removeEventListener("click", this.clickClone)
|
|
85
107
|
this.observer?.disconnect()
|
|
86
108
|
if (this.frame) cancelAnimationFrame(this.frame)
|
|
87
109
|
this.frame = null
|
|
110
|
+
clearTimeout(this.settleTimer)
|
|
111
|
+
this.settleTimer = null
|
|
88
112
|
this.stopAutoplay()
|
|
113
|
+
this.removeClones()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -- the loop's clones ------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
// Copies the slides round the seam either side, and holds the row where it was:
|
|
119
|
+
// the scroll moves by the width the clones added before the first slide.
|
|
120
|
+
addClones() {
|
|
121
|
+
this.cloned = 0
|
|
122
|
+
if (!this.loop) return
|
|
123
|
+
const slides = this.slideTargets
|
|
124
|
+
const perView = parseFloat(getComputedStyle(this.element).getPropertyValue("--carousel-per-view"))
|
|
125
|
+
const { before, after } = cloneLayout(slides.length, cloneCount(perView, slides.length))
|
|
126
|
+
if (before.length === 0) return
|
|
127
|
+
|
|
128
|
+
const track = this.trackTarget
|
|
129
|
+
const first = slides[0]
|
|
130
|
+
const left = track.scrollLeft
|
|
131
|
+
const was = first.offsetLeft
|
|
132
|
+
first.before(...before.map((index) => this.cloneOf(slides[index])))
|
|
133
|
+
slides[slides.length - 1].after(...after.map((index) => this.cloneOf(slides[index])))
|
|
134
|
+
this.cloned = before.length
|
|
135
|
+
track.scrollTo({ left: left + first.offsetLeft - was, behavior: "instant" })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
cloneOf(slide) {
|
|
139
|
+
const clone = slide.cloneNode(true)
|
|
140
|
+
for (const node of [clone, ...clone.querySelectorAll("*")]) {
|
|
141
|
+
for (const { name } of [...node.attributes]) if (CLONE_STRIP.test(name)) node.removeAttribute(name)
|
|
142
|
+
}
|
|
143
|
+
for (const name of ["role", "aria-roledescription", "aria-label"]) clone.removeAttribute(name)
|
|
144
|
+
clone.setAttribute("aria-hidden", "true")
|
|
145
|
+
clone.setAttribute("inert", "")
|
|
146
|
+
clone.setAttribute("data-carousel-clone", "")
|
|
147
|
+
return clone
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
removeClones() {
|
|
151
|
+
this.trackTarget.querySelectorAll(":scope > [data-carousel-clone]").forEach((clone) => clone.remove())
|
|
152
|
+
this.cloned = 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Every slide in row order, the clones included.
|
|
156
|
+
rowSlides() {
|
|
157
|
+
return this.cloned ? [...this.trackTarget.querySelectorAll(":scope > .carousel-slide")] : this.slideTargets
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The row came to rest. On a clone it jumps to the real slide the clone copies,
|
|
161
|
+
// at the same place in the row, so nothing on screen changes.
|
|
162
|
+
settle() {
|
|
163
|
+
this.settleTimer = null
|
|
164
|
+
if (!this.cloned) return
|
|
165
|
+
this.sync()
|
|
166
|
+
const real = seamJump(this.virtual, this.positions.length)
|
|
167
|
+
if (real === null) return
|
|
168
|
+
const at = this.full[this.virtual + this.cloned]
|
|
169
|
+
if (Math.abs(this.trackTarget.scrollLeft - at) > TOLERANCE) return
|
|
170
|
+
this.element.setAttribute("data-carousel-wrapping", "")
|
|
171
|
+
this.trackTarget.scrollTo({ left: this.positions[real], behavior: "instant" })
|
|
172
|
+
this.sync()
|
|
173
|
+
void this.trackTarget.offsetWidth
|
|
174
|
+
requestAnimationFrame(() => this.element.removeAttribute("data-carousel-wrapping"))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// An inert clone takes no click, so the click lands on the track: a click on a
|
|
178
|
+
// clone's box moves the row to it.
|
|
179
|
+
clickClone(event) {
|
|
180
|
+
if (!this.cloned || event.target !== this.trackTarget) return
|
|
181
|
+
const index = this.rowSlides().findIndex((slide) => {
|
|
182
|
+
if (!slide.hasAttribute("data-carousel-clone")) return false
|
|
183
|
+
const box = slide.getBoundingClientRect()
|
|
184
|
+
return event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom
|
|
185
|
+
})
|
|
186
|
+
if (index < 0) return
|
|
187
|
+
this.interacted()
|
|
188
|
+
this.sync()
|
|
189
|
+
this.scrollToStop(index - this.cloned)
|
|
89
190
|
}
|
|
90
191
|
|
|
91
192
|
// -- autoplay ---------------------------------------------------------------
|
|
@@ -183,7 +284,7 @@ export default class extends Controller {
|
|
|
183
284
|
this.timer = null
|
|
184
285
|
this.sync()
|
|
185
286
|
const count = this.positions.length
|
|
186
|
-
const to = autoplayStop(this.current, count, this.loop)
|
|
287
|
+
const to = this.cloned ? loopStop("next", this.virtual, 1, count, this.cloned) : autoplayStop(this.current, count, this.loop)
|
|
187
288
|
if (to === null && count > 1) {
|
|
188
289
|
// The end of a row that does not loop: autoplay is done until Play.
|
|
189
290
|
this.pause("stopped", true)
|
|
@@ -194,6 +295,10 @@ export default class extends Controller {
|
|
|
194
295
|
}
|
|
195
296
|
|
|
196
297
|
schedule() {
|
|
298
|
+
if (this.cloned && !this.scrollEnd) {
|
|
299
|
+
clearTimeout(this.settleTimer)
|
|
300
|
+
this.settleTimer = setTimeout(this.settle, 150)
|
|
301
|
+
}
|
|
197
302
|
if (this.frame) return
|
|
198
303
|
this.frame = requestAnimationFrame(() => {
|
|
199
304
|
this.frame = null
|
|
@@ -201,9 +306,11 @@ export default class extends Controller {
|
|
|
201
306
|
})
|
|
202
307
|
}
|
|
203
308
|
|
|
309
|
+
// The stops of every slide in the row. A cloned row measures its clones too, and
|
|
310
|
+
// every slide of it is a stop: the row never ends.
|
|
204
311
|
measure() {
|
|
205
312
|
const track = this.trackTarget
|
|
206
|
-
const slides = this.
|
|
313
|
+
const slides = this.rowSlides()
|
|
207
314
|
if (this.element.classList.contains("carousel-spotlight")) {
|
|
208
315
|
const max = Math.max(0, track.scrollWidth - track.clientWidth)
|
|
209
316
|
const boxes = slides.map((slide) => ({ left: slide.offsetLeft, width: slide.offsetWidth }))
|
|
@@ -212,6 +319,12 @@ export default class extends Controller {
|
|
|
212
319
|
const origin = slides[0]?.offsetLeft || 0
|
|
213
320
|
const max = Math.max(0, track.scrollWidth - track.clientWidth)
|
|
214
321
|
const gap = parseFloat(getComputedStyle(track).columnGap) || 0
|
|
322
|
+
if (this.cloned) {
|
|
323
|
+
return {
|
|
324
|
+
positions: slides.map((slide) => Math.min(max, Math.max(0, slide.offsetLeft - origin))),
|
|
325
|
+
page: perPage(track.clientWidth, slides[0]?.offsetWidth, gap)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
215
328
|
return {
|
|
216
329
|
positions: stops(slides.map((slide) => slide.offsetLeft - origin), max),
|
|
217
330
|
page: perPage(track.clientWidth, slides[0]?.offsetWidth, gap)
|
|
@@ -223,22 +336,35 @@ export default class extends Controller {
|
|
|
223
336
|
const { positions, page } = this.measure()
|
|
224
337
|
const ends = hiddenEnds(track)
|
|
225
338
|
|
|
226
|
-
this.positions = positions
|
|
227
339
|
this.page = page
|
|
228
|
-
|
|
340
|
+
if (this.cloned) {
|
|
341
|
+
// positions holds every slide, clones included; this.positions the real ones.
|
|
342
|
+
const total = this.slideTargets.length
|
|
343
|
+
this.full = positions
|
|
344
|
+
this.virtual = currentStop(track.scrollLeft, positions) - this.cloned
|
|
345
|
+
this.current = wrapIndex(this.virtual, total)
|
|
346
|
+
this.positions = positions.slice(this.cloned, this.cloned + total)
|
|
347
|
+
} else {
|
|
348
|
+
this.full = null
|
|
349
|
+
this.positions = positions
|
|
350
|
+
this.current = currentStop(track.scrollLeft, positions)
|
|
351
|
+
this.virtual = this.current
|
|
352
|
+
}
|
|
229
353
|
|
|
230
|
-
this.element.toggleAttribute("data-carousel-static", positions.length < 2)
|
|
354
|
+
this.element.toggleAttribute("data-carousel-static", this.positions.length < 2)
|
|
231
355
|
track.toggleAttribute("data-fade-start", ends.start)
|
|
232
356
|
track.toggleAttribute("data-fade-end", ends.end)
|
|
233
357
|
// A looping row never disables an arrow: each end leads round to the other.
|
|
234
|
-
const wraps = this.loop && positions.length > 1
|
|
358
|
+
const wraps = this.loop && this.positions.length > 1
|
|
235
359
|
if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!wraps && !ends.start))
|
|
236
360
|
if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!wraps && !ends.end))
|
|
237
361
|
if (this.element.classList.contains("carousel-spotlight")) {
|
|
238
|
-
|
|
362
|
+
// A clone is marked as a real slide is, so the fallback scales it the same.
|
|
363
|
+
const at = this.virtual + this.cloned
|
|
364
|
+
this.rowSlides().forEach((slide, index) => slide.toggleAttribute("data-current", index === at))
|
|
239
365
|
}
|
|
240
366
|
this.dotTargets.forEach((dot, index) => {
|
|
241
|
-
dot.hidden = index >= positions.length
|
|
367
|
+
dot.hidden = index >= this.positions.length
|
|
242
368
|
if (index === this.current) dot.setAttribute("aria-current", "true")
|
|
243
369
|
else dot.removeAttribute("aria-current")
|
|
244
370
|
})
|
|
@@ -251,7 +377,8 @@ export default class extends Controller {
|
|
|
251
377
|
goTo(event) {
|
|
252
378
|
this.interacted()
|
|
253
379
|
this.sync()
|
|
254
|
-
|
|
380
|
+
const index = Number(event.currentTarget.dataset.carouselIndex)
|
|
381
|
+
this.scrollToStop(this.cloned ? nearestVirtual(index, this.virtual, this.positions.length, this.cloned) : index)
|
|
255
382
|
}
|
|
256
383
|
|
|
257
384
|
key(event) {
|
|
@@ -270,17 +397,35 @@ export default class extends Controller {
|
|
|
270
397
|
move(action) {
|
|
271
398
|
this.interacted()
|
|
272
399
|
this.sync()
|
|
400
|
+
if (this.cloned) {
|
|
401
|
+
const to = loopStop(action, this.virtual, this.page, this.positions.length, this.cloned)
|
|
402
|
+
if (to === this.virtual) return
|
|
403
|
+
this.scrollToStop(to)
|
|
404
|
+
return
|
|
405
|
+
}
|
|
273
406
|
const to = targetStop(action, this.current, this.page, this.positions.length, this.loop)
|
|
274
407
|
if (to === this.current) return
|
|
275
408
|
this.scrollToStop(to)
|
|
276
409
|
}
|
|
277
410
|
|
|
278
|
-
// announce: false for autoplay, which moves the row without a word.
|
|
411
|
+
// announce: false for autoplay, which moves the row without a word. On a cloned
|
|
412
|
+
// row the index is virtual: a clone's place glides there and settle() jumps home,
|
|
413
|
+
// but under reduced motion the row goes straight to the real slide.
|
|
279
414
|
scrollToStop(index, { announce = true } = {}) {
|
|
415
|
+
const reduce = window.matchMedia?.(REDUCE).matches
|
|
416
|
+
if (this.cloned) {
|
|
417
|
+
const total = this.positions.length
|
|
418
|
+
const real = wrapIndex(index, total)
|
|
419
|
+
const left = reduce ? this.positions[real] : this.full[index + this.cloned]
|
|
420
|
+
if (left === undefined) return
|
|
421
|
+
this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
|
|
422
|
+
if (announce && this.hasStatusTarget) this.statusTarget.textContent = loopPositionText(real, this.page, total)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
280
426
|
const left = this.positions[index]
|
|
281
427
|
if (left === undefined) return
|
|
282
428
|
|
|
283
|
-
const reduce = window.matchMedia?.(REDUCE).matches
|
|
284
429
|
this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
|
|
285
430
|
|
|
286
431
|
if (!announce || !this.hasStatusTarget) return
|
|
@@ -25,15 +25,25 @@
|
|
|
25
25
|
standard row shows a small line under every slide; the
|
|
26
26
|
spotlight shows it under the centred slide only.
|
|
27
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
|
|
29
|
-
so both halves of the row hold a slide at
|
|
30
|
-
|
|
28
|
+
slide at items.size / 2). Defaults to :middle for a spotlight
|
|
29
|
+
that does not loop, so both halves of the row hold a slide at
|
|
30
|
+
load, and to 0 for a standard row and for a looping spotlight
|
|
31
|
+
(its wrap already shows the last slide left of the first, so it
|
|
32
|
+
opens on the host's first item). An explicit start: wins. The browser sets it before the first paint where
|
|
31
33
|
it has scroll-initial-target; elsewhere the controller sets it
|
|
32
34
|
when it connects, with no glide and nothing announced. A link
|
|
33
35
|
to an id inside the row (#fragment) wins over the start.
|
|
34
|
-
loop true wraps the row
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
loop true wraps the row, and a row of more than one slide is
|
|
37
|
+
visually continuous: the controller copies the last slides
|
|
38
|
+
before the first and the first after the last, so the first
|
|
39
|
+
slide centred shows the last beside it. Next at the last slide
|
|
40
|
+
glides on to the first, Previous at the first back to the last,
|
|
41
|
+
then the row jumps silently onto the real slide (no glide under
|
|
42
|
+
reduced motion), and neither arrow is ever disabled. A copy is
|
|
43
|
+
aria-hidden and inert, with no id, name, test hook or controller,
|
|
44
|
+
so a screen reader and Tab meet each slide once, a #fragment
|
|
45
|
+
finds the real slide, and the dots and "slide N of M" count the
|
|
46
|
+
real slides. Every slide of a looping row is a stop.
|
|
37
47
|
autoplay seconds between moves (a number above 0); nil, the default, is
|
|
38
48
|
off. The row moves one slide each interval and holds still while
|
|
39
49
|
the pointer is over the slides, while keyboard focus is in it,
|
|
@@ -75,7 +85,11 @@
|
|
|
75
85
|
when "spotlight" then true
|
|
76
86
|
else raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard or :spotlight"
|
|
77
87
|
end
|
|
78
|
-
|
|
88
|
+
loop = loop ? true : false
|
|
89
|
+
wraps = loop && total > 1
|
|
90
|
+
# A looping spotlight has a left neighbour at its first slide (the clones), so it
|
|
91
|
+
# opens there; one that does not loop opens mid-row so neither half is empty.
|
|
92
|
+
start = spotlight && !wraps ? :middle : 0 if start.nil?
|
|
79
93
|
start_index = case start
|
|
80
94
|
when :middle, "middle" then total / 2
|
|
81
95
|
when Integer then start.clamp(0, [total - 1, 0].max)
|
|
@@ -86,8 +100,6 @@
|
|
|
86
100
|
raise ArgumentError, "studio/carousel autoplay: #{autoplay.inspect} is not a number of seconds above 0 or nil"
|
|
87
101
|
end
|
|
88
102
|
autoplay = autoplay.to_i if autoplay && autoplay == autoplay.floor
|
|
89
|
-
loop = loop ? true : false
|
|
90
|
-
wraps = loop && total > 1
|
|
91
103
|
arrow_noun = spotlight ? "slide" : "slides"
|
|
92
104
|
track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
|
|
93
105
|
%>
|
|
@@ -81,9 +81,32 @@
|
|
|
81
81
|
.ftr-kit { margin: .75rem 0 0; }
|
|
82
82
|
.ftr-socials { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin: 2rem 0 0; padding: 0; list-style: none; }
|
|
83
83
|
.ftr-social { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem;
|
|
84
|
-
border-radius: 9999px; background: var(--ftr-primary); color: #fff;
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
border-radius: 9999px; background: var(--ftr-primary); color: #fff;
|
|
85
|
+
transition: transform .15s ease, filter .15s ease, box-shadow .15s ease; }
|
|
86
|
+
/* LIT, LIKE THE BUTTONS, IN EVERY APP. A linked disc wears the glass lit button's
|
|
87
|
+
family (engine.css, html[data-surface-style="glass"] .btn-primary): a top
|
|
88
|
+
highlight, a bottom edge, a contact shadow and a soft glow in its own fill.
|
|
89
|
+
Each light reads the button's --lit-* token, which only a glass page defines,
|
|
90
|
+
and falls back to the button's own value, so a flat-surface app shades the
|
|
91
|
+
same way. The gradient runs from the fill DOWN only (never lighter than
|
|
92
|
+
--ftr-primary), so the white glyph never loses contrast; a browser without
|
|
93
|
+
relative oklch keeps the flat fill and the shadows. The glow is drawn from
|
|
94
|
+
--ftr-primary-rgb, which every browser reads. */
|
|
95
|
+
.ftr { --ftr-lit-top: var(--lit-top, inset 0 1px 0 rgb(255 255 255 / .35));
|
|
96
|
+
--ftr-lit-bottom: var(--lit-bottom, inset 0 -2px 0 rgb(0 0 0 / .2));
|
|
97
|
+
--ftr-lit-contact: var(--lit-contact, 0 1px 2px rgb(0 0 0 / .4)); --ftr-lit-glow-alpha: var(--lit-glow-alpha, .6); }
|
|
98
|
+
.dark .ftr { --ftr-lit-bottom: var(--lit-bottom, inset 0 -2px 0 rgb(0 0 0 / .3));
|
|
99
|
+
--ftr-lit-contact: var(--lit-contact, 0 1px 2px rgb(0 0 0 / .65)); --ftr-lit-glow-alpha: var(--lit-glow-alpha, .8); }
|
|
100
|
+
a.ftr-social { background-image: linear-gradient(var(--ftr-primary), oklch(from var(--ftr-primary) calc(l - .08) c h));
|
|
101
|
+
box-shadow: var(--ftr-lit-top), var(--ftr-lit-bottom), var(--ftr-lit-contact),
|
|
102
|
+
0 4px 12px -4px rgb(var(--ftr-primary-rgb) / var(--ftr-lit-glow-alpha)); }
|
|
103
|
+
/* Hover lifts and deepens, as the lit button darkens (brightness .94): never
|
|
104
|
+
brighter, which would fade the white glyph. */
|
|
105
|
+
a.ftr-social:hover { transform: translateY(-2px); filter: brightness(.94);
|
|
106
|
+
box-shadow: var(--ftr-lit-top), var(--ftr-lit-bottom), var(--ftr-lit-contact),
|
|
107
|
+
0 8px 16px -6px rgb(var(--ftr-primary-rgb) / var(--ftr-lit-glow-alpha)); }
|
|
108
|
+
/* A pending handle stays a flat dashed outline: no light, no shadow. */
|
|
109
|
+
.ftr-social[data-pending] { background: transparent; color: inherit; opacity: .5; border: 1px dashed currentColor; box-shadow: none; }
|
|
87
110
|
.ftr-social-letter { font-weight: 700; font-size: .95rem; line-height: 1; }
|
|
88
111
|
.ftr-heading { margin: 0 0 1rem; font-size: 1rem; font-weight: 700; color: var(--ftr-ink); }
|
|
89
112
|
.ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
|
data/docs/SITE_FOOTER.md
CHANGED
|
@@ -242,8 +242,9 @@ Rows may be tuples, as above, or hashes (`{ label:, href: }`, `{ heading:, links
|
|
|
242
242
|
from 0.5 to 4; anything else is ignored. Without one, the first column is 1.5
|
|
243
243
|
and the rest are 1 (the brand, from 1024px, is 1.7). Below 768px the two tracks
|
|
244
244
|
are fixed at 1.2 and 1, and with more than four columns the hints are not used.
|
|
245
|
+
- **A linked social icon is lit like the buttons**, in every app, light and dark: a top highlight, an edge and a soft shadow in `--color-primary`.
|
|
245
246
|
- **A social profile with a `nil` URL** renders its icon unlinked, with a dashed
|
|
246
|
-
outline. The engine draws `:linkedin`, `:instagram`, `:x`, `:facebook` and
|
|
247
|
+
outline and no shading. The engine draws `:linkedin`, `:instagram`, `:x`, `:facebook` and
|
|
247
248
|
`:youtube`; any other icon falls back to the label's first letter.
|
|
248
249
|
|
|
249
250
|
### The address and the map
|
data/lib/studio/version.rb
CHANGED