From 38e6fa2bd8c82e9404b4739a64d0506c7961f503 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 1 Nov 2026 08:05:29 -0800 Subject: [PATCH] feat(Tooltip): pressTrigger="toggle" pins the tooltip on a press `component:Tooltip` FR9 and FR10, `spec:AST-003` FR4: - `pressTrigger` (`none`, the default, or `toggle`) on Tooltip and useTooltip, with the type TooltipPressTrigger. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the tooltip and the same press closes it; hover and focus still preview 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 `pressTrigger` that changes after mount wires or unwires the trigger, and a tooltip whose `pressTrigger` turns to `none` 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). - LayerPressTrigger 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/press-trigger-toggle.md | 7 + .../TooltipPressToggleA11y.stories.tsx | 151 ++++ docs/specs/components/Tooltip/Tooltip.spec.md | 43 +- .../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 | 687 ++++++++++++++++++ packages/core/src/Layer/usePressPin.ts | 550 ++++++++++++++ packages/core/src/Layer/useTouchTrigger.ts | 39 +- packages/core/src/Tooltip/Tooltip.doc.mjs | 27 +- packages/core/src/Tooltip/Tooltip.tsx | 42 +- packages/core/src/Tooltip/index.ts | 1 + packages/core/src/Tooltip/useTooltip.doc.mjs | 4 +- packages/core/src/Tooltip/useTooltip.tsx | 96 ++- .../useAnnounceRenderedText.adopters.test.tsx | 18 +- 15 files changed, 1899 insertions(+), 56 deletions(-) create mode 100644 .changeset/press-trigger-toggle.md create mode 100644 apps/storybook/stories/TooltipPressToggleA11y.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/press-trigger-toggle.md b/.changeset/press-trigger-toggle.md new file mode 100644 index 0000000000000..3bbe7a53074d4 --- /dev/null +++ b/.changeset/press-trigger-toggle.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Tooltip (and `useTooltip`) take `pressTrigger` (type `TooltipPressTrigger`): `none` (the default, unchanged) or `toggle`. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the tooltip open and the same press closes it, while hover and focus still preview 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`, Tooltip renders nothing around the trigger to stop it: the anchor's own `onClick` runs, Astryx clickable rows, cards, and containers ignore the press, and a plain `onClick` element around the anchor receives it unless the anchor'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; beside `pressTrigger="toggle"`, `touchTrigger` is ignored. + +@vjeux diff --git a/apps/storybook/stories/TooltipPressToggleA11y.stories.tsx b/apps/storybook/stories/TooltipPressToggleA11y.stories.tsx new file mode 100644 index 0000000000000..3804a6ab8a600 --- /dev/null +++ b/apps/storybook/stories/TooltipPressToggleA11y.stories.tsx @@ -0,0 +1,151 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file TooltipPressToggleA11y.stories.tsx + * @input Uses Tooltip, Button, ClickableCard and Layout from core + * @output The fixtures the press-toggle 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 `pressTrigger="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 press toggle', + // Driven by the press-toggle 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 `pressTrigger="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 4250811dd0265..b97a5ee738b0b 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 `pressTrigger="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 `pressTrigger="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 `pressTrigger`, default `none`; `touchTrigger="tap"` deprecated (DEP-0016); `touchTrigger` folds into `pressTrigger` at the next minor (CLN-0024). FR9, FR10, and AR6 are not yet built; ten further implementation gaps (see Current-state impact). | +| Compatibility/readiness | Additive `pressTrigger`, default `none`; `touchTrigger="tap"` deprecated (DEP-0016); `touchTrigger` folds into `pressTrigger` at the next minor (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`. | @@ -131,11 +133,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). -- **Press-to-pin is not built.** `pressTrigger`, its 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. +- **DEP-0016 is not built.** `touchTrigger="tap"` carries no `@deprecated` + metadata yet, and the `astryx upgrade` codemod DEP-0016 names does not exist + yet; `pressTrigger="toggle"` is built (FR9, FR10). +- **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 + `pressTrigger="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` @@ -203,18 +206,18 @@ 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, a completed tap opens per `touchTrigger` 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 `auto`, 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). Implementation gap: the walk ships with CLN-0024 in the next minor; until then `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 `pressTrigger="toggle"`, FR9 decides what a tap does and `touchTrigger` is ignored. | 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 `pressTrigger="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 `pressTrigger="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..527694e34d5fa --- /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..85017412043c8 --- /dev/null +++ b/packages/core/src/Layer/usePressPin.test.tsx @@ -0,0 +1,687 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file usePressPin.test.tsx + * @input Uses vitest, @testing-library/react, Tooltip, usePressPin + * @output Proof of `pressTrigger="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 pressTrigger="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 pressTrigger 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 pressTrigger turns to none closes on leave again', () => { + vi.useFakeTimers(); + const tip = (pressTrigger?: '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('pressTrigger="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('pressTrigger="toggle"'), + ); + expect(calls).toHaveLength(1); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('Tooltip pressTrigger="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..f3e85759f51ed --- /dev/null +++ b/packages/core/src/Layer/usePressPin.ts @@ -0,0 +1,550 @@ +// 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 + * `pressTrigger` 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; LayerPressTrigger + * 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 keeps deciding taps while + * `pressTrigger` is `none` + * + * With `pressTrigger="toggle"` and a trigger whose role is `button` (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.