Animate quantity steppers and make the nav bar sticky
- Add an AnimatedNumber component that slides a quantity stepper's count horizontally — left on increment, right on decrement — and wire it into both the menu and basket steppers. - Make the top nav bar position: sticky, with an explicit background so scrolled content doesn't show through it. - Reserve the scrollbar's gutter on every page (scrollable or not) so the sticky header doesn't shift width when navigating between short and tall pages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||
import type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||
|
||||
import styles from './MenuList.module.scss'
|
||||
@@ -51,7 +52,7 @@ export const MenuList = ({
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className={styles.count}>{quantity}</span>
|
||||
<AnimatedNumber value={quantity} className={styles.count} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
justify-items: center;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.digit {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
|
||||
import styles from './AnimatedNumber.module.scss'
|
||||
|
||||
type AnimatedNumberProps = {
|
||||
value: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variants = {
|
||||
initial: (direction: number) => ({ x: direction * 12, opacity: 0 }),
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: (direction: number) => ({ x: direction * -12, opacity: 0 }),
|
||||
}
|
||||
|
||||
// A quantity stepper's count slides leftward when incrementing (the new
|
||||
// digit enters from the right, the old one exits to the left) and
|
||||
// rightward when decrementing.
|
||||
//
|
||||
// `direction` is derived from a prop change during render (the React-docs
|
||||
// "adjusting state when a prop changes" pattern) rather than mutating a ref
|
||||
// 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) => {
|
||||
const reduceMotion = useReducedMotion()
|
||||
const [previousValue, setPreviousValue] = useState(value)
|
||||
const [direction, setDirection] = useState(1)
|
||||
|
||||
if (value !== previousValue) {
|
||||
setDirection(value > previousValue ? 1 : -1)
|
||||
setPreviousValue(value)
|
||||
}
|
||||
|
||||
if (reduceMotion) {
|
||||
return <span className={className}>{value}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${styles.wrapper} ${className ?? ''}`}>
|
||||
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
|
||||
<motion.span
|
||||
key={value}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className={styles.digit}
|
||||
>
|
||||
{value}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.topBar {
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
// Explicit stacking context, ranked above any page content: setting
|
||||
// `view-transition-name` on an element (here and on OrderStatusView's
|
||||
// order card) implicitly makes it its own stacking context too — without
|
||||
@@ -13,6 +14,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $space-3;
|
||||
background: $color-background;
|
||||
border-bottom: 1px solid $color-border;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
|
||||
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||
import { useBasket } from '#/lib/basket.tsx'
|
||||
import { getBrand } from '#/server/brand.ts'
|
||||
import { getMenu } from '#/server/menu.ts'
|
||||
@@ -54,7 +55,7 @@ const BasketPage = () => {
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className={styles.count}>{quantity}</span>
|
||||
<AnimatedNumber value={quantity} className={styles.count} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stepperButton}
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Reserve the scrollbar's width on every page, scrollable or not — without
|
||||
// this, the sticky nav bar (and page content) shifts a few pixels wider
|
||||
// when navigating from a short page (no scrollbar) to a tall one (has one).
|
||||
// `scrollbar-gutter` only takes effect on an actual scroll container, so
|
||||
// `html` needs an explicit non-visible overflow for it to apply at all.
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
|
||||
Reference in New Issue
Block a user