mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 08:03:22 +00:00
fix(work-orders): give type filter an accessible menu keyboard contract
Keep aria-checked off shared checkbox buttons so dispatcher rows stay ordinary buttons.
This commit is contained in:
parent
919748a274
commit
efefcf9270
4 changed files with 147 additions and 25 deletions
|
|
@ -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<WOType>): string {
|
|||
return `${selected.size} types selected`;
|
||||
}
|
||||
|
||||
function TypeFilterMenuItem({
|
||||
checked,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<MenuItem
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
dense
|
||||
disableGutters
|
||||
onClick={onClick}
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded px-2.5 py-1.5"
|
||||
>
|
||||
<CheckboxIndicator checked={checked} size="sm" />
|
||||
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
|||
<ChevronDown size={14} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||
</MenuAnchorButton>
|
||||
|
||||
<Popover
|
||||
<Menu
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={close}
|
||||
variant="menu"
|
||||
transitionDuration={0}
|
||||
onClose={(_event, reason) => {
|
||||
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",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div role="menu" aria-label="Work order types" data-testid="wo-type-filter-menu">
|
||||
<CheckboxMenuItem
|
||||
role="menuitemcheckbox"
|
||||
checked={allSelected}
|
||||
label="All types"
|
||||
labelClassName="text-[13px] flex-1 text-left"
|
||||
onClick={() => onChange(new Set())}
|
||||
<TypeFilterMenuItem
|
||||
checked={allSelected}
|
||||
label="All types"
|
||||
onClick={() => onChange(new Set())}
|
||||
/>
|
||||
{WO_TYPES.map((type) => (
|
||||
<TypeFilterMenuItem
|
||||
key={type}
|
||||
checked={selected.has(type)}
|
||||
label={type}
|
||||
onClick={() => toggle(type)}
|
||||
/>
|
||||
{WO_TYPES.map((type) => (
|
||||
<CheckboxMenuItem
|
||||
key={type}
|
||||
role="menuitemcheckbox"
|
||||
checked={selected.has(type)}
|
||||
label={type}
|
||||
labelClassName="text-[13px] flex-1 text-left"
|
||||
onClick={() => toggle(type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ export function CheckboxMenuItem({
|
|||
className,
|
||||
)}
|
||||
{...props}
|
||||
aria-checked={checked}
|
||||
>
|
||||
<CheckboxIndicator checked={checked} size="sm" />
|
||||
{left}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<DispatcherFilter
|
||||
selectedIds={new Set()}
|
||||
onChange={vi.fn()}
|
||||
users={users}
|
||||
currentUserId="u1"
|
||||
/>,
|
||||
{ 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<WOType>(),
|
||||
|
|
@ -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(<TypeFilterHarness />, { 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(<TypeFilterHarness />, { 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(<TypeFilterHarness />, { 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(<TypeFilterHarness />, { 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(<TypeFilterHarness />, { 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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue