Browse Source

feat(component): Component that renders keyboard hotkeys (#35234)

Co-authored-by: Evan Purkhiser <evanpurkhiser@gmail.com>
Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Jenn Mueng 2 years ago
parent
commit
f9d35972ed

+ 44 - 0
docs-ui/stories/components/hotkeysLabel.stories.js

@@ -0,0 +1,44 @@
+import HotkeysLabel from 'sentry/components/hotkeysLabel';
+
+export default {
+  title: 'Components/HotkeysLabel',
+  argTypes: {
+    platform: {
+      options: ['macos', 'generic'],
+      control: {type: 'radio'},
+    },
+  },
+  component: HotkeysLabel,
+};
+
+export const Default = args => (
+  <div>
+    <HotkeysLabel
+      value={['command+option+a', 'ctrl+alt+a']}
+      forcePlatform={args.platform}
+    />
+    <HotkeysLabel value="shift+!" forcePlatform={args.platform} />
+    <HotkeysLabel value="ctrl+alt+delete" forcePlatform={args.platform} />
+    <HotkeysLabel value="fn+backspace" forcePlatform={args.platform} />
+    <HotkeysLabel value="left+right+up+down" forcePlatform={args.platform} />
+    <HotkeysLabel value={['command+space', 'alt+space']} forcePlatform={args.platform} />
+    <HotkeysLabel value=";+:+[+]" forcePlatform={args.platform} />
+    Fallback to entirely different key combination:
+    <HotkeysLabel value={['command+control', 'alt']} forcePlatform={args.platform} />
+    No fallback for windows
+    <HotkeysLabel value={['command+option']} forcePlatform={args.platform} />
+  </div>
+);
+
+Default.args = {
+  platform: 'macos',
+};
+
+Default.storyName = 'HotkeysLabel';
+Default.parameters = {
+  docs: {
+    description: {
+      story: 'Render some cool keyboard keys',
+    },
+  },
+};

+ 100 - 0
static/app/components/hotkeysLabel.tsx

@@ -0,0 +1,100 @@
+import styled from '@emotion/styled';
+
+import space from 'sentry/styles/space';
+import {getKeyCode} from 'sentry/utils/getKeyCode';
+
+const macModifiers = {
+  18: '⌥',
+  17: '⌃',
+  91: '⌘',
+};
+
+const normalModifiers = {
+  18: 'ALT',
+  17: 'CTRL',
+};
+
+const genericGlyphs = {
+  16: '⇧',
+  8: '⌫',
+  37: '←',
+  38: '↑',
+  39: '→',
+  40: '↓',
+  107: '+',
+};
+
+const keyToDisplay = (
+  key: string,
+  isMac: boolean
+): {label: React.ReactNode; specificToOs: 'macos' | 'generic'} => {
+  const keyCode = getKeyCode(key);
+
+  // Not a special key
+  if (!keyCode) {
+    return {label: <Key>{key.toUpperCase()}</Key>, specificToOs: 'generic'};
+  }
+
+  const modifierMap = isMac ? macModifiers : normalModifiers;
+  const keyStr = modifierMap[keyCode] ?? genericGlyphs[keyCode] ?? key.toUpperCase();
+
+  const specificToOs = keyCode === getKeyCode('command') ? 'macos' : 'generic';
+
+  return {label: <Key key={keyStr}>{keyStr}</Key>, specificToOs};
+};
+
+type Props = {
+  /**
+   * Pass key combinations in with + as the separator.
+   * For example: `'command+option+x'`
+   *
+   * Pass an array of strings for fallback key combos when the first one contains a key that does not exist on that os (non-mac):
+   * `['command+option+x', 'ctrl+shift+x']`
+   * (does not have to be the same combo)
+   */
+  value: string[] | string;
+  forcePlatform?: 'macos' | 'generic';
+};
+
+const HotkeysLabel = ({value, forcePlatform}: Props) => {
+  // Split by commas and then split by +, but allow escaped /+
+  const hotkeySets = (Array.isArray(value) ? value : [value]).map(o =>
+    o.trim().split('+')
+  );
+
+  const isMac = forcePlatform
+    ? forcePlatform === 'macos'
+    : window?.navigator?.platform?.toLowerCase().startsWith('mac') ?? false;
+
+  // If we're not using mac find the first key set that is generic.
+  // Otherwise show whatever the first hotkey is.
+  const finalKeySet = hotkeySets
+    .map(keySet => keySet.map(key => keyToDisplay(key, isMac)))
+    .find(keySet =>
+      !isMac ? keySet.every(key => key.specificToOs === 'generic') : true
+    );
+
+  // No key available for the OS. Don't show a hotkey
+  if (finalKeySet === undefined) {
+    return null;
+  }
+
+  return <HotkeysContainer>{finalKeySet.map(key => key.label)}</HotkeysContainer>;
+};
+
+export default HotkeysLabel;
+
+const Key = styled('span')`
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
+const HotkeysContainer = styled('div')`
+  font-family: ${p => p.theme.text.family};
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > * {
+    margin-right: ${space(1)};
+  }
+`;

+ 81 - 0
static/app/utils/getKeyCode.ts

@@ -0,0 +1,81 @@
+// key maps and utils retrieved from https://github.com/jaywcjlove/hotkeys
+
+/**
+ * Includes a lot of leftover unused codes for the future in case we
+ * want glyphs for them
+ */
+const keyNameCodeMapping = {
+  backspace: 8,
+  tab: 9,
+  clear: 12,
+  enter: 13,
+  return: 13,
+  esc: 27,
+  escape: 27,
+  space: 32,
+  left: 37,
+  up: 38,
+  right: 39,
+  down: 40,
+  del: 46,
+  delete: 46,
+  ins: 45,
+  insert: 45,
+  home: 36,
+  end: 35,
+  pageup: 33,
+  pagedown: 34,
+  capslock: 20,
+  num_0: 96,
+  num_1: 97,
+  num_2: 98,
+  num_3: 99,
+  num_4: 100,
+  num_5: 101,
+  num_6: 102,
+  num_7: 103,
+  num_8: 104,
+  num_9: 105,
+  num_multiply: 106,
+  num_add: 107,
+  num_enter: 108,
+  num_subtract: 109,
+  num_decimal: 110,
+  num_divide: 111,
+  '⇪': 20,
+  ',': 188,
+  '.': 190,
+  '/': 191,
+  '`': 192,
+  '-': 189,
+  '=': 187,
+  ';': 186,
+  "'": 222,
+  '[': 219,
+  ']': 221,
+  '\\': 220,
+};
+
+// Modifier Keys
+const modifierNameKeyCodeMapping = {
+  // shiftKey
+  '⇧': 16,
+  shift: 16,
+  // altKey
+  '⌥': 18,
+  alt: 18,
+  option: 18,
+  // ctrlKey
+  '⌃': 17,
+  ctrl: 17,
+  control: 17,
+  // metaKey
+  '⌘': 91,
+  cmd: 91,
+  command: 91,
+};
+
+export const getKeyCode = (x: string): number =>
+  keyNameCodeMapping[x.toLowerCase()] ||
+  modifierNameKeyCodeMapping[x.toLowerCase()] ||
+  x.toUpperCase().charCodeAt(0);

+ 39 - 0
tests/js/spec/components/hotkeysLabel.spec.jsx

@@ -0,0 +1,39 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import HotkeysLabel from 'sentry/components/hotkeysLabel';
+
+describe('HotkeysLabel', function () {
+  it('ctrl+alt+delete mac', function () {
+    render(<HotkeysLabel value={['ctrl+alt+delete']} forcePlatform="macos" />);
+    expect(screen.getByText('⌃')).toBeInTheDocument();
+    expect(screen.getByText('⌥')).toBeInTheDocument();
+    expect(screen.getByText('DELETE')).toBeInTheDocument();
+  });
+
+  it('ctrl+alt+delete windows', function () {
+    render(<HotkeysLabel value={['ctrl+alt+delete']} forcePlatform="generic" />);
+    expect(screen.getByText('CTRL')).toBeInTheDocument();
+    expect(screen.getByText('ALT')).toBeInTheDocument();
+    expect(screen.getByText('DELETE')).toBeInTheDocument();
+  });
+
+  it('falls back when not on mac', function () {
+    render(<HotkeysLabel value={['cmd', 'alt']} forcePlatform="generic" />);
+    expect(screen.queryByText('⌘')).not.toBeInTheDocument();
+    expect(screen.queryByText('CMD')).not.toBeInTheDocument();
+
+    expect(screen.getByText('ALT')).toBeInTheDocument();
+  });
+
+  it('does not render at all without fallback', function () {
+    render(<HotkeysLabel value={['cmd+k', 'cmd+alt+l']} forcePlatform="generic" />);
+    expect(screen.queryByText('⌘')).not.toBeInTheDocument();
+    expect(screen.queryByText('L')).not.toBeInTheDocument();
+    expect(screen.queryByText('ALT')).not.toBeInTheDocument();
+  });
+
+  it('takes just a string', function () {
+    render(<HotkeysLabel value="option" forcePlatform="generic" />);
+    expect(screen.getByText('ALT')).toBeInTheDocument();
+  });
+});