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:
committed by
GitHub
parent
257f130fff
commit
079040f1c0
+137
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user