UNPKG

react-horizontal-scrolling-menu

Version:

Horizontal scrolling menu component for React with per-item visibility tracking. Supports mouse and touch devices, SSR-safe.

438 lines (344 loc) 14.4 kB
--- name: 'menu-visibility' description: > Track which react-horizontal-scrolling-menu items are on screen: useIsVisible(itemId | 'first' | 'last', defaultValue), useLeftArrowVisible/useRightArrowVisible, the options prop (ratio, rootMargin, threshold), items.getVisible()/subscribe/unsubscribe, and the async IntersectionObserver truth model (items must be seen once, menuVisible gating, non-reactive isFirstItemVisible reads). Load when disabling arrows at the edges, styling items by visibility, building progress indicators, or debugging flickering or dead arrows. metadata: type: core library: 'react-horizontal-scrolling-menu' library_version: '8.2.3' sources: - 'asmyshlyaev177/react-horizontal-scrolling-menu:README.md' - 'asmyshlyaev177/react-horizontal-scrolling-menu:src/createApi.ts' - 'asmyshlyaev177/react-horizontal-scrolling-menu:src/hooks/useIntersectionObserver.ts' - 'asmyshlyaev177/react-horizontal-scrolling-menu:src/settings.ts' - 'asmyshlyaev177/react-horizontal-scrolling-menu:src/ItemsMap/ItemsMap.ts' --- # Visibility Tracking Visibility is IntersectionObserver-driven and **asynchronous**. An item has visibility data only after the observer has reported it at least once — which requires the menu itself to have been on screen. Nothing is true synchronously: not at mount, not on the server, not in the same tick as a click or a scroll call. Read visibility reactively (hooks) or inside callbacks (`onUpdate`, event handlers) — never as a one-shot read during setup. Two structural facts drive everything below: - All state lives in `api.items`, an `ItemsMap` (a `Map` of `String(itemId) → IOItem`), where `IOItem` is `{ index: string; key: string; entry: IntersectionObserverEntry; visible: boolean }`. - The hooks live **on the context api object** (`api.useIsVisible(...)`, `api.useLeftArrowVisible()`). Unusual, but rules-of-hooks-safe: the api is built in a `React.useMemo` whose dependencies (`items`, transition props, `noPolyfill`, `menuVisible` ref) never change after mount, so the hook identities are stable. Call them unconditionally at the top of components rendered under `ScrollMenu` (arrows, header/footer, items). ## Setup ```tsx import React from 'react'; import { ScrollMenu, VisibilityContext, type publicApiType, } from 'react-horizontal-scrolling-menu'; import 'react-horizontal-scrolling-menu/dist/styles.css'; const items = Array.from({ length: 10 }, (_, i) => `item-${i + 1}`); export function App() { return ( <ScrollMenu LeftArrow={LeftArrow} RightArrow={RightArrow}> {items.map((id) => ( <Card itemId={id} key={id} title={id} /> ))} </ScrollMenu> ); } function LeftArrow() { const api = React.useContext<publicApiType>(VisibilityContext); const disabled = api.useLeftArrowVisible(); return ( <button disabled={disabled} onClick={() => api.scrollPrev()}> ← </button> ); } function RightArrow() { const api = React.useContext<publicApiType>(VisibilityContext); const disabled = api.useRightArrowVisible(); return ( <button disabled={disabled} onClick={() => api.scrollNext()}> → </button> ); } function Card({ itemId, title }: { itemId: string; title: string }) { const api = React.useContext<publicApiType>(VisibilityContext); const isVisible = api.useIsVisible(itemId, true); return ( <div style={{ width: '160px', opacity: isVisible ? 1 : 0.4 }}>{title}</div> ); } ``` ## Core Patterns ### Edge-aware arrows with the built-in hooks `useLeftArrowVisible()` / `useRightArrowVisible()` return the `disabled` state for each arrow. They wrap `useIsVisible('first', true)` / `useIsVisible('last', false)` **plus a latch**: the internal state only updates while `menuVisible.current` is true, so arrows do not flicker when the page scrolls the menu out of the viewport vertically (`src/createApi.ts:65-89`). This is the canonical arrow pattern from `stories/Simple` — prefer it over hand-rolling `useIsVisible` arrows. ```tsx function LeftArrow() { const api = React.useContext<publicApiType>(VisibilityContext); const disabled = api.useLeftArrowVisible(); return ( <button disabled={disabled} onClick={() => api.scrollPrev()}> Left </button> ); } ``` ### Per-item visibility with useIsVisible `useIsVisible(itemId | 'first' | 'last', defaultValue = false)` subscribes to one item and re-renders on changes. `defaultValue` is the SSR / first-paint state — what renders before the first IntersectionObserver batch arrives client-side. The canonical arrow defaults (`('first', true)`, `('last', false)`) paint a row scrolled to its start; for items above the fold use `true` so they don't flash from hidden styling on hydration. ```tsx function Card({ itemId, title }: { itemId: string; title: string }) { const api = React.useContext<publicApiType>(VisibilityContext); const isVisible = api.useIsVisible(itemId, true); return <div data-visible={isVisible}>{title}</div>; } ``` The hook also reads the `ItemsMap` directly on mount, because the observer's first batch can fire before the subscription effect runs (`src/createApi.ts:37-60`) — you never need to handle that race yourself. ### Reacting to visibility changes: onUpdate + items.getVisible() For progress dots, lazy loading, or analytics, read the visible set inside the `onUpdate` callback — it fires after each visibility batch. `items.getVisible()` returns `[itemId, IOItem]` pairs sorted by index. ```tsx <ScrollMenu onUpdate={(api: publicApiType) => { const visibleIds = api.items.getVisible().map(([id]) => id); console.log('visible now:', visibleIds); }} > {items.map((id) => ( <Card itemId={id} key={id} title={id} /> ))} </ScrollMenu> ``` For a single item outside the menu tree, subscribe on the `items` instance — keys are an `itemId` or `'first'`, `'last'`, `'onInit'`, `'onUpdate'`. `subscribe` and `unsubscribe` both take `(key, callback)` and cleanup must pass the same callback instance (`src/ItemsMap/ItemsMap.ts:15-21`). ### Tuning the observer: the options prop Defaults from `src/settings.ts`: `{ ratio: 0.9, rootMargin: '5px', threshold: [0.05, 0.5, 0.75, 0.95] }`. An item counts as visible when its `intersectionRatio >= ratio`. The prop is partially merged over the defaults. ```tsx <ScrollMenu options={{ ratio: 0.5, rootMargin: '5px' }}> {items.map((id) => ( <Card itemId={id} key={id} title={id} /> ))} </ScrollMenu> ``` Lower `ratio` when items are nearly as wide as the container. `root` is not configurable — it is always the internal scroll container. ## Common Mistakes ### [CRITICAL] Reading isFirstItemVisible/isLastItemVisible in render expecting reactivity Wrong: ```tsx const { isFirstItemVisible, scrollPrev } = React.useContext(VisibilityContext); return ( <button disabled={isFirstItemVisible} onClick={() => scrollPrev()}> ← </button> ); ``` Correct: ```tsx const api = React.useContext<publicApiType>(VisibilityContext); const disabled = api.useLeftArrowVisible(); return ( <button disabled={disabled} onClick={() => api.scrollPrev()}> ← </button> ); ``` Reading these fields never subscribes the component: the value is live at read time (live getters over the ItemsMap; on <= 8.3.1 they were frozen at api creation and stuck `false`), but nothing re-renders when it changes. Reactive paths are the hooks, or `items.getVisible()` inside callbacks; reading the getters inside event handlers and timers is fine. Source: src/createApi.ts (isFirstItemVisible/isLastItemVisible getters); CHANGELOG v6.0.0 ### [CRITICAL] Destructuring the removed v5-era visibility API from context Wrong: ```tsx const { isFirstItemVisible, visibleElements, initComplete } = React.useContext(VisibilityContext); ``` Correct: ```tsx const api = React.useContext<publicApiType>(VisibilityContext); const disabled = api.useLeftArrowVisible(); const visibleIds = api.items.getVisible().map(([id]) => id); ``` `visibleElements`, `initComplete` and reactive `isFirstItemVisible`/`isLastItemVisible` were removed in v6 (Observer rewrite); nearly every pre-2024 tutorial uses them, and in v8 they are `undefined` or non-reactive getters. Source: CHANGELOG v6.0.0 (#270); issue #282; see skills/menu-migration/SKILL.md ### [HIGH] Visibility logic assuming items are known before being seen Wrong: ```tsx <ScrollMenu onInit={(api: publicApiType) => { if (!api.isItemVisible('item-9')) { api.scrollToItem(api.getItemById('item-9')); } }} > {cards} </ScrollMenu> ``` Correct: ```tsx <ScrollMenu onUpdate={(api: publicApiType) => { if (api.items.getVisible().length && !api.isItemVisible('item-9')) { api.scrollToItem(api.getItemById('item-9')); } }} > {cards} </ScrollMenu> ``` Items must be observed on screen at least once before visibility data exists; a menu below the fold at load has no valid data (dead or wrong arrows, `isItemVisible` always false) until it is scrolled into view. Source: https://github.com/asmyshlyaev177/react-horizontal-scrolling-menu/issues/286 (#289, #284) ### [HIGH] Arrow state flickering when the page scrolls vertically Wrong: ```tsx const api = React.useContext<publicApiType>(VisibilityContext); const disabled = api.useIsVisible('first', true); // flickers: items leave the viewport when the page scrolls past the menu ``` Correct: ```tsx const api = React.useContext<publicApiType>(VisibilityContext); const isFirst = api.useIsVisible('first', true); const [disabled, setDisabled] = React.useState(isFirst); React.useEffect(() => { if (api.menuVisible.current) { setDisabled(isFirst); } }, [isFirst, api]); ``` When the page scrolls the menu off screen every item reports not-visible, so naive visibility-driven arrows flicker; `useLeftArrowVisible`/ `useRightArrowVisible` already contain this `menuVisible`-gated latch — hand-rolled `useIsVisible` arrows must add it (a `!!api.items.getVisible().length` gate works too). Source: https://github.com/asmyshlyaev177/react-horizontal-scrolling-menu/issues/284 (#275, #147, #298); stories/Simple; src/createApi.ts:65-89 ### [HIGH] Wrong useIsVisible defaultValue causes hydration flicker Wrong: ```tsx const disabled = api.useIsVisible('first'); // SSR paints enabled, flips on hydration ``` Correct: ```tsx const disabled = api.useIsVisible('first', true); ``` `defaultValue` is the server-rendered/first-paint state (it defaults to `false`); the canonical arrows use `('first', true)` / `('last', false)` so SSR matches a row at its start — wrong defaults flip arrow state after hydration. Source: README.md SSR; maintainer interview; see skills/menu-testing-ssr/SKILL.md ### [MEDIUM] Items larger than the container never count as visible Wrong: ```tsx <ScrollMenu>{fullWidthSlides}</ScrollMenu> // 100vw-wide slides with the default ratio 0.9 — never "visible" ``` Correct: ```tsx <ScrollMenu options={{ ratio: 0.5 }}>{fullWidthSlides}</ScrollMenu> ``` An item is visible when `intersectionRatio >= options.ratio` (default 0.9); an item bigger than the container can never reach that, so edge detection and arrows break — lower the ratio for oversized items. Source: https://github.com/asmyshlyaev177/react-horizontal-scrolling-menu/issues/287 (#279); src/settings.ts ### [MEDIUM] Passing options.root expecting a custom observer root Wrong: ```tsx <ScrollMenu options={{ root: document.querySelector('#viewport') }}> {cards} </ScrollMenu> ``` Correct: ```tsx <ScrollMenu options={{ ratio: 0.5, rootMargin: '5px' }}>{cards}</ScrollMenu> ``` The `options` prop type accepts `IntersectionObserverInit`, but `root` is always overridden with the internal scroll container — a custom root is silently ignored. Source: src/hooks/useIntersectionObserver.ts:42 ### [MEDIUM] items.subscribe without matching unsubscribe cleanup Wrong: ```tsx React.useEffect(() => { api.items.subscribe('item-5', (item) => setVisible(!!item?.visible)); }, [api]); ``` Correct: ```tsx React.useEffect(() => { const cb = (item?: { visible: boolean }) => setVisible(!!item?.visible); api.items.subscribe('item-5', cb); return () => api.items.unsubscribe('item-5', cb); }, [api]); ``` `subscribe`/`unsubscribe` both take `(key, fn)` and require the same callback instance — subscribing without cleanup (or unsubscribing a fresh closure, or omitting the key) leaks subscribers across renders. Source: README.md items class instance; src/ItemsMap/ItemsMap.ts:15-21; src/Observer/Observer.ts:19-26 ## Tensions ### HIGH Tension: trivial quick start vs total silence on misuse The library contains zero throws or warnings — every visibility contract violation (missing/duplicate `itemId`, render-reading the getters, off-screen menus) fails silently with no error to debug from. Self-check the contracts instead of waiting for the console. Setup-side contracts: skills/menu-setup/SKILL.md; scrolling-side: skills/menu-scrolling/SKILL.md. ### HIGH Tension: imperative convenience vs reactive truth The api object mixes live methods, reactive hooks, non-reactive getters (`isFirstItemVisible`) and mutable stores (`items`, `apiRef`). Reads during render don't subscribe and go stale; hooks are correct but bound by rules of hooks. Read data via hooks (or the getters/`getVisible()` inside callbacks), fire methods imperatively. Imperative side: skills/menu-scrolling/SKILL.md. ### HIGH Tension: SSR first paint vs async browser truth The server paints `defaultValue` guesses; real visibility exists only after IntersectionObserver fires client-side — until then nothing about the menu is true. Never read visibility at mount and never assert it synchronously in tests (poll instead). Details: skills/menu-testing-ssr/SKILL.md. ## See also - skills/menu-setup/SKILL.md — canonical arrows are visibility-driven; setup quality depends on the reactive hooks - skills/menu-scrolling/SKILL.md — paging math consumes `items.getVisible()`; programmatic scroll gating uses `menuVisible` - skills/menu-testing-ssr/SKILL.md — hydration first paint is controlled by `useIsVisible` defaultValue; test assertions must respect the async visibility model - skills/menu-migration/SKILL.md — the highest-impact removed APIs are the old visibility fields; migration lands on the v8 hooks