SH-279: replace Vendor trade overflow title with MUI Tooltip (#187)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run

* fix(vendors): add accessible trade overflow tooltip

* refactor(vendors): satisfy row maintainability gate

---------

Co-authored-by: Codex Review Integration <codex-review@local.invalid>
This commit is contained in:
Alexandre Brandizzi 2026-09-15 16:23:14 -03:00 • committed by GitHub
parent 80be702733
commit 861f820715
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 155 additions and 26 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;
@ -129,10 +135,54 @@ interface VendorTableRowProps {
onOpenEdit: (row: VendorListItem) => void;
}
function VendorTradeCell({
primaryTrade,
omittedTrades,
}: {
primaryTrade: string;
omittedTrades: string[];
}) {
const extraTrades = omittedTrades.length;
return (
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && (
<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>
)}
</TableCell>
);
}
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 { locationText, phoneText } = getVendorDisplayValues(row);
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
@ -197,30 +247,7 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
color="var(--color-muted-foreground)"
/>
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{primaryTrade === "—" ? (
"—"
) : (
<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>
)}
</Stack>
)}
</TableCell>
<VendorTradeCell primaryTrade={primaryTrade} omittedTrades={omittedTrades} />
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
<TruncatedText
value={phoneText || "—"}

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) => ({