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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user