174 lines
6.0 KiB
TypeScript
174 lines
6.0 KiB
TypeScript
import { useLayoutEffect, useRef, useState } from 'react'
|
|
import type { CSSProperties, RefObject } from 'react'
|
|
|
|
// useCardAnimations animates a keyed row of cards laid out in a flex container:
|
|
//
|
|
// • MOVE — when the order changes (dragging to reorder) or a neighbor is
|
|
// added/removed, the surviving cards glide from their old slot to the new
|
|
// one using the FLIP technique (measure, invert, play).
|
|
// • ENTER — a card that appears animates in. `data-enter="flip"` reads as the
|
|
// card being flipped face-up off the deck; `data-enter="pop"` springs it in.
|
|
// • EXIT — a card that disappears is pinned where it sat and shrinks away as
|
|
// a "ghost" (the live element is already gone, so React can't hold it), then
|
|
// is dropped once the exit finishes. Meanwhile the MOVE pass slides its
|
|
// former neighbors into the gap it left.
|
|
//
|
|
// Only children carrying `data-flip-key` take part; placeholders (empty shop
|
|
// slots) are ignored. The consumer renders `ghosts` inside the same container.
|
|
// Honors prefers-reduced-motion by recording positions but skipping animation.
|
|
|
|
const MOVE_MS = 300
|
|
const ENTER_FLIP_MS = 440
|
|
const ENTER_POP_MS = 320
|
|
const EXIT_MS = 280
|
|
const MOVE_EASE = 'cubic-bezier(0.34, 1.4, 0.5, 1)'
|
|
const ENTER_EASE = 'cubic-bezier(0.2, 0.8, 0.3, 1)'
|
|
const STAGGER_MS = 55
|
|
|
|
export interface Ghost<T> {
|
|
key: string
|
|
item: T
|
|
style: CSSProperties
|
|
}
|
|
|
|
interface Tracked<T> {
|
|
rect: DOMRect
|
|
item: T
|
|
}
|
|
|
|
function prefersReducedMotion(): boolean {
|
|
return (
|
|
typeof window !== 'undefined' &&
|
|
!!window.matchMedia &&
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
)
|
|
}
|
|
|
|
function enterFrames(style: string): Keyframe[] {
|
|
if (style === 'flip') {
|
|
return [
|
|
{ transform: 'perspective(700px) rotateY(-95deg)', opacity: 0, offset: 0 },
|
|
{ opacity: 1, offset: 0.4 },
|
|
{ transform: 'perspective(700px) rotateY(0deg)', opacity: 1, offset: 1 },
|
|
]
|
|
}
|
|
// 'pop'
|
|
return [
|
|
{ transform: 'scale(0.4) translateY(16px)', opacity: 0 },
|
|
{ transform: 'scale(1) translateY(0)', opacity: 1 },
|
|
]
|
|
}
|
|
|
|
// `animate` can be turned off transiently (e.g. while a drag is in progress) to
|
|
// reorder instantly without gliding; positions are still recorded so the next
|
|
// animated change starts from the right place.
|
|
export function useCardAnimations<T>(
|
|
items: T[],
|
|
keyOf: (item: T) => string,
|
|
animate = true,
|
|
): { containerRef: RefObject<HTMLDivElement | null>; ghosts: Ghost<T>[] } {
|
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
// Positions + items from the previous committed layout, keyed by card id.
|
|
const prev = useRef<Map<string, Tracked<T>>>(new Map())
|
|
// In-flight move animations, so a fast drag can cancel-and-replace instead of
|
|
// stacking transforms on the same card.
|
|
const moving = useRef<Map<string, Animation>>(new Map())
|
|
const [ghosts, setGhosts] = useState<Ghost<T>[]>([])
|
|
|
|
// Re-run only when membership or order actually changes — plain re-renders
|
|
// (an opponent acting, connector re-measurement) leave the signature alone.
|
|
const signature = items.map(keyOf).join(' |