Fix Assistant autocomplete screen reader focus

The composer kept DOM focus in its textarea while only visually indicating the active autocomplete option. Screen reader users therefore could not identify the controlled listbox or follow mention and slash-command selection changes.

Change-source: pulse-maintainer

Contract-Neutral: Assistant autocomplete accessibility and responsive presentation only; no API or subsystem contract changed
This commit is contained in:
pulse-triage[bot]
2026-09-02 10:56:24 +01:00
parent fc0adf7073
commit 6399653836
7 changed files with 219 additions and 31 deletions
+24 -22
View File
@@ -1,19 +1,21 @@
{
"version": 1,
"base_sha": "6c5af82b522b437db91ab077c79cee3d59833a34",
"verified_at": "2026-09-02T08:58:36Z",
"base_sha": "fc0adf7073d99447e8042d5053d331d323b8a372",
"verified_at": "2026-09-02T10:25:49Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/SearchInputEnhancements.tsx",
"frontend-modern/src/components/shared/searchInputEnhancementsModel.ts",
"frontend-modern/src/components/shared/useSearchInputEnhancements.ts"
"frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx",
"frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx",
"frontend-modern/src/components/AI/Chat/index.tsx"
],
"content_sha256": {
"frontend-modern/src/components/shared/SearchInputEnhancements.tsx": "9df1147f092b523655a3503cfe27bbac88fb0572bd3ccb219895d2ffa2f762a7",
"frontend-modern/src/components/shared/searchInputEnhancementsModel.ts": "b56c756423156cdce3bf1c7f867aa3b6c2751419f070c2d8e5edad91d3c23dec",
"frontend-modern/src/components/shared/useSearchInputEnhancements.ts": "15d750671b420d6566749eac60bc4cb6c87db36037e90a4117cebc60c1635dad"
"frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx": "fde53ef7a82b0ff854a759087a0fc7dfaafd89e7e8933b68335598100adb8955",
"frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx": "2073d3c388590dad8c291dfc7a0d8a3132e4c18a50014918c0a2a90510401c28",
"frontend-modern/src/components/AI/Chat/index.tsx": "010def979665a1db9be3dfa3537ebfa8714125fb7ce9212102fb49465d5d22db"
},
"routes": ["/standalone/machines"],
"routes": [
"/"
],
"viewports": [
{
"width": 1280,
@@ -25,20 +27,20 @@
}
],
"states": [
"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"
"Closed named Assistant composer with no listbox relationship in desktop docked and narrow overlay layouts",
"Open slash-command list with the selected active option and the wrapped final option scrolled into view",
"Filtered slash-command empty result with listbox ownership and no stale active descendant",
"Slash command selected with Tab and popup closed with composer focus restored",
"Open resource mention list with the selected active option and the wrapped final option scrolled into view",
"Mention selected by pointer and popup closed with composer focus retained",
"Autocomplete closed by Escape and outside pointer dismissal without stale relationships"
],
"interactions": [
"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"
"opened Pulse Assistant from the current-view launcher and verified the composer accessible name and focus",
"opened slash commands and mentions by typing, then checked aria-controls, aria-activedescendant, aria-selected, and option tab exclusion",
"moved active options with ArrowDown and wrapped with ArrowUp, hovered options by pointer, and verified active options remained visible",
"selected /new with Tab and selected a mention by pointer while verifying focus remained in the composer",
"exercised Escape and outside pointer dismissal, including the no-match slash-command state",
"inspected desktop and narrow pixels for popup placement, viewport containment, clipping, stacking, scrolling, and visible composer focus"
]
}
@@ -17,8 +17,12 @@ interface MentionAutocompleteProps {
onSelect: (resource: MentionResource) => void;
onClose: () => void;
visible: boolean;
onActiveDescendantChange?: (id: string | undefined) => void;
}
export const ASSISTANT_MENTION_LISTBOX_ID = 'assistant-mention-listbox';
export const getAssistantMentionOptionId = (index: number) => `assistant-mention-option-${index}`;
export function MentionAutocomplete(props: MentionAutocompleteProps) {
const [selectedIndex, setSelectedIndex] = createSignal(0);
@@ -36,6 +40,27 @@ export function MentionAutocomplete(props: MentionAutocompleteProps) {
setSelectedIndex(0);
});
createEffect(() => {
const total = filteredResources().length;
setSelectedIndex((index) => (total > 0 ? Math.min(index, total - 1) : 0));
});
createEffect(() => {
const resources = filteredResources();
const activeId =
props.visible && resources.length > 0
? getAssistantMentionOptionId(selectedIndex())
: undefined;
props.onActiveDescendantChange?.(activeId);
if (activeId) {
queueMicrotask(() => {
document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' });
});
}
});
onCleanup(() => props.onActiveDescendantChange?.(undefined));
const consumeMentionKey = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -159,7 +184,7 @@ export function MentionAutocomplete(props: MentionAutocompleteProps) {
return (
<Show when={props.visible && filteredResources().length > 0}>
<div
class="absolute z-50 bg-surface border border-border rounded-md shadow-sm overflow-hidden min-w-[280px] max-w-[400px]"
class="absolute z-50 w-[calc(100vw-36px)] min-w-[280px] max-w-[400px] overflow-hidden rounded-md border border-border bg-surface shadow-sm"
style={{
bottom: `${props.position.top}px`,
left: `${props.position.left}px`,
@@ -168,12 +193,19 @@ export function MentionAutocomplete(props: MentionAutocompleteProps) {
onClick={(event) => event.stopPropagation()}
>
<div class="px-3 py-2 border-b border-border text-xs font-medium text-muted">Resources</div>
<div class="max-h-[240px] overflow-y-auto" role="listbox" aria-label="Assistant resources">
<div
id={ASSISTANT_MENTION_LISTBOX_ID}
class="max-h-[240px] overflow-y-auto"
role="listbox"
aria-label="Assistant resources"
>
<For each={filteredResources()}>
{(resource, index) => (
<button
type="button"
id={getAssistantMentionOptionId(index())}
role="option"
tabIndex={-1}
aria-selected={index() === selectedIndex()}
aria-label={`Mention ${resource.label}: ${resource.type}${
resource.node ? ` on ${resource.node}` : ''
@@ -181,6 +213,7 @@ export function MentionAutocomplete(props: MentionAutocompleteProps) {
class={`w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-surface-hover transition-colors ${
index() === selectedIndex() ? 'bg-surface-hover' : ''
}`}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation();
props.onSelect(resource);
@@ -27,8 +27,13 @@ interface SlashCommandAutocompleteProps {
position: { top: number; left: number };
onClose: () => void;
onSelect: (command: AssistantSlashCommand) => void;
onActiveDescendantChange?: (id: string | undefined) => void;
}
export const ASSISTANT_SLASH_COMMAND_LISTBOX_ID = 'assistant-slash-command-listbox';
export const getAssistantSlashCommandOptionId = (index: number) =>
`assistant-slash-command-option-${index}`;
export const AssistantSlashCommandIcon = (props: { action: AssistantSlashCommandAction }) => {
switch (props.action) {
case 'help':
@@ -82,6 +87,27 @@ export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) {
setSelectedIndex(0);
});
createEffect(() => {
const total = commands().length;
setSelectedIndex((index) => (total > 0 ? Math.min(index, total - 1) : 0));
});
createEffect(() => {
const options = commands();
const activeId =
props.visible && options.length > 0
? getAssistantSlashCommandOptionId(selectedIndex())
: undefined;
props.onActiveDescendantChange?.(activeId);
if (activeId) {
queueMicrotask(() => {
document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' });
});
}
});
onCleanup(() => props.onActiveDescendantChange?.(undefined));
const selectCommand = (command?: AssistantSlashCommand) => {
if (!command) return;
props.onSelect(command);
@@ -140,7 +166,7 @@ export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) {
return (
<Show when={props.visible}>
<div
class="absolute z-50 min-w-[280px] max-w-[420px] overflow-hidden rounded-md border border-border bg-surface shadow-sm"
class="absolute z-50 w-[calc(100vw-36px)] min-w-[280px] max-w-[420px] overflow-hidden rounded-md border border-border bg-surface shadow-sm"
style={{
bottom: `${props.position.top}px`,
left: `${props.position.left}px`,
@@ -149,7 +175,12 @@ export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) {
onClick={(event) => event.stopPropagation()}
>
<div class="border-b border-border px-3 py-2 text-xs font-medium text-muted">Commands</div>
<div class="max-h-[260px] overflow-y-auto" role="listbox" aria-label="Assistant commands">
<div
id={ASSISTANT_SLASH_COMMAND_LISTBOX_ID}
class="max-h-[260px] overflow-y-auto"
role="listbox"
aria-label="Assistant commands"
>
<Show
when={commands().length > 0}
fallback={
@@ -175,7 +206,9 @@ export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) {
return (
<button
type="button"
id={getAssistantSlashCommandOptionId(item.index)}
role="option"
tabIndex={-1}
aria-selected={item.index === selectedIndex()}
aria-disabled={command.disabled ? 'true' : undefined}
aria-label={`${
@@ -188,6 +221,7 @@ export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) {
? 'cursor-not-allowed opacity-60'
: 'hover:bg-surface-hover'
} ${item.index === selectedIndex() ? 'bg-surface-hover' : ''}`}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation();
selectCommand(command);
@@ -2179,7 +2179,12 @@ describe('AIChat', () => {
fireEvent.input(textarea, { target: { value: '/mo' } });
expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /Run \/models/ })).toBeInTheDocument();
const selectedOption = screen.getByRole('option', { name: /Run \/models/ });
expect(selectedOption).toBeInTheDocument();
expect(textarea).toHaveAccessibleName('Message Pulse Assistant');
expect(textarea).toHaveAttribute('aria-autocomplete', 'list');
expect(textarea).toHaveAttribute('aria-controls', 'assistant-slash-command-listbox');
expect(textarea).toHaveAttribute('aria-activedescendant', selectedOption.id);
fireEvent.keyDown(textarea, { key: 'Enter' });
@@ -2187,6 +2192,8 @@ describe('AIChat', () => {
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '1');
});
expect(screen.queryByRole('listbox', { name: 'Assistant commands' })).not.toBeInTheDocument();
expect(textarea).not.toHaveAttribute('aria-controls');
expect(textarea).not.toHaveAttribute('aria-activedescendant');
expect(mockChat.sendMessage).not.toHaveBeenCalled();
});
@@ -179,11 +179,14 @@ describe('MentionAutocomplete', () => {
const listbox = screen.getByRole('listbox', { name: 'Assistant resources' });
expect(listbox).toBeInTheDocument();
expect(listbox.parentElement).toHaveClass('w-[calc(100vw-36px)]');
const firstOption = screen.getByRole('option', {
name: 'Mention web-server: vm on pve1, running',
});
expect(firstOption).toHaveAttribute('aria-selected', 'true');
expect(firstOption).toHaveAttribute('id', 'assistant-mention-option-0');
expect(firstOption).toHaveAttribute('tabindex', '-1');
fireEvent.keyDown(document, { key: 'ArrowDown' });
@@ -194,6 +197,30 @@ describe('MentionAutocomplete', () => {
}),
).toHaveAttribute('aria-selected', 'true');
});
it('reports the active descendant and keeps it visible while focus remains in the composer', async () => {
const onActiveDescendantChange = vi.fn();
renderAutocomplete({ onActiveDescendantChange });
expect(screen.getByRole('listbox', { name: 'Assistant resources' })).toHaveAttribute(
'id',
'assistant-mention-listbox',
);
expect(onActiveDescendantChange).toHaveBeenLastCalledWith('assistant-mention-option-0');
const secondOption = screen.getByRole('option', {
name: 'Mention db-container: system-container on pve2, running',
});
const scrollIntoView = vi.fn();
Object.defineProperty(secondOption, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
fireEvent.keyDown(document, { key: 'ArrowDown' });
expect(onActiveDescendantChange).toHaveBeenLastCalledWith('assistant-mention-option-1');
await vi.waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }));
});
});
describe('click interaction', () => {
@@ -201,7 +228,12 @@ describe('MentionAutocomplete', () => {
const onSelect = vi.fn();
renderAutocomplete({ onSelect });
fireEvent.click(screen.getByText('web-server'));
const option = screen.getByText('web-server').closest('button')!;
const pointerDown = new Event('pointerdown', { bubbles: true, cancelable: true });
option.dispatchEvent(pointerDown);
expect(pointerDown.defaultPrevented).toBe(true);
fireEvent.click(option);
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(defaultResources[0]);
});
@@ -1,11 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { filterAssistantSlashCommands } from '../assistantSlashCommands';
import { SlashCommandAutocomplete } from '../SlashCommandAutocomplete';
afterEach(cleanup);
describe('SlashCommandAutocomplete', () => {
it('prevents pointer selection from taking focus from the composer', () => {
const onSelect = vi.fn();
render(() => (
<SlashCommandAutocomplete
query="new"
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={onSelect}
/>
));
const option = screen.getByRole('option', { name: /Run \/new/ });
const pointerDown = new Event('pointerdown', { bubbles: true, cancelable: true });
option.dispatchEvent(pointerDown);
expect(pointerDown.defaultPrevented).toBe(true);
fireEvent.click(option);
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ action: 'new' }));
});
it('reports the active option, keeps it visible, and excludes options from the tab order', async () => {
const onActiveDescendantChange = vi.fn();
render(() => (
<SlashCommandAutocomplete
query=""
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={vi.fn()}
onActiveDescendantChange={onActiveDescendantChange}
/>
));
const listbox = screen.getByRole('listbox', { name: 'Assistant commands' });
expect(listbox).toHaveAttribute('id', 'assistant-slash-command-listbox');
expect(listbox.parentElement).toHaveClass('w-[calc(100vw-36px)]');
const options = screen.getAllByRole('option');
expect(options[0]).toHaveAttribute('id', 'assistant-slash-command-option-0');
expect(options[0]).toHaveAttribute('tabindex', '-1');
expect(onActiveDescendantChange).toHaveBeenLastCalledWith('assistant-slash-command-option-0');
const scrollIntoView = vi.fn();
Object.defineProperty(options[1], 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
fireEvent.keyDown(document, { key: 'ArrowDown' });
expect(onActiveDescendantChange).toHaveBeenLastCalledWith('assistant-slash-command-option-1');
await vi.waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }));
});
it('consumes local command navigation keys before later document handlers see them', () => {
const onClose = vi.fn();
const onSelect = vi.fn();
@@ -168,8 +168,15 @@ import {
import { ChatMessages } from './ChatMessages';
import { AssistantCommandHelpDialog } from './AssistantCommandHelpDialog';
import { ModelSelector } from './ModelSelector';
import { MentionAutocomplete, type MentionResource } from './MentionAutocomplete';
import { SlashCommandAutocomplete } from './SlashCommandAutocomplete';
import {
ASSISTANT_MENTION_LISTBOX_ID,
MentionAutocomplete,
type MentionResource,
} from './MentionAutocomplete';
import {
ASSISTANT_SLASH_COMMAND_LISTBOX_ID,
SlashCommandAutocomplete,
} from './SlashCommandAutocomplete';
import { getAssistantActiveTurnStatus } from './activeTurnStatus';
import { selectQuickResumeSessions } from './recentSessionsModel';
import {
@@ -761,6 +768,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
const [pastedBlocks, setPastedBlocks] = createSignal<PastedTextBlock[]>([]);
const [slashCommandActive, setSlashCommandActive] = createSignal(false);
const [slashCommandQuery, setSlashCommandQuery] = createSignal('');
const [mentionActiveDescendant, setMentionActiveDescendant] = createSignal<string>();
const [slashCommandActiveDescendant, setSlashCommandActiveDescendant] = createSignal<string>();
let textareaRef: HTMLTextAreaElement | undefined;
let transcriptFallbackTextareaRef: HTMLTextAreaElement | undefined;
let interruptArmTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -5353,6 +5362,22 @@ export const AIChat: Component<AIChatProps> = (props) => {
onKeyDown={handleKeyDown}
onPaste={handleComposerPaste}
placeholder={AI_CHAT_INPUT_PLACEHOLDER}
aria-label="Message Pulse Assistant"
aria-autocomplete="list"
aria-controls={
mentionActive() && mentionActiveDescendant()
? ASSISTANT_MENTION_LISTBOX_ID
: slashCommandActive()
? ASSISTANT_SLASH_COMMAND_LISTBOX_ID
: undefined
}
aria-activedescendant={
mentionActive()
? mentionActiveDescendant()
: slashCommandActive()
? slashCommandActiveDescendant()
: undefined
}
rows={1}
class="max-h-40 min-h-[54px] flex-1 resize-none bg-transparent px-3.5 py-3.5 pr-14 text-sm leading-5 text-base-content placeholder-slate-400 focus:outline-none"
/>
@@ -5364,6 +5389,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
onSelect={handleMentionSelect}
onClose={() => setMentionActive(false)}
visible={mentionActive()}
onActiveDescendantChange={setMentionActiveDescendant}
/>
</div>
<SlashCommandAutocomplete
@@ -5373,6 +5399,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
onSelect={handleSlashCommandSelect}
onClose={() => closeSlashCommandAutocomplete({ clearTransientDraft: true })}
visible={slashCommandActive()}
onActiveDescendantChange={setSlashCommandActiveDescendant}
/>
<div class="absolute bottom-2 right-2 flex items-center gap-1.5">
<ActionIconButton