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}