scaffolder: use CSS grid layout to fix search field focus loss
The search field was being rendered in two separate conditional branches (empty state vs list state), causing React to remount it when toggling between them and losing focus. Restructure to a CSS grid layout where the search field is always in a stable tree position. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -689,4 +689,42 @@ describe('ActionsPage', () => {
|
||||
screen.getByRole('row', { name: /github:repo:push/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should keep search field focused when filtering causes empty then non-empty results', async () => {
|
||||
scaffolderApiMock.listActions.mockResolvedValue([
|
||||
{
|
||||
id: 'github:repo:create',
|
||||
description: 'Create a new Github repository',
|
||||
schema: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<ActionsPage />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create/actions': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const searchField = screen.getByPlaceholderText('Search for an action');
|
||||
await userEvent.click(searchField);
|
||||
expect(searchField).toHaveFocus();
|
||||
|
||||
await userEvent.type(searchField, 'zzz-no-match');
|
||||
expect(searchField).toHaveFocus();
|
||||
expect(
|
||||
screen.queryByRole('row', { name: /github:repo:create/ }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(searchField);
|
||||
await userEvent.type(searchField, 'create');
|
||||
expect(searchField).toHaveFocus();
|
||||
expect(
|
||||
await screen.findByRole('row', { name: /github:repo:create/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,68 +149,61 @@ export const ActionPageContent = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && !filteredActions.length) {
|
||||
return (
|
||||
<Flex direction="column" gap="4">
|
||||
<SearchField
|
||||
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
|
||||
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
/>
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: selectedAction ? '320px 1fr' : '1fr',
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<SearchField
|
||||
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
|
||||
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
/>
|
||||
{!loading && !filteredActions.length ? (
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title={t('actionsPage.content.emptyState.title')}
|
||||
description={t('actionsPage.content.emptyState.description')}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const listElement = (
|
||||
<List
|
||||
aria-label={t('actionsPage.title')}
|
||||
selectionMode="single"
|
||||
selectionBehavior="toggle"
|
||||
selectedKeys={selectedActionId ? [selectedActionId] : []}
|
||||
onSelectionChange={selection => {
|
||||
if (selection === 'all') {
|
||||
return;
|
||||
}
|
||||
const selected = [...selection][0] as string | undefined;
|
||||
setSelectedActionId(prev => (prev === selected ? undefined : selected));
|
||||
}}
|
||||
>
|
||||
{filteredActions.map(action => (
|
||||
<ListRow
|
||||
key={action.id}
|
||||
id={action.id}
|
||||
textValue={action.id}
|
||||
description={action.description ?? undefined}
|
||||
) : (
|
||||
<List
|
||||
aria-label={t('actionsPage.title')}
|
||||
selectionMode="single"
|
||||
selectionBehavior="toggle"
|
||||
selectedKeys={selectedActionId ? [selectedActionId] : []}
|
||||
onSelectionChange={selection => {
|
||||
if (selection === 'all') {
|
||||
return;
|
||||
}
|
||||
const selected = [...selection][0] as string | undefined;
|
||||
setSelectedActionId(prev =>
|
||||
prev === selected ? undefined : selected,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{action.id}
|
||||
</ListRow>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap="6" alignItems="start">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={selectedAction ? { minWidth: 320, maxWidth: 320 } : undefined}
|
||||
>
|
||||
<SearchField
|
||||
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
|
||||
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
/>
|
||||
{listElement}
|
||||
</Flex>
|
||||
{filteredActions.map(action => (
|
||||
<ListRow
|
||||
key={action.id}
|
||||
id={action.id}
|
||||
textValue={action.id}
|
||||
description={action.description ?? undefined}
|
||||
>
|
||||
{action.id}
|
||||
</ListRow>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
{selectedAction && (
|
||||
<Flex direction="column" gap="3" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="3"
|
||||
style={{ gridRow: '1 / -1', minWidth: 0 }}
|
||||
>
|
||||
<Flex direction="column" gap="1">
|
||||
<Text as="h2" variant="title-medium" weight="bold">
|
||||
{selectedAction.id}
|
||||
@@ -222,7 +215,7 @@ export const ActionPageContent = () => {
|
||||
<ActionDetail action={selectedAction} />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user