Replace hotkey scopes by focus stack (Part 1 - Dropdowns and Side Panel) (#12673)
This PR is the first part of a refactoring aiming to deprecate the hotkey scopes api in favor of the new focus stack api which is more robust. The refactored components in this PR are the dropdowns and the side panel/command menu. - Replaced `useScopedHotkeys` by `useHotkeysOnFocusedElement` for all dropdown components, selectable lists and the command menu - Introduced `focusId` for all dropdowns and created a common hotkey scope `DropdownHotkeyScope` for backward compatibility - Replaced `setHotkeyScopeAndMemorizePreviousScope` occurrences with `usePushFocusItemToFocusStack` and `goBackToPreviousHotkeyScope` with `removeFocusItemFromFocusStack` Note: Test that the shorcuts and arrow key navigation still work properly when interacting with dropdowns and the command menu. Bugs that I have spotted during the QA but which are already present on main: - Icon picker select with arrow keys doesn’t work inside dropdowns - Some dropdowns are not selectable with arrow keys (no selectable list) - Dropdowns in dropdowns don’t reset the hotkey scope correctly when closing - The table click outside is not triggered after closing a table cell and clicking outside of the table
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { DEBUG_HOTKEY_SCOPE } from '@/ui/utilities/hotkey/constants/DebugHotkeyScope';
|
||||
|
||||
export const DEBUG_FOCUS_STACK = DEBUG_HOTKEY_SCOPE;
|
||||
+4
-4
@@ -9,12 +9,12 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
const renderHooks = () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const pushFocusItem = usePushFocusItemToFocusStack();
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const focusStack = useRecoilValue(focusStackState);
|
||||
const currentFocusId = useRecoilValue(currentFocusIdSelector);
|
||||
|
||||
return {
|
||||
pushFocusItem,
|
||||
pushFocusItemToFocusStack,
|
||||
focusStack,
|
||||
currentFocusId,
|
||||
};
|
||||
@@ -46,7 +46,7 @@ describe('usePushFocusItemToFocusStack', () => {
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
result.current.pushFocusItem({
|
||||
result.current.pushFocusItemToFocusStack({
|
||||
focusId: focusItem.focusId,
|
||||
component: {
|
||||
type: focusItem.componentInstance.componentType,
|
||||
@@ -73,7 +73,7 @@ describe('usePushFocusItemToFocusStack', () => {
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
result.current.pushFocusItem({
|
||||
result.current.pushFocusItemToFocusStack({
|
||||
focusId: anotherFocusItem.focusId,
|
||||
component: {
|
||||
type: anotherFocusItem.componentInstance.componentType,
|
||||
|
||||
+11
-10
@@ -1,5 +1,5 @@
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
import { useRemoveFocusIdFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack';
|
||||
import { useRemoveFocusItemFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStack';
|
||||
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
|
||||
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
@@ -10,14 +10,15 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
const renderHooks = () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const pushFocusItem = usePushFocusItemToFocusStack();
|
||||
const removeFocusId = useRemoveFocusIdFromFocusStack();
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { removeFocusItemFromFocusStack } =
|
||||
useRemoveFocusItemFromFocusStack();
|
||||
const focusStack = useRecoilValue(focusStackState);
|
||||
const currentFocusId = useRecoilValue(currentFocusIdSelector);
|
||||
|
||||
return {
|
||||
pushFocusItem,
|
||||
removeFocusId,
|
||||
pushFocusItemToFocusStack,
|
||||
removeFocusItemFromFocusStack,
|
||||
focusStack,
|
||||
currentFocusId,
|
||||
};
|
||||
@@ -30,8 +31,8 @@ const renderHooks = () => {
|
||||
return { result };
|
||||
};
|
||||
|
||||
describe('useRemoveFocusIdFromFocusStack', () => {
|
||||
it('should remove focus id from the stack', async () => {
|
||||
describe('useRemoveFocusItemFromFocusStack', () => {
|
||||
it('should remove focus item from the stack', async () => {
|
||||
const { result } = renderHooks();
|
||||
|
||||
const firstFocusItem = {
|
||||
@@ -59,7 +60,7 @@ describe('useRemoveFocusIdFromFocusStack', () => {
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
result.current.pushFocusItem({
|
||||
result.current.pushFocusItemToFocusStack({
|
||||
focusId: firstFocusItem.focusId,
|
||||
component: {
|
||||
type: firstFocusItem.componentInstance.componentType,
|
||||
@@ -71,7 +72,7 @@ describe('useRemoveFocusIdFromFocusStack', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.pushFocusItem({
|
||||
result.current.pushFocusItemToFocusStack({
|
||||
focusId: secondFocusItem.focusId,
|
||||
component: {
|
||||
type: secondFocusItem.componentInstance.componentType,
|
||||
@@ -89,7 +90,7 @@ describe('useRemoveFocusIdFromFocusStack', () => {
|
||||
expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId);
|
||||
|
||||
await act(async () => {
|
||||
result.current.removeFocusId({
|
||||
result.current.removeFocusItemFromFocusStack({
|
||||
focusId: firstFocusItem.focusId,
|
||||
memoizeKey: 'global',
|
||||
});
|
||||
+4
-4
@@ -10,13 +10,13 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
const renderHooks = () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const pushFocusItem = usePushFocusItemToFocusStack();
|
||||
const resetFocusStack = useResetFocusStack();
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { resetFocusStack } = useResetFocusStack();
|
||||
const focusStack = useRecoilValue(focusStackState);
|
||||
const currentFocusId = useRecoilValue(currentFocusIdSelector);
|
||||
|
||||
return {
|
||||
pushFocusItem,
|
||||
pushFocusItemToFocusStack,
|
||||
resetFocusStack,
|
||||
focusStack,
|
||||
currentFocusId,
|
||||
@@ -47,7 +47,7 @@ describe('useResetFocusStack', () => {
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
result.current.pushFocusItem({
|
||||
result.current.pushFocusItemToFocusStack({
|
||||
focusId: focusItem.focusId,
|
||||
component: {
|
||||
type: focusItem.componentInstance.componentType,
|
||||
|
||||
+5
-5
@@ -10,13 +10,13 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
const renderHooks = () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const pushFocusItem = usePushFocusItemToFocusStack();
|
||||
const resetFocusStackToFocusItem = useResetFocusStackToFocusItem();
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
|
||||
const focusStack = useRecoilValue(focusStackState);
|
||||
const currentFocusId = useRecoilValue(currentFocusIdSelector);
|
||||
|
||||
return {
|
||||
pushFocusItem,
|
||||
pushFocusItemToFocusStack,
|
||||
resetFocusStackToFocusItem,
|
||||
focusStack,
|
||||
currentFocusId,
|
||||
@@ -59,7 +59,7 @@ describe('useResetFocusStackToFocusItem', () => {
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
result.current.pushFocusItem({
|
||||
result.current.pushFocusItemToFocusStack({
|
||||
focusId: firstFocusItem.focusId,
|
||||
component: {
|
||||
type: firstFocusItem.componentInstance.componentType,
|
||||
@@ -71,7 +71,7 @@ describe('useResetFocusStackToFocusItem', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.pushFocusItem({
|
||||
result.current.pushFocusItemToFocusStack({
|
||||
focusId: secondFocusItem.focusId,
|
||||
component: {
|
||||
type: secondFocusItem.componentInstance.componentType,
|
||||
|
||||
+38
-18
@@ -1,3 +1,4 @@
|
||||
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
|
||||
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
|
||||
@@ -5,26 +6,27 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH
|
||||
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
|
||||
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { logDebug } from '~/utils/logDebug';
|
||||
|
||||
const addOrMoveItemToTheTopOfTheStack = ({
|
||||
focusStackItem,
|
||||
currentFocusStack,
|
||||
}: {
|
||||
focusStackItem: FocusStackItem;
|
||||
currentFocusStack: FocusStackItem[];
|
||||
}) => [
|
||||
...currentFocusStack.filter(
|
||||
(currentFocusStackItem) =>
|
||||
currentFocusStackItem.focusId !== focusStackItem.focusId,
|
||||
),
|
||||
focusStackItem,
|
||||
];
|
||||
|
||||
export const usePushFocusItemToFocusStack = () => {
|
||||
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
|
||||
|
||||
const addOrMoveItemToTheTopOfTheStack = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(focusStackItem: FocusStackItem) => {
|
||||
set(focusStackState, (currentFocusStack) => [
|
||||
...currentFocusStack.filter(
|
||||
(currentFocusStackItem) =>
|
||||
currentFocusStackItem.focusId !== focusStackItem.focusId,
|
||||
),
|
||||
focusStackItem,
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return useRecoilCallback(
|
||||
() =>
|
||||
const pushFocusItemToFocusStack = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
({
|
||||
focusId,
|
||||
component,
|
||||
@@ -57,7 +59,23 @@ export const usePushFocusItemToFocusStack = () => {
|
||||
},
|
||||
};
|
||||
|
||||
addOrMoveItemToTheTopOfTheStack(focusStackItem);
|
||||
const currentFocusStack = snapshot
|
||||
.getLoadable(focusStackState)
|
||||
.getValue();
|
||||
|
||||
const newFocusStack = addOrMoveItemToTheTopOfTheStack({
|
||||
focusStackItem,
|
||||
currentFocusStack,
|
||||
});
|
||||
|
||||
set(focusStackState, newFocusStack);
|
||||
|
||||
if (DEBUG_FOCUS_STACK) {
|
||||
logDebug(`DEBUG: pushFocusItemToFocusStack ${focusId}`, {
|
||||
focusStackItem,
|
||||
newFocusStack,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Remove this once we've migrated hotkey scopes to the new api
|
||||
setHotkeyScopeAndMemorizePreviousScope({
|
||||
@@ -66,6 +84,8 @@ export const usePushFocusItemToFocusStack = () => {
|
||||
memoizeKey,
|
||||
});
|
||||
},
|
||||
[setHotkeyScopeAndMemorizePreviousScope, addOrMoveItemToTheTopOfTheStack],
|
||||
[setHotkeyScopeAndMemorizePreviousScope],
|
||||
);
|
||||
|
||||
return { pushFocusItemToFocusStack };
|
||||
};
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
export const useRemoveFocusIdFromFocusStack = () => {
|
||||
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
|
||||
|
||||
return useRecoilCallback(
|
||||
({ set }) =>
|
||||
({ focusId, memoizeKey }: { focusId: string; memoizeKey: string }) => {
|
||||
set(focusStackState, (previousFocusStack) =>
|
||||
previousFocusStack.filter(
|
||||
(focusStackItem) => focusStackItem.focusId !== focusId,
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: Remove this once we've migrated hotkey scopes to the new api
|
||||
goBackToPreviousHotkeyScope(memoizeKey);
|
||||
},
|
||||
[goBackToPreviousHotkeyScope],
|
||||
);
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
|
||||
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { logDebug } from '~/utils/logDebug';
|
||||
|
||||
export const useRemoveFocusItemFromFocusStack = () => {
|
||||
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
|
||||
|
||||
const removeFocusItemFromFocusStack = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
({ focusId, memoizeKey }: { focusId: string; memoizeKey: string }) => {
|
||||
const focusStack = snapshot.getLoadable(focusStackState).getValue();
|
||||
|
||||
const newFocusStack = focusStack.filter(
|
||||
(focusStackItem) => focusStackItem.focusId !== focusId,
|
||||
);
|
||||
|
||||
set(focusStackState, newFocusStack);
|
||||
|
||||
if (DEBUG_FOCUS_STACK) {
|
||||
logDebug(`DEBUG: removeFocusItemFromFocusStack ${focusId}`, {
|
||||
newFocusStack,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Remove this once we've migrated hotkey scopes to the new api
|
||||
goBackToPreviousHotkeyScope(memoizeKey);
|
||||
},
|
||||
[goBackToPreviousHotkeyScope],
|
||||
);
|
||||
|
||||
return { removeFocusItemFromFocusStack };
|
||||
};
|
||||
@@ -1,18 +1,26 @@
|
||||
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
|
||||
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
|
||||
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
|
||||
import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { logDebug } from '~/utils/logDebug';
|
||||
|
||||
export const useResetFocusStack = () => {
|
||||
return useRecoilCallback(
|
||||
const resetFocusStack = useRecoilCallback(
|
||||
({ reset }) =>
|
||||
(memoizeKey = 'global') => {
|
||||
reset(focusStackState);
|
||||
|
||||
if (DEBUG_FOCUS_STACK) {
|
||||
logDebug(`DEBUG: reset focus stack`);
|
||||
}
|
||||
|
||||
// TODO: Remove this once we've migrated hotkey scopes to the new api
|
||||
reset(previousHotkeyScopeFamilyState(memoizeKey as string));
|
||||
reset(currentHotkeyScopeState);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { resetFocusStack };
|
||||
};
|
||||
|
||||
+11
-1
@@ -1,12 +1,14 @@
|
||||
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
|
||||
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
|
||||
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
|
||||
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
|
||||
import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState';
|
||||
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { logDebug } from '~/utils/logDebug';
|
||||
|
||||
export const useResetFocusStackToFocusItem = () => {
|
||||
return useRecoilCallback(
|
||||
const resetFocusStackToFocusItem = useRecoilCallback(
|
||||
({ set }) =>
|
||||
({
|
||||
focusStackItem,
|
||||
@@ -19,10 +21,18 @@ export const useResetFocusStackToFocusItem = () => {
|
||||
}) => {
|
||||
set(focusStackState, [focusStackItem]);
|
||||
|
||||
if (DEBUG_FOCUS_STACK) {
|
||||
logDebug(`DEBUG: reset focus stack to focus item`, {
|
||||
focusStackItem,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Remove this once we've migrated hotkey scopes to the new api
|
||||
set(previousHotkeyScopeFamilyState(memoizeKey), null);
|
||||
set(currentHotkeyScopeState, hotkeyScope);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { resetFocusStackToFocusItem };
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export enum FocusComponentType {
|
||||
MODAL = 'modal',
|
||||
DROPDOWN = 'dropdown',
|
||||
SIDE_PANEL = 'side-panel',
|
||||
OPEN_FIELD_INPUT = 'open-field-input',
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { Keys } from 'react-hotkeys-hook';
|
||||
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
|
||||
type HotkeyEffectProps = {
|
||||
hotkey: {
|
||||
key: Keys;
|
||||
scope: string;
|
||||
};
|
||||
onHotkeyTriggered: () => void;
|
||||
focusId: string;
|
||||
};
|
||||
|
||||
export const HotkeyEffect = ({
|
||||
hotkey,
|
||||
focusId,
|
||||
onHotkeyTriggered,
|
||||
}: HotkeyEffectProps) => {
|
||||
useScopedHotkeys(hotkey.key, () => onHotkeyTriggered(), hotkey.scope, [
|
||||
onHotkeyTriggered,
|
||||
]);
|
||||
useHotkeysOnFocusedElement({
|
||||
keys: hotkey.key,
|
||||
callback: onHotkeyTriggered,
|
||||
focusId,
|
||||
scope: DropdownHotkeyScope.Dropdown,
|
||||
dependencies: [onHotkeyTriggered],
|
||||
});
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ export const useHotkeysOnFocusedElementCallback = (
|
||||
hotkeysEvent.keys
|
||||
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
|
||||
', ',
|
||||
)}] and the current focus identifier is [${focusId}]`,
|
||||
)}] and the current focus identifier is [${currentFocusId}], and the focusId is [${focusId}]`,
|
||||
'color: gray; ',
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export const useHotkeysOnFocusedElementCallback = (
|
||||
hotkeysEvent.keys
|
||||
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
|
||||
', ',
|
||||
)}], and the current focus identifier is [${focusId}]`,
|
||||
)}], and the current focus identifier is [${currentFocusId}], and the focusId is [${focusId}]`,
|
||||
'color: green;',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user