remirror/remirror

View on GitHub
packages/remirror__react-hooks/src/use-menu-navigation.ts

Summary

Maintainability
A
0 mins
Test Coverage
C
70%
/**
 * @module
 *
 * Create menu navigation handlers when showing popup menus inside the editor.
 */

import {
  MultishiftHelpers,
  MultishiftPropGetters,
  MultishiftState,
  Type,
  useMultishift,
} from 'multishift';
import { useCallback, useMemo, useState } from 'react';
import { KeyBindingCommandFunction, KeyBindingNames, KeyBindings } from '@remirror/core';
import { useCommands } from '@remirror/react-core';

import { indexFromArrowPress } from './react-hook-utils';
import { useKeymap } from './use-keymap';
import { useKeymaps } from './use-keymaps';

interface MenuNavigationProps<Item = any> extends MenuNavigationOptions {
  /**
   * The items that will be rendered as part of the dropdown menu.
   *
   * When the items are an empty array then nothing will be shown.
   */
  items: Item[];

  /**
   * Set to `true` when the menu should be visible.
   */
  isOpen: boolean;

  /**
   * Called when submitting the inline menu via the keyboard.
   *
   * Currently the hardcoded submit key is `Enter`
   *
   * Return `true` to indicate the event was handled or false to indicated that
   * nothing has been done.
   */
  onSubmit: (item: Item, type: 'click' | 'keyPress') => boolean;

  /**
   * Called when dismissing the inline menu.
   *
   * Currently `Tab` and `Escape` dismiss the menu.
   *
   * Return `true` to indicate the event was handled or false to indicated that
   * nothing has been done.
   */
  onDismiss: () => boolean;
}

export interface MenuNavigationOptions {
  /**
   * The direction of the arrow key press.
   *
   * @defaultValue 'vertical';
   */
  direction?: MenuDirection;

  /**
   * Keys that can submit the selection.
   *
   * @defaultValue ['Enter']
   */
  submitKeys?: KeyBindingNames[];

  /**
   * Keys that can dismiss the menu.
   *
   * @defaultValue ['Escape', 'Tab', 'Shift-Tab']
   */
  dismissKeys?: KeyBindingNames[];

  /**
   * When true, refocus the editor when a click is made.
   *
   * @defaultValue true
   */
  focusOnClick?: boolean;
}

export type MenuDirection = 'horizontal' | 'vertical';

export interface UseMenuNavigationReturn<Item = any>
  extends Pick<MultishiftPropGetters<Item>, 'getMenuProps' | 'getItemProps'>,
    Pick<
      MultishiftHelpers<Item>,
      'itemIsSelected' | 'indexIsSelected' | 'indexIsHovered' | 'itemIsHovered'
    >,
    Pick<MultishiftState<Item>, 'hoveredIndex'> {
  /**
   * The selected index.
   */
  index: number;

  setIndex: (index: number) => void;
}

const DEFAULT_DISMISS_KEYS = ['Escape', 'Tab', 'Shift-Tab'];
const DEFAULT_SUBMIT_KEYS = ['Enter'];

/**
 * This hook provides the primitives for rendering a dropdown menu within
 */
export function useMenuNavigation<Item = any>(
  props: MenuNavigationProps,
): UseMenuNavigationReturn<Item> {
  const {
    items,
    direction = 'vertical',
    isOpen,
    onDismiss,
    onSubmit,
    focusOnClick = true,
    dismissKeys = DEFAULT_DISMISS_KEYS,
    submitKeys = DEFAULT_SUBMIT_KEYS,
  } = props;
  const [index, setIndex] = useState(0);
  const { focus } = useCommands();

  const nextShortcut = direction === 'vertical' ? 'ArrowDown' : 'ArrowRight';
  const previousShortcut = direction === 'vertical' ? 'ArrowUp' : 'ArrowLeft';

  const {
    getMenuProps,
    getItemProps: _getItemProps,
    hoveredIndex,
    itemIsSelected,
    indexIsSelected,
    indexIsHovered,
    itemIsHovered,
  } = useMultishift<Item>({
    items,
    isOpen,
    highlightedIndexes: 0 <= index && index < items.length ? [index] : [],
    type: Type.ControlledMenu,
  });

  /**
   * Callback used when pressing the next arrow key.
   */
  const homeCallback: KeyBindingCommandFunction = useCallback(() => {
    if (!isOpen) {
      return false;
    }

    if (index !== 0) {
      setIndex(0);
    }

    return true;
  }, [index, isOpen]);

  /**
   * Callback used when pressing the next arrow key.
   */
  const endCallback: KeyBindingCommandFunction = useCallback(() => {
    if (!isOpen) {
      return false;
    }

    if (index === items.length - 1) {
      setIndex(items.length - 1);
    }

    return true;
  }, [items, index, isOpen]);

  /**
   * Callback used when pressing the next arrow key.
   */
  const nextCallback: KeyBindingCommandFunction = useCallback(() => {
    if (!isOpen) {
      return false;
    }

    setIndex(
      indexFromArrowPress({
        direction: 'next',
        matchLength: items.length,
        previousIndex: index,
      }),
    );
    return true;
  }, [items, index, isOpen]);

  /**
   * Callback used when pressing the previous arrow key.
   */
  const previousCallback: KeyBindingCommandFunction = useCallback(() => {
    if (!isOpen) {
      return false;
    }

    setIndex(
      indexFromArrowPress({
        direction: 'previous',
        matchLength: items.length,
        previousIndex: index,
      }),
    );

    return true;
  }, [items, index, isOpen]);

  const submitCallback: KeyBindingCommandFunction = useCallback(() => {
    const item = items[index];

    if (!isOpen || !item) {
      return false;
    }

    return onSubmit(item, 'keyPress');
  }, [index, isOpen, items, onSubmit]);

  const dismissCallback: KeyBindingCommandFunction = useCallback(() => {
    if (!isOpen) {
      return false;
    }

    return onDismiss();
  }, [isOpen, onDismiss]);

  /**
   * Automatically select the item when clicked.
   */
  const getItemProps: MultishiftPropGetters<Item>['getItemProps'] = useCallback(
    (itemProps) => ({
      ..._getItemProps({
        ...itemProps,
        onClick: (event) => {
          itemProps.onClick?.(event);
          onSubmit(itemProps.item, 'click');

          if (focusOnClick) {
            focus();
          }
        },
      }),
    }),
    [_getItemProps, onSubmit, focus, focusOnClick],
  );

  const submitBindings: KeyBindings = useMemo(() => {
    const bindings: KeyBindings = {};

    for (const key of submitKeys) {
      bindings[key] = submitCallback;
    }

    return bindings;
  }, [submitCallback, submitKeys]);

  const dismissBindings: KeyBindings = useMemo(() => {
    const bindings: KeyBindings = {};

    for (const key of dismissKeys) {
      bindings[key] = dismissCallback;
    }

    return bindings;
  }, [dismissCallback, dismissKeys]);

  // Navigation callbacks
  useKeymap(nextShortcut, nextCallback);
  useKeymap(previousShortcut, previousCallback);
  useKeymap('Home', homeCallback);
  useKeymap(`Cmd-${nextShortcut}`, homeCallback);
  useKeymap('End', nextCallback);
  useKeymap(`Cmd-${previousShortcut}`, endCallback);

  // Handle the submit keybindings
  useKeymaps(submitBindings);

  // Handle the dismiss bindings.
  useKeymaps(dismissBindings);

  return useMemo(
    () => ({
      getMenuProps,
      getItemProps,
      hoveredIndex,
      indexIsSelected,
      itemIsSelected,
      indexIsHovered,
      itemIsHovered,
      index,
      setIndex,
    }),
    [
      getItemProps,
      getMenuProps,
      hoveredIndex,
      indexIsHovered,
      indexIsSelected,
      itemIsHovered,
      itemIsSelected,
      index,
    ],
  );
}