From 186fc21a9c659c370a1d87a3732b3fb977b63add Mon Sep 17 00:00:00 2001 From: Will King Date: Wed, 28 Oct 2026 14:55:49 -0500 Subject: [PATCH] fix(tooltip): keep Kumo's disabled trigger out of the tab order When Kumo renders its own trigger (no render or asChild), it wraps non-interactive content such as truncated Text that only needs a tooltip when it overflows. A disabled tooltip there has nothing to reveal, but the native button trigger was still a tab stop. Set tabIndex -1 on Kumo's trigger while disabled; custom triggers keep their own focus behavior. Co-authored-by: Pi Co-authored-by: cloudflare Co-authored-by: claude-opus-5-5 --- .../tooltip-disabled-trigger-tab-order.md | 5 ++ .../src/components/tooltip/tooltip.test.tsx | 72 +++++++++++++++++++ .../kumo/src/components/tooltip/tooltip.tsx | 5 ++ 3 files changed, 82 insertions(+) create mode 100644 .changeset/tooltip-disabled-trigger-tab-order.md diff --git a/.changeset/tooltip-disabled-trigger-tab-order.md b/.changeset/tooltip-disabled-trigger-tab-order.md new file mode 100644 index 000000000..fd6deefbe --- /dev/null +++ b/.changeset/tooltip-disabled-trigger-tab-order.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": patch +--- + +`Tooltip` now keeps its own trigger out of the tab order while the tooltip is `disabled`. This applies only when Kumo renders the trigger (no `render` or `asChild`), for example around truncated `Text` that needs a tooltip only when it overflows. A disabled tooltip there has nothing to reveal, so keyboard users no longer stop on it. Custom triggers passed through `render` keep their own focus behavior. diff --git a/packages/kumo/src/components/tooltip/tooltip.test.tsx b/packages/kumo/src/components/tooltip/tooltip.test.tsx index 80f6783de..26a1a5822 100644 --- a/packages/kumo/src/components/tooltip/tooltip.test.tsx +++ b/packages/kumo/src/components/tooltip/tooltip.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vite-plus/test"; import { Text } from "../text/text"; import { Tooltip } from "./tooltip"; @@ -20,4 +21,75 @@ describe("Tooltip", () => { expect(trigger).not.toBeNull(); expect(trigger?.className).not.toMatch(/\bleading-/); }); + + describe("trigger tab order", () => { + function renderBetweenButtons(tooltip: React.ReactNode) { + render( + <> + + {tooltip} + + , + ); + } + + it("keeps Kumo's trigger out of the tab order when disabled", async () => { + renderBetweenButtons( + + FULL_VARIABLE_NAME + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Before" })); + await user.tab(); + + expect( + screen + .getByRole("button", { name: "FULL_VARIABLE_NAME" }) + .getAttribute("tabindex"), + ).toBe("-1"); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "After" }), + ); + }); + + it("keeps Kumo's trigger focusable when enabled", async () => { + renderBetweenButtons( + + FULL_VARIABLE_NAME + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Before" })); + await user.tab(); + + const trigger = screen.getByRole("button", { + name: "FULL_VARIABLE_NAME", + }); + expect(trigger.hasAttribute("tabindex")).toBe(false); + expect(document.activeElement).toBe(trigger); + }); + + it("leaves a custom trigger's focus behavior alone when disabled", async () => { + renderBetweenButtons( + } + > + Save + , + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Before" })); + await user.tab(); + + const trigger = screen.getByRole("button", { name: "Save" }); + expect(trigger.hasAttribute("tabindex")).toBe(false); + expect(document.activeElement).toBe(trigger); + }); + }); }); diff --git a/packages/kumo/src/components/tooltip/tooltip.tsx b/packages/kumo/src/components/tooltip/tooltip.tsx index 456ddb7c6..0eb5485c2 100644 --- a/packages/kumo/src/components/tooltip/tooltip.tsx +++ b/packages/kumo/src/components/tooltip/tooltip.tsx @@ -161,6 +161,10 @@ export function Tooltip({ render ?? (asChild ? (children as TriggerProps["render"]) : undefined); const shouldUseRender = resolvedRender !== undefined; + // A disabled tooltip has nothing to reveal, so keep Kumo's own trigger + // (usually plain text) out of the tab order. Custom triggers keep theirs. + const triggerTabIndex = !shouldUseRender && props.disabled ? -1 : undefined; + return ( {asChild ? undefined : (children as ReactNode)}