Skip to content

feat: add Waypoint and useWaypoint components #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions src/Overlay.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
47 changes: 47 additions & 0 deletions src/Waypoint.tsx
Original file line number Diff line number Diff line change
@@ -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<any>) => (
<span ref={ref} style={{ fontSize: 0 }} />
);

export interface WaypointProps extends WaypointOptions {
renderComponent?: (ref: React.RefCallback<any>) => 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<Element>();

useWaypoint(element, onPositionChange, options);

return renderComponent(setElement);
}

export default Waypoint;
10 changes: 7 additions & 3 deletions src/usePopper.ts
Original file line number Diff line number Diff line change
@@ -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<Name, Options> = Popper.Modifier<Name, Options>;
Expand Down
17 changes: 17 additions & 0 deletions src/useScrollParent.tsx
Original file line number Diff line number Diff line change
@@ -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<Element | Document | null | undefined>(
null,
);

useIsomorphicEffect(() => {
if (element) {
setParent(getScrollParent(element as any, true));
}
}, [element]);

return parent;
}
125 changes: 125 additions & 0 deletions src/useWaypoint.tsx
Original file line number Diff line number Diff line change
@@ -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<IntersectionObserverInit, 'rootMargin' | 'root'> {
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<Position | null>(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;
Loading