Add Keyboard navigation on IconPicker (#2778)

* Add Add Keyboard navigation on IconPicker

Co-authored-by: Matheus <matheus_benini@hotmail.com>

* Add Keyboard navigation on IconPicker

Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Matheus <matheus_benini@hotmail.com>

* Add Keyboard navigation on IconPicker

Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Matheus <matheus_benini@hotmail.com>

* Add Keyboard navigation on IconPicker

Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Matheus <matheus_benini@hotmail.com>

* Refactor according to review

Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Matheus <matheus_benini@hotmail.com>

* Implement IconPicker

* Remove onEnter clicked

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: Matheus <matheus_benini@hotmail.com>
Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
gitstart-twenty
2023-12-09 15:30:40 +05:45
committed by GitHub
parent 130e4c8313
commit 7c40dc7b81
14 changed files with 281 additions and 125 deletions

View File

@ -4,7 +4,6 @@ import { useRecoilValue } from 'recoil';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { Activity } from '@/activities/types/Activity';
import { CommandMenuSelectableListEffect } from '@/command-menu/components/CommandMenuSelectableListEffect';
import { Company } from '@/companies/types/Company';
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
@ -86,7 +85,7 @@ export const StyledEmpty = styled.div`
`;
export const CommandMenu = () => {
const { toggleCommandMenu, closeCommandMenu } = useCommandMenu();
const { toggleCommandMenu } = useCommandMenu();
const openActivityRightDrawer = useOpenActivityRightDrawer();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
@ -109,17 +108,6 @@ export const CommandMenu = () => {
[toggleCommandMenu, setSearch],
);
useScopedHotkeys(
'esc',
() => {
setSearch('');
closeKeyboardShortcutMenu();
closeCommandMenu();
},
AppHotkeyScope.CommandMenu,
[toggleCommandMenu, setSearch],
);
const { records: people } = useFindManyRecords<Person>({
skip: !isCommandMenuOpened,
objectNameSingular: 'person',
@ -198,12 +186,13 @@ export const CommandMenu = () => {
<StyledList>
<ScrollWrapper>
<StyledInnerList>
<CommandMenuSelectableListEffect
selectableItemIds={selectableItemIds}
/>
<SelectableList
selectableListId="command-menu-list"
selectableItemIds={selectableItemIds}
selectableItemIds={[selectableItemIds]}
hotkeyScope={AppHotkeyScope.CommandMenu}
onEnter={(itemId) => {
console.log(itemId);
}}
>
{!matchingCreateCommand.length &&
!matchingNavigateCommand.length &&

View File

@ -1,5 +1,3 @@
import { useNavigate } from 'react-router-dom';
import { IconArrowUpRight } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
@ -26,8 +24,7 @@ export const CommandMenuItem = ({
firstHotKey,
secondHotKey,
}: CommandMenuItemProps) => {
const navigate = useNavigate();
const { toggleCommandMenu } = useCommandMenu();
const { onItemClick } = useCommandMenu();
if (to && !Icon) {
Icon = IconArrowUpRight;
@ -35,26 +32,13 @@ export const CommandMenuItem = ({
const { isSelectedItemId } = useSelectableList({ itemId: id });
const onItemClick = () => {
toggleCommandMenu();
if (onClick) {
onClick();
return;
}
if (to) {
navigate(to);
return;
}
};
return (
<MenuItemCommand
LeftIcon={Icon}
text={label}
firstHotKey={firstHotKey}
secondHotKey={secondHotKey}
onClick={onItemClick}
onClick={() => onItemClick(onClick, to)}
isSelected={isSelectedItemId}
/>
);

View File

@ -1,21 +0,0 @@
import { useEffect } from 'react';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
type CommandMenuSelectableListEffectProps = {
selectableItemIds: string[];
};
export const CommandMenuSelectableListEffect = ({
selectableItemIds,
}: CommandMenuSelectableListEffectProps) => {
const { setSelectableItemIds } = useSelectableList({
selectableListId: 'command-menu-list',
});
useEffect(() => {
setSelectableItemIds(selectableItemIds);
}, [selectableItemIds, setSelectableItemIds]);
return <></>;
};

View File

@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@ -10,6 +11,7 @@ import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
import { Command } from '../types/Command';
export const useCommandMenu = () => {
const navigate = useNavigate();
const setIsCommandMenuOpened = useSetRecoilState(isCommandMenuOpenedState);
const setCommands = useSetRecoilState(commandMenuCommandsState);
const {
@ -50,11 +52,28 @@ export const useCommandMenu = () => {
setCommands(commandMenuCommands);
};
const onItemClick = useCallback(
(onClick?: () => void, to?: string) => {
toggleCommandMenu();
if (onClick) {
onClick();
return;
}
if (to) {
navigate(to);
return;
}
},
[navigate, toggleCommandMenu],
);
return {
openCommandMenu,
closeCommandMenu,
toggleCommandMenu,
addToCommandMenu,
onItemClick,
setToIntitialCommandMenu,
};
};

View File

@ -9,6 +9,10 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { arrayToChunks } from '~/utils/array/array-to-chunks';
import { IconButton, IconButtonVariant } from '../button/components/IconButton';
import { LightIconButton } from '../button/components/LightIconButton';
@ -44,6 +48,34 @@ const StyledLightIconButton = styled(LightIconButton)<{ isSelected?: boolean }>`
const convertIconKeyToLabel = (iconKey: string) =>
iconKey.replace(/[A-Z]/g, (letter) => ` ${letter}`).trim();
type IconPickerIconProps = {
iconKey: string;
onClick: () => void;
selectedIconKey?: string;
Icon: IconComponent;
};
const IconPickerIcon = ({
iconKey,
onClick,
selectedIconKey,
Icon,
}: IconPickerIconProps) => {
const { isSelectedItemId } = useSelectableList({ itemId: iconKey });
return (
<StyledLightIconButton
key={iconKey}
aria-label={convertIconKeyToLabel(iconKey)}
size="medium"
title={iconKey}
isSelected={iconKey === selectedIconKey || isSelectedItemId}
Icon={Icon}
onClick={onClick}
/>
);
};
export const IconPicker = ({
disabled,
dropdownScopeId = 'icon-picker',
@ -56,6 +88,10 @@ export const IconPicker = ({
className,
}: IconPickerProps) => {
const [searchString, setSearchString] = useState('');
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const { closeDropdown } = useDropdown({ dropdownScopeId });
@ -79,6 +115,11 @@ export const IconPicker = ({
).slice(0, 25);
}, [icons, searchString, selectedIconKey]);
const iconKeys2d = useMemo(
() => arrayToChunks(iconKeys.slice(), 5),
[iconKeys],
);
return (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<div className={className}>
@ -93,36 +134,53 @@ export const IconPicker = ({
}
dropdownMenuWidth={176}
dropdownComponents={
<DropdownMenu width={176}>
<DropdownMenuSearchInput
placeholder="Search icon"
autoFocus
onChange={(event) => setSearchString(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{isLoading ? (
<DropdownMenuSkeletonItem />
) : (
<StyledMenuIconItemsContainer>
{iconKeys.map((iconKey) => (
<StyledLightIconButton
key={iconKey}
aria-label={convertIconKeyToLabel(iconKey)}
isSelected={selectedIconKey === iconKey}
size="medium"
title={iconKey}
Icon={icons[iconKey]}
onClick={() => {
onChange({ iconKey, Icon: icons[iconKey] });
closeDropdown();
}}
/>
))}
</StyledMenuIconItemsContainer>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
<SelectableList
selectableListId="icon-list"
selectableItemIds={iconKeys2d}
hotkeyScope={IconPickerHotkeyScope.IconPicker}
onEnter={(iconKey) => {
onChange({ iconKey, Icon: icons[iconKey] });
closeDropdown();
}}
>
<DropdownMenu width={176}>
<DropdownMenuSearchInput
placeholder="Search icon"
autoFocus
onChange={(event) => setSearchString(event.target.value)}
/>
<DropdownMenuSeparator />
<div
onMouseEnter={() => {
setHotkeyScopeAndMemorizePreviousScope(
IconPickerHotkeyScope.IconPicker,
);
}}
onMouseLeave={goBackToPreviousHotkeyScope}
>
<DropdownMenuItemsContainer>
{isLoading ? (
<DropdownMenuSkeletonItem />
) : (
<StyledMenuIconItemsContainer>
{iconKeys.map((iconKey) => (
<IconPickerIcon
key={iconKey}
iconKey={iconKey}
onClick={() => {
onChange({ iconKey, Icon: icons[iconKey] });
closeDropdown();
}}
selectedIconKey={selectedIconKey}
Icon={icons[iconKey]}
/>
))}
</StyledMenuIconItemsContainer>
)}
</DropdownMenuItemsContainer>
</div>
</DropdownMenu>
</SelectableList>
}
onClickOutside={onClickOutside}
onClose={() => {

View File

@ -1,13 +1,17 @@
import { ReactNode } from 'react';
import { ReactNode, useEffect } from 'react';
import styled from '@emotion/styled';
import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { SelectableListScope } from '@/ui/layout/selectable-list/scopes/SelectableListScope';
type SelectableListProps = {
children: ReactNode;
selectableListId: string;
selectableItemIds: string[];
selectableItemIds: string[][];
onSelect?: (selected: string) => void;
hotkeyScope: string;
onEnter?: (itemId: string) => void;
};
const StyledSelectableItemsContainer = styled.div`
@ -17,8 +21,23 @@ const StyledSelectableItemsContainer = styled.div`
export const SelectableList = ({
children,
selectableListId,
hotkeyScope,
selectableItemIds,
onEnter,
}: SelectableListProps) => {
useSelectableListHotKeys(selectableListId);
useSelectableListHotKeys(selectableListId, hotkeyScope);
const { setSelectableItemIds, setSelectableListOnEnter } = useSelectableList({
selectableListId,
});
useEffect(() => {
setSelectableListOnEnter(() => onEnter);
}, [onEnter, setSelectableListOnEnter]);
useEffect(() => {
setSelectableItemIds(selectableItemIds);
}, [selectableItemIds, setSelectableItemIds]);
return (
<SelectableListScope selectableListScopeId={selectableListId}>

View File

@ -1,16 +1,37 @@
import { isNull } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { Key } from 'ts-key-enum';
import { getSelectableListScopedStates } from '@/ui/layout/selectable-list/utils/internal/getSelectableListScopedStates';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
export const useSelectableListHotKeys = (scopeId: string) => {
type Direction = 'up' | 'down' | 'left' | 'right';
export const useSelectableListHotKeys = (
scopeId: string,
hotkeyScope: string,
) => {
const findPosition = (
selectableItemIds: string[][],
selectedItemId?: string | null,
) => {
if (!selectedItemId) {
// If nothing is selected, return the default position
return { row: 0, col: 0 };
}
for (let row = 0; row < selectableItemIds.length; row++) {
const col = selectableItemIds[row].indexOf(selectedItemId);
if (col !== -1) {
return { row, col };
}
}
return { row: 0, col: 0 };
};
const handleSelect = useRecoilCallback(
({ snapshot, set }) =>
(direction: 'up' | 'down') => {
(direction: Direction) => {
const { selectedItemIdState, selectableItemIdsState } =
getSelectableListScopedStates({
selectableListScopeId: scopeId,
@ -21,31 +42,54 @@ export const useSelectableListHotKeys = (scopeId: string) => {
selectableItemIdsState,
);
const computeNextId = (direction: 'up' | 'down') => {
const { row: currentRow, col: currentCol } = findPosition(
selectableItemIds,
selectedItemId,
);
const computeNextId = (direction: Direction) => {
if (selectableItemIds.length === 0) {
return;
}
if (isNull(selectedItemId)) {
return direction === 'up'
? selectableItemIds[selectableItemIds.length - 1]
: selectableItemIds[0];
const isSingleRow = selectableItemIds.length === 1;
let nextRow: number;
let nextCol: number;
switch (direction) {
case 'up':
nextRow = isSingleRow ? currentRow : Math.max(0, currentRow - 1);
nextCol = isSingleRow ? Math.max(0, currentCol - 1) : currentCol;
break;
case 'down':
nextRow = isSingleRow
? currentRow
: Math.min(selectableItemIds.length - 1, currentRow + 1);
nextCol = isSingleRow
? Math.min(
selectableItemIds[currentRow].length - 1,
currentCol + 1,
)
: currentCol;
break;
case 'left':
nextRow = currentRow;
nextCol = Math.max(0, currentCol - 1);
break;
case 'right':
nextRow = currentRow;
nextCol = Math.min(
selectableItemIds[currentRow].length - 1,
currentCol + 1,
);
break;
default:
nextRow = currentRow;
nextCol = currentCol;
}
const currentIndex = selectableItemIds.indexOf(selectedItemId);
if (currentIndex === -1) {
return direction === 'up'
? selectableItemIds[selectableItemIds.length - 1]
: selectableItemIds[0];
}
return direction === 'up'
? currentIndex == 0
? selectableItemIds[selectableItemIds.length - 1]
: selectableItemIds[currentIndex - 1]
: currentIndex == selectableItemIds.length - 1
? selectableItemIds[0]
: selectableItemIds[currentIndex + 1];
return selectableItemIds[nextRow][nextCol];
};
const nextId = computeNextId(direction);
@ -70,17 +114,45 @@ export const useSelectableListHotKeys = (scopeId: string) => {
[scopeId],
);
useScopedHotkeys(Key.ArrowUp, () => handleSelect('up'), hotkeyScope, []);
useScopedHotkeys(Key.ArrowDown, () => handleSelect('down'), hotkeyScope, []);
useScopedHotkeys(Key.ArrowLeft, () => handleSelect('left'), hotkeyScope, []);
useScopedHotkeys(
Key.ArrowUp,
() => handleSelect('up'),
AppHotkeyScope.CommandMenu,
Key.ArrowRight,
() => handleSelect('right'),
hotkeyScope,
[],
);
useScopedHotkeys(
Key.ArrowDown,
() => handleSelect('down'),
AppHotkeyScope.CommandMenu,
Key.Enter,
useRecoilCallback(
({ snapshot }) =>
() => {
const { selectedItemIdState, selectableListOnEnterState } =
getSelectableListScopedStates({
selectableListScopeId: scopeId,
});
const selectedItemId = getSnapshotValue(
snapshot,
selectedItemIdState,
);
const onEnter = getSnapshotValue(
snapshot,
selectableListOnEnterState,
);
if (selectedItemId) {
onEnter?.(selectedItemId);
}
},
[scopeId],
),
hotkeyScope,
[],
);

View File

@ -20,6 +20,7 @@ export const useSelectableListScopedStates = (
selectedItemIdState,
selectableItemIdsState,
isSelectedItemIdSelector,
selectableListOnEnterState,
} = getSelectableListScopedStates({
selectableListScopeId: scopeId,
itemId: itemId,
@ -30,5 +31,6 @@ export const useSelectableListScopedStates = (
isSelectedItemIdSelector,
selectableItemIdsState,
selectedItemIdState,
selectableListOnEnterState,
};
};

View File

@ -15,18 +15,25 @@ export const useSelectableList = (props?: UseSelectableListProps) => {
props?.selectableListId,
);
const { selectableItemIdsState, isSelectedItemIdSelector } =
useSelectableListScopedStates({
selectableListScopeId: scopeId,
itemId: props?.itemId,
});
const {
selectableItemIdsState,
isSelectedItemIdSelector,
selectableListOnEnterState,
} = useSelectableListScopedStates({
selectableListScopeId: scopeId,
itemId: props?.itemId,
});
const setSelectableItemIds = useSetRecoilState(selectableItemIdsState);
const setSelectableListOnEnter = useSetRecoilState(
selectableListOnEnterState,
);
const isSelectedItemId = useRecoilValue(isSelectedItemIdSelector);
return {
setSelectableItemIds,
isSelectedItemId,
setSelectableListOnEnter,
selectableListId: scopeId,
isSelectedItemIdSelector,
};

View File

@ -1,6 +1,6 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const selectableItemIdsScopedState = createScopedState<string[]>({
export const selectableItemIdsScopedState = createScopedState<string[][]>({
key: 'selectableItemIdsScopedState',
defaultValue: [],
defaultValue: [[]],
});

View File

@ -0,0 +1,8 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const selectableListOnEnterScopedState = createScopedState<
((itemId: string) => void) | undefined
>({
key: 'selectableListOnEnterScopedState',
defaultValue: undefined,
});

View File

@ -1,4 +1,5 @@
import { selectableItemIdsScopedState } from '@/ui/layout/selectable-list/states/selectableItemIdsScopedState';
import { selectableListOnEnterScopedState } from '@/ui/layout/selectable-list/states/selectableListOnEnterScopedState';
import { selectedItemIdScopedState } from '@/ui/layout/selectable-list/states/selectedItemIdScopedState';
import { isSelectedItemIdScopedFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdScopedFamilySelector';
import { getScopedState } from '@/ui/utilities/recoil-scope/utils/getScopedState';
@ -27,9 +28,15 @@ export const getSelectableListScopedStates = ({
selectableListScopeId,
);
const selectableListOnEnterState = getScopedState(
selectableListOnEnterScopedState,
selectableListScopeId,
);
return {
isSelectedItemIdSelector,
selectableItemIdsState,
selectedItemIdState,
selectableListOnEnterState,
};
};

View File

@ -74,6 +74,7 @@ export const useSetHotkeyScope = () =>
}
scopesToSet.push(newHotkeyScope.scope);
console.log(scopesToSet);
set(internalHotkeysEnabledScopesState, scopesToSet);
set(currentHotkeyScopeState, newHotkeyScope);
},

View File

@ -0,0 +1,11 @@
// split an array into subarrays of a given size
export const arrayToChunks = <T>(array: T[], size: number) => {
const arrayCopy = [...array];
const results = [];
while (arrayCopy.length) {
results.push(arrayCopy.splice(0, size));
}
return results;
};