From 593409dbc8797a660e6b1b7a6d8d565c29e1a14d Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Tue, 8 Sep 2026 15:43:10 +0900 Subject: [PATCH] feat: let a Tooltip trigger be a control `toggleable` gives the trigger role="button" with an aria-expanded that tracks the content, and makes Enter and Space toggle it. Both keys are prevented from their defaults, since Space scrolls the page and Enter submits an enclosing form. Off by default, so no existing trigger changes. A trigger whose content only supplements what is already on screen is not a control and should not say it is, which is why this is opt-in rather than the other way round. A glossary term is the opposite case: the definition is summoned deliberately, and without hover, activation is the only way to it. Both continuum-hub and backend.ai-go had built this same contract around a glossary term (tabIndex, role="button", aria-describedby, aria-expanded, Enter and Space) before this component existed. Adopting it cost the first of them that affordance, which is not a reasonable price for sharing code, and would have cost the second the same. Six tests, each checked against the perturbation that should break it: dropping the role, dropping aria-expanded, dropping the preventDefault, wiring the handler unconditionally, and handling Enter without Space. --- CHANGELOG.md | 16 +++++ src/components/Tooltip/Tooltip.test.tsx | 93 +++++++++++++++++++++++++ src/components/Tooltip/Tooltip.tsx | 50 ++++++++++++- 3 files changed, 157 insertions(+), 2 deletions(-) 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}