From 3870f5e73eff39ad5d6644d214b5698d01dcdc2c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 1 Nov 2026 12:08:21 -0800 Subject: [PATCH] feat(Tooltip): triggerBehavior="toggle" pins the tooltip on a press `component:Tooltip` FR9 and FR10, `spec:AST-003` FR4: - `triggerBehavior` on Tooltip and useTooltip, with the type TooltipTriggerBehavior: `adaptive`, the default and the released behavior (a tap opens the tooltip unless the trigger acts; an explicit `touchTrigger` still decides taps under it until the next minor); `hover` (touch never opens it); or `toggle`. Hover and focus preview it under every value. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the tooltip and the same press closes it. - A pinned tooltip stays until Escape, a second press, a press or scroll outside (the touch trigger's outside dismissal, which the pin arms: the pin owns the layer's touch trigger, so useTooltip makes one usePressPin call), or focus moving onto an element outside the trigger and the tooltip (the pin listens for it on the trigger, whatever `focusTrigger` is); a press on the tooltip's own text keeps it. It takes Escape before the layer beneath it. A `triggerBehavior` that changes after mount wires or unwires the trigger, and a tooltip whose `triggerBehavior` turns to `adaptive` while pinned closes on leave again. - The press stops at its trigger. 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 (FR9). The trigger carries aria-expanded and aria-controls on a button trigger, and the tooltip's rendered text is announced once when it pins, through useAnnounceRenderedText (`spec:AST-086` FR12). - A non-button trigger gets one development warning and keeps tap-only opening (`Layer/usePressPin.ts`, the hover layers' shared press pin). - LayerTriggerBehavior and the touch trigger's armOutsideDismiss (useTouchTriggerInternal) stay internal: neither is exported from the Layer entry point, and the public useTouchTrigger return is unchanged. --- .changeset/trigger-behavior-toggle.md | 7 + .../TooltipToggleBehaviorA11y.stories.tsx | 151 ++++ docs/specs/components/Tooltip/Tooltip.spec.md | 46 +- .../TouchTrigger.a11y.chromium.spec.ts | 144 ++++ .../src/Layer/layerDismissalFamilies.test.tsx | 34 +- .../core/src/Layer/usePressPin.calls.test.tsx | 112 +++ packages/core/src/Layer/usePressPin.test.tsx | 720 ++++++++++++++++++ packages/core/src/Layer/usePressPin.ts | 538 +++++++++++++ packages/core/src/Layer/useTouchTrigger.ts | 39 +- packages/core/src/Tooltip/Tooltip.doc.mjs | 27 +- packages/core/src/Tooltip/Tooltip.tsx | 46 +- packages/core/src/Tooltip/index.ts | 1 + packages/core/src/Tooltip/useTooltip.doc.mjs | 6 +- packages/core/src/Tooltip/useTooltip.tsx | 109 ++- .../useAnnounceRenderedText.adopters.test.tsx | 18 +- 15 files changed, 1938 insertions(+), 60 deletions(-) create mode 100644 .changeset/trigger-behavior-toggle.md create mode 100644 apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx create mode 100644 packages/core/src/Layer/usePressPin.calls.test.tsx create mode 100644 packages/core/src/Layer/usePressPin.test.tsx create mode 100644 packages/core/src/Layer/usePressPin.ts 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..24aa83bf7045f --- /dev/null +++ b/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx @@ -0,0 +1,151 @@ +// 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, and a trigger whose tooltip opens on no focus. + * @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: () => ( +
+ + + + + + +
+ ), +}; diff --git a/docs/specs/components/Tooltip/Tooltip.spec.md b/docs/specs/components/Tooltip/Tooltip.spec.md index cb705452a8698..a5a560f342db4 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 rewrites icon and span triggers and reports the rest (DEP-0016), and CLN-0024 names each remaining kind's replacement at the next minor. | -| 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; ten 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; eleven 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`. | @@ -133,12 +135,12 @@ 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). +- **FR9's own-text pin is not built.** A trigger whose tooltip repeats its own + visible text, such as a truncated Text, gets FR9's development warning under + `triggerBehavior="toggle"` 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` @@ -206,19 +208,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. Implementation gap: `auto` recognizes only an explicit non-negative `tabindex`, HTML links, buttons, form fields, and editable elements, so a `` or an SVG link never opens on keyboard focus; and it reads the tab order only when it attaches the trigger's listeners, on mount and again when `focusTrigger`, `isEnabled`, `isOpen`, `delay`, `hideDelay`, or `onOpenChange` changes, so a trigger rendered with `tabindex="-1"` does not open on focus until such a re-render follows its move into the tab order (Current-state impact). | 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..d26cc95d51bc9 --- /dev/null +++ b/packages/core/src/Layer/usePressPin.test.tsx @@ -0,0 +1,720 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file usePressPin.test.tsx + * @input Uses vitest, @testing-library/react, Tooltip, 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 + * @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 {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); + }); +}); + +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..2c3ac8cc44805 --- /dev/null +++ b/packages/core/src/Layer/usePressPin.ts @@ -0,0 +1,538 @@ +// 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; 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). A trigger that is not a button gets a development + * warning and keeps tap-only opening. + * + * 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 the wrapper can stop them (`stopPressPinClick`). */ +const pressPinClicks = new WeakSet(); + +/** + * 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'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'; + + // 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 && + 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 !== '') { + 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; + } + if (!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); + 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. + const isWired = isToggleMode && offeredKind === 2; + useDevWarning( + options.component, + '`triggerBehavior="toggle"` pins only a button trigger (a + , + ); + 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.