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