|
| 1 | +import type { MotionValue } from 'framer-motion' |
| 2 | +import { motionValue, useTransform } from 'framer-motion' |
| 3 | +import { numberToPx } from '../utils/numberToPx' |
| 4 | +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' |
| 5 | +import type { Rect } from './useMotionRect' |
| 6 | +import { useMotionRect } from './useMotionRect' |
| 7 | +import { useMotionValueValue } from './useMotionValueValue' |
| 8 | + |
| 9 | +type StickyTo = string | null |
| 10 | +type Position = 'end' | 'center' |
| 11 | + |
| 12 | +export type StickyStackConfig<E extends HTMLElement> = { |
| 13 | + /** Ref for the element to make sticky */ |
| 14 | + ref: React.RefObject<E> |
| 15 | + /** Name for other sticky elements to provide the in the stickyTo value */ |
| 16 | + name: string |
| 17 | + /** If it is the first element in the stack, set this to null. */ |
| 18 | + to: StickyTo |
| 19 | + /** Whether the element should be sticky */ |
| 20 | + sticky?: boolean |
| 21 | + /** How to position the element relative to the stickyTo element */ |
| 22 | + position?: Position |
| 23 | +} |
| 24 | + |
| 25 | +type StickyItemMeasured = StickyStackConfig<HTMLElement> & { rect: Rect } |
| 26 | + |
| 27 | +/** |
| 28 | + * We're using a motionValue to store the global sticky stack. |
| 29 | + * |
| 30 | + * Any other state management solution with selectors would also work. |
| 31 | + */ |
| 32 | +const stickyContext = motionValue<Record<string, StickyItemMeasured>>({}) |
| 33 | + |
| 34 | +function get(stickyName: string | null) { |
| 35 | + return stickyName ? stickyContext.get()[stickyName] : undefined |
| 36 | +} |
| 37 | + |
| 38 | +function has(stickyName: string) { |
| 39 | + return stickyContext.get()[stickyName] !== undefined |
| 40 | +} |
| 41 | + |
| 42 | +function set<E extends HTMLElement>(options: StickyStackConfig<E>, rect: MotionValue<Rect>) { |
| 43 | + const { name, sticky = true, position = 'end' } = options |
| 44 | + const current = { ...stickyContext.get() } |
| 45 | + current[name] = { ...options, rect: rect.get(), sticky, position } |
| 46 | + stickyContext.set(current) |
| 47 | +} |
| 48 | + |
| 49 | +function del<E extends HTMLElement>(options: StickyStackConfig<E>) { |
| 50 | + const current = { ...stickyContext.get() } |
| 51 | + delete current[options.name] |
| 52 | + stickyContext.set(current) |
| 53 | +} |
| 54 | + |
| 55 | +function findStickTo(to: StickyTo) { |
| 56 | + const sticky = get(to) |
| 57 | + if (!sticky) return null |
| 58 | + if (sticky.sticky) return sticky |
| 59 | + return findStickTo(sticky.to) |
| 60 | +} |
| 61 | + |
| 62 | +/** |
| 63 | + * An hook that allows you to create a sticky stack of elements. |
| 64 | + * |
| 65 | + * It calculates the top value of an element based on the size of elements in the stack before it. |
| 66 | + */ |
| 67 | +export function useStickyTop<E extends HTMLElement>(config: StickyStackConfig<E>) { |
| 68 | + const rect = useMotionRect(config.ref) |
| 69 | + |
| 70 | + if (!has(config.name)) set(config, rect) |
| 71 | + |
| 72 | + useIsomorphicLayoutEffect(() => { |
| 73 | + const onChange = () => set(config, rect) |
| 74 | + onChange() |
| 75 | + rect.on('change', onChange) |
| 76 | + |
| 77 | + return () => del(config) |
| 78 | + }, [config, rect]) |
| 79 | + |
| 80 | + return useMotionValueValue( |
| 81 | + useTransform(() => { |
| 82 | + const getPosition = (self: StickyItemMeasured | undefined): number => { |
| 83 | + if (!self?.sticky) return 0 |
| 84 | + |
| 85 | + const to = findStickTo(self?.to) |
| 86 | + if (!to) return 0 |
| 87 | + |
| 88 | + const pos = self.position ?? 'end' |
| 89 | + switch (pos) { |
| 90 | + case 'center': |
| 91 | + return to.rect.height / 2 - self.rect.height / 2 + getPosition(to) |
| 92 | + case 'end': |
| 93 | + return (to.sticky ? to.rect.height : 0) + getPosition(to) |
| 94 | + default: |
| 95 | + throw new Error('Invalid position') |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + return getPosition(get(config.name)) |
| 100 | + }), |
| 101 | + (v) => v, |
| 102 | + ) |
| 103 | +} |
0 commit comments