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: () => (
+
+
+ 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 `