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

View File

@ -2,15 +2,17 @@ import { useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
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 { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils';
@ -21,7 +23,7 @@ import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmp
type MultiSelectInputProps = {
selectableListComponentInstanceId: string;
values: FieldMultiSelectValue;
hotkeyScope: string;
focusId: string;
onCancel?: () => void;
options: SelectOption[];
onOptionSelected: (value: FieldMultiSelectValue) => void;
@ -32,7 +34,7 @@ export const MultiSelectInput = ({
selectableListComponentInstanceId,
values,
options,
hotkeyScope,
focusId,
onCancel,
onOptionSelected,
dropdownWidth,
@ -69,15 +71,16 @@ export const MultiSelectInput = ({
}
};
useScopedHotkeys(
Key.Escape,
() => {
useHotkeysOnFocusedElement({
keys: Key.Escape,
callback: () => {
onCancel?.();
resetSelectedItem();
},
hotkeyScope,
[onCancel, resetSelectedItem],
);
focusId,
scope: DEFAULT_CELL_SCOPE.scope,
dependencies: [onCancel, resetSelectedItem],
});
useListenClickOutside({
refs: [containerRef],
@ -102,7 +105,8 @@ export const MultiSelectInput = ({
<SelectableList
selectableListInstanceId={selectableListComponentInstanceId}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
focusId={focusId}
hotkeyScope={DEFAULT_CELL_SCOPE.scope}
>
<DropdownContent
ref={containerRef}
@ -122,17 +126,25 @@ export const MultiSelectInput = ({
<DropdownMenuItemsContainer hasMaxHeight>
{filteredOptionsInDropDown.map((option) => {
return (
<MenuItemMultiSelectTag
<SelectableListItem
key={option.value}
selected={values?.includes(option.value) || false}
text={option.label}
color={option.color ?? 'transparent'}
Icon={option.Icon ?? undefined}
onClick={() =>
onOptionSelected(formatNewSelectedOptions(option.value))
}
isKeySelected={selectedItemId === option.value}
/>
itemId={option.value}
onEnter={() => {
onOptionSelected(formatNewSelectedOptions(option.value));
}}
>
<MenuItemMultiSelectTag
key={option.value}
selected={values?.includes(option.value) || false}
text={option.label}
color={option.color ?? 'transparent'}
Icon={option.Icon ?? undefined}
onClick={() =>
onOptionSelected(formatNewSelectedOptions(option.value))
}
isKeySelected={selectedItemId === option.value}
/>
</SelectableListItem>
);
})}
</DropdownMenuItemsContainer>

View File

@ -1,3 +1,4 @@
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { SelectInput as SelectBaseInput } from '@/ui/input/components/SelectInput';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectOption } from 'twenty-ui/input';
@ -5,7 +6,7 @@ import { SelectOption } from 'twenty-ui/input';
type SelectInputProps = {
selectableListComponentInstanceId: string;
selectableItemIdArray: string[];
hotkeyScope: string;
focusId: string;
onEnter: (itemId: string) => void;
onOptionSelected: (selectedOption: SelectOption) => void;
options: SelectOption[];
@ -19,7 +20,7 @@ type SelectInputProps = {
export const SelectInput = ({
selectableListComponentInstanceId,
selectableItemIdArray,
hotkeyScope,
focusId,
onOptionSelected,
options,
onCancel,
@ -32,7 +33,8 @@ export const SelectInput = ({
<SelectableList
selectableListInstanceId={selectableListComponentInstanceId}
selectableItemIdArray={selectableItemIdArray}
hotkeyScope={hotkeyScope}
focusId={focusId}
hotkeyScope={DEFAULT_CELL_SCOPE.scope}
>
<SelectBaseInput
onOptionSelected={onOptionSelected}
@ -42,7 +44,7 @@ export const SelectInput = ({
onFilterChange={onFilterChange}
onClear={onClear}
clearLabel={clearLabel}
hotkeyScope={hotkeyScope}
focusId={focusId}
/>
</SelectableList>
);

View File

@ -12,6 +12,7 @@ import { arrayToChunks } from '~/utils/array/arrayToChunks';
import { ICON_PICKER_DROPDOWN_CONTENT_WIDTH } from '@/ui/input/components/constants/IconPickerDropdownContentWidth';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
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';
@ -71,9 +72,10 @@ const IconPickerIcon = ({
);
useSelectableListListenToEnterHotkeyOnItem({
hotkeyScope: IconPickerHotkeyScope.IconPicker,
focusId: iconKey,
itemId: iconKey,
onEnter: onClick,
hotkeyScope: DropdownHotkeyScope.Dropdown,
});
return (
@ -184,7 +186,6 @@ export const IconPicker = ({
<div className={className}>
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{ scope: IconPickerHotkeyScope.IconPicker }}
clickableComponent={
<IconButton
ariaLabel={`Click to select icon ${
@ -203,7 +204,8 @@ export const IconPicker = ({
<SelectableList
selectableListInstanceId="icon-list"
selectableItemIdMatrix={iconKeys2d}
hotkeyScope={IconPickerHotkeyScope.IconPicker}
focusId={dropdownId}
hotkeyScope={DropdownHotkeyScope.Dropdown}
>
<DropdownMenuSearchInput
placeholder={t`Search icon`}

View File

@ -9,6 +9,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
@ -19,7 +20,6 @@ import { isDefined } from 'twenty-shared/utils';
import { IconComponent } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectSizeVariant = 'small' | 'default';
@ -166,9 +166,10 @@ export const Select = <Value extends SelectValue>({
{!!filteredOptions.length && (
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
hotkeyScope={SelectHotkeyScope.Select}
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={selectableItemIdArray}
hotkeyScope={DropdownHotkeyScope.Dropdown}
>
{filteredOptions.map((option) => (
<SelectableListItem
@ -211,7 +212,6 @@ export const Select = <Value extends SelectValue>({
)}
</DropdownContent>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
)}
</StyledContainer>

View File

@ -2,10 +2,13 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { TagColor } from 'twenty-ui/components';
import { SelectOption } from 'twenty-ui/input';
@ -19,7 +22,7 @@ interface SelectInputProps {
onFilterChange?: (filteredOptions: SelectOption[]) => void;
onClear?: () => void;
clearLabel?: string;
hotkeyScope: string;
focusId: string;
}
export const SelectInput = ({
@ -30,10 +33,19 @@ export const SelectInput = ({
onCancel,
defaultOption,
onFilterChange,
hotkeyScope,
}: SelectInputProps) => {
const containerRef = useRef<HTMLDivElement>(null);
// Get the SelectableList instance id from context
const selectableListInstanceId = useAvailableComponentInstanceIdOrThrow(
SelectableListComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValueV2(
selectedItemIdComponentState,
selectableListInstanceId,
);
const [searchFilter, setSearchFilter] = useState('');
const [selectedOption, setSelectedOption] = useState<
SelectOption | undefined
@ -61,6 +73,11 @@ export const SelectInput = ({
onOptionSelected(option);
};
const handleClearOption = () => {
setSelectedOption(undefined);
onClear?.();
};
useEffect(() => {
onFilterChange?.(optionsInDropDown);
}, [onFilterChange, optionsInDropDown]);
@ -81,20 +98,6 @@ export const SelectInput = ({
listenerId: 'select-input',
});
useScopedHotkeys(
Key.Enter,
() => {
const selectedOption = optionsInDropDown.find((option) =>
option.label.toLowerCase().includes(searchFilter.toLowerCase()),
);
if (isDefined(selectedOption)) {
handleOptionChange(selectedOption);
}
},
hotkeyScope,
[searchFilter, optionsInDropDown],
);
return (
<DropdownContent ref={containerRef} selectDisabled>
<DropdownMenuSearchInput
@ -105,27 +108,37 @@ export const SelectInput = ({
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{onClear && clearLabel && (
<MenuItemSelectTag
key={`No ${clearLabel}`}
text={`No ${clearLabel}`}
color="transparent"
variant={'outline'}
onClick={() => {
setSelectedOption(undefined);
onClear();
}}
/>
<SelectableListItem
itemId={`No ${clearLabel}`}
onEnter={handleClearOption}
>
<MenuItemSelectTag
key={`No ${clearLabel}`}
text={`No ${clearLabel}`}
color="transparent"
variant={'outline'}
onClick={handleClearOption}
isKeySelected={selectedItemId === `No ${clearLabel}`}
/>
</SelectableListItem>
)}
{optionsInDropDown.map((option) => {
return (
<MenuItemSelectTag
<SelectableListItem
key={option.value}
focused={selectedOption?.value === option.value}
text={option.label}
color={(option.color as TagColor) ?? 'transparent'}
onClick={() => handleOptionChange(option)}
LeftIcon={option.Icon}
/>
itemId={option.value}
onEnter={() => handleOptionChange(option)}
>
<MenuItemSelectTag
key={option.value}
selected={selectedOption?.value === option.value}
text={option.label}
color={(option.color as TagColor) ?? 'transparent'}
onClick={() => handleOptionChange(option)}
LeftIcon={option.Icon}
isKeySelected={selectedItemId === option.value}
/>
</SelectableListItem>
);
})}
</DropdownMenuItemsContainer>

View File

@ -70,7 +70,6 @@ export const CurrencyPickerDropdownButton = ({
return (
<Dropdown
dropdownId="currency-picker-dropdown-id"
dropdownHotkeyScope={{ scope: CurrencyPickerHotkeyScope.CurrencyPicker }}
clickableComponent={
<StyledDropdownButtonContainer>
<StyledIconContainer>

View File

@ -6,8 +6,6 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { CountryPickerHotkeyScope } from '../types/CountryPickerHotkeyScope';
import { PhoneCountryPickerDropdownSelect } from './PhoneCountryPickerDropdownSelect';
import 'react-phone-number-input/style.css';
@ -98,7 +96,6 @@ export const PhoneCountryPickerDropdownButton = ({
return (
<Dropdown
dropdownId="country-picker-dropdown-id"
dropdownHotkeyScope={{ scope: CountryPickerHotkeyScope.CountryPicker }}
clickableComponent={
<StyledDropdownButtonContainer isUnfolded={isDropdownOpen}>
<StyledIconContainer>

View File

@ -1,3 +0,0 @@
export enum SelectHotkeyScope {
Select = 'select',
}

View File

@ -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}
/>

View File

@ -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={{

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,3 @@
export enum DropdownHotkeyScope {
Dropdown = 'dropdown',
}

View File

@ -1,3 +0,0 @@
export enum DropdownMenuHotkeyScope {
InnerSelect = 'dropdown-menu-inner-select',
}

View File

@ -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);
});
});

View File

@ -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,

View File

@ -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 };

View File

@ -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);
}
};

View File

@ -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],

View File

@ -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,
]);
};

View File

@ -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,
});

View File

@ -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(

View File

@ -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>

View File

@ -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;
};

View File

@ -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],
});
};

View File

@ -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],
});
};

View File

@ -1,6 +1,7 @@
import { createRequiredContext } from '~/utils/createRequiredContext';
export type SelectableListContextValue = {
focusId: string;
hotkeyScope: string;
};

View File

@ -67,7 +67,6 @@ export const TabListDropdown = ({
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownHotkeyScope={{ scope: dropdownId }}
/>
);
};

View File

@ -1,4 +1,3 @@
import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useTheme } from '@emotion/react';
import { Placement } from '@floating-ui/react';
@ -93,7 +92,6 @@ export const MenuItemWithOptionDropdown = ({
dropdownPlacement={dropdownPlacement}
dropdownComponents={dropdownContent}
dropdownId={dropdownId}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
</div>
{hasSubMenu && (

View File

@ -5,7 +5,6 @@ import { MultiWorkspaceDropdownThemesComponents } from '@/ui/navigation/navigati
import { MultiWorkspaceDropdownWorkspacesListComponents } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/internal/MultiWorkspaceDropdownWorkspacesListComponents';
import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MultiWorkspaceDropdownId';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { NavigationDrawerHotKeyScope } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerHotKeyScope';
import { useMemo } from 'react';
import { useRecoilState } from 'recoil';
@ -28,9 +27,6 @@ export const MultiWorkspaceDropdownButton = () => {
return (
<Dropdown
dropdownId={MULTI_WORKSPACE_DROPDOWN_ID}
dropdownHotkeyScope={{
scope: NavigationDrawerHotKeyScope.MultiWorkspaceDropdownButton,
}}
dropdownOffset={{ y: -35, x: -5 }}
clickableComponent={<MultiWorkspaceDropdownClickableComponent />}
dropdownComponents={<DropdownComponents />}

View File

@ -1,14 +1,15 @@
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { useAuth } from '@/auth/hooks/useAuth';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
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';
@ -37,13 +38,11 @@ import {
UndecoratedLink,
} from 'twenty-ui/navigation';
import {
useSignUpInNewWorkspaceMutation,
AvailableWorkspace,
useSignUpInNewWorkspaceMutation,
} from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.light};
@ -118,7 +117,6 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
/>
}
dropdownId={'multi-workspace-dropdown-context-menu'}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>

View File

@ -0,0 +1,3 @@
import { DEBUG_HOTKEY_SCOPE } from '@/ui/utilities/hotkey/constants/DebugHotkeyScope';
export const DEBUG_FOCUS_STACK = DEBUG_HOTKEY_SCOPE;

View File

@ -9,12 +9,12 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
pushFocusItemToFocusStack,
focusStack,
currentFocusId,
};
@ -46,7 +46,7 @@ describe('usePushFocusItemToFocusStack', () => {
};
await act(async () => {
result.current.pushFocusItem({
result.current.pushFocusItemToFocusStack({
focusId: focusItem.focusId,
component: {
type: focusItem.componentInstance.componentType,
@ -73,7 +73,7 @@ describe('usePushFocusItemToFocusStack', () => {
};
await act(async () => {
result.current.pushFocusItem({
result.current.pushFocusItemToFocusStack({
focusId: anotherFocusItem.focusId,
component: {
type: anotherFocusItem.componentInstance.componentType,

View File

@ -1,5 +1,5 @@
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 { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@ -10,14 +10,15 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const removeFocusId = useRemoveFocusIdFromFocusStack();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStack } =
useRemoveFocusItemFromFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
removeFocusId,
pushFocusItemToFocusStack,
removeFocusItemFromFocusStack,
focusStack,
currentFocusId,
};
@ -30,8 +31,8 @@ const renderHooks = () => {
return { result };
};
describe('useRemoveFocusIdFromFocusStack', () => {
it('should remove focus id from the stack', async () => {
describe('useRemoveFocusItemFromFocusStack', () => {
it('should remove focus item from the stack', async () => {
const { result } = renderHooks();
const firstFocusItem = {
@ -59,7 +60,7 @@ describe('useRemoveFocusIdFromFocusStack', () => {
};
await act(async () => {
result.current.pushFocusItem({
result.current.pushFocusItemToFocusStack({
focusId: firstFocusItem.focusId,
component: {
type: firstFocusItem.componentInstance.componentType,
@ -71,7 +72,7 @@ describe('useRemoveFocusIdFromFocusStack', () => {
});
await act(async () => {
result.current.pushFocusItem({
result.current.pushFocusItemToFocusStack({
focusId: secondFocusItem.focusId,
component: {
type: secondFocusItem.componentInstance.componentType,
@ -89,7 +90,7 @@ describe('useRemoveFocusIdFromFocusStack', () => {
expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId);
await act(async () => {
result.current.removeFocusId({
result.current.removeFocusItemFromFocusStack({
focusId: firstFocusItem.focusId,
memoizeKey: 'global',
});

View File

@ -10,13 +10,13 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const resetFocusStack = useResetFocusStack();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { resetFocusStack } = useResetFocusStack();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
pushFocusItemToFocusStack,
resetFocusStack,
focusStack,
currentFocusId,
@ -47,7 +47,7 @@ describe('useResetFocusStack', () => {
};
await act(async () => {
result.current.pushFocusItem({
result.current.pushFocusItemToFocusStack({
focusId: focusItem.focusId,
component: {
type: focusItem.componentInstance.componentType,

View File

@ -10,13 +10,13 @@ import { RecoilRoot, useRecoilValue } from 'recoil';
const renderHooks = () => {
const { result } = renderHook(
() => {
const pushFocusItem = usePushFocusItemToFocusStack();
const resetFocusStackToFocusItem = useResetFocusStackToFocusItem();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
const focusStack = useRecoilValue(focusStackState);
const currentFocusId = useRecoilValue(currentFocusIdSelector);
return {
pushFocusItem,
pushFocusItemToFocusStack,
resetFocusStackToFocusItem,
focusStack,
currentFocusId,
@ -59,7 +59,7 @@ describe('useResetFocusStackToFocusItem', () => {
};
await act(async () => {
result.current.pushFocusItem({
result.current.pushFocusItemToFocusStack({
focusId: firstFocusItem.focusId,
component: {
type: firstFocusItem.componentInstance.componentType,
@ -71,7 +71,7 @@ describe('useResetFocusStackToFocusItem', () => {
});
await act(async () => {
result.current.pushFocusItem({
result.current.pushFocusItemToFocusStack({
focusId: secondFocusItem.focusId,
component: {
type: secondFocusItem.componentInstance.componentType,

View File

@ -1,3 +1,4 @@
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
@ -5,26 +6,27 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH
import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
const addOrMoveItemToTheTopOfTheStack = ({
focusStackItem,
currentFocusStack,
}: {
focusStackItem: FocusStackItem;
currentFocusStack: FocusStackItem[];
}) => [
...currentFocusStack.filter(
(currentFocusStackItem) =>
currentFocusStackItem.focusId !== focusStackItem.focusId,
),
focusStackItem,
];
export const usePushFocusItemToFocusStack = () => {
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const addOrMoveItemToTheTopOfTheStack = useRecoilCallback(
({ set }) =>
(focusStackItem: FocusStackItem) => {
set(focusStackState, (currentFocusStack) => [
...currentFocusStack.filter(
(currentFocusStackItem) =>
currentFocusStackItem.focusId !== focusStackItem.focusId,
),
focusStackItem,
]);
},
[],
);
return useRecoilCallback(
() =>
const pushFocusItemToFocusStack = useRecoilCallback(
({ snapshot, set }) =>
({
focusId,
component,
@ -57,7 +59,23 @@ export const usePushFocusItemToFocusStack = () => {
},
};
addOrMoveItemToTheTopOfTheStack(focusStackItem);
const currentFocusStack = snapshot
.getLoadable(focusStackState)
.getValue();
const newFocusStack = addOrMoveItemToTheTopOfTheStack({
focusStackItem,
currentFocusStack,
});
set(focusStackState, newFocusStack);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: pushFocusItemToFocusStack ${focusId}`, {
focusStackItem,
newFocusStack,
});
}
// TODO: Remove this once we've migrated hotkey scopes to the new api
setHotkeyScopeAndMemorizePreviousScope({
@ -66,6 +84,8 @@ export const usePushFocusItemToFocusStack = () => {
memoizeKey,
});
},
[setHotkeyScopeAndMemorizePreviousScope, addOrMoveItemToTheTopOfTheStack],
[setHotkeyScopeAndMemorizePreviousScope],
);
return { pushFocusItemToFocusStack };
};

View File

@ -1,22 +0,0 @@
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilCallback } from 'recoil';
export const useRemoveFocusIdFromFocusStack = () => {
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
return useRecoilCallback(
({ set }) =>
({ focusId, memoizeKey }: { focusId: string; memoizeKey: string }) => {
set(focusStackState, (previousFocusStack) =>
previousFocusStack.filter(
(focusStackItem) => focusStackItem.focusId !== focusId,
),
);
// TODO: Remove this once we've migrated hotkey scopes to the new api
goBackToPreviousHotkeyScope(memoizeKey);
},
[goBackToPreviousHotkeyScope],
);
};

View File

@ -0,0 +1,34 @@
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
export const useRemoveFocusItemFromFocusStack = () => {
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const removeFocusItemFromFocusStack = useRecoilCallback(
({ snapshot, set }) =>
({ focusId, memoizeKey }: { focusId: string; memoizeKey: string }) => {
const focusStack = snapshot.getLoadable(focusStackState).getValue();
const newFocusStack = focusStack.filter(
(focusStackItem) => focusStackItem.focusId !== focusId,
);
set(focusStackState, newFocusStack);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: removeFocusItemFromFocusStack ${focusId}`, {
newFocusStack,
});
}
// TODO: Remove this once we've migrated hotkey scopes to the new api
goBackToPreviousHotkeyScope(memoizeKey);
},
[goBackToPreviousHotkeyScope],
);
return { removeFocusItemFromFocusStack };
};

View File

@ -1,18 +1,26 @@
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
export const useResetFocusStack = () => {
return useRecoilCallback(
const resetFocusStack = useRecoilCallback(
({ reset }) =>
(memoizeKey = 'global') => {
reset(focusStackState);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: reset focus stack`);
}
// TODO: Remove this once we've migrated hotkey scopes to the new api
reset(previousHotkeyScopeFamilyState(memoizeKey as string));
reset(currentHotkeyScopeState);
},
[],
);
return { resetFocusStack };
};

View File

@ -1,12 +1,14 @@
import { DEBUG_FOCUS_STACK } from '@/ui/utilities/focus/constants/DebugFocusStack';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem';
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug';
export const useResetFocusStackToFocusItem = () => {
return useRecoilCallback(
const resetFocusStackToFocusItem = useRecoilCallback(
({ set }) =>
({
focusStackItem,
@ -19,10 +21,18 @@ export const useResetFocusStackToFocusItem = () => {
}) => {
set(focusStackState, [focusStackItem]);
if (DEBUG_FOCUS_STACK) {
logDebug(`DEBUG: reset focus stack to focus item`, {
focusStackItem,
});
}
// TODO: Remove this once we've migrated hotkey scopes to the new api
set(previousHotkeyScopeFamilyState(memoizeKey), null);
set(currentHotkeyScopeState, hotkeyScope);
},
[],
);
return { resetFocusStackToFocusItem };
};

View File

@ -1,3 +1,6 @@
export enum FocusComponentType {
MODAL = 'modal',
DROPDOWN = 'dropdown',
SIDE_PANEL = 'side-panel',
OPEN_FIELD_INPUT = 'open-field-input',
}

View File

@ -1,22 +1,28 @@
import { Keys } from 'react-hotkeys-hook';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
type HotkeyEffectProps = {
hotkey: {
key: Keys;
scope: string;
};
onHotkeyTriggered: () => void;
focusId: string;
};
export const HotkeyEffect = ({
hotkey,
focusId,
onHotkeyTriggered,
}: HotkeyEffectProps) => {
useScopedHotkeys(hotkey.key, () => onHotkeyTriggered(), hotkey.scope, [
onHotkeyTriggered,
]);
useHotkeysOnFocusedElement({
keys: hotkey.key,
callback: onHotkeyTriggered,
focusId,
scope: DropdownHotkeyScope.Dropdown,
dependencies: [onHotkeyTriggered],
});
return <></>;
};

View File

@ -49,7 +49,7 @@ export const useHotkeysOnFocusedElementCallback = (
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}] and the current focus identifier is [${focusId}]`,
)}] and the current focus identifier is [${currentFocusId}], and the focusId is [${focusId}]`,
'color: gray; ',
);
}
@ -63,7 +63,7 @@ export const useHotkeysOnFocusedElementCallback = (
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}], and the current focus identifier is [${focusId}]`,
)}], and the current focus identifier is [${currentFocusId}], and the focusId is [${focusId}]`,
'color: green;',
);
}