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
132
133
134
135
136
137
138
139
|
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useHistory } from '../lib/history';
import { useEffectEvent } from '../lib/utility-hooks';
interface IKeyboardNavigationProps {
children: React.ReactElement | Array<React.ReactElement>;
}
// Listens for and handles keyboard shortcuts
export default function KeyboardNavigation(props: IKeyboardNavigationProps) {
const { pop } = useHistory();
const [backAction, setBackActionImpl] = useState<BackActionFn>();
// Since the backaction is now a function we need to make sure it's not called when setting the
// state.
const setBackAction = useCallback((backAction: BackActionFn | undefined) => {
setBackActionImpl(() => backAction);
}, []);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.shiftKey && window.env.development) {
pop(true);
} else {
backAction?.();
}
}
},
[pop, backAction],
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
return <BackActionTracker registerBackAction={setBackAction}>{props.children}</BackActionTracker>;
}
type BackActionFn = () => void;
interface IBackActionContext {
parentBackAction?: BackActionFn;
registerBackAction: (backAction: BackActionFn) => void;
removeBackAction: (backAction: BackActionFn) => 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;
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 { registerBackAction, removeBackAction } = useContext(BackActionContext);
const [childrenBackAction, setChildrenBackActionImpl] = useState<BackActionFn>();
// Since the backaction is now a function we need to make sure it's not called when setting the
// state.
const setChildrenBackAction = useCallback((backAction: BackActionFn | undefined) => {
setChildrenBackActionImpl(() => backAction);
}, []);
// Each back action needs to be unique to make `removeBackAction` work. This is accomplished by
// wrapping it in a callback. This was an issue since `history.pop`, which is commonly used as a
// back action, is the same function for every component.
const backAction = useCallback(() => {
(childrenBackAction ?? props.action)();
}, [props.action, childrenBackAction]);
// Every time the action or the disabled property changes the action needs to be reregistered.
useEffect((): (() => void) | void => {
if (!props.disabled && backAction) {
registerBackAction(backAction);
return () => removeBackAction(backAction);
}
}, [props.disabled, backAction, registerBackAction, removeBackAction]);
// 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={props.action}>
{props.children}
</BackActionTracker>
);
}
interface IBackActionTracker {
parentBackAction?: BackActionFn;
registerBackAction: (backAction: BackActionFn | 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<BackActionFn>>([]);
const registerBackAction = useCallback((backAction: BackActionFn) => {
setBackActions((backActions) => [...backActions, backAction]);
}, []);
const removeBackAction = useCallback((backAction: BackActionFn) => {
setBackActions((backActions) => backActions.filter((action) => action !== backAction));
}, []);
const backActionContext = useMemo(
() => ({ parentBackAction: props.parentBackAction, registerBackAction, removeBackAction }),
[props.parentBackAction, registerBackAction, removeBackAction],
);
const registerBackActionEvent = useEffectEvent((backActions: Array<BackActionFn>) => {
props.registerBackAction(backActions.at(0));
});
// These lint rules are disabled for now because the react plugin for eslint does
// not understand that useEffectEvent should not be added to the dependency array.
// Enable these rules again when eslint can lint useEffectEvent properly.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => registerBackActionEvent(backActions), [backActions]);
return (
<BackActionContext.Provider value={backActionContext}>
{props.children}
</BackActionContext.Provider>
);
}
|