@fire-spark/storefront turns that tree into sections. @fire-spark/rn-ui keeps the category nav on the section that is on screen.
@fire-spark/react-ui (web) stays on the offset hook. Virtualized lists are a React Native concern.
Flatten the catalog
flattenMenuCategories walks the menu depth-first. Each section’s instanceId is the path of category ids (food/mains), so the same category can appear under two parents.
Rows for a virtualized list
buildMenuRows turns those sections into rows a list can recycle. It does not render anything. The product type is whatever you store in the map.
header row (sectionId is instanceId). Product rows follow, and index restarts at 0 in every section. An empty row is added only when the section has no products and no CATEGORY items, so a parent that only groups subcategories does not show an empty state.
Keys stay unique when one product sits in two sections:
first is true only on the first header.
spanMenuRow sets layout.span to the list’s column count when the row is not a product. Product cells keep a span of one.
Scroll-spy on a plain scroll view
Pass section ids and the scroll view ref.registerCategory records each section’s y. onScroll picks the last section whose top is at or above the offset line. selectCategory scrolls there.
active.registerCategory(section.instanceId) on the section’s onLayout, and active.onScroll on the scroll view.
Scroll-spy on a virtualized list
Off-screen rows are not mounted, so layout offsets are missing. Pass the index mode instead. The hook does not import a list package. Any ref withscrollToIndex fits, including FlashList, FlatList, and LegendList.
- Spy.
onScrollreadslistRef.current.getFirstVisibleIndex()and maps that row torowSectionIds. You can call it with no event. For FlatList, passactive.onViewableItemsChanged. It uses the lowest visible index. - Jump.
selectCategory(id)callsscrollToIndex({ index: headerIndex.get(id), animated: true, viewOffset: offset }). Do not wait on the promise FlashList returns. - Lock. While the jump animates, scroll events do not move the highlight. The lock clears 140ms after scrolling stops, or after 1s if the list never scrolls.
- Deep link.
initialCategoryIdis the active section before the first scroll. If that id leavesheaderIndex, the active id falls back to the first section.
registerCategory is a no-op in this mode. Category* primitives are plain views, so they render inside a list cell with no other changes.