diff --git a/.changeset/trigger-behavior-toggle.md b/.changeset/trigger-behavior-toggle.md new file mode 100644 index 0000000000000..5b8eea01b74a9 --- /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, keeps the released behavior. `hover` previews the tooltip on hover and focus, and no touch or pen tap opens it. `toggle` pins the tooltip on a click, tap, Enter, or Space on a button trigger, and the same press closes it. Escape, a press outside, or focus leaving also closes it. The press stops at the trigger: its default action is prevented, the trigger's own handlers still run, and a clickable row, card, or label around it does nothing. With `anchorRef` or a caller's own trigger, a plain `onClick` element around the trigger still receives the press. The trigger reports `aria-expanded` and `aria-controls`, and the tooltip's text is announced once when it pins. A trigger that is not a button gets a development warning. `touchTrigger` now decides taps only under `adaptive`. + +@vjeux diff --git a/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx b/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx new file mode 100644 index 0000000000000..6d53a3f0829ba --- /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 Fixtures for the toggle-behavior browser tests. One is a button + * whose only job is its tooltip, alone and inside a clickable card. One is + * an anchored trigger with its own click handler, inside a clickable card. + * One is 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). Per `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 52500cb0795ad..8d2e0edbf6dcb 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, @@ -33,11 +35,19 @@ architecture: architecture:public-component-api, ] contributing: [] -system_specs: [spec:AST-003, spec:AST-017] +system_specs: [spec:AST-003, spec:AST-017, spec:AST-086/FR12, spec:AST-086/FR14] --- # Tooltip component contract + + +```json +{ + "paths": ["packages/core/src/Layer/usePressPin.ts"] +} +``` + ## Contract at a glance | Area | Contract | @@ -46,7 +56,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; 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`. | @@ -150,12 +160,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` @@ -214,19 +224,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..e55e78376fadf --- /dev/null +++ b/packages/core/src/Layer/usePressPin.test.tsx @@ -0,0 +1,818 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file usePressPin.test.tsx + * @input Uses vitest, @testing-library/react, Tooltip, usePressPin + * @output Tests of `triggerBehavior="toggle"`: a press pins and closes the + * layer, and a pin stays when the pointer leaves. Escape, a second press, a + * press outside, or focus leaving closes it. The trigger carries the toggle + * state, the press stops at the trigger, and a non-button trigger warns. + * @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 KeyboardEvent as ReactKeyboardEvent, + 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. A message written 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: ' '}); + fireEvent.keyUp(trigger, {key: ' '}); + expect(shows(TIP)).toBe(false); + }); + + it('Space on a role="button" trigger presses it on keyup, as on a native button', () => { + render( + + + About billing + + , + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + // The keydown scrolls nothing and pins nothing yet. + expect(fireEvent.keyDown(trigger, {key: ' '})).toBe(false); + expect(shows(TIP)).toBe(false); + fireEvent.keyUp(trigger, {key: ' '}); + expect(shows(TIP)).toBe(true); + // A keyup with no keydown on the trigger presses nothing. + fireEvent.keyUp(trigger, {key: ' '}); + expect(shows(TIP)).toBe(true); + }); + + it.each(['keydown', 'keyup'] as const)( + 'a role="button" trigger that clicks itself on Space %s pins once', + async timing => { + const clickOnSpace = (event: ReactKeyboardEvent) => { + if (event.key === ' ') { + event.currentTarget.click(); + } + }; + render( + + + About billing + + , + ); + const trigger = screen.getByRole('button', {name: 'About billing'}); + fireEvent.keyDown(trigger, {key: ' '}); + // A browser dispatches the keyup in a later task. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + fireEvent.keyUp(trigger, {key: ' '}); + expect(shows(TIP)).toBe(true); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }, + ); + + 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(); + }); +}); + +describe('a toggle trigger in another document', () => { + it('closes the pin when focus moves to another control in that document', () => { + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const frameWindow = iframe.contentWindow as Window & typeof globalThis; + const frameDocument = frameWindow.document; + // The frame has its own HTMLElement, without this file's popover stubs. + const frameElement = frameWindow.HTMLElement.prototype; + const frameMatches = frameElement.matches; + frameElement.showPopover = HTMLElement.prototype.showPopover; + frameElement.hidePopover = HTMLElement.prototype.hidePopover; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (frameElement as any).matches = function ( + this: HTMLElement, + selector: string, + ): boolean { + return selector === ':popover-open' + ? (popoverOpenState.get(this) ?? false) + : frameMatches.call(this, selector); + }; + const container = frameDocument.createElement('div'); + frameDocument.body.append(container); + const view = render( + <> + + + , + {container, baseElement: frameDocument.body}, + ); + try { + const trigger = view.getByRole('button', {name: 'About billing'}); + click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.focusOut(trigger, { + relatedTarget: view.getByRole('button', {name: 'Next'}), + }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + } finally { + view.unmount(); + iframe.remove(); + } + }); +}); diff --git a/packages/core/src/Layer/usePressPin.ts b/packages/core/src/Layer/usePressPin.ts new file mode 100644 index 0000000000000..98d07040c419a --- /dev/null +++ b/packages/core/src/Layer/usePressPin.ts @@ -0,0 +1,568 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file usePressPin.ts + * @input A hover layer's trigger, open state, show and hide, and its + * `triggerBehavior` and `touchTrigger` options + * @output usePressPin, the press toggle that pins a hover layer open; + * isPressPinTrigger; stopPressPinClick; the LayerTriggerBehavior type + * @position Core-internal: one call per hover layer, useTooltip's. It owns + * the layer's touch trigger, so the host makes no second touch-trigger call. + * + * 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 a tap opens it unless the trigger acts. While + * `touchTrigger` remains, it decides those taps. `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. It stops a press that pinned or + * closed the layer, after the trigger's own handlers ran. No clickable row, + * card, or container around the trigger then receives it. An anchored trigger + * has no wrapper, so a plain `onClick` element around it still receives the + * press. + */ +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, when it has a `label`. A press that pins the layer says + * the name once, from the press handler. The host then announces no + * rendered text. + */ + label?: string; + + /** + * The layer's `touchTrigger`. It decides taps under `adaptive`. Under + * `hover`, no touch or pen tap opens the layer. The toggle ignores it, and a + * trigger that 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, that is 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` changed 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. + * While it does, the host announces the layer's rendered text. A layer with + * a `label` is the exception: the press handler already said its name. + */ + 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); + } +} + +/** + * Pins a hover layer open on a press of a button trigger, under + * `triggerBehavior="toggle"`. A click, a completed tap, Enter, or Space pins + * the layer, and the same press closes it. Escape, a press or scroll outside, + * or focus that moves outside the trigger and the layer also closes it. A + * press on the layer's own text keeps it. A wired trigger carries + * `aria-expanded` and `aria-controls`, and its own values come back when it + * unwires. The press stops at the trigger: its default action is prevented, + * and the component's wrapper stops it after the trigger's own handlers. A + * trigger that is not a button gets a development warning and keeps tap-only + * opening. + */ +export function usePressPin(options: UsePressPinOptions): UsePressPinReturn { + const {triggerBehavior, isOpen, isLayerOpen, layerId} = options; + const isToggleMode = triggerBehavior === 'toggle'; + + // The handlers run from DOM listeners, so they read the latest options and + // announcer through refs. The announcer is the one for where the layer + // renders: inside a modal, the modal's own region. + 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 that the host offered last + // also lives in a ref, wired or not. A `triggerBehavior` change after mount + // then wires or unwires it, because 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 the host offered last: 0 is nothing yet, 1 a trigger that is not a + // button, 2 a button. It is a primitive, so a host that attaches the same + // trigger on every commit 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 closes a pinned layer through the + // pin, so the pin drops with it. That dismissal is a press that lifts + // outside the trigger and the layer, or a scroll that starts there. An + // unpinned layer 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 goes straight to useAnnounce from the press handler, with no + // Effect. + 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 that closed some other + // way, such as a leave after a change 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]); + + // A trigger that is not a native button gets no click from the browser for + // a key. So Enter presses it on keydown, and Space on keyup, as on a native + // button. The trigger's own key handler can also click it. A click with no + // pointer (`detail` 0) while Space is down, or in the task of a key press, + // is that same press. + const keyPressedRef = useRef(false); + const spaceHeldRef = 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 ( + event.detail === 0 && + (keyPressedRef.current || spaceHeldRef.current) + ) { + return; + } + toggle(); + }, + [toggle], + ); + const pressFromKey = useCallback(() => { + keyPressedRef.current = true; + setTimeout(() => { + keyPressedRef.current = false; + }, 0); + toggle(); + }, [toggle]); + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const current = optionsRef.current; + if ( + !current.isEnabled || + current.triggerBehavior !== 'toggle' || + event.isComposing || + (event.key !== 'Enter' && event.key !== ' ') + ) { + return; + } + // The press replaces the key's own action, such as a page scroll on Space. + event.preventDefault(); + if (event.repeat) { + return; + } + if (event.key === ' ') { + spaceHeldRef.current = true; + return; + } + pressFromKey(); + }, + [pressFromKey], + ); + // Only a Space that went down on the trigger presses it when it comes up. + const handleKeyUp = useCallback( + (event: KeyboardEvent) => { + if (event.key !== ' ' || !spaceHeldRef.current) { + return; + } + spaceHeldRef.current = false; + const current = optionsRef.current; + if (!current.isEnabled || current.triggerBehavior !== 'toggle') { + return; + } + pressFromKey(); + }, + [pressFromKey], + ); + + 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) => { + // A Space that comes up after focus left presses nothing. + spaceHeldRef.current = false; + if (!isHeldOpen()) { + return false; + } + const next = event.relatedTarget; + const trigger = triggerRef.current; + // A press decides focus that leaves for no element. A press on the + // layer's own text moves focus to the body and keeps the pin. A press + // outside closes it through the outside dismissal. The test uses the + // trigger's window: a node from another window is no `Node` of this one. + const view = trigger?.ownerDocument.defaultView; + if (view == null || !(next instanceof view.Node)) { + return true; + } + 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('keyup', handleKeyUp); + 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, handleKeyUp, handleFocusLeave]); + + // The element and the mode that the toggle settled last, wired or not. The + // same pair again, from a re-render or the mount after the ref attached, + // 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); + element.addEventListener('keyup', handleKeyUp); + } + // The pin listens for focus leaving the trigger itself, because a host + // can attach no focus listeners (a `focusTrigger="never"` tooltip). + element.addEventListener('focusout', handleFocusLeave); + ownStateRef.current = { + expanded: element.getAttribute('aria-expanded'), + controls: element.getAttribute('aria-controls'), + }; + triggerRef.current = element; + }, + [unwire, handlePress, handleKeyDown, handleKeyUp, 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.