diff --git a/src/components/ui/AnimatedCounter.tsx b/src/components/ui/AnimatedCounter.tsx new file mode 100644 index 0000000..10042a8 --- /dev/null +++ b/src/components/ui/AnimatedCounter.tsx @@ -0,0 +1,44 @@ +import { animate, useReducedMotion } from 'framer-motion' +import { useEffect, useRef, useState } from 'react' + +type AnimatedCounterProps = { + value: number + format?: (value: number) => string + className?: string +} + +// Tweens the displayed number from its previous value to the new one, +// rather than swapping straight to it, so a price change reads as counting +// up or down. `displayValueRef` tracks the in-flight value so a change that +// interrupts a running tween continues from wherever it currently is +// instead of jumping back to the last committed value. +export const AnimatedCounter = ({ + value, + format = String, + className, +}: AnimatedCounterProps) => { + const reduceMotion = useReducedMotion() + const [displayValue, setDisplayValue] = useState(value) + const displayValueRef = useRef(value) + + useEffect(() => { + if (reduceMotion) { + displayValueRef.current = value + setDisplayValue(value) + return + } + + const controls = animate(displayValueRef.current, value, { + duration: 0.5, + ease: 'easeOut', + onUpdate: (latest) => { + displayValueRef.current = latest + setDisplayValue(latest) + }, + }) + + return () => controls.stop() + }, [value, reduceMotion]) + + return {format(Math.round(displayValue))} +} diff --git a/src/components/ui/AnimatedNumber.tsx b/src/components/ui/AnimatedNumber.tsx index 0a4a7c4..425525e 100644 --- a/src/components/ui/AnimatedNumber.tsx +++ b/src/components/ui/AnimatedNumber.tsx @@ -5,6 +5,8 @@ import styles from './AnimatedNumber.module.scss' type AnimatedNumberProps = { value: number + format?: (value: number) => string + align?: 'center' | 'start' className?: string } @@ -23,7 +25,12 @@ const variants = { // in the render body — mutating a ref here would already reflect the new // value by the time `exit`'s "custom" is resolved for the outgoing digit, // so every transition — increment or decrement — read as "forward". -export const AnimatedNumber = ({ value, className }: AnimatedNumberProps) => { +export const AnimatedNumber = ({ + value, + format = String, + align = 'center', + className, +}: AnimatedNumberProps) => { const reduceMotion = useReducedMotion() const [previousValue, setPreviousValue] = useState(value) const [direction, setDirection] = useState(1) @@ -34,11 +41,14 @@ export const AnimatedNumber = ({ value, className }: AnimatedNumberProps) => { } if (reduceMotion) { - return {value} + return {format(value)} } return ( - + { transition={{ duration: 0.15, ease: 'easeOut' }} className={styles.digit} > - {value} + {format(value)} diff --git a/src/routes/basket.tsx b/src/routes/basket.tsx index a2d31aa..e9bc2e4 100644 --- a/src/routes/basket.tsx +++ b/src/routes/basket.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { BasketIcon } from '#/components/basket/BasketLink.tsx' import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx' +import { AnimatedCounter } from '#/components/ui/AnimatedCounter.tsx' import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx' import type { MenuItemWithRecipe } from '#/domain/ordering.ts' import { useBasket } from '#/lib/basket.tsx' @@ -92,56 +93,66 @@ const BasketPage = () => { transition={transition} > - {resolvedLines.map(({ line, item, unitPriceCents }) => { - const names = optionNames(item, line.optionIds) - return ( - - - {item.name} - {names.length > 0 && ( - - {names.map((name) => ( - {name} - ))} - - )} - - {formatPrice(unitPriceCents * line.quantity)} - - - - remove(line.menuItemId, line.optionIds)} - aria-label={`Remove one ${item.name}`} - > - − - - - add(line.menuItemId, line.optionIds)} - aria-label={`Add one ${item.name}`} - > - + - - - - ) - })} + + {resolvedLines.map(({ line, item, unitPriceCents }) => { + const names = optionNames(item, line.optionIds) + return ( + + + {item.name} + {names.length > 0 && ( + + {names.map((name) => ( + {name} + ))} + + )} + + + + remove(line.menuItemId, line.optionIds)} + aria-label={`Remove one ${item.name}`} + > + − + + + add(line.menuItemId, line.optionIds)} + aria-label={`Add one ${item.name}`} + > + + + + + + ) + })} + Total - {formatPrice(totalCents)} +