Fix selectable list arrow focus (#21679)

## Summary

Tested on all select fields one by one

- Keep searchable selectable-list inputs focused while ArrowUp/ArrowDown
moves the selected item.
- Remove the old global "grid focused" mode and blur/refocus recovery
path.
- Scroll the selected item into view with `block: 'nearest'`, which
restores keyboard scrolling in long relation pickers without forcing the
row to the top.
- Add focused regressions for command-menu input focus and selected-item
scrolling.

## Root Cause

`SelectableList` hotkeys blurred the active input before arrow
navigation and stored a global grid-focused state. That let ArrowDown
move selection, but focus could fall back to the underlying page/table
instead of remaining in the command menu input.

## Recording

### Before


https://github.com/user-attachments/assets/a802cbc3-4cfd-4466-bc22-274935a77715

### After


https://github.com/user-attachments/assets/9c315d2e-dcb1-424d-80c9-a5942eb1b6bd

## QA Note

I checked all inputs one by one with a 2h30 agent in goal mode.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21679?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Thomas des Francs
2026-06-17 10:46:07 +02:00
committed by GitHub
parent 257f130fff
commit 079040f1c0
7 changed files with 224 additions and 111 deletions
@@ -0,0 +1,137 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { SIDE_PANEL_SELECTABLE_LIST_ID } from '@/side-panel/constants/SidePanelSelectableListId';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { SidePanelTopBar } from '@/side-panel/components/SidePanelTopBar';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { PageFocusId } from '@/types/PageFocusId';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { SidePanelPages } from 'twenty-shared/types';
import { IconDotsVertical } from 'twenty-ui-deprecated/display';
jest.mock('@/side-panel/components/SidePanelTopBarInputFocusEffect', () => ({
SidePanelTopBarInputFocusEffect: () => null,
}));
jest.mock('@/side-panel/components/SidePanelTopBarRightCornerIcon', () => ({
SidePanelTopBarRightCornerIcon: () => null,
}));
jest.mock('@/side-panel/hooks/useSidePanelContextChips', () => ({
useSidePanelContextChips: () => ({ contextChips: [] }),
}));
jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({
useSidePanelMenu: () => ({
closeSidePanelMenu: jest.fn(),
}),
}));
jest.mock('twenty-ui-deprecated/utilities', () => ({
useIsMobile: () => true,
}));
const recordIndexFocusItem = {
focusId: PageFocusId.RecordIndex,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.RecordIndex,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
};
const createSidePanelTopBarStore = () => {
const store = createStore();
store.set(isSidePanelOpenedState.atom, true);
store.set(sidePanelPageState.atom, SidePanelPages.CommandMenuDisplay);
store.set(sidePanelNavigationStackState.atom, [
{
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command Menu',
pageIcon: IconDotsVertical,
pageId: 'command-menu',
},
]);
store.set(focusStackState.atom, [recordIndexFocusItem]);
return store;
};
const renderSidePanelCommandMenu = () => {
const store = createSidePanelTopBarStore();
render(
<I18nProvider i18n={i18n}>
<JotaiProvider store={store}>
<SidePanelTopBar />
<SidePanelList selectableItemIds={['first-item', 'second-item']}>
<SelectableListItem itemId="first-item">
First item
</SelectableListItem>
<SelectableListItem itemId="second-item">
Second item
</SelectableListItem>
</SidePanelList>
</JotaiProvider>
</I18nProvider>,
);
return { store };
};
describe('SidePanelTopBar', () => {
it('keeps the command menu search input focused while arrowing through items', async () => {
const { store } = renderSidePanelCommandMenu();
const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID);
input.focus();
await waitFor(() => {
expect(
store.get(
selectedItemIdComponentState.atomFamily({
instanceId: SIDE_PANEL_SELECTABLE_LIST_ID,
}),
),
).toBe('first-item');
});
fireEvent.keyDown(input, {
key: 'ArrowDown',
code: 'ArrowDown',
});
await waitFor(() => {
expect(
store.get(
selectedItemIdComponentState.atomFamily({
instanceId: SIDE_PANEL_SELECTABLE_LIST_ID,
}),
),
).toBe('second-item');
});
expect(document.activeElement).toBe(input);
expect(store.get(focusStackState.atom).at(-1)).toMatchObject({
focusId: SIDE_PANEL_FOCUS_ID,
componentInstance: {
componentType: FocusComponentType.TEXT_INPUT,
componentInstanceId: SIDE_PANEL_FOCUS_ID,
},
});
});
});
@@ -1,18 +1,11 @@
import { useStore } from 'jotai';
import { useEffect, useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { isSelectableListGridFocusedState } from '@/ui/layout/selectable-list/states/isSelectableListGridFocusedState';
export const useInputFocusWithoutScrollOnMount = () => {
const inputRef = useRef<HTMLInputElement>(null);
const store = useStore();
useEffect(() => {
if (
isDefined(inputRef.current) &&
!store.get(isSelectableListGridFocusedState.atom)
) {
if (isDefined(inputRef.current)) {
inputRef.current.focus({ preventScroll: true });
}
});
@@ -1,10 +1,8 @@
import { useStore } from 'jotai';
import { type ReactNode, useEffect } from 'react';
import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { SelectableListContextProvider } from '@/ui/layout/selectable-list/states/contexts/SelectableListContext';
import { isSelectableListGridFocusedState } from '@/ui/layout/selectable-list/states/isSelectableListGridFocusedState';
import { selectableItemIdsComponentState } from '@/ui/layout/selectable-list/states/selectableItemIdsComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { isDefined } from 'twenty-shared/utils';
@@ -29,7 +27,6 @@ export const SelectableList = ({
}: SelectableListProps) => {
useSelectableListHotKeys(selectableListInstanceId, focusId, onSelect);
const store = useStore();
const setSelectableItemIds = useSetAtomComponentState(
selectableItemIdsComponentState,
selectableListInstanceId,
@@ -51,12 +48,6 @@ export const SelectableList = ({
}
}, [selectableItemIdArray, selectableItemIdMatrix, setSelectableItemIds]);
useEffect(() => {
return () => {
store.set(isSelectableListGridFocusedState.atom, false);
};
}, [store]);
return (
<SelectableListComponentInstanceContext.Provider
value={{
@@ -39,17 +39,9 @@ export const SelectableListItem = ({
return;
}
const scrollContainer = listItemRef.current.closest(
'[id^="scroll-wrapper-"]',
) as HTMLElement | null;
if (isDefined(scrollContainer) && scrollContainer.scrollTop === 0) {
return;
}
listItemRef.current.scrollIntoView({
listItemRef.current.scrollIntoView?.({
behavior: 'auto',
block: 'start',
block: 'nearest',
});
}, [isSelectedItemId]);
@@ -0,0 +1,78 @@
import { render, waitFor } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
const selectableListInstanceId = 'test-selectable-list';
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
describe('SelectableListItem', () => {
const scrollIntoViewMock = jest.fn();
beforeEach(() => {
scrollIntoViewMock.mockClear();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoViewMock,
});
});
afterAll(() => {
if (typeof originalScrollIntoView === 'function') {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: originalScrollIntoView,
});
return;
}
delete (
HTMLElement.prototype as {
scrollIntoView?: HTMLElement['scrollIntoView'];
}
).scrollIntoView;
});
it('scrolls the selected item into view even when the scroll wrapper is at the top', async () => {
const store = createStore();
store.set(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId: selectableListInstanceId,
familyKey: 'second-item',
}),
true,
);
render(
<JotaiProvider store={store}>
<div id="scroll-wrapper-test">
<SelectableList
selectableListInstanceId={selectableListInstanceId}
selectableItemIdArray={['first-item', 'second-item']}
focusId="test-focus-id"
>
<SelectableListItem itemId="first-item">
First item
</SelectableListItem>
<SelectableListItem itemId="second-item">
Second item
</SelectableListItem>
</SelectableList>
</div>
</JotaiProvider>,
);
await waitFor(() => {
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: 'auto',
block: 'nearest',
});
});
});
});
@@ -1,9 +1,8 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useStore } from 'jotai';
import { useCallback, useRef } from 'react';
import { useCallback } from 'react';
import { Key } from 'ts-key-enum';
import { isSelectableListGridFocusedState } from '@/ui/layout/selectable-list/states/isSelectableListGridFocusedState';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { selectableItemIdsComponentState } from '@/ui/layout/selectable-list/states/selectableItemIdsComponentState';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
@@ -16,41 +15,8 @@ export const useSelectableListHotKeys = (
focusId: string,
onSelect?: (itemId: string) => void,
) => {
// oxlint-disable-next-line twenty/no-state-useref
const lastBlurredInputRef = useRef<HTMLInputElement | null>(null);
const store = useStore();
const blurActiveInputIfNeeded = () => {
if (document.activeElement instanceof HTMLInputElement) {
lastBlurredInputRef.current = document.activeElement;
store.set(isSelectableListGridFocusedState.atom, true);
document.activeElement.blur();
}
};
const refocusBlurredInput = () => {
if (!lastBlurredInputRef.current) {
return;
}
store.set(isSelectableListGridFocusedState.atom, false);
lastBlurredInputRef.current.focus();
lastBlurredInputRef.current = null;
};
const clearSelection = (selectedItemId: string | null) => {
if (isNonEmptyString(selectedItemId)) {
store.set(selectedItemIdComponentState.atomFamily({ instanceId }), null);
store.set(
isSelectedItemIdComponentFamilyState.atomFamily({
instanceId,
familyKey: selectedItemId,
}),
false,
);
}
};
const findPosition = (
selectableItemIds: string[][],
selectedItemId?: string | null,
@@ -173,57 +139,19 @@ export const useSelectableListHotKeys = (
useHotkeysOnFocusedElement({
keys: Key.ArrowUp,
callback: () => {
blurActiveInputIfNeeded();
const selectedItemId = store.get(
selectedItemIdComponentState.atomFamily({ instanceId }),
);
const selectableItemIds = store.get(
selectableItemIdsComponentState.atomFamily({ instanceId }),
);
const position = findPosition(selectableItemIds, selectedItemId);
const isAtTop = position !== undefined && position.row === 0;
if (!isAtTop || !lastBlurredInputRef.current) {
handleSelect('up');
return;
}
clearSelection(selectedItemId);
refocusBlurredInput();
},
focusId,
dependencies: [handleSelect, store, instanceId],
});
useHotkeysOnFocusedElement({
keys: Key.ArrowDown,
callback: () => {
blurActiveInputIfNeeded();
handleSelect('down');
handleSelect('up');
},
focusId,
dependencies: [handleSelect],
});
useHotkeysOnFocusedElement({
keys: '*',
callback: (keyboardEvent) => {
if (keyboardEvent.key.length !== 1) {
return;
}
if (
keyboardEvent.metaKey ||
keyboardEvent.ctrlKey ||
keyboardEvent.altKey
) {
return;
}
refocusBlurredInput();
keys: Key.ArrowDown,
callback: () => {
handleSelect('down');
},
focusId,
dependencies: [],
options: { enableOnFormTags: false, preventDefault: false },
dependencies: [handleSelect],
});
useHotkeysOnFocusedElement({
@@ -1,6 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isSelectableListGridFocusedState = createAtomState<boolean>({
key: 'isSelectableListGridFocusedState',
defaultValue: false,
});