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:
@@ -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={() => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+101
-29
@@ -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,
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: [[]],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
+7
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -74,6 +74,7 @@ export const useSetHotkeyScope = () =>
|
||||
}
|
||||
|
||||
scopesToSet.push(newHotkeyScope.scope);
|
||||
console.log(scopesToSet);
|
||||
set(internalHotkeysEnabledScopesState, scopesToSet);
|
||||
set(currentHotkeyScopeState, newHotkeyScope);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user