diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx index caf77692..10ec3a7b 100644 --- a/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx +++ b/src/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; -import { Popover } from "@mui/material"; +import { Menu, MenuItem } from "@mui/material"; import { ChevronDown, Tag } from "lucide-react"; -import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; +import { CheckboxIndicator } from "@/components/ui/checkbox-indicator"; import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; import { useMenuAnchor } from "@/hooks/use-menu-anchor"; @@ -22,6 +22,30 @@ function typeFilterLabel(selected: Set): string { return `${selected.size} types selected`; } +function TypeFilterMenuItem({ + checked, + label, + onClick, +}: { + checked: boolean; + label: string; + onClick: () => void; +}) { + return ( + + + {label} + + ); +} + export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFilterProps) { const { anchorEl, open, setAnchor, close } = useMenuAnchor(); const allSelected = selected.size === 0; @@ -57,38 +81,41 @@ export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFil - { + if (reason === "backdropClick" || reason === "escapeKeyDown" || reason === "tabKeyDown") { + close(); + } + }} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} slotProps={{ paper: { className: "mt-1 w-[180px] rounded-lg border border-border p-1.5 shadow-md", }, + list: { + "aria-label": "Work order types", + }, }} > -
- onChange(new Set())} + onChange(new Set())} + /> + {WO_TYPES.map((type) => ( + toggle(type)} /> - {WO_TYPES.map((type) => ( - toggle(type)} - /> - ))} -
-
+ ))} + ); } diff --git a/src/components/ui/checkbox-menu-item.tsx b/src/components/ui/checkbox-menu-item.tsx index 4ef1e5e5..473bde76 100644 --- a/src/components/ui/checkbox-menu-item.tsx +++ b/src/components/ui/checkbox-menu-item.tsx @@ -32,7 +32,6 @@ export function CheckboxMenuItem({ className, )} {...props} - aria-checked={checked} > {left} diff --git a/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx b/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx index d186543a..ee92488d 100644 --- a/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx +++ b/src/test/app/(protected)/workorders/dispatcher-filter.test.tsx @@ -122,4 +122,28 @@ describe("DispatcherFilter (SH-242)", () => { await user.click(within(menu).getByRole("button", { name: "All dispatchers" })); expect(onChange).toHaveBeenLastCalledWith(new Set()); }); + + it("does not put aria-checked on ordinary dispatcher buttons", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + { withAuth: false }, + ); + + await user.click(screen.getByRole("button", { name: "All dispatchers" })); + const popover = screen.getByRole("presentation"); + const items = [ + within(popover).getByRole("button", { name: "All dispatchers" }), + within(popover).getByRole("button", { name: "Unassigned" }), + within(popover).getByRole("button", { name: "Blair Cho" }), + ]; + for (const item of items) { + expect(item).not.toHaveAttribute("aria-checked"); + } + }); }); diff --git a/src/test/app/(protected)/workorders/wo-type-filter.test.tsx b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx index e9cde5af..faa20e5a 100644 --- a/src/test/app/(protected)/workorders/wo-type-filter.test.tsx +++ b/src/test/app/(protected)/workorders/wo-type-filter.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { WoTypeFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import { renderWithProviders } from "@/test/test-utils"; -import { screen, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; function TypeFilterHarness({ initial = new Set(), @@ -144,4 +144,76 @@ describe("WoTypeFilter", () => { .map((item) => item.textContent?.trim()); expect(labels).toEqual(["All types", "PM", "Reactive", "Emergency", "Overdue"]); }); + + it("moves focus into the menu when opened", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + await waitFor(() => { + expect(within(menu).getByRole("menuitemcheckbox", { name: "All types" })).toHaveFocus(); + }); + }); + + it("navigates with Arrow, Home, and End keys", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + const allTypes = within(menu).getByRole("menuitemcheckbox", { name: "All types" }); + const pm = within(menu).getByRole("menuitemcheckbox", { name: "PM" }); + const overdue = within(menu).getByRole("menuitemcheckbox", { name: "Overdue" }); + + await waitFor(() => { + expect(allTypes).toHaveFocus(); + }); + await user.keyboard("{ArrowDown}"); + expect(pm).toHaveFocus(); + await user.keyboard("{End}"); + expect(overdue).toHaveFocus(); + await user.keyboard("{Home}"); + expect(allTypes).toHaveFocus(); + await user.keyboard("{ArrowUp}"); + expect(overdue).toHaveFocus(); + }); + + it("keeps inactive items out of the Tab sequence", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + const items = within(menu).getAllByRole("menuitemcheckbox"); + for (const item of items) { + expect(item).toHaveAttribute("tabindex", "-1"); + } + }); + + it("toggles the focused type from the keyboard without closing the menu", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + const menu = await openTypeMenu(user, /all types/i); + await waitFor(() => { + expect(within(menu).getByRole("menuitemcheckbox", { name: "All types" })).toHaveFocus(); + }); + await user.keyboard("{ArrowDown}"); + await user.keyboard(" "); + expect(within(menu).getByRole("menuitemcheckbox", { name: "PM" })).toHaveAttribute( + "aria-checked", + "true", + ); + expect(screen.getByRole("menu", { name: "Work order types" })).toBeInTheDocument(); + }); + + it("closes the menu on Tab instead of walking each item", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await openTypeMenu(user, /all types/i); + await waitFor(() => { + expect(screen.getByRole("menuitemcheckbox", { name: "All types" })).toHaveFocus(); + }); + await user.keyboard("{Tab}"); + expect(screen.queryByRole("menu", { name: "Work order types" })).not.toBeInTheDocument(); + }); });