feat(FN-4254): improve mission manager list accessibility
- Make mission and interview list rows keyboard-reachable buttons with descriptive aria labels - Handle Enter and Space activation while preventing nested control bubbling from triggering row actions - Replace targeted MissionManager sizing literals with design tokens and add regression coverage for tokenized CSS
This commit is contained in:
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
.mission-manager__title {
|
||||
font-size: 16px;
|
||||
font-size: var(--space-lg);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
@@ -164,7 +164,7 @@
|
||||
}
|
||||
|
||||
.mission-manager__sidebar {
|
||||
width: 300px;
|
||||
width: calc(var(--space-lg) * 18.75);
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -347,21 +347,20 @@
|
||||
}
|
||||
|
||||
/* ── Status Badge ── */
|
||||
/* Fine-grained badge paddings stay pixel-specific for compact legibility. */
|
||||
.mission-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
padding: 2px 8px;
|
||||
padding: calc(var(--space-xs) / 2) var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mission-status-badge--sm {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
font-size: calc(var(--space-sm) + (var(--space-xs) / 2));
|
||||
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4));
|
||||
}
|
||||
|
||||
/* ── Shared Controls ── */
|
||||
@@ -734,6 +733,15 @@
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.mission-list__item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.mission-list__item:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.mission-list__item--selected {
|
||||
border-color: var(--todo);
|
||||
box-shadow: inset 0 0 0 1px var(--todo);
|
||||
@@ -950,7 +958,7 @@
|
||||
}
|
||||
|
||||
.mission-detail__title {
|
||||
font-size: 18px;
|
||||
font-size: calc(var(--space-lg) + (var(--space-xs) / 2));
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
@@ -974,10 +982,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 11px;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--color-success);
|
||||
background: var(--meta-badge-bg);
|
||||
padding: 2px 8px;
|
||||
padding: calc(var(--space-xs) / 2) var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
@@ -1124,8 +1132,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
padding: calc(var(--space-xs) / 2) var(--space-sm);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -1230,8 +1238,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
padding: calc(var(--space-xs) / 2) var(--space-sm);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
text-transform: capitalize;
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
@@ -1495,9 +1503,9 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
width: var(--space-lg);
|
||||
height: var(--space-lg);
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1677,12 +1685,12 @@
|
||||
}
|
||||
|
||||
.mission-assertion__linked-count {
|
||||
font-size: 11px;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-dim);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px;
|
||||
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -3598,7 +3598,21 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<div
|
||||
key={session.id}
|
||||
className="mission-list__item mission-list__item--interview"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Resume interview ${session.title || "Mission interview"}`}
|
||||
onClick={() => handleResumeInterviewSession(session.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.currentTarget !== event.target) return;
|
||||
if (event.key === "Enter") {
|
||||
handleResumeInterviewSession(session.id);
|
||||
return;
|
||||
}
|
||||
if (event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleResumeInterviewSession(session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mission-list__item-content">
|
||||
<div className="mission-list__item-header">
|
||||
@@ -3662,7 +3676,22 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<div
|
||||
key={m.id}
|
||||
className={`mission-list__item ${isSelected ? "mission-list__item--selected" : ""} ${isInterviewStyle ? "mission-list__item--interview" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open mission ${m.title}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => handleSelectMission(mission)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.currentTarget !== event.target) return;
|
||||
if (event.key === "Enter") {
|
||||
handleSelectMission(mission);
|
||||
return;
|
||||
}
|
||||
if (event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleSelectMission(mission);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mission-list__item-content">
|
||||
<div className="mission-list__item-header">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
|
||||
import { MissionManager } from "../MissionManager";
|
||||
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||
|
||||
/**
|
||||
* MissionManager layout reference (post FN-3136):
|
||||
@@ -4067,6 +4068,92 @@ describe("MissionManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mission list row interactions", () => {
|
||||
it("renders mission and interview rows as keyboard-reachable buttons with labels", async () => {
|
||||
mockFetchMissionInterviewDrafts.mockResolvedValue([
|
||||
{
|
||||
id: "S-001",
|
||||
title: "Auth interview",
|
||||
status: "awaiting_input",
|
||||
projectId: null,
|
||||
hasConversation: true,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = createFetchMock();
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
const missionRow = await screen.findByRole("button", { name: "Open mission Build Auth System" });
|
||||
const interviewRow = await screen.findByRole("button", { name: "Resume interview Auth interview" });
|
||||
|
||||
expect(missionRow).toHaveAttribute("tabindex", "0");
|
||||
expect(missionRow).toHaveAttribute("aria-pressed", "false");
|
||||
expect(interviewRow).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
it("activates rows from keyboard and prevents bubbling from interview row actions", async () => {
|
||||
mockFetchMissionInterviewDrafts.mockResolvedValue([
|
||||
{
|
||||
id: "S-002",
|
||||
title: "Retry interview",
|
||||
status: "error",
|
||||
projectId: null,
|
||||
hasConversation: true,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const fetchMock = createFetchMock();
|
||||
globalThis.fetch = fetchMock;
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
const interviewRow = await screen.findByRole("button", { name: "Resume interview Retry interview" });
|
||||
fireEvent.keyDown(interviewRow, { key: "Enter" });
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("S-002");
|
||||
});
|
||||
|
||||
mockFetchAiSession.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard draft" }));
|
||||
expect(mockFetchAiSession).not.toHaveBeenCalled();
|
||||
|
||||
const missionRow = await screen.findByRole("button", { name: "Open mission Build Auth System" });
|
||||
const missionDetailFetchesBefore = fetchMock.mock.calls.filter(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001"),
|
||||
).length;
|
||||
|
||||
const spaceEvent = fireEvent.keyDown(missionRow, { key: " " });
|
||||
expect(spaceEvent).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
const missionDetailFetchesAfter = fetchMock.mock.calls.filter(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001"),
|
||||
).length;
|
||||
expect(missionDetailFetchesAfter).toBe(missionDetailFetchesBefore + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MissionManager tokenized sizing regression", () => {
|
||||
it("does not retain targeted hardcoded px literals in MissionManager selectors", async () => {
|
||||
const css = await loadAllAppCssBaseOnly();
|
||||
|
||||
expect(css).not.toMatch(/\.mission-manager__title\s*\{[^}]*font-size:\s*16px/i);
|
||||
expect(css).not.toMatch(/\.mission-manager__sidebar\s*\{[^}]*width:\s*300px/i);
|
||||
expect(css).not.toMatch(/\.mission-status-badge\s*\{[^}]*font-size:\s*11px/i);
|
||||
expect(css).not.toMatch(/\.mission-status-badge\s*\{[^}]*padding:\s*2px\s+8px/i);
|
||||
expect(css).not.toMatch(/\.mission-status-badge--sm\s*\{[^}]*font-size:\s*10px/i);
|
||||
expect(css).not.toMatch(/\.mission-status-badge--sm\s*\{[^}]*padding:\s*1px\s+6px/i);
|
||||
expect(css).not.toMatch(/\.mission-detail__title\s*\{[^}]*font-size:\s*18px/i);
|
||||
expect(css).not.toMatch(/\.mission-event__type\s*\{[^}]*font-size:\s*11px/i);
|
||||
expect(css).not.toMatch(/\.mission-event__type\s*\{[^}]*padding:\s*2px\s+8px/i);
|
||||
expect(css).not.toMatch(/\.mission-plan-state-indicator\s*\{[^}]*width:\s*16px/i);
|
||||
expect(css).not.toMatch(/\.mission-plan-state-indicator\s*\{[^}]*height:\s*16px/i);
|
||||
expect(css).not.toMatch(/\.mission-plan-state-indicator\s*\{[^}]*border-radius:\s*4px/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("two-panel layout test IDs", () => {
|
||||
it("renders sidebar and empty detail pane on desktop via test IDs", async () => {
|
||||
globalThis.fetch = createFetchMock();
|
||||
|
||||
Reference in New Issue
Block a user