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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb1274e13e7eca7030835c5ff43a48a897f01e723273ec60e4f5eb334e190c85
4
- data.tar.gz: a8e5bb472135b9f72a8662026dac450d2b0830dd30d6913a504c0ced09e57dd2
3
+ metadata.gz: a9937bbeb00f70902af7404d9ded16f10216e7123dba4d929eef0d04fe8c1f80
4
+ data.tar.gz: ff26fdac225b730cc611af898fe0fc786ee968545a5ee674f89131cfe183f035
5
5
  SHA512:
6
- metadata.gz: 4184a55e11cf2e7c3146c73520878a5e1edbc6802bcb19eb9a3c61b09d6d73912a867c98bbd56772d664dc4bf2bace1a152a864c36c1447102bf83d7d0d444df
7
- data.tar.gz: d06d58547262873513c597eb54f8313e76ac8bbbc3dcc7d6693a5a12ffbc23b4c08eeb782a94edf384d4af09bc3f83eda27845f5fc5a112af339c8ada5269bc0
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
- animation: carousel-cascade-in 320ms ease both;
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, so Tab walks the set. A
61
- // looping cascade takes no clones: the steps wrap round the set, and a slide that
62
- // crosses the stack round the seam (cascadeJumps) takes no glide. A focused slide
63
- // that leaves the window hands focus to the track, so the keys keep working.
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 ?? this.slideAt(event.target)
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 walks the set. A
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. A looping cascade needs no clones: the
78
- steps wrap round the set, so the window is never wider than the set. Under
79
- reduced motion it lays flat, as a spotlight does, and swaps with no glide. If
80
- its controller fails it falls back to a plain scrolling row.
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
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.138.0"
2
+ VERSION = "0.139.0"
3
3
  end
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.138.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