Introduce focus stack to handle hotkeys (#12166)

# Introduce focus stack to handle hotkeys

This PR introduces a focus stack to track the order in which the
elements are focused:
- Each focused element has a unique focus id
- When an element is focused, it is pushed on top of the stack
- When an element loses focus, we remove it from the stack 

This focus stack is then used to determine which hotkeys are available.
The previous implementation lead to many regressions because of race
conditions, of wrong order of open and close operations and by
overwriting previous states. This implementation should be way more
robust than the previous one.

The new api can be incrementally implemented since it preserves
backwards compatibility by writing to the old hotkey scopes states.
For now, it has been implemented on the modal components.

To test this PR, verify that the shortcuts still work correctly,
especially for the modal components.
This commit is contained in:
Raphaël Bosi
2025-05-21 15:52:40 +02:00
committed by GitHub
parent 7f1d6f5c7f
commit c982bcdb52
69 changed files with 1233 additions and 267 deletions

View File

@ -52,7 +52,9 @@ export const RecordBoardHotkeyEffect = () => {
useScopedHotkeys(
Key.ArrowLeft,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: BoardHotkeyScope.BoardFocus,
});
move('left');
},
RecordIndexHotkeyScope.RecordIndex,
@ -61,7 +63,9 @@ export const RecordBoardHotkeyEffect = () => {
useScopedHotkeys(
Key.ArrowRight,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: BoardHotkeyScope.BoardFocus,
});
move('right');
},
RecordIndexHotkeyScope.RecordIndex,
@ -70,7 +74,9 @@ export const RecordBoardHotkeyEffect = () => {
useScopedHotkeys(
Key.ArrowUp,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: BoardHotkeyScope.BoardFocus,
});
move('up');
},
RecordIndexHotkeyScope.RecordIndex,
@ -79,7 +85,9 @@ export const RecordBoardHotkeyEffect = () => {
useScopedHotkeys(
Key.ArrowDown,
() => {
setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: BoardHotkeyScope.BoardFocus,
});
move('down');
},
RecordIndexHotkeyScope.RecordIndex,

View File

@ -79,12 +79,12 @@ export const RecordBoardColumnHeader = () => {
const handleBoardColumnMenuOpen = () => {
setIsBoardColumnMenuOpen(true);
setHotkeyScopeAndMemorizePreviousScope(
RecordBoardColumnHotkeyScope.BoardColumn,
{
setHotkeyScopeAndMemorizePreviousScope({
scope: RecordBoardColumnHotkeyScope.BoardColumn,
customScopes: {
goto: false,
},
);
});
};
const handleBoardColumnMenuClose = () => {

View File

@ -60,9 +60,9 @@ export const FormFieldInputInnerContainer = forwardRef(
onFocus?.(e);
if (!preventSetHotkeyScope) {
setHotkeyScopeAndMemorizePreviousScope(
FormFieldInputHotKeyScope.FormFieldInput,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: FormFieldInputHotKeyScope.FormFieldInput,
});
}
};

View File

@ -119,7 +119,9 @@ export const FormMultiSelectFieldInput = ({
editingMode: 'edit',
});
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
setHotkeyScopeAndMemorizePreviousScope({
scope: hotkeyScope,
});
};
const onOptionSelected = (value: FieldMultiSelectValue) => {

View File

@ -31,9 +31,7 @@ export const useOpenFieldInputEditMode = () => {
const { openActivityTargetCellEditMode } =
useOpenActivityTargetCellEditMode();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(
INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const openFieldInput = useRecoilCallback(
({ snapshot }) =>
@ -105,10 +103,11 @@ export const useOpenFieldInputEditMode = () => {
}
}
setHotkeyScopeAndMemorizePreviousScope(
DEFAULT_CELL_SCOPE.scope,
DEFAULT_CELL_SCOPE.customScopes,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: DEFAULT_CELL_SCOPE.scope,
customScopes: DEFAULT_CELL_SCOPE.customScopes,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
},
[
openActivityTargetCellEditMode,

View File

@ -88,9 +88,9 @@ export const useOpenRelationFromManyFieldInput = () => {
forcePickableMorphItems: pickableMorphItems,
});
setHotkeyScopeAndMemorizePreviousScope(
MultipleRecordPickerHotkeyScope.MultipleRecordPicker,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: MultipleRecordPickerHotkeyScope.MultipleRecordPicker,
});
},
[performSearch, setHotkeyScopeAndMemorizePreviousScope],
);

View File

@ -34,9 +34,9 @@ export const useOpenRelationToOneFieldInput = () => {
);
}
setHotkeyScopeAndMemorizePreviousScope(
SingleRecordPickerHotkeyScope.SingleRecordPicker,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: SingleRecordPickerHotkeyScope.SingleRecordPicker,
});
},
[setHotkeyScopeAndMemorizePreviousScope],
);

View File

@ -35,9 +35,7 @@ export const useInlineCell = (
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(
INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
);
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const initFieldInputDraftValue = useInitDraftValueV2();
@ -45,7 +43,7 @@ export const useInlineCell = (
onCloseEditMode?.();
setIsInlineCellInEditMode(false);
goBackToPreviousHotkeyScope();
goBackToPreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY);
goBackToPreviousDropdownFocusId();
};

View File

@ -31,7 +31,9 @@ export const useMapKeyboardToFocus = (recordTableId?: string) => {
useScopedHotkeys(
[Key.ArrowUp],
() => {
setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: TableHotkeyScope.TableFocus,
});
move('up');
},
RecordIndexHotkeyScope.RecordIndex,
@ -41,7 +43,9 @@ export const useMapKeyboardToFocus = (recordTableId?: string) => {
useScopedHotkeys(
[Key.ArrowDown],
() => {
setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus);
setHotkeyScopeAndMemorizePreviousScope({
scope: TableHotkeyScope.TableFocus,
});
move('down');
},
RecordIndexHotkeyScope.RecordIndex,

View File

@ -42,16 +42,15 @@ export const RecordTitleCellSingleTextDisplayMode = () => {
const { openInlineCell } = useInlineCell();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(
INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
return (
<StyledDiv
onClick={() => {
setHotkeyScopeAndMemorizePreviousScope(
TitleInputHotkeyScope.TitleInput,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: TitleInputHotkeyScope.TitleInput,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
openInlineCell();
}}
>

View File

@ -45,15 +45,14 @@ export const RecordTitleFullNameFieldDisplay = () => {
.join(' ')
.trim();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(
INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
);
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
return (
<StyledDiv
onClick={() => {
setHotkeyScopeAndMemorizePreviousScope(
TitleInputHotkeyScope.TitleInput,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: TitleInputHotkeyScope.TitleInput,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
openInlineCell();
}}
>

View File

@ -18,7 +18,7 @@ export const useRecordTitleCell = () => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY);
} = usePreviousHotkeyScope();
const closeRecordTitleCell = useRecoilCallback(
({ set }) =>
@ -38,7 +38,7 @@ export const useRecordTitleCell = () => {
false,
);
goBackToPreviousHotkeyScope();
goBackToPreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY);
goBackToPreviousDropdownFocusId();
},
@ -61,14 +61,16 @@ export const useRecordTitleCell = () => {
customEditHotkeyScopeForField?: HotkeyScope;
}) => {
if (isDefined(customEditHotkeyScopeForField)) {
setHotkeyScopeAndMemorizePreviousScope(
customEditHotkeyScopeForField.scope,
customEditHotkeyScopeForField.customScopes,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: customEditHotkeyScopeForField.scope,
customScopes: customEditHotkeyScopeForField.customScopes,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
} else {
setHotkeyScopeAndMemorizePreviousScope(
TitleInputHotkeyScope.TitleInput,
);
setHotkeyScopeAndMemorizePreviousScope({
scope: TitleInputHotkeyScope.TitleInput,
memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY,
});
}
const recordTitleCellId = getRecordTitleCellId(