Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tooltip-disabled-trigger-tab-order.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions packages/kumo/src/components/tooltip/tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(
<>
<button type="button">Before</button>
{tooltip}
<button type="button">After</button>
</>,
);
}

it("keeps Kumo's trigger out of the tab order when disabled", async () => {
renderBetweenButtons(
<Tooltip content="FULL_VARIABLE_NAME" disabled>
<Text truncate>FULL_VARIABLE_NAME</Text>
</Tooltip>,
);
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(
<Tooltip content="FULL_VARIABLE_NAME">
<Text truncate>FULL_VARIABLE_NAME</Text>
</Tooltip>,
);
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(
<Tooltip
content="Save changes"
disabled
render={<button type="button" />}
>
Save
</Tooltip>,
);
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);
});
});
});
5 changes: 5 additions & 0 deletions packages/kumo/src/components/tooltip/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TooltipBase.Root {...props}>
<TooltipBase.Trigger
Expand All @@ -179,6 +183,7 @@ export function Tooltip({
className,
)}
render={resolvedRender}
tabIndex={triggerTabIndex}
>
{asChild ? undefined : (children as ReactNode)}
</TooltipBase.Trigger>
Expand Down
Loading