From 044a57450d0a8aac6524d9c282b13cb672457654 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 10 Jun 2026 15:51:46 -0400 Subject: [PATCH 1/2] FIX Make conversation panel and attack history rows keyboard-accessible ConversationPanel and AttackTable rows were clickable but unreachable by keyboard. Add tabIndex, role/aria-label, and Enter/Space onKeyDown handlers so keyboard users can select conversations and open attacks, with matching focus-visible outlines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Chat/ConversationPanel.styles.ts | 4 ++ .../Chat/ConversationPanel.test.tsx | 40 +++++++++++++++++++ .../src/components/Chat/ConversationPanel.tsx | 13 +++++- .../History/AttackHistory.styles.ts | 4 ++ .../components/History/AttackTable.test.tsx | 24 +++++++++++ .../src/components/History/AttackTable.tsx | 8 ++++ 6 files changed, 92 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Chat/ConversationPanel.styles.ts b/frontend/src/components/Chat/ConversationPanel.styles.ts index b8c0daf98b..d821b33580 100644 --- a/frontend/src/components/Chat/ConversationPanel.styles.ts +++ b/frontend/src/components/Chat/ConversationPanel.styles.ts @@ -41,6 +41,10 @@ export const useConversationPanelStyles = makeStyles({ '&:hover': { backgroundColor: tokens.colorNeutralBackground1Hover, }, + '&:focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '-2px', + }, }, conversationItemActive: { backgroundColor: tokens.colorNeutralBackground1Selected, diff --git a/frontend/src/components/Chat/ConversationPanel.test.tsx b/frontend/src/components/Chat/ConversationPanel.test.tsx index d7c8f134bb..286a23ed63 100644 --- a/frontend/src/components/Chat/ConversationPanel.test.tsx +++ b/frontend/src/components/Chat/ConversationPanel.test.tsx @@ -217,6 +217,46 @@ describe("ConversationPanel", () => { expect(onSelectConversation).toHaveBeenCalledWith("branch-1"); }); + it("should be keyboard accessible for conversation selection", async () => { + const user = userEvent.setup(); + const onSelectConversation = jest.fn(); + + mockedAttacksApi.getConversations.mockResolvedValue({ + attack_result_id: "ar-attack-123", + main_conversation_id: "attack-123", + conversations: [ + { + conversation_id: "branch-1", + message_count: 2, + last_message_preview: null, + created_at: "2026-02-18T11:00:00Z", + }, + ], + }); + + render( + + + + ); + + const convItem = await screen.findByTestId("conversation-item-branch-1"); + expect(convItem).toHaveAttribute("role", "button"); + expect(convItem).toHaveAttribute("tabindex", "0"); + + convItem.focus(); + await user.keyboard("{Enter}"); + expect(onSelectConversation).toHaveBeenCalledWith("branch-1"); + + onSelectConversation.mockClear(); + convItem.focus(); + await user.keyboard(" "); + expect(onSelectConversation).toHaveBeenCalledWith("branch-1"); + }); + it("should call onNewConversation when clicking new conversation button", async () => { const user = userEvent.setup(); const onNewConversation = jest.fn(); diff --git a/frontend/src/components/Chat/ConversationPanel.tsx b/frontend/src/components/Chat/ConversationPanel.tsx index 267b0feaf1..a0072cc7d1 100644 --- a/frontend/src/components/Chat/ConversationPanel.tsx +++ b/frontend/src/components/Chat/ConversationPanel.tsx @@ -166,11 +166,22 @@ export default function ConversationPanel({ {!isLoading && !error && conversations.map((conv) => { const isActive = conv.conversation_id === activeConversationId + const selectConversation = () => onSelectConversation(conv.conversation_id) return (
onSelectConversation(conv.conversation_id)} + onClick={selectConversation} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + selectConversation() + } + }} + role="button" + tabIndex={0} + aria-label={`Select conversation ${conv.conversation_id}`} + aria-current={isActive ? 'true' : undefined} data-testid={`conversation-item-${conv.conversation_id}`} >
diff --git a/frontend/src/components/History/AttackHistory.styles.ts b/frontend/src/components/History/AttackHistory.styles.ts index 6dcafd8634..7c0c87417c 100644 --- a/frontend/src/components/History/AttackHistory.styles.ts +++ b/frontend/src/components/History/AttackHistory.styles.ts @@ -53,6 +53,10 @@ export const useAttackHistoryStyles = makeStyles({ ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover, }, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '-2px', + }, }, previewCell: { display: 'block', diff --git a/frontend/src/components/History/AttackTable.test.tsx b/frontend/src/components/History/AttackTable.test.tsx index eefe89dda5..5f9b14615a 100644 --- a/frontend/src/components/History/AttackTable.test.tsx +++ b/frontend/src/components/History/AttackTable.test.tsx @@ -145,6 +145,30 @@ describe('AttackTable', () => { expect(onOpenAttack).toHaveBeenCalledWith('ar-1') }) + it('should call onOpenAttack when Enter or Space is pressed on a row', () => { + const onOpenAttack = jest.fn() + + render( + + + + ) + + const row = screen.getByTestId('attack-row-ar-1') + expect(row).toHaveAttribute('tabindex', '0') + + fireEvent.keyDown(row, { key: 'Enter' }) + expect(onOpenAttack).toHaveBeenCalledWith('ar-1') + + onOpenAttack.mockClear() + fireEvent.keyDown(row, { key: ' ' }) + expect(onOpenAttack).toHaveBeenCalledWith('ar-1') + + onOpenAttack.mockClear() + fireEvent.keyDown(row, { key: 'a' }) + expect(onOpenAttack).not.toHaveBeenCalled() + }) + it('should call onOpenAttack when open button is clicked', () => { const onOpenAttack = jest.fn() diff --git a/frontend/src/components/History/AttackTable.tsx b/frontend/src/components/History/AttackTable.tsx index 16d052d7c7..906be3538b 100644 --- a/frontend/src/components/History/AttackTable.tsx +++ b/frontend/src/components/History/AttackTable.tsx @@ -69,6 +69,14 @@ export default function AttackTable({ attacks, onOpenAttack, formatDate }: Attac key={attack.attack_result_id} className={styles.clickableRow} onClick={() => onOpenAttack(attack.attack_result_id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenAttack(attack.attack_result_id) + } + }} + tabIndex={0} + aria-label={`Open ${attack.attack_type} attack`} data-testid={`attack-row-${attack.attack_result_id}`} > From 69a55045eb1e25c44271722008342c95bbaf22ab Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Fri, 12 Jun 2026 12:17:00 -0400 Subject: [PATCH 2/2] TEST Use userEvent instead of fireEvent for keyboard test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #1990 — the new keyboard-accessibility test for AttackTable was using fireEvent.keyDown, but per frontend-test.instructions.md user interactions should use @testing-library/user-event. Switched to user.keyboard with focused row. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/History/AttackTable.test.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/History/AttackTable.test.tsx b/frontend/src/components/History/AttackTable.test.tsx index 5f9b14615a..03f7ec6063 100644 --- a/frontend/src/components/History/AttackTable.test.tsx +++ b/frontend/src/components/History/AttackTable.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { FluentProvider, webLightTheme } from '@fluentui/react-components' import AttackTable from './AttackTable' import type { AttackSummary } from '../../types' @@ -145,7 +146,8 @@ describe('AttackTable', () => { expect(onOpenAttack).toHaveBeenCalledWith('ar-1') }) - it('should call onOpenAttack when Enter or Space is pressed on a row', () => { + it('should call onOpenAttack when Enter or Space is pressed on a row', async () => { + const user = userEvent.setup() const onOpenAttack = jest.fn() render( @@ -157,15 +159,18 @@ describe('AttackTable', () => { const row = screen.getByTestId('attack-row-ar-1') expect(row).toHaveAttribute('tabindex', '0') - fireEvent.keyDown(row, { key: 'Enter' }) + row.focus() + await user.keyboard('{Enter}') expect(onOpenAttack).toHaveBeenCalledWith('ar-1') onOpenAttack.mockClear() - fireEvent.keyDown(row, { key: ' ' }) + row.focus() + await user.keyboard(' ') expect(onOpenAttack).toHaveBeenCalledWith('ar-1') onOpenAttack.mockClear() - fireEvent.keyDown(row, { key: 'a' }) + row.focus() + await user.keyboard('a') expect(onOpenAttack).not.toHaveBeenCalled() })