diff --git a/.changeset/trigger-behavior-toggle.md b/.changeset/trigger-behavior-toggle.md new file mode 100644 index 0000000000000..a62594c3e4365 --- /dev/null +++ b/.changeset/trigger-behavior-toggle.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Tooltip (and `useTooltip`) take `triggerBehavior` (type `TooltipTriggerBehavior`): `adaptive`, the default and the released behavior, where hover and focus preview the tooltip and a tap opens it unless the trigger performs an action of its own; `hover`, where hover and focus preview it and no touch or pen tap opens it; or `toggle`, where hover and focus still preview it and a click, a tap, Enter, or Space on a button trigger pins it open, and the same press closes it. A pinned tooltip stays until Escape, a second press, a press outside, or focus leaving, and it takes Escape before the layer beneath it. The press stops at its trigger, so a clickable row, card, or label around it does nothing, and the trigger's own handlers still run. With `anchorRef`, or `useTooltip` with a caller's trigger, nothing is rendered around the trigger to stop it: the trigger's own `onClick` runs, Astryx clickable rows, cards, and containers ignore the press, and a plain `onClick` element around the trigger receives it unless the trigger's `onClick` stops it. The trigger reports `aria-expanded` and `aria-controls`, and the tooltip's text is announced once when it pins, inside a modal too (`component:Tooltip` FR9 and FR10). A trigger that is not a button gets a development warning and keeps tap-only opening; `touchTrigger` decides taps only under `adaptive`; `hover` and `toggle` ignore it. + +@vjeux diff --git a/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx b/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx new file mode 100644 index 0000000000000..0e9900fa766df --- /dev/null +++ b/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx @@ -0,0 +1,173 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file TooltipToggleBehaviorA11y.stories.tsx + * @input Uses Tooltip, Button, ClickableCard and Layout from core + * @output The fixtures the toggle-behavior proofs drive in a real browser: a + * button whose only job is its tooltip, the same trigger inside a clickable + * card, an anchored trigger with its own click handler inside a clickable + * card, a trigger whose tooltip opens on no focus, and one trigger per + * `triggerBehavior` value. + * @position The reproduction path for the `triggerBehavior="toggle"` cases in + * `packages/core/src/Layer/__tests__/TouchTrigger.a11y.chromium.spec.ts` + * (`component:Tooltip` FR9, FR10; `docs/specs/AST-009/spec.md` FR30: a + * browser claim is checked against a checked-in story). + * + * SYNC: The spec navigates to these story ids by name. Nothing at compile + * time catches a renamed export — the Chromium spec does, when it finds no + * story. + */ + +import {useRef, useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Button} from '@astryxdesign/core/Button'; +import {ClickableCard} from '@astryxdesign/core/ClickableCard'; +import {VStack} from '@astryxdesign/core/Layout'; +import {Tooltip} from '@astryxdesign/core/Tooltip'; + +const meta: Meta = { + title: 'a11y/Tooltip toggle behavior', + // Driven by the toggle-behavior spec, not photographed: the states under test + // are pins and closes across presses, which no single frame shows. + tags: ['no-visual'], + parameters: { + docs: { + description: { + component: + 'A button whose only job is its tooltip takes `triggerBehavior="toggle"`: a click, a tap, Enter, or Space pins the tooltip, and the same press closes it. Inside a clickable card, the press stops at the trigger.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** A press pins the tooltip open; the same press, Escape, or a press outside closes it. */ +export const ButtonToggle: Story = { + render: () => ( +
+ + + +
+ ), +}; + +function InAClickableCardRender() { + const [opened, setOpened] = useState(0); + return ( +
+ + setOpened(n => n + 1)}> + + Icon usage + + + + + + Card opened {opened} times + +
+ ); +} + +/** A press on the trigger pins its tooltip and stops there: the card does not open. */ +export const InAClickableCard: Story = { + render: () => , +}; + +function AnchoredActionInACardRender() { + const [opened, setOpened] = useState(0); + const [pressed, setPressed] = useState(0); + const anchorRef = useRef(null); + return ( +
+ + setOpened(n => n + 1)}> + + Icon usage + + + + + Card opened {opened} times + Button pressed {pressed} times + +
+ ); +} + +/** + * An anchored trigger with its own click handler, in a clickable card: the + * pinning press runs the trigger's own handler, and the card ignores it. + */ +export const AnchoredActionInACard: Story = { + render: () => , +}; + +/** A tooltip that opens on no focus: focus moving to another control still closes a pin. */ +export const FocusTriggerNever: Story = { + render: () => ( +
+ + + + + + +
+ ), +}; + +/** One info-only trigger per `triggerBehavior` value: a tap opens, never opens, or pins. */ +export const TriggerBehaviors: Story = { + render: () => ( +
+ + + Adaptive term + + + Hover term + + + + + +
+ ), +}; diff --git a/docs/specs/components/Tooltip/Tooltip.spec.md b/docs/specs/components/Tooltip/Tooltip.spec.md index 2afae938ac2b4..73c8da13956bc 100644 --- a/docs/specs/components/Tooltip/Tooltip.spec.md +++ b/docs/specs/components/Tooltip/Tooltip.spec.md @@ -12,6 +12,8 @@ owners: [cixzhang] review_triggers: [public-api, behavior, theming, accessibility] verified_by: [ + packages/core/src/Layer/usePressPin.test.tsx, + packages/core/src/Layer/layerDismissalFamilies.test.tsx, packages/core/src/Tooltip/Tooltip.test.tsx, packages/core/src/Layer/__tests__/TouchTrigger.a11y.chromium.spec.ts, packages/core/src/Tooltip/useTooltip.test.tsx, @@ -46,7 +48,7 @@ system_specs: [spec:AST-003, spec:AST-017] | Behavior | A `role="tooltip"` layer that describes its trigger. It opens on hover after `delay`, on keyboard focus, and on a qualifying tap; it stays open while the pointer moves onto it, and closes on leave, Escape, or pressing the trigger (a press that focuses a text field reopens it). With `triggerBehavior="hover"`, no touch or pen tap opens it. With `triggerBehavior="toggle"`, hover and focus preview it, and a click, tap, Enter, or Space on a button trigger pins it open until Escape, a second press, a press outside, or focus leaving. | | End-user impact | Mouse, keyboard, and touch users can each reveal the hint, move onto it, and dismiss it; screen-reader users hear it as the trigger's description. With the toggle, people on touch, keyboard, or a screen reader can keep it open to read it, and hear it once when it pins. | | Builder impact | A trigger whose information lives only in the tooltip passes `triggerBehavior="toggle"` and a button trigger (DEC-1). `touchTrigger="tap"` callers migrate by trigger kind: the deprecating release's codemod, an opt-in early migration when run explicitly, rewrites icon and span triggers and reports the rest (DEP-0016), and CLN-0024 names each remaining kind's replacement in 0.8.0, whose upgrade runs the codemod again. | -| Compatibility/readiness | Additive `triggerBehavior`, default `adaptive`, the released behavior; `touchTrigger="tap"` deprecated (DEP-0016); `touchTrigger` is removed at the next minor, `triggerBehavior="adaptive"` keeping its `auto` behavior and `triggerBehavior="hover"` its `none` behavior (CLN-0024). FR9, FR10, and AR6 are not yet built; nine further implementation gaps (see Current-state impact). | +| Compatibility/readiness | Additive `triggerBehavior`, default `adaptive`, the released behavior; `touchTrigger="tap"` deprecated (DEP-0016); `touchTrigger` is removed at the next minor, `triggerBehavior="adaptive"` keeping its `auto` behavior and `triggerBehavior="hover"` its `none` behavior (CLN-0024). DEP-0016's deprecation metadata and codemod are not yet built; ten further implementation gaps (see Current-state impact). | | Review checks | Reject a tooltip that cannot be hovered or dismissed (WCAG 1.4.13), one that opens on a tap's focus or on pointer focus of a trigger that is not a text field, a lost `aria-describedby` link, a pinned tooltip that closes on pointer leave or lets Escape pass to the layer beneath, a toggle without `aria-expanded`, or a non-button toggle trigger without the development warning. | | Governing rules | WCAG 1.4.13, 2.1.1; `family:overlay-dismissal`; `spec:AST-003` FR4; `spec:AST-017` (deprecation); `architecture:public-component-api` INV9, INV21; `architecture:layer-runtime`; `architecture:interaction-modality`; `architecture:component-theming-surface`. | @@ -135,12 +137,14 @@ Consumer migration instructions belong in consumer docs and release notes. `touchTrigger="auto"` asks only whether the trigger itself performs an action, so a truncated label inside a link opens its tooltip on the tap that follows the link. The walk ships with CLN-0024 (FR5, DEC-1). -- **Toggle pinning is not built.** `triggerBehavior` and its three values, the - toggle (FR9), the trigger wiring and announcement (FR10), and a pinned - tooltip's Escape priority (`spec:AST-003` FR4) are specified and not - implemented, and the codemod DEP-0016 names does not exist yet. - `touchTrigger="tap"` is the shipped way to open on every tap, and - `touchTrigger="none"` the shipped way to open on no tap. +- **DEP-0016 is not built.** `touchTrigger="tap"` carries no `@deprecated` + metadata yet, and the `astryx upgrade` codemod DEP-0016 names does not exist + yet; `triggerBehavior` is built (FR9, FR10, and FR5's rules for its values). +- **No trigger reaches FR9's own-text pin yet.** A trigger whose tooltip + repeats its own visible text pins without being a button only through an + internal option, and no component passes it yet, so a text trigger under + `triggerBehavior="toggle"` gets FR9's development warning and keeps tap-only + opening. - **The tooltip consumes Escape until AST-003 ships.** A visible, topmost tooltip handles Escape, as `family:overlay-dismissal` records, so the layer underneath it, such as a Dialog, stays open for that press. `spec:AST-003` @@ -199,19 +203,19 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| FR1 | The rendered tooltip contains one owned surface, `role="tooltip"`, carrying the `tooltip` target, at most 300 px wide, with the supplied `content` inside; content has no separate target and there is no arrow. | Current source, docs, and tests | -| FR2 | Trigger wiring: a single string or number child is wrapped in a focusable inline span; element `children` attach to their first element; `anchorRef` attaches to that element. The trigger's `aria-describedby` gains the tooltip id, merged with and later restored to any existing value. Implementation gap: the wiring runs only when the trigger's listeners attach, so a trigger element replaced after mount, or a trigger whose own `aria-describedby` changes, loses it until a re-render re-attaches them (Current-state impact). | Current source and tests | -| FR3 | On a hover-capable pointer, entering the trigger opens after `delay` ms; leaving closes after `hideDelay` ms. With `hideDelay` 0 a short bridge lets the pointer move onto the surface without closing it, and the surface stays open while hovered. | WCAG 1.4.13; tests | -| FR4 | Focus that matches `:focus-visible` opens the tooltip per `focusTrigger`: keyboard focus, programmatic focus that follows keyboard input (AR4), pointer focus on a text-entry trigger, which browsers mark focus-visible, and programmatic focus on a text-entry trigger unless a finger or pen pressed that trigger and the last input was a pointer press, and on any other trigger unless the last input was a pointer (after keyboard input, as in AR4, or before any input). Implementation gap: script focus after a pointer press that focused nothing opens it, because the hook reads `:focus-visible` alone (Current-state impact). Focus a tap leaves behind, and other pointer focus, does not. Focus leaving the trigger schedules the hide as a pointer leave does. Under `focusTrigger="auto"`, focus opens the tooltip on any focusable trigger, judged when focus arrives. | Current source and tests | -| FR5 | On touch and pen, a completed tap opens per `triggerBehavior` when the finger lifts; a touch that becomes a scroll, or a press the browser does not count as a tap, does not open it. Under `adaptive`, a tap that already acts leaves it shut: walking out from the tapped element, the trigger included, the innermost button, link, label, form control, option, menu item, or row or card made clickable with `useClickableContainer` decides, and an `aria-disabled` control, or a label for a disabled control, is not an action, so a disabled control's reason opens on a tap even inside a clickable row or card (DEC-1). Until CLN-0024 removes `touchTrigger`, an explicit `touchTrigger` decides instead under `adaptive`: `tap` opens on every tap and `none` on no tap. Implementation gap: the walk ships with CLN-0024 in the next minor; until then `adaptive`, like the shipped `touchTrigger="auto"`, asks only whether the trigger itself performs an action, apart from a disabled control (Compatibility and migration). A second tap on the trigger, a tap outside, or a scroll starting outside closes it. Under `hover`, no tap opens it. Under `toggle`, FR9 decides what a tap does. `hover` and `toggle` ignore `touchTrigger`. | Current source and tests | -| FR6 | Escape closes a visible tooltip; Escape during text composition is ignored. Escape routing follows `family:overlay-dismissal`: a visible topmost tooltip in the page handles that Escape, so a layer underneath stays open for that press. Implementation gap: a tooltip rendered into another document, such as an iframe or a Picture-in-Picture window, with no layer beneath it is never closed by Escape (Current-state impact). `spec:AST-003` FR4, accepted and not yet implemented, makes an unpinned tooltip passive and gives a pinned one Escape first (Current-state impact). Outside `triggerBehavior="toggle"`, pressing an uncontrolled tooltip's trigger closes it; a press that focuses a text-entry trigger reopens it through FR4. | WCAG 1.4.13; tests | -| FR7 | `isOpen` forces the tooltip shown or hidden, and `isOpen={true}` suppresses hover and focus hiding; a controlled tooltip answers Escape by calling `onOpenChange(false)` without hiding, and is not closed by pressing the trigger. `isDefaultOpen` opens it on mount, still dismissible. Implementation gap: with `isOpen={false}`, any focus FR4 opens on, keyboard focus or a click into a text-field trigger, still opens the tooltip and reports `onOpenChange(true)`, and Escape then only reports `onOpenChange(false)`, so it stays until focus or the pointer leaves the trigger or the tooltip, or the caller re-renders with a new `onOpenChange` (Current-state impact). `isEnabled={false}` disables triggers and drops a pending open. | Current source and tests | -| FR8 | `onOpenChange` reports each show and hide. `hasHoverIndication` (`auto`: a single string or number child) adds a hover cue to the inline trigger. | Current source and tests | -| FR9 | With `triggerBehavior="toggle"` and a trigger whose role is `button` (a ` + + , + ); + fireEvent.click(screen.getByRole('button', {name: 'Info'})); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalled(); + expect(screen.getByRole('button', {name: 'Info'})).toHaveAttribute( + 'aria-expanded', + 'true', + ); + vi.mocked(HTMLElement.prototype.hidePopover).mockClear(); + + let event = new KeyboardEvent('keydown'); + act(() => { + event = pressEscape(); + }); + + expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled(); + expect(screen.getByRole('button', {name: 'Info'})).toHaveAttribute( + 'aria-expanded', + 'false', + ); + expect(onDialogChange).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + }); + it('a controlled Tooltip takes the press and reports instead of hiding', () => { const onTipChange = vi.fn(); const onDialogChange = vi.fn(); diff --git a/packages/core/src/Layer/usePressPin.calls.test.tsx b/packages/core/src/Layer/usePressPin.calls.test.tsx new file mode 100644 index 0000000000000..6587832f16efd --- /dev/null +++ b/packages/core/src/Layer/usePressPin.calls.test.tsx @@ -0,0 +1,112 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file usePressPin.calls.test.tsx + * @input Tooltip, with usePressPin, the touch trigger hooks and + * useLayerDismissal wrapped in call counters + * @output Unit tests: the layer's hook makes one usePressPin call per render, + * and that call is the only path to the touch trigger + * (`architecture:react-component-runtime` INV16) + * @position Beside usePressPin.test.tsx, which covers what the pin does + * + * Each host calls useLayerDismissal once per render, so a host that makes one + * usePressPin call per render shows as equal pin and render counts. Every + * touch trigger call must come from inside usePressPin, and the public + * useTouchTrigger is never called. + */ + +import {fireEvent, render, screen} from '@testing-library/react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import type * as PressPinModule from './usePressPin'; +import type * as TouchTriggerModule from './useTouchTrigger'; +import type * as LayerDismissalModule from './useLayerDismissal'; + +const calls = vi.hoisted(() => ({ + pin: 0, + touch: 0, + publicTouch: 0, + touchOutsidePin: 0, + render: 0, + isInPin: false, +})); + +vi.mock('./usePressPin', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + usePressPin: (options: PressPinModule.UsePressPinOptions) => { + calls.pin += 1; + calls.isInPin = true; + try { + return actual.usePressPin(options); + } finally { + calls.isInPin = false; + } + }, + }; +}); + +vi.mock('./useTouchTrigger', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTouchTrigger: (options: TouchTriggerModule.UseTouchTriggerOptions) => { + calls.publicTouch += 1; + return actual.useTouchTrigger(options); + }, + useTouchTriggerInternal: ( + options: TouchTriggerModule.UseTouchTriggerOptions, + ) => { + calls.touch += 1; + if (!calls.isInPin) { + calls.touchOutsidePin += 1; + } + return actual.useTouchTriggerInternal(options); + }, + }; +}); + +vi.mock('./useLayerDismissal', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useLayerDismissal: ( + options: LayerDismissalModule.UseLayerDismissalOptions, + ) => { + calls.render += 1; + return actual.useLayerDismissal(options); + }, + }; +}); + +const {Tooltip} = await import('../Tooltip/Tooltip'); + +beforeEach(() => { + calls.pin = 0; + calls.touch = 0; + calls.publicTouch = 0; + calls.touchOutsidePin = 0; + calls.render = 0; +}); + +function expectOneCallPerRender() { + expect(calls.render).toBeGreaterThan(0); + expect(calls.pin).toBe(calls.render); + expect(calls.touch).toBe(calls.pin); + expect(calls.touchOutsidePin).toBe(0); + expect(calls.publicTouch).toBe(0); +} + +describe('usePressPin is each layer’s one call (react-component-runtime INV16)', () => { + it('Tooltip: one usePressPin call per render, the only path to the touch trigger', () => { + render( + + + , + ); + fireEvent.click(screen.getByRole('button', {name: 'About billing'})); + expectOneCallPerRender(); + }); +}); diff --git a/packages/core/src/Layer/usePressPin.test.tsx b/packages/core/src/Layer/usePressPin.test.tsx new file mode 100644 index 0000000000000..c0282e795d704 --- /dev/null +++ b/packages/core/src/Layer/usePressPin.test.tsx @@ -0,0 +1,781 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file usePressPin.test.tsx + * @input Uses vitest, @testing-library/react, Tooltip, useTooltipInternal, + * usePressPin + * @output Proof of `triggerBehavior="toggle"` (component:Tooltip FR9 and FR10): + * a press pins and closes the layer, a + * pinned layer survives the pointer leaving and closes on Escape, a second + * press, a press outside, or focus leaving; the trigger carries + * aria-expanded and aria-controls and the layer is announced once; the press + * stops at its trigger; a non-button trigger warns and keeps tap-only + * opening; a trigger whose layer repeats its text pins with no toggle state + * @position Unit tests beside usePressPin + * + * SYNC: When usePressPin.ts or useTooltip.tsx change, update these cases. + */ + +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterAll, + afterEach, +} from 'vitest'; +import {render, screen, fireEvent, act} from '@testing-library/react'; +import {useRef, useState, type ReactElement, type ReactNode} from 'react'; +import {Tooltip} from '../Tooltip/Tooltip'; +import {useTooltipInternal} from '../Tooltip/useTooltip'; +import {ClickableCard} from '../ClickableCard/ClickableCard'; +import {Dialog} from '../Dialog/Dialog'; +import {Item} from '../Item/Item'; +import {Table} from '../Table/Table'; +import {useTableRowExpansion} from '../Table/plugins/rowExpansion/useTableRowExpansion'; +import {announceMessage} from '../hooks/useAnnounce'; +import type * as UseAnnounceModule from '../hooks/useAnnounce'; +import {__resetInteractionModalityForTest} from '../utils/interactionModality'; +import {__resetDevWarnings} from '../utils/devWarning'; + +// The pin speaks through useAnnounce, which routes a message into an open +// modal's own region (spec:AST-086 FR14, its own tests); writing straight to +// the page-body region would go unheard behind a modal. +const {announce} = vi.hoisted(() => ({announce: vi.fn()})); +vi.mock('../hooks/useAnnounce', async importOriginal => ({ + ...(await importOriginal()), + announceMessage: vi.fn(() => null), + useAnnounce: () => announce, +})); + +const originalMatches = HTMLElement.prototype.matches; +const popoverOpenState = new WeakMap(); + +beforeAll(() => { + HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { + popoverOpenState.set(this, true); + }); + HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { + popoverOpenState.set(this, false); + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (HTMLElement.prototype as any).matches = function ( + this: HTMLElement, + selector: string, + ): boolean { + if (selector === ':popover-open') { + return popoverOpenState.get(this) ?? false; + } + return originalMatches.call(this, selector); + }; +}); + +afterAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (HTMLElement.prototype as any).matches = originalMatches; +}); + +beforeEach(() => { + __resetInteractionModalityForTest(); + __resetDevWarnings(); + vi.mocked(announceMessage).mockClear(); + announce.mockClear(); + // jsdom does not implement showModal/close. + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute('open', ''); + }); + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute('open'); + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** The layer holding `text`, when it is rendered. */ +function layerOf(text: string): HTMLElement | null { + return ( + screen + .queryByText(text, {exact: false}) + ?.closest('[popover]') ?? null + ); +} + +/** Whether the layer holding `text` shows. */ +function shows(text: string): boolean { + const layer = layerOf(text); + return layer != null && layer.matches(':popover-open'); +} + +/** A finger's tap, in the order a browser dispatches it. */ +function tap(element: HTMLElement) { + fireEvent.pointerEnter(element, {pointerType: 'touch'}); + fireEvent.pointerDown(element, {pointerType: 'touch'}); + fireEvent.pointerUp(element, {pointerType: 'touch'}); + fireEvent.mouseEnter(element); + fireEvent.click(element); +} + +/** A mouse click: the press, then its click. */ +function click(element: HTMLElement) { + fireEvent.pointerDown(element, {pointerType: 'mouse'}); + fireEvent.pointerUp(element, {pointerType: 'mouse'}); + fireEvent.click(element, {detail: 1}); +} + +function InfoTooltip({ + children, + ...props +}: { + children?: ReactNode; + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; +}): ReactElement { + return ( + + {children ?? ( + + )} + + ); +} + +const TIP = 'Billed on the first of each month'; + +describe('Tooltip triggerBehavior="toggle" (component:Tooltip FR9)', () => { + it('a click pins the tooltip open, and the same click closes it', () => { + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + click(trigger); + expect(shows(TIP)).toBe(true); + click(trigger); + expect(shows(TIP)).toBe(false); + }); + + it('a pinned tooltip stays when the pointer leaves the trigger', () => { + vi.useFakeTimers(); + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + click(trigger); + fireEvent.mouseLeave(trigger); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(shows(TIP)).toBe(true); + }); + + it('a tap pins it, and the next tap on the trigger closes it', () => { + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + tap(trigger); + expect(shows(TIP)).toBe(true); + tap(trigger); + expect(shows(TIP)).toBe(false); + }); + + it('Enter and Space on a role="button" trigger pin and close it', () => { + render( + + + About billing + + , + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + fireEvent.keyDown(trigger, {key: 'Enter'}); + expect(shows(TIP)).toBe(true); + fireEvent.keyDown(trigger, {key: ' '}); + expect(shows(TIP)).toBe(false); + }); + + it('Escape closes a pinned tooltip', () => { + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + click(trigger); + fireEvent.keyDown(trigger, {key: 'Escape'}); + expect(shows(TIP)).toBe(false); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('a press outside closes it, and a press on the tooltip keeps it', () => { + render( + <> + +

Elsewhere

+ , + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + click(trigger); + const layer = layerOf(TIP) as HTMLElement; + fireEvent.pointerDown(layer); + fireEvent.pointerUp(layer); + expect(shows(TIP)).toBe(true); + // The hover layers' one outside-dismissal owner (useTouchTrigger) closes + // it when the press outside lifts. + const elsewhere = screen.getByText('Elsewhere'); + fireEvent.pointerDown(elsewhere); + fireEvent.pointerUp(elsewhere); + expect(shows(TIP)).toBe(false); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('a scroll that starts outside closes it', () => { + render( + <> + +

Elsewhere

+ , + ); + click(screen.getByRole('button', {name: 'About billing'})); + fireEvent.pointerCancel(screen.getByText('Elsewhere')); + expect(shows(TIP)).toBe(false); + }); + + it('focus moving outside closes it', () => { + render( + <> + + + , + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + act(() => { + trigger.focus(); + }); + click(trigger); + expect(shows(TIP)).toBe(true); + act(() => { + screen.getByRole('button', {name: 'Next field'}).focus(); + }); + expect(shows(TIP)).toBe(false); + }); + + it('a press on the pinned tooltip’s own text keeps it, though focus moves to no element', () => { + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + act(() => { + trigger.focus(); + }); + click(trigger); + // In Chromium a press on text that takes no focus moves focus from the + // trigger to the body: the focusout names no element. + const text = screen.getByText(TIP); + fireEvent.pointerDown(text); + fireEvent.focusOut(trigger, {relatedTarget: null}); + fireEvent.pointerUp(text); + fireEvent.click(text); + expect(shows(TIP)).toBe(true); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('a triggerBehavior that turns to toggle after mount wires the trigger', () => { + const {rerender} = render( + + + , + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + expect(trigger).not.toHaveAttribute('aria-expanded'); + rerender( + + + , + ); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + click(trigger); + expect(shows(TIP)).toBe(true); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + rerender( + + + , + ); + expect(trigger).not.toHaveAttribute('aria-expanded'); + }); + + it('with focusTrigger="never", focus moving to another control closes a pinned tooltip', () => { + render( + <> + + + + + , + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + act(() => { + trigger.focus(); + }); + click(trigger); + expect(shows(TIP)).toBe(true); + act(() => { + screen.getByRole('button', {name: 'Next field'}).focus(); + }); + expect(shows(TIP)).toBe(false); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('a pinned tooltip whose triggerBehavior turns to adaptive closes on leave again', () => { + vi.useFakeTimers(); + const tip = (triggerBehavior?: 'toggle') => ( + + + + ); + const {rerender} = render(tip('toggle')); + const trigger = screen.getByRole('button', {name: 'About billing'}); + click(trigger); + expect(shows(TIP)).toBe(true); + rerender(tip()); + fireEvent.mouseLeave(trigger); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(shows(TIP)).toBe(false); + }); + + it('a controlled tooltip reports the toggled value and does not change itself (FR7)', () => { + const onOpenChange = vi.fn(); + render(); + click(screen.getByRole('button', {name: 'About billing'})); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(shows(TIP)).toBe(false); + }); + + it('a trigger that is not a button warns and keeps tap-only opening', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + render( + + Billing + , + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('triggerBehavior="toggle"'), + ); + const trigger = screen.getByText('Billing'); + click(trigger); + expect(shows(TIP)).toBe(false); + expect(trigger).not.toHaveAttribute('aria-expanded'); + tap(trigger); + expect(shows(TIP)).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it('warns once per trigger: at mount, and not again on re-render or re-attach', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const tip = (delay: number) => ( + + Billing + + ); + const {rerender} = render(tip(200)); + rerender(tip(300)); + rerender(tip(400)); + const calls = warn.mock.calls.filter(([message]) => + String(message).includes('triggerBehavior="toggle"'), + ); + expect(calls).toHaveLength(1); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('Tooltip triggerBehavior="adaptive" and "hover" (component:Tooltip FR5)', () => { + /** An info-only trigger: a tap on it performs no action of its own. */ + function Hint(props: {triggerBehavior?: 'adaptive' | 'hover'}): ReactElement { + return ( + + Billing + + ); + } + + it('adaptive, the default: a tap on an info-only trigger opens it', () => { + render(); + tap(screen.getByText('Billing')); + expect(shows(TIP)).toBe(true); + }); + + it('hover: a tap never opens it, a mouse hover still previews it, and no press pins it', () => { + vi.useFakeTimers(); + render(); + const trigger = screen.getByText('Billing'); + tap(trigger); + expect(shows(TIP)).toBe(false); + fireEvent.pointerEnter(trigger, {pointerType: 'mouse'}); + fireEvent.mouseEnter(trigger); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(shows(TIP)).toBe(true); + click(trigger); + expect(trigger).not.toHaveAttribute('aria-expanded'); + }); +}); + +describe('Tooltip triggerBehavior="toggle" trigger state (component:Tooltip FR10)', () => { + it('carries aria-expanded, true only while pinned, and aria-controls while it shows', () => { + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).not.toHaveAttribute('aria-controls'); + click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(trigger).toHaveAttribute( + 'aria-controls', + (layerOf(TIP) as HTMLElement).id, + ); + // The description link is kept (FR2). + expect(trigger.getAttribute('aria-describedby')).toContain( + (layerOf(TIP) as HTMLElement).id, + ); + click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).not.toHaveAttribute('aria-controls'); + }); + + it('a hover preview leaves aria-expanded false', () => { + vi.useFakeTimers(); + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + fireEvent.pointerEnter(trigger, {pointerType: 'mouse'}); + fireEvent.mouseEnter(trigger); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(shows(TIP)).toBe(true); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it("restores the trigger's own aria-expanded and aria-controls on cleanup", () => { + const {unmount} = render( + + + , + ); + const trigger = screen.getByRole('button', {name: 'Menu'}); + click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + unmount(); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveAttribute('aria-controls', 'menu-1'); + }); + + it('announces its text once when it pins, and nothing for a preview', () => { + vi.useFakeTimers(); + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + fireEvent.pointerEnter(trigger, {pointerType: 'mouse'}); + fireEvent.mouseEnter(trigger); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(announce).not.toHaveBeenCalled(); + click(trigger); + expect(announce).toHaveBeenCalledTimes(1); + expect(announce).toHaveBeenCalledWith(TIP, 'polite'); + }); +}); + +describe('a toggle press stops at its trigger', () => { + it('a clickable row around the trigger does not activate, and the trigger’s own onClick runs', () => { + const onRow = vi.fn(); + const onOwn = vi.fn(); + render( +
+ + + +
, + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + tap(trigger); + expect(shows(TIP)).toBe(true); + expect(onOwn).toHaveBeenCalledTimes(1); + expect(onRow).not.toHaveBeenCalled(); + click(trigger); + expect(shows(TIP)).toBe(false); + expect(onRow).not.toHaveBeenCalled(); + }); + + it('a press inside a label does not operate the labelled control', () => { + render( + , + ); + click(screen.getByRole('button', {name: 'About billing'})); + expect(shows(TIP)).toBe(true); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('inside a ClickableCard, the card does not open', () => { + const onCard = vi.fn(); + render( + + Billing + + , + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + tap(trigger); + expect(shows(TIP)).toBe(true); + click(trigger); + expect(shows(TIP)).toBe(false); + expect(onCard).not.toHaveBeenCalled(); + }); + + describe('with anchorRef (component:Tooltip FR9: the caller’s own handlers still receive the press)', () => { + function Anchored({ + onOwn, + stopsOwnClick = false, + }: { + onOwn?: () => void; + stopsOwnClick?: boolean; + }) { + const ref = useRef(null); + return ( + <> + + + + ); + } + + it('an action anchor’s own onClick fires on a pinning press', () => { + const onOwn = vi.fn(); + render(); + const trigger = screen.getByRole('button', {name: 'About billing'}); + click(trigger); + expect(shows(TIP)).toBe(true); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(onOwn).toHaveBeenCalledTimes(1); + click(trigger); + expect(shows(TIP)).toBe(false); + expect(onOwn).toHaveBeenCalledTimes(2); + }); + + it('an Astryx ClickableCard around the anchor does not open', () => { + const onCard = vi.fn(); + render( + + Billing + + , + ); + click(screen.getByRole('button', {name: 'About billing'})); + expect(shows(TIP)).toBe(true); + expect(onCard).not.toHaveBeenCalled(); + }); + + it('an Astryx Item row around the anchor does not activate', () => { + const onRow = vi.fn(); + render( + } />, + ); + click(screen.getByRole('button', {name: 'About billing'})); + expect(shows(TIP)).toBe(true); + expect(onRow).not.toHaveBeenCalled(); + }); + + it('an Astryx Table row that expands on click does not expand', () => { + function Rows() { + const [expanded, setExpanded] = useState(new Set()); + const expansion = useTableRowExpansion<{id: string; name: string}>({ + expandedKeys: expanded, + onToggle: key => + setExpanded(previous => { + const next = new Set(previous); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }), + getRowKey: item => item.id, + renderExpanded: item =>
{item.name}
, + hasRowClickExpansion: true, + }); + return ( + }, + ]} + idKey="id" + plugins={{expansion}} + /> + ); + } + render(); + click(screen.getByRole('button', {name: 'About billing'})); + expect(shows(TIP)).toBe(true); + expect(screen.queryByTestId('panel')).not.toBeInTheDocument(); + }); + + it('a plain onClick row receives the press, unless the anchor’s own onClick stops it', () => { + const onRow = vi.fn(); + const {unmount} = render( +
+ +
, + ); + click(screen.getByRole('button', {name: 'About billing'})); + expect(shows(TIP)).toBe(true); + expect(onRow).toHaveBeenCalledTimes(1); + unmount(); + onRow.mockClear(); + render( +
+ +
, + ); + click(screen.getByRole('button', {name: 'About billing'})); + expect(shows(TIP)).toBe(true); + expect(onRow).not.toHaveBeenCalled(); + }); + }); + + it('inside an Item row, the row does not activate', () => { + const onRow = vi.fn(); + render( + } />, + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + click(trigger); + expect(shows(TIP)).toBe(true); + tap(trigger); + expect(shows(TIP)).toBe(false); + expect(onRow).not.toHaveBeenCalled(); + }); + + it('without the toggle, a tap still reaches the row (released behavior)', () => { + const onRow = vi.fn(); + render( +
+ + + +
, + ); + tap(screen.getByRole('button', {name: 'About billing'})); + expect(onRow).toHaveBeenCalledTimes(1); + }); +}); + +/** A truncated Text that is its own tab stop: its tooltip repeats its text. */ +function RepeatingText({text}: {text: string}): ReactElement { + const tooltip = useTooltipInternal({ + triggerBehavior: 'toggle', + repeatsTriggerText: true, + }); + return ( + <> + + {text} + + {tooltip.renderTooltip({text})} + + ); +} + +describe('a trigger whose tooltip repeats its own text (component:Tooltip FR9)', () => { + it('pins without being a button, with no warning, toggle state, or announcement', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + render(); + const trigger = screen.getAllByText('Quarterly revenue by region')[0]; + click(trigger); + const full = document.querySelector('[data-full]'); + expect( + full?.closest('[popover]')?.matches(':popover-open'), + ).toBe(true); + expect(trigger).not.toHaveAttribute('aria-expanded'); + expect(trigger).not.toHaveAttribute('aria-controls'); + expect(announce).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + fireEvent.keyDown(trigger, {key: 'Enter'}); + expect( + full?.closest('[popover]')?.matches(':popover-open'), + ).toBe(false); + } finally { + warn.mockRestore(); + } + }); + + it('inside an Astryx ClickableCard, with no wrapper to stop the press, the card does not open', () => { + const onCard = vi.fn(); + render( + + + , + ); + click(screen.getAllByText('Quarterly revenue by region')[0]); + const full = document.querySelector('[data-full]'); + expect( + full?.closest('[popover]')?.matches(':popover-open'), + ).toBe(true); + expect(onCard).not.toHaveBeenCalled(); + }); +}); + +describe('a pin inside a modal Dialog (spec:AST-086 FR14)', () => { + it('is announced through the modal, not the page body behind it', async () => { + render( + {}} aria-label="Billing"> + + , + ); + click(screen.getByRole('button', {name: 'About billing'})); + expect(shows(TIP)).toBe(true); + expect(announce).toHaveBeenCalledWith(TIP, 'polite'); + expect(announceMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/Layer/usePressPin.ts b/packages/core/src/Layer/usePressPin.ts new file mode 100644 index 0000000000000..115a9606b3704 --- /dev/null +++ b/packages/core/src/Layer/usePressPin.ts @@ -0,0 +1,566 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file usePressPin.ts + * @input A hover layer's trigger, its open state, show and hide, and its + * `triggerBehavior` and `touchTrigger` options + * @output usePressPin: the press toggle that pins a hover layer open + * (component:Tooltip FR9 and FR10), with the layer's touch trigger wired to + * it; isPressPinTrigger, the button test it applies; isPressPinClick and + * stopPressPinClick, the stop at the trigger; LayerTriggerBehavior type + * (Core's own, not exported from the Layer entry point) + * @position One call per hover layer that pins on a press, useTooltip's + * (`architecture:react-component-runtime` INV16): it owns the layer's touch + * trigger (useTouchTriggerInternal), which decides taps under `adaptive` + * (an explicit `touchTrigger` included), opens on no tap under `hover`, + * and pins under `toggle` + * + * Under `triggerBehavior="toggle"`, a click, a completed tap, Enter, or Space + * on a button trigger pins the layer and the same press closes it; Escape, a + * press or scroll outside (the touch trigger's outside dismissal, which the + * pin arms), or focus leaving the trigger and the layer (the pin listens on + * the trigger it wires) closes a pin, and a press on the layer's own text + * keeps it. A controlled layer only reports the toggled value (`report`). + * While wired, the trigger carries `aria-expanded` and `aria-controls`, + * restored on unwiring; a named layer's `label` is announced from the press + * handler (spec:AST-086 FR14), else the host announces its rendered text + * (FR12). The press stops at the trigger: its default action is prevented, + * and the component's wrapper stops it after the trigger's own handlers + * (`stopPressPinClick`); an anchored trigger has no wrapper, so a plain + * `onClick` element around it receives the press unless the trigger stops it + * (component:Tooltip FR9); Astryx clickable containers ignore a pinning + * press (`isPressPinClick`), even from a trigger that is not a nested button. + * A trigger that is not a button gets a development warning and keeps + * tap-only opening; one whose layer repeats its own visible text + * (`repeatsTriggerText`, a truncated Text that is its own tab stop) pins + * without being a button, with no warning, toggle state, or announcement. + * + * SYNC: When modified, update: + * - /packages/core/src/Tooltip/useTooltip.tsx + * - /packages/core/src/Layer/usePressPin.test.tsx + */ + +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type RefObject, +} from 'react'; +import {useAnnounce} from '../hooks/useAnnounce'; +import {useDevWarning} from '../hooks/useDevWarning'; +import { + useTouchTriggerInternal, + type LayerTouchTrigger, + type UseTouchTriggerReturn, +} from './useTouchTrigger'; + +/** + * How the trigger opens the layer: `adaptive` (the default) previews it on + * hover and focus and opens it on a tap unless the trigger acts, as + * `touchTrigger` decides while it remains; `hover` previews it on hover and + * focus, and no touch or pen tap opens it; `toggle` pins it on every press of + * a button trigger. + */ +export type LayerTriggerBehavior = 'adaptive' | 'hover' | 'toggle'; + +/** The clicks a press pin answered, so wrappers can stop them (`isPressPinClick`). */ +const pressPinClicks = new WeakSet(); + +/** + * Whether a press pin answered this click: the component wrapping the trigger + * stops it there, and a clickable container ignores it, so the press does + * nothing beyond opening or closing its layer. + */ +export function isPressPinClick(event: Event): boolean { + return pressPinClicks.has(event); +} + +/** + * The layer component's wrapper handler: a press that pinned or closed the + * layer stops here, after the trigger's own handlers ran, so no clickable + * row, card, or container around the trigger receives it + * (component:Tooltip FR9). + */ +export function stopPressPinClick(event: ReactMouseEvent): void { + if (pressPinClicks.has(event.nativeEvent)) { + event.stopPropagation(); + } +} + +/** Whether presses on this element can pin a layer: a button, by role. */ +export function isPressPinTrigger(element: Element): boolean { + const role = element.getAttribute('role'); + if (role != null && role !== '') { + return role === 'button'; + } + return element.tagName === 'BUTTON'; +} + +export interface UsePressPinOptions { + /** The layer's `triggerBehavior`. */ + triggerBehavior: LayerTriggerBehavior; + + /** Whether the layer's triggers are live at all. */ + isEnabled: boolean; + + /** The controlled open value, or `undefined` when uncontrolled. */ + isOpen: boolean | undefined; + + /** Whether the layer shows now. */ + isLayerOpen: boolean; + + /** The layer surface's element id. */ + layerId: string; + + /** The component its development warning names. */ + component: string; + + /** Shows the layer now, with no delay. */ + show: () => void; + + /** Hides the layer now. */ + hide: () => void; + + /** Reports a controlled layer's toggled value without changing it. */ + report: (isOpen: boolean) => void; + + /** + * The layer repeats the trigger's own visible text (a truncated Text that is + * its own tab stop): a press pins it without a button trigger, with no + * development warning, no `aria-expanded` or `aria-controls`, and no + * announcement, since its text is already the trigger's content. + */ + repeatsTriggerText?: boolean; + + /** + * The layer's name (a named layer's `label`), when it has one: a press that + * pins the layer says it once, from the press handler, in place of the + * rendered text the host announces otherwise. + */ + label?: string; + + /** + * The layer's `touchTrigger`, which decides taps under `adaptive`. Under + * `hover` no touch or pen tap opens the layer; under the toggle it is ignored: a + * trigger no press can pin keeps tap-only opening. + */ + touchTrigger: LayerTouchTrigger; + + /** The host's trigger element, which the touch trigger reads. */ + triggerRef: RefObject; +} + +export interface UsePressPinReturn { + /** Whether presses on this trigger pin the layer: toggle mode and a button. */ + isToggleTrigger: (element: HTMLElement | null) => boolean; + + /** + * Whether a press holds the layer open now: under the toggle, on a wired + * trigger, a pin while the layer shows (or a controlled layer's open + * value). The host's hover and focus hides ask it, so a layer whose + * `triggerBehavior` turned to `adaptive` while pinned closes on leave again. + */ + isHeldOpen: () => boolean; + + /** Wires the trigger element, or unwires it with `null`. */ + attach: (element: HTMLElement | null) => void; + + /** Drops the pin: Escape, or a hide that came from elsewhere. */ + unpin: () => void; + + /** + * While pinned, focus moving onto an element outside the trigger and the + * layer closes it, and any other focus move keeps it. Returns whether the + * pin decided. The pin calls it for focus leaving the trigger it wires; a + * host calls it for focus leaving the layer's own content. + */ + handleFocusLeave: (event: FocusEvent) => boolean; + + /** + * Whether the layer shows pinned on a trigger that carries the toggle state: + * the host announces the layer's rendered text while it is, unless the + * layer has a `label`, which the press handler already said. + */ + isPinShown: boolean; + + /** The layer's touch trigger (useTouchTriggerInternal), wired to the pin. */ + touch: Pick< + UseTouchTriggerReturn, + | 'isTouchPointerRef' + | 'isTouchInteraction' + | 'handlePointerEnter' + | 'handlePointerDown' + | 'clearTapOpen' + >; +} + +function restoreAttribute( + element: HTMLElement, + name: string, + value: string | null, +): void { + if (value == null) { + element.removeAttribute(name); + } else { + element.setAttribute(name, value); + } +} + +function writeAttribute( + element: HTMLElement, + name: string, + value: string | null, +): void { + if (element.getAttribute(name) !== value) { + restoreAttribute(element, name, value); + } +} + +export function usePressPin(options: UsePressPinOptions): UsePressPinReturn { + const {triggerBehavior, isOpen, isLayerOpen, layerId} = options; + const isToggleMode = triggerBehavior === 'toggle'; + const wiresState = options.repeatsTriggerText !== true; + + // The handlers run from DOM listeners: they read the latest options, and + // the announcer for where the layer renders (inside a modal, the modal's + // own region), here. + const optionsRef = useRef(options); + optionsRef.current = options; + const announce = useAnnounce(); + const announceRef = useRef(announce); + announceRef.current = announce; + + const isPinnedRef = useRef(false); + const [isPinned, setIsPinnedState] = useState(false); + const setPinned = useCallback((next: boolean) => { + isPinnedRef.current = next; + setIsPinnedState(next); + }, []); + + // The wired trigger lives in a ref. The element the host last offered stays + // in a ref too, wired or not, so a `triggerBehavior` that changes after mount + // wires or unwires it (the host's ref does not run again); what the render + // needs to know about it is state. + const triggerRef = useRef(null); + const offeredRef = useRef(null); + // What kind of element the host offered last: 0 none yet, 1 a trigger that + // is not a button, 2 a button. A primitive, so a host that re-attaches the + // same trigger on every commit sets the same value and renders nothing. + const [offeredKind, setOfferedKind] = useState<0 | 1 | 2>(0); + // The trigger's own aria-expanded and aria-controls, restored on unwiring. + const ownStateRef = useRef<{ + expanded: string | null; + controls: string | null; + } | null>(null); + + // The touch trigger's outside dismissal (a press that lifts outside the + // trigger and the layer, or a scroll that starts there) closes a pinned + // layer through the pin, so the pin drops with it; otherwise it hides as a + // tap-open does. + const hideFromTouch = useCallback(() => { + const current = optionsRef.current; + if (current.triggerBehavior === 'toggle' && triggerRef.current != null) { + if (current.isOpen !== undefined) { + current.report(false); + return; + } + if (isPinnedRef.current) { + setPinned(false); + } + } + current.hide(); + }, [setPinned]); + + const { + isTouchPointerRef, + isTouchInteraction, + handlePointerEnter, + handlePointerDown, + clearTapOpen, + armOutsideDismiss, + } = useTouchTriggerInternal({ + touchTrigger: isToggleMode + ? 'tap' + : triggerBehavior === 'hover' + ? 'none' + : options.touchTrigger, + isEnabled: options.isEnabled, + isControlled: isOpen !== undefined, + isOpen: isLayerOpen, + layerId, + triggerRef: options.triggerRef, + show: options.show, + hide: hideFromTouch, + }); + + const isToggleTrigger = useCallback( + (element: HTMLElement | null) => + optionsRef.current.triggerBehavior === 'toggle' && + element != null && + (optionsRef.current.repeatsTriggerText === true || + isPressPinTrigger(element)), + [], + ); + + const close = useCallback(() => { + const current = optionsRef.current; + clearTapOpen(); + if (current.isOpen !== undefined) { + current.report(false); + return; + } + setPinned(false); + current.hide(); + }, [setPinned, clearTapOpen]); + + // A string, so it goes straight to useAnnounce, with no Effect (FR14). + const announceName = useCallback(() => { + const current = optionsRef.current; + const name = current.label?.trim() ?? ''; + if (name !== '' && current.repeatsTriggerText !== true) { + announceRef.current(name, 'polite'); + } + }, []); + + const toggle = useCallback(() => { + const current = optionsRef.current; + if (current.isOpen !== undefined) { + if (!current.isOpen) { + armOutsideDismiss(); + announceName(); + } else { + clearTapOpen(); + } + current.report(!current.isOpen); + return; + } + // A pin counts only while its layer shows: a layer closed some other way + // (a leave after `triggerBehavior` turned to `adaptive`) pins on the next press. + if (isPinnedRef.current && current.isLayerOpen) { + setPinned(false); + clearTapOpen(); + current.hide(); + } else { + setPinned(true); + current.show(); + armOutsideDismiss(); + announceName(); + } + }, [setPinned, announceName, armOutsideDismiss, clearTapOpen]); + + // Enter or Space on a trigger that is not a native button: no click + // follows from the browser, so the key presses. A click its own key + // handler synthesizes in the same task is that press, not a second one. + const keyPressedRef = useRef(false); + const handlePress = useCallback( + (event: MouseEvent) => { + const current = optionsRef.current; + if (!current.isEnabled || current.triggerBehavior !== 'toggle') { + return; + } + // The press stops at its trigger: no label control, link, or submit. + event.preventDefault(); + pressPinClicks.add(event); + if (keyPressedRef.current && event.detail === 0) { + return; + } + toggle(); + }, + [toggle], + ); + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const current = optionsRef.current; + if ( + !current.isEnabled || + current.triggerBehavior !== 'toggle' || + event.repeat || + event.isComposing || + (event.key !== 'Enter' && event.key !== ' ') + ) { + return; + } + event.preventDefault(); + keyPressedRef.current = true; + setTimeout(() => { + keyPressedRef.current = false; + }, 0); + toggle(); + }, + [toggle], + ); + + const isHeldOpen = useCallback(() => { + const current = optionsRef.current; + if (current.triggerBehavior !== 'toggle' || triggerRef.current == null) { + return false; + } + return current.isOpen !== undefined + ? current.isOpen && current.isLayerOpen + : isPinnedRef.current && current.isLayerOpen; + }, []); + + const handleFocusLeave = useCallback( + (event: FocusEvent) => { + if (!isHeldOpen()) { + return false; + } + const next = event.relatedTarget; + // Focus that leaves for no element is a press's to decide: a press on + // the layer's own text moves it to the body and keeps the pin, and a + // press outside closes it through the outside dismissal. + if (!(next instanceof Node)) { + return true; + } + const trigger = triggerRef.current; + const surface = + trigger?.ownerDocument.getElementById(optionsRef.current.layerId) ?? + null; + if ( + trigger?.contains(next) === true || + surface?.contains(next) === true + ) { + return true; + } + close(); + return true; + }, + [isHeldOpen, close], + ); + + const unwire = useCallback(() => { + const previous = triggerRef.current; + if (previous == null) { + return; + } + previous.removeEventListener('click', handlePress); + previous.removeEventListener('keydown', handleKeyDown); + previous.removeEventListener('focusout', handleFocusLeave); + const own = ownStateRef.current; + if (own != null) { + restoreAttribute(previous, 'aria-expanded', own.expanded); + restoreAttribute(previous, 'aria-controls', own.controls); + } + ownStateRef.current = null; + triggerRef.current = null; + }, [handlePress, handleKeyDown, handleFocusLeave]); + + // The element and mode the toggle last settled, wired or not: the same pair + // again (the mount after the host's ref attached, a re-render) does nothing. + const settledRef = useRef<{ + element: HTMLElement | null; + mode: LayerTriggerBehavior; + } | null>(null); + + const wire = useCallback( + (element: HTMLElement | null) => { + const current = optionsRef.current; + const settled = settledRef.current; + if ( + settled != null && + settled.element === element && + settled.mode === current.triggerBehavior + ) { + return; + } + settledRef.current = {element, mode: current.triggerBehavior}; + unwire(); + if (element == null || current.triggerBehavior !== 'toggle') { + return; + } + const isRepeat = current.repeatsTriggerText === true; + if (!isRepeat && !isPressPinTrigger(element)) { + return; + } + element.addEventListener('click', handlePress); + if (element.tagName !== 'BUTTON') { + element.addEventListener('keydown', handleKeyDown); + } + // The pin's own focus-leave close, whatever focus listeners the host + // attaches (a `focusTrigger="never"` tooltip attaches none). + element.addEventListener('focusout', handleFocusLeave); + if (!isRepeat) { + ownStateRef.current = { + expanded: element.getAttribute('aria-expanded'), + controls: element.getAttribute('aria-controls'), + }; + } + triggerRef.current = element; + }, + [unwire, handlePress, handleKeyDown, handleFocusLeave], + ); + + const attach = useCallback( + (element: HTMLElement | null) => { + offeredRef.current = element; + // A detach is not recorded: hosts detach and re-attach their trigger + // within one commit, and a removed trigger wires nothing anyway. + if (element != null) { + setOfferedKind(isPressPinTrigger(element) ? 2 : 1); + } + wire(element); + }, + [wire], + ); + + // Whether the toggle wires the offered trigger: a button, or a trigger whose + // layer repeats its own text. + const isWired = + isToggleMode && offeredKind !== 0 && (!wiresState || offeredKind === 2); + useDevWarning( + options.component, + '`triggerBehavior="toggle"` pins only a button trigger (a + ) : ( + Billing + )} + , + ); + const opened = () => onOpenChange.mock.calls.some(([open]) => open); + return {trigger: screen.getByText('Billing'), opened}; + } + + /** A finger or a landed pen: down, up, then the click (a pen hovers, so no arrival). */ + function tap(element: HTMLElement, pointerType: 'touch' | 'pen') { + if (pointerType === 'touch') { + fireEvent.pointerEnter(element, {pointerType}); + } + fireEvent.pointerDown(element, {pointerType}); + fireEvent.pointerUp(element, {pointerType}); + fireEvent.click(element); + } + + const settle = async () => + new Promise(resolve => { + setTimeout(resolve, 50); + }); + + describe.each([ + ['adaptive by default', undefined, true], + ['adaptive', 'adaptive', true], + ['hover', 'hover', false], + ['toggle', 'toggle', true], + ] as const)('%s', (_, behavior, tapOpens) => { + it('a mouse hover previews it', async () => { + const {trigger, opened} = setup(behavior); + fireEvent.pointerEnter(trigger, {pointerType: 'mouse'}); + fireEvent.mouseEnter(trigger); + await waitFor(() => expect(opened()).toBe(true)); + }); + + it('keyboard focus previews it', async () => { + const {trigger, opened} = setup(behavior); + const realMatches = trigger.matches.bind(trigger); + vi.spyOn(trigger, 'matches').mockImplementation((selector: string) => + selector === ':focus-visible' ? true : realMatches(selector), + ); + fireEvent.keyDown(document, {key: 'Tab'}); + fireEvent.focusIn(trigger); + await waitFor(() => expect(opened()).toBe(true)); + }); + + it.each(['touch', 'pen'] as const)( + `a %s tap ${tapOpens ? 'opens' : 'never opens'} it`, + async pointerType => { + const {trigger, opened} = setup(behavior); + tap(trigger, pointerType); + await settle(); + expect(opened()).toBe(tapOpens); + }, + ); + + it.each(['mouse click', 'touch tap'] as const)( + `a %s on a button trigger ${behavior === 'toggle' ? 'pins it' : 'pins nothing'}`, + async press => { + const {trigger} = setup(behavior, undefined, 'button'); + if (press === 'mouse click') { + fireEvent.pointerDown(trigger, {pointerType: 'mouse'}); + fireEvent.pointerUp(trigger, {pointerType: 'mouse'}); + fireEvent.click(trigger); + } else { + tap(trigger, 'touch'); + } + await settle(); + expect(trigger.getAttribute('aria-expanded')).toBe( + behavior === 'toggle' ? 'true' : null, + ); + }, + ); + }); + + it.each([ + ['adaptive', 'none', 'span', 'decides: no tap opens it', false], + ['adaptive', 'tap', 'button', 'decides: every tap opens it', true], + ['hover', 'tap', 'button', 'is ignored: no tap opens it', false], + ['toggle', 'none', 'button', 'is ignored: a tap pins it', true], + ] as const)( + 'beside triggerBehavior="%s", touchTrigger="%s" on a %s %s', + async (behavior, touchTrigger, trigger, _, tapOpens) => { + const tip = setup(behavior, touchTrigger, trigger); + tap(tip.trigger, 'touch'); + await settle(); + expect(tip.opened()).toBe(tapOpens); + }, + ); +}); diff --git a/packages/core/src/Tooltip/Tooltip.tsx b/packages/core/src/Tooltip/Tooltip.tsx index c63cac264dcf8..bf40b6f7ee00c 100644 --- a/packages/core/src/Tooltip/Tooltip.tsx +++ b/packages/core/src/Tooltip/Tooltip.tsx @@ -25,12 +25,18 @@ import * as stylex from '@stylexjs/stylex'; import { useTooltip, type TooltipFocusTrigger, + type TooltipTriggerBehavior, type TooltipTouchTrigger, } from './useTooltip'; +import {stopPressPinClick} from '../Layer/usePressPin'; import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer'; import {colorVars} from '../theme/tokens.stylex'; -export type {TooltipFocusTrigger, TooltipTouchTrigger} from './useTooltip'; +export type { + TooltipFocusTrigger, + TooltipTriggerBehavior, + TooltipTouchTrigger, +} from './useTooltip'; const styles = stylex.create({ wrapperContents: { @@ -109,15 +115,39 @@ export interface TooltipProps { * - `auto`: tap opens the tooltip, unless the trigger performs an action of * its own (a button, a link, a form control) — that tap belongs to the * control, and a hint about a control the user just operated is noise - * - `tap`: tap always opens the tooltip. This is what an info icon rendered - * as a button wants: it looks like an action to the DOM, but revealing the - * tooltip is the only thing it does + * - `tap`: tap always opens the tooltip, even on a trigger that acts * - `none`: touch never opens the tooltip * + * It decides only under `triggerBehavior="adaptive"`, the default. + * * @default 'auto' */ touchTrigger?: TooltipTouchTrigger; + /** + * How the trigger opens the tooltip: + * - `adaptive` (the default): hover and focus preview it; a tap opens it + * unless the trigger performs an action of its own (`touchTrigger` + * decides while it remains) + * - `hover`: hover and focus preview it; no touch or pen tap opens it + * - `toggle`: on a button trigger, a click, a tap, Enter, or Space pins the + * tooltip open and the same press closes it; hover and keyboard focus + * still preview it. A pinned tooltip stays until Escape, a second press, + * a press outside, or focus leaving, and the press stops at the trigger: + * no clickable row or card around it activates. With `anchorRef`, the + * caller's own handlers still receive the press: the anchor's `onClick` + * runs, and a plain `onClick` element around the anchor receives it + * unless the anchor's `onClick` stops it; Astryx clickable rows, cards, + * and containers ignore it. The trigger reports + * `aria-expanded` and `aria-controls`, and the tooltip's text is + * announced once when it pins. Use it for a trigger whose information + * lives only in the tooltip, such as an info icon. `touchTrigger` is + * ignored. + * + * @default 'adaptive' + */ + triggerBehavior?: TooltipTriggerBehavior; + /** * Whether the tooltip is enabled. * When false, hover/focus triggers are disabled. @@ -201,6 +231,7 @@ export function Tooltip({ hideDelay = 0, focusTrigger = 'auto', touchTrigger = 'auto', + triggerBehavior = 'adaptive', isEnabled = true, onOpenChange, hasHoverIndication = 'auto', @@ -230,6 +261,7 @@ export function Tooltip({ hideDelay, focusTrigger, touchTrigger, + triggerBehavior, isEnabled, isOpen, isDefaultOpen, @@ -343,6 +375,7 @@ export function Tooltip({ ref={tooltip.ref} tabIndex={0} aria-describedby={tooltip.describedBy} + onClick={stopPressPinClick} {...stylex.props( styles.wrapperInline, showHoverIndication && styles.hoverIndication, @@ -357,7 +390,10 @@ export function Tooltip({ // For element children: use display:contents, ref on first child return ( <> -
+
{children}
{tooltip.renderTooltip(content)} diff --git a/packages/core/src/Tooltip/index.ts b/packages/core/src/Tooltip/index.ts index 99d411531f9bb..1dcef4e7d8458 100644 --- a/packages/core/src/Tooltip/index.ts +++ b/packages/core/src/Tooltip/index.ts @@ -15,6 +15,7 @@ export {useTooltip} from './useTooltip'; export type { TooltipFocusTrigger, + TooltipTriggerBehavior, TooltipTouchTrigger, TooltipOptions, TooltipReturn, diff --git a/packages/core/src/Tooltip/useTooltip.doc.mjs b/packages/core/src/Tooltip/useTooltip.doc.mjs index d1686ef659183..a2f3a3bcb10c6 100644 --- a/packages/core/src/Tooltip/useTooltip.doc.mjs +++ b/packages/core/src/Tooltip/useTooltip.doc.mjs @@ -12,7 +12,8 @@ export const docs = { {name: 'delay', type: 'number', description: 'Delay before showing on hover, in milliseconds.', default: '200'}, {name: 'hideDelay', type: 'number', description: 'Delay before hiding after mouse or focus leaves, in milliseconds.', default: '0'}, {name: 'focusTrigger', type: "'auto' | 'always' | 'never'", description: 'When focus should open the tooltip. auto opens when the trigger itself takes focus, or focus lands inside a trigger that is focusable at that moment, so a roving-tabindex member opens when the arrow keys reach it.', default: "'auto'"}, - {name: 'touchTrigger', type: "'auto' | 'tap' | 'none'", description: 'What a tap does where there is no hover. auto opens on tap unless the trigger performs an action of its own (an aria-disabled control performs none); tap always opens (an info icon rendered as a button); none never opens on touch.', default: "'auto'"}, + {name: 'touchTrigger', type: "'auto' | 'tap' | 'none'", description: 'What a tap does where there is no hover. auto opens on tap unless the trigger performs an action of its own (an aria-disabled control performs none); tap always opens; none never opens on touch. It decides only under triggerBehavior="adaptive", the default.', default: "'auto'"}, + {name: 'triggerBehavior', type: "'adaptive' | 'hover' | 'toggle'", description: 'How the trigger opens the tooltip. adaptive (the default) previews on hover and focus and opens on a tap unless the trigger acts (touchTrigger decides while it remains); hover previews on hover and focus, and no touch or pen tap opens it; toggle, on a button trigger, pins on a click, a tap, Enter, or Space and closes on the same press, Escape, a press outside, or focus leaving. The trigger carries aria-expanded and aria-controls, the tooltip is announced once when it pins. Astryx clickable rows and cards ignore the pinning press; a plain onClick ancestor still receives it from the hook (the Tooltip component stops it at a child trigger).', default: "'adaptive'"}, {name: 'isEnabled', type: 'boolean', description: 'Whether hover and focus triggers are enabled.', default: 'true'}, {name: 'isOpen', type: 'boolean', description: 'Controlled open state. true force-shows, false force-hides, undefined lets hover/focus manage visibility.'}, {name: 'isDefaultOpen', type: 'boolean', description: 'Whether the tooltip should be shown on mount.', default: 'false'}, @@ -50,7 +51,8 @@ export const docsDense = { delay: 'show delay in ms.', hideDelay: 'hide delay after mouse/focus leave in ms.', focusTrigger: 'when focus opens tooltip; auto = focus on the trigger, or inside a trigger focusable when focus arrives.', - touchTrigger: 'tap behavior with no hover; auto = tap unless trigger acts, tap = always, none = never.', + touchTrigger: 'tap behavior with no hover, under triggerBehavior adaptive only; auto = tap unless trigger acts, tap = always, none = never.', + triggerBehavior: 'adaptive (default) = hover/focus preview, tap opens unless the trigger acts; hover = no touch/pen tap opens; toggle = a press on a button trigger pins and closes; clickable rows and cards ignore it, and the Tooltip component also stops it at a child trigger.', isEnabled: 'whether hover/focus triggers are enabled.', isOpen: 'controlled open state: true force-show, false force-hide, undefined unmanaged.', isDefaultOpen: 'show on mount.', diff --git a/packages/core/src/Tooltip/useTooltip.test.tsx b/packages/core/src/Tooltip/useTooltip.test.tsx index 16d97f3384482..ce4b5a92ca738 100644 --- a/packages/core/src/Tooltip/useTooltip.test.tsx +++ b/packages/core/src/Tooltip/useTooltip.test.tsx @@ -24,6 +24,8 @@ import { import {render, screen, fireEvent, act} from '@testing-library/react'; import {useTooltip, type TooltipOptions} from './useTooltip'; import {useLayer} from '../Layer/useLayer'; +import {ClickableCard} from '../ClickableCard/ClickableCard'; +import type {MouseEvent as ReactMouseEvent} from 'react'; // jsdom implements neither the Popover API nor `:popover-open`; mirror // Tooltip.test.tsx's shims so the layer can open. @@ -595,3 +597,60 @@ describe('useTooltip — focusTrigger="auto" decides when focus arrives', () => expect(onHide).not.toHaveBeenCalled(); }); }); + +describe('useTooltip with a caller’s trigger under triggerBehavior="toggle" (component:Tooltip FR9)', () => { + function Tip({ + onTriggerClick, + }: { + onTriggerClick?: (event: ReactMouseEvent) => void; + }) { + const tooltip = useTooltip({triggerBehavior: 'toggle', delay: 0}); + return ( + <> + + {tooltip.renderTooltip('Billed monthly')} + + ); + } + const trigger = () => screen.getByRole('button', {name: 'What is this?'}); + + it('the trigger’s own onClick runs, and an Astryx ClickableCard around it does not activate', () => { + const onCard = vi.fn(); + const onTrigger = vi.fn(); + render( + + + , + ); + + fireEvent.click(trigger()); + + expect(onTrigger).toHaveBeenCalledTimes(1); + expect(onCard).not.toHaveBeenCalled(); + expect(trigger()).toHaveAttribute('aria-expanded', 'true'); + }); + + it('a plain onClick element around it receives the press, unless the trigger’s onClick stops it', () => { + const onRow = vi.fn(); + const {unmount} = render( +
+ +
, + ); + fireEvent.click(trigger()); + expect(onRow).toHaveBeenCalledTimes(1); + expect(trigger()).toHaveAttribute('aria-expanded', 'true'); + unmount(); + + onRow.mockClear(); + render( +
+ event.stopPropagation()} /> +
, + ); + fireEvent.click(trigger()); + expect(onRow).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/Tooltip/useTooltip.tsx b/packages/core/src/Tooltip/useTooltip.tsx index b9c8da0964ffb..4373bc2b139c7 100644 --- a/packages/core/src/Tooltip/useTooltip.tsx +++ b/packages/core/src/Tooltip/useTooltip.tsx @@ -26,12 +26,12 @@ import { type LayerAlignment, type LayerPlacement, } from '../Layer/useLayer'; -import { - useTouchTrigger, - type LayerTouchTrigger, -} from '../Layer/useTouchTrigger'; +import type {LayerTouchTrigger} from '../Layer/useTouchTrigger'; +import {useAnnounceRenderedText} from '../hooks/useAnnounceRenderedText'; +import {useMergedRefs} from '../hooks/useMergedRefs'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; import {registerLayerSurface} from '../Layer/layerSurfacePress'; +import {usePressPin, type LayerTriggerBehavior} from '../Layer/usePressPin'; import {useLayerDismissal} from '../Layer/useLayerDismissal'; import {themeProps} from '../utils/themeProps'; import { @@ -83,6 +83,15 @@ export type TooltipFocusTrigger = 'auto' | 'always' | 'never'; */ export type TooltipTouchTrigger = LayerTouchTrigger; +/** + * How the trigger opens the tooltip: `adaptive` (the default) previews it on + * hover and focus and opens it on a tap unless the trigger acts; `hover` + * previews it on hover and focus, and no touch or pen tap opens it; `toggle` + * pins it on a click, tap, Enter, or Space on a button trigger + * (component:Tooltip FR5, FR9). + */ +export type TooltipTriggerBehavior = LayerTriggerBehavior; + export interface TooltipOptions { /** * Position placement relative to anchor @@ -124,15 +133,36 @@ export interface TooltipOptions { * - `auto`: tap opens the tooltip, unless the trigger performs an action of * its own (a button, a link, a form control) — that tap belongs to the * control, and a hint about a control the user just operated is noise - * - `tap`: tap always opens the tooltip. This is what an info icon rendered - * as a button wants: it looks like an action to the DOM, but revealing the - * tooltip is the only thing it does + * - `tap`: tap always opens the tooltip, even on a trigger that acts * - `none`: touch never opens the tooltip * + * It decides only under `triggerBehavior="adaptive"`, the default. + * * @default 'auto' */ touchTrigger?: TooltipTouchTrigger; + /** + * How the trigger opens the tooltip: + * - `adaptive` (the default): hover and focus preview it; a tap opens it + * unless the trigger performs an action of its own (`touchTrigger` + * decides while it remains) + * - `hover`: hover and focus preview it; no touch or pen tap opens it + * - `toggle`: on a button trigger, a click, a tap, Enter, or Space pins the + * tooltip open and the same press closes it; hover and keyboard focus + * still preview it. A pinned tooltip stays until Escape, a second press, + * a press outside, or focus leaving. Astryx clickable rows and cards + * ignore the pinning press; the `Tooltip` component also stops it at a + * child trigger, and a hook caller stops a plain `onClick` ancestor itself. + * The trigger reports `aria-expanded` and `aria-controls`, and the + * tooltip's text is announced once when it pins. Use it for a trigger + * whose information lives only in the tooltip, such as an info icon. + * `touchTrigger` is ignored. + * + * @default 'adaptive' + */ + triggerBehavior?: TooltipTriggerBehavior; + /** * Whether the tooltip is enabled. * When false, hover/focus triggers are disabled. @@ -268,6 +298,21 @@ function isFocusable(element: HTMLElement): boolean { */ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { + return useTooltipInternal(options); +} + +/** + * `useTooltip` with the internal options Core components pass: + * `repeatsTriggerText` marks a tooltip that repeats its trigger's own visible + * text (a truncated Text that is its own tab stop), which + * `triggerBehavior="toggle"` pins without a button trigger and without toggle + * state (component:Tooltip FR9). + * + * @internal + */ +export function useTooltipInternal( + options: TooltipOptions & {repeatsTriggerText?: boolean} = {}, +): TooltipReturn { const { placement = 'above', alignment = 'center', @@ -275,6 +320,8 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { hideDelay = 0, focusTrigger = 'auto', touchTrigger = 'auto', + triggerBehavior = 'adaptive', + repeatsTriggerText = false, isEnabled = true, isOpen, isDefaultOpen = false, @@ -332,23 +379,46 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { hideLayer(); }, [clearTimeouts, hideLayer]); + // A press pins the tooltip under `triggerBehavior="toggle"` (FR9, FR10). The pin + // also owns the tooltip's touch trigger, so this is the one call that wires + // both (`architecture:react-component-runtime` INV16). const { - isTouchPointerRef, - isTouchInteraction, - handlePointerEnter, - handlePointerDown: handleTouchPointerDown, - clearTapOpen, - } = useTouchTrigger({ + isToggleTrigger, + isHeldOpen: isPressHeldOpen, + attach: attachPressPin, + unpin: unpinPress, + isPinShown, + touch: { + isTouchPointerRef, + isTouchInteraction, + handlePointerEnter, + handlePointerDown: handleTouchPointerDown, + clearTapOpen, + }, + } = usePressPin({ + triggerBehavior, touchTrigger, isEnabled, - isControlled: isOpen !== undefined, - isOpen: isLayerOpen, - layerId: layerId, + isOpen, + isLayerOpen, + layerId, triggerRef, + component: 'Tooltip', show: showNow, hide: hideNow, + report: next => (next ? onShow?.() : onHide?.()), + repeatsTriggerText, }); + // A pinned tooltip's own rendered text is said once when it pins (FR10, + // spec:AST-086 FR12). + const contentRef = useRef(null); + useAnnounceRenderedText(contentRef, isPinShown, 'pinned'); + const contentRefs = useMergedRefs( + contentRef, + registerLayerSurface, + ); + // Schedule show with delay (suppressed when isOpen is false) const scheduleShow = useCallback(() => { if (!isEnabled || isOpen === false) { @@ -376,7 +446,8 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { // trigger onto the tooltip surface without the tooltip vanishing — required // for WCAG 1.4.13 (Content on Hover or Focus: hoverable). const scheduleHide = useCallback(() => { - if (isOpen === true) { + // A pinned tooltip stays when the pointer or focus leaves (FR9). + if (isOpen === true || isPressHeldOpen()) { return; } clearTimeouts(); @@ -384,7 +455,7 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { hideTimeoutRef.current = setTimeout(() => { hideLayer(); }, effectiveHideDelay); - }, [isOpen, clearTimeouts, hideLayer, hideDelay]); + }, [isOpen, isPressHeldOpen, clearTimeouts, hideLayer, hideDelay]); // Cancel a pending hide (e.g. the pointer entered the tooltip surface). const cancelHide = useCallback(() => { @@ -482,6 +553,9 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { ], ); + // A pinned tooltip's focus-leave close is the pin's own: it listens on the + // trigger whatever `focusTrigger` is. This schedules the unpinned hide, + // which a held tooltip skips. const handleFocusOut = useCallback( (e: Event) => { if (!focusCountedRef.current && !isFocusForTrigger(e)) { @@ -502,6 +576,11 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { // tooltip at all), so the touch path answers it first. const handlePointerDown = useCallback( (event: PointerEvent) => { + // A press on a toggle trigger pins or closes through its click (FR9): + // it neither hides the preview here nor waits for a tap verdict. + if (isToggleTrigger(triggerRef.current)) { + return; + } if (handleTouchPointerDown(event)) { return; } @@ -511,7 +590,7 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { clearTimeouts(); hideLayer(); }, - [handleTouchPointerDown, isOpen, clearTimeouts, hideLayer], + [isToggleTrigger, handleTouchPointerDown, isOpen, clearTimeouts, hideLayer], ); // Interaction ref that handles event listeners only @@ -552,8 +631,10 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { } triggerRef.current = el; + attachPressPin(el); }, [ + attachPressPin, focusTrigger, handleMouseEnter, handleMouseLeave, @@ -641,6 +722,7 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { onDismiss: () => { clearTimeouts(); clearTapOpen(); + unpinPress(); if (isOpen !== undefined) { onHide?.(); return; @@ -676,8 +758,9 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { // ancestors, portaled or not. A registered surface counts as // something else in the shared interactive-ancestor walk, so a // clickable container around the trigger leaves the press alone while - // document listeners still see it. -
+ // document listeners still see it. The pin's announcement reads the + // same element's text (contentRefs). +
{children}
, renderProps, @@ -690,6 +773,7 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn { popoverXstyle, cancelHide, scheduleHide, + contentRefs, ], ); diff --git a/packages/core/src/hooks/useAnnounceRenderedText.adopters.test.tsx b/packages/core/src/hooks/useAnnounceRenderedText.adopters.test.tsx index e2330e9fd4a1e..db2bb62aa73cb 100644 --- a/packages/core/src/hooks/useAnnounceRenderedText.adopters.test.tsx +++ b/packages/core/src/hooks/useAnnounceRenderedText.adopters.test.tsx @@ -2,8 +2,9 @@ /** * @file useAnnounceRenderedText.adopters.test.tsx - * @input Every core component that announces its own rendered content, with - * the shared own-content module replaced by a recorder + * @input Every core component that announces its own rendered content + * (Tooltip, when a press pins it, among them), with the + * shared own-content module replaced by a recorder * @output architecture:react-component-runtime INV10 test: each adopter hands * its own-content announcement to the shared module and announces none of it * itself @@ -67,6 +68,7 @@ const {BaseTypeahead} = await import('../Typeahead/BaseTypeahead'); const {TokenizerWithPresentation} = await import('../internal/tokenizer-presentation'); const {createStaticSource} = await import('../Typeahead/createStaticSource'); +const {Tooltip} = await import('../Tooltip'); const source = createStaticSource([{id: 'apple', label: 'Apple'}]); @@ -164,6 +166,18 @@ const ADOPTERS: Record Promise> = { fireEvent.change(screen.getByRole('combobox'), {target: {value: 'zzz'}}); await expectDelegated('No results found'); }, + 'Tooltip/useTooltip.tsx': async () => { + // A tooltip a press pinned says its own text (component:Tooltip FR10). + render( + + + , + ); + fireEvent.click(screen.getByRole('button', {name: 'About billing'})); + await expectDelegated('Billed on the first of each month'); + }, 'Tokenizer/TokenizerBottomSheet.tsx': async () => { // The sheet's own content is a caller's `emptySearchText` element; the // default EmptyState announces itself. diff --git a/packages/core/src/hooks/useClickableContainer.ts b/packages/core/src/hooks/useClickableContainer.ts index a6781ce57f9e1..98b06494451ae 100644 --- a/packages/core/src/hooks/useClickableContainer.ts +++ b/packages/core/src/hooks/useClickableContainer.ts @@ -24,6 +24,7 @@ import {useCallback, useEffect, type RefObject, type MouseEvent} from 'react'; import {isLayerSurface} from '../Layer/layerSurfacePress'; import {isSafeUrl} from '../utils/safeUrl'; +import {isPressPinClick} from '../Layer/usePressPin'; /** * The attribute this hook sets on the container it makes clickable. A click @@ -211,7 +212,9 @@ export function useClickableContainer({ const onClick = useCallback( (event: MouseEvent) => { - if (disabled) { + // A press that pinned or closed a hover layer stops at its trigger: the + // container does not act on it (component:Tooltip FR9). + if (disabled || isPressPinClick(event.nativeEvent)) { return; }