Skip to content

reaviz/reakeys

Repository files navigation

⌨️ reakeys


React Hook for Ctrl-keys Hotkeys


🚀 Quick Links

📦 Usage

Install via NPM:

yarn add reakeys

Then in your component, just add the useHotkeys hook and specify your keys like:

import React, { FC } from 'react';
import { useHotkeys } from 'reakeys';

export const YourComponent: FC = () => {
  useHotkeys([
    {
      name: 'Dashboard',
      keys: 'mod+shift+d',
      category: 'Navigation',
      callback: event => {
        event.preventDefault();
        history.push('/dashboard');
      }
    }
  ]);
};

Below are the options you can set in the hook array:

type HotkeyShortcuts = {
  name: string;
  category?: string;
  description?: string;
  keys: string | string[];
  ref?: any;
  hidden?: boolean;
  disabled?: boolean;
  callback: (e: ExtendedKeyboardEvent, combo: string) => void;
  action?: 'keypress'| 'keydown'| 'keyup';
};

You can also get all the hotkeys that are registered by just calling the useHotkeys hook and it will return the current hotkeys.

const hotkeys = useHotkeys();

This is useful for creating a dialog to present the user with all the options. Below is an example of how to make a dialog using reablocks:

import React, { useState, FC, useCallback, useMemo } from 'react';
import { Dialog } from 'reablocks';
import { useHotkeys, getHotkeyText } from 'reakeys';
import groupBy from 'lodash/groupBy';
import sortBy from 'lodash/sortBy';

export const HotkeyCombos: FC = () => {
  // useHotkeys returns the same object if the hotkeys haven't changed, meaning
  // that you can use useMemo to avoid expensive recalculation in that case.
  //
  // Note that the object will change if another component passes a different
  // object to their useHotkeys, even if that component doesn't actually change
  // anything. In React <18, it will cause two re-renders in a row.
  //
  // There is another long comment at the bottom of this example explaining why
  // useMemo is important.

  const hotkeys = useHotkeys();
  const categories = useMemo(() => groupBy(hotkeys, 'category'), [hotkeys]);

  const sorted = useMemo(() => Object.keys(categories).reduce((prev, cur) => {
    const category = sortBy(categories[cur], 'name');
    const label = cur === 'undefined' ? 'General' : cur;

    return {
      ...prev,
      [label]: category.filter(k => !k.hidden)
    };
  }, {}), [categories]);

  const { General, ...rest } = sorted as any;
  const others = sortBy(Object.keys(rest || {}));

  const renderKeyCode = useCallback(keyCode => {
    const wrapped = Array.isArray(keyCode) ? keyCode : [keyCode];
    const formatted = wrapped.map(k => getHotkeyText(k));

    return (
      <div className={css.keyComboContainer}>
        {formatted.map((k, i) => (
          <kbd key={i} className={css.keyCombo}>
            {k}
          </kbd>
        ))}
      </div>
    );
  }, []);

  const renderGroups = useCallback(
    group => {
      if (!sorted[group]) {
        return null;
      }

      return (
        <div key={group}>
          <h3>{group}</h3>
          <ul className={css.list}>
            {sorted[group].map(kk => (
              <li key={kk.name} className={css.listItem}>
                <label>{kk.name}</label>
                {renderKeyCode(kk.keys)}
                {kk.description && <p>{kk.description}</p>}
              </li>
            ))}
          </ul>
        </div>
      );
    },
    [renderKeyCode, sorted]
  );

  return (
    <div className={css.groups}>
      {renderGroups('General')}
      {others.map(renderGroups)}
    </div>
  );
};

export const HotkeyDialog: FC = () => {
  const [visible, setVisible] = useState<boolean>(false);
  const openDialog = useCallback(() => setVisible(true), [setVisible]);
  const closeDialog = useCallback(() => setVisible(false), [setVisible]);

  // If your hotkeys haven't changed, it's important to provide the same object
  // to useHotkeys, or else it will remove and replace your hotkeys.
  //
  // That isn't always a bad thing, and works perfectly fine, but it would cause
  // unnecessary updates if other components also call useHotkeys() to retrieve
  // the list of hotkeys, because this component would update it every render.
  //
  // Ideally, you should only change the object passed to useHotkeys when the
  // actual hotkeys have changed (name for instance, when using i18n).
  // useMemo is good for this.

  useHotkeys(useMemo(() => [
    {
      name: 'Hotkey Dialog',
      keys: 'SHIFT+?',
      hidden: true,
      callback: openDialog
    }
  ], [openDialog]));

  const combosRenderer = useCallback(() => <HotkeyCombos />, [HotkeyCombos]);

  return (
    <Dialog
      size="800px"
      header="Hotkeys"
      open={visible}
      onClose={closeDialog}
    >
      {combosRenderer}
    </Dialog>
  );
};

You can also get a formatted version of the hotkey combo text:

import { getHotkeyText } from 'reakeys';

getHotkeyText('mod+shift+a'); //=> '⌘+⇧+a'

🔭 Development

If you want to run reakeys locally, its super easy!

  • Clone the repo
  • yarn install
  • yarn start
  • Browser opens to Storybook page

❤️ Contributors

Thanks to all our contributors!