Replace hotkey scopes by focus stack (Part 1 - Dropdowns and Side Panel) (#12673)

This PR is the first part of a refactoring aiming to deprecate the
hotkey scopes api in favor of the new focus stack api which is more
robust.

The refactored components in this PR are the dropdowns and the side
panel/command menu.

- Replaced `useScopedHotkeys` by `useHotkeysOnFocusedElement` for all
dropdown components, selectable lists and the command menu
- Introduced `focusId` for all dropdowns and created a common hotkey
scope `DropdownHotkeyScope` for backward compatibility
- Replaced `setHotkeyScopeAndMemorizePreviousScope` occurrences with
`usePushFocusItemToFocusStack` and `goBackToPreviousHotkeyScope` with
`removeFocusItemFromFocusStack`

Note: Test that the shorcuts and arrow key navigation still work
properly when interacting with dropdowns and the command menu.

Bugs that I have spotted during the QA but which are already present on
main:
- Icon picker select with arrow keys doesn’t work inside dropdowns
- Some dropdowns are not selectable with arrow keys (no selectable list)
- Dropdowns in dropdowns don’t reset the hotkey scope correctly when
closing
- The table click outside is not triggered after closing a table cell
and clicking outside of the table
This commit is contained in:
Raphaël Bosi
2025-06-19 14:53:18 +02:00
committed by GitHub
parent 6dd3a71497
commit cbc0d06a2f
155 changed files with 977 additions and 845 deletions
@@ -4,11 +4,10 @@ import { DROPDOWN_RESIZE_MIN_HEIGHT } from '@/ui/layout/dropdown/constants/Dropd
import { DROPDOWN_RESIZE_MIN_WIDTH } from '@/ui/layout/dropdown/constants/DropdownResizeMinWidth';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponeInstanceContext';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { dropdownHotkeyComponentState } from '@/ui/layout/dropdown/states/dropdownHotkeyComponentState';
import { dropdownMaxHeightComponentState } from '@/ui/layout/dropdown/states/internal/dropdownMaxHeightComponentState';
import { dropdownMaxWidthComponentState } from '@/ui/layout/dropdown/states/internal/dropdownMaxWidthComponentState';
import { DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled';
import {
@@ -47,9 +46,8 @@ export type DropdownProps = {
dropdownComponents: ReactNode;
hotkey?: {
key: Keys;
scope: string;
};
dropdownHotkeyScope: HotkeyScope;
globalHotkeysConfig?: Partial<GlobalHotkeysConfig>;
dropdownId: string;
dropdownPlacement?: Placement;
dropdownOffset?: DropdownOffset;
@@ -66,7 +64,7 @@ export const Dropdown = ({
dropdownComponents,
hotkey,
dropdownId,
dropdownHotkeyScope,
globalHotkeysConfig,
dropdownPlacement = 'bottom-end',
dropdownStrategy = 'absolute',
dropdownOffset,
@@ -145,21 +143,14 @@ export const Dropdown = ({
});
const handleClickableComponentClick = useRecoilCallback(
({ set }) =>
async (event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
() => async (event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
// TODO: refactor this when we have finished dropdown refactor with state and V1 + V2
set(
dropdownHotkeyComponentState({ scopeId: dropdownId }),
dropdownHotkeyScope,
);
toggleDropdown(dropdownHotkeyScope);
onClickOutside?.();
},
[dropdownId, dropdownHotkeyScope, onClickOutside, toggleDropdown],
toggleDropdown(globalHotkeysConfig);
onClickOutside?.();
},
[globalHotkeysConfig, onClickOutside, toggleDropdown],
);
return (
@@ -190,10 +181,9 @@ export const Dropdown = ({
dropdownId={dropdownId}
dropdownPlacement={placement}
floatingUiRefs={refs}
hotkeyScope={dropdownHotkeyScope}
hotkey={hotkey}
onClickOutside={onClickOutside}
onHotkeyTriggered={toggleDropdown}
onHotkeyTriggered={onOpen}
excludedClickOutsideIds={excludedClickOutsideIds}
isDropdownInModal={isDropdownInModal}
/>
@@ -1,7 +1,6 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownMenuHotkeyScope';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useTheme } from '@emotion/react';
@@ -72,12 +71,9 @@ export const DropdownMenuInnerSelect = ({
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownHotkeyScope={{
scope: DropdownMenuHotkeyScope.InnerSelect,
customScopes: {
commandMenu: false,
commandMenuOpen: false,
},
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
dropdownId={dropdownId}
dropdownOffset={{
@@ -39,13 +39,11 @@ const meta: Meta<typeof Dropdown> = {
decorators: [ComponentDecorator, (Story) => <Story />],
args: {
clickableComponent: <Button title="Open Dropdown" />,
dropdownHotkeyScope: { scope: 'testDropdownMenu' },
dropdownOffset: { x: 0, y: 8 },
dropdownId: 'test-dropdown-id',
},
argTypes: {
clickableComponent: { control: false },
dropdownHotkeyScope: { control: false },
dropdownOffset: { control: false },
dropdownComponents: { control: false },
},
@@ -352,7 +350,6 @@ const ModalWithDropdown = () => {
title="Open Dropdown in Modal"
/>
}
dropdownHotkeyScope={{ scope: 'modal-dropdown' }}
dropdownOffset={{ x: 0, y: 8 }}
dropdownId="modal-dropdown-test"
isDropdownInModal={true}
@@ -1,6 +1,5 @@
import { Meta, StoryObj } from '@storybook/react';
import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
@@ -63,7 +62,6 @@ export const ContextDropdownAndAvatar: Story = {
EndComponent: (
<Dropdown
dropdownId={'story-dropdown-id-context-menu'}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
@@ -1,13 +1,12 @@
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useInternalHotkeyScopeManagement } from '@/ui/layout/dropdown/hooks/useInternalHotkeyScopeManagement';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { dropdownMaxHeightComponentState } from '@/ui/layout/dropdown/states/internal/dropdownMaxHeightComponentState';
import { dropdownMaxWidthComponentState } from '@/ui/layout/dropdown/states/internal/dropdownMaxWidthComponentState';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@@ -45,11 +44,9 @@ export type DropdownInternalContainerProps = {
dropdownPlacement: Placement;
floatingUiRefs: UseFloatingReturn['refs'];
onClickOutside?: () => void;
hotkeyScope: HotkeyScope;
floatingStyles: UseFloatingReturn['floatingStyles'];
hotkey?: {
key: Keys;
scope: string;
};
onHotkeyTriggered?: () => void;
dropdownComponents: React.ReactNode;
@@ -63,7 +60,6 @@ export const DropdownInternalContainer = ({
dropdownPlacement,
floatingUiRefs,
onClickOutside,
hotkeyScope,
floatingStyles,
hotkey,
onHotkeyTriggered,
@@ -108,23 +104,24 @@ export const DropdownInternalContainer = ({
excludedClickOutsideIds,
});
useInternalHotkeyScopeManagement({
dropdownScopeId: dropdownId,
dropdownHotkeyScopeFromParent: hotkeyScope,
});
useScopedHotkeys(
[Key.Escape],
() => {
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: () => {
if (activeDropdownFocusId !== dropdownId) return;
if (isDropdownOpen) {
closeDropdown();
}
},
hotkeyScope?.scope,
[closeDropdown, isDropdownOpen],
);
focusId: dropdownId,
scope: DropdownHotkeyScope.Dropdown,
dependencies: [
closeDropdown,
isDropdownOpen,
activeDropdownFocusId,
dropdownId,
],
});
const dropdownMenuStyles = {
...floatingStyles,
@@ -137,7 +134,11 @@ export const DropdownInternalContainer = ({
return (
<>
{hotkey && onHotkeyTriggered && (
<HotkeyEffect hotkey={hotkey} onHotkeyTriggered={onHotkeyTriggered} />
<HotkeyEffect
hotkey={hotkey}
onHotkeyTriggered={onHotkeyTriggered}
focusId={dropdownId}
/>
)}
<FloatingPortal>
@@ -0,0 +1,3 @@
export enum DropdownHotkeyScope {
Dropdown = 'dropdown',
}
@@ -1,3 +0,0 @@
export enum DropdownMenuHotkeyScope {
InnerSelect = 'dropdown-menu-inner-select',
}
@@ -1,47 +0,0 @@
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useDropdownStates } from '@/ui/layout/dropdown/hooks/internal/useDropdownStates';
import { useInternalHotkeyScopeManagement } from '@/ui/layout/dropdown/hooks/useInternalHotkeyScopeManagement';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
const dropdownScopeId = 'test-dropdown-id-scope';
const Wrapper = ({ children }: { children: React.ReactNode }) => {
return <RecoilRoot>{children}</RecoilRoot>;
};
describe('useInternalHotkeyScopeManagement', () => {
it('should update dropdownHotkeyScope', async () => {
const { result, rerender } = renderHook(
({
dropdownHotkeyScopeFromParent,
}: {
dropdownHotkeyScopeFromParent?: HotkeyScope;
}) => {
useInternalHotkeyScopeManagement({
dropdownScopeId,
dropdownHotkeyScopeFromParent,
});
const { dropdownHotkeyScopeState } = useDropdownStates({
dropdownScopeId,
});
const dropdownHotkeyScope = useRecoilValue(dropdownHotkeyScopeState);
return { dropdownHotkeyScope };
},
{
wrapper: Wrapper,
initialProps: {},
},
);
expect(result.current.dropdownHotkeyScope).toBeNull();
const scopeFromParent = { scope: 'customScope' };
rerender({ dropdownHotkeyScopeFromParent: scopeFromParent });
expect(result.current.dropdownHotkeyScope).toEqual(scopeFromParent);
});
});
@@ -1,5 +1,4 @@
import { DropdownScopeInternalContext } from '@/ui/layout/dropdown/scopes/scope-internal-context/DropdownScopeInternalContext';
import { dropdownHotkeyComponentState } from '@/ui/layout/dropdown/states/dropdownHotkeyComponentState';
import { dropdownPlacementComponentState } from '@/ui/layout/dropdown/states/dropdownPlacementComponentState';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
@@ -23,10 +22,6 @@ export const useDropdownStates = ({
dropdownPlacementComponentState,
scopeId,
),
dropdownHotkeyScopeState: extractComponentState(
dropdownHotkeyComponentState,
scopeId,
),
isDropdownOpenState: extractComponentState(
isDropdownOpenComponentState,
scopeId,
@@ -1,15 +1,15 @@
import { useCloseDropdownFromOutside } from '@/ui/layout/dropdown/hooks/useCloseDropdownFromOutside';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRemoveFocusItemFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStack';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useCloseAnyOpenDropdown = () => {
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const { closeDropdownFromOutside } = useCloseDropdownFromOutside();
const { removeFocusItemFromFocusStack } = useRemoveFocusItemFromFocusStack();
const closeAnyOpenDropdown = useRecoilCallback(
({ snapshot, set }) =>
() => {
@@ -33,18 +33,24 @@ export const useCloseAnyOpenDropdown = () => {
if (isDefined(activeDropdownFocusId)) {
closeDropdownFromOutside(activeDropdownFocusId);
removeFocusItemFromFocusStack({
focusId: activeDropdownFocusId,
memoizeKey: 'global',
});
}
if (thereIsOneNestedDropdownOpen) {
closeDropdownFromOutside(previousDropdownFocusId);
removeFocusItemFromFocusStack({
focusId: previousDropdownFocusId,
memoizeKey: 'global',
});
}
set(previousDropdownFocusIdState, null);
set(activeDropdownFocusIdState, null);
goBackToPreviousHotkeyScope();
},
[closeDropdownFromOutside, goBackToPreviousHotkeyScope],
[closeDropdownFromOutside, removeFocusItemFromFocusStack],
);
return { closeAnyOpenDropdown };
@@ -3,18 +3,19 @@ import { useRecoilCallback, useRecoilState } from 'recoil';
import { useDropdownStates } from '@/ui/layout/dropdown/hooks/internal/useDropdownStates';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { dropdownHotkeyComponentState } from '@/ui/layout/dropdown/states/dropdownHotkeyComponentState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useDropdown = (dropdownId?: string) => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStack } = useRemoveFocusItemFromFocusStack();
const { scopeId, isDropdownOpenState, dropdownPlacementState } =
useDropdownStates({
dropdownScopeId: dropdownId,
});
useDropdownStates({ dropdownScopeId: dropdownId });
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
@@ -22,71 +23,67 @@ export const useDropdown = (dropdownId?: string) => {
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const [isDropdownOpen, setIsDropdownOpen] =
useRecoilState(isDropdownOpenState);
const [dropdownPlacement, setDropdownPlacement] = useRecoilState(
dropdownPlacementState,
);
const [isDropdownOpen, setIsDropdownOpen] =
useRecoilState(isDropdownOpenState);
const closeDropdown = useCallback(() => {
if (isDropdownOpen) {
goBackToPreviousHotkeyScope();
setIsDropdownOpen(false);
goBackToPreviousDropdownFocusId();
removeFocusItemFromFocusStack({
focusId: dropdownId ?? scopeId,
memoizeKey: 'global',
});
}
}, [
isDropdownOpen,
goBackToPreviousHotkeyScope,
setIsDropdownOpen,
goBackToPreviousDropdownFocusId,
removeFocusItemFromFocusStack,
dropdownId,
scopeId,
]);
const openDropdown = useRecoilCallback(
({ snapshot }) =>
(dropdownHotkeyScopeFromProps?: HotkeyScope) => {
if (!isDropdownOpen) {
setIsDropdownOpen(true);
setActiveDropdownFocusIdAndMemorizePrevious(dropdownId ?? scopeId);
() => (globalHotkeysConfig?: Partial<GlobalHotkeysConfig>) => {
if (!isDropdownOpen) {
setIsDropdownOpen(true);
setActiveDropdownFocusIdAndMemorizePrevious(dropdownId ?? scopeId);
const dropdownHotkeyScope = getSnapshotValue(
snapshot,
dropdownHotkeyComponentState({
scopeId: dropdownId ?? scopeId,
}),
);
const dropdownHotkeyScopeForOpening =
dropdownHotkeyScopeFromProps ?? dropdownHotkeyScope;
if (isDefined(dropdownHotkeyScopeForOpening)) {
setHotkeyScopeAndMemorizePreviousScope({
scope: dropdownHotkeyScopeForOpening.scope,
customScopes: dropdownHotkeyScopeForOpening.customScopes,
});
}
}
},
pushFocusItemToFocusStack({
focusId: dropdownId ?? scopeId,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: dropdownId ?? scopeId,
},
globalHotkeysConfig,
// TODO: Remove this once we've fully migrated away from hotkey scopes
hotkeyScope: { scope: 'dropdown' } as HotkeyScope,
memoizeKey: 'global',
});
}
},
[
isDropdownOpen,
setIsDropdownOpen,
setActiveDropdownFocusIdAndMemorizePrevious,
pushFocusItemToFocusStack,
dropdownId,
scopeId,
setHotkeyScopeAndMemorizePreviousScope,
],
);
const toggleDropdown = (dropdownHotkeyScopeFromProps?: HotkeyScope) => {
const toggleDropdown = (
globalHotkeysConfig?: Partial<GlobalHotkeysConfig>,
) => {
if (isDropdownOpen) {
closeDropdown();
} else {
openDropdown(dropdownHotkeyScopeFromProps);
openDropdown(globalHotkeysConfig);
}
};
@@ -1,21 +1,25 @@
import { useRecoilCallback } from 'recoil';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { dropdownHotkeyComponentState } from '@/ui/layout/dropdown/states/dropdownHotkeyComponentState';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDefined } from 'twenty-shared/utils';
export const useDropdownV2 = () => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStack } = useRemoveFocusItemFromFocusStack();
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
const closeDropdown = useRecoilCallback(
({ set, snapshot }) =>
(specificComponentId: string) => {
@@ -26,7 +30,10 @@ export const useDropdownV2 = () => {
.getValue();
if (isDropdownOpen) {
goBackToPreviousHotkeyScope();
removeFocusItemFromFocusStack({
focusId: scopeId,
memoizeKey: 'global',
});
goBackToPreviousDropdownFocusId();
set(
isDropdownOpenComponentState({
@@ -36,18 +43,17 @@ export const useDropdownV2 = () => {
);
}
},
[goBackToPreviousHotkeyScope, goBackToPreviousDropdownFocusId],
[removeFocusItemFromFocusStack, goBackToPreviousDropdownFocusId],
);
const openDropdown = useRecoilCallback(
({ set, snapshot }) =>
(specificComponentId: string, customHotkeyScope?: HotkeyScope) => {
({ set }) =>
(
specificComponentId: string,
globalHotkeysConfig?: Partial<GlobalHotkeysConfig>,
) => {
const scopeId = specificComponentId;
const dropdownHotkeyScope = snapshot
.getLoadable(dropdownHotkeyComponentState({ scopeId }))
.getValue();
set(
isDropdownOpenComponentState({
scopeId,
@@ -55,24 +61,29 @@ export const useDropdownV2 = () => {
true,
);
if (isDefined(customHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope({
scope: customHotkeyScope.scope,
customScopes: customHotkeyScope.customScopes,
});
} else if (isDefined(dropdownHotkeyScope)) {
setHotkeyScopeAndMemorizePreviousScope({
scope: dropdownHotkeyScope.scope,
customScopes: dropdownHotkeyScope.customScopes,
});
}
setActiveDropdownFocusIdAndMemorizePrevious(specificComponentId);
pushFocusItemToFocusStack({
focusId: scopeId,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: scopeId,
},
globalHotkeysConfig,
// TODO: Remove this once we've fully migrated away from hotkey scopes
hotkeyScope: { scope: 'dropdown' } as HotkeyScope,
memoizeKey: 'global',
});
},
[setHotkeyScopeAndMemorizePreviousScope],
[pushFocusItemToFocusStack, setActiveDropdownFocusIdAndMemorizePrevious],
);
const toggleDropdown = useRecoilCallback(
({ snapshot }) =>
(specificComponentId: string, customHotkeyScope?: HotkeyScope) => {
(
specificComponentId: string,
globalHotkeysConfig?: Partial<GlobalHotkeysConfig>,
) => {
const scopeId = specificComponentId;
const isDropdownOpen = snapshot
.getLoadable(isDropdownOpenComponentState({ scopeId }))
@@ -81,7 +92,7 @@ export const useDropdownV2 = () => {
if (isDropdownOpen) {
closeDropdown(specificComponentId);
} else {
openDropdown(specificComponentId, customHotkeyScope);
openDropdown(specificComponentId, globalHotkeysConfig);
}
},
[closeDropdown, openDropdown],
@@ -1,30 +0,0 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { useDropdownStates } from '@/ui/layout/dropdown/hooks/internal/useDropdownStates';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useInternalHotkeyScopeManagement = ({
dropdownScopeId,
dropdownHotkeyScopeFromParent,
}: {
dropdownScopeId: string;
dropdownHotkeyScopeFromParent?: HotkeyScope;
}) => {
const { dropdownHotkeyScopeState } = useDropdownStates({ dropdownScopeId });
const [dropdownHotkeyScope, setDropdownHotkeyScope] = useRecoilState(
dropdownHotkeyScopeState,
);
useEffect(() => {
if (!isDeeplyEqual(dropdownHotkeyScopeFromParent, dropdownHotkeyScope)) {
setDropdownHotkeyScope(dropdownHotkeyScopeFromParent);
}
}, [
dropdownHotkeyScope,
dropdownHotkeyScopeFromParent,
setDropdownHotkeyScope,
]);
};
@@ -1,9 +0,0 @@
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const dropdownHotkeyComponentState = createComponentState<
HotkeyScope | null | undefined
>({
key: 'dropdownHotkeyComponentState',
defaultValue: null,
});
@@ -1,13 +1,13 @@
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusIdFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack';
import { useRemoveFocusItemFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilCallback } from 'recoil';
export const useModal = () => {
const pushFocusItem = usePushFocusItemToFocusStack();
const removeFocusId = useRemoveFocusIdFromFocusStack();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStack } = useRemoveFocusItemFromFocusStack();
const closeModal = useRecoilCallback(
({ set, snapshot }) =>
@@ -22,7 +22,7 @@ export const useModal = () => {
return;
}
removeFocusId({
removeFocusItemFromFocusStack({
focusId: modalId,
memoizeKey: modalId,
});
@@ -32,7 +32,7 @@ export const useModal = () => {
false,
);
},
[removeFocusId],
[removeFocusItemFromFocusStack],
);
const openModal = useRecoilCallback(
@@ -53,7 +53,7 @@ export const useModal = () => {
true,
);
pushFocusItem({
pushFocusItemToFocusStack({
focusId: modalId,
component: {
type: FocusComponentType.MODAL,
@@ -76,7 +76,7 @@ export const useModal = () => {
memoizeKey: modalId,
});
},
[pushFocusItem],
[pushFocusItemToFocusStack],
);
const toggleModal = useRecoilCallback(
@@ -13,19 +13,26 @@ type SelectableListProps = {
selectableItemIdArray?: string[];
selectableItemIdMatrix?: string[][];
onSelect?: (selected: string) => void;
hotkeyScope: string;
selectableListInstanceId: string;
focusId: string;
hotkeyScope: string;
};
export const SelectableList = ({
children,
hotkeyScope,
selectableItemIdArray,
selectableItemIdMatrix,
selectableListInstanceId,
onSelect,
focusId,
hotkeyScope,
}: SelectableListProps) => {
useSelectableListHotKeys(selectableListInstanceId, hotkeyScope, onSelect);
useSelectableListHotKeys(
selectableListInstanceId,
hotkeyScope,
focusId,
onSelect,
);
const setSelectableItemIds = useSetRecoilComponentStateV2(
selectableItemIdsComponentState,
@@ -54,7 +61,7 @@ export const SelectableList = ({
instanceId: selectableListInstanceId,
}}
>
<SelectableListContextProvider value={{ hotkeyScope }}>
<SelectableListContextProvider value={{ focusId, hotkeyScope }}>
{children}
</SelectableListContextProvider>
</SelectableListComponentInstanceContext.Provider>
@@ -8,12 +8,14 @@ export const SelectableListItemHotkeyEffect = ({
itemId: string;
onEnter: () => void;
}) => {
const { hotkeyScope } = useSelectableListContextOrThrow();
const { focusId, hotkeyScope } = useSelectableListContextOrThrow();
useSelectableListListenToEnterHotkeyOnItem({
hotkeyScope,
focusId,
itemId,
onEnter,
hotkeyScope,
});
return null;
};
@@ -5,14 +5,16 @@ import { Key } from 'ts-key-enum';
import { selectableItemIdsComponentState } from '@/ui/layout/selectable-list/states/selectableItemIdsComponentState';
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';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
type Direction = 'up' | 'down' | 'left' | 'right';
export const useSelectableListHotKeys = (
instanceId: string,
// TODO: Remove this after migration to focus stack
hotkeyScope: string,
focusId: string,
onSelect?: (itemId: string) => void,
) => {
const findPosition = (
@@ -134,16 +136,35 @@ export const useSelectableListHotKeys = (
[instanceId, onSelect],
);
useScopedHotkeys(Key.ArrowUp, () => handleSelect('up'), hotkeyScope, []);
useHotkeysOnFocusedElement({
keys: Key.ArrowUp,
callback: () => handleSelect('up'),
focusId,
scope: hotkeyScope,
dependencies: [handleSelect],
});
useScopedHotkeys(Key.ArrowDown, () => handleSelect('down'), hotkeyScope, []);
useHotkeysOnFocusedElement({
keys: Key.ArrowDown,
callback: () => handleSelect('down'),
focusId,
scope: hotkeyScope,
dependencies: [handleSelect],
});
useScopedHotkeys(Key.ArrowLeft, () => handleSelect('left'), hotkeyScope, []);
useHotkeysOnFocusedElement({
keys: Key.ArrowLeft,
callback: () => handleSelect('left'),
focusId,
scope: hotkeyScope,
dependencies: [handleSelect],
});
useScopedHotkeys(
Key.ArrowRight,
() => handleSelect('right'),
hotkeyScope,
[],
);
useHotkeysOnFocusedElement({
keys: Key.ArrowRight,
callback: () => handleSelect('right'),
focusId,
scope: hotkeyScope,
dependencies: [handleSelect],
});
};
@@ -1,6 +1,6 @@
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { isNonEmptyString } from '@sniptt/guards';
@@ -8,20 +8,24 @@ import { useRecoilCallback } from 'recoil';
import { Key } from 'ts-key-enum';
export const useSelectableListListenToEnterHotkeyOnItem = ({
hotkeyScope,
focusId,
itemId,
onEnter,
hotkeyScope,
}: {
hotkeyScope: string;
focusId: string;
itemId: string;
onEnter: () => void;
// TODO: Remove this after migration to focus stack
hotkeyScope: string;
}) => {
const instanceId = useAvailableComponentInstanceIdOrThrow(
SelectableListComponentInstanceContext,
);
useScopedHotkeys(
Key.Enter,
useRecoilCallback(
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: useRecoilCallback(
({ snapshot }) =>
() => {
const selectedItemId = getSnapshotValue(
@@ -37,7 +41,8 @@ export const useSelectableListListenToEnterHotkeyOnItem = ({
},
[instanceId, itemId, onEnter],
),
hotkeyScope,
[itemId, onEnter],
);
focusId,
scope: hotkeyScope,
dependencies: [itemId, onEnter],
});
};
@@ -1,6 +1,7 @@
import { createRequiredContext } from '~/utils/createRequiredContext';
export type SelectableListContextValue = {
focusId: string;
hotkeyScope: string;
};
@@ -67,7 +67,6 @@ export const TabListDropdown = ({
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownHotkeyScope={{ scope: dropdownId }}
/>
);
};