fix: prevent nested buttons in tooltip button (#12177)

Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Bharath A V
2025-12-31 21:38:37 +05:30
committed by GitHub
parent 96d073ee5b
commit f9b316453d
12 changed files with 150 additions and 289 deletions

View File

@@ -1,9 +1,9 @@
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
interface ChatActionTooltipProps {
children: React.ReactNode;
tooltip: string | React.ReactNode;
ariaLabel: string;
ariaLabel?: string;
}
export function ChatActionTooltip({
@@ -12,14 +12,12 @@ export function ChatActionTooltip({
ariaLabel,
}: ChatActionTooltipProps) {
return (
<TooltipButton
tooltip={tooltip}
ariaLabel={ariaLabel}
disabled={false}
<StyledTooltip
content={tooltip}
placement="bottom"
tooltipClassName="bg-white text-black text-xs font-medium leading-5"
>
{children}
</TooltipButton>
<span data-aria-label={ariaLabel}>{children}</span>
</StyledTooltip>
);
}

View File

@@ -2,7 +2,7 @@ import React from "react";
import { cn } from "#/utils/utils";
import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button";
import { OpenHandsSourceType } from "#/types/core/base";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import { MarkdownRenderer } from "../markdown/markdown-renderer";
interface ChatMessageProps {
@@ -53,7 +53,7 @@ export function ChatMessage({
className={cn(
"rounded-xl relative w-fit max-w-full last:mb-4",
"flex flex-col gap-2",
type === "user" && " p-4 bg-tertiary self-end",
type === "user" && "p-4 bg-tertiary self-end",
type === "agent" && "mt-6 w-full max-w-full bg-transparent",
isFromPlanningAgent && "border border-[#597ff4] bg-tertiary p-4",
)}
@@ -67,21 +67,16 @@ export function ChatMessage({
>
{actions?.map((action, index) =>
action.tooltip ? (
<TooltipButton
key={index}
tooltip={action.tooltip}
ariaLabel={action.tooltip}
placement="top"
>
<StyledTooltip key={index} content={action.tooltip} placement="top">
<button
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
aria-label={action.tooltip}
>
{action.icon}
</button>
</TooltipButton>
</StyledTooltip>
) : (
<button
key={index}
@@ -112,6 +107,7 @@ export function ChatMessage({
>
<MarkdownRenderer includeStandard>{message}</MarkdownRenderer>
</div>
{children}
</article>
);

View File

@@ -1,4 +1,4 @@
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
interface GitControlBarTooltipWrapperProps {
tooltipMessage: string;
@@ -18,16 +18,15 @@ export function GitControlBarTooltipWrapper({
}
return (
<TooltipButton
tooltip={tooltipMessage}
ariaLabel={tooltipMessage}
testId={testId}
<StyledTooltip
content={tooltipMessage}
placement="top"
className="hover:opacity-100"
tooltipClassName="bg-white text-black"
showArrow
tooltipClassName="bg-white text-black"
>
{children}
</TooltipButton>
<span data-testid={testId} className="hover:opacity-100">
{children}
</span>
</StyledTooltip>
);
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { ConversationStatus } from "#/types/conversation-status";
import { cn, getConversationStatusLabel } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
interface ConversationStatusIndicatorProps {
conversationStatus: ConversationStatus;
@@ -17,7 +17,7 @@ export function ConversationStatusIndicator({
const conversationStatusBackgroundColor = useMemo(() => {
switch (conversationStatus) {
case "STOPPED":
return "bg-[#3C3C49]"; // Inactive/stopped - grey
return "bg-[#3C3C49]";
case "RUNNING":
return "bg-[#1FBD53]"; // Running/online - green
case "STARTING":
@@ -34,13 +34,10 @@ export function ConversationStatusIndicator({
);
return (
<TooltipButton
tooltip={statusLabel}
ariaLabel={statusLabel}
<StyledTooltip
content={statusLabel}
placement="right"
showArrow
asSpan
className="p-0 border-0 bg-transparent hover:opacity-100"
tooltipClassName="bg-[#1a1a1a] text-white text-xs shadow-lg"
>
<div
@@ -49,6 +46,6 @@ export function ConversationStatusIndicator({
conversationStatusBackgroundColor,
)}
/>
</TooltipButton>
</StyledTooltip>
);
}

View File

@@ -1,7 +1,7 @@
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { GitRepository } from "#/types/git";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
interface MicroagentManagementAccordionTitleProps {
repository: GitRepository;
@@ -14,17 +14,17 @@ export function MicroagentManagementAccordionTitle({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitProviderIcon gitProvider={repository.git_provider} />
<TooltipButton
tooltip={repository.full_name}
ariaLabel={repository.full_name}
testId="repository-name-tooltip"
placement="bottom"
asSpan
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[194px] translate-y-[-1px]"
>
<span>{repository.full_name}</span>
</TooltipButton>
<StyledTooltip content={repository.full_name} placement="bottom">
<span
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[194px] translate-y-[-1px]"
data-testid="repository-name-tooltip"
>
{repository.full_name}
</span>
</StyledTooltip>
</div>
<MicroagentManagementAddMicroagentButton repository={repository} />
</div>
);

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import ListIcon from "#/icons/list.svg?react";
import { TooltipButton } from "./tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import { cn } from "#/utils/utils";
interface ConversationPanelButtonProps {
@@ -17,23 +17,28 @@ export function ConversationPanelButton({
}: ConversationPanelButtonProps) {
const { t } = useTranslation();
const label = t(I18nKey.SIDEBAR$CONVERSATIONS);
return (
<TooltipButton
testId="toggle-conversation-panel"
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
onClick={onClick}
disabled={disabled}
>
<ListIcon
width={24}
height={24}
className={cn(
"cursor-pointer",
isOpen ? "text-white" : "text-[#B1B9D3]",
disabled && "opacity-50",
)}
/>
</TooltipButton>
<StyledTooltip content={label}>
<button
type="button"
data-testid="toggle-conversation-panel"
aria-label={label}
onClick={onClick}
disabled={disabled}
className="p-0 bg-transparent border-0"
>
<ListIcon
width={24}
height={24}
className={cn(
"cursor-pointer",
isOpen ? "text-white" : "text-[#B1B9D3]",
disabled && "opacity-50",
)}
/>
</button>
</StyledTooltip>
);
}

View File

@@ -1,6 +1,7 @@
import { NavLink } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import RobotIcon from "#/icons/robot.svg?react";
interface MicroagentManagementButtonProps {
@@ -15,14 +16,21 @@ export function MicroagentManagementButton({
const microagentManagement = t(I18nKey.MICROAGENT_MANAGEMENT$TITLE);
return (
<TooltipButton
tooltip={microagentManagement}
ariaLabel={microagentManagement}
navLinkTo="/microagent-management"
testId="microagent-management-button"
disabled={disabled}
>
<RobotIcon width={28} height={28} />
</TooltipButton>
<StyledTooltip content={microagentManagement}>
<NavLink
to="/microagent-management"
data-testid="microagent-management-button"
aria-label={microagentManagement}
tabIndex={disabled ? -1 : 0}
onClick={(e) => {
if (disabled) {
e.preventDefault();
}
}}
className={disabled ? "pointer-events-none opacity-50" : undefined}
>
<RobotIcon width={28} height={28} />
</NavLink>
</StyledTooltip>
);
}

View File

@@ -1,33 +1,41 @@
import { useLocation } from "react-router";
import { NavLink } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import PlusIcon from "#/icons/u-plus.svg?react";
import { cn } from "#/utils/utils";
interface NewProjectButtonProps {
disabled?: boolean;
}
export function NewProjectButton({ disabled = false }: NewProjectButtonProps) {
const { pathname } = useLocation();
const { t } = useTranslation();
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
return (
<TooltipButton
tooltip={startNewProject}
ariaLabel={startNewProject}
navLinkTo="/"
testId="new-project-button"
disabled={disabled}
<StyledTooltip
content={startNewProject}
placement="right"
tooltipClassName="bg-transparent"
>
<PlusIcon
width={24}
height={24}
color={pathname === "/" ? "#ffffff" : "#B1B9D3"}
/>
</TooltipButton>
<NavLink
to="/"
data-testid="new-project-button"
aria-label={startNewProject}
tabIndex={disabled ? -1 : 0}
onClick={(e) => {
if (disabled) {
e.preventDefault();
}
}}
className={cn("inline-flex items-center justify-center", {
"pointer-events-none opacity-50": disabled,
})}
>
<PlusIcon width={24} height={24} />
</NavLink>
</StyledTooltip>
);
}

View File

@@ -1,18 +1,20 @@
import { NavLink } from "react-router";
import { useTranslation } from "react-i18next";
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
export function OpenHandsLogoButton() {
const { t } = useTranslation();
const tooltipText = t(I18nKey.BRANDING$OPENHANDS);
const ariaLabel = t(I18nKey.BRANDING$OPENHANDS_LOGO);
return (
<TooltipButton
tooltip={t(I18nKey.BRANDING$OPENHANDS)}
ariaLabel={t(I18nKey.BRANDING$OPENHANDS_LOGO)}
navLinkTo="/"
>
<OpenHandsLogo width={46} height={30} />
</TooltipButton>
<StyledTooltip content={tooltipText}>
<NavLink to="/" aria-label={ariaLabel}>
<OpenHandsLogo width={46} height={30} />
</NavLink>
</StyledTooltip>
);
}

View File

@@ -0,0 +1,32 @@
import { Tooltip, TooltipProps } from "@heroui/react";
import React, { ReactNode } from "react";
export interface StyledTooltipProps {
children: ReactNode;
content: string | ReactNode;
tooltipClassName?: React.HTMLAttributes<HTMLDivElement>["className"];
placement?: TooltipProps["placement"];
showArrow?: boolean;
closeDelay?: number;
}
export function StyledTooltip({
children,
content,
tooltipClassName,
placement = "right",
showArrow = false,
closeDelay = 100,
}: StyledTooltipProps) {
return (
<Tooltip
content={content}
closeDelay={closeDelay}
placement={placement}
className={tooltipClassName}
showArrow={showArrow}
>
<div className="inline-flex">{children}</div>
</Tooltip>
);
}

View File

@@ -1,184 +0,0 @@
import { Tooltip, TooltipProps } from "@heroui/react";
import React, { ReactNode } from "react";
import { NavLink } from "react-router";
import { cn } from "#/utils/utils";
export interface TooltipButtonProps {
children: ReactNode;
tooltip: string | ReactNode;
onClick?: () => void;
href?: string;
navLinkTo?: string;
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
tooltipClassName?: React.HTMLAttributes<HTMLDivElement>["className"];
disabled?: boolean;
placement?: TooltipProps["placement"];
showArrow?: boolean;
asSpan?: boolean;
}
export function TooltipButton({
children,
tooltip,
onClick,
href,
navLinkTo,
ariaLabel,
testId,
className,
tooltipClassName,
disabled = false,
placement = "right",
showArrow = false,
asSpan = false,
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick && !disabled) {
onClick();
e.preventDefault();
}
};
const isClickable = !!onClick && !disabled;
let buttonContent: React.ReactNode;
if (asSpan) {
if (isClickable) {
buttonContent = (
<span
role="button"
tabIndex={0}
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClick();
e.preventDefault();
}
}}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
aria-disabled={disabled}
>
{children}
</span>
);
} else {
buttonContent = (
<span
aria-label={ariaLabel}
data-testid={testId}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
aria-disabled={disabled}
>
{children}
</span>
);
}
} else {
buttonContent = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
className={cn(
"hover:opacity-80",
disabled && "opacity-50 cursor-not-allowed",
className,
)}
disabled={disabled}
>
{children}
</button>
);
}
let content;
if (navLinkTo && !disabled) {
content = (
<NavLink
to={navLinkTo}
onClick={handleClick}
className={({ isActive }) =>
cn(
"hover:opacity-80",
isActive ? "text-white" : "text-[#9099AC]",
className,
)
}
aria-label={ariaLabel}
data-testid={testId}
>
{children}
</NavLink>
);
} else if (navLinkTo && disabled) {
// If disabled and has navLinkTo, render a button that looks like a NavLink but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn(
"text-[#9099AC]",
"opacity-50 cursor-not-allowed",
className,
)}
disabled
>
{children}
</button>
);
} else if (href && !disabled) {
content = (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className={cn("hover:opacity-80", className)}
aria-label={ariaLabel}
data-testid={testId}
>
{children}
</a>
);
} else if (href && disabled) {
// If disabled and has href, render a button that looks like a link but doesn't navigate
content = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
className={cn("opacity-50 cursor-not-allowed", className)}
disabled
>
{children}
</button>
);
} else {
content = buttonContent;
}
return (
<Tooltip
content={tooltip}
closeDelay={100}
placement={placement}
className={tooltipClassName}
showArrow={showArrow}
>
{content}
</Tooltip>
);
}

View File

@@ -9,7 +9,7 @@ import { useSettings } from "#/hooks/query/use-settings";
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import QuestionCircleIcon from "#/icons/question-circle.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "#/components/features/settings/settings-input";
@@ -693,13 +693,13 @@ function LlmSettingsScreen() {
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>
<TooltipButton
tooltip={t(I18nKey.SETTINGS$CONFIRMATION_MODE_TOOLTIP)}
ariaLabel={t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
className="text-[#9099AC] hover:text-white cursor-help"
<StyledTooltip
content={t(I18nKey.SETTINGS$CONFIRMATION_MODE_TOOLTIP)}
>
<QuestionCircleIcon width={16} height={16} />
</TooltipButton>
<span className="text-[#9099AC] hover:text-white cursor-help">
<QuestionCircleIcon width={16} height={16} />
</span>
</StyledTooltip>
</div>
{confirmationModeEnabled && (