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:
parent
abaa39e4a2
commit
3a22d4eedc
6 changed files with 94 additions and 12 deletions
|
|
@ -10,6 +10,17 @@ describe("CopyButton", () => {
|
||||||
expect(screen.getByText("⎘")).toBeInTheDocument();
|
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 () => {
|
it("triggers a click without throwing when clipboard is unavailable", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(<CopyButton value="hello" />);
|
renderWithMantine(<CopyButton value="hello" />);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,15 @@ export interface CopyButtonProps {
|
||||||
* rows (e.g. beside a Code block). Icon size is unchanged.
|
* rows (e.g. beside a Code block). Icon size is unchanged.
|
||||||
*/
|
*/
|
||||||
flush?: boolean;
|
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({
|
const CopyActionIcon = ActionIcon.withProps({
|
||||||
|
|
@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
|
||||||
fz: 24,
|
fz: 24,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
|
||||||
|
const qualifier = label ? ` ${label}` : "";
|
||||||
return (
|
return (
|
||||||
<MantineCopyButton value={value}>
|
<MantineCopyButton value={value}>
|
||||||
{({ copied, copy }) => (
|
{({ copied, copy }) => (
|
||||||
|
|
@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
||||||
<CopyActionIcon
|
<CopyActionIcon
|
||||||
color={copied ? "green" : "var(--inspector-text-primary)"}
|
color={copied ? "green" : "var(--inspector-text-primary)"}
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
aria-label={copied ? "Copied" : "Copy"}
|
aria-label={(copied ? "Copied" : "Copy") + qualifier}
|
||||||
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
||||||
>
|
>
|
||||||
{copied ? "\u2713" : "\u2398"}
|
{copied ? "\u2713" : "\u2398"}
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,9 @@ export const WithOAuthIdToken: Story = {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
expect(canvas.getByText("ID Token")).toBeInTheDocument();
|
expect(canvas.getByText("ID Token")).toBeInTheDocument();
|
||||||
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
|
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
|
||||||
await userEvent.click(canvas.getByRole("button", { name: "Decode JWT" }));
|
await userEvent.click(
|
||||||
|
canvas.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||||
|
);
|
||||||
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
|
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -495,6 +495,41 @@ describe("ConnectionInfoContent", () => {
|
||||||
expect(screen.getByText("id-token-456")).toBeInTheDocument();
|
expect(screen.getByText("id-token-456")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives the two token rows' controls distinct accessible names", () => {
|
||||||
|
// Both rows carry a copy control, and both tokens here are JWTs so both
|
||||||
|
// carry a decode toggle. Screen-reader button navigation has only the
|
||||||
|
// accessible name to go on, so the four must not collide (#2019 review).
|
||||||
|
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
oauth={{
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
accessToken: jwt,
|
||||||
|
idToken: jwt,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.map((button) => button.getAttribute("aria-label") ?? button.textContent)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
expect(new Set(names).size).toBe(names.length);
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
"Copy Access Token",
|
||||||
|
"Copy ID Token",
|
||||||
|
"Decode JWT for Access Token",
|
||||||
|
"Decode JWT for ID Token",
|
||||||
|
]) {
|
||||||
|
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the ID Token row on its own when there is no access token", () => {
|
it("renders the ID Token row on its own when there is no access token", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ConnectionInfoContent
|
<ConnectionInfoContent
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ describe("OAuthTokenField", () => {
|
||||||
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
|
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Copy Access Token" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
||||||
|
|
@ -20,11 +22,15 @@ describe("OAuthTokenField", () => {
|
||||||
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
|
||||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
|
||||||
|
);
|
||||||
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Show token" }));
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Show token for Access Token" }),
|
||||||
|
);
|
||||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -37,7 +43,7 @@ describe("OAuthTokenField", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: "Decode JWT" }),
|
screen.queryByRole("button", { name: "Decode JWT for Access Token" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -50,8 +56,10 @@ describe("OAuthTokenField", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
await user.click(
|
||||||
await user.click(screen.getByRole("button", { name: "Copy" }));
|
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
|
||||||
|
|
||||||
expect(writeText).toHaveBeenCalledWith(
|
expect(writeText).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('"sub": "user"'),
|
expect.stringContaining('"sub": "user"'),
|
||||||
|
|
@ -68,7 +76,7 @@ describe("OAuthTokenField", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
await user.click(screen.getByRole("button", { name: "Copy" }));
|
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
|
||||||
|
|
||||||
expect(writeText).toHaveBeenCalledWith(jwt);
|
expect(writeText).toHaveBeenCalledWith(jwt);
|
||||||
});
|
});
|
||||||
|
|
@ -101,10 +109,25 @@ describe("OAuthTokenField", () => {
|
||||||
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||||
|
);
|
||||||
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("qualifies the decode and copy accessible names with the row label", () => {
|
||||||
|
// Two rows can render simultaneously (Access Token + ID Token), so the
|
||||||
|
// controls must not share accessible names — see #2019 review.
|
||||||
|
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Copy ID Token" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("omits the clear action when no handler is given", () => {
|
it("omits the clear action when no handler is given", () => {
|
||||||
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ export function OAuthTokenField({
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={() => setShowDecoded((open) => !open)}
|
onClick={() => setShowDecoded((open) => !open)}
|
||||||
aria-pressed={showDecoded}
|
aria-pressed={showDecoded}
|
||||||
|
aria-label={`${showDecoded ? "Show token" : "Decode JWT"} for ${label}`}
|
||||||
>
|
>
|
||||||
{showDecoded ? "Show token" : "Decode JWT"}
|
{showDecoded ? "Show token" : "Decode JWT"}
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
@ -130,7 +131,7 @@ export function OAuthTokenField({
|
||||||
)}
|
)}
|
||||||
</TokenCode>
|
</TokenCode>
|
||||||
</TokenColumn>
|
</TokenColumn>
|
||||||
<CopyButton value={copyValue} flush />
|
<CopyButton value={copyValue} flush label={label} />
|
||||||
</TokenRow>
|
</TokenRow>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue