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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning).

## [Unreleased]

### Added

- **`Tooltip` takes `toggleable`, which makes the trigger a control.** With it,
the trigger carries `role="button"` and an `aria-expanded` that tracks the
content, and Enter and Space toggle it (both prevented from their defaults,
since Space scrolls the page and Enter submits an enclosing form). Off by
default, so no existing trigger changes: content that only supplements what is
already on screen is not a control and should not claim to be one.

It exists because a glossary term is the opposite case. The definition is
something a reader summons deliberately, and where there is no hover at all,
activation is how they get it. Two products had independently built exactly
this trigger contract around one, and both would have had to give it up to
adopt this component. Losing an affordance is not a reasonable price for
sharing code.

## [0.1.0-alpha.15]

### Fixed
Expand Down
93 changes: 93 additions & 0 deletions src/components/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,4 +434,97 @@ describe("Tooltip", () => {
expect(tooltip).toHaveClass("tooltip__content--bottom");
});
});
/**
* A glossary term is the case `toggleable` exists for: the phrase carries a
* definition a reader summons deliberately, which makes the trigger a control
* and not incidental hover help. Two products had built that contract around
* one before this component existed, and both would have lost it here.
*/
describe("toggleable trigger", () => {
beforeEach(() => {
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

function renderTrigger(toggleable: boolean) {
const { container } = render(
<Tooltip content="Helpful hint" toggleable={toggleable}>
<span>Trigger</span>
</Tooltip>,
);
const wrapper = container.querySelector(".tooltip__wrapper");
if (!(wrapper instanceof HTMLElement)) throw new Error("no wrapper");
return wrapper;
}

it("announces the trigger as a button and reports its state", () => {
const wrapper = renderTrigger(true);
expect(screen.getByRole("button", { name: "Trigger" })).toBe(wrapper);
expect(wrapper).toHaveAttribute("aria-expanded", "false");

fireEvent.mouseEnter(wrapper);

expect(wrapper).toHaveAttribute("aria-expanded", "true");
});

it("toggles on Enter and on Space", () => {
const wrapper = renderTrigger(true);

for (const key of ["Enter", " "]) {
fireEvent.keyDown(wrapper, { key });
expect(screen.getByRole("tooltip")).toBeInTheDocument();
expect(wrapper).toHaveAttribute("aria-expanded", "true");

fireEvent.keyDown(wrapper, { key });
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
expect(wrapper).toHaveAttribute("aria-expanded", "false");
}
});

it("prevents the default, since Space scrolls and Enter submits", () => {
const wrapper = renderTrigger(true);

for (const key of ["Enter", " "]) {
const notPrevented = fireEvent.keyDown(wrapper, { key });
expect(notPrevented).toBe(false);
fireEvent.keyDown(wrapper, { key });
}
});

it("leaves other keys to the page", () => {
const wrapper = renderTrigger(true);

const notPrevented = fireEvent.keyDown(wrapper, { key: "ArrowDown" });

expect(notPrevented).toBe(true);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

it("claims nothing by default", () => {
const wrapper = renderTrigger(false);
expect(wrapper).not.toHaveAttribute("role");
expect(wrapper).not.toHaveAttribute("aria-expanded");

const notPrevented = fireEvent.keyDown(wrapper, { key: " " });

expect(notPrevented).toBe(true);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

it("still reports expanded when opened by hover rather than by key", () => {
const wrapper = renderTrigger(true);

fireEvent.mouseEnter(wrapper);
expect(wrapper).toHaveAttribute("aria-expanded", "true");

fireEvent.keyDown(wrapper, { key: "Escape" });
expect(wrapper).toHaveAttribute("aria-expanded", "false");
});
});
});
50 changes: 48 additions & 2 deletions src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,20 @@
* - Persistent: it stays while the trigger is hovered or focused.
*
* Also uses role="tooltip" and aria-describedby so a screen reader announces
* the content with the trigger.
* the content with the trigger. `toggleable` additionally makes the trigger a
* button with aria-expanded that Enter and Space operate, for content a reader
* summons rather than passes over.
*/

import { useState, useRef, useEffect, useCallback, useId, type ReactNode } from "react";
import {
useState,
useRef,
useEffect,
useCallback,
useId,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import "./Tooltip.css";

Expand All @@ -32,6 +42,20 @@ export interface TooltipProps {
tooltipId?: string;
/** Tab index for the wrapper. Use -1 when wrapping already-focusable elements like buttons. Defaults to 0. */
tabIndex?: number;
/**
* Announce the trigger as a button carrying `aria-expanded`, and let Enter
* and Space toggle the content.
*
* Off by default: a trigger whose content only supplements what is already
* on screen is not a control and should not claim to be one. Turn it on
* where the content is something a reader summons deliberately. A glossary
* term is the case this exists for, and two products had independently built
* the same trigger contract around one.
*
* Do not combine with `tabIndex={-1}`, which is for wrapping an element that
* is already focusable and already owns its role.
*/
toggleable?: boolean;
}

/** Long enough to cross the 8px gap to the tooltip, short enough not to linger. */
Expand All @@ -50,6 +74,7 @@ export function Tooltip({
contentClassName,
tooltipId,
tabIndex = 0,
toggleable = false,
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState<TooltipPosition | null>(null);
Expand Down Expand Up @@ -217,6 +242,24 @@ export function Tooltip({
};
}, [isVisible, hideNow]);

/**
* Enter and Space toggle, for a trigger that is a control rather than
* incidental hover help. Both default to something else, Space scrolling the
* page and Enter submitting an enclosing form, so both are prevented.
*/
const handleKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
if (isVisible) {
hideNow();
} else {
handleShow();
}
},
[isVisible, hideNow, handleShow],
);

const tooltipElement = isVisible && (
<div
ref={tooltipRef}
Expand Down Expand Up @@ -246,7 +289,10 @@ export function Tooltip({
className={`tooltip__wrapper ${className ?? ""}`}
onMouseEnter={handleShow}
onMouseLeave={handleHide}
onKeyDown={toggleable ? handleKeyDown : undefined}
tabIndex={tabIndex}
role={toggleable ? "button" : undefined}
aria-expanded={toggleable ? isVisible : undefined}
aria-describedby={isVisible ? effectiveTooltipId : undefined}
>
{children}
Expand Down