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 type { MenuItemWithRecipe } from '#/domain/ordering.ts'
|
||||||
|
|
||||||
import styles from './MenuList.module.scss'
|
import styles from './MenuList.module.scss'
|
||||||
@@ -51,7 +52,7 @@ export const MenuList = ({
|
|||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.count}>{quantity}</span>
|
<AnimatedNumber value={quantity} className={styles.count} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.stepperButton}
|
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 *;
|
@use '../styles/variables' as *;
|
||||||
|
|
||||||
.topBar {
|
.topBar {
|
||||||
position: relative;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
// Explicit stacking context, ranked above any page content: setting
|
// Explicit stacking context, ranked above any page content: setting
|
||||||
// `view-transition-name` on an element (here and on OrderStatusView's
|
// `view-transition-name` on an element (here and on OrderStatusView's
|
||||||
// order card) implicitly makes it its own stacking context too — without
|
// order card) implicitly makes it its own stacking context too — without
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: $space-3;
|
padding: $space-3;
|
||||||
|
background: $color-background;
|
||||||
border-bottom: 1px solid $color-border;
|
border-bottom: 1px solid $color-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
|
import { CheckoutSection } from '#/components/ordering/CheckoutSection.tsx'
|
||||||
|
import { AnimatedNumber } from '#/components/ui/AnimatedNumber.tsx'
|
||||||
import { useBasket } from '#/lib/basket.tsx'
|
import { useBasket } from '#/lib/basket.tsx'
|
||||||
import { getBrand } from '#/server/brand.ts'
|
import { getBrand } from '#/server/brand.ts'
|
||||||
import { getMenu } from '#/server/menu.ts'
|
import { getMenu } from '#/server/menu.ts'
|
||||||
@@ -54,7 +55,7 @@ const BasketPage = () => {
|
|||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.count}>{quantity}</span>
|
<AnimatedNumber value={quantity} className={styles.count} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.stepperButton}
|
className={styles.stepperButton}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
box-sizing: border-box;
|
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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family:
|
font-family:
|
||||||
|
|||||||
Reference in New Issue
Block a user