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:
@ -101,14 +101,6 @@ export const MultiSelectInput = ({
|
||||
selectableListInstanceId={selectableListComponentInstanceId}
|
||||
selectableItemIdArray={optionIds}
|
||||
hotkeyScope={hotkeyScope}
|
||||
onEnter={(itemId) => {
|
||||
const option = filteredOptionsInDropDown.find(
|
||||
(option) => option.value === itemId,
|
||||
);
|
||||
if (isDefined(option)) {
|
||||
onOptionSelected(formatNewSelectedOptions(option.value));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu data-select-disable ref={containerRef}>
|
||||
<DropdownMenuSearchInput
|
||||
|
||||
@ -20,7 +20,6 @@ export const SelectInput = ({
|
||||
selectableListComponentInstanceId,
|
||||
selectableItemIdArray,
|
||||
hotkeyScope,
|
||||
onEnter,
|
||||
onOptionSelected,
|
||||
options,
|
||||
onCancel,
|
||||
@ -34,7 +33,6 @@ export const SelectInput = ({
|
||||
selectableListInstanceId={selectableListComponentInstanceId}
|
||||
selectableItemIdArray={selectableItemIdArray}
|
||||
hotkeyScope={hotkeyScope}
|
||||
onEnter={onEnter}
|
||||
>
|
||||
<SelectBaseInput
|
||||
onOptionSelected={onOptionSelected}
|
||||
|
||||
@ -11,6 +11,7 @@ import { SelectableList } from '@/ui/layout/selectable-list/components/Selectabl
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { arrayToChunks } from '~/utils/array/arrayToChunks';
|
||||
|
||||
import { useSelectableListListenToEnterHotkeyOnItem } from '@/ui/layout/selectable-list/hooks/useSelectableListListenToEnterHotkeyOnItem';
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@ -22,7 +23,6 @@ import {
|
||||
LightIconButton,
|
||||
} from 'twenty-ui/input';
|
||||
import { IconPickerHotkeyScope } from '../types/IconPickerHotkeyScope';
|
||||
|
||||
export type IconPickerProps = {
|
||||
disabled?: boolean;
|
||||
dropdownId?: string;
|
||||
@ -69,6 +69,12 @@ const IconPickerIcon = ({
|
||||
iconKey,
|
||||
);
|
||||
|
||||
useSelectableListListenToEnterHotkeyOnItem({
|
||||
hotkeyScope: IconPickerHotkeyScope.IconPicker,
|
||||
itemId: iconKey,
|
||||
onEnter: onClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledLightIconButton
|
||||
key={iconKey}
|
||||
@ -179,10 +185,6 @@ export const IconPicker = ({
|
||||
selectableListInstanceId="icon-list"
|
||||
selectableItemIdMatrix={iconKeys2d}
|
||||
hotkeyScope={IconPickerHotkeyScope.IconPicker}
|
||||
onEnter={(iconKey) => {
|
||||
onChange({ iconKey, Icon: getIcon(iconKey) });
|
||||
closeDropdown();
|
||||
}}
|
||||
>
|
||||
<DropdownMenu width={176}>
|
||||
<DropdownMenuSearchInput
|
||||
|
||||
@ -9,6 +9,10 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
|
||||
import { SelectControl } from '@/ui/input/components/SelectControl';
|
||||
import { DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
|
||||
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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconComponent } from 'twenty-ui/display';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
@ -109,6 +113,13 @@ export const Select = <Value extends SelectValue>({
|
||||
? selectContainerRef.current?.clientWidth
|
||||
: dropdownWidth;
|
||||
|
||||
const selectableItemIdArray = filteredOptions.map((option) => option.label);
|
||||
|
||||
const selectedItemId = useRecoilComponentValueV2(
|
||||
selectedItemIdComponentState,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
className={className}
|
||||
@ -153,20 +164,36 @@ export const Select = <Value extends SelectValue>({
|
||||
)}
|
||||
{!!filteredOptions.length && (
|
||||
<DropdownMenuItemsContainer hasMaxHeight width={'auto'}>
|
||||
{filteredOptions.map((option) => (
|
||||
<MenuItemSelect
|
||||
key={`${option.value}-${option.label}`}
|
||||
LeftIcon={option.Icon}
|
||||
text={option.label}
|
||||
selected={selectedOption.value === option.value}
|
||||
needIconCheck={needIconCheck}
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
onBlur?.();
|
||||
closeDropdown();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<SelectableList
|
||||
hotkeyScope={SelectHotkeyScope.Select}
|
||||
selectableListInstanceId={dropdownId}
|
||||
selectableItemIdArray={selectableItemIdArray}
|
||||
>
|
||||
{filteredOptions.map((option) => (
|
||||
<SelectableListItem
|
||||
key={`${option.value}-${option.label}`}
|
||||
itemId={option.label}
|
||||
onEnter={() => {
|
||||
onChange?.(option.value);
|
||||
onBlur?.();
|
||||
closeDropdown();
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
LeftIcon={option.Icon}
|
||||
text={option.label}
|
||||
selected={selectedOption.value === option.value}
|
||||
focused={selectedItemId === option.label}
|
||||
needIconCheck={needIconCheck}
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
onBlur?.();
|
||||
closeDropdown();
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
{!!callToActionButton && !!filteredOptions.length && (
|
||||
|
||||
@ -7,9 +7,9 @@ import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useLis
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { MenuItemSelectTag } from 'twenty-ui/navigation';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
import { TagColor } from 'twenty-ui/components';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
import { MenuItemSelectTag } from 'twenty-ui/navigation';
|
||||
|
||||
interface SelectInputProps {
|
||||
onOptionSelected: (selectedOption: SelectOption) => void;
|
||||
@ -107,7 +107,6 @@ export const SelectInput = ({
|
||||
{onClear && clearLabel && (
|
||||
<MenuItemSelectTag
|
||||
key={`No ${clearLabel}`}
|
||||
selected={false}
|
||||
text={`No ${clearLabel}`}
|
||||
color="transparent"
|
||||
variant={'outline'}
|
||||
@ -121,7 +120,7 @@ export const SelectInput = ({
|
||||
return (
|
||||
<MenuItemSelectTag
|
||||
key={option.value}
|
||||
selected={selectedOption?.value === option.value}
|
||||
focused={selectedOption?.value === option.value}
|
||||
text={option.label}
|
||||
color={(option.color as TagColor) ?? 'transparent'}
|
||||
onClick={() => handleOptionChange(option)}
|
||||
|
||||
@ -2,8 +2,8 @@ import { ReactNode, useEffect } from 'react';
|
||||
|
||||
import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys';
|
||||
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
|
||||
import { SelectableListContextProvider } from '@/ui/layout/selectable-list/states/contexts/SelectableListContext';
|
||||
import { selectableItemIdsComponentState } from '@/ui/layout/selectable-list/states/selectableItemIdsComponentState';
|
||||
import { selectableListOnEnterComponentState } from '@/ui/layout/selectable-list/states/selectableListOnEnterComponentState';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { arrayToChunks } from '~/utils/array/arrayToChunks';
|
||||
@ -14,7 +14,6 @@ type SelectableListProps = {
|
||||
selectableItemIdMatrix?: string[][];
|
||||
onSelect?: (selected: string) => void;
|
||||
hotkeyScope: string;
|
||||
onEnter?: (itemId: string) => void;
|
||||
selectableListInstanceId: string;
|
||||
};
|
||||
|
||||
@ -24,25 +23,15 @@ export const SelectableList = ({
|
||||
selectableItemIdArray,
|
||||
selectableItemIdMatrix,
|
||||
selectableListInstanceId,
|
||||
onEnter,
|
||||
onSelect,
|
||||
}: SelectableListProps) => {
|
||||
useSelectableListHotKeys(selectableListInstanceId, hotkeyScope, onSelect);
|
||||
|
||||
const setSelectableListOnEnter = useSetRecoilComponentStateV2(
|
||||
selectableListOnEnterComponentState,
|
||||
selectableListInstanceId,
|
||||
);
|
||||
|
||||
const setSelectableItemIds = useSetRecoilComponentStateV2(
|
||||
selectableItemIdsComponentState,
|
||||
selectableListInstanceId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectableListOnEnter(() => onEnter);
|
||||
}, [onEnter, setSelectableListOnEnter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectableItemIdArray && !selectableItemIdMatrix) {
|
||||
throw new Error(
|
||||
@ -65,7 +54,9 @@ export const SelectableList = ({
|
||||
instanceId: selectableListInstanceId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<SelectableListContextProvider value={{ hotkeyScope }}>
|
||||
{children}
|
||||
</SelectableListContextProvider>
|
||||
</SelectableListComponentInstanceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,25 +1,29 @@
|
||||
import { ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import { SelectableListItemHotkeyEffect } from '@/ui/layout/selectable-list/components/SelectableListItemHotkeyEffect';
|
||||
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
|
||||
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
|
||||
import styled from '@emotion/styled';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export type SelectableItemProps = {
|
||||
export type SelectableListItemProps = {
|
||||
itemId: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onEnter?: () => void;
|
||||
};
|
||||
|
||||
export const SelectableItem = ({
|
||||
export const SelectableListItem = ({
|
||||
itemId,
|
||||
children,
|
||||
className,
|
||||
}: SelectableItemProps) => {
|
||||
onEnter,
|
||||
}: SelectableListItemProps) => {
|
||||
const isSelectedItemId = useRecoilComponentFamilyValueV2(
|
||||
isSelectedItemIdComponentFamilySelector,
|
||||
itemId,
|
||||
@ -34,8 +38,13 @@ export const SelectableItem = ({
|
||||
}, [isSelectedItemId]);
|
||||
|
||||
return (
|
||||
<StyledContainer className={className} ref={scrollRef}>
|
||||
{children}
|
||||
</StyledContainer>
|
||||
<>
|
||||
{isSelectedItemId && isDefined(onEnter) && (
|
||||
<SelectableListItemHotkeyEffect itemId={itemId} onEnter={onEnter} />
|
||||
)}
|
||||
<StyledContainer className={className} ref={scrollRef}>
|
||||
{children}
|
||||
</StyledContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import { useSelectableListListenToEnterHotkeyOnItem } from '@/ui/layout/selectable-list/hooks/useSelectableListListenToEnterHotkeyOnItem';
|
||||
import { useSelectableListContextOrThrow } from '@/ui/layout/selectable-list/states/contexts/SelectableListContext';
|
||||
|
||||
export const SelectableListItemHotkeyEffect = ({
|
||||
itemId,
|
||||
onEnter,
|
||||
}: {
|
||||
itemId: string;
|
||||
onEnter: () => void;
|
||||
}) => {
|
||||
const { hotkeyScope } = useSelectableListContextOrThrow();
|
||||
|
||||
useSelectableListListenToEnterHotkeyOnItem({
|
||||
hotkeyScope,
|
||||
itemId,
|
||||
onEnter,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
@ -3,7 +3,6 @@ import { useRecoilCallback } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { selectableItemIdsComponentState } from '@/ui/layout/selectable-list/states/selectableItemIdsComponentState';
|
||||
import { selectableListOnEnterComponentState } from '@/ui/layout/selectable-list/states/selectableListOnEnterComponentState';
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
@ -147,35 +146,4 @@ export const useSelectableListHotKeys = (
|
||||
hotkeyScope,
|
||||
[],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Enter,
|
||||
useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
() => {
|
||||
const selectedItemId = getSnapshotValue(
|
||||
snapshot,
|
||||
selectedItemIdComponentState.atomFamily({
|
||||
instanceId: instanceId,
|
||||
}),
|
||||
);
|
||||
const onEnter = getSnapshotValue(
|
||||
snapshot,
|
||||
selectableListOnEnterComponentState.atomFamily({
|
||||
instanceId: instanceId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isNonEmptyString(selectedItemId)) {
|
||||
onEnter?.(selectedItemId);
|
||||
}
|
||||
},
|
||||
[instanceId],
|
||||
),
|
||||
hotkeyScope,
|
||||
[],
|
||||
{
|
||||
preventDefault: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@ -39,8 +39,5 @@ export const useSelectableListListenToEnterHotkeyOnItem = ({
|
||||
),
|
||||
hotkeyScope,
|
||||
[itemId, onEnter],
|
||||
{
|
||||
preventDefault: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import { createRequiredContext } from '~/utils/createRequiredContext';
|
||||
|
||||
export type SelectableListContextValue = {
|
||||
hotkeyScope: string;
|
||||
};
|
||||
|
||||
export const [SelectableListContextProvider, useSelectableListContextOrThrow] =
|
||||
createRequiredContext<SelectableListContextValue>('SelectableListContext');
|
||||
@ -1,10 +0,0 @@
|
||||
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
|
||||
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
|
||||
|
||||
export const selectableListOnEnterComponentState = createComponentStateV2<
|
||||
((itemId: string) => void) | undefined
|
||||
>({
|
||||
key: 'selectableListOnEnterComponentState',
|
||||
defaultValue: undefined,
|
||||
componentInstanceContext: SelectableListComponentInstanceContext,
|
||||
});
|
||||
Reference in New Issue
Block a user