ariakit-react-utils
Version:
Ariakit React utils
358 lines (336 loc) • 10.1 kB
JavaScript
import * as React from 'react';
import { useLayoutEffect, useEffect, useState, useRef, useCallback, useMemo, useReducer } from 'react';
import { canUseDOM } from 'ariakit-utils/dom';
import { addGlobalEventListener } from 'ariakit-utils/events';
import { applyState } from 'ariakit-utils/misc';
import { setRef } from './misc.js';
// @ts-ignore Access React v18 hooks using string concatenation in order to
// prevent Webpack from inferring that they are not present in React v17. For
// example, React.useId will raise a compile time error when using React v17,
// but React['use' + 'Id'] will not.
const useReactId = React["use" + "Id"];
// @ts-ignore
const useReactDeferredValue = React["use" + "DeferredValue"];
// @ts-ignore
const useInsertionEffect = React["use" + "InsertionEffect"];
/**
* `React.useLayoutEffect` that fallbacks to `React.useEffect` on server side.
*/
const useSafeLayoutEffect = canUseDOM ? useLayoutEffect : useEffect;
/**
* Returns a value that never changes even if the argument is updated.
* @example
* function Component({ prop }) {
* const initialProp = useInitialValue(prop);
* }
*/
function useInitialValue(value) {
const [initialValue] = useState(value);
return initialValue;
}
/**
* Returns a value that is lazily initiated and never changes.
* @example
* function Component() {
* const set = useLazyValue(() => new Set());
* }
*/
function useLazyValue(init) {
const ref = useRef();
if (ref.current === undefined) {
ref.current = init();
}
return ref.current;
}
/**
* Creates a `React.RefObject` that is constantly updated with the incoming
* value.
* @example
* function Component({ prop }) {
* const propRef = useLiveRef(prop);
* }
*/
function useLiveRef(value) {
const ref = useRef(value);
useSafeLayoutEffect(() => {
ref.current = value;
});
return ref;
}
/**
* Keeps the reference of the previous value to be used in the render phase.
*/
function usePreviousValue(value) {
const [previousValue, setPreviousValue] = useState(value);
if (value !== previousValue) {
setPreviousValue(value);
}
return previousValue;
}
/**
* Creates a stable callback function that has access to the latest state and
* can be used within event handlers and effect callbacks. Throws when used in
* the render phase.
* @example
* function Component(props) {
* const onClick = useEvent(props.onClick);
* React.useEffect(() => {}, [onClick]);
* }
*/
function useEvent(callback) {
const ref = useRef(() => {
throw new Error("Cannot call an event handler while rendering.");
});
if (useInsertionEffect) {
useInsertionEffect(() => {
ref.current = callback;
});
} else {
ref.current = callback;
}
return useCallback(function () {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
return ref.current?.(...args);
}, []);
}
/**
* Merges React Refs into a single memoized function ref so you can pass it to
* an element.
* @example
* const Component = React.forwardRef((props, ref) => {
* const internalRef = React.useRef();
* return <div {...props} ref={useForkRef(internalRef, ref)} />;
* });
*/
function useForkRef() {
for (var _len2 = arguments.length, refs = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
refs[_key2] = arguments[_key2];
}
return useMemo(() => {
if (!refs.some(Boolean)) return;
return value => {
refs.forEach(ref => {
setRef(ref, value);
});
};
}, refs);
}
/**
* Returns the ref element's ID.
*/
function useRefId(ref, deps) {
const [id, setId] = useState(undefined);
useSafeLayoutEffect(() => {
setId(ref?.current?.id);
}, deps);
return id;
}
/**
* Generates a unique ID. Uses React's useId if available.
*/
function useId(defaultId) {
if (useReactId) {
const reactId = useReactId();
if (defaultId) return defaultId;
return reactId;
}
const [id, setId] = useState(defaultId);
useSafeLayoutEffect(() => {
if (defaultId || id) return;
const random = Math.random().toString(36).substr(2, 6);
setId(`id-${random}`);
}, [defaultId, id]);
return defaultId || id;
}
/**
* Uses React's useDeferredValue if available.
*/
function useDeferredValue(value) {
if (useReactDeferredValue) {
return useReactDeferredValue(value);
}
const [deferredValue, setDeferredValue] = useState(value);
useEffect(() => {
const raf = requestAnimationFrame(() => setDeferredValue(value));
return () => cancelAnimationFrame(raf);
}, [value]);
return deferredValue;
}
/**
* Returns the tag name by parsing an element ref and the `as` prop.
* @example
* function Component(props) {
* const ref = React.useRef();
* const tagName = useTagName(ref, "button"); // div
* return <div ref={ref} {...props} />;
* }
*/
function useTagName(ref, type) {
const [tagName, setTagName] = useState(() => stringOrUndefined(type));
useSafeLayoutEffect(() => {
setTagName(ref?.current?.tagName.toLowerCase() || stringOrUndefined(type));
}, [ref, type]);
return tagName;
}
function stringOrUndefined(type) {
if (typeof type === "string") {
return type;
}
return;
}
/**
* A `React.useEffect` that will not run on the first render.
*/
function useUpdateEffect(effect, deps) {
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) {
return effect();
}
mounted.current = true;
}, deps);
useEffect(() => () => {
mounted.current = false;
}, []);
}
/**
* A `React.useLayoutEffect` that will not run on the first render.
*/
function useUpdateLayoutEffect(effect, deps) {
const mounted = useRef(false);
useSafeLayoutEffect(() => {
if (mounted.current) {
return effect();
}
mounted.current = true;
}, deps);
useSafeLayoutEffect(() => () => {
mounted.current = false;
}, []);
}
/**
* A custom version of `React.useState` that uses the `state` and `setState`
* arguments. If they're not provided, it will use the internal state.
*/
function useControlledState(defaultState, state, setState) {
const [localState, setLocalState] = useState(defaultState);
const nextState = state !== undefined ? state : localState;
const stateRef = useLiveRef(state);
const setStateRef = useLiveRef(setState);
const nextStateRef = useLiveRef(nextState);
const setNextState = useCallback(prevValue => {
const setStateProp = setStateRef.current;
if (setStateProp) {
if (isSetNextState(setStateProp)) {
setStateProp(prevValue);
} else {
const nextValue = applyState(prevValue, nextStateRef.current);
nextStateRef.current = nextValue;
setStateProp(nextValue);
}
}
if (stateRef.current === undefined) {
setLocalState(prevValue);
}
}, []);
defineSetNextState(setNextState);
return [nextState, setNextState];
}
const SET_NEXT_STATE = Symbol("setNextState");
function isSetNextState(arg) {
return arg[SET_NEXT_STATE] === true;
}
function defineSetNextState(arg) {
if (!isSetNextState(arg)) {
Object.defineProperty(arg, SET_NEXT_STATE, {
value: true
});
}
}
/**
* A React hook similar to `useState` and `useReducer`, but with the only
* purpose of re-rendering the component.
*/
function useForceUpdate() {
return useReducer(() => [], []);
}
/**
* Returns an event callback similar to `useEvent`, but this also accepts a
* boolean value, which will be turned into a function.
*/
function useBooleanEvent(booleanOrCallback) {
return useEvent(typeof booleanOrCallback === "function" ? booleanOrCallback : () => booleanOrCallback);
}
/**
* Returns props with an additional `wrapElement` prop.
*/
function useWrapElement(props, callback, deps) {
if (deps === void 0) {
deps = [];
}
const wrapElement = useCallback(element => {
if (props.wrapElement) {
element = props.wrapElement(element);
}
return callback(element);
}, [...deps, props.wrapElement]);
return {
...props,
wrapElement
};
}
/**
* Merges the portalRef prop and returns a `domReady` to be used in the
* components that use Portal underneath.
*/
function usePortalRef(portalProp, portalRefProp) {
if (portalProp === void 0) {
portalProp = false;
}
const [portalNode, setPortalNode] = useState(null);
const portalRef = useForkRef(setPortalNode, portalRefProp);
const domReady = !portalProp || portalNode;
return {
portalRef,
portalNode,
domReady
};
}
/**
* Returns a function that checks whether the mouse is moving.
*/
function useIsMouseMoving() {
useEffect(() => {
// We're not returning the event listener cleanup function here because we
// may lose some events if this component is unmounted, but others are
// still mounted.
addGlobalEventListener("mousemove", setMouseMoving, true);
// See https://github.com/ariakit/ariakit/issues/1137
addGlobalEventListener("mousedown", resetMouseMoving, true);
addGlobalEventListener("mouseup", resetMouseMoving, true);
addGlobalEventListener("keydown", resetMouseMoving, true);
addGlobalEventListener("scroll", resetMouseMoving, true);
}, []);
const isMouseMoving = useEvent(() => mouseMoving);
return isMouseMoving;
}
let mouseMoving = false;
let previousScreenX = 0;
let previousScreenY = 0;
function hasMouseMovement(event) {
const movementX = event.movementX || event.screenX - previousScreenX;
const movementY = event.movementY || event.screenY - previousScreenY;
previousScreenX = event.screenX;
previousScreenY = event.screenY;
return movementX || movementY || process.env.NODE_ENV === "test";
}
function setMouseMoving(event) {
if (!hasMouseMovement(event)) return;
mouseMoving = true;
}
function resetMouseMoving() {
mouseMoving = false;
}
export { useBooleanEvent, useControlledState, useDeferredValue, useEvent, useForceUpdate, useForkRef, useId, useInitialValue, useIsMouseMoving, useLazyValue, useLiveRef, usePortalRef, usePreviousValue, useRefId, useSafeLayoutEffect, useTagName, useUpdateEffect, useUpdateLayoutEffect, useWrapElement };