diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f311d14..e86a2f63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/components/Tooltip/Tooltip.test.tsx b/src/components/Tooltip/Tooltip.test.tsx
index 6d8b91ef..30e42aad 100644
--- a/src/components/Tooltip/Tooltip.test.tsx
+++ b/src/components/Tooltip/Tooltip.test.tsx
@@ -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(
+
+ Trigger
+ ,
+ );
+ 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");
+ });
+ });
});
diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx
index 794b3674..3e4bac22 100644
--- a/src/components/Tooltip/Tooltip.tsx
+++ b/src/components/Tooltip/Tooltip.tsx
@@ -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";
@@ -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. */
@@ -50,6 +74,7 @@ export function Tooltip({
contentClassName,
tooltipId,
tabIndex = 0,
+ toggleable = false,
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState(null);
@@ -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) => {
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ if (isVisible) {
+ hideNow();
+ } else {
+ handleShow();
+ }
+ },
+ [isVisible, hideNow, handleShow],
+ );
+
const tooltipElement = isVisible && (
{children}