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:
Patrik Oldsberg
2026-03-28 18:28:41 +01:00
parent 8d03040306
commit 9a6d9969a6
2 changed files with 87 additions and 56 deletions
@@ -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>
);
};