Merge search history keyboard accessibility

Integrate reviewed web-product candidate 02d64d1478 without re-parenting it.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-02 10:26:09 +01:00
6 changed files with 220 additions and 37 deletions
+25 -23
View File
@@ -1,42 +1,44 @@
{
"version": 1,
"base_sha": "23b3893ae88b86497f3f103c8972663508555766",
"verified_at": "2026-09-02T07:15:12Z",
"base_sha": "6c5af82b522b437db91ab077c79cee3d59833a34",
"verified_at": "2026-09-02T08:58:36Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/CommandPaletteModal.tsx",
"frontend-modern/src/components/shared/SearchField.tsx",
"frontend-modern/src/components/shared/searchFieldModel.ts",
"frontend-modern/src/components/shared/useCommandPaletteState.ts"
"frontend-modern/src/components/shared/SearchInputEnhancements.tsx",
"frontend-modern/src/components/shared/searchInputEnhancementsModel.ts",
"frontend-modern/src/components/shared/useSearchInputEnhancements.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/CommandPaletteModal.tsx": "300030975b76634513d98b9459fe9ae4d07e8d7d03fedffa91f702746a7d3f06",
"frontend-modern/src/components/shared/SearchField.tsx": "0c2cc0d002763b59c2c065ceccff134318122aa975fe45ddc31271387cd623ad",
"frontend-modern/src/components/shared/searchFieldModel.ts": "3b9ca4ffc6aef0e5910e5094daebb9b03878b2b6ef4c9c7aed165bf7f46097db",
"frontend-modern/src/components/shared/useCommandPaletteState.ts": "df4baf2ccba420f0c8d7d67ee7ddeba5a55ddbee8af323d8d7924aa389ad9b3a"
"frontend-modern/src/components/shared/SearchInputEnhancements.tsx": "9df1147f092b523655a3503cfe27bbac88fb0572bd3ccb219895d2ffa2f762a7",
"frontend-modern/src/components/shared/searchInputEnhancementsModel.ts": "b56c756423156cdce3bf1c7f867aa3b6c2751419f070c2d8e5edad91d3c23dec",
"frontend-modern/src/components/shared/useSearchInputEnhancements.ts": "15d750671b420d6566749eac60bc4cb6c87db36037e90a4117cebc60c1635dad"
},
"routes": ["/proxmox"],
"routes": ["/standalone/machines"],
"viewports": [
{
"width": 1280,
"height": 720
"height": 800
},
{
"width": 393,
"height": 851
"width": 390,
"height": 844
}
],
"states": [
"Command palette open with twelve results and the first result selected at desktop and narrow widths",
"Command palette scrolled to the last keyboard-selected result at desktop and narrow widths",
"Command palette empty result state after a query with no matching commands at desktop and narrow widths",
"Command palette closed after Escape and after backdrop dismissal at desktop and narrow widths"
"Empty recent-search menu with explanatory copy at desktop width",
"Populated three-entry menu with keyboard focus on its first, removal, and clear actions at desktop width",
"Selected search restored from persisted history after reload at desktop and narrow widths",
"Populated menu over the filtered no-match Machines state at narrow width",
"Menu closed after outside pointer dismissal and Escape at desktop and narrow widths"
],
"interactions": [
"opened the command palette with Control+K on the authenticated Proxmox route",
"verified the search retained DOM focus while pointer hover and Home and End keys updated aria-activedescendant and aria-selected",
"verified End scrolled the last selected option fully into the result viewport and Tab did not focus an option",
"entered a no-match query and verified the combobox collapsed, cleared its active descendant, and removed the listbox",
"dismissed the palette with Escape and reopened and dismissed it through the backdrop"
"opened the history menu by pointer, ArrowDown, and ArrowUp and verified menu semantics, aria-controls, and expanded state",
"moved focus through entry, item-specific removal, and clear actions with ArrowDown, ArrowUp, Home, and End",
"selected a history entry and verified the menu closed while the search field received focus and the selected value",
"removed the focused first history entry and verified focus moved to the next item-specific removal action",
"cleared all history and verified the menu closed, storage emptied, and search focus returned",
"committed a search, reloaded the route, and verified the persisted entry remained available",
"dismissed the menu by outside pointer action and Escape and verified Escape returned focus to the toggle",
"inspected desktop and narrow pixels for placement, clipping, stacking, responsive width, and visible keyboard focus"
]
}
@@ -30,8 +30,10 @@ export const SearchInputTrailingControls: Component<SearchInputTrailingControlsP
class={getSearchHistoryToggleButtonClass(props.state.isHistoryOpen())}
onClick={props.state.toggleHistory}
onMouseDown={props.state.onClearMouseDown}
aria-haspopup="listbox"
onKeyDown={props.state.handleHistoryToggleKeyDown}
aria-haspopup="menu"
aria-expanded={props.state.isHistoryOpen()}
aria-controls={props.state.historyMenuId()}
title={getSearchHistoryToggleTitle(props.state.searchHistory().length)}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -61,7 +63,15 @@ interface SearchInputHistoryDropdownProps {
export const SearchInputHistoryDropdown: Component<SearchInputHistoryDropdownProps> = (props) => (
<Show when={props.state.hasHistory() && props.state.isHistoryOpen()}>
<div ref={props.state.setHistoryMenuRef} class={SEARCH_HISTORY_MENU_CLASS} role="listbox">
<div
id={props.state.historyMenuId()}
ref={props.state.setHistoryMenuRef}
class={SEARCH_HISTORY_MENU_CLASS}
role="menu"
aria-label="Recent searches"
onKeyDown={props.state.handleHistoryMenuKeyDown}
onFocusOut={props.state.handleHistoryMenuFocusOut}
>
<Show
when={props.state.searchHistory().length > 0}
fallback={
@@ -71,9 +81,15 @@ export const SearchInputHistoryDropdown: Component<SearchInputHistoryDropdownPro
<div class="max-h-52 overflow-y-auto py-1">
<For each={props.state.searchHistory()}>
{(entry) => (
<div class={SEARCH_HISTORY_ROW_CLASS}>
<div
class={SEARCH_HISTORY_ROW_CLASS}
role="group"
aria-label={`History item ${entry}`}
>
<button
type="button"
role="menuitem"
tabIndex={-1}
class={SEARCH_HISTORY_ENTRY_BUTTON_CLASS}
onClick={() => props.state.selectHistoryEntry(entry)}
onMouseDown={props.state.onClearMouseDown}
@@ -82,8 +98,11 @@ export const SearchInputHistoryDropdown: Component<SearchInputHistoryDropdownPro
</button>
<button
type="button"
role="menuitem"
tabIndex={-1}
class={getSearchHistoryDeleteButtonClass()}
title="Remove from history"
aria-label={`Remove ${entry} from history`}
title={`Remove ${entry} from history`}
onClick={() => props.state.deleteHistoryEntry(entry)}
onMouseDown={props.state.onClearMouseDown}
>
@@ -102,6 +121,8 @@ export const SearchInputHistoryDropdown: Component<SearchInputHistoryDropdownPro
</div>
<button
type="button"
role="menuitem"
tabIndex={-1}
class={getSearchHistoryClearButtonClass()}
onClick={props.state.clearHistory}
onMouseDown={props.state.onClearMouseDown}
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSignal } from 'solid-js';
import { SearchInput } from '@/components/shared/SearchInput';
@@ -37,6 +37,7 @@ const SearchHarness = (props: {
describe('SearchInput', () => {
afterEach(() => {
cleanup();
window.localStorage.clear();
});
it('keeps search input on shell, runtime, and model owners', () => {
@@ -102,11 +103,78 @@ describe('SearchInput', () => {
fireEvent.click(screen.getByRole('button', { name: 'Show search history' }));
const historyMenu = screen.getByRole('listbox');
const historyMenu = screen.getByRole('menu', { name: 'Recent searches' });
expect(historyMenu).toHaveClass('w-full', 'max-w-lg');
expect(historyMenu).not.toHaveClass('right-0');
});
it('exposes recent-search actions as a keyboard-operated menu', async () => {
const storageKey = 'pulse:test:search-history-accessibility';
window.localStorage.setItem(storageKey, JSON.stringify(['alpha', 'beta']));
const HistoryHarness = () => {
const [value, setValue] = createSignal('');
return <SearchInput value={value} onChange={setValue} history={{ storageKey }} />;
};
render(() => <HistoryHarness />);
const toggle = screen.getByRole('button', { name: 'Show search history' });
expect(toggle).toHaveAttribute('aria-haspopup', 'menu');
fireEvent.keyDown(toggle, { key: 'ArrowDown' });
const menu = screen.getByRole('menu', { name: 'Recent searches' });
expect(toggle).toHaveAttribute('aria-controls', menu.id);
const items = within(menu).getAllByRole('menuitem');
expect(items.map((item) => item.textContent?.trim())).toEqual([
'alpha',
'',
'beta',
'',
'Clear history',
]);
expect(within(menu).getByRole('menuitem', { name: 'Remove alpha from history' })).toBe(
items[1],
);
await waitFor(() => expect(items[0]).toHaveFocus());
fireEvent.keyDown(items[0], { key: 'ArrowDown' });
expect(items[1]).toHaveFocus();
fireEvent.keyDown(items[1], { key: 'End' });
expect(items[4]).toHaveFocus();
fireEvent.keyDown(items[4], { key: 'Escape' });
expect(screen.queryByRole('menu', { name: 'Recent searches' })).not.toBeInTheDocument();
await waitFor(() => expect(toggle).toHaveFocus());
});
it('keeps menu focus stable when a recent search is removed', async () => {
const storageKey = 'pulse:test:search-history-delete-focus';
window.localStorage.setItem(storageKey, JSON.stringify(['alpha', 'beta']));
const HistoryHarness = () => {
const [value, setValue] = createSignal('');
return <SearchInput value={value} onChange={setValue} history={{ storageKey }} />;
};
render(() => <HistoryHarness />);
fireEvent.click(screen.getByRole('button', { name: 'Show search history' }));
const menu = screen.getByRole('menu', { name: 'Recent searches' });
const removeAlpha = within(menu).getByRole('menuitem', {
name: 'Remove alpha from history',
});
removeAlpha.focus();
fireEvent.click(removeAlpha);
expect(within(menu).queryByText('alpha')).not.toBeInTheDocument();
await waitFor(() =>
expect(
within(menu).getByRole('menuitem', { name: 'Remove beta from history' }),
).toHaveFocus(),
);
});
it('captures typed characters by default when focus is outside the input', async () => {
render(() => <SearchHarness />);
@@ -4,7 +4,7 @@ export const SEARCH_HISTORY_EMPTY_STATE_CLASS = 'px-3 py-2 text-xs text-muted';
export const SEARCH_HISTORY_ROW_CLASS =
'flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900';
export const SEARCH_HISTORY_ENTRY_BUTTON_CLASS =
'flex-1 truncate pr-2 text-left text-sm text-base-content transition-colors hover:text-blue-600 focus:outline-none dark:hover:text-blue-300';
'flex-1 truncate rounded pr-2 text-left text-sm text-base-content transition-colors hover:text-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:text-blue-300';
export const SEARCH_HISTORY_CLEAR_LABEL = 'Clear history';
export function getSearchHistoryToggleButtonClass(isOpen: boolean): string {
return `flex h-11 w-11 items-center justify-center rounded-md transition-colors sm:h-7 sm:w-7 ${
@@ -19,9 +19,9 @@ export function getSearchHistoryToggleTitle(historyCount: number): string {
}
export function getSearchHistoryDeleteButtonClass(): string {
return 'ml-1 flex h-6 w-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none';
return 'ml-1 flex h-6 w-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface-hover hover:text-base-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500';
}
export function getSearchHistoryClearButtonClass(): string {
return 'flex w-full items-center justify-center gap-2 border-t border-border px-3 py-2 text-xs font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content';
return 'flex w-full items-center justify-center gap-2 border-t border-border px-3 py-2 text-xs font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500';
}
@@ -1,4 +1,11 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import {
createEffect,
createMemo,
createSignal,
createUniqueId,
onCleanup,
onMount,
} from 'solid-js';
import type { Accessor } from 'solid-js';
import { createSearchHistoryManager } from '@/utils/searchHistory';
import {
@@ -56,6 +63,7 @@ export interface SearchInputEnhancementsState {
isSimple: Accessor<boolean>;
searchHistory: Accessor<string[]>;
isHistoryOpen: Accessor<boolean>;
historyMenuId: Accessor<string>;
completionSuffix: Accessor<string>;
emptyHistoryMessage: Accessor<string>;
tipsPopoverId: Accessor<string>;
@@ -64,6 +72,9 @@ export interface SearchInputEnhancementsState {
setHistoryToggleRef: (el: HTMLButtonElement | undefined) => void;
toggleHistory: () => void;
closeHistory: () => void;
handleHistoryMenuKeyDown: (event: KeyboardEvent) => void;
handleHistoryMenuFocusOut: (event: FocusEvent) => void;
handleHistoryToggleKeyDown: (event: KeyboardEvent) => void;
clearHistory: () => void;
deleteHistoryEntry: (term: string) => void;
selectHistoryEntry: (term: string) => void;
@@ -97,6 +108,7 @@ export const useSearchInputEnhancements = (
const [isFieldFocused, setIsFieldFocused] = createSignal(false);
const [showInlineCompletion, setShowInlineCompletion] = createSignal(true);
const [acceptedSuggestionId, setAcceptedSuggestionId] = createSignal<string>();
const historyMenuId = `search-history-${createUniqueId()}`;
const rankedSuggestions = createMemo<SearchInputSuggestion[]>(() => {
const config = options.suggestions;
@@ -140,6 +152,22 @@ export const useSearchInputEnhancements = (
let historyToggleRef: HTMLButtonElement | undefined;
let suppressBlurCommit = false;
const getHistoryMenuItems = () =>
Array.from(historyMenuRef?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
const focusHistoryItem = (position: 'first' | 'last') => {
queueMicrotask(() => {
const items = getHistoryMenuItems();
const item = position === 'first' ? items[0] : items[items.length - 1];
item?.focus();
});
};
const openHistory = (position: 'first' | 'last' = 'first') => {
setIsHistoryOpen(true);
focusHistoryItem(position);
};
onMount(() => {
if (historyManager) setSearchHistory(historyManager.read());
});
@@ -151,18 +179,77 @@ export const useSearchInputEnhancements = (
const deleteHistoryEntry = (term: string) => {
if (!historyManager) return;
const focusedIndex = getHistoryMenuItems().findIndex((item) => item === document.activeElement);
setSearchHistory(historyManager.remove(term));
if (focusedIndex >= 0) {
queueMicrotask(() => {
const items = getHistoryMenuItems();
items[Math.min(focusedIndex, items.length - 1)]?.focus();
});
}
};
const closeHistory = () => {
setIsHistoryOpen(false);
queueMicrotask(() => historyToggleRef?.blur());
};
const clearHistory = () => {
if (!historyManager) return;
setSearchHistory(historyManager.clear());
closeHistory();
queueMicrotask(options.focusInput);
};
const handleHistoryMenuKeyDown = (event: KeyboardEvent) => {
const items = getHistoryMenuItems();
const currentIndex = items.findIndex((item) => item === document.activeElement);
let nextIndex: number | undefined;
switch (event.key) {
case 'ArrowDown':
nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
break;
case 'ArrowUp':
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = items.length - 1;
break;
case 'Escape':
event.preventDefault();
event.stopPropagation();
closeHistory();
queueMicrotask(() => historyToggleRef?.focus());
return;
default:
return;
}
if (nextIndex === undefined || nextIndex < 0) return;
event.preventDefault();
event.stopPropagation();
items[nextIndex]?.focus();
};
const handleHistoryMenuFocusOut = (event: FocusEvent) => {
const next = event.relatedTarget as Node | null;
if (!next || historyMenuRef?.contains(next) || historyToggleRef?.contains(next)) return;
closeHistory();
};
const handleHistoryToggleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
openHistory(event.key === 'ArrowUp' ? 'last' : 'first');
return;
}
if (event.key === 'Escape' && isHistoryOpen()) {
event.preventDefault();
closeHistory();
}
};
const markSuppressCommit = () => {
@@ -217,6 +304,7 @@ export const useSearchInputEnhancements = (
isSimple,
searchHistory,
isHistoryOpen,
historyMenuId: () => historyMenuId,
completionSuffix,
emptyHistoryMessage,
tipsPopoverId,
@@ -228,9 +316,13 @@ export const useSearchInputEnhancements = (
historyToggleRef = el;
},
toggleHistory: () => {
setIsHistoryOpen((previous) => !previous);
if (isHistoryOpen()) closeHistory();
else openHistory();
},
closeHistory,
handleHistoryMenuKeyDown,
handleHistoryMenuFocusOut,
handleHistoryToggleKeyDown,
clearHistory,
deleteHistoryEntry,
selectHistoryEntry: (term) => {
@@ -283,7 +375,7 @@ export const useSearchInputEnhancements = (
event.currentTarget.blur();
} else if (hasHistory() && event.key === 'ArrowDown' && searchHistory().length > 0) {
event.preventDefault();
setIsHistoryOpen(true);
openHistory();
} else if (event.key === 'ArrowLeft' || event.key === 'Home') {
setShowInlineCompletion(false);
}
@@ -543,7 +543,7 @@ describe('AgentsMachinesTable', () => {
const historyToggle = screen.getByTitle('Show recent searches');
await fireEvent.click(historyToggle);
expect(screen.getByRole('button', { name: 'macos' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'macos' })).toBeInTheDocument();
const tipsButton = screen.getByRole('button', { name: 'Search tips' });
await fireEvent.click(tipsButton);