Monday, October 17, 2022

Scroll Spy For Mr Yum

In the last few weeks I've been working on some updates to the navigation bar that guests see when they view a Mr Yum menu.

This is the previous navigation menu which was quite static and not reactive to scrolling by the user.

The updated navigation is reactive to scrolling by the user and in my opinion makes for a better experience.

The actual UI itself wasn't too difficult. The implementation took some basic CSS with Tailwind. What was tricky was implementing a scroll spy similar to how something like Storekit works.

I experimented for a while with react-intersection-observer to try and determine when certain elements are in view set a state to update the highlighted item in the nav. This approach didn't really work.

I took some time to research and ended up stumbling upon a library called react-waypoint.

Basically what you can do with react-waypoint is shown below

<Waypoint
  onEnter={onSectionEnter}
  onLeave={onSectionLeave}
  topOffset={TOP_OFFSET}
  bottomOffset={FOOTER_HEIGHT}
>
  <SectionContent />
</Waypoint>

whereby when a certain portion of the page is visible on the page (in Mr Yum's case a <Section />) the onEnter is triggered and the function passed to it as a prop is invoked. The onLeave works in reverse.

The topOffset and bottomOffset props are also handy as we need to take into account fixed navigation bars and footers.

The onSectionEnter function invokes a set state action

setCurrentSectionIndexes((prevSections) => [
  ...prevSections,
  sectionIndex,
])

which is passed to this component via a context.

The context is defined as follows:

export const CategoryMenuBarProvider = ({
  children,
}: {
  children: React.ReactNode
}) => {
  const [currentSectionIndexes, setCurrentSectionIndexes] = useState<number[]>([
    0,
  ])
  const [currentSectionIndex, setCurrentSectionIndex] = useState(0)
  const [sectionButtonClicked, setSectionButtonClicked] = useState(false)

  useEffect(() => {
    const sortedCurrentSectionIndexes = currentSectionIndexes.sort()

    const uniqueCurrentSectionIndexes = sortedCurrentSectionIndexes.filter(
      (sectionIndex, index, arr) => arr.indexOf(sectionIndex) === index,
    )

    setCurrentSectionIndex(uniqueCurrentSectionIndexes[0])
  }, [currentSectionIndexes])

  return (
    <CategoryMenuBarContext.Provider
      value={{
        ...defaultCategoryMenuBarContextValue,
        currentSectionIndexes,
        setCurrentSectionIndexes,
        currentSectionIndex,
        setSectionButtonClicked,
        sectionButtonClicked,
      }}
    >
      {children}
    </CategoryMenuBarContext.Provider>
  )
}

The important thing to note here is the useEffect. It's invoked when the currentSectionIndexes array changes (by the set state action we saw above). It will sort the section indexes and ensure that the array contains unique elements only. It will then grab the first item in the array and set the currentSectionIndex which is then used to highlight a specific section in the navigation.

This looks something like this in the navigation code:

<span
  className={cn(
    'paragraph-sm flex h-9 items-center justify-center rounded px-4 transition duration-200',
    {
      'bg-neutral-150 text-neutral-900 hover:bg-neutral-200':
        currentSectionIndex !== index,
    },
    {
      'bg-neutral-900 text-white hover:bg-neutral-900':
        currentSectionIndex === index,
    },
  )}
  >
  {menuSection.name}
</span>

which is again taking the currentSectionIndex value from the context.

This sorting and filtering of unique elements ensures the current index is correct. As many set state actions are running so frequently the currentSectionIndexes array can sometimes get into a weird state.

The other tricky part to this was ensuring that the navigation bar automatically scrolls to the highlighted section. This was achieved with the following useEffect

useEffect(() => {
  const menuSectionRef = menuSectionsWithRef[currentSectionIndex]

  if (!menuSectionRef) return

  const currentMenuSectionRef = menuSectionRef.current

  if (!currentMenuSectionRef) return

  currentMenuSectionRef.scrollIntoView({
    inline: currentSectionIndex === 0 ? 'end' : 'start',
  })
}, [currentSectionIndex, menuSectionsWithRef])

whereby when currentSectionIndex updates in the context this effect will run. It grabs a <Section /> ref getting it's current property before using the Element.scrollIntoView Web API to scroll the horizontal scroll bar into the correct position.