512 Ability to navigate dropdown menus with keyboard (#11735)
# Ability to navigate dropdown menus with keyboard The aim of this PR is to improve accessibility by allowing the user to navigate inside the dropdown menus with the keyboard. This PR refactors the `SelectableList` and `SelectableListItem` components to move the Enter event handling responsibility from `SelectableList` to the individual `SelectableListItem` components. Closes [512](https://github.com/twentyhq/core-team-issues/issues/512) ## Key Changes: - All dropdowns are now navigable with arrow keys ## Technical Implementation: - Each `SelectableListItem` now has direct access to its own `Enter` key handler, improving component encapsulation - Removed the central `Enter` key handler logic from `SelectableList` - Added `SelectableList` and `SelectableListItem` to all `Dropdown` components inside the app - Updated all component implementations to adapt to the new pattern: - Action menu components (`ActionDropdownItem`, `ActionListItem`) - Command menu components - Object filter, sort and options dropdowns - Record picker components - Select components --------- Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
@ -7,7 +7,6 @@ import { useMatchingCommandMenuActions } from '@/command-menu/hooks/useMatchingC
|
||||
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
@ -89,9 +88,7 @@ export const CommandMenu = () => {
|
||||
>
|
||||
{isDefined(previousContextStoreCurrentObjectMetadataItemId) && (
|
||||
<CommandGroup heading={t`Context`}>
|
||||
<SelectableItem itemId={RESET_CONTEXT_TO_SELECTION}>
|
||||
<ResetContextToSelectionCommandButton />
|
||||
</SelectableItem>
|
||||
<ResetContextToSelectionCommandButton />
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandMenuList>
|
||||
|
||||
@ -4,8 +4,6 @@ import { ActionGroupConfig } from '@/command-menu/components/CommandMenu';
|
||||
import { CommandMenuDefaultSelectionEffect } from '@/command-menu/components/CommandMenuDefaultSelectionEffect';
|
||||
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
|
||||
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
|
||||
import { RESET_CONTEXT_TO_SELECTION } from '@/command-menu/constants/ResetContextToSelection';
|
||||
import { useResetPreviousCommandMenuContext } from '@/command-menu/hooks/useResetPreviousCommandMenuContext';
|
||||
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
|
||||
@ -64,9 +62,6 @@ export const CommandMenuList = ({
|
||||
loading = false,
|
||||
noResults = false,
|
||||
}: CommandMenuListProps) => {
|
||||
const { resetPreviousCommandMenuContext } =
|
||||
useResetPreviousCommandMenuContext();
|
||||
|
||||
const setHasUserSelectedCommand = useSetRecoilState(
|
||||
hasUserSelectedCommandState,
|
||||
);
|
||||
@ -82,12 +77,6 @@ export const CommandMenuList = ({
|
||||
selectableListInstanceId="command-menu-list"
|
||||
hotkeyScope={AppHotkeyScope.CommandMenuOpen}
|
||||
selectableItemIdArray={selectableItemIds}
|
||||
onEnter={(itemId) => {
|
||||
if (itemId === RESET_CONTEXT_TO_SELECTION) {
|
||||
resetPreviousCommandMenuContext();
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onSelect={() => {
|
||||
setHasUserSelectedCommand(true);
|
||||
}}
|
||||
|
||||
@ -6,6 +6,7 @@ import { useResetPreviousCommandMenuContext } from '@/command-menu/hooks/useRese
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
@ -42,17 +43,22 @@ export const ResetContextToSelectionCommandButton = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandMenuItem
|
||||
id={RESET_CONTEXT_TO_SELECTION}
|
||||
Icon={IconArrowBackUp}
|
||||
label={t`Reset to`}
|
||||
RightComponent={
|
||||
<CommandMenuContextRecordsChip
|
||||
objectMetadataItemId={objectMetadataItem.id}
|
||||
instanceId={COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID}
|
||||
/>
|
||||
}
|
||||
onClick={resetPreviousCommandMenuContext}
|
||||
/>
|
||||
<SelectableListItem
|
||||
itemId={RESET_CONTEXT_TO_SELECTION}
|
||||
onEnter={resetPreviousCommandMenuContext}
|
||||
>
|
||||
<CommandMenuItem
|
||||
id={RESET_CONTEXT_TO_SELECTION}
|
||||
Icon={IconArrowBackUp}
|
||||
label={t`Reset to`}
|
||||
RightComponent={
|
||||
<CommandMenuContextRecordsChip
|
||||
objectMetadataItemId={objectMetadataItem.id}
|
||||
instanceId={COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID}
|
||||
/>
|
||||
}
|
||||
onClick={resetPreviousCommandMenuContext}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
};
|
||||
|
||||
@ -8,6 +8,18 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe
|
||||
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
|
||||
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { CommandMenuHotkeyScope } from '@/command-menu/types/CommandMenuHotkeyScope';
|
||||
|
||||
const mockGoBackToPreviousHotkeyScope = jest.fn();
|
||||
const mockSetHotkeyScopeAndMemorizePreviousScope = jest.fn();
|
||||
|
||||
jest.mock('@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope', () => ({
|
||||
usePreviousHotkeyScope: () => ({
|
||||
goBackToPreviousHotkeyScope: mockGoBackToPreviousHotkeyScope,
|
||||
setHotkeyScopeAndMemorizePreviousScope:
|
||||
mockSetHotkeyScopeAndMemorizePreviousScope,
|
||||
}),
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot>
|
||||
@ -47,6 +59,10 @@ const renderHooks = () => {
|
||||
};
|
||||
|
||||
describe('useCommandMenu', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should open and close the command menu', () => {
|
||||
const { result } = renderHooks();
|
||||
|
||||
@ -55,6 +71,12 @@ describe('useCommandMenu', () => {
|
||||
});
|
||||
|
||||
expect(result.current.isCommandMenuOpened).toBe(true);
|
||||
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
|
||||
CommandMenuHotkeyScope.CommandMenuFocused,
|
||||
{
|
||||
commandMenuOpen: true,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.commandMenu.closeCommandMenu();
|
||||
@ -73,6 +95,12 @@ describe('useCommandMenu', () => {
|
||||
});
|
||||
|
||||
expect(result.current.isCommandMenuOpened).toBe(true);
|
||||
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
|
||||
CommandMenuHotkeyScope.CommandMenuFocused,
|
||||
{
|
||||
commandMenuOpen: true,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.commandMenu.toggleCommandMenu();
|
||||
@ -80,4 +108,21 @@ describe('useCommandMenu', () => {
|
||||
|
||||
expect(result.current.isCommandMenuOpened).toBe(false);
|
||||
});
|
||||
|
||||
it('should call goBackToPreviousHotkeyScope when closing the command menu', () => {
|
||||
const { result } = renderHooks();
|
||||
|
||||
act(() => {
|
||||
result.current.commandMenu.openCommandMenu();
|
||||
});
|
||||
|
||||
expect(result.current.isCommandMenuOpened).toBe(true);
|
||||
expect(mockGoBackToPreviousHotkeyScope).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.commandMenu.closeCommandMenu();
|
||||
});
|
||||
|
||||
expect(mockGoBackToPreviousHotkeyScope).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,7 +23,6 @@ import { IconList } from 'twenty-ui/display';
|
||||
const mockCloseDropdown = jest.fn();
|
||||
const mockResetContextStoreStates = jest.fn();
|
||||
const mockResetSelectedItem = jest.fn();
|
||||
const mockGoBackToPreviousHotkeyScope = jest.fn();
|
||||
const mockEmitRightDrawerCloseEvent = jest.fn();
|
||||
|
||||
jest.mock('@/ui/layout/dropdown/hooks/useDropdownV2', () => ({
|
||||
@ -44,12 +43,6 @@ jest.mock('@/ui/layout/selectable-list/hooks/useSelectableList', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope', () => ({
|
||||
usePreviousHotkeyScope: () => ({
|
||||
goBackToPreviousHotkeyScope: mockGoBackToPreviousHotkeyScope,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent', () => ({
|
||||
emitRightDrawerCloseEvent: () => {
|
||||
mockEmitRightDrawerCloseEvent();
|
||||
@ -231,7 +224,6 @@ describe('useCommandMenuCloseAnimationCompleteCleanup', () => {
|
||||
expect(mockCloseDropdown).toHaveBeenCalledTimes(1);
|
||||
expect(mockResetContextStoreStates).toHaveBeenCalledTimes(2);
|
||||
expect(mockResetSelectedItem).toHaveBeenCalledTimes(1);
|
||||
expect(mockGoBackToPreviousHotkeyScope).toHaveBeenCalledTimes(1);
|
||||
expect(mockEmitRightDrawerCloseEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockCloseDropdown).toHaveBeenCalledWith(
|
||||
|
||||
@ -2,11 +2,13 @@ import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
|
||||
|
||||
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
|
||||
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
|
||||
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown';
|
||||
import { isDragSelectionStartEnabledState } from '@/ui/utilities/drag-select/states/internal/isDragSelectionStartEnabledState';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useCallback } from 'react';
|
||||
import { IconDotsVertical } from 'twenty-ui/display';
|
||||
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
|
||||
@ -14,16 +16,26 @@ import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
|
||||
export const useCommandMenu = () => {
|
||||
const { navigateCommandMenu } = useNavigateCommandMenu();
|
||||
const { closeAnyOpenDropdown } = useCloseAnyOpenDropdown();
|
||||
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(
|
||||
COMMAND_MENU_COMPONENT_INSTANCE_ID,
|
||||
);
|
||||
|
||||
const closeCommandMenu = useRecoilCallback(
|
||||
({ set }) =>
|
||||
({ set, snapshot }) =>
|
||||
() => {
|
||||
set(isCommandMenuOpenedState, false);
|
||||
set(isCommandMenuClosingState, true);
|
||||
set(isDragSelectionStartEnabledState, true);
|
||||
closeAnyOpenDropdown();
|
||||
const isCommandMenuOpened = snapshot
|
||||
.getLoadable(isCommandMenuOpenedState)
|
||||
.getValue();
|
||||
|
||||
if (isCommandMenuOpened) {
|
||||
set(isCommandMenuOpenedState, false);
|
||||
set(isCommandMenuClosingState, true);
|
||||
set(isDragSelectionStartEnabledState, true);
|
||||
closeAnyOpenDropdown();
|
||||
goBackToPreviousHotkeyScope();
|
||||
}
|
||||
},
|
||||
[closeAnyOpenDropdown],
|
||||
[closeAnyOpenDropdown, goBackToPreviousHotkeyScope],
|
||||
);
|
||||
|
||||
const openCommandMenu = useCallback(() => {
|
||||
|
||||
@ -18,7 +18,6 @@ import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRi
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/code-action/constants/WorkflowServerlessFunctionTabListComponentId';
|
||||
import { WorkflowServerlessFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/code-action/types/WorkflowServerlessFunctionTabId';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
@ -26,10 +25,6 @@ import { useRecoilCallback } from 'recoil';
|
||||
export const useCommandMenuCloseAnimationCompleteCleanup = () => {
|
||||
const { resetSelectedItem } = useSelectableList('command-menu-list');
|
||||
|
||||
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(
|
||||
COMMAND_MENU_COMPONENT_INSTANCE_ID,
|
||||
);
|
||||
|
||||
const { resetContextStoreStates } = useResetContextStoreStates();
|
||||
|
||||
const { closeDropdown } = useDropdownV2();
|
||||
@ -56,7 +51,6 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => {
|
||||
set(commandMenuNavigationStackState, []);
|
||||
resetSelectedItem();
|
||||
set(hasUserSelectedCommandState, false);
|
||||
goBackToPreviousHotkeyScope();
|
||||
|
||||
emitRightDrawerCloseEvent();
|
||||
set(isCommandMenuClosingState, false);
|
||||
@ -81,12 +75,7 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => {
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
closeDropdown,
|
||||
goBackToPreviousHotkeyScope,
|
||||
resetContextStoreStates,
|
||||
resetSelectedItem,
|
||||
],
|
||||
[closeDropdown, resetContextStoreStates, resetSelectedItem],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user