diff --git a/package.json b/package.json index e8cc593..15364dc 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@babel/runtime": "^7.13.16", "@popperjs/core": "^2.9.2", "@react-aria/ssr": "^3.0.1", - "@restart/hooks": "^0.3.26", + "@restart/hooks": "^0.4.0", "@types/warning": "^3.0.0", "dequal": "^2.0.2", "dom-helpers": "^5.2.0", @@ -77,13 +77,13 @@ }, "devDependencies": { "@4c/cli": "^2.2.3", + "@4c/docusaurus-preset": "^0.2.4", "@4c/rollout": "^2.2.1", "@4c/tsconfig": "^0.3.1", "@babel/cli": "^7.13.16", "@babel/core": "^7.13.16", "@babel/preset-react": "^7.13.13", "@babel/preset-typescript": "^7.13.0", - "@4c/docusaurus-preset": "^0.2.4", "@react-bootstrap/eslint-config": "^2.0.0", "@rollup/plugin-node-resolve": "^11.2.1", "@types/classnames": "^2.3.1", diff --git a/src/Overlay.tsx b/src/Overlay.tsx index c498dee..02f34f7 100644 --- a/src/Overlay.tsx +++ b/src/Overlay.tsx @@ -1,20 +1,20 @@ -import PropTypes from 'prop-types'; -import { useState } from 'react'; import * as React from 'react'; +import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import useCallbackRef from '@restart/hooks/useCallbackRef'; import useMergedRefs from '@restart/hooks/useMergedRefs'; -import { placements } from './popper'; +import { useState } from 'react'; import usePopper, { - Placement, - UsePopperOptions, Offset, + Placement, State, + UsePopperOptions, } from './usePopper'; import useRootClose, { RootCloseOptions } from './useRootClose'; import useWaitForDOMRef, { DOMContainer } from './useWaitForDOMRef'; import { TransitionCallbacks } from './types'; import mergeOptionsWithPopperConfig from './mergeOptionsWithPopperConfig'; +import { placements } from './popper'; export interface OverlayProps extends TransitionCallbacks { flip?: boolean; diff --git a/src/Waypoint.tsx b/src/Waypoint.tsx new file mode 100644 index 0000000..2ca1681 --- /dev/null +++ b/src/Waypoint.tsx @@ -0,0 +1,47 @@ +import useCallbackRef from '@restart/hooks/useCallbackRef '; +import * as React from 'react'; + +import useWaypoint, { + WaypointOptions, + WaypointEvent, + Position, +} from './useWaypoint'; + +export { Position }; +export type { WaypointEvent }; + +const defaultRenderComponent = (ref: React.RefCallback) => ( + +); + +export interface WaypointProps extends WaypointOptions { + renderComponent?: (ref: React.RefCallback) => React.ReactElement; + + /** + * The callback fired when a waypoint's position is updated. This generally + * fires as a waypoint enters or exits the viewport but will also be called + * on mount. + */ + onPositionChange: ( + details: WaypointEvent, + entry: IntersectionObserverEntry, + ) => void; +} + +/** + * A component that tracks when it enters or leaves the viewport. Implemented + * using IntersectionObserver, polyfill may be required for older browsers. + */ +function Waypoint({ + renderComponent = defaultRenderComponent, + onPositionChange, + ...options +}: WaypointProps) { + const [element, setElement] = useCallbackRef(); + + useWaypoint(element, onPositionChange, options); + + return renderComponent(setElement); +} + +export default Waypoint; diff --git a/src/usePopper.ts b/src/usePopper.ts index 74b17c0..cd24cad 100644 --- a/src/usePopper.ts +++ b/src/usePopper.ts @@ -1,10 +1,14 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import useSafeState from '@restart/hooks/useSafeState'; import * as Popper from '@popperjs/core'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { dequal } from 'dequal'; +import useSafeState from '@restart/hooks/useSafeState'; import { createPopper } from './popper'; -const disabledApplyStylesModifier = { name: 'applyStyles', enabled: false }; +const disabledApplyStylesModifier = { + name: 'applyStyles', + enabled: false, + phase: 'afterWrite', +}; // until docjs supports type exports... export type Modifier = Popper.Modifier; diff --git a/src/useScrollParent.tsx b/src/useScrollParent.tsx new file mode 100644 index 0000000..b5ff1d2 --- /dev/null +++ b/src/useScrollParent.tsx @@ -0,0 +1,17 @@ +import useIsomorphicEffect from '@restart/hooks/useIsomorphicEffect'; +import getScrollParent from 'dom-helpers/scrollParent'; +import { useState } from 'react'; + +export default function useScrollParent(element: null | Element) { + const [parent, setParent] = useState( + null, + ); + + useIsomorphicEffect(() => { + if (element) { + setParent(getScrollParent(element as any, true)); + } + }, [element]); + + return parent; +} diff --git a/src/useWaypoint.tsx b/src/useWaypoint.tsx new file mode 100644 index 0000000..34c4fd9 --- /dev/null +++ b/src/useWaypoint.tsx @@ -0,0 +1,125 @@ +import useEventCallback from '@restart/hooks/useEventCallback'; +import useIntersectionObserver from '@restart/hooks/useIntersectionObserver'; +import { useMemo, useRef } from 'react'; +import getScrollParent from 'dom-helpers/scrollParent'; + +export interface WaypointEvent { + position: Position; + previousPosition: Position | null; +} + +export interface Rect { + top?: number; + bottom?: number; + left?: number; + right?: number; +} + +export type WaypointCallback = ( + details: WaypointEvent, + entry: IntersectionObserverEntry, + observer: IntersectionObserver, +) => void; + +export type RootElement = Element | Document | null | undefined; + +/** Accepts all options an IntersectionObserver accepts */ +export interface WaypointOptions + extends Omit { + root?: RootElement | 'scrollParent'; + + /** + * A valid CSS `margin` property or object containing the specific "top", "left", etc properties. + * The root margin functionally adjusts the "size" of the viewport when considering the waypoint's + * position. A positive margin will cause the waypoint to "enter" the waypoint early while a + * negative margin will have the opposite effect. + */ + rootMargin?: string | Rect; + + /** + * Set the direction of the scroll to consider when tracking the waypoint's position + */ + scrollDirection?: 'vertical' | 'horizontal'; +} + +export enum Position { + BEFORE = 1, + INSIDE, + AFTER, +} + +function toCss(margin?: string | Rect) { + if (!margin || typeof margin === 'string') return margin; + + const { top = 0, right = 0, bottom = 0, left = 0 } = margin; + + return `${top}px ${right}px ${bottom}px ${left}px`; +} + +function useWaypoint( + element: Element | null, + callback: WaypointCallback, + options: WaypointOptions = {}, +): void { + const { root, rootMargin, threshold, scrollDirection = 'vertical' } = options; + const handler = useEventCallback(callback); + + const prevPositionRef = useRef(null); + + const findScrollParent = root === 'scrollParent'; + const scrollParent = useMemo( + () => + (element && findScrollParent && getScrollParent(element as any, true)) || + null, + [element, findScrollParent], + ); + + const realRoot = root === 'scrollParent' ? scrollParent : root; + + useIntersectionObserver( + // We change the meaning of explicit null to "not provided yet" + // this is to allow easier synchronizing between element and roots derived + // from it. Otherwise if the root updates later an observer will be created + // for the document and then for the root + element, + ([entry], observer) => { + if (!entry) return; + + const [start, end, point] = + scrollDirection === 'vertical' + ? (['top', 'bottom', 'y'] as const) + : (['left', 'right', 'x'] as const); + + const { [point]: coord } = entry.boundingClientRect; + + const rootStart = entry.rootBounds?.[start] || 0; + const rootEnd = entry.rootBounds?.[end] || 0; + + let position: Position = Position.INSIDE; + if (entry.isIntersecting) { + position = Position.INSIDE; + } else if (coord > rootEnd) { + position = Position.AFTER; + } else if (coord < rootStart) { + position = Position.BEFORE; + } + + const previousPosition = prevPositionRef.current; + + if (previousPosition === position) { + return; + } + + handler({ position, previousPosition }, entry, observer); + + prevPositionRef.current = position; + }, + { + threshold, + root: realRoot, + rootMargin: toCss(rootMargin), + }, + ); +} + +export default useWaypoint; diff --git a/www/docs/Waypoint.mdx b/www/docs/Waypoint.mdx new file mode 100644 index 0000000..50d67c2 --- /dev/null +++ b/www/docs/Waypoint.mdx @@ -0,0 +1,149 @@ +import WaypointExample from "../src/WaypointExample"; + +A component (and related hook) to provide a high level interface +over [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) +designed to detect when a component enters and exits a viewport. + +`Waypoint` provides a good basis for building other components like infinite lists, +"scroll spy" style navigation, lazy loading images, and more. + +## How it works + +Waypoint renders a "sentinel" element that you place in a scrollable +area. Using an `IntersectionObserver`, the waypoint fires a callback when it +"intersects" with the visible area of the scroll view. Waypoint will fire a callback +when this happens with details about where the waypoint is in relation to the visible +area. + +The example below adds visible styling to waypoint for clarity. By default a +waypoint renders an invisible zero-height element. + + + +## Scroll direction + +For simplicity, scroll detection with waypoints is limited to a single direction at one time. +By default that direction is "vertical", but can configured to "horizontal". + +```tsx + +``` + + + +## Specifying the scroll parent + +By default waypoint observes scrolling on the device viewport, generally the `window`. +You can also specify a specific element as the root by providing a DOM element +to the `root` prop. + +```tsx +function ScrollArea({ items, onPositionChange }) { + const [element, setElement] = useState( + null + ); + + return ( +
+ {items} + +
+ ); +} +``` + +## Margins + +You can adjust the functional shape of the viewport by providing a `rootMargin` +prop, which is passed to the underlying `IntersectionObserver`. As a convenience, +Waypoint accepts an object with inset properties as well as a valid CSS margin property +value. + +Positive margins grow the overall size of the viewport causing waypoints to enter +before they are actually in view. Negative values shrink the viewport, and delays +the waypoint from entering by the specified pixel amount. Margins are illustrated +by the yellow blocks in the example below. + +Here is an example of positive margins: + +```tsx + +``` + + + +

And the effect with negative margins

+ +```tsx + +``` + + + +## `useWaypoint` + +The underlying hook powering the Waypoint component. Leverage `useWaypoint` +to turn any component into a waypoint. Using the hook directly is helpful +when you want an individual component to respond + +```tsx live +import getScrollParent from "dom-helpers/scrollParent"; +import useWaypoint, { + Position, +} from "@restart/ui/useWaypoint"; + +function LazyImage({ src }) { + const [element, attachRef] = useState(null); + const [hasBeenSeen, setSeen] = useState(false); + + const waypointRef = useWaypoint( + element, + ({ position, previousPosition }, entry, observer) => { + if (hasBeenSeen) { + return; + } + + setSeen(position === Position.INSIDE); + }, + { + root: "scrollParent", + scrollDirection: "horizontal", + rootMargin: { right: -80 }, + } + ); + + return ( + + ); +} + +
+
+
+ + + + + + + + + +
+
; +``` diff --git a/www/plugins/webpack.js b/www/plugins/webpack.js index fa04709..07ad54e 100644 --- a/www/plugins/webpack.js +++ b/www/plugins/webpack.js @@ -1,32 +1,30 @@ const path = require('path'); -module.exports = () => { - return { - name: 'webpack-plugin', - configureWebpack(_, isServer, { getBabelLoader }) { - return { - devtool: 'inline-module-source-map', +module.exports = () => ({ + name: 'webpack-plugin', + configureWebpack(_, isServer, { getBabelLoader }) { + return { + devtool: 'inline-module-source-map', - module: { - rules: [ - { - test: /\.(j|t)sx?$/, - include: [path.resolve(__dirname, '../../src')], - use: [ - getBabelLoader( - isServer, - path.resolve(__dirname, '../babel.config.js'), - ), - ], - }, - ], - }, - resolve: { - alias: { - '@restart/ui': path.resolve(__dirname, '../../src'), + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + include: [path.resolve(__dirname, '../../src')], + use: [ + getBabelLoader( + isServer, + path.resolve(__dirname, '../babel.config.js'), + ), + ], }, + ], + }, + resolve: { + alias: { + '@restart/ui': path.resolve(__dirname, '../../src'), }, - }; - }, - }; -}; + }, + }; + }, +}); diff --git a/www/sidebars.js b/www/sidebars.js index b51f161..9a4a305 100644 --- a/www/sidebars.js +++ b/www/sidebars.js @@ -7,7 +7,15 @@ module.exports = { type: 'category', label: 'API', collapsed: false, - items: ['Button', 'Dropdown', 'Modal', 'Nav', 'Overlay', 'Portal'], + items: [ + 'Button', + 'Dropdown', + 'Modal', + 'Nav', + 'Overlay', + 'Portal', + 'Waypoint', + ], }, { type: 'category', diff --git a/www/src/WaypointExample.tsx b/www/src/WaypointExample.tsx new file mode 100644 index 0000000..353aaf1 --- /dev/null +++ b/www/src/WaypointExample.tsx @@ -0,0 +1,164 @@ +import { useCallbackRef } from '@restart/hooks'; +import useIsomorphicEffect from '@restart/hooks/useIsomorphicEffect'; +import Waypoint, { Position } from '@restart/ui/Waypoint'; +import clsx from 'clsx'; +import React, { useState } from 'react'; + +interface Props { + horizontal?: boolean; + scrollIntoView?: boolean; + margins?: any; +} + +const VIEWPORT = 60; +const defaultMargin = { + top: -VIEWPORT, + right: -VIEWPORT, + bottom: -VIEWPORT, + left: -VIEWPORT, +}; +function Spacer({ horizontal }) { + return ( +
+ + ▼ + +
+ ); +} + +function Overlay({ horizontal }) { + return ( +
+ ); +} + +function Margins({ margins }) { + const { top, bottom } = margins; + // eslint-disable-next-line no-nested-ternary + + return ( + <> + {top != null && ( +
+ )} + {bottom != null && ( +
+ )} + + ); +} + +function normalizeMargins(margins) { + if (!margins) return defaultMargin; + const next = { ...defaultMargin }; + if (margins.top != null) next.top += margins.top; + if (margins.bottom != null) next.bottom += margins.bottom; + + return next; +} +function WaypointExample({ horizontal, scrollIntoView, margins }: Props) { + const [root, attachRef] = useCallbackRef(); + const [message, setMessage] = useState(''); + + useIsomorphicEffect(() => { + if (!scrollIntoView || !root) return; + + root + .querySelector('.docs-example-waypoint') + .scrollIntoView({ block: 'center' }); + // root.scrollTop = root.scrollHeight / 2; + }, [root]); + + return ( +
+ {message && ( +
+ {message} +
+ )} + + {margins && } +
+ + + + + + ( +
+ )} + onPositionChange={({ position }) => { + if (position === Position.BEFORE || position === Position.AFTER) { + setMessage(`Exited (${Position[position].toLowerCase()})`); + } + if (position === Position.INSIDE) { + setMessage('Entered'); + } + }} + /> + + + + + +
+
+ ); +} + +export default WaypointExample; diff --git a/www/src/css/tailwind.css b/www/src/css/tailwind.css index fddfd21..ae4d9c5 100644 --- a/www/src/css/tailwind.css +++ b/www/src/css/tailwind.css @@ -1,5 +1,9 @@ @import 'tailwindcss/base'; +a { + color: theme('colors.brand.500') +} + h1, h2, h3, h4, h5, h6 { color: var(--ifm-heading-color); font-weight: var(--ifm-heading-font-weight); line-height: var(--ifm-heading-line-height); margin: var(--ifm-heading-margin-top) 0 var(--ifm-heading-margin-bottom) 0; } h1 { font-size: var(--ifm-h1-font-size); } h2 { font-size: var(--ifm-h2-font-size); } @@ -21,3 +25,4 @@ ul { @apply list-disc list-inside } } @import 'tailwindcss/utilities'; + diff --git a/yarn.lock b/yarn.lock index 49ea979..520386a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2214,6 +2214,13 @@ lodash "^4.17.20" lodash-es "^4.17.20" +"@restart/hooks@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.0.tgz#f923d7c96b9bce969de097dd458c822b5c8072ee" + integrity sha512-+RenTVobiCHPjUTbhQDV8m0PU1xEWqgloMIIOlf86oKnfghKR/l4tKto7TH543shEQZZa7ARSMTvT0cXN9u8+g== + dependencies: + dequal "^2.0.2" + "@rollup/plugin-node-resolve@^11.2.1": version "11.2.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz#82aa59397a29cd4e13248b106e6a4a1880362a60"