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