mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 21:42:06 +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 { useMemo } from "react";
|
||||||
import { Popover } from "@mui/material";
|
import { Menu, MenuItem } from "@mui/material";
|
||||||
import { ChevronDown, Tag } from "lucide-react";
|
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 { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||||
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
@ -22,6 +22,30 @@ function typeFilterLabel(selected: Set<WOType>): string {
|
||||||
return `${selected.size} types selected`;
|
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) {
|
export function WoTypeFilter({ selected, onChange, disabled = false }: WoTypeFilterProps) {
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
const allSelected = selected.size === 0;
|
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)" }} />
|
<ChevronDown size={14} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||||
</MenuAnchorButton>
|
</MenuAnchorButton>
|
||||||
|
|
||||||
<Popover
|
<Menu
|
||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={close}
|
variant="menu"
|
||||||
|
transitionDuration={0}
|
||||||
|
onClose={(_event, reason) => {
|
||||||
|
if (reason === "backdropClick" || reason === "escapeKeyDown" || reason === "tabKeyDown") {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
paper: {
|
paper: {
|
||||||
className: "mt-1 w-[180px] rounded-lg border border-border p-1.5 shadow-md",
|
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">
|
<TypeFilterMenuItem
|
||||||
<CheckboxMenuItem
|
checked={allSelected}
|
||||||
role="menuitemcheckbox"
|
label="All types"
|
||||||
checked={allSelected}
|
onClick={() => onChange(new Set())}
|
||||||
label="All types"
|
/>
|
||||||
labelClassName="text-[13px] flex-1 text-left"
|
{WO_TYPES.map((type) => (
|
||||||
onClick={() => onChange(new Set())}
|
<TypeFilterMenuItem
|
||||||
|
key={type}
|
||||||
|
checked={selected.has(type)}
|
||||||
|
label={type}
|
||||||
|
onClick={() => toggle(type)}
|
||||||
/>
|
/>
|
||||||
{WO_TYPES.map((type) => (
|
))}
|
||||||
<CheckboxMenuItem
|
</Menu>
|
||||||
key={type}
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
checked={selected.has(type)}
|
|
||||||
label={type}
|
|
||||||
labelClassName="text-[13px] flex-1 text-left"
|
|
||||||
onClick={() => toggle(type)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ export function CheckboxMenuItem({
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
aria-checked={checked}
|
|
||||||
>
|
>
|
||||||
<CheckboxIndicator checked={checked} size="sm" />
|
<CheckboxIndicator checked={checked} size="sm" />
|
||||||
{left}
|
{left}
|
||||||
|
|
|
||||||
|
|
@ -122,4 +122,28 @@ describe("DispatcherFilter (SH-242)", () => {
|
||||||
await user.click(within(menu).getByRole("button", { name: "All dispatchers" }));
|
await user.click(within(menu).getByRole("button", { name: "All dispatchers" }));
|
||||||
expect(onChange).toHaveBeenLastCalledWith(new Set());
|
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 { WoTypeFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-filter";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
import { screen, within } from "@testing-library/react";
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
||||||
function TypeFilterHarness({
|
function TypeFilterHarness({
|
||||||
initial = new Set<WOType>(),
|
initial = new Set<WOType>(),
|
||||||
|
|
@ -144,4 +144,76 @@ describe("WoTypeFilter", () => {
|
||||||
.map((item) => item.textContent?.trim());
|
.map((item) => item.textContent?.trim());
|
||||||
expect(labels).toEqual(["All types", "PM", "Reactive", "Emergency", "Overdue"]);
|
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