fix(CQ-WEB-11+12): Fix accessibility labels + SSR window check
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
@@ -132,4 +132,70 @@ describe("FilterBar", (): void => {
|
|||||||
// Should show 3 active filters (2 statuses + 1 priority)
|
// Should show 3 active filters (2 statuses + 1 priority)
|
||||||
expect(screen.getByText(/3/)).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export function FilterBar({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search tasks..."
|
placeholder="Search tasks..."
|
||||||
|
aria-label="Search tasks"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchValue(e.target.value);
|
setSearchValue(e.target.value);
|
||||||
@@ -141,14 +142,17 @@ export function FilterBar({
|
|||||||
{Object.values(TaskStatus).map((status) => (
|
{Object.values(TaskStatus).map((status) => (
|
||||||
<label
|
<label
|
||||||
key={status}
|
key={status}
|
||||||
|
htmlFor={`status-filter-${status}`}
|
||||||
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
|
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id={`status-filter-${status}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={filters.status?.includes(status) ?? false}
|
checked={filters.status?.includes(status) ?? false}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
handleStatusToggle(status);
|
handleStatusToggle(status);
|
||||||
}}
|
}}
|
||||||
|
aria-label={`Filter by status: ${status.replace(/_/g, " ")}`}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
{status.replace(/_/g, " ")}
|
{status.replace(/_/g, " ")}
|
||||||
@@ -179,14 +183,17 @@ export function FilterBar({
|
|||||||
{Object.values(TaskPriority).map((priority) => (
|
{Object.values(TaskPriority).map((priority) => (
|
||||||
<label
|
<label
|
||||||
key={priority}
|
key={priority}
|
||||||
|
htmlFor={`priority-filter-${priority}`}
|
||||||
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
|
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id={`priority-filter-${priority}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={filters.priority?.includes(priority) ?? false}
|
checked={filters.priority?.includes(priority) ?? false}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
handlePriorityToggle(priority);
|
handlePriorityToggle(priority);
|
||||||
}}
|
}}
|
||||||
|
aria-label={`Filter by priority: ${priority}`}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
{priority}
|
{priority}
|
||||||
@@ -201,6 +208,7 @@ export function FilterBar({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
placeholder="From date"
|
placeholder="From date"
|
||||||
|
aria-label="Filter from date"
|
||||||
value={filters.dateFrom ?? ""}
|
value={filters.dateFrom ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleFilterChange("dateFrom", e.target.value || undefined);
|
handleFilterChange("dateFrom", e.target.value || undefined);
|
||||||
@@ -211,6 +219,7 @@ export function FilterBar({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
placeholder="To date"
|
placeholder="To date"
|
||||||
|
aria-label="Filter to date"
|
||||||
value={filters.dateTo ?? ""}
|
value={filters.dateTo ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleFilterChange("dateTo", e.target.value || undefined);
|
handleFilterChange("dateTo", e.target.value || undefined);
|
||||||
|
|||||||
@@ -222,7 +222,9 @@ export function ReactFlowEditor({
|
|||||||
}, [readOnly, selectedNode, onNodeDelete, setNodes, setEdges]);
|
}, [readOnly, selectedNode, onNodeDelete, setNodes, setEdges]);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useEffect((): (() => void) => {
|
useEffect((): (() => void) | undefined => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
|
|
||||||
@@ -240,8 +242,13 @@ export function ReactFlowEditor({
|
|||||||
};
|
};
|
||||||
}, [readOnly, selectedNode, handleDeleteSelected]);
|
}, [readOnly, selectedNode, handleDeleteSelected]);
|
||||||
|
|
||||||
const isDark =
|
// Dark mode detection - must be in state+effect to avoid SSR/hydration mismatch
|
||||||
typeof window !== "undefined" && document.documentElement.classList.contains("dark");
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
useEffect((): void => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full h-full ${className}`} style={{ minHeight: "500px" }}>
|
<div className={`w-full h-full ${className}`} style={{ minHeight: "500px" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user