@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.
- package/package.json +20 -10
- package/src/controls/controls.css +1 -1
- package/src/data-display/before-after/before-after.stories.js +74 -0
- package/src/data-display/before-after/handlers.js +44 -0
- package/src/data-display/before-after/helpers.js +4 -0
- package/src/data-display/before-after/index.js +17 -0
- package/src/data-display/before-after/style.css +128 -0
- package/src/data-display/before-after/template.js +225 -0
- package/src/data-display/carousel/carousel.stories.js +166 -0
- package/src/data-display/carousel/handlers.js +78 -0
- package/src/data-display/carousel/helpers.js +54 -0
- package/src/data-display/carousel/index.js +17 -0
- package/src/data-display/carousel/style.css +233 -0
- package/src/data-display/carousel/template.js +655 -0
- package/src/data-display/data-display.css +2 -0
- package/src/examples/dashboard/app-drawer.js +6 -0
- package/src/examples/dashboard/before-after-data.js +29 -0
- package/src/examples/dashboard/primitive-content.js +151 -33
- package/src/index.js +2 -0
- package/src/layout/flex/flex.stories.js +49 -0
- package/src/layout/flex/style.css +59 -0
- package/src/layout/flex/template.js +2 -0
- package/types/data-display/before-after.d.ts +39 -0
- package/types/data-display/carousel.d.ts +72 -0
- package/types/layout/flex.d.ts +1 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|