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.