remirror/remirror

View on GitHub
packages/multishift/src/multishift-hooks.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { setStatus } from 'a11y-status';
import type { DependencyList, Dispatch, EffectCallback, MutableRefObject } from 'react';
import { useEffect, useReducer, useRef } from 'react';
import { isEmptyArray } from '@remirror/core-helpers';

import { multishiftReducer } from './multishift-reducer';
import type {
  A11yStatusMessageProps,
  GetA11yStatusMessage,
  ItemsToString,
  MultishiftA11yIdProps,
  MultishiftProps,
  MultishiftRootActions,
  MultishiftState,
} from './multishift-types';
import {
  callChangeHandlers,
  defaultItemsToString,
  GetElementIds,
  getElementIds,
  getInitialStateProps,
  isOrContainsNode,
} from './multishift-utils';
import { useId } from './use-id';

/**
 * Creates the reducer for managing the multishift internal state.
 */
export function useMultishiftReducer<Item = any>(
  props: MultishiftProps<Item>,
): [MultishiftState<Item>, Dispatch<MultishiftRootActions<Item>>] {
  const { stateReducer, ...rest } = props;
  const initialState = getInitialStateProps<Item>(rest);

  return useReducer((prevState: MultishiftState<Item>, action: MultishiftRootActions<Item>) => {
    const [state, changes] = multishiftReducer(prevState, action, rest);
    const changeset = { changes, state, prevState };

    callChangeHandlers(rest, changeset);

    if (stateReducer) {
      return stateReducer(changeset, action, rest);
    }

    return state;
  }, initialState);
}

/**
 * Creates the ids for identifying the elements in the app.
 */
export function useElementIds(props: MultishiftA11yIdProps): GetElementIds {
  const defaultId = useId();

  return getElementIds(defaultId ?? '', props);
}

interface UseElementRefs {
  toggleButton: MutableRefObject<HTMLElement | undefined>;
  input: MutableRefObject<HTMLElement | undefined>;
  menu: MutableRefObject<HTMLElement | undefined>;
  comboBox: MutableRefObject<HTMLElement | undefined>;
  items: MutableRefObject<HTMLElement[]>;
  ignored: MutableRefObject<HTMLElement[]>;
}

/**
 * Get the element references.
 */
export function useElementRefs(): UseElementRefs {
  const items = useRef<HTMLElement[]>([]);
  const ignored = useRef<HTMLElement[]>([]);
  const toggleButton = useRef<HTMLElement>();
  const input = useRef<HTMLElement>();
  const menu = useRef<HTMLElement>();
  const comboBox = useRef<HTMLElement>();

  // Reset the items ref nodes on each call render.
  items.current = [];
  ignored.current = [];

  return useRef({ toggleButton, input, menu, comboBox, items, ignored }).current;
}

/**
 * A default getA11yStatusMessage function is provided that will check `items.current.length`
 * and return "No results." or if there are results but no item is highlighted,
 * "resultCount results are available, use up and down arrow keys to navigate."
 * If items are highlighted it will run `itemToString(highlightedItem)` and display
 * the value of the `highlightedItem`.
 */
const defaultGetA11yStatusMessage = <Item = any>({
  items,
  state: { selectedItems, isOpen },
  itemsToString = defaultItemsToString,
}: A11yStatusMessageProps<Item>) => {
  if (!isEmptyArray(selectedItems)) {
    return `${itemsToString(selectedItems)} has been selected.`;
  }

  if (isEmptyArray(items)) {
    return '';
  }

  const resultCount = items.length;

  if (isOpen) {
    if (resultCount === 0) {
      return 'No results are available';
    }

    return `${resultCount} result${
      resultCount === 1 ? ' is' : 's are'
    } available, use up and down arrow keys to navigate. Press Enter key to select.`;
  }

  return '';
};

interface UseSetA11yProps<Item = any> {
  state: MultishiftState<Item>;
  items: Item[];
  itemsToString?: ItemsToString<Item>;
  getA11yStatusMessage?: GetA11yStatusMessage<Item>;
  customA11yStatusMessage?: string;
}

export function useSetA11y<Item = any>(props: UseSetA11yProps<Item>): void {
  const {
    state,
    items,
    itemsToString = defaultItemsToString,
    getA11yStatusMessage = defaultGetA11yStatusMessage,
    customA11yStatusMessage = '',
  } = props;
  const automaticMessage = getA11yStatusMessage({
    state,
    items,
    itemsToString,
  });

  // Sets a11y status message on changes to relevant state values.
  useEffectOnUpdate(() => {
    setStatus(automaticMessage);
  }, [state.isOpen, state.selectedItems]);

  // Sets a11y status message on changes in customA11yStatusMessage
  useEffect(() => {
    if (customA11yStatusMessage) {
      setStatus(customA11yStatusMessage);
    }
  }, [customA11yStatusMessage]);
}

/**
 * This is a hook that listens for events mouse and touch events.
 *
 * When something does occur outside of the registered elements it will dispatch
 * the relevant action.
 */
export function useOuterEventListener<Item = any>(
  refs: ReturnType<typeof useElementRefs>,
  state: MultishiftState<Item>,
  { outerMouseUp, outerTouchEnd }: { outerMouseUp: () => void; outerTouchEnd: () => void },
): MutableRefObject<{
  isMouseDown: boolean;
  isTouchMove: boolean;
  lastBlurred: HTMLElement | undefined;
}> {
  const context = useRef({
    isMouseDown: false,
    isTouchMove: false,
    lastBlurred: undefined as HTMLElement | undefined,
  });

  const isOpen = useRef(state.isOpen);
  isOpen.current = state.isOpen;

  const targetWithinMultishift = (target: Node | null, checkActiveElement = true) =>
    [
      refs.comboBox.current,
      refs.menu.current,
      refs.toggleButton.current,
      refs.input.current,
      ...refs.ignored.current,
      ...refs.items.current,
    ].some(
      (node) =>
        node &&
        (isOrContainsNode(node, target) ||
          (checkActiveElement && isOrContainsNode(node, window.document.activeElement))),
    );

  useEffectOnce(() => {
    // Borrowed from `downshift`
    // context.current.isMouseDown helps us track whether the mouse is currently held down.
    // This is useful when the user clicks on an item in the list, but holds the mouse
    // down long enough for the list to disappear (because the blur event fires on the input)
    // context.current.isMouseDown is used in the blur handler on the input to determine whether the blur event should
    // trigger hiding the menu.
    const onMouseDown = () => {
      context.current.isMouseDown = true;
    };

    const onMouseUp = (event: MouseEvent) => {
      context.current.isMouseDown = false;
      // if the target element or the activeElement is within a multishift node
      // then we don't want to reset multishift
      const contextWithinMultishift = targetWithinMultishift(event.target as Node);

      if (!contextWithinMultishift && isOpen.current) {
        outerMouseUp();
      }
    };

    // Borrowed from `downshift`
    // Touching an element in iOS gives focus and hover states, but touching out of
    // the element will remove hover, and persist the focus state, resulting in the
    // blur event not being triggered.
    // context.current.isTouchMove helps us track whether the user is tapping or swiping on a touch screen.
    // If the user taps outside of Multishift, the component should be reset,
    // but not if the user is swiping
    const onTouchStart = () => {
      context.current.isTouchMove = false;
    };

    const onTouchMove = () => {
      context.current.isTouchMove = true;
    };

    const onTouchEnd = (event: TouchEvent) => {
      const contextWithinMultishift = targetWithinMultishift(event.target as Node, false);

      if (!context.current.isTouchMove && !contextWithinMultishift && isOpen.current) {
        outerTouchEnd();
      }
    };

    window.addEventListener('mousedown', onMouseDown);
    window.addEventListener('mouseup', onMouseUp);
    window.addEventListener('touchstart', onTouchStart);
    window.addEventListener('touchmove', onTouchMove);
    window.addEventListener('touchend', onTouchEnd);

    return () => {
      window.removeEventListener('mousedown', onMouseDown);
      window.removeEventListener('mouseup', onMouseUp);
      window.removeEventListener('touchstart', onTouchStart);
      window.removeEventListener('touchmove', onTouchMove);
      window.removeEventListener('touchend', onTouchEnd);
    };
  });

  return context;
}

/**
 * A hook for managing multiple timeouts.
 *
 * @remarks
 *
 * All timeouts are automatically cleared when un-mounting.
 */
export function useTimeouts(): Readonly<[(fn: () => void, time?: number) => void, () => void]> {
  const timeoutIds = useRef<any[]>([]);

  const setHookTimeout = (fn: () => void, time = 1) => {
    const id = setTimeout(() => {
      timeoutIds.current = timeoutIds.current.filter((timeoutId) => timeoutId !== id);
      fn();
    }, time);

    timeoutIds.current.push(id);
  };

  const clearHookTimeouts = () => {
    timeoutIds.current.forEach((id) => {
      clearTimeout(id);
    });

    timeoutIds.current = [];
  };

  // Clear the timeouts on dismount
  useEffectOnce(() => clearHookTimeouts);

  return [setHookTimeout, clearHookTimeouts] as const;
}

/**
 * React effect hook that ignores the first invocation (e.g. on mount).
 *
 * @remarks
 *
 * The signature is exactly the same as the useEffect hook.
 *
 * ```tsx
 * import React from 'react'
 * import { useEffectOnUpdate } from 'react-use';
 *
 * const Demo = () => {
 *   const [count, setCount] = React.useState(0);
 *
 *   React.useEffect(() => {
 *     const interval = setInterval(() => {
 *       setCount(count => count + 1)
 *     }, 1000)
 *
 *     return () => {
 *       clearInterval(interval)
 *     }
 *   }, [])
 *
 *   useEffectOnUpdate(() => {
 *     log('count', count) // will only show 1 and beyond
 *
 *     return () => { // *OPTIONAL*
 *       // do something on unmount
 *     }
 *   }) // you can include deps array if necessary
 *
 *   return <div>Count: {count}</div>
 * };
 * ```
 */
export function useEffectOnUpdate(effect: EffectCallback, dependencies: DependencyList): void {
  const isInitialMount = useRef(true);

  useEffect(() => {
    if (isInitialMount.current) {
      isInitialMount.current = false;
    } else {
      return effect();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [...dependencies]);
}

/**
 * React lifecycle hook that calls a function when the component will unmount.
 *
 * @remarks
 *
 * Try `useEffectOnce` if you need both a mount and unmount function.
 *
 * ```jsx
 * import {useUnmount} from 'react-use';
 *
 * const Demo = () => {
 *   useUnmount(() => log('UNMOUNTED'));
 *   return null;
 * };
 * ```
 */
export function useUnmount(fn: () => void | undefined): void {
  useEffectOnce(() => fn);
}

function useEffectOnce(fn: EffectCallback) {
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(fn, []);
}