fix(CQ-WEB-11+12): Fix accessibility labels + SSR window check
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

CQ-WEB-11: Add aria-label attributes to search input, date inputs,
and id/htmlFor associations for status and priority filter checkboxes
in FilterBar component to improve screen reader accessibility.

CQ-WEB-12: Guard all browser-specific API usage in ReactFlowEditor
behind typeof window checks. Move isDark detection into useState +
useEffect to prevent SSR/hydration mismatches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-06 18:45:56 -06:00
parent bfeea743f7
commit 3d9edf4141
3 changed files with 85 additions and 3 deletions

View File

@@ -132,4 +132,70 @@ describe("FilterBar", (): void => {
// Should show 3 active filters (2 statuses + 1 priority)
expect(screen.getByText(/3/)).toBeInTheDocument();
});
describe("accessibility (CQ-WEB-11)", (): void => {
it("should have aria-label on search input", (): void => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
const searchInput = screen.getByRole("textbox", { name: /search tasks/i });
expect(searchInput).toBeInTheDocument();
});
it("should have aria-label on date inputs", (): void => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByLabelText(/filter from date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/filter to date/i)).toBeInTheDocument();
});
it("should have aria-labels on status filter buttons", (): void => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByRole("button", { name: /status filter/i })).toBeInTheDocument();
});
it("should have aria-labels on priority filter buttons", (): void => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByRole("button", { name: /priority filter/i })).toBeInTheDocument();
});
it("should have id and htmlFor associations on status checkboxes", async (): Promise<void> => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} />);
// Open status dropdown
await user.click(screen.getByRole("button", { name: /status filter/i }));
// Verify specific status checkboxes have proper id attributes
const notStartedCheckbox = screen.getByLabelText(/filter by status: not started/i);
expect(notStartedCheckbox).toHaveAttribute("id", "status-filter-NOT_STARTED");
const inProgressCheckbox = screen.getByLabelText(/filter by status: in progress/i);
expect(inProgressCheckbox).toHaveAttribute("id", "status-filter-IN_PROGRESS");
const completedCheckbox = screen.getByLabelText(/filter by status: completed/i);
expect(completedCheckbox).toHaveAttribute("id", "status-filter-COMPLETED");
});
it("should have id and htmlFor associations on priority checkboxes", async (): Promise<void> => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} />);
// Open priority dropdown
await user.click(screen.getByRole("button", { name: /priority filter/i }));
// Verify specific priority checkboxes have proper id attributes
const lowCheckbox = screen.getByLabelText(/filter by priority: low/i);
expect(lowCheckbox).toHaveAttribute("id", "priority-filter-LOW");
const mediumCheckbox = screen.getByLabelText(/filter by priority: medium/i);
expect(mediumCheckbox).toHaveAttribute("id", "priority-filter-MEDIUM");
const highCheckbox = screen.getByLabelText(/filter by priority: high/i);
expect(highCheckbox).toHaveAttribute("id", "priority-filter-HIGH");
});
it("should have aria-label on clear filters button", (): void => {
const filtersWithSearch = { search: "test" };
render(<FilterBar onFilterChange={mockOnFilterChange} initialFilters={filtersWithSearch} />);
expect(screen.getByRole("button", { name: /clear filters/i })).toBeInTheDocument();
});
});
});

View File

@@ -112,6 +112,7 @@ export function FilterBar({
<input
type="text"
placeholder="Search tasks..."
aria-label="Search tasks"
value={searchValue}
onChange={(e) => {
setSearchValue(e.target.value);
@@ -141,14 +142,17 @@ export function FilterBar({
{Object.values(TaskStatus).map((status) => (
<label
key={status}
htmlFor={`status-filter-${status}`}
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
>
<input
id={`status-filter-${status}`}
type="checkbox"
checked={filters.status?.includes(status) ?? false}
onChange={() => {
handleStatusToggle(status);
}}
aria-label={`Filter by status: ${status.replace(/_/g, " ")}`}
className="mr-2"
/>
{status.replace(/_/g, " ")}
@@ -179,14 +183,17 @@ export function FilterBar({
{Object.values(TaskPriority).map((priority) => (
<label
key={priority}
htmlFor={`priority-filter-${priority}`}
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
>
<input
id={`priority-filter-${priority}`}
type="checkbox"
checked={filters.priority?.includes(priority) ?? false}
onChange={() => {
handlePriorityToggle(priority);
}}
aria-label={`Filter by priority: ${priority}`}
className="mr-2"
/>
{priority}
@@ -201,6 +208,7 @@ export function FilterBar({
<input
type="date"
placeholder="From date"
aria-label="Filter from date"
value={filters.dateFrom ?? ""}
onChange={(e) => {
handleFilterChange("dateFrom", e.target.value || undefined);
@@ -211,6 +219,7 @@ export function FilterBar({
<input
type="date"
placeholder="To date"
aria-label="Filter to date"
value={filters.dateTo ?? ""}
onChange={(e) => {
handleFilterChange("dateTo", e.target.value || undefined);

View File

@@ -222,7 +222,9 @@ export function ReactFlowEditor({
}, [readOnly, selectedNode, onNodeDelete, setNodes, setEdges]);
// Keyboard shortcuts
useEffect((): (() => void) => {
useEffect((): (() => void) | undefined => {
if (typeof window === "undefined") return undefined;
const handleKeyDown = (event: KeyboardEvent): void => {
if (readOnly) return;
@@ -240,8 +242,13 @@ export function ReactFlowEditor({
};
}, [readOnly, selectedNode, handleDeleteSelected]);
const isDark =
typeof window !== "undefined" && document.documentElement.classList.contains("dark");
// Dark mode detection - must be in state+effect to avoid SSR/hydration mismatch
const [isDark, setIsDark] = useState(false);
useEffect((): void => {
if (typeof window !== "undefined") {
setIsDark(document.documentElement.classList.contains("dark"));
}
}, []);
return (
<div className={`w-full h-full ${className}`} style={{ minHeight: "500px" }}>