Builds on #61, which consolidated 17 copy buttons onto a shared CopyButton and gave every one of them the same feedback. With that consistency in place, this refines the interaction itself.
Change
The tooltip currently does two jobs: a"Copy X" hint on hover, and the "Copied!" confirmation on click. Reserve it for the confirmation:
This matches the interaction in mcp-context-forge #5452 (client/src/components/ui/code-block.tsx on 5448-preview-prompt), with the icon swap and error state added.
Reserving hover for the confirmation also frees it for truncated values (see #73).
Approach
Render the bubble with @floating-ui/react-dom + createPortal rather than Radix Tooltip:
- Radix tooltip content is a
DismissableLayer, and only the topmost layer answers Escape (react-dismissable-layer/dist/index.mjs:59-64). Inside ToolSchemaDialog and TokenCreatedDialog that means Escape is swallowed while the bubble is up.
role="tooltip" describes a control; it isn't the right semantic for a transient status. Dropping it leaves role="status" as the single announcement channel.
- Floating-ui is already in
node_modules beneath Radix. It needs promoting to a direct dependency. A plain absolute span was considered but clips against table.tsx:7, dialog.tsx:39, and TestConnectionPanel.tsx:421.
const { refs, floatingStyles } = useFloating({
placement: "top",
middleware: [offset(6), flip(), shift({ padding: 8 })],
});
<Button ref={refs.setReference} aria-label={label} onClick={…}>
{status === "copied" ? <Check className="text-emerald-600 dark:text-emerald-400" /> : <Copy />}
</Button>
{status !== "idle" && createPortal(
<span ref={refs.setFloating} style={floatingStyles} aria-hidden="true" …>
{status === "copied" ? copiedLabel : failedLabel}
</span>, document.body)}
{/* always mounted, so the text change is what gets announced */}
<span role="status" className="sr-only">
{status === "copied" ? copiedLabel : status === "error" ? failedLabel : ""}
</span>
The status region is mounted unconditionally. A live region that appears with its text already inside it is generally not announced by NVDA or JAWS, and aria-label stays pinned, so this region is the only non-visual signal.
Scope
Accessible labels
Three variations exist across the tables today: bare ("Copy tool ID", identical for all 40 rows), value-interpolating ("Copy {uri}", which reads out a full URL), and field-plus-row ("Copy UUID for {name}"). The last is the one to standardize on.
Rule: the label names which field and which row, using the row's human-readable name, never the raw value, unless the raw value is the human-readable name.
tools.table.copyName already satisfies the rule: the value is the name, and prompts.details.table.copyPromptId / mcpServer.table.copyUuid are already in the target shape.
Focus placement
The onOpenAutoFocus redirects were added because focusing the copy button opened a tooltip, so the first Escape closed the tooltip instead of the dialog. With no tooltip on focus, the redirect only decides where focus starts, and the two dialogs need different starting points.
Acceptance
- Hovering or tabbing to a copy button shows nothing
- Clicking shows the bubble and the Check swap; both clear after 1.5s
- Escape closes either dialog on the first press, including right after a copy
- Accessible name and description are unchanged by copying
- No user-visible English strings in
es-ES / pt-BR
Builds on #61, which consolidated 17 copy buttons onto a shared
CopyButtonand gave every one of them the same feedback. With that consistency in place, this refines the interaction itself.Change
The tooltip currently does two jobs: a"Copy X" hint on hover, and the "Copied!" confirmation on click. Reserve it for the confirmation:
labelreverts toaria-labelonly, not visible textThis matches the interaction in
mcp-context-forge#5452 (client/src/components/ui/code-block.tsxon5448-preview-prompt), with the icon swap and error state added.Reserving hover for the confirmation also frees it for truncated values (see #73).
Approach
Render the bubble with
@floating-ui/react-dom+createPortalrather than RadixTooltip:DismissableLayer, and only the topmost layer answers Escape (react-dismissable-layer/dist/index.mjs:59-64). InsideToolSchemaDialogandTokenCreatedDialogthat means Escape is swallowed while the bubble is up.role="tooltip"describes a control; it isn't the right semantic for a transient status. Dropping it leavesrole="status"as the single announcement channel.node_modulesbeneath Radix. It needs promoting to a direct dependency. A plain absolute span was considered but clips againsttable.tsx:7,dialog.tsx:39, andTestConnectionPanel.tsx:421.The status region is mounted unconditionally. A live region that appears with its text already inside it is generally not announced by NVDA or JAWS, and
aria-labelstays pinned, so this region is the only non-visual signal.Scope
ui/copy-button.tsx: removehoverOpen,onOpenChange,delayDuration,side; portal the bubble; keep the status region mounted at all timespackage.json:@floating-ui/react-domas a direct dependencyui/code-block.tsx: i18n the"Copy code"default; drop the unusedcopyAriaLabelpropresources/ResourcePreviewResult.tsx:166: usecommon.copyValuerather thanmimeType || "content"TooltipProviderinApp.tsxstays: sidebar, card-tag, and MCP servers use itAccessible labels
Three variations exist across the tables today: bare (
"Copy tool ID", identical for all 40 rows), value-interpolating ("Copy {uri}", which reads out a full URL), and field-plus-row ("Copy UUID for {name}"). The last is the one to standardize on.Rule: the label names which field and which row, using the row's human-readable name, never the raw value, unless the raw value is the human-readable name.
tools.table.copyToolId→"Copy tool ID for {name}"resources.table.copyUri→"Copy URI for {name}"resources.table.copyResourceId→"Copy resource ID for {name}"MCPServerDetailsPanel.tsx:508andVirtualServerDetailsPanel.tsx:605: stop passing raw identifiers intocommon.copyValue; pass the field nouncommon.copyValue:VirtualServerDetailsPanel.tsx:677("server ID"),:680("URL"),MCPServerDetailsPanel.tsx:572("UUID"),:577("URL"), andToolSchemaDialog.tsx:44, which passestitle.toLowerCase()wheretitleis a hardcoded"Input"/"Output"(:83-84)tools.table.copyNamealready satisfies the rule: the value is the name, andprompts.details.table.copyPromptId/mcpServer.table.copyUuidare already in the target shape.Focus placement
The onOpenAutoFocus redirects were added because focusing the copy button opened a tooltip, so the first Escape closed the tooltip instead of the dialog. With no tooltip on focus, the redirect only decides where focus starts, and the two dialogs need different starting points.
TokenCreatedDialog.tsx:47-53: revert the redirect.ToolSchemaDialog.tsx:59: keep the redirect.ToolSchemaDialog.test.tsx:275("...instead of dismissing a copy button tooltip first")TokenCreatedDialog, which has none todayAcceptance
es-ES/pt-BR