fix(ui): name the popover copy buttons after the field they copy

The shared user popover copied alias, email and ID through three copy
buttons that all announced themselves as "Copy ID", so a screen reader
could not tell them apart. IdCell now takes the label, defaulting to the
old text everywhere else.

Also drops the closest("tr") the new link tests used, which put the
testing-library/no-node-access budget over its ceiling, and asserts the
sentinel row leaves User Email and the admin badge unlinked too.

Claude-Session: https://claude.ai/code/session_01NfwfQhamRNnSqgXMUjf3h4
This commit is contained in:
ryan-crabbe-berri 2026-09-10 18:31:19 -07:00
parent 56e2d8846d
commit 06b259e092
4 changed files with 23 additions and 11 deletions

View file

@ -31,7 +31,7 @@ export function UserPopoverCell({ userAlias, userEmail, userId, width }: UserPop
<div key={label} className="flex flex-col min-w-0">
<span className="text-muted-foreground">{label}</span>
{value ? (
<IdCell value={value} variant="plain" copyable className="max-w-full" />
<IdCell value={value} variant="plain" copyable copyLabel={`Copy ${label}`} className="max-w-full" />
) : (
<span className="font-mono">-</span>
)}

View file

@ -71,6 +71,14 @@ describe("IdCell", () => {
expect(rowClick).not.toHaveBeenCalled();
});
it("names the copy button after the field it copies", async () => {
const user = userEvent.setup();
render(<IdCell value="alice@example.com" copyable copyLabel="Copy User Email" />);
expect(screen.queryByRole("button", { name: "Copy ID" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Copy User Email" }));
expect(copyToClipboardMock).toHaveBeenCalledWith("alice@example.com");
});
it("passes dataTestId through to the id element", () => {
render(<IdCell value="k-1" dataTestId="key-id-cell" />);
expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1");

View file

@ -15,6 +15,7 @@ interface IdCellProps {
variant?: IdCellVariant;
onClick?: (value: string) => void;
copyable?: boolean;
copyLabel?: string;
truncate?: boolean;
fallback?: string;
tooltip?: React.ReactNode;
@ -39,6 +40,7 @@ export function IdCell({
variant = "pill",
onClick,
copyable = false,
copyLabel = "Copy ID",
truncate = true,
fallback = "-",
tooltip,
@ -80,7 +82,7 @@ export function IdCell({
{withTooltip}
<button
type="button"
aria-label="Copy ID"
aria-label={copyLabel}
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();

View file

@ -396,7 +396,8 @@ describe("TeamVirtualKeysTable", () => {
refetch: vi.fn(),
} as any);
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} organization={organization} />);
return (await screen.findByText(key.key_alias as string)).closest("tr") as HTMLElement;
await screen.findByText(key.key_alias as string);
return screen.getByRole("row", { name: new RegExp(key.key_alias as string) });
};
it("points the Organization ID cell at the org's detail page", async () => {
@ -433,17 +434,18 @@ describe("TeamVirtualKeysTable", () => {
it("leaves the default_user_id placeholder unlinked in the User ID and Created By cells", async () => {
const placeholder = { user_id: "default_user_id", user_email: "admin@example.com", user_alias: "Proxy Admin" };
const row = await renderRow(
createMockKey({
user_id: placeholder.user_id,
user: placeholder,
created_by: placeholder.user_id,
created_by_user: placeholder,
}),
);
const ownedAndCreatedByPlaceholder = {
user_id: placeholder.user_id,
user: placeholder,
created_by: placeholder.user_id,
created_by_user: placeholder,
};
const row = await renderRow(createMockKey(ownedAndCreatedByPlaceholder));
expect(within(row).getByText("Default Proxy Admin")).toBeInTheDocument();
expect(within(row).getByText("Proxy Admin")).toBeInTheDocument();
expect(within(row).queryByRole("link", { name: "Proxy Admin" })).not.toBeInTheDocument();
expect(within(row).queryByRole("link", { name: placeholder.user_email })).not.toBeInTheDocument();
expect(within(row).queryByRole("link", { name: "Default Proxy Admin" })).not.toBeInTheDocument();
});
});
});