Skip to content

Commit cdeac77

Browse files
authored
accessibility improvements (#1386)
* Add keyboard navigation, focus management and accessibility improvements - Add KeyboardShortcutsDialog component and a global shortcut handler hook - Implement useKeyboardNavigation and useGlobalKeyboardShortcuts hooks for consistent keyboard handling - Add focusManager utility (focus history, focus trap, helpers) for centralized focus control - Wire keyboard support into FeedbackBoard, FeedbackColumn, FeedbackItem and FeedbackItemGroup: - column and item navigation (arrow keys, Home/End, 1-9 to jump columns) - actions via keys (create/edit/delete/vote/group/move/add action/timer, expand/collapse groups) - register and manage item refs, roving focus behavior and programmatic focus/focus cleanup - Improve ARIA semantics and attributes (roles, aria-labels, aria-expanded, aria-controls, roledescription, live regions) - Update CSS to expose visible focus styles and dialog styles for accessibility - Remove/cleanup event listeners on unmount - Add .playwright-mcp to .gitignore * Enhance accessibility: expand feedback item aria-label to include title, creator, creation date and vote details * Improve focus preservation when feedback items change - Add focus preservation and restoration in FeedbackColumn (getSnapshotBeforeUpdate, componentDidUpdate) - Preserve active element, selection ranges for inputs/textareas and cursor position for contentEditable - Restore focus and selection after items are added/removed/reordered - Add data-feedback-item-id to FeedbackItem root to reliably locate items for focus restoration * Add arrow key navigation between feedback cards Handle ArrowUp/ArrowDown in item keydown handler (prevent default) and add navigateToAdjacentCard implementation that finds visible items in the column and moves focus to the previous/next feedback item via its data-feedback-item-id. * Add keyboard shortcuts menu item and dialog to extension settings * Add accessibility, keyboard navigation and focus tests for dialogs, columns, items and FocusManager * Add comprehensive FeedbackColumn tests for keyboard navigation, focus, dialogs, DnD and item lifecycle - Add keyboard navigation tests (ArrowUp/Down, Home, End, 'n', 'e', 'i') including cases when inputs or dialogs block handling - Add focus preservation tests for focus restoration, input selection, and contenteditable cursor retention after rerender - Add dialog management tests for opening/closing edit and info dialogs and editing column notes - Add drag-and-drop tests for dragover and drop handling on the column - Add item registration/unregistration tests for item refs - Add createEmptyFeedbackItem tests for Collect/Vote phase behavior and duplicate prevention * Add tests: EditableText click-outside/save/escape behavior and event cleanup; SelectorCombo mobile/callout selection and header visibility; trim whitespace in FeedbackColumn tests * Add tests: EditableText Ctrl+Enter empty validation; FeedbackCarousel sorting/aggregation/multi-column; FeedbackItemGroup keyboard, focus, drag/drop, aria and cleanup; SelectorCombo multi-list/empty-list cases; WorkflowStage non-Enter early return * Add tests: FeedbackBoard keyboard navigation, FocusManager edge cases, EffectivenessMeasurementRow vote edge cases - Add comprehensive keyboard navigation/shortcut tests for FeedbackBoard (?, arrow/number keys, modifiers, input/textarea/contentEditable/dialog guards) - Expand FocusManager tests for findNextFocusableElement and createFocusTrap (visibility mocking, wrap behavior, non-Tab keys, empty containers, verify preventDefault) - Add EffectivenessMeasurementRow tests for votes with undefined or empty responses arrays - Remove unused React require in mocked FeedbackColumn * Accessibility: simplify vote button aria-labels; include title in group aria-label - Simplify vote action button aria-labels to remove the full feedback title and use concise phrasing ("Vote up/Vote down. Current vote count is X"). - Update feedback group aria-label to prepend the main item's title (falls back to "Untitled feedback") while retaining item count and expanded/collapsed state. - Add unit tests covering simplified vote labels (including zero votes and hidden items) and group aria-label scenarios (title presence, untitled fallback, expansion state, and item counts). * chore(deps): bump @types/react/@types/react-dom, cssnano, rimraf and related postcss packages * Accessibility: move aria-live from container to error span and add role=alert * Accessibility: simplify WorkflowStage aria-label to only display text; use aria-selected to indicate state; add aria-label to tablist; update tests * Accessibility: make FeedbackColumn tabbable and handle immediate keyboard navigation; add tests - Set column tabIndex to 0 so the column is keyboard-focusable. - Fix navigateItems/prev logic to handle negative focused index and bail out when no valid target. - Ensure keyboard handlers gracefully process ArrowDown/Home/End/ArrowUp even when nothing is focused. - Add unit tests covering immediate-focus keyboard behavior (tabIndex, ArrowDown, Home, End, ArrowUp). * Accessibility: mark obscured feedback as aria-hidden and add tests - Set aria-hidden on feedback item root when hideFeedbackItems is true so screen readers ignore obscured items. - Apply aria-hidden to related child title when the child item is hidden. - Add unit tests covering obscured feedback scenarios (hidden items, visible items, owner-exempt items, and grouped child visibility). - Add userIdentityHelper mock in tests to support owner-checking logic. * Accessibility: move focus-within highlight to .feedback-column-header and remove column border color changes * Accessibility: move focus-within highlight from .feedback-column-header to .feedback-column
1 parent 7fcb9cf commit cdeac77

33 files changed

+4236
-114
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,5 @@ obj/
180180
TestResults/
181181
test-report.xml
182182
dist/
183+
184+
.playwright-mcp

src/frontend/components/__tests__/editableText.test.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,173 @@ describe("Editable Text Component", () => {
163163
// Text should be updated in display mode
164164
expect(document.body.textContent).toContain("Updated from props");
165165
});
166+
167+
it("saves text and exits edit mode when clicking outside with valid text", async () => {
168+
const { container } = render(<EditableText {...mockedTestProps} text="Initial" />);
169+
170+
const clickableElement = document.querySelector('[title="Click to edit"]') as HTMLElement;
171+
await userEvent.click(clickableElement);
172+
173+
const input = document.querySelector("input, textarea") as HTMLElement;
174+
await userEvent.clear(input);
175+
await userEvent.type(input, "New Text");
176+
177+
// Create an outside element and click it
178+
const outsideDiv = document.createElement("div");
179+
document.body.appendChild(outsideDiv);
180+
181+
const mouseDownEvent = new MouseEvent("mousedown", { bubbles: true });
182+
Object.defineProperty(mouseDownEvent, "target", { value: outsideDiv, enumerable: true });
183+
document.dispatchEvent(mouseDownEvent);
184+
185+
await new Promise(resolve => setTimeout(resolve, 10));
186+
187+
expect(mockOnSave).toHaveBeenCalledWith("New Text");
188+
189+
document.body.removeChild(outsideDiv);
190+
});
191+
192+
it("saves empty string when clicking outside with empty text", async () => {
193+
const { container } = render(<EditableText {...mockedTestProps} text="Initial" />);
194+
195+
const clickableElement = document.querySelector('[title="Click to edit"]') as HTMLElement;
196+
await userEvent.click(clickableElement);
197+
198+
const input = document.querySelector("input, textarea") as HTMLElement;
199+
await userEvent.clear(input);
200+
201+
// Create an outside element and click it
202+
const outsideDiv = document.createElement("div");
203+
document.body.appendChild(outsideDiv);
204+
205+
const mouseDownEvent = new MouseEvent("mousedown", { bubbles: true });
206+
Object.defineProperty(mouseDownEvent, "target", { value: outsideDiv, enumerable: true });
207+
document.dispatchEvent(mouseDownEvent);
208+
209+
await new Promise(resolve => setTimeout(resolve, 10));
210+
211+
expect(mockOnSave).toHaveBeenCalledWith("");
212+
213+
document.body.removeChild(outsideDiv);
214+
});
215+
216+
it("does not save when clicking inside the editable text component", async () => {
217+
const { container } = render(<EditableText {...mockedTestProps} text="Initial" />);
218+
219+
const clickableElement = document.querySelector('[title="Click to edit"]') as HTMLElement;
220+
await userEvent.click(clickableElement);
221+
222+
const input = document.querySelector("input, textarea") as HTMLElement;
223+
await userEvent.clear(input);
224+
await userEvent.type(input, "New Text");
225+
226+
// Click inside the component
227+
const mouseDownEvent = new MouseEvent("mousedown", { bubbles: true });
228+
Object.defineProperty(mouseDownEvent, "target", { value: input, enumerable: true });
229+
document.dispatchEvent(mouseDownEvent);
230+
231+
await new Promise(resolve => setTimeout(resolve, 10));
232+
233+
// Should not save yet, still in edit mode
234+
expect(document.querySelector("input, textarea")).toBeTruthy();
235+
});
236+
237+
it("cleans up event listener on unmount", () => {
238+
const removeEventListenerSpy = jest.spyOn(document, "removeEventListener");
239+
const { unmount } = render(<EditableText {...mockedTestProps} text="Test" />);
240+
241+
unmount();
242+
243+
expect(removeEventListenerSpy).toHaveBeenCalledWith("mousedown", expect.any(Function));
244+
245+
removeEventListenerSpy.mockRestore();
246+
});
247+
248+
it("shows error when trying to add newline with Ctrl+Enter on empty text", async () => {
249+
const propsMultiline = { ...mockedTestProps, text: "", isMultiline: true };
250+
render(<EditableText {...propsMultiline} />);
251+
252+
const textarea = document.querySelector("textarea") as HTMLElement;
253+
await userEvent.clear(textarea);
254+
await userEvent.type(textarea, "{Control>}{Enter}{/Control}");
255+
256+
expect(document.body.textContent).toContain("This cannot be empty.");
257+
});
258+
259+
describe("Accessibility - Fix double character announcement (Issue #1261)", () => {
260+
it("should not have aria-live on the editable text container", () => {
261+
render(<EditableText {...mockedTestProps} />);
262+
263+
const container = document.querySelector(".editable-text-container") as HTMLElement;
264+
expect(container).toBeTruthy();
265+
expect(container.getAttribute("aria-live")).toBeNull();
266+
});
267+
268+
it("should have aria-live only on error message when validation fails", async () => {
269+
render(<EditableText {...mockedTestProps} />);
270+
271+
const input = document.querySelector('[aria-label="Please enter feedback title"]') as HTMLElement;
272+
await userEvent.clear(input);
273+
await userEvent.type(input, " ");
274+
await userEvent.clear(input);
275+
276+
const errorMessage = document.querySelector(".input-validation-message") as HTMLElement;
277+
expect(errorMessage).toBeTruthy();
278+
expect(errorMessage.getAttribute("aria-live")).toBe("assertive");
279+
expect(errorMessage.getAttribute("role")).toBe("alert");
280+
});
281+
282+
it("should not announce character input twice during typing", async () => {
283+
const propsWithText = { ...mockedTestProps, text: "Initial" };
284+
render(<EditableText {...propsWithText} />);
285+
286+
const clickableElement = document.querySelector('[title="Click to edit"]') as HTMLElement;
287+
await userEvent.click(clickableElement);
288+
289+
const container = document.querySelector(".editable-text-container") as HTMLElement;
290+
const input = document.querySelector("input, textarea") as HTMLElement;
291+
292+
// Verify container does not have aria-live while editing
293+
expect(container.getAttribute("aria-live")).toBeNull();
294+
295+
// Type characters and verify no aria-live on container
296+
await userEvent.type(input, " Text");
297+
expect(container.getAttribute("aria-live")).toBeNull();
298+
});
299+
300+
it("should properly announce errors without double character echo", async () => {
301+
render(<EditableText {...mockedTestProps} text="" />);
302+
303+
const input = document.querySelector('[aria-label="Please enter feedback title"]') as HTMLElement;
304+
305+
// Type and clear to trigger error
306+
await userEvent.type(input, "a");
307+
await userEvent.clear(input);
308+
309+
const container = document.querySelector(".editable-text-container") as HTMLElement;
310+
const errorMessage = document.querySelector(".input-validation-message") as HTMLElement;
311+
312+
// Container should not have aria-live
313+
expect(container.getAttribute("aria-live")).toBeNull();
314+
315+
// Only error message should have aria-live
316+
expect(errorMessage.getAttribute("aria-live")).toBe("assertive");
317+
expect(errorMessage.getAttribute("role")).toBe("alert");
318+
});
319+
320+
it("should maintain proper ARIA attributes during edit mode", async () => {
321+
const propsWithText = { ...mockedTestProps, text: "Test" };
322+
render(<EditableText {...propsWithText} />);
323+
324+
const clickableElement = document.querySelector('[title="Click to edit"]') as HTMLElement;
325+
await userEvent.click(clickableElement);
326+
327+
const input = document.querySelector('[aria-label="Please enter feedback title"]') as HTMLElement;
328+
const container = document.querySelector(".editable-text-container") as HTMLElement;
329+
330+
// Verify input has proper ARIA but container doesn't have aria-live
331+
expect(input.getAttribute("aria-label")).toBe("Please enter feedback title");
332+
expect(container.getAttribute("aria-live")).toBeNull();
333+
});
334+
});
166335
});

src/frontend/components/__tests__/effectivenessMeasurementRow.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,5 +213,39 @@ describe("EffectivenessMeasurementRow", () => {
213213
expect(button?.querySelector('[data-icon-name="CircleRing"]')).toBeInTheDocument();
214214
}
215215
});
216+
217+
it("handles votes with undefined responses array", () => {
218+
const votesWithoutResponses: ITeamEffectivenessMeasurementVoteCollection[] = [
219+
{
220+
userId: "encrypted-test-user-id",
221+
responses: undefined as any,
222+
},
223+
];
224+
const props = { ...defaultProps, votes: votesWithoutResponses };
225+
const { container } = render(<EffectivenessMeasurementRow {...props} />);
226+
227+
// Should default to no selection (0)
228+
for (let i = 1; i <= 10; i++) {
229+
const button = container.querySelector(`button[aria-label="${i}"]`);
230+
expect(button?.querySelector('[data-icon-name="CircleRing"]')).toBeInTheDocument();
231+
}
232+
});
233+
234+
it("handles user vote with empty responses array", () => {
235+
const votesWithEmptyResponses: ITeamEffectivenessMeasurementVoteCollection[] = [
236+
{
237+
userId: "encrypted-test-user-id",
238+
responses: [],
239+
},
240+
];
241+
const props = { ...defaultProps, votes: votesWithEmptyResponses };
242+
const { container } = render(<EffectivenessMeasurementRow {...props} />);
243+
244+
// Should default to no selection (0)
245+
for (let i = 1; i <= 10; i++) {
246+
const button = container.querySelector(`button[aria-label="${i}"]`);
247+
expect(button?.querySelector('[data-icon-name="CircleRing"]')).toBeInTheDocument();
248+
}
249+
});
216250
});
217251
});

src/frontend/components/__tests__/extensionSettingsMenu.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ jest.mock("../../utilities/servicesHelper", () => ({
6464
getProjectId: jest.fn().mockResolvedValue("test-project-id"),
6565
}));
6666

67+
jest.mock("../keyboardShortcutsDialog", () => ({
68+
__esModule: true,
69+
default: ({ isOpen, onClose }: any) =>
70+
isOpen ? (
71+
<div data-testid="keyboard-shortcuts-dialog">
72+
<h2>Keyboard Shortcuts</h2>
73+
<button onClick={onClose}>Close</button>
74+
</div>
75+
) : null,
76+
}));
77+
6778
describe("ExtensionSettingsMenu", () => {
6879
let windowOpenSpy: jest.SpyInstance;
6980

@@ -175,6 +186,34 @@ describe("ExtensionSettingsMenu", () => {
175186
});
176187
});
177188

189+
it("opens Keyboard Shortcuts dialog", async () => {
190+
render(<ExtensionSettingsMenu />);
191+
fireEvent.click(screen.getByTitle("Retrospective Help"));
192+
await waitFor(() => {
193+
fireEvent.click(screen.getByText("Keyboard shortcuts"));
194+
});
195+
await waitFor(() => {
196+
expect(screen.getByTestId("keyboard-shortcuts-dialog")).toBeInTheDocument();
197+
expect(screen.getByText("Keyboard Shortcuts")).toBeInTheDocument();
198+
});
199+
});
200+
201+
it("closes Keyboard Shortcuts dialog", async () => {
202+
render(<ExtensionSettingsMenu />);
203+
fireEvent.click(screen.getByTitle("Retrospective Help"));
204+
await waitFor(() => {
205+
fireEvent.click(screen.getByText("Keyboard shortcuts"));
206+
});
207+
await waitFor(() => {
208+
expect(screen.getByTestId("keyboard-shortcuts-dialog")).toBeInTheDocument();
209+
});
210+
211+
fireEvent.click(screen.getByText("Close"));
212+
await waitFor(() => {
213+
expect(screen.queryByTestId("keyboard-shortcuts-dialog")).not.toBeInTheDocument();
214+
});
215+
});
216+
178217
it("opens GitHub issues", async () => {
179218
render(<ExtensionSettingsMenu />);
180219
fireEvent.click(screen.getByTitle("Retrospective Help"));

0 commit comments

Comments
 (0)