Skip to content

Copy buttons: show confirmation on click only #72

Description

@a-effort

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

  • ui/copy-button.tsx: remove hoverOpen, onOpenChange, delayDuration, side; portal the bubble; keep the status region mounted at all times
  • package.json: @floating-ui/react-dom as a direct dependency
  • ui/code-block.tsx: i18n the "Copy code" default; drop the unused copyAriaLabel prop
  • resources/ResourcePreviewResult.tsx:166: use common.copyValue rather than mimeType || "content"
  • Root TooltipProvider in App.tsx stays: sidebar, card-tag, and MCP servers use it
  • Tests: hover and focus produce no bubble; the status region exists before any copy

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.copyToolId"Copy tool ID for {name}"
  • resources.table.copyUri"Copy URI for {name}"
  • resources.table.copyResourceId"Copy resource ID for {name}"
  • MCPServerDetailsPanel.tsx:508 and VirtualServerDetailsPanel.tsx:605: stop passing raw identifiers into common.copyValue; pass the field noun
  • i18n the hardcoded English passed into common.copyValue: VirtualServerDetailsPanel.tsx:677 ("server ID"), :680 ("URL"), MCPServerDetailsPanel.tsx:572 ("UUID"), :577 ("URL"), and ToolSchemaDialog.tsx:44, which passes title.toLowerCase() where title is a hardcoded "Input" / "Output" (:83-84)

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.

  • TokenCreatedDialog.tsx:47-53: revert the redirect.
  • ToolSchemaDialog.tsx:59: keep the redirect.
  • Update both comments, which cite the tooltip mechanism by name
  • Rename ToolSchemaDialog.test.tsx:275 ("...instead of dismissing a copy button tooltip first")
  • Add an initial-focus test to TokenCreatedDialog, which has none today

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions