fix: disambiguate the two token rows' controls for screen readers (#2019)

Copilot review round 1. With an ID Token row alongside the Access Token
row, both can render a "Decode JWT" toggle and a "Copy" control, and the
four buttons shared two accessible names — button-by-button screen-reader
navigation could not tell which token a control acted on. That collision
is introduced by this PR: before it there was only ever one token row.

- `CopyButton` takes an optional `label` naming what is being copied,
  used for the accessible name only ("Copy ID Token"). The visible
  tooltip stays "Copy", and the name keeps "Copy"/"Copied" as its first
  word so it still contains the visible text (WCAG 2.5.3 Label in Name).
- `OAuthTokenField` qualifies its decode toggle the same way ("Decode JWT
  for Access Token") and passes its row label down to `CopyButton`.

The visible UI is unchanged — this is accessible-name only.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 21:13:11 -04:00
commit 3a22d4eedc
2 changed files with 94 additions and 12 deletions

View file

@ -10,6 +10,17 @@ describe("CopyButton", () => {
expect(screen.getByText("⎘")).toBeInTheDocument();
});
it("names itself 'Copy' with no label, and qualifies the name when given one", () => {
const { unmount } = renderWithMantine(<CopyButton value="hello" />);
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
unmount();
renderWithMantine(<CopyButton value="hello" label="ID Token" />);
expect(
screen.getByRole("button", { name: "Copy ID Token" }),
).toBeInTheDocument();
});
it("triggers a click without throwing when clipboard is unavailable", async () => {
const user = userEvent.setup();
renderWithMantine(<CopyButton value="hello" />);

View file

@ -11,6 +11,15 @@ export interface CopyButtonProps {
* rows (e.g. beside a Code block). Icon size is unchanged.
*/
flush?: boolean;
/**
* Names what is being copied, for the accessible label only ("Copy ID
* Token"). Set it when more than one CopyButton can appear in the same
* region, so button-by-button screen-reader navigation can tell them apart;
* a lone control needs no qualifier. The visible tooltip stays "Copy" either
* way, and the accessible name keeps "Copy"/"Copied" as its first word so it
* still contains the visible text (WCAG 2.5.3).
*/
label?: string;
}
const CopyActionIcon = ActionIcon.withProps({
@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
fz: 24,
});
export function CopyButton({ value, flush = false }: CopyButtonProps) {
export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
const qualifier = label ? ` ${label}` : "";
return (
<MantineCopyButton value={value}>
{({ copied, copy }) => (
@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
<CopyActionIcon
color={copied ? "green" : "var(--inspector-text-primary)"}
onClick={copy}
aria-label={copied ? "Copied" : "Copy"}
aria-label={(copied ? "Copied" : "Copy") + qualifier}
{...(flush && { p: 0, h: "auto", w: "auto" })}
>
{copied ? "\u2713" : "\u2398"}