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:
Raphaël Bosi
2025-06-19 14:53:18 +02:00
committed by GitHub
parent 6dd3a71497
commit cbc0d06a2f
155 changed files with 977 additions and 845 deletions

View File

@ -7,7 +7,6 @@ import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dro
import { ObjectOptionsContentId } from '@/object-record/object-options-dropdown/types/ObjectOptionsContentId';
import { RecordGroupReorderConfirmationModal } from '@/object-record/record-group/components/RecordGroupReorderConfirmationModal';
import { useRecordGroupReorderConfirmationModal } from '@/object-record/record-group/hooks/useRecordGroupReorderConfirmationModal';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
@ -40,7 +39,6 @@ export const ObjectOptionsDropdown = ({
<>
<Dropdown
dropdownId={OBJECT_OPTIONS_DROPDOWN_ID}
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
dropdownOffset={{ y: DROPDOWN_OFFSET_Y }}
clickableComponent={
<StyledHeaderDropdownButton isUnfolded={isDropdownOpen}>

View File

@ -4,12 +4,12 @@ import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hook
import { useSetViewTypeFromLayoutOptionsMenu } from '@/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
@ -108,8 +108,9 @@ export const ObjectOptionsDropdownLayoutContent = () => {
{!!currentView && (
<SelectableList
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
hotkeyScope={TableOptionsHotkeyScope.Dropdown}
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
hotkeyScope={DropdownHotkeyScope.Dropdown}
>
<DropdownMenuItemsContainer scrollable={false}>
<SelectableListItem

View File

@ -2,11 +2,11 @@ import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropd
import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
import { useUpdateObjectViewOptions } from '@/object-record/object-options-dropdown/hooks/useUpdateObjectViewOptions';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
@ -53,8 +53,9 @@ export const ObjectOptionsDropdownLayoutOpenInContent = () => {
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
hotkeyScope={TableOptionsHotkeyScope.Dropdown}
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
hotkeyScope={DropdownHotkeyScope.Dropdown}
>
<SelectableListItem
itemId={ViewOpenRecordInType.SIDE_PANEL}

View File

@ -1,20 +1,17 @@
import { Key } from 'ts-key-enum';
import { ObjectOptionsDropdownMenuViewName } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuViewName';
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
@ -48,14 +45,6 @@ export const ObjectOptionsDropdownMenuContent = () => {
(isDefined(currentView?.viewGroups) && currentView.viewGroups.length > 0) ||
currentView?.key !== 'INDEX';
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const { visibleBoardFields } = useObjectOptionsForBoard({
objectNameSingular: objectMetadataItem.nameSingular,
recordBoardId: recordIndexId,
@ -101,8 +90,9 @@ export const ObjectOptionsDropdownMenuContent = () => {
<DropdownMenuSeparator />
<SelectableList
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
hotkeyScope={TableOptionsHotkeyScope.Dropdown}
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
hotkeyScope={DropdownHotkeyScope.Dropdown}
>
<DropdownMenuItemsContainer scrollable={false}>
<SelectableListItem

View File

@ -4,12 +4,13 @@ import { useUpdateObjectViewOptions } from '@/object-record/object-options-dropd
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { View } from '@/views/types/View';
import { ViewsHotkeyScope } from '@/views/types/ViewsHotkeyScope';
import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId';
import { useUpdateViewFromCurrentState } from '@/views/view-picker/hooks/useUpdateViewFromCurrentState';
import { viewPickerIsDirtyComponentState } from '@/views/view-picker/states/viewPickerIsDirtyComponentState';
import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState';
@ -75,17 +76,19 @@ export const ObjectOptionsDropdownMenuViewName = ({
const { updateViewFromCurrentState } = useUpdateViewFromCurrentState();
const [viewName, setViewName] = useState(currentView?.name);
useScopedHotkeys(
Key.Enter,
async () => {
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: async () => {
if (viewPickerIsPersisting) {
return;
}
await updateViewFromCurrentState();
},
ViewsHotkeyScope.ListDropdown,
);
focusId: VIEW_PICKER_DROPDOWN_ID,
scope: DropdownHotkeyScope.Dropdown,
dependencies: [viewPickerIsPersisting, updateViewFromCurrentState],
});
const handleIconChange = ({ iconKey }: { iconKey: string }) => {
setViewPickerIsDirty(true);

View File

@ -5,11 +5,11 @@ import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hook
import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-group/states/selectors/hiddenRecordGroupIdsComponentSelector';
import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort';
import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
@ -73,8 +73,9 @@ export const ObjectOptionsDropdownRecordGroupSortContent = () => {
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
hotkeyScope={TableOptionsHotkeyScope.Dropdown}
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
hotkeyScope={DropdownHotkeyScope.Dropdown}
>
<SelectableListItem
itemId={RecordGroupSort.Manual}

View File

@ -9,12 +9,12 @@ import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-gr
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { recordIndexRecordGroupHideComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordGroupHideComponentFamilyState';
import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
@ -94,6 +94,8 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => {
'HideEmptyGroups',
];
const hiddenGroupsSelectableListId = `${OBJECT_OPTIONS_DROPDOWN_ID}-hidden-groups`;
return (
<DropdownContent>
<DropdownMenuHeader
@ -109,8 +111,9 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => {
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
hotkeyScope={TableOptionsHotkeyScope.Dropdown}
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
hotkeyScope={DropdownHotkeyScope.Dropdown}
>
{currentView?.key !== 'INDEX' && (
<>
@ -175,9 +178,10 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => {
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<SelectableList
selectableListInstanceId={`${OBJECT_OPTIONS_DROPDOWN_ID}-hidden-groups`}
hotkeyScope={TableOptionsHotkeyScope.Dropdown}
selectableListInstanceId={hiddenGroupsSelectableListId}
focusId={hiddenGroupsSelectableListId}
selectableItemIdArray={['HiddenGroups']}
hotkeyScope={DropdownHotkeyScope.Dropdown}
>
<SelectableListItem
itemId="HiddenGroups"