fix(webapp): use native controls for inline actions (#4698)

## Summary

Replace mouse-only dashboard actions with native buttons.

Copy, remove, and stop-generation controls now expose keyboard focus and
accessible names. Hover-revealed actions remain mounted so keyboard
users can discover them, and a decorative clipboard icon no longer
captures clicks.

Base: [#4697](https://github.com/triggerdotdev/trigger.dev/pull/4697)
This commit is contained in:
Chris Arderne
2026-08-19 16:35:45 +01:00
committed by GitHub
parent 3d156dfd75
commit 73c8a4d975
8 changed files with 180 additions and 134 deletions
+6 -2
View File
@@ -543,8 +543,12 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) {
/>
{isGeneratingAnswer ? (
<SimpleTooltip
asChild
tabbable
button={
<span
<button
type="button"
aria-label="Stop generating"
onClick={() => stopGeneration()}
className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center"
>
@@ -553,7 +557,7 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) {
className="absolute inset-0 animate-spin"
hoverEffect
/>
</span>
</button>
}
content="Stop generating"
/>
@@ -843,6 +843,37 @@ function CopyableCell({
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(value);
// The button (with its aria-label) always sits in the same position in the tree, wrapped by
// the same SimpleTooltip, so it is never unmounted/remounted on hover (which would drop
// keyboard focus). The tooltip is left uncontrolled so Radix opens it only when the pointer or
// keyboard focus is actually on the button, not whenever the pointer is anywhere in this
// virtualized grid's cell. `focus-visible:` (not `focus:`) ensures keyboard focus reveals the
// button without leaving it visible after a mouse click moves outside the cell.
const copyButton = (
<button
type="button"
aria-label={copied ? "Copied" : "Copy"}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
copy();
}}
className={cn(
"absolute right-1 top-1/2 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded border border-border-bright bg-background-hover transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100",
isHovered ? "opacity-100" : "pointer-events-none opacity-0",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</button>
);
return (
<div
className={cn(
@@ -856,37 +887,13 @@ function CopyableCell({
onMouseLeave={() => setIsHovered(false)}
>
<span className="flex items-center truncate">{children}</span>
{isHovered && (
<span
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
copy();
}}
className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer"
>
<SimpleTooltip
button={
<span
className={cn(
"flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
disableHoverableContent
/>
</span>
)}
<SimpleTooltip
asChild
tabbable
button={copyButton}
content={copied ? "Copied!" : "Copy"}
disableHoverableContent
/>
</div>
);
}
@@ -60,7 +60,6 @@ const NO_AS_CHILD_BASELINE = new Set([
"app/components/GitMetadata.tsx::LinkButton",
"app/components/code/TSQLResultsTable.tsx::TextLink",
"app/components/integrations/VercelLink.tsx::LinkButton",
"app/components/primitives/CopyButton.tsx::Button",
"app/components/runs/v3/RunTag.tsx::Link",
"app/components/runs/v3/TaskRunsTable.tsx::DialogTrigger",
"app/routes/account.tokens/route.tsx::DialogTrigger",
@@ -90,6 +89,16 @@ function attrOf(node: JsxNode, name: string) {
return open.attributes.properties.find((p) => ts.isJsxAttribute(p) && p.name.getText() === name);
}
function hasStaticTrueAttribute(node: JsxNode, name: string): boolean {
const attribute = attrOf(node, name);
if (!attribute || !ts.isJsxAttribute(attribute)) return false;
if (!attribute.initializer) return true;
return (
ts.isJsxExpression(attribute.initializer) &&
attribute.initializer.expression?.kind === ts.SyntaxKind.TrueKeyword
);
}
/** Text anywhere under the element, ignoring an expression that can render nothing. */
function hasText(node: TsNode): boolean {
if (!ts.isJsxElement(node)) return false;
@@ -179,7 +188,7 @@ function scanFile(file: string, relative: string): Violation[] {
const initializer =
buttonAttr && ts.isJsxAttribute(buttonAttr) ? buttonAttr.initializer : undefined;
if (initializer && ts.isJsxExpression(initializer)) {
const asChild = !!attrOf(node, "asChild");
const asChild = hasStaticTrueAttribute(node, "asChild");
for (const trigger of resolve(initializer.expression).flatMap(triggersIn)) {
const named =
!!attrOf(trigger, "aria-label") ||
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useState } from "react";
import { cn } from "~/utils/cn";
import { CopyButton } from "./CopyButton";
@@ -116,7 +116,7 @@ export function ClipboardField({
fullWidth = true,
}: ClipboardFieldProps) {
const [isSecure, setIsSecure] = useState(secure !== undefined && secure);
const inputIcon = useRef<HTMLInputElement>(null);
const inputId = useId();
const { container, input, buttonVariant, button, size } = variants[variant];
useEffect(() => {
@@ -128,16 +128,13 @@ export function ClipboardField({
return (
<span className={cn(container, fullWidth ? "w-full" : "max-w-fit", className)}>
{icon && (
<span
onClick={() => inputIcon.current && inputIcon.current.focus()}
className="flex items-center pl-1"
>
<label htmlFor={inputId} className="flex items-center pl-1">
{icon}
</span>
</label>
)}
<input
id={inputId}
type="text"
ref={inputIcon}
value={isSecure ? maskedValue : value}
readOnly={true}
className={cn(
@@ -44,58 +44,69 @@ export function CopyButton({
const { icon: iconSize, button: buttonSize } = sizes[size];
const button =
variant === "icon" ? (
<span
onClick={copy}
className={cn(
buttonSize,
"flex shrink-0 items-center justify-center rounded border border-border-bright bg-background-hover",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright",
buttonClassName
)}
>
{copied ? (
<ClipboardCheckIcon className={iconSize} />
) : (
<ClipboardIcon className={iconSize} />
)}
if (variant === "button") {
return (
<span className={className}>
<Button
variant={`${buttonVariant}/${size === "extra-small" ? "small" : size}`}
onClick={copy}
className={cn("shrink-0", buttonClassName)}
tooltip={showTooltip ? (copied ? "Copied!" : "Copy") : undefined}
aria-label={children ? undefined : copied ? "Copied" : "Copy"}
LeadingIcon={
copied ? (
<ClipboardCheckIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
)}
/>
) : (
<ClipboardIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
)}
/>
)
}
>
{children}
</Button>
</span>
) : (
<Button
variant={`${buttonVariant}/${size === "extra-small" ? "small" : size}`}
onClick={copy}
className={cn("shrink-0", buttonClassName)}
LeadingIcon={
copied ? (
<ClipboardCheckIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
)}
/>
) : (
<ClipboardIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
)}
/>
)
}
>
{children}
</Button>
);
}
if (!showTooltip) return <span className={className}>{button}</span>;
const iconButton = (
<button
type="button"
aria-label={copied ? "Copied" : "Copy"}
onClick={copy}
className={cn(
buttonSize,
"flex shrink-0 items-center justify-center rounded border border-border-bright bg-background-hover",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright",
buttonClassName
)}
>
{copied ? (
<ClipboardCheckIcon className={iconSize} />
) : (
<ClipboardIcon className={iconSize} />
)}
</button>
);
if (!showTooltip) return <span className={className}>{iconButton}</span>;
return (
<span className={className}>
<SimpleTooltip
button={button}
asChild
tabbable
button={iconButton}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
@@ -39,7 +39,11 @@ export function CopyableText({
if (resolvedVariant === "icon-right") {
const iconButton = (
<span
<button
type="button"
aria-label={copied ? "Copied" : "Copy"}
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover",
asChild && "p-1",
@@ -53,7 +57,7 @@ export function CopyableText({
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
</button>
);
return (
@@ -72,24 +76,23 @@ export function CopyableText({
{value}
</span>
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute top-0 z-10 size-6 font-sans",
"absolute top-0 z-10 flex size-6 font-sans transition-opacity has-focus-visible:pointer-events-auto has-focus-visible:opacity-100",
// Truncated values reserve a right gutter, so the button sits inside it
truncate ? "right-0" : "-right-6",
isHovered ? "flex" : "hidden"
isHovered ? "opacity-100" : "pointer-events-none opacity-0"
)}
>
{hideTooltip ? (
iconButton
) : (
<SimpleTooltip
asChild
tabbable
button={iconButton}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
)}
</span>
+38 -31
View File
@@ -478,6 +478,37 @@ export const CopyableTableCell = forwardRef<HTMLTableCellElement, CopyableTableC
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(value);
// The button (with its aria-label) always sits in the same position in the tree, wrapped by
// the same SimpleTooltip, so it is never unmounted/remounted on hover (which would drop
// keyboard focus). The tooltip is left uncontrolled so Radix opens it only when the pointer
// or keyboard focus is actually on the button, not whenever the pointer is anywhere in the cell.
// `focus-visible:` reveals keyboard focus without leaving the button visible after a mouse click
// moves outside the cell.
const copyButton = (
<button
type="button"
aria-label={copied ? "Copied" : "Copy"}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
copy();
}}
className={cn(
"absolute -right-2 top-1/2 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded border border-border-bright bg-background-hover transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100",
isHovered ? "opacity-100" : "pointer-events-none opacity-0",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</button>
);
return (
<TableCell ref={ref} className={className} {...props}>
<div
@@ -486,37 +517,13 @@ export const CopyableTableCell = forwardRef<HTMLTableCellElement, CopyableTableC
onMouseLeave={() => setIsHovered(false)}
>
{children}
{isHovered && (
<span
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
copy();
}}
className="absolute -right-2 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer"
>
<SimpleTooltip
button={
<span
className={cn(
"flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
disableHoverableContent
/>
</span>
)}
<SimpleTooltip
asChild
tabbable
button={copyButton}
content={copied ? "Copied!" : "Copy"}
disableHoverableContent
/>
</div>
</TableCell>
);
+16 -8
View File
@@ -113,13 +113,17 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered:
return (
<SimpleTooltip
asChild
tabbable
button={
<span
<button
type="button"
aria-label={copied ? "Copied" : "Copy tag"}
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover",
isHovered ? "flex" : "hidden",
"absolute -right-6 top-0 z-10 flex size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100",
isHovered ? "opacity-100" : "pointer-events-none opacity-0",
copied
? "text-green-500"
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
@@ -130,7 +134,7 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered:
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
</button>
}
content={copied ? "Copied!" : "Copy tag"}
disableHoverableContent
@@ -158,18 +162,22 @@ function DeleteButton({
return (
<SimpleTooltip
asChild
tabbable
button={
<span
<button
type="button"
aria-label="Remove tag"
onClick={handleDelete}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover",
isHovered ? "flex" : "hidden",
"absolute -right-6 top-0 z-10 flex size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100",
isHovered ? "opacity-100" : "pointer-events-none opacity-0",
"text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-rose-400"
)}
>
<XIcon className="size-3.5" />
</span>
</button>
}
content="Remove tag"
disableHoverableContent