mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge search history keyboard accessibility
Integrate reviewed web-product candidate 02d64d1478 without re-parenting it.
Change-source: pulse-maintainer
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user