Files
twenty/packages/twenty-front/src/modules/command-menu/components/CommandMenuList.tsx
T
Raphaël Bosi f201091c68 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>
2025-04-25 18:55:39 +02:00

103 lines
3.5 KiB
TypeScript

import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent';
import { CommandGroup } from '@/command-menu/components/CommandGroup';
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 { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
const MOBILE_NAVIGATION_BAR_HEIGHT = 64;
export type CommandMenuListProps = {
commandGroups: ActionGroupConfig[];
selectableItemIds: string[];
children?: React.ReactNode;
loading?: boolean;
noResults?: boolean;
};
const StyledInnerList = styled.div`
max-height: calc(
100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px -
${COMMAND_MENU_SEARCH_BAR_PADDING * 2}px -
${MOBILE_NAVIGATION_BAR_HEIGHT}px
);
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(1)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
@media (min-width: ${MOBILE_VIEWPORT}px) {
max-height: calc(
100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px -
${COMMAND_MENU_SEARCH_BAR_PADDING * 2}px
);
}
`;
const StyledCommandMenuList = styled.div`
overflow-y: hidden;
`;
const StyledEmpty = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
height: 64px;
justify-content: center;
white-space: pre-wrap;
`;
export const CommandMenuList = ({
commandGroups,
selectableItemIds,
children,
loading = false,
noResults = false,
}: CommandMenuListProps) => {
const setHasUserSelectedCommand = useSetRecoilState(
hasUserSelectedCommandState,
);
return (
<StyledCommandMenuList>
<CommandMenuDefaultSelectionEffect
selectableItemIds={selectableItemIds}
/>
<ScrollWrapper componentInstanceId={`scroll-wrapper-command-menu`}>
<StyledInnerList>
<SelectableList
selectableListInstanceId="command-menu-list"
hotkeyScope={AppHotkeyScope.CommandMenuOpen}
selectableItemIdArray={selectableItemIds}
onSelect={() => {
setHasUserSelectedCommand(true);
}}
>
{children}
{commandGroups.map(({ heading, items }) =>
items?.length ? (
<CommandGroup heading={heading} key={heading}>
{items.map((item) => (
<ActionComponent action={item} key={item.key} />
))}
</CommandGroup>
) : null,
)}
{noResults && !loading && (
<StyledEmpty>No results found</StyledEmpty>
)}
</SelectableList>
</StyledInnerList>
</ScrollWrapper>
</StyledCommandMenuList>
);
};