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

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/steady-button-tooltips.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": patch
---

Keep Button and LinkButton tooltip triggers stable when disabled or loading state changes.
92 changes: 91 additions & 1 deletion packages/kumo/src/components/button/button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,12 @@ describe("Button", () => {
it("title prop wraps in Tooltip and removes native title attribute", () => {
render(<Button title="Save changes">Save</Button>);
const button = screen.getByRole("button", { name: "Save" });
const trigger = button.parentElement;
// title is intercepted by Tooltip wrapper, not set as native attribute
expect(button.getAttribute("title")).toBeNull();
expect(button.hasAttribute("data-base-ui-tooltip-trigger")).toBe(false);
expect(trigger?.tagName).toBe("SPAN");
expect(trigger?.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true);
});

it("uses title as the accessible name when there are no children", () => {
Expand Down Expand Up @@ -178,6 +182,37 @@ describe("Button", () => {
expect(trigger?.hasAttribute("disabled")).toBe(false);
});

it.each(["disabled", "loading"] as const)(
"keeps the tooltip trigger mounted when %s changes",
(state) => {
const { container, rerender } = render(
<Button title="Save changes">Save</Button>,
);
const trigger = container.querySelector("[data-base-ui-tooltip-trigger]");
const button = screen.getByRole("button");
const activeState =
state === "disabled" ? { disabled: true } : { loading: true };

expect(trigger).toBeTruthy();

rerender(
<Button title="Save changes" {...activeState}>
Save
</Button>,
);
expect(container.querySelector("[data-base-ui-tooltip-trigger]")).toBe(
trigger,
);
expect(screen.getByRole("button")).toBe(button);

rerender(<Button title="Save changes">Save</Button>);
expect(container.querySelector("[data-base-ui-tooltip-trigger]")).toBe(
trigger,
);
expect(screen.getByRole("button")).toBe(button);
},
);

it("keeps emphasized variant rings color-matched when pressed or focused", () => {
for (const variant of ["primary", "destructive"] as const) {
const className = buttonVariants({ variant });
Expand Down Expand Up @@ -249,9 +284,12 @@ describe("LinkButton", () => {
</LinkButton>,
);
const link = screen.getByRole("link", { name: "Home" });
const trigger = link.parentElement;
// title is intercepted by the Kumo Tooltip wrapper, not set as a native attribute
expect(link.getAttribute("title")).toBeNull();
expect(link.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true);
expect(link.hasAttribute("data-base-ui-tooltip-trigger")).toBe(false);
expect(trigger?.tagName).toBe("SPAN");
expect(trigger?.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true);
});

describe("disabled", () => {
Expand Down Expand Up @@ -322,5 +360,57 @@ describe("LinkButton", () => {
expect(trigger?.hasAttribute("data-base-ui-tooltip-trigger")).toBe(true);
expect(trigger?.hasAttribute("disabled")).toBe(false);
});

it("uses title as the accessible name when disabled without children", () => {
render(
<LinkButton
href="/home"
disabled
icon={Plus}
title="Go home"
aria-label=""
/>,
);

expect(screen.getByRole("button", { name: "Go home" })).toBeTruthy();
});

it("keeps the tooltip trigger mounted when disabled changes", () => {
const { container, rerender } = render(
<LinkButton href="/home" title="Go home">
Home
</LinkButton>,
);
const trigger = container.querySelector("[data-base-ui-tooltip-trigger]");

expect(trigger).toBeTruthy();
expect(
container.querySelectorAll("[data-base-ui-tooltip-trigger]"),
).toHaveLength(1);

rerender(
<LinkButton href="/home" title="Go home" disabled>
Home
</LinkButton>,
);
expect(container.querySelector("[data-base-ui-tooltip-trigger]")).toBe(
trigger,
);
expect(
container.querySelectorAll("[data-base-ui-tooltip-trigger]"),
).toHaveLength(1);

rerender(
<LinkButton href="/home" title="Go home">
Home
</LinkButton>,
);
expect(container.querySelector("[data-base-ui-tooltip-trigger]")).toBe(
trigger,
);
expect(
container.querySelectorAll("[data-base-ui-tooltip-trigger]"),
).toHaveLength(1);
});
});
});
56 changes: 29 additions & 27 deletions packages/kumo/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,18 +399,14 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
</button>
);

if (title && (disabled || loading)) {
if (title) {
return (
<Tooltip content={title} render={<span className="inline-flex" />}>
{button}
</Tooltip>
);
}

if (title) {
return <Tooltip content={title} render={button} />;
}

return button;
},
);
Expand Down Expand Up @@ -471,31 +467,33 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>(
) => {
const LinkComponent = useLinkComponent();
const emphasisStyle = getEmphasisStyle(variant);
const titleLabel = getTitleLabel(title);
const externalProps = external
? { target: "_blank", rel: "noopener noreferrer" }
: {};

if (disabled) {
const linkButton = disabled ? (
// ref is intentionally not forwarded: it's typed for the anchor, but the disabled state renders a button
return (
<Button
{...toDisabledButtonProps(props)}
className={cn("select-text", className)}
data-kumo-component="LinkButton"
disabled
icon={IconComponent}
shape={shape as "base"}
size={size}
style={style}
title={title}
variant={variant}
>
{children}
</Button>
);
}

const link = (
<Button
{...toDisabledButtonProps(props)}
aria-label={
props["aria-label"] ||
(!React.Children.count(children) && !props["aria-labelledby"]
? titleLabel
: undefined)
}
className={cn("select-text", className)}
data-kumo-component="LinkButton"
disabled
icon={IconComponent}
shape={shape as "base"}
size={size}
style={style}
variant={variant}
>
{children}
</Button>
) : (
<LinkComponent
ref={ref}
data-kumo-component="LinkButton"
Expand All @@ -515,10 +513,14 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>(
);

if (title) {
return <Tooltip content={title} render={link} />;
return (
<Tooltip content={title} render={<span className="inline-flex" />}>
{linkButton}
</Tooltip>
);
}

return link;
return linkButton;
},
);

Expand Down