@inglorious/ui 6.0.0 → 6.1.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.
@@ -0,0 +1,655 @@
1
+ /**
2
+ * @typedef {import('../../../types/data-display/carousel.js').CarouselProps} CarouselProps
3
+ * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
4
+ */
5
+
6
+ import { html } from "@inglorious/web"
7
+ import { classMap } from "@inglorious/web/directives/class-map"
8
+ import { ref } from "@inglorious/web/directives/ref"
9
+ import { repeat } from "@inglorious/web/directives/repeat"
10
+ import { when } from "@inglorious/web/directives/when"
11
+
12
+ import { Button } from "../../controls/button/index.js"
13
+ import {
14
+ clampPage,
15
+ DEFAULT_ALIGN,
16
+ DEFAULT_ARROW_PLACEMENT,
17
+ DEFAULT_ARROW_VARIANT,
18
+ DEFAULT_AXIS,
19
+ DEFAULT_GAP,
20
+ FIRST_PAGE,
21
+ getLastPage,
22
+ normalizeRotation,
23
+ VERTICAL_AXIS,
24
+ } from "./helpers.js"
25
+
26
+ const PREVIOUS = "previous"
27
+ const NEXT = "next"
28
+ const ONE_PAGE = 1
29
+ const HUMAN_OFFSET = 1
30
+ const SCROLL_EPSILON = 1
31
+
32
+ /** Transient drag bookkeeping, kept off the entity so it stays serializable. */
33
+ const drags = new WeakMap()
34
+
35
+ export const Carousel = {
36
+ /**
37
+ * Main entrypoint for the carousel template. It delegates to the base renderer so overrides can still reuse it.
38
+ * Carousels lay items out in a scroll-snapping viewport and page through them.
39
+ * @param {CarouselProps} props The carousel props.
40
+ * @returns {TemplateResult} The rendered carousel.
41
+ */
42
+ render(props) {
43
+ return this.renderCarousel(props)
44
+ },
45
+
46
+ /**
47
+ * Renders the container, the stage that holds the viewport and its arrows, and the indicators.
48
+ * This is the canonical renderer that custom `render()` overrides should call.
49
+ * @param {CarouselProps} props The carousel props.
50
+ * @returns {TemplateResult} The rendered carousel.
51
+ */
52
+ renderCarousel(props) {
53
+ const {
54
+ axis = DEFAULT_AXIS,
55
+ gap = DEFAULT_GAP,
56
+ align = DEFAULT_ALIGN,
57
+ arrowPlacement = DEFAULT_ARROW_PLACEMENT,
58
+ hasArrows = true,
59
+ hasIndicators = true,
60
+ isFullWidth = false,
61
+ } = props
62
+
63
+ const classes = {
64
+ "iw-carousel": true,
65
+ "iw-carousel-vertical": axis === VERTICAL_AXIS,
66
+ [`iw-carousel-gap-${gap}`]: gap !== DEFAULT_GAP,
67
+ [`iw-carousel-align-${align}`]: align !== DEFAULT_ALIGN,
68
+ [`iw-carousel-arrows-${arrowPlacement}`]: true,
69
+ "iw-carousel-full-width": isFullWidth,
70
+ }
71
+
72
+ // The arrows live in the stage, next to the viewport, so that centring them
73
+ // ignores the indicators underneath.
74
+ return html`<div class=${classMap(classes)}>
75
+ <div class="iw-carousel-stage">
76
+ ${when(hasArrows, () => this.renderArrow?.(props, PREVIOUS))}
77
+ ${this.renderViewport?.(props)}
78
+ ${when(hasArrows, () => this.renderArrow?.(props, NEXT))}
79
+ </div>
80
+ ${when(hasIndicators, () => this.renderIndicators?.(props))}
81
+ </div>`
82
+ },
83
+
84
+ /**
85
+ * Renders the scrolling viewport that holds the items.
86
+ * Snapping, momentum and touch swiping are left to the browser; this adds mouse dragging and paging.
87
+ * @param {CarouselProps} props The carousel props.
88
+ * @returns {TemplateResult} The rendered viewport.
89
+ */
90
+ renderViewport(props) {
91
+ const { label } = props
92
+ const items = displayedItems(props)
93
+
94
+ return html`<div
95
+ class="iw-carousel-viewport"
96
+ role="group"
97
+ aria-roledescription="carousel"
98
+ aria-label=${label || "Carousel"}
99
+ tabindex="0"
100
+ @scroll=${handleScroll(props)}
101
+ @scrollend=${handleScrollEnd(props)}
102
+ @keydown=${handleKeyDown(props)}
103
+ @pointerdown=${handlePointerDown(props)}
104
+ @pointermove=${handlePointerMove}
105
+ @pointerup=${handlePointerUp(props)}
106
+ @pointercancel=${handlePointerUp(props)}
107
+ ${ref(settleOnInitialPage(props))}
108
+ >
109
+ ${repeat(
110
+ items,
111
+ (item, index) => index,
112
+ (item, index) =>
113
+ this.renderItem?.(props, item, logicalIndex(props, index)),
114
+ )}
115
+ </div>`
116
+ },
117
+
118
+ /**
119
+ * Renders a single slide. Override this to wrap items in your own markup.
120
+ * @param {CarouselProps} props The carousel props.
121
+ * @param {unknown} item The item to render.
122
+ * @param {number} index The item index.
123
+ * @returns {TemplateResult} The rendered item.
124
+ */
125
+ renderItem(props, item, index) {
126
+ const total = props.items?.length ?? 0
127
+
128
+ return html`<div
129
+ class="iw-carousel-item"
130
+ role="group"
131
+ aria-roledescription="slide"
132
+ aria-label=${`${index + HUMAN_OFFSET} of ${total}`}
133
+ >
134
+ ${item}
135
+ </div>`
136
+ },
137
+
138
+ /**
139
+ * Renders one arrow, reusing the Button component so it themes like the rest of the system.
140
+ * Override this to supply custom arrows.
141
+ * @param {CarouselProps} props The carousel props.
142
+ * @param {"previous"|"next"} direction Which way the arrow pages.
143
+ * @returns {TemplateResult} The rendered arrow.
144
+ */
145
+ renderArrow(props, direction) {
146
+ const {
147
+ page = FIRST_PAGE,
148
+ items = [],
149
+ isInfinite = false,
150
+ arrowVariant = DEFAULT_ARROW_VARIANT,
151
+ arrowColor,
152
+ } = props
153
+ const isPrevious = direction === PREVIOUS
154
+
155
+ // Infinite carousels wrap, so their arrows never run out.
156
+ const isDisabled =
157
+ !isInfinite &&
158
+ (isPrevious ? page <= FIRST_PAGE : page >= getLastPage(items))
159
+
160
+ // The click is caught on the wrapper: Button's onClick carries no event, and
161
+ // a disabled button emits no click at all, so nothing can leak through.
162
+ return html`<span
163
+ class=${classMap({
164
+ "iw-carousel-arrow": true,
165
+ [`iw-carousel-arrow-${direction}`]: true,
166
+ })}
167
+ @click=${(event) =>
168
+ pageBy(props, isPrevious ? -ONE_PAGE : ONE_PAGE, event.currentTarget)}
169
+ >
170
+ ${Button.render({
171
+ variant: arrowVariant,
172
+ color: arrowColor,
173
+ shape: "round",
174
+ isDisabled,
175
+ children: html`<span class="iw-carousel-chevron"></span>`,
176
+ "aria-label": isPrevious ? "Previous slide" : "Next slide",
177
+ })}
178
+ </span>`
179
+ },
180
+
181
+ /**
182
+ * Renders the row of pagination dots.
183
+ * Override this to build custom pagination.
184
+ * @param {CarouselProps} props The carousel props.
185
+ * @returns {TemplateResult} The rendered indicators.
186
+ */
187
+ renderIndicators(props) {
188
+ const { items = [] } = props
189
+
190
+ return html`<div class="iw-carousel-indicators">
191
+ ${repeat(
192
+ items,
193
+ (item, index) => index,
194
+ (item, index) => this.renderIndicator?.(props, index),
195
+ )}
196
+ </div>`
197
+ },
198
+
199
+ /**
200
+ * Where an indicator sends the viewport. Infinite carousels rotate the
201
+ * clicked item to the front; finite ones just page to it.
202
+ * @param {CarouselProps} props The carousel props.
203
+ * @param {number} index The logical item the dot represents.
204
+ * @param {HTMLElement} viewport The viewport to move.
205
+ */
206
+ indicatorSelect(props, index, viewport) {
207
+ goToLogical(props, viewport, index)
208
+ },
209
+
210
+ /**
211
+ * Renders a single pagination dot.
212
+ * @param {CarouselProps} props The carousel props.
213
+ * @param {number} index The page the dot jumps to.
214
+ * @returns {TemplateResult} The rendered indicator.
215
+ */
216
+ renderIndicator(props, index) {
217
+ const isCurrent = index === currentLogical(props)
218
+
219
+ return html`<button
220
+ class=${classMap({
221
+ "iw-carousel-indicator": true,
222
+ "iw-carousel-indicator-current": isCurrent,
223
+ })}
224
+ type="button"
225
+ aria-label=${`Go to slide ${index + HUMAN_OFFSET}`}
226
+ aria-current=${isCurrent ? "page" : "false"}
227
+ @click=${(event) =>
228
+ this.indicatorSelect?.(props, index, viewportFrom(event.currentTarget))}
229
+ ></button>`
230
+ },
231
+ }
232
+
233
+ /**
234
+ * Report the page the viewport has settled on. Native scrolling owns the
235
+ * position, so this listener is the only thing that writes the page back.
236
+ * @param {CarouselProps} props
237
+ * @returns {(event: Event) => void}
238
+ */
239
+ function handleScroll(props) {
240
+ return (event) => {
241
+ // Infinite carousels re-centre on settle instead of tracking every scroll.
242
+ if (props.isInfinite) return
243
+
244
+ const page = pageFromScroll(event.currentTarget, props.axis)
245
+ if (page !== props.page) props.onPageChange?.(page)
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Updates the logical position after an infinite scroll. Infinite carousels
251
+ * render a repeated strip, so returning from one copy to the middle copy keeps
252
+ * exactly the same slides in view while leaving scroll room on both sides.
253
+ * @param {CarouselProps} props
254
+ * @returns {(event: Event) => void}
255
+ */
256
+ function handleScrollEnd(props) {
257
+ return (event) => {
258
+ const viewport = event.currentTarget
259
+
260
+ // Mid-drag pauses fire scrollend too; leave the drag alone until it ends.
261
+ if (drags.has(viewport)) return
262
+
263
+ viewport.classList.remove("iw-carousel-viewport-dragging")
264
+
265
+ if (!props.isInfinite) return
266
+
267
+ const isVertical = (props.axis ?? DEFAULT_AXIS) === VERTICAL_AXIS
268
+ const length = props.items?.length ?? 0
269
+ if (!length) return
270
+
271
+ const page = pageFromScroll(viewport, props.axis)
272
+ const logical = normalizeRotation(page, length)
273
+ const home = length + currentLogical(props)
274
+ const delta = page - home
275
+ if (!delta) return
276
+
277
+ props.onRotate?.(delta)
278
+ setScroll(
279
+ viewport,
280
+ offsetOf(viewport, length + logical, isVertical),
281
+ isVertical,
282
+ )
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Page with the arrow keys, or jump to either end.
288
+ * @param {CarouselProps} props
289
+ * @returns {(event: KeyboardEvent) => void}
290
+ */
291
+ function handleKeyDown(props) {
292
+ return (event) => {
293
+ const isVertical = (props.axis ?? DEFAULT_AXIS) === VERTICAL_AXIS
294
+
295
+ switch (event.key) {
296
+ case "ArrowLeft":
297
+ if (isVertical) return
298
+ pageBy(props, -ONE_PAGE, event.currentTarget)
299
+ break
300
+ case "ArrowUp":
301
+ if (!isVertical) return
302
+ pageBy(props, -ONE_PAGE, event.currentTarget)
303
+ break
304
+ case "ArrowRight":
305
+ if (isVertical) return
306
+ pageBy(props, ONE_PAGE, event.currentTarget)
307
+ break
308
+ case "ArrowDown":
309
+ if (!isVertical) return
310
+ pageBy(props, ONE_PAGE, event.currentTarget)
311
+ break
312
+ case "Home":
313
+ goToLogical(props, event.currentTarget, FIRST_PAGE)
314
+ break
315
+ case "End":
316
+ goToLogical(props, event.currentTarget, getLastPage(props.items))
317
+ break
318
+ default:
319
+ return
320
+ }
321
+
322
+ event.preventDefault()
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Start a mouse drag. Touch already pans the viewport natively, so only the
328
+ * mouse needs help here.
329
+ * @param {CarouselProps} props
330
+ * @returns {(event: PointerEvent) => void}
331
+ */
332
+ function handlePointerDown(props) {
333
+ return (event) => {
334
+ if (event.pointerType !== "mouse") return
335
+
336
+ const viewport = event.currentTarget
337
+ const isVertical = (props.axis ?? DEFAULT_AXIS) === VERTICAL_AXIS
338
+
339
+ drags.set(viewport, {
340
+ from: isVertical ? event.clientY : event.clientX,
341
+ scroll: isVertical ? viewport.scrollTop : viewport.scrollLeft,
342
+ isVertical,
343
+ })
344
+ // Snapping would fight every move; it is restored on pointerup.
345
+ viewport.classList.add("iw-carousel-viewport-dragging")
346
+ viewport.setPointerCapture?.(event.pointerId)
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Pan the viewport with the mouse while the button is held down.
352
+ * The drag record already knows the axis, so no props are needed.
353
+ * @param {PointerEvent} event
354
+ */
355
+ function handlePointerMove(event) {
356
+ const drag = drags.get(event.currentTarget)
357
+ if (!drag) return
358
+
359
+ const to = drag.isVertical ? event.clientY : event.clientX
360
+ const scroll = drag.scroll - (to - drag.from)
361
+
362
+ if (drag.isVertical) event.currentTarget.scrollTop = scroll
363
+ else event.currentTarget.scrollLeft = scroll
364
+ }
365
+
366
+ /**
367
+ * End a mouse drag by landing on the nearest slide.
368
+ * Snapping stays suspended until the landing has settled: switching it back on
369
+ * first makes the browser jump to whichever item it had snapped to before the
370
+ * drag, undoing the whole gesture.
371
+ * @param {CarouselProps} props
372
+ * @returns {(event: PointerEvent) => void}
373
+ */
374
+ function handlePointerUp(props) {
375
+ return (event) => {
376
+ const viewport = event.currentTarget
377
+ if (!drags.has(viewport)) return
378
+
379
+ drags.delete(viewport)
380
+ viewport.releasePointerCapture?.(event.pointerId)
381
+
382
+ // Land on the nearest slide; handleScrollEnd restores snapping and, when
383
+ // infinite, re-centres from there.
384
+ goToPage(viewport, pageFromScroll(viewport, props.axis), props, "smooth")
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Page relative to where the viewport actually sits, rather than to the
390
+ * reported page: trailing items can be past the end of the scroll range, so
391
+ * page arithmetic alone would get stuck there.
392
+ * @param {CarouselProps} props
393
+ * @param {number} step
394
+ * @param {HTMLElement} from The control the paging was triggered from.
395
+ */
396
+ function pageBy(props, step, from) {
397
+ const viewport = viewportFrom(from)
398
+ if (!viewport) return
399
+
400
+ const isVertical = (props.axis ?? DEFAULT_AXIS) === VERTICAL_AXIS
401
+ const scroll = isVertical ? viewport.scrollTop : viewport.scrollLeft
402
+ const offsets = itemOffsets(viewport, isVertical)
403
+ const here = pageFromScroll(viewport, props.axis)
404
+
405
+ // Infinite carousels use a three-copy strip, so a normal one-step scroll is
406
+ // always available. Their settle handler silently returns to the middle copy
407
+ // after wrapping, where the visible sequence is identical.
408
+ if (props.isInfinite) {
409
+ goToPage(
410
+ viewport,
411
+ here + (step < 0 ? -ONE_PAGE : ONE_PAGE),
412
+ props,
413
+ "smooth",
414
+ )
415
+ return
416
+ }
417
+
418
+ // Several items can be visible at once, so their later offsets may extend
419
+ // past the physical scroll range. At the end, choosing the first such offset
420
+ // would make a right arrow jump backwards after the browser clamps it.
421
+ const limit = maxScroll(viewport, isVertical)
422
+ if (step > 0 && limit > SCROLL_EPSILON && scroll >= limit - SCROLL_EPSILON) {
423
+ return
424
+ }
425
+
426
+ const page =
427
+ step < 0
428
+ ? offsets.findLastIndex((offset) => offset < scroll - SCROLL_EPSILON)
429
+ : offsets.findIndex((offset) => offset > scroll + SCROLL_EPSILON)
430
+
431
+ if (page >= FIRST_PAGE) goToPage(viewport, page, props)
432
+ }
433
+
434
+ /**
435
+ * Move to a logical item, whichever rotation it currently sits at. Infinite
436
+ * carousels rotate it into the home slot; finite ones page straight to it.
437
+ * @param {CarouselProps} props
438
+ * @param {HTMLElement | null | undefined} viewport
439
+ * @param {number} logical
440
+ */
441
+ function goToLogical(props, viewport, logical) {
442
+ if (!viewport) return
443
+
444
+ if (props.isInfinite) {
445
+ const length = props.items?.length ?? 0
446
+ const page = pageFromScroll(viewport, props.axis)
447
+ const candidates = [logical, length + logical, length * 2 + logical]
448
+ const target = candidates.reduce((nearest, candidate) =>
449
+ Math.abs(candidate - page) < Math.abs(nearest - page)
450
+ ? candidate
451
+ : nearest,
452
+ )
453
+ goToPage(viewport, target, props)
454
+ return
455
+ }
456
+
457
+ goToPage(viewport, logical, props)
458
+ }
459
+
460
+ /**
461
+ * Jump the scroll offset instantly. `scrollLeft = x` would animate here because
462
+ * the viewport has `scroll-behavior: smooth`, which is exactly what must not
463
+ * happen when re-anchoring the treadmill — the jump has to be a single frame.
464
+ * @param {HTMLElement} viewport
465
+ * @param {number} offset
466
+ * @param {boolean} isVertical
467
+ */
468
+ function setScroll(viewport, offset, isVertical) {
469
+ viewport.scrollTo?.(
470
+ isVertical
471
+ ? { top: offset, behavior: "instant" }
472
+ : { left: offset, behavior: "instant" },
473
+ )
474
+ }
475
+
476
+ /**
477
+ * The start offset of one item along the active axis.
478
+ * @param {HTMLElement} viewport
479
+ * @param {number} index
480
+ * @param {boolean} isVertical
481
+ * @returns {number}
482
+ */
483
+ function offsetOf(viewport, index, isVertical) {
484
+ const item = viewport.children[index]
485
+ if (!item) return 0
486
+ return isVertical ? item.offsetTop : item.offsetLeft
487
+ }
488
+
489
+ /**
490
+ * Position the viewport on a page. Scrolling triggers the scroll listener,
491
+ * which is what updates the state, so the two never fight each other.
492
+ * @param {HTMLElement | null | undefined} viewport
493
+ * @param {number} page
494
+ * @param {CarouselProps} props
495
+ * @param {ScrollBehavior} [behavior] Defaults to the CSS scroll-behavior.
496
+ */
497
+ function goToPage(viewport, page, props, behavior = "auto") {
498
+ if (!viewport) return
499
+
500
+ const index = props.isInfinite
501
+ ? Math.max(FIRST_PAGE, Math.min(page, viewport.children.length - 1))
502
+ : clampPage(page, props.items)
503
+ const item = viewport.children[index]
504
+ if (!item) return
505
+
506
+ // Guarded like setPointerCapture: real browsers always have scrollTo, but
507
+ // non-layout environments such as jsdom do not.
508
+ viewport.scrollTo?.(
509
+ (props.axis ?? DEFAULT_AXIS) === VERTICAL_AXIS
510
+ ? { top: item.offsetTop, behavior }
511
+ : { left: item.offsetLeft, behavior },
512
+ )
513
+ }
514
+
515
+ /**
516
+ * Place the viewport on its starting page, once, without fighting later scrolls.
517
+ * Item offsets are all zero until the carousel has been laid out, so this waits
518
+ * for the next frame, and jumps rather than animating on arrival.
519
+ * @param {CarouselProps} props
520
+ * @returns {(viewport: HTMLElement | undefined) => void}
521
+ */
522
+ function settleOnInitialPage(props) {
523
+ return (viewport) => {
524
+ if (!viewport || viewport.dataset.iwCarouselReady) return
525
+
526
+ viewport.dataset.iwCarouselReady = "true"
527
+ requestAnimationFrame(() =>
528
+ goToPage(viewport, initialPage(props), props, "instant"),
529
+ )
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Get the page whose item sits closest to the current scroll offset.
535
+ * @param {HTMLElement} viewport
536
+ * @param {"x"|"y"} [axis]
537
+ * @returns {number}
538
+ */
539
+ function pageFromScroll(viewport, axis = DEFAULT_AXIS) {
540
+ const isVertical = axis === VERTICAL_AXIS
541
+ const scroll = isVertical ? viewport.scrollTop : viewport.scrollLeft
542
+ const offsets = itemOffsets(viewport, isVertical)
543
+
544
+ // Several items may be visible at once. At the physical end of the strip,
545
+ // use the next item's offset rather than the final item: an infinite
546
+ // carousel re-centres from this page, and treating the end as the last item
547
+ // would rotate past slides that were never scrolled through.
548
+ const limit = maxScroll(viewport, isVertical)
549
+ if (limit > SCROLL_EPSILON && scroll >= limit - SCROLL_EPSILON) {
550
+ const next = offsets.findIndex(
551
+ (offset) => offset >= scroll - SCROLL_EPSILON,
552
+ )
553
+ return next >= FIRST_PAGE ? next : getLastPage(offsets)
554
+ }
555
+
556
+ let page = FIRST_PAGE
557
+ let closest = Infinity
558
+
559
+ for (const [index, offset] of offsets.entries()) {
560
+ const distance = Math.abs(offset - scroll)
561
+
562
+ if (distance < closest) {
563
+ closest = distance
564
+ page = index
565
+ }
566
+ }
567
+
568
+ return page
569
+ }
570
+
571
+ /**
572
+ * The start offset of every item along the scrolling axis.
573
+ * @param {HTMLElement} viewport
574
+ * @param {boolean} isVertical
575
+ * @returns {number[]}
576
+ */
577
+ function itemOffsets(viewport, isVertical) {
578
+ return [...viewport.children].map((item) =>
579
+ isVertical ? item.offsetTop : item.offsetLeft,
580
+ )
581
+ }
582
+
583
+ /**
584
+ * How far the viewport can scroll before it runs out of content.
585
+ * @param {HTMLElement} viewport
586
+ * @param {boolean} isVertical
587
+ * @returns {number}
588
+ */
589
+ function maxScroll(viewport, isVertical) {
590
+ return isVertical
591
+ ? viewport.scrollHeight - viewport.clientHeight
592
+ : viewport.scrollWidth - viewport.clientWidth
593
+ }
594
+
595
+ /**
596
+ * Infinite carousels use three copies of the items. This gives the viewport a
597
+ * real buffer at each end, so scrolling never has to animate into a reordered
598
+ * strip. The middle copy is used as the resting position after every wrap.
599
+ * @param {CarouselProps} props
600
+ * @returns {unknown[]}
601
+ */
602
+ function displayedItems(props) {
603
+ const items = props.items ?? []
604
+ return props.isInfinite ? [...items, ...items, ...items] : items
605
+ }
606
+
607
+ /**
608
+ * Translate a physical repeated-strip index to the original item index.
609
+ * @param {CarouselProps} props
610
+ * @param {number} index
611
+ * @returns {number}
612
+ */
613
+ function logicalIndex(props, index) {
614
+ return props.isInfinite
615
+ ? normalizeRotation(index, props.items?.length ?? 0)
616
+ : index
617
+ }
618
+
619
+ /**
620
+ * The physical page to use when the viewport first mounts.
621
+ * @param {CarouselProps} props
622
+ * @returns {number}
623
+ */
624
+ function initialPage(props) {
625
+ if (!props.isInfinite) return props.page ?? FIRST_PAGE
626
+ return (props.items?.length ?? 0) + currentLogical(props)
627
+ }
628
+
629
+ /**
630
+ * The logical index of the slide currently in view, i.e. which of the original
631
+ * items it is once the rotation is accounted for.
632
+ * @param {CarouselProps} props
633
+ * @returns {number}
634
+ */
635
+ function currentLogical(props) {
636
+ const page = props.page ?? FIRST_PAGE
637
+ if (!props.isInfinite) return page
638
+ return normalizeRotation(
639
+ page + (props.rotation ?? 0),
640
+ props.items?.length ?? 0,
641
+ )
642
+ }
643
+
644
+ /**
645
+ * Find the viewport from any control inside the same carousel, or from the
646
+ * viewport itself. Works on detached DOM, unlike an id lookup.
647
+ * @param {HTMLElement} element
648
+ * @returns {HTMLElement | null | undefined}
649
+ */
650
+ function viewportFrom(element) {
651
+ return (
652
+ element.closest(".iw-carousel-viewport") ??
653
+ element.closest(".iw-carousel")?.querySelector(".iw-carousel-viewport")
654
+ )
655
+ }
@@ -1,5 +1,7 @@
1
1
  @import "./avatar/style.css";
2
2
  @import "./badge/style.css";
3
+ @import "./before-after/style.css";
4
+ @import "./carousel/style.css";
3
5
  @import "./chip/style.css";
4
6
  @import "./data-grid/style.css";
5
7
  @import "./divider/style.css";
@@ -165,6 +165,12 @@ function getDefaultItems() {
165
165
  children: [
166
166
  { id: "avatar", primary: "Avatar", href: "/data-display/avatar" },
167
167
  { id: "badge", primary: "Badge", href: "/data-display/badge" },
168
+ {
169
+ id: "before-after",
170
+ primary: "Before / After",
171
+ href: "/data-display/before-after",
172
+ },
173
+ { id: "carousel", primary: "Carousel", href: "/data-display/carousel" },
168
174
  { id: "chip", primary: "Chip", href: "/data-display/chip" },
169
175
  {
170
176
  id: "data-grid",
@@ -0,0 +1,29 @@
1
+ // Self-contained demo images for the Before/After control, so the dashboard
2
+ // renders the comparison without any network requests.
3
+
4
+ const svg = (label, from, to) =>
5
+ "data:image/svg+xml," +
6
+ encodeURIComponent(
7
+ `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
8
+ <defs>
9
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
10
+ <stop offset="0" stop-color="${from}"/>
11
+ <stop offset="1" stop-color="${to}"/>
12
+ </linearGradient>
13
+ </defs>
14
+ <rect width="960" height="540" fill="url(#g)"/>
15
+ <text x="48" y="496" font-family="sans-serif" font-size="72" font-weight="700"
16
+ fill="rgba(255,255,255,0.92)">${label}</text>
17
+ </svg>`,
18
+ )
19
+
20
+ export const beforeAfterImages = {
21
+ before: {
22
+ src: svg("BEFORE", "#9ca3af", "#374151"),
23
+ alt: "Before — grayscale",
24
+ },
25
+ after: {
26
+ src: svg("AFTER", "#22d3ee", "#4f46e5"),
27
+ alt: "After — color graded",
28
+ },
29
+ }