Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tooltip: handle non-interactive tooltip triggers #130

Merged
merged 8 commits into from
Dec 6, 2023
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
107 changes: 105 additions & 2 deletions src/components/Tooltip/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,57 @@ export default {
title: "Tooltip",
component: TooltipComponent,
tags: ["autodocs"],
argTypes: {},
args: {},
controls: {
include: [
"side",
"align",
"open",
"label",
"caption",
"isTriggerInteractive",
],
},
argTypes: {
side: {
control: "inline-radio",
options: ["left", "right", "top", "bottom"],
},
align: {
control: "inline-radio",
options: ["center", "start", "end"],
},
open: {
control: "boolean",
},
isTriggerInteractive: {
control: "boolean",
},
label: {
control: "string",
},
caption: {
control: "string",
},
},
args: {
side: "left",
align: "center",
open: undefined,
label: "@bob:example.org",
caption: undefined,
children: (
<IconButton data-testid="testbutton">
<UserIcon />
</IconButton>
),
},
decorators: [
(Story: StoryFn) => (
<div style={{ padding: 100 }}>
<Story />
</div>
),
],
} as Meta<typeof TooltipComponent>;

const TemplateSide: StoryFn<typeof TooltipComponent> = () => (
Expand Down Expand Up @@ -104,3 +153,57 @@ const TemplateAlign: StoryFn<typeof TooltipComponent> = () => (

export const Align = TemplateAlign.bind({});
Align.args = {};

export const Default = {
args: {
// unset to test defaults
side: undefined,
align: undefined,
},
};

export const WithCaption = {
args: {
...Default.args,
label: "Copy",
caption: "⌘ + C",
},
};

export const ForcedOpen = {
args: {
...Default.args,
open: true,
label: "I'm always open",
},
};

export const ForcedClose = {
args: {
...Default.args,
open: false,
label: "You can't see me",
children: <span>No tooltip to see here</span>,
},
};

export const InteractiveTrigger = {
args: {
...Default.args,
isTriggerInteractive: true,
label: "Shown with delay",
},
};

export const NonInteractiveTrigger = {
args: {
...Default.args,
isTriggerInteractive: false,
label: "Shown without delay",
children: (
<IconButton data-testid="testbutton" disabled>
<UserIcon />
</IconButton>
),
},
};
98 changes: 80 additions & 18 deletions src/components/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,97 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { describe, it, expect, beforeAll } from "vitest";
import { render } from "@testing-library/react";
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import React from "react";

import { Tooltip } from "./Tooltip";
import { IconButton } from "../Button";
import * as stories from "./Tooltip.stories";
import { composeStories, composeStory } from "@storybook/react";

const {
Default,
WithCaption,
ForcedOpen,
ForcedClose,
InteractiveTrigger,
NonInteractiveTrigger,
} = composeStories(stories);

describe("Tooltip", () => {
beforeAll(() => {
global.ResizeObserver = require("resize-observer-polyfill");
});

afterEach(cleanup);

it("renders open by default", () => {
const { asFragment } = render(
<Tooltip label="Hello world 👋" caption="⌘ + C" open={true}>
<IconButton>
<svg />
</IconButton>
</Tooltip>,
);
const { asFragment } = render(<ForcedOpen />);
// trigger rendered
expect(asFragment()).toMatchSnapshot();
// tooltip shown
expect(screen.getByRole("tooltip")).toMatchSnapshot();
});
it("renders", () => {
const { asFragment } = render(
<Tooltip label="Hello world 👋">
<IconButton>
<svg />
</IconButton>
</Tooltip>,

it("renders closed by default", () => {
const { asFragment } = render(<ForcedClose />);
expect(asFragment()).toMatchSnapshot();
// no tooltip
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

it("renders default tooltip", async () => {
render(<Default />);
const trigger = screen.getByTestId("testbutton");

fireEvent.focus(trigger);
// tooltip shown
expect(await screen.findByRole("tooltip")).toMatchSnapshot();
});

it("opens tooltip on focus", async () => {
render(<InteractiveTrigger />);
const trigger = screen.getByTestId("testbutton");

expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
fireEvent.focus(trigger);
// tooltip shown
expect(await screen.findByRole("tooltip")).toMatchSnapshot();
});

it("opens tooltip on focus where trigger is non interactive", async () => {
const { container } = render(<NonInteractiveTrigger />);

expect(container).toMatchSnapshot();
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
const trigger = screen.getByTestId("testbutton");
fireEvent.focus(trigger);
// tooltip shown
expect(await screen.findByRole("tooltip")).toMatchSnapshot();
});

it("overrides default tab index for non interactive triggers", async () => {
const Component = composeStory(
{
...stories.NonInteractiveTrigger,
args: {
...stories.NonInteractiveTrigger.args,
nonInteractiveTriggerTabIndex: -1,
},
},
stories.default,
);
const { container } = render(<Component />);

expect(container).toMatchSnapshot();
});

it("renders with caption", async () => {
const { asFragment } = render(<WithCaption />);
expect(asFragment()).toMatchSnapshot();
const trigger = screen.getByTestId("testbutton");

fireEvent.focus(trigger);
// tooltip shown
expect(await screen.findByRole("tooltip")).toMatchSnapshot();
});
});
30 changes: 28 additions & 2 deletions src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,29 @@ type TooltipProps = {
>["onPointerDownOutside"];
/**
* The controlled open state of the tooltip.
* When true, the tooltip is always open. When false, the tooltip is always hidden.
* When undefined, the tooltip will manage its own open state.
* You will mostly want to omit this property. Will be used the vast majority
* of the time during development.
* @default undefined
*/
open?: boolean;
/**
* Whether the trigger element is interactive.
* When trigger is interactive:
* - tooltip will be shown after a 300ms delay.
* When trigger is not interactive:
* - tooltip will be shown instantly when pointer enters trigger.
* - trigger will be wrapped in a span with a tab index from prop nonInteractiveTriggerTabIndex
* @default true
*/
isTriggerInteractive?: boolean;
/**
* Tab index to apply to the span wrapping non interactive tooltip triggers.
* Only used when `isTriggerInteractive` is false.
* @default 0
*/
nonInteractiveTriggerTabIndex?: number;
};

/**
Expand All @@ -83,12 +101,20 @@ export const Tooltip = ({
align = "center",
onEscapeKeyDown,
onPointerDownOutside,
isTriggerInteractive = true,
nonInteractiveTriggerTabIndex = 0,
open,
}: PropsWithChildren<TooltipProps>): JSX.Element => {
return (
<Provider>
<Root open={open} delayDuration={300}>
<Trigger asChild>{children}</Trigger>
<Root open={open} delayDuration={isTriggerInteractive ? 300 : 0}>
<Trigger asChild>
{isTriggerInteractive ? (
children
) : (
<span tabIndex={nonInteractiveTriggerTabIndex}>{children}</span>
)}
</Trigger>
<Portal>
<Content
side={side}
Expand Down
Loading
Loading