summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer/components/KeyboardNavigation.tsx
blob: 8e8bae5fafa56ba3ee693d7b6b0b3a8b0dff36ed (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router';

import { useHistory } from '../lib/history';
import { disableDismissForRoutes, RoutePath } from '../lib/routes';

interface IKeyboardNavigationProps {
  children: React.ReactElement | Array<React.ReactElement>;
}

// Listens for and handles keyboard shortcuts
export default function KeyboardNavigation(props: IKeyboardNavigationProps) {
  const history = useHistory();
  const [backAction, setBackAction] = useState<IBackActionConfiguration>();
  const location = useLocation();

  const handleKeyDown = useCallback(
    (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        const path = location.pathname as RoutePath;
        if (!disableDismissForRoutes.includes(path)) {
          if (event.shiftKey) {
            history.pop(true);
          } else {
            backAction?.action();
          }
        }
      }
    },
    [history.pop, backAction, location.pathname],
  );

  useEffect(() => {
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [handleKeyDown]);

  return <BackActionTracker registerBackAction={setBackAction}>{props.children}</BackActionTracker>;
}

type BackActionIcon = 'back' | 'close';
type BackActionFn = () => void;

interface IBackActionConfiguration {
  icon: BackActionIcon;
  action: BackActionFn;
}

interface IBackActionContext {
  parentBackAction?: IBackActionConfiguration;
  registerBackAction: (backAction: IBackActionConfiguration) => void;
  removeBackAction: (backAction: IBackActionConfiguration) => void;
}

export const BackActionContext = React.createContext<IBackActionContext>({
  registerBackAction(_backAction) {
    throw new Error('Missing BackActionContext');
  },
  removeBackAction(_backAction) {
    throw new Error('Missing BackActionContext');
  },
});

interface IBackActionProps {
  disabled?: boolean;
  icon?: BackActionIcon;
  action: BackActionFn;
  children: React.ReactNode;
}

// Component for registering back actions, e.g. navigate back or close modal. These are called
// either by pressing the back button in the navigation bar or by pressing escape.
export function BackAction(props: IBackActionProps) {
  const backActionContext = useContext(BackActionContext);
  const [childrenBackAction, setChildrenBackAction] = useState<IBackActionConfiguration>();

  const parentBackAction = useMemo<IBackActionConfiguration>(
    () => ({ icon: props.icon ?? 'back', action: props.action }),
    [props.icon, props.action],
  );
  const backActionConfiguration = childrenBackAction ?? parentBackAction;

  // Every time the action or the disabled property changes the action needs to be reregistered.
  useEffect((): (() => void) | void => {
    if (!props.disabled && backActionConfiguration) {
      backActionContext.registerBackAction(backActionConfiguration);
      return () => backActionContext.removeBackAction(backActionConfiguration);
    }
  }, [props.disabled, backActionConfiguration]);

  // Every back action keeps track of the back actions in its subtree. This makes it possible to
  // always use the action furthest down in the tree.
  return (
    <BackActionTracker
      registerBackAction={setChildrenBackAction}
      parentBackAction={parentBackAction}>
      {props.children}
    </BackActionTracker>
  );
}

interface IBackActionTracker {
  parentBackAction?: IBackActionConfiguration;
  registerBackAction: (backAction: IBackActionConfiguration | undefined) => void;
  children: React.ReactNode;
}

// This component keeps track of all registered back actions in it's subtree and reports one of them
// to it's parent.
function BackActionTracker(props: IBackActionTracker) {
  const [backActions, setBackActions] = useState<Array<IBackActionConfiguration>>([]);

  const registerBackAction = useCallback((backAction: IBackActionConfiguration) => {
    setBackActions((backActions) => [...backActions, backAction]);
  }, []);
  const removeBackAction = useCallback((backAction: IBackActionConfiguration) => {
    setBackActions((backActions) => backActions.filter((action) => action !== backAction));
  }, []);
  const backActionContext = useMemo(
    () => ({ parentBackAction: props.parentBackAction, registerBackAction, removeBackAction }),
    [backActions],
  );

  useEffect(() => props.registerBackAction(backActions.at(0)), [backActions]);

  return (
    <BackActionContext.Provider value={backActionContext}>
      {props.children}
    </BackActionContext.Provider>
  );
}