fix(vendors): add accessible trade overflow tooltip

This commit is contained in:
Codex Review Integration 2026-09-14 12:26:05 -03:00
parent 99df031f68
commit 0b4dafc868
3 changed files with 132 additions and 15 deletions

View file

@ -409,7 +409,16 @@ test.describe("Vendor directory prototype parity", () => {
);
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
const tradeBadge = page.getByTestId("vendor-trade-badge").first();
await expect(tradeBadge).toHaveText("+1");
await tradeBadge.hover();
await expect(page.getByRole("tooltip")).toHaveText("Backflow Preventers");
await tradeBadge.focus();
await expect(tradeBadge).toBeFocused();
await tradeBadge.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await tradeBadge.press("Enter");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("2 active technicians")).toBeVisible();
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");

View file

@ -51,6 +51,12 @@ function stopPropagation(event: MouseEvent): void {
event.stopPropagation();
}
function stopTradeBadgeKeyDown(event: KeyboardEvent): void {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
}
interface TruncatedTextProps {
value: string;
testId: string;
@ -132,7 +138,8 @@ interface VendorTableRowProps {
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
const trades = splitTrades(row.tradeSpecialties);
const primaryTrade = trades[0] ?? "—";
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
const omittedTrades = trades.slice(1);
const extraTrades = omittedTrades.length;
const { locationText, phoneText } = getVendorDisplayValues(row);
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
@ -204,19 +211,27 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && (
<Box
component="span"
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
<Tooltip title={omittedTrades.join(", ")} placement="top" arrow>
<Box
component="span"
tabIndex={0}
aria-label={`+${extraTrades} more ${extraTrades === 1 ? "trade" : "trades"}: ${omittedTrades.join(", ")}`}
data-testid="vendor-trade-badge"
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
onClick={stopPropagation}
onKeyDown={stopTradeBadgeKeyDown}
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
</Tooltip>
)}
</Stack>
)}

View file

@ -244,6 +244,99 @@ describe("VendorsListPage", () => {
);
});
it("shows omitted trade names on the +N badge tooltip on hover and keyboard focus", async () => {
setupDefaults();
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
expect(badge).toHaveTextContent("+2");
expect(screen.getByText("Backflow Preventers")).toBeInTheDocument();
expect(badge).toHaveAttribute("aria-label", "+2 more trades: Water Heaters, Drain Cleaning");
await userEvent.hover(badge);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("Water Heaters, Drain Cleaning");
expect(tooltip).not.toHaveTextContent(/Backflow/);
await userEvent.unhover(badge);
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
fireEvent.keyDown(document, { key: "Tab" });
badge.focus();
expect(badge).toHaveFocus();
expect(await screen.findByRole("tooltip")).toHaveTextContent("Water Heaters, Drain Cleaning");
});
it("does not show the +N badge when the vendor has a single trade", () => {
setupDefaults();
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([activeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
expect(screen.queryByTestId("vendor-trade-badge")).not.toBeInTheDocument();
expect(screen.getByText("Backflow Preventers")).toBeInTheDocument();
});
it("does not open the vendor detail when the +N badge is clicked or activated by keyboard", async () => {
setupDefaults();
useVendorCompanyRoster.mockReturnValue({
data: activeRoster,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
});
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters, Drain Cleaning",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
await userEvent.click(badge);
expect(screen.queryByRole("heading", { level: 2, name: "Adam Whyte" })).not.toBeInTheDocument();
badge.focus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{Space}");
expect(screen.queryByRole("heading", { level: 2, name: "Adam Whyte" })).not.toBeInTheDocument();
});
it("relies on the MUI tooltip instead of a native title attribute on the +N badge", () => {
setupDefaults();
const multiTradeVendor: VendorListItem = {
...activeVendor,
tradeSpecialties: "Backflow Preventers, Water Heaters",
};
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
if (params.pageSize === 1) return result([], 1);
return params.isActive ? result([multiTradeVendor], 1) : result([], 0);
});
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
const badge = screen.getByTestId("vendor-trade-badge");
expect(badge).not.toHaveAttribute("title");
expect(badge).toHaveAttribute("tabIndex", "0");
});
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
setupDefaults();
const pageVendors = Array.from({ length: 3 }, (_, index) => ({