studio-engine 0.138.0 → 0.139.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 +15 -0
- data/README.md +60 -1
- data/app/assets/tailwind/studio_engine/engine.css +9 -2
- data/app/javascript/studio/carousel.js +43 -0
- data/app/javascript/studio/controllers/carousel_captions_controller.js +42 -0
- data/app/javascript/studio/controllers/carousel_controller.js +50 -10
- data/app/javascript/studio/stimulus.js +3 -1
- data/app/views/studio/_carousel.html.erb +21 -5
- data/app/views/style/_tricks.html.erb +23 -0
- data/lib/studio/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a9937bbeb00f70902af7404d9ded16f10216e7123dba4d929eef0d04fe8c1f80
|
|
4
|
+
data.tar.gz: ff26fdac225b730cc611af898fe0fc786ee968545a5ee674f89131cfe183f035
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3721562277466c4af36db5835ad05ad5fba414d4a091c9a447787f95d5d8bbe8ae053b5ff0a0563422a732169130aedd77e2bf5f721e6026b099e99172b7e6f
|
|
7
|
+
data.tar.gz: 2b2405866b62361954dc69c30d66b5dbcd56536ef40b9e26d5b64094d3ed7a07bfb36cda26483ae872cc370f95a1f7f69a7a79dd4c24c4744c1d2ce1a59a8504
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.139.0 — 2026-11-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`studio/_carousel` dispatches `studio:carousel:change`.** Every style (standard, spotlight, cascade) dispatches it from the region, bubbling, once each time the open slide changes, after `data-current` has moved. `event.detail` is `{ index, previous, slide, total }`. A host that follows the open slide (a caption under the row, a counter) listens for it instead of a `MutationObserver` on `data-current`. The first paint's slide fires nothing; a looping row fires once, for the real slide, when it lands home off the seam copy.
|
|
12
|
+
- **`studio/_carousel` takes `dots:`.** `dots: false` draws no dot group, so nothing for Tab or a screen reader to meet; the arrows, keys, swipe and status line stay. The default, `dots: true`, renders byte for byte what leaving it out does (render digests for standard, spotlight and cascade). A host that hid the dots with CSS passes `dots: false` instead.
|
|
13
|
+
- **The image-only cascade pattern is documented and on `/admin/style`.** Each card is only its picture, the open card wears a ring (`in-data-current:`), and one caption under the stack follows the open card. The new lazy `carousel-captions` controller does the following: wrap the row and its captions in `data-studio-controller="carousel-captions"` and mark each caption `data-carousel-captions-target="caption"`; no page script. The README gives the markup to copy.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **A cascade press released off the track no longer hijacks Enter.** A mouse press on a side card that let go outside the track left its card recorded, so Enter on the front card's link later moved the stack to that card instead of following the link. A click with no press (Enter or Space) now reads only the card it lands in, and the next press replaces a stale one.
|
|
18
|
+
- **A cascade card that crosses a looping stack's seam fades in on every move.** Next then Previous on a five-card loop took the same card round the seam twice, and the second jump did not fade in. The fade now replays each time and leaves no finished animation behind.
|
|
19
|
+
- **A narrow cascade fades its side cards at the track's edge** instead of cutting them hard; a wide stack and a failed controller's scrolling row keep no mask.
|
|
20
|
+
- **The cascade's Tab wording is exact.** The partial, the controller and the README now say that Tab skips the cards past the window, and that a cascade which does not loop and opens mid-row reaches the cards before the window by Shift+Tab, Home, the arrow keys, Previous and the dots.
|
|
21
|
+
|
|
7
22
|
## 0.138.0 — 2026-11-08
|
|
8
23
|
|
|
9
24
|
### Added
|
data/README.md
CHANGED
|
@@ -350,6 +350,10 @@ as a layout; the block draws each slide. `per_view:` is the wide row's count
|
|
|
350
350
|
|
|
351
351
|
`caption:` (a callable on the item) writes a small line under each slide.
|
|
352
352
|
|
|
353
|
+
`dots: false` draws no dots: the row has no dot group in the tab order or the
|
|
354
|
+
accessibility tree, and the arrows, keys, swipe and status line stay. The default,
|
|
355
|
+
`dots: true`, renders exactly what leaving it out does.
|
|
356
|
+
|
|
353
357
|
`style: :spotlight` snaps each slide to the centre: the centred slide is full size and
|
|
354
358
|
colour, with its caption under it (the spotlight shows the centred slide's only), and its
|
|
355
359
|
neighbours are smaller, faded and grey. The arrows ("Previous slide", "Next slide") and
|
|
@@ -412,7 +416,15 @@ status line says "Slide N of M". `start:`, `caption:` (under the open slide only
|
|
|
412
416
|
`autoplay:` and `loop:` behave as on the spotlight; a looping cascade needs no clones,
|
|
413
417
|
since the steps wrap round the set. Under reduced motion the stack lays flat as a spotlight
|
|
414
418
|
row, the neighbours only dimmed, and a move swaps at once. A controller that fails leaves
|
|
415
|
-
a plain scrolling row.
|
|
419
|
+
a plain scrolling row. A region under 36rem wide fades its side cards at the track's edge
|
|
420
|
+
instead of cutting them, and a card that crosses a looping stack round the seam fades in at
|
|
421
|
+
its new place on every move.
|
|
422
|
+
|
|
423
|
+
Tab skips the cards past the window (they are hidden). Tab from the track lands on the
|
|
424
|
+
first card in view and brings it to the front, and each Tab after moves one card on. A
|
|
425
|
+
cascade that does not loop and opens mid-row (the default) has cards before the window that
|
|
426
|
+
forward Tab never reaches: Shift+Tab from the first card in view, Home, the arrow keys,
|
|
427
|
+
Previous and the dots reach them.
|
|
416
428
|
|
|
417
429
|
```erb
|
|
418
430
|
<%= render layout: "studio/carousel",
|
|
@@ -421,6 +433,53 @@ a plain scrolling row.
|
|
|
421
433
|
<% end %>
|
|
422
434
|
```
|
|
423
435
|
|
|
436
|
+
**The change event.** Every style dispatches `studio:carousel:change` from the region
|
|
437
|
+
(`section.carousel`), bubbling, once each time the open slide changes, after the
|
|
438
|
+
controller has moved `data-current`. `event.detail` is `{ index, previous, slide, total }`:
|
|
439
|
+
the open slide's index among the real slides (from 0), the one open before, its element
|
|
440
|
+
(never a looping row's copy) and the count. Follow the open slide with it; do not observe
|
|
441
|
+
`data-current`. The first paint's slide fires nothing, and a row that opens elsewhere
|
|
442
|
+
when its controller connects (a `#fragment`, a restored scroll) fires once. A cascade
|
|
443
|
+
fires once per move. A scrolling row (standard or spotlight) fires for each slide its
|
|
444
|
+
glide passes, as its `data-current` moves; a looping one fires for the real slide once it
|
|
445
|
+
has jumped home off the seam copy. A standard row's open slide is the one at its first
|
|
446
|
+
stop, and it marks none `data-current`.
|
|
447
|
+
|
|
448
|
+
**The image-only pattern** (the hub Portfolio, `/lab/carousel_cascade`, `/admin/style`):
|
|
449
|
+
each card is only its picture, filling the card; the open card wears a ring; and one caption
|
|
450
|
+
sits under the stack and follows the open card. A caption inside each slide would ride the
|
|
451
|
+
slide's 3D move, so the captions live outside the row, one per item in item order, all in one
|
|
452
|
+
grid cell. The engine's `carousel-captions` controller (lazy) marks the open one
|
|
453
|
+
`data-current` and the rest `aria-hidden` on each change event; the page needs no script:
|
|
454
|
+
|
|
455
|
+
```erb
|
|
456
|
+
<div data-studio-controller="carousel-captions">
|
|
457
|
+
<%= render layout: "studio/carousel",
|
|
458
|
+
locals: { label: "Portfolio", items: @apps, style: :cascade, loop: true, dots: false,
|
|
459
|
+
slide_label: ->(app) { app.name } } do |app| %>
|
|
460
|
+
<%= link_to app.url, aria: { label: app.name },
|
|
461
|
+
class: "block aspect-[4/3] overflow-hidden rounded-2xl transition-shadow duration-300 in-data-current:ring-2 in-data-current:ring-primary" do %>
|
|
462
|
+
<%= image_tag app.image, alt: "", class: "h-full w-full object-cover" %>
|
|
463
|
+
<% end %>
|
|
464
|
+
<% end %>
|
|
465
|
+
<div class="grid pt-4 text-center">
|
|
466
|
+
<% @apps.each_with_index do |app, index| %>
|
|
467
|
+
<div class="col-start-1 row-start-1 opacity-0 invisible transition-opacity data-current:opacity-100 data-current:visible"
|
|
468
|
+
data-carousel-captions-target="caption"<%= " data-current".html_safe if index.zero? %><%= ' aria-hidden="true"'.html_safe unless index.zero? %>>
|
|
469
|
+
<h2 class="type-h4 text-heading"><%= app.name %></h2>
|
|
470
|
+
<p class="text-sm text-secondary"><%= app.blurb %></p>
|
|
471
|
+
</div>
|
|
472
|
+
<% end %>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
The server marks the caption of the slide the row opens on (`start:`; index 0 for a
|
|
478
|
+
looping row). The ring keys on `data-current`, which the cascade and the spotlight set on
|
|
479
|
+
the open slide (`in-data-current:` matches the card inside it). The row's status line
|
|
480
|
+
already says which slide is open, so the caption stack is not a live region. A host that
|
|
481
|
+
needs more than a caption listens for `studio:carousel:change` itself.
|
|
482
|
+
|
|
424
483
|
### The "at" time stamp — `at_time_tag`
|
|
425
484
|
|
|
426
485
|
Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a
|
|
@@ -1174,13 +1174,16 @@ body.modal-open .studio-app-banner {
|
|
|
1174
1174
|
--cascade-shift: 0.62;
|
|
1175
1175
|
}
|
|
1176
1176
|
|
|
1177
|
-
/* A narrow region shows one each side, on wider cards.
|
|
1177
|
+
/* A narrow region shows one each side, on wider cards. Those side cards run past
|
|
1178
|
+
the track's edge, so the edge fades them out instead of cutting them hard. */
|
|
1178
1179
|
@container (width < 36rem) {
|
|
1179
1180
|
& > .carousel-track {
|
|
1180
1181
|
--carousel-cascade-reach: 1;
|
|
1181
1182
|
--cascade-w: 64cqi;
|
|
1182
1183
|
--cascade-shift: 0.44;
|
|
1183
1184
|
--cascade-depth: 3.5rem;
|
|
1185
|
+
-webkit-mask-image: linear-gradient(to right, transparent, #000 var(--carousel-cascade-fade, 10%), #000 calc(100% - var(--carousel-cascade-fade, 10%)), transparent);
|
|
1186
|
+
mask-image: linear-gradient(to right, transparent, #000 var(--carousel-cascade-fade, 10%), #000 calc(100% - var(--carousel-cascade-fade, 10%)), transparent);
|
|
1184
1187
|
}
|
|
1185
1188
|
}
|
|
1186
1189
|
|
|
@@ -1264,7 +1267,9 @@ body.modal-open .studio-app-banner {
|
|
|
1264
1267
|
/* Round the seam: no glide across the stack, a fade in at the new place. */
|
|
1265
1268
|
& > .carousel-track > .carousel-slide[data-cascade-jump] {
|
|
1266
1269
|
transition: none;
|
|
1267
|
-
|
|
1270
|
+
/* backwards, not both: a fade that has ended leaves nothing running, so the
|
|
1271
|
+
slide holds no finished animation until its next move. */
|
|
1272
|
+
animation: carousel-cascade-in 320ms ease backwards;
|
|
1268
1273
|
}
|
|
1269
1274
|
|
|
1270
1275
|
& > .carousel-track > .carousel-slide[data-cascade-jump]:is([data-cascade-step="before"], [data-cascade-step="after"]) {
|
|
@@ -1322,6 +1327,8 @@ body.modal-open .studio-app-banner {
|
|
|
1322
1327
|
overflow-x: auto;
|
|
1323
1328
|
scroll-snap-type: x mandatory;
|
|
1324
1329
|
perspective: none;
|
|
1330
|
+
-webkit-mask-image: none;
|
|
1331
|
+
mask-image: none;
|
|
1325
1332
|
}
|
|
1326
1333
|
|
|
1327
1334
|
&[data-studio-controller-failed] > .carousel-track > .carousel-slide {
|
|
@@ -312,3 +312,46 @@ export function cascadeTarget(active, by, total, loop = false) {
|
|
|
312
312
|
if (loop) return wrapIndex(to, total)
|
|
313
313
|
return Math.min(total - 1, Math.max(0, to))
|
|
314
314
|
}
|
|
315
|
+
|
|
316
|
+
// -- the change event ----------------------------------------------------------
|
|
317
|
+
//
|
|
318
|
+
// THE CHANGE EVENT: the region dispatches CHANGE_EVENT once each time its open slide
|
|
319
|
+
// changes, on every style, after the controller has moved data-current (a spotlight
|
|
320
|
+
// and a cascade mark the open slide; a standard row marks none). It bubbles, so a
|
|
321
|
+
// host listens on the region or the document and needs no MutationObserver:
|
|
322
|
+
//
|
|
323
|
+
// region.addEventListener("studio:carousel:change", ({ detail }) => {
|
|
324
|
+
// detail.index // the open slide, 0-based among the real slides
|
|
325
|
+
// detail.previous // the one open before
|
|
326
|
+
// detail.slide // that slide's element (never a looping row's copy)
|
|
327
|
+
// detail.total // the real slides
|
|
328
|
+
// })
|
|
329
|
+
//
|
|
330
|
+
// A standard row's "open slide" is the one at its first stop. The first paint's
|
|
331
|
+
// slide (start:) fires nothing; a row that opens elsewhere when its controller
|
|
332
|
+
// connects (a #fragment, a restored scroll) fires once. A looping row's glide round
|
|
333
|
+
// the seam fires once, for the real slide, when the row has jumped home onto it, not
|
|
334
|
+
// for the copy it rests on first. A scrolling row's glide across several slides fires
|
|
335
|
+
// for each slide it passes, as data-current moves; a cascade does not glide, so a dot
|
|
336
|
+
// or End fires once.
|
|
337
|
+
export const CHANGE_EVENT = "studio:carousel:change"
|
|
338
|
+
|
|
339
|
+
// The event's detail when the open slide moved from `previous` to `index`, else null.
|
|
340
|
+
export function changeDetail(previous, index, slides) {
|
|
341
|
+
if (!Number.isInteger(index) || index < 0 || index === previous) return null
|
|
342
|
+
const list = slides || []
|
|
343
|
+
if (index >= list.length) return null
|
|
344
|
+
return { index, previous: Number.isInteger(previous) ? previous : null, slide: list[index], total: list.length }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// -- the cascade's press --------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
// The slide a click on a cascade belongs to. A mouse press on a side slide records
|
|
350
|
+
// it (pressed), since a link whose focus already moved the stack still counts as a
|
|
351
|
+
// side slide's. A click with no press (detail 0: Enter or Space on a focused link, or
|
|
352
|
+
// a script's click()) never reads a record a press left behind: it is the slide it
|
|
353
|
+
// lands in, so Enter on the open card follows its link.
|
|
354
|
+
export function clickedSlide(pressed, detail, hit) {
|
|
355
|
+
if (detail === 0) return hit
|
|
356
|
+
return pressed ?? hit
|
|
357
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// carousel-captions: one caption under a carousel that follows its open slide (the
|
|
2
|
+
// image-only pattern, README "The carousel"). A caption inside each slide would ride
|
|
3
|
+
// a cascade's 3D move, so the captions live outside the row: one per item, in item
|
|
4
|
+
// order, laid in one grid cell, and this marks the open slide's caption data-current
|
|
5
|
+
// and hides the rest from assistive tech (aria-hidden). The page styles the cross-fade.
|
|
6
|
+
//
|
|
7
|
+
// <div data-studio-controller="carousel-captions">
|
|
8
|
+
// <%= render layout: "studio/carousel", ... %>
|
|
9
|
+
// <div data-carousel-captions-target="caption" data-current>first</div>
|
|
10
|
+
// <div data-carousel-captions-target="caption" aria-hidden="true">second</div>
|
|
11
|
+
// </div>
|
|
12
|
+
//
|
|
13
|
+
// It listens for the carousel's change event (studio/carousel CHANGE_EVENT), which
|
|
14
|
+
// bubbles from the region inside it, so it needs no observer and no timer. The server
|
|
15
|
+
// marks the caption of the slide the row opens on. A lazy controller (studio/stimulus):
|
|
16
|
+
// a page with no caption stage fetches nothing; one whose controller failed keeps the
|
|
17
|
+
// server's caption.
|
|
18
|
+
import { Controller } from "@hotwired/stimulus"
|
|
19
|
+
import { CHANGE_EVENT } from "studio/carousel"
|
|
20
|
+
|
|
21
|
+
export default class extends Controller {
|
|
22
|
+
static targets = ["caption"]
|
|
23
|
+
|
|
24
|
+
connect() {
|
|
25
|
+
this.follow = (event) => this.show(event.detail?.index)
|
|
26
|
+
this.element.addEventListener(CHANGE_EVENT, this.follow)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.element.removeEventListener(CHANGE_EVENT, this.follow)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
show(index) {
|
|
34
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.captionTargets.length) return
|
|
35
|
+
this.captionTargets.forEach((caption, at) => {
|
|
36
|
+
const open = at === index
|
|
37
|
+
caption.toggleAttribute("data-current", open)
|
|
38
|
+
if (open) caption.removeAttribute("aria-hidden")
|
|
39
|
+
else caption.setAttribute("aria-hidden", "true")
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -57,13 +57,24 @@
|
|
|
57
57
|
// and autoplay move it as they move a spotlight, through the same move() and
|
|
58
58
|
// scrollToStop(). A swipe or a drag (pointer events, past DRAG_THRESHOLD) moves it
|
|
59
59
|
// too; a click on a side slide brings that slide to the front and follows no link,
|
|
60
|
-
// and keyboard focus landing in a side slide does the same
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// that
|
|
60
|
+
// and keyboard focus landing in a side slide in view does the same. TAB: a slide past
|
|
61
|
+
// the window is hidden, so Tab skips it. Tab from the track lands on the first slide
|
|
62
|
+
// in view and brings it to the front, and each Tab after moves one slide on. A cascade
|
|
63
|
+
// that does not loop and opens mid-row (the default :middle) has slides before the
|
|
64
|
+
// window that forward Tab never reaches: Shift+Tab from the first slide in view (its
|
|
65
|
+
// focus has moved the stack, so the slide before it is in view), Home, the arrow keys,
|
|
66
|
+
// the Previous arrow and the dots reach them. A looping cascade takes no clones: the
|
|
67
|
+
// steps wrap round the set, and a slide that crosses the stack round the seam
|
|
68
|
+
// (cascadeJumps) takes no glide but fades in, every time. A focused slide that leaves
|
|
69
|
+
// the window hands focus to the track, so the keys keep working.
|
|
70
|
+
//
|
|
71
|
+
// THE CHANGE EVENT (studio/carousel CHANGE_EVENT, "studio:carousel:change"): every
|
|
72
|
+
// style dispatches it from the region, bubbling, once per change of the open slide,
|
|
73
|
+
// after data-current has moved (announceChange). detail: { index, previous, slide,
|
|
74
|
+
// total }. A host follows the open slide with it instead of observing data-current.
|
|
64
75
|
import { Controller } from "@hotwired/stimulus"
|
|
65
76
|
import {
|
|
66
|
-
CLONE_STRIP, TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, cascadeJumps, cascadeReach, cascadeSlot,
|
|
77
|
+
CHANGE_EVENT, CLONE_STRIP, TOLERANCE, autoplayRunning, changeDetail, clickedSlide, autoplaySeconds, autoplayStop, cascadeJumps, cascadeReach, cascadeSlot,
|
|
67
78
|
cascadeStep, cascadeSteps, cascadeTarget, centerStops, cloneCount, cloneLayout, dragSteps,
|
|
68
79
|
currentStop, hiddenEnds, idleDelay, initialReasons, keyAction, loopPositionText, loopStop, nearestVirtual, ownsKey,
|
|
69
80
|
perPage, positionText, seamJump, startStop, stops, targetStop, toggleLabel, withReason, wrapIndex
|
|
@@ -95,6 +106,8 @@ export default class extends Controller {
|
|
|
95
106
|
this.observer.observe(this.trackTarget)
|
|
96
107
|
}
|
|
97
108
|
this.element.setAttribute("data-carousel-ready", "")
|
|
109
|
+
// The first paint's slide: the change event fires only when the open slide leaves it.
|
|
110
|
+
this.opened = Math.max(0, Number(this.element.dataset.carouselStart) || 0)
|
|
98
111
|
this.openAtStart()
|
|
99
112
|
this.addClones()
|
|
100
113
|
this.sync()
|
|
@@ -107,6 +120,7 @@ export default class extends Controller {
|
|
|
107
120
|
const slides = this.slideTargets
|
|
108
121
|
const marked = slides.findIndex((slide) => slide.hasAttribute("data-current"))
|
|
109
122
|
this.active = marked >= 0 ? marked : 0
|
|
123
|
+
this.opened = this.active
|
|
110
124
|
// A #fragment inside a slide opens on that slide.
|
|
111
125
|
const hash = window.location?.hash
|
|
112
126
|
if (hash && hash.length > 1) {
|
|
@@ -119,7 +133,7 @@ export default class extends Controller {
|
|
|
119
133
|
["pointerdown", (event) => this.dragStart(event)],
|
|
120
134
|
["pointermove", (event) => this.dragMove(event)],
|
|
121
135
|
["pointerup", (event) => this.dragEnd(event)],
|
|
122
|
-
["pointercancel", () => this.dragCancel()],
|
|
136
|
+
["pointercancel", () => { this.pressed = null; this.dragCancel() }],
|
|
123
137
|
["click", (event) => this.clickSide(event), true],
|
|
124
138
|
["focusin", (event) => this.focusSide(event)],
|
|
125
139
|
["dragstart", (event) => event.preventDefault()]
|
|
@@ -159,6 +173,14 @@ export default class extends Controller {
|
|
|
159
173
|
if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!wraps && this.active >= total - 1))
|
|
160
174
|
const steps = cascadeSteps(total, this.active, this.loop)
|
|
161
175
|
const jumps = this.laid ? cascadeJumps(this.laid, steps, this.active) : steps.map(() => false)
|
|
176
|
+
// A jumping slide's fade replays only if the style saw it without the mark first.
|
|
177
|
+
// It may still wear the mark from its last jump (Next then Previous round the
|
|
178
|
+
// seam), or have lost it earlier in this same task (move() syncs before it moves),
|
|
179
|
+
// which the style never saw: drop the mark, settle the style, then set it.
|
|
180
|
+
if (jumps.some(Boolean)) {
|
|
181
|
+
this.slideTargets.forEach((slide, index) => { if (jumps[index]) slide.removeAttribute("data-cascade-jump") })
|
|
182
|
+
void this.trackTarget.offsetWidth
|
|
183
|
+
}
|
|
162
184
|
this.slideTargets.forEach((slide, index) => {
|
|
163
185
|
slide.setAttribute("data-cascade-step", cascadeSlot(steps[index]))
|
|
164
186
|
slide.toggleAttribute("data-cascade-jump", jumps[index])
|
|
@@ -170,6 +192,16 @@ export default class extends Controller {
|
|
|
170
192
|
if (index === this.active) dot.setAttribute("aria-current", "true")
|
|
171
193
|
else dot.removeAttribute("aria-current")
|
|
172
194
|
})
|
|
195
|
+
this.announceChange(this.active)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// THE CHANGE EVENT (studio/carousel CHANGE_EVENT): once per change of the open
|
|
199
|
+
// slide, after data-current has moved, bubbling from the region.
|
|
200
|
+
announceChange(index) {
|
|
201
|
+
const detail = changeDetail(this.opened, index, this.slideTargets)
|
|
202
|
+
if (!detail) return
|
|
203
|
+
this.opened = index
|
|
204
|
+
this.element.dispatchEvent(new CustomEvent(CHANGE_EVENT, { bubbles: true, detail }))
|
|
173
205
|
}
|
|
174
206
|
|
|
175
207
|
// Brings slide `index` to the front. A focused slide that leaves the window hands
|
|
@@ -192,7 +224,9 @@ export default class extends Controller {
|
|
|
192
224
|
|
|
193
225
|
// A press on a side slide: the click that follows brings it to the front and opens
|
|
194
226
|
// nothing. The slide is read at the press, so a link whose focus already moved the
|
|
195
|
-
// stack still counts as a side slide's.
|
|
227
|
+
// stack still counts as a side slide's. A click with no press (event.detail 0:
|
|
228
|
+
// Enter or Space on a focused link) is read where it lands (clickedSlide), so a
|
|
229
|
+
// press released off the track leaves nothing behind for Enter on the open card.
|
|
196
230
|
clickSide(event) {
|
|
197
231
|
if (this.dragged) {
|
|
198
232
|
this.dragged = false
|
|
@@ -200,7 +234,7 @@ export default class extends Controller {
|
|
|
200
234
|
event.stopPropagation()
|
|
201
235
|
return
|
|
202
236
|
}
|
|
203
|
-
const index = this.pressed
|
|
237
|
+
const index = clickedSlide(this.pressed, event.detail, this.slideAt(event.target))
|
|
204
238
|
this.pressed = null
|
|
205
239
|
if (index < 0 || index === this.active) return
|
|
206
240
|
event.preventDefault()
|
|
@@ -209,7 +243,8 @@ export default class extends Controller {
|
|
|
209
243
|
this.scrollToStop(index)
|
|
210
244
|
}
|
|
211
245
|
|
|
212
|
-
// Keyboard focus in a side slide brings it to the front, so Tab
|
|
246
|
+
// Keyboard focus in a side slide brings it to the front, so Tab moves one slide on
|
|
247
|
+
// from the first slide in view (see TAB above for the slides before the window). A
|
|
213
248
|
// mouse press focuses without :focus-visible and leaves the move to its click.
|
|
214
249
|
focusSide(event) {
|
|
215
250
|
const index = this.slideAt(event.target)
|
|
@@ -221,8 +256,10 @@ export default class extends Controller {
|
|
|
221
256
|
this.scrollToStop(index, { announce: false })
|
|
222
257
|
}
|
|
223
258
|
|
|
259
|
+
// A press released off the track sends no pointerup here, so its record is stale:
|
|
260
|
+
// the next press of the same pointer replaces it. A second finger is ignored.
|
|
224
261
|
dragStart(event) {
|
|
225
|
-
if (event.button > 0 || this.drag) return
|
|
262
|
+
if (event.button > 0 || (this.drag && this.drag.id !== event.pointerId)) return
|
|
226
263
|
const index = this.slideAt(event.target)
|
|
227
264
|
this.pressed = index >= 0 && index !== this.active ? index : null
|
|
228
265
|
this.dragged = false
|
|
@@ -564,6 +601,9 @@ export default class extends Controller {
|
|
|
564
601
|
if (index === this.current) dot.setAttribute("aria-current", "true")
|
|
565
602
|
else dot.removeAttribute("aria-current")
|
|
566
603
|
})
|
|
604
|
+
// A looping row resting on a copy is on its way home (settle): it announces the
|
|
605
|
+
// real slide once it lands there, so the slide the event names carries the mark.
|
|
606
|
+
if (!this.cloned || this.virtual === this.current) this.announceChange(this.current)
|
|
567
607
|
}
|
|
568
608
|
|
|
569
609
|
prev() { this.move("prev") }
|
|
@@ -71,7 +71,8 @@ export const schema = {
|
|
|
71
71
|
// So is the page header: its tabs are links, and a row whose controller failed
|
|
72
72
|
// keeps its bar at all times (data-studio-controller-failed="page-header").
|
|
73
73
|
// So is the carousel: its track is a native scroll row, and one whose controller
|
|
74
|
-
// failed keeps a scrollbar and hides the arrows and dots it cannot drive.
|
|
74
|
+
// failed keeps a scrollbar and hides the arrows and dots it cannot drive. So are
|
|
75
|
+
// the carousel's captions: a stage whose controller failed keeps the server's caption.
|
|
75
76
|
// So is infinite scroll: a list whose controller failed keeps its "Load more"
|
|
76
77
|
// link, which loads the next page by itself. So is pull to refresh: nothing
|
|
77
78
|
// depends on it, and the browser's own reload still works.
|
|
@@ -93,6 +94,7 @@ export const LAZY = {
|
|
|
93
94
|
"copy-text": () => import("studio/controllers/copy_text_controller"),
|
|
94
95
|
"page-header": () => import("studio/controllers/page_header_controller"),
|
|
95
96
|
"carousel": () => import("studio/controllers/carousel_controller"),
|
|
97
|
+
"carousel-captions": () => import("studio/controllers/carousel_captions_controller"),
|
|
96
98
|
"infinite-scroll": () => import("studio/controllers/infinite_scroll_controller"),
|
|
97
99
|
"pull-to-refresh": () => import("studio/controllers/pull_to_refresh_controller")
|
|
98
100
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
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, visible: 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, visible: nil, dots: true) %>
|
|
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
|
|
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
so a screen reader and Tab meet each slide once, a #fragment
|
|
52
52
|
finds the real slide, and the dots and "slide N of M" count the
|
|
53
53
|
real slides. Every slide of a looping row is a stop.
|
|
54
|
+
dots true (the default) draws a dot per slide under the row; false
|
|
55
|
+
draws none, so the row has no dot group in the tab order or the
|
|
56
|
+
accessibility tree. The arrows, keys, swipe and status line stay.
|
|
54
57
|
autoplay seconds between moves (a number above 0); nil, the default, is
|
|
55
58
|
off. The row moves one slide each interval and holds still while
|
|
56
59
|
the pointer is over the slides, while keyboard focus is in it,
|
|
@@ -74,10 +77,21 @@
|
|
|
74
77
|
lays the stack out (perspective, translateZ, a slight turn toward the centre)
|
|
75
78
|
and transitions it. An arrow, a dot, a key, a swipe or drag, autoplay, a click
|
|
76
79
|
on a side slide and keyboard focus in one bring that slide to the front; a
|
|
77
|
-
click on a side slide opens nothing.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
click on a side slide opens nothing. Tab skips the slides past the window (they
|
|
81
|
+
are hidden): it lands on the first slide in view and moves one on each press.
|
|
82
|
+
A cascade that does not loop and opens mid-row has slides before the window
|
|
83
|
+
that forward Tab never reaches; Shift+Tab from the first slide in view, Home,
|
|
84
|
+
the arrow keys, Previous and the dots do. A looping cascade needs no clones: the
|
|
85
|
+
steps wrap round the set, so the window is never wider than the set. A narrow
|
|
86
|
+
region fades its side slides at the track's edge. Under reduced motion it lays
|
|
87
|
+
flat, as a spotlight does, and swaps with no glide. If its controller fails it
|
|
88
|
+
falls back to a plain scrolling row.
|
|
89
|
+
|
|
90
|
+
THE CHANGE EVENT. Every style dispatches "studio:carousel:change" from the
|
|
91
|
+
region, bubbling, once each time the open slide changes, after data-current
|
|
92
|
+
has moved: detail { index, previous, slide, total }. A host that follows the
|
|
93
|
+
open slide listens for it. One caption under the row needs no script: the
|
|
94
|
+
carousel-captions controller follows it (README, the image-only pattern).
|
|
81
95
|
|
|
82
96
|
THE TRACK IS A NATIVE SCROLL-SNAP ROW (engine.css, carousel). A swipe, a
|
|
83
97
|
trackpad, a wheel and a focused link move it with no script. The carousel
|
|
@@ -164,6 +178,7 @@
|
|
|
164
178
|
<% end %>
|
|
165
179
|
</div>
|
|
166
180
|
<div class="carousel-controls">
|
|
181
|
+
<% if dots %>
|
|
167
182
|
<div class="carousel-dots" role="group" aria-label="Choose a slide">
|
|
168
183
|
<% total.times do |index| %>
|
|
169
184
|
<button type="button" class="carousel-dot" aria-label="Go to slide <%= index + 1 %>"
|
|
@@ -172,6 +187,7 @@
|
|
|
172
187
|
data-studio-action="click->carousel#goTo"></button>
|
|
173
188
|
<% end %>
|
|
174
189
|
</div>
|
|
190
|
+
<% end %>
|
|
175
191
|
<div class="carousel-arrows">
|
|
176
192
|
<% if autoplay %>
|
|
177
193
|
<%# The pause button (WCAG 2.2.2). The controller renames it "Play slideshow"
|
|
@@ -281,6 +281,29 @@
|
|
|
281
281
|
</div>
|
|
282
282
|
<% end %>
|
|
283
283
|
</div>
|
|
284
|
+
|
|
285
|
+
<%# The image-only pattern (README, The carousel): each card is only its picture,
|
|
286
|
+
the open card wears a ring, the row draws no dots, and one caption under the
|
|
287
|
+
stack follows the open card (the carousel-captions controller, on
|
|
288
|
+
studio:carousel:change). %>
|
|
289
|
+
<div class="w-full min-w-0" data-studio-controller="carousel-captions" data-carousel-image-only-specimen>
|
|
290
|
+
<%= render layout: "studio/carousel",
|
|
291
|
+
locals: { label: "Image-only specimen", items: (1..5).to_a, style: :cascade, loop: true, dots: false,
|
|
292
|
+
id: "style-carousel-image-only-specimen", slide_label: ->(n) { "Picture #{n}" } } do |n| %>
|
|
293
|
+
<div role="img" aria-label="Picture <%= n %>"
|
|
294
|
+
class="aspect-[4/3] overflow-hidden rounded-2xl transition-shadow duration-300 in-data-current:ring-2 in-data-current:ring-primary"
|
|
295
|
+
style="background-image: linear-gradient(<%= 120 + n * 30 %>deg, rgb(var(--color-primary-rgb) / <%= 0.25 + n * 0.12 %>), rgb(var(--color-primary-rgb) / 0.08))"></div>
|
|
296
|
+
<% end %>
|
|
297
|
+
<div class="grid pt-3 text-center">
|
|
298
|
+
<% (1..5).each do |n| %>
|
|
299
|
+
<div class="col-start-1 row-start-1 opacity-0 invisible transition-opacity duration-200 data-current:opacity-100 data-current:visible"
|
|
300
|
+
data-carousel-captions-target="caption"<%= " data-current".html_safe if n == 1 %><%= ' aria-hidden="true"'.html_safe unless n == 1 %>>
|
|
301
|
+
<p class="text-sm font-semibold text-heading">Picture <%= n %></p>
|
|
302
|
+
<p class="text-xs text-secondary">One caption under the stack.</p>
|
|
303
|
+
</div>
|
|
304
|
+
<% end %>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
284
307
|
<% end %>
|
|
285
308
|
</section>
|
|
286
309
|
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.139.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -516,6 +516,7 @@ files:
|
|
|
516
516
|
- app/javascript/studio/controllers/agent_chat_voice_controller.js
|
|
517
517
|
- app/javascript/studio/controllers/board_controller.js
|
|
518
518
|
- app/javascript/studio/controllers/booking_controller.js
|
|
519
|
+
- app/javascript/studio/controllers/carousel_captions_controller.js
|
|
519
520
|
- app/javascript/studio/controllers/carousel_controller.js
|
|
520
521
|
- app/javascript/studio/controllers/copy_text_controller.js
|
|
521
522
|
- app/javascript/studio/controllers/email_banner_scale_controller.js
|