overlapping-cards-scroll 0.1.0 → 0.1.2
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/README.md +16 -0
- package/dist/react-native-web.cjs +22 -4
- package/dist/react-native-web.js +21 -3
- package/dist/react-native.cjs +257 -30
- package/dist/react-native.js +258 -30
- package/dist/types/rn/OverlappingCardsScrollRN.native.d.ts +4 -26
- package/dist/types/rn/OverlappingCardsScrollRN.types.d.ts +74 -0
- package/dist/types/rn/OverlappingCardsScrollRN.web.d.ts +4 -18
- package/package.json +8 -3
- package/src/lib/OverlappingCardsScroll.css +206 -0
- package/src/lib/OverlappingCardsScroll.tsx +943 -0
- package/src/lib/index.ts +10 -0
- package/src/rn/OverlappingCardsScrollRN.native.tsx +868 -0
- package/src/rn/OverlappingCardsScrollRN.types.ts +102 -0
- package/src/rn/OverlappingCardsScrollRN.web.tsx +90 -0
- package/src/rn/RNWebDemo.tsx +241 -0
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
Fragment,
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import type React from 'react'
|
|
13
|
+
import type { ComponentProps, ComponentType, CSSProperties, ReactElement, ReactNode } from 'react'
|
|
14
|
+
import './OverlappingCardsScroll.css'
|
|
15
|
+
|
|
16
|
+
export interface CardItem {
|
|
17
|
+
name: string
|
|
18
|
+
id: string | number
|
|
19
|
+
jsx: ReactElement
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OverlappingCardsScrollTabProps {
|
|
23
|
+
name: string
|
|
24
|
+
index: number
|
|
25
|
+
position: 'above' | 'below'
|
|
26
|
+
isPrincipal: boolean
|
|
27
|
+
influence: number
|
|
28
|
+
animate: {
|
|
29
|
+
opacity: number
|
|
30
|
+
}
|
|
31
|
+
className: string
|
|
32
|
+
style: CSSProperties
|
|
33
|
+
ariaLabel: string
|
|
34
|
+
ariaCurrent?: 'page'
|
|
35
|
+
onClick: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OverlappingCardsScrollTabsContainerProps {
|
|
39
|
+
children: ReactNode
|
|
40
|
+
position: 'above' | 'below'
|
|
41
|
+
className: string
|
|
42
|
+
style: CSSProperties
|
|
43
|
+
ariaLabel: string
|
|
44
|
+
cardNames: string[]
|
|
45
|
+
activeIndex: number
|
|
46
|
+
progress: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type SharedProps = {
|
|
50
|
+
className?: string
|
|
51
|
+
cardHeight?: number | string
|
|
52
|
+
cardWidth?: number | string
|
|
53
|
+
cardWidthRatio?: number
|
|
54
|
+
basePeek?: number
|
|
55
|
+
minPeek?: number
|
|
56
|
+
maxPeek?: number
|
|
57
|
+
showPageDots?: boolean
|
|
58
|
+
pageDotsPosition?: 'above' | 'below' | 'overlay'
|
|
59
|
+
pageDotsOffset?: number | string
|
|
60
|
+
pageDotsBehavior?: 'smooth' | 'auto'
|
|
61
|
+
pageDotsClassName?: string
|
|
62
|
+
cardContainerClassName?: string
|
|
63
|
+
cardContainerStyle?: CSSProperties
|
|
64
|
+
snapToCardOnRelease?: boolean
|
|
65
|
+
snapReleaseDelay?: number
|
|
66
|
+
focusTransitionDuration?: number
|
|
67
|
+
ariaLabel?: string
|
|
68
|
+
showTabs?: boolean
|
|
69
|
+
tabsPosition?: 'above' | 'below'
|
|
70
|
+
tabsOffset?: number | string
|
|
71
|
+
tabsBehavior?: 'smooth' | 'auto'
|
|
72
|
+
tabsClassName?: string
|
|
73
|
+
tabsComponent?: ComponentType<OverlappingCardsScrollTabProps>
|
|
74
|
+
tabsContainerComponent?: ComponentType<OverlappingCardsScrollTabsContainerProps>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type WithChildren = SharedProps & {
|
|
78
|
+
children: ReactNode
|
|
79
|
+
items?: never
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type WithItems = SharedProps & {
|
|
83
|
+
items: CardItem[]
|
|
84
|
+
children?: never
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type OverlappingCardsScrollProps = WithChildren | WithItems
|
|
88
|
+
|
|
89
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
|
90
|
+
|
|
91
|
+
const toCssDimension = (value) => (typeof value === 'number' ? `${value}px` : value)
|
|
92
|
+
const PAGE_DOT_POSITIONS = new Set(['above', 'below', 'overlay'])
|
|
93
|
+
|
|
94
|
+
const normalizePageDotsPosition = (value) =>
|
|
95
|
+
PAGE_DOT_POSITIONS.has(value) ? value : 'below'
|
|
96
|
+
|
|
97
|
+
const TAB_POSITIONS = new Set(['above', 'below'])
|
|
98
|
+
|
|
99
|
+
const normalizeTabsPosition = (value) =>
|
|
100
|
+
TAB_POSITIONS.has(value) ? value : 'above'
|
|
101
|
+
|
|
102
|
+
function DefaultTabsContainerComponent({
|
|
103
|
+
children,
|
|
104
|
+
className,
|
|
105
|
+
style,
|
|
106
|
+
ariaLabel,
|
|
107
|
+
}: OverlappingCardsScrollTabsContainerProps) {
|
|
108
|
+
return (
|
|
109
|
+
<nav className={className} style={style} aria-label={ariaLabel}>
|
|
110
|
+
{children}
|
|
111
|
+
</nav>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function DefaultTabsComponent({
|
|
116
|
+
name,
|
|
117
|
+
className,
|
|
118
|
+
style,
|
|
119
|
+
ariaLabel,
|
|
120
|
+
ariaCurrent,
|
|
121
|
+
onClick,
|
|
122
|
+
}: OverlappingCardsScrollTabProps) {
|
|
123
|
+
return (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
className={className}
|
|
127
|
+
aria-label={ariaLabel}
|
|
128
|
+
aria-current={ariaCurrent}
|
|
129
|
+
onClick={onClick}
|
|
130
|
+
style={style}
|
|
131
|
+
>
|
|
132
|
+
{name}
|
|
133
|
+
</button>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const resolveCardX = (index, principalIndex, transitionProgress, layout) => {
|
|
138
|
+
if (index <= principalIndex) {
|
|
139
|
+
return index * layout.peek
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let cardX =
|
|
143
|
+
principalIndex * layout.peek +
|
|
144
|
+
layout.cardWidth +
|
|
145
|
+
(index - principalIndex - 1) * layout.peek
|
|
146
|
+
|
|
147
|
+
if (index === principalIndex + 1) {
|
|
148
|
+
cardX -= transitionProgress * (layout.cardWidth - layout.peek)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return cardX
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const OverlappingCardsScrollControllerContext = createContext(null)
|
|
155
|
+
const OverlappingCardsScrollCardIndexContext = createContext(null)
|
|
156
|
+
|
|
157
|
+
function useOverlappingCardsScrollCardControl() {
|
|
158
|
+
const controller = useContext(OverlappingCardsScrollControllerContext)
|
|
159
|
+
const cardIndex = useContext(OverlappingCardsScrollCardIndexContext)
|
|
160
|
+
|
|
161
|
+
const canFocus = controller !== null && cardIndex !== null
|
|
162
|
+
const focusCard = useCallback(
|
|
163
|
+
(options: { behavior?: string; transitionMode?: string; duration?: number } = {}) => {
|
|
164
|
+
if (!canFocus) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
controller.focusCard(cardIndex, options)
|
|
168
|
+
},
|
|
169
|
+
[canFocus, cardIndex, controller],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
cardIndex,
|
|
174
|
+
canFocus,
|
|
175
|
+
focusCard,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface OverlappingCardsScrollFocusTriggerProps
|
|
180
|
+
extends Omit<ComponentProps<'button'>, 'onClick'> {
|
|
181
|
+
children?: ReactNode
|
|
182
|
+
className?: string
|
|
183
|
+
behavior?: 'smooth' | 'auto'
|
|
184
|
+
transitionMode?: 'swoop' | 'instant'
|
|
185
|
+
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function OverlappingCardsScrollFocusTrigger({
|
|
189
|
+
children = 'Make principal',
|
|
190
|
+
className = '',
|
|
191
|
+
behavior = 'smooth',
|
|
192
|
+
transitionMode = 'swoop',
|
|
193
|
+
onClick = undefined,
|
|
194
|
+
...buttonProps
|
|
195
|
+
}: OverlappingCardsScrollFocusTriggerProps) {
|
|
196
|
+
const { canFocus, focusCard } = useOverlappingCardsScrollCardControl()
|
|
197
|
+
|
|
198
|
+
const handleClick = (event) => {
|
|
199
|
+
onClick?.(event)
|
|
200
|
+
|
|
201
|
+
if (!event.defaultPrevented) {
|
|
202
|
+
focusCard({ behavior, transitionMode })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const buttonClassName = className
|
|
207
|
+
? `ocs-focus-trigger ${className}`
|
|
208
|
+
: 'ocs-focus-trigger'
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<button type="button" className={buttonClassName} disabled={!canFocus} onClick={handleClick} {...buttonProps}>
|
|
212
|
+
{children}
|
|
213
|
+
</button>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const resolveCardWidth = (cardWidth, viewportWidth, fallbackRatio) => {
|
|
218
|
+
if (typeof cardWidth === 'number' && Number.isFinite(cardWidth) && cardWidth > 0) {
|
|
219
|
+
return cardWidth
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (typeof cardWidth === 'string') {
|
|
223
|
+
const value = cardWidth.trim()
|
|
224
|
+
if (value.endsWith('%')) {
|
|
225
|
+
const percent = Number.parseFloat(value.slice(0, -1))
|
|
226
|
+
if (Number.isFinite(percent) && percent > 0) {
|
|
227
|
+
return (viewportWidth * percent) / 100
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const numeric = Number.parseFloat(value)
|
|
232
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
233
|
+
return numeric
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return viewportWidth * fallbackRatio
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
|
|
241
|
+
const {
|
|
242
|
+
className = '',
|
|
243
|
+
cardHeight = 300,
|
|
244
|
+
cardWidth = undefined,
|
|
245
|
+
cardWidthRatio = 1 / 3,
|
|
246
|
+
basePeek = 64,
|
|
247
|
+
minPeek = 10,
|
|
248
|
+
maxPeek = 84,
|
|
249
|
+
showPageDots = false,
|
|
250
|
+
pageDotsPosition = 'below',
|
|
251
|
+
pageDotsOffset = 10,
|
|
252
|
+
pageDotsBehavior = 'smooth',
|
|
253
|
+
pageDotsClassName = '',
|
|
254
|
+
cardContainerClassName = '',
|
|
255
|
+
cardContainerStyle = {},
|
|
256
|
+
snapToCardOnRelease = true,
|
|
257
|
+
snapReleaseDelay = 800,
|
|
258
|
+
focusTransitionDuration = 420,
|
|
259
|
+
ariaLabel = 'Overlapping cards scroll',
|
|
260
|
+
showTabs = false,
|
|
261
|
+
tabsPosition = 'above',
|
|
262
|
+
tabsOffset = 10,
|
|
263
|
+
tabsBehavior = 'smooth',
|
|
264
|
+
tabsClassName = '',
|
|
265
|
+
tabsComponent: TabsComponent = DefaultTabsComponent,
|
|
266
|
+
tabsContainerComponent: TabsContainerComponent = DefaultTabsContainerComponent,
|
|
267
|
+
} = props
|
|
268
|
+
|
|
269
|
+
const hasItems = 'items' in props && Array.isArray(props.items)
|
|
270
|
+
const hasChildren = 'children' in props && props.children != null
|
|
271
|
+
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (hasItems && hasChildren) {
|
|
274
|
+
console.warn(
|
|
275
|
+
'OverlappingCardsScroll: Both `items` and `children` were provided. `items` takes precedence.'
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
}, [hasItems, hasChildren])
|
|
279
|
+
|
|
280
|
+
const itemsProp = hasItems ? props.items : null
|
|
281
|
+
const childrenProp = hasChildren ? props.children : null
|
|
282
|
+
|
|
283
|
+
const cards = useMemo(() => {
|
|
284
|
+
if (itemsProp) {
|
|
285
|
+
return itemsProp.map((item) => (
|
|
286
|
+
<Fragment key={item.id}>{item.jsx}</Fragment>
|
|
287
|
+
))
|
|
288
|
+
}
|
|
289
|
+
return Children.toArray(childrenProp) as ReactElement[]
|
|
290
|
+
}, [itemsProp, childrenProp])
|
|
291
|
+
|
|
292
|
+
const cardNames: string[] | null = useMemo(() => {
|
|
293
|
+
if (itemsProp) {
|
|
294
|
+
return itemsProp.map((item) => item.name)
|
|
295
|
+
}
|
|
296
|
+
return null
|
|
297
|
+
}, [itemsProp])
|
|
298
|
+
|
|
299
|
+
const cardCount = cards.length
|
|
300
|
+
|
|
301
|
+
const containerRef = useRef(null)
|
|
302
|
+
const scrollRef = useRef(null)
|
|
303
|
+
const touchStateRef = useRef(null)
|
|
304
|
+
const snapTimeoutRef = useRef(null)
|
|
305
|
+
const shouldSnapOnMouseMoveRef = useRef(false)
|
|
306
|
+
const focusTransitionTimeoutRef = useRef(null)
|
|
307
|
+
|
|
308
|
+
const [viewportWidth, setViewportWidth] = useState(1)
|
|
309
|
+
const [scrollLeft, setScrollLeft] = useState(0)
|
|
310
|
+
const [focusTransition, setFocusTransition] = useState(null)
|
|
311
|
+
|
|
312
|
+
const clearSnapTimeout = useCallback(() => {
|
|
313
|
+
if (snapTimeoutRef.current !== null) {
|
|
314
|
+
clearTimeout(snapTimeoutRef.current)
|
|
315
|
+
snapTimeoutRef.current = null
|
|
316
|
+
}
|
|
317
|
+
}, [])
|
|
318
|
+
|
|
319
|
+
const clearFocusTransitionTimeout = useCallback(() => {
|
|
320
|
+
if (focusTransitionTimeoutRef.current !== null) {
|
|
321
|
+
clearTimeout(focusTransitionTimeoutRef.current)
|
|
322
|
+
focusTransitionTimeoutRef.current = null
|
|
323
|
+
}
|
|
324
|
+
}, [])
|
|
325
|
+
|
|
326
|
+
const cancelFocusTransition = useCallback(() => {
|
|
327
|
+
clearFocusTransitionTimeout()
|
|
328
|
+
setFocusTransition(null)
|
|
329
|
+
}, [clearFocusTransitionTimeout])
|
|
330
|
+
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
const containerElement = containerRef.current
|
|
333
|
+
const scrollElement = scrollRef.current
|
|
334
|
+
if (!containerElement || !scrollElement) {
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const syncScroll = () => {
|
|
339
|
+
setScrollLeft(scrollElement.scrollLeft)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
343
|
+
const entry = entries[0]
|
|
344
|
+
const width = entry?.contentRect?.width ?? 1
|
|
345
|
+
setViewportWidth(Math.max(1, width))
|
|
346
|
+
syncScroll()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
resizeObserver.observe(containerElement)
|
|
350
|
+
setViewportWidth(Math.max(1, containerElement.getBoundingClientRect().width || 1))
|
|
351
|
+
syncScroll()
|
|
352
|
+
|
|
353
|
+
scrollElement.addEventListener('scroll', syncScroll, { passive: true })
|
|
354
|
+
|
|
355
|
+
return () => {
|
|
356
|
+
resizeObserver.disconnect()
|
|
357
|
+
scrollElement.removeEventListener('scroll', syncScroll)
|
|
358
|
+
}
|
|
359
|
+
}, [])
|
|
360
|
+
|
|
361
|
+
useEffect(() => () => clearSnapTimeout(), [clearSnapTimeout])
|
|
362
|
+
useEffect(() => () => clearFocusTransitionTimeout(), [clearFocusTransitionTimeout])
|
|
363
|
+
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
if (snapToCardOnRelease && cardCount > 1) {
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
clearSnapTimeout()
|
|
370
|
+
shouldSnapOnMouseMoveRef.current = false
|
|
371
|
+
cancelFocusTransition()
|
|
372
|
+
}, [cancelFocusTransition, cardCount, clearSnapTimeout, snapToCardOnRelease])
|
|
373
|
+
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (cardCount > 1) {
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
cancelFocusTransition()
|
|
380
|
+
}, [cancelFocusTransition, cardCount])
|
|
381
|
+
|
|
382
|
+
const layout = useMemo(() => {
|
|
383
|
+
const safeWidth = Math.max(1, viewportWidth)
|
|
384
|
+
const safeRatio = clamp(cardWidthRatio, 0.2, 0.95)
|
|
385
|
+
const width = Math.max(1, resolveCardWidth(cardWidth, safeWidth, safeRatio))
|
|
386
|
+
|
|
387
|
+
if (cardCount < 2) {
|
|
388
|
+
return {
|
|
389
|
+
cardWidth: width,
|
|
390
|
+
peek: 0,
|
|
391
|
+
stepDistance: 1,
|
|
392
|
+
scrollRange: 0,
|
|
393
|
+
trackWidth: safeWidth,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const availableStackWidth = Math.max(0, safeWidth - width)
|
|
398
|
+
const maxVisiblePeek = availableStackWidth / (cardCount - 1)
|
|
399
|
+
const preferredPeek = clamp(basePeek, minPeek, maxPeek)
|
|
400
|
+
const peek = Math.min(preferredPeek, maxVisiblePeek)
|
|
401
|
+
|
|
402
|
+
const stepDistance = Math.max(1, width - peek)
|
|
403
|
+
const scrollRange = stepDistance * (cardCount - 1)
|
|
404
|
+
const trackWidth = safeWidth + scrollRange
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
cardWidth: width,
|
|
408
|
+
peek,
|
|
409
|
+
stepDistance,
|
|
410
|
+
scrollRange,
|
|
411
|
+
trackWidth,
|
|
412
|
+
}
|
|
413
|
+
}, [basePeek, cardCount, cardWidth, cardWidthRatio, maxPeek, minPeek, viewportWidth])
|
|
414
|
+
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
const scrollElement = scrollRef.current
|
|
417
|
+
if (!scrollElement) {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (scrollElement.scrollLeft > layout.scrollRange) {
|
|
422
|
+
scrollElement.scrollLeft = layout.scrollRange
|
|
423
|
+
setScrollLeft(layout.scrollRange)
|
|
424
|
+
}
|
|
425
|
+
}, [layout.scrollRange])
|
|
426
|
+
|
|
427
|
+
const progress = cardCount > 1 ? clamp(scrollLeft / layout.stepDistance, 0, cardCount - 1) : 0
|
|
428
|
+
const activeIndex = Math.floor(progress)
|
|
429
|
+
const transitionProgress = progress - activeIndex
|
|
430
|
+
|
|
431
|
+
const snapToNearestCard = useCallback(
|
|
432
|
+
(options: { behavior?: string } = {}) => {
|
|
433
|
+
if (!snapToCardOnRelease || cardCount < 2) {
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const scrollElement = scrollRef.current
|
|
438
|
+
if (!scrollElement) {
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const currentScrollLeft = clamp(scrollElement.scrollLeft, 0, layout.scrollRange)
|
|
443
|
+
const nearestIndex = clamp(
|
|
444
|
+
Math.round(currentScrollLeft / layout.stepDistance),
|
|
445
|
+
0,
|
|
446
|
+
cardCount - 1,
|
|
447
|
+
)
|
|
448
|
+
const targetScrollLeft = clamp(
|
|
449
|
+
nearestIndex * layout.stepDistance,
|
|
450
|
+
0,
|
|
451
|
+
layout.scrollRange,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if (Math.abs(targetScrollLeft - currentScrollLeft) < 1) {
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const behavior = options.behavior ?? 'smooth'
|
|
459
|
+
if (typeof scrollElement.scrollTo === 'function') {
|
|
460
|
+
scrollElement.scrollTo({
|
|
461
|
+
left: targetScrollLeft,
|
|
462
|
+
behavior: behavior as ScrollBehavior,
|
|
463
|
+
})
|
|
464
|
+
} else {
|
|
465
|
+
scrollElement.scrollLeft = targetScrollLeft
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (behavior === 'auto') {
|
|
469
|
+
setScrollLeft(targetScrollLeft)
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
[cardCount, layout.scrollRange, layout.stepDistance, snapToCardOnRelease],
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
const scheduleSnapToNearestCard = useCallback(
|
|
476
|
+
(delay = snapReleaseDelay) => {
|
|
477
|
+
if (!snapToCardOnRelease || cardCount < 2) {
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const safeDelay = Number.isFinite(delay) ? Math.max(0, delay) : 800
|
|
482
|
+
clearSnapTimeout()
|
|
483
|
+
snapTimeoutRef.current = setTimeout(() => {
|
|
484
|
+
snapTimeoutRef.current = null
|
|
485
|
+
shouldSnapOnMouseMoveRef.current = false
|
|
486
|
+
snapToNearestCard({ behavior: 'smooth' })
|
|
487
|
+
}, safeDelay)
|
|
488
|
+
},
|
|
489
|
+
[cardCount, clearSnapTimeout, snapReleaseDelay, snapToCardOnRelease, snapToNearestCard],
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
const markSnapCandidateFromScroll = useCallback(() => {
|
|
493
|
+
if (!snapToCardOnRelease || cardCount < 2) {
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
shouldSnapOnMouseMoveRef.current = true
|
|
498
|
+
scheduleSnapToNearestCard()
|
|
499
|
+
}, [cardCount, scheduleSnapToNearestCard, snapToCardOnRelease])
|
|
500
|
+
|
|
501
|
+
useEffect(() => {
|
|
502
|
+
if (typeof window === 'undefined' || !snapToCardOnRelease || cardCount < 2) {
|
|
503
|
+
return undefined
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const handleMouseMove = () => {
|
|
507
|
+
if (!shouldSnapOnMouseMoveRef.current) {
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
shouldSnapOnMouseMoveRef.current = false
|
|
512
|
+
clearSnapTimeout()
|
|
513
|
+
snapToNearestCard({ behavior: 'smooth' })
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
window.addEventListener('mousemove', handleMouseMove, { passive: true })
|
|
517
|
+
return () => {
|
|
518
|
+
window.removeEventListener('mousemove', handleMouseMove)
|
|
519
|
+
}
|
|
520
|
+
}, [cardCount, clearSnapTimeout, snapToCardOnRelease, snapToNearestCard])
|
|
521
|
+
|
|
522
|
+
const focusCard = useCallback(
|
|
523
|
+
(
|
|
524
|
+
targetIndex: number,
|
|
525
|
+
options: { behavior?: string; transitionMode?: string; duration?: number } = {},
|
|
526
|
+
) => {
|
|
527
|
+
const scrollElement = scrollRef.current
|
|
528
|
+
if (!scrollElement || cardCount === 0) {
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
clearSnapTimeout()
|
|
533
|
+
shouldSnapOnMouseMoveRef.current = false
|
|
534
|
+
cancelFocusTransition()
|
|
535
|
+
|
|
536
|
+
const safeIndex = clamp(Math.round(targetIndex), 0, cardCount - 1)
|
|
537
|
+
const nextScrollLeft = clamp(safeIndex * layout.stepDistance, 0, layout.scrollRange)
|
|
538
|
+
const transitionMode = options.transitionMode ?? 'swoop'
|
|
539
|
+
|
|
540
|
+
if (transitionMode === 'swoop') {
|
|
541
|
+
const duration = Number.isFinite(options.duration)
|
|
542
|
+
? Math.max(0, options.duration)
|
|
543
|
+
: focusTransitionDuration
|
|
544
|
+
|
|
545
|
+
clearFocusTransitionTimeout()
|
|
546
|
+
setFocusTransition({ duration })
|
|
547
|
+
|
|
548
|
+
scrollElement.scrollLeft = nextScrollLeft
|
|
549
|
+
setScrollLeft(nextScrollLeft)
|
|
550
|
+
|
|
551
|
+
if (duration <= 0) {
|
|
552
|
+
setFocusTransition(null)
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
focusTransitionTimeoutRef.current = setTimeout(() => {
|
|
557
|
+
focusTransitionTimeoutRef.current = null
|
|
558
|
+
setFocusTransition(null)
|
|
559
|
+
}, duration + 40)
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (typeof scrollElement.scrollTo === 'function') {
|
|
564
|
+
scrollElement.scrollTo({
|
|
565
|
+
left: nextScrollLeft,
|
|
566
|
+
behavior: (options.behavior ?? 'smooth') as ScrollBehavior,
|
|
567
|
+
})
|
|
568
|
+
} else {
|
|
569
|
+
scrollElement.scrollLeft = nextScrollLeft
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if ((options.behavior ?? 'smooth') === 'auto') {
|
|
573
|
+
setScrollLeft(nextScrollLeft)
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
[
|
|
577
|
+
cardCount,
|
|
578
|
+
cancelFocusTransition,
|
|
579
|
+
clearFocusTransitionTimeout,
|
|
580
|
+
clearSnapTimeout,
|
|
581
|
+
focusTransitionDuration,
|
|
582
|
+
layout.scrollRange,
|
|
583
|
+
layout.stepDistance,
|
|
584
|
+
],
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
const controllerContextValue = useMemo(
|
|
588
|
+
() => ({
|
|
589
|
+
focusCard,
|
|
590
|
+
}),
|
|
591
|
+
[focusCard],
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
const setControllerScroll = useCallback(
|
|
595
|
+
(nextValue) => {
|
|
596
|
+
const scrollElement = scrollRef.current
|
|
597
|
+
if (!scrollElement) {
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const nextScrollLeft = clamp(nextValue, 0, layout.scrollRange)
|
|
602
|
+
if (scrollElement.scrollLeft !== nextScrollLeft) {
|
|
603
|
+
scrollElement.scrollLeft = nextScrollLeft
|
|
604
|
+
}
|
|
605
|
+
setScrollLeft(nextScrollLeft)
|
|
606
|
+
},
|
|
607
|
+
[layout.scrollRange],
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
const applyScrollDelta = useCallback(
|
|
611
|
+
(delta) => {
|
|
612
|
+
const scrollElement = scrollRef.current
|
|
613
|
+
if (!scrollElement) {
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
setControllerScroll(scrollElement.scrollLeft + delta)
|
|
618
|
+
},
|
|
619
|
+
[setControllerScroll],
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
const handleWheel = useCallback(
|
|
623
|
+
(event: WheelEvent) => {
|
|
624
|
+
if (cardCount < 2) {
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const absX = Math.abs(event.deltaX)
|
|
629
|
+
const absY = Math.abs(event.deltaY)
|
|
630
|
+
|
|
631
|
+
if (absX === 0 && absY === 0) {
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Let vertical-dominant scrolls pass through to child scrollables
|
|
636
|
+
if (absY > absX) {
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
event.preventDefault()
|
|
641
|
+
cancelFocusTransition()
|
|
642
|
+
applyScrollDelta(event.deltaX)
|
|
643
|
+
markSnapCandidateFromScroll()
|
|
644
|
+
},
|
|
645
|
+
[cardCount, cancelFocusTransition, applyScrollDelta, markSnapCandidateFromScroll],
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
const handleTouchStart = (event) => {
|
|
649
|
+
if (cardCount < 2) {
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const scrollElement = scrollRef.current
|
|
654
|
+
const touch = event.touches[0]
|
|
655
|
+
if (!scrollElement || !touch) {
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
cancelFocusTransition()
|
|
660
|
+
|
|
661
|
+
touchStateRef.current = {
|
|
662
|
+
startX: touch.clientX,
|
|
663
|
+
startScrollLeft: scrollElement.scrollLeft,
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const handleTouchMove = (event) => {
|
|
668
|
+
const touchState = touchStateRef.current
|
|
669
|
+
const touch = event.touches[0]
|
|
670
|
+
if (!touchState || !touch) {
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const delta = touchState.startX - touch.clientX
|
|
675
|
+
if (Math.abs(delta) < 2) {
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
event.preventDefault()
|
|
680
|
+
setControllerScroll(touchState.startScrollLeft + delta)
|
|
681
|
+
markSnapCandidateFromScroll()
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const handleTouchEnd = () => {
|
|
685
|
+
if (touchStateRef.current && snapToCardOnRelease && cardCount > 1) {
|
|
686
|
+
scheduleSnapToNearestCard(80)
|
|
687
|
+
}
|
|
688
|
+
touchStateRef.current = null
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const stageRef = useRef(null)
|
|
692
|
+
|
|
693
|
+
useEffect(() => {
|
|
694
|
+
const stageElement = stageRef.current
|
|
695
|
+
if (!stageElement) {
|
|
696
|
+
return undefined
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
stageElement.addEventListener('wheel', handleWheel, { passive: false })
|
|
700
|
+
return () => {
|
|
701
|
+
stageElement.removeEventListener('wheel', handleWheel)
|
|
702
|
+
}
|
|
703
|
+
}, [handleWheel])
|
|
704
|
+
|
|
705
|
+
const containerClassName = className
|
|
706
|
+
? `overlapping-cards-scroll ${className}`
|
|
707
|
+
: 'overlapping-cards-scroll'
|
|
708
|
+
const resolvedPageDotsPosition = normalizePageDotsPosition(pageDotsPosition)
|
|
709
|
+
const showNavigationDots = showPageDots && cardCount > 1
|
|
710
|
+
|
|
711
|
+
const resolvedTabsPosition = normalizeTabsPosition(tabsPosition)
|
|
712
|
+
const showNavigationTabs = showTabs && cardCount > 1 && cardNames !== null
|
|
713
|
+
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
if (showTabs && cardNames === null) {
|
|
716
|
+
console.warn(
|
|
717
|
+
'OverlappingCardsScroll: `showTabs` requires the `items` prop to provide card names. Tabs will not render.'
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
}, [showTabs, cardNames])
|
|
721
|
+
|
|
722
|
+
const renderTabs = (position: 'above' | 'below') => {
|
|
723
|
+
if (!showNavigationTabs || cardNames === null) {
|
|
724
|
+
return null
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const containerClassName = tabsClassName
|
|
728
|
+
? `ocs-tabs ocs-tabs--${position} ${tabsClassName}`
|
|
729
|
+
: `ocs-tabs ocs-tabs--${position}`
|
|
730
|
+
|
|
731
|
+
const containerStyle =
|
|
732
|
+
position === 'above'
|
|
733
|
+
? { marginBottom: toCssDimension(tabsOffset) }
|
|
734
|
+
: { marginTop: toCssDimension(tabsOffset) }
|
|
735
|
+
|
|
736
|
+
return (
|
|
737
|
+
<TabsContainerComponent
|
|
738
|
+
position={position}
|
|
739
|
+
className={containerClassName}
|
|
740
|
+
style={containerStyle}
|
|
741
|
+
ariaLabel="Card tabs"
|
|
742
|
+
cardNames={cardNames}
|
|
743
|
+
activeIndex={activeIndex}
|
|
744
|
+
progress={progress}
|
|
745
|
+
>
|
|
746
|
+
{cardNames.map((name, index) => {
|
|
747
|
+
const influence = clamp(1 - Math.abs(progress - index), 0, 1)
|
|
748
|
+
const isPrincipal = influence > 0.98
|
|
749
|
+
const animate = {
|
|
750
|
+
opacity: 0.45 + influence * 0.55,
|
|
751
|
+
}
|
|
752
|
+
const className = isPrincipal ? 'ocs-tab ocs-tab--active' : 'ocs-tab'
|
|
753
|
+
const style = { opacity: animate.opacity }
|
|
754
|
+
|
|
755
|
+
return (
|
|
756
|
+
<TabsComponent
|
|
757
|
+
key={`ocs-tab-${position}-${index}`}
|
|
758
|
+
name={name}
|
|
759
|
+
index={index}
|
|
760
|
+
position={position}
|
|
761
|
+
isPrincipal={isPrincipal}
|
|
762
|
+
influence={influence}
|
|
763
|
+
animate={animate}
|
|
764
|
+
className={className}
|
|
765
|
+
style={style}
|
|
766
|
+
ariaLabel={`Go to ${name}`}
|
|
767
|
+
ariaCurrent={isPrincipal ? 'page' : undefined}
|
|
768
|
+
onClick={() =>
|
|
769
|
+
focusCard(index, {
|
|
770
|
+
behavior: tabsBehavior,
|
|
771
|
+
transitionMode: 'swoop',
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
/>
|
|
775
|
+
)
|
|
776
|
+
})}
|
|
777
|
+
</TabsContainerComponent>
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
<OverlappingCardsScrollControllerContext.Provider value={controllerContextValue}>
|
|
783
|
+
<section className={containerClassName} aria-label={ariaLabel} ref={containerRef}>
|
|
784
|
+
{resolvedTabsPosition === 'above' ? renderTabs('above') : null}
|
|
785
|
+
{showNavigationDots && resolvedPageDotsPosition === 'above' ? (
|
|
786
|
+
<nav
|
|
787
|
+
className={
|
|
788
|
+
pageDotsClassName
|
|
789
|
+
? `ocs-page-dots ocs-page-dots--above ${pageDotsClassName}`
|
|
790
|
+
: 'ocs-page-dots ocs-page-dots--above'
|
|
791
|
+
}
|
|
792
|
+
style={{ marginBottom: toCssDimension(pageDotsOffset) }}
|
|
793
|
+
aria-label="Card pages"
|
|
794
|
+
>
|
|
795
|
+
{cards.map((_, index) => {
|
|
796
|
+
const influence = clamp(1 - Math.abs(progress - index), 0, 1)
|
|
797
|
+
const opacity = 0.25 + influence * 0.75
|
|
798
|
+
const scale = 0.9 + influence * 0.22
|
|
799
|
+
|
|
800
|
+
return (
|
|
801
|
+
<button
|
|
802
|
+
key={`ocs-page-dot-above-${index}`}
|
|
803
|
+
type="button"
|
|
804
|
+
className="ocs-page-dot"
|
|
805
|
+
aria-label={`Go to card ${index + 1}`}
|
|
806
|
+
aria-current={influence > 0.98 ? 'page' : undefined}
|
|
807
|
+
onClick={() =>
|
|
808
|
+
focusCard(index, {
|
|
809
|
+
behavior: pageDotsBehavior,
|
|
810
|
+
transitionMode: 'swoop',
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
style={{ opacity, transform: `scale(${scale})` }}
|
|
814
|
+
/>
|
|
815
|
+
)
|
|
816
|
+
})}
|
|
817
|
+
</nav>
|
|
818
|
+
) : null}
|
|
819
|
+
<div className="ocs-stage-frame">
|
|
820
|
+
<div
|
|
821
|
+
className="ocs-stage"
|
|
822
|
+
ref={stageRef}
|
|
823
|
+
style={{
|
|
824
|
+
minHeight: toCssDimension(cardHeight),
|
|
825
|
+
}}
|
|
826
|
+
onTouchStart={handleTouchStart}
|
|
827
|
+
onTouchMove={handleTouchMove}
|
|
828
|
+
onTouchEnd={handleTouchEnd}
|
|
829
|
+
onTouchCancel={handleTouchEnd}
|
|
830
|
+
>
|
|
831
|
+
<div
|
|
832
|
+
className="ocs-track"
|
|
833
|
+
>
|
|
834
|
+
{cards.map((card, index) => {
|
|
835
|
+
const cardX = resolveCardX(index, activeIndex, transitionProgress, layout)
|
|
836
|
+
|
|
837
|
+
return (
|
|
838
|
+
<div
|
|
839
|
+
key={card.key ?? `ocs-card-${index}`}
|
|
840
|
+
className={
|
|
841
|
+
cardContainerClassName
|
|
842
|
+
? `${focusTransition ? 'ocs-card ocs-card--focus-transition' : 'ocs-card'} ${cardContainerClassName}`
|
|
843
|
+
: focusTransition
|
|
844
|
+
? 'ocs-card ocs-card--focus-transition'
|
|
845
|
+
: 'ocs-card'
|
|
846
|
+
}
|
|
847
|
+
style={{
|
|
848
|
+
width: `${layout.cardWidth}px`,
|
|
849
|
+
transform: `translate3d(${cardX}px, 0, 0)`,
|
|
850
|
+
transitionDuration: focusTransition ? `${focusTransition.duration}ms` : undefined,
|
|
851
|
+
...cardContainerStyle,
|
|
852
|
+
}}
|
|
853
|
+
>
|
|
854
|
+
<OverlappingCardsScrollCardIndexContext.Provider value={index}>
|
|
855
|
+
{card}
|
|
856
|
+
</OverlappingCardsScrollCardIndexContext.Provider>
|
|
857
|
+
</div>
|
|
858
|
+
)
|
|
859
|
+
})}
|
|
860
|
+
</div>
|
|
861
|
+
<div className="ocs-scroll-region" ref={scrollRef}>
|
|
862
|
+
<div
|
|
863
|
+
className="ocs-scroll-spacer"
|
|
864
|
+
style={{
|
|
865
|
+
width: `${layout.trackWidth}px`,
|
|
866
|
+
}}
|
|
867
|
+
/>
|
|
868
|
+
</div>
|
|
869
|
+
</div>
|
|
870
|
+
{showNavigationDots && resolvedPageDotsPosition === 'overlay' ? (
|
|
871
|
+
<nav
|
|
872
|
+
className={
|
|
873
|
+
pageDotsClassName
|
|
874
|
+
? `ocs-page-dots ocs-page-dots--overlay ${pageDotsClassName}`
|
|
875
|
+
: 'ocs-page-dots ocs-page-dots--overlay'
|
|
876
|
+
}
|
|
877
|
+
style={{ bottom: toCssDimension(pageDotsOffset) }}
|
|
878
|
+
aria-label="Card pages"
|
|
879
|
+
>
|
|
880
|
+
{cards.map((_, index) => {
|
|
881
|
+
const influence = clamp(1 - Math.abs(progress - index), 0, 1)
|
|
882
|
+
const opacity = 0.25 + influence * 0.75
|
|
883
|
+
const scale = 0.9 + influence * 0.22
|
|
884
|
+
|
|
885
|
+
return (
|
|
886
|
+
<button
|
|
887
|
+
key={`ocs-page-dot-overlay-${index}`}
|
|
888
|
+
type="button"
|
|
889
|
+
className="ocs-page-dot"
|
|
890
|
+
aria-label={`Go to card ${index + 1}`}
|
|
891
|
+
aria-current={influence > 0.98 ? 'page' : undefined}
|
|
892
|
+
onClick={() =>
|
|
893
|
+
focusCard(index, {
|
|
894
|
+
behavior: pageDotsBehavior,
|
|
895
|
+
transitionMode: 'swoop',
|
|
896
|
+
})
|
|
897
|
+
}
|
|
898
|
+
style={{ opacity, transform: `scale(${scale})` }}
|
|
899
|
+
/>
|
|
900
|
+
)
|
|
901
|
+
})}
|
|
902
|
+
</nav>
|
|
903
|
+
) : null}
|
|
904
|
+
</div>
|
|
905
|
+
{showNavigationDots && resolvedPageDotsPosition === 'below' ? (
|
|
906
|
+
<nav
|
|
907
|
+
className={
|
|
908
|
+
pageDotsClassName
|
|
909
|
+
? `ocs-page-dots ocs-page-dots--below ${pageDotsClassName}`
|
|
910
|
+
: 'ocs-page-dots ocs-page-dots--below'
|
|
911
|
+
}
|
|
912
|
+
style={{ marginTop: toCssDimension(pageDotsOffset) }}
|
|
913
|
+
aria-label="Card pages"
|
|
914
|
+
>
|
|
915
|
+
{cards.map((_, index) => {
|
|
916
|
+
const influence = clamp(1 - Math.abs(progress - index), 0, 1)
|
|
917
|
+
const opacity = 0.25 + influence * 0.75
|
|
918
|
+
const scale = 0.9 + influence * 0.22
|
|
919
|
+
|
|
920
|
+
return (
|
|
921
|
+
<button
|
|
922
|
+
key={`ocs-page-dot-below-${index}`}
|
|
923
|
+
type="button"
|
|
924
|
+
className="ocs-page-dot"
|
|
925
|
+
aria-label={`Go to card ${index + 1}`}
|
|
926
|
+
aria-current={influence > 0.98 ? 'page' : undefined}
|
|
927
|
+
onClick={() =>
|
|
928
|
+
focusCard(index, {
|
|
929
|
+
behavior: pageDotsBehavior,
|
|
930
|
+
transitionMode: 'swoop',
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
style={{ opacity, transform: `scale(${scale})` }}
|
|
934
|
+
/>
|
|
935
|
+
)
|
|
936
|
+
})}
|
|
937
|
+
</nav>
|
|
938
|
+
) : null}
|
|
939
|
+
{resolvedTabsPosition === 'below' ? renderTabs('below') : null}
|
|
940
|
+
</section>
|
|
941
|
+
</OverlappingCardsScrollControllerContext.Provider>
|
|
942
|
+
)
|
|
943
|
+
}
|