diff --git a/.changeset/hover-card-trigger-behavior.md b/.changeset/hover-card-trigger-behavior.md
new file mode 100644
index 0000000000000..566d6f00c72a6
--- /dev/null
+++ b/.changeset/hover-card-trigger-behavior.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feat] HoverCard (and `useHoverCard`) take `triggerBehavior` (type `HoverCardTriggerBehavior`): `adaptive`, the default and the released behavior, where hover and focus preview the card and a tap opens it unless the trigger performs an action of its own; `hover`, where hover and focus preview it and touch never opens it; or `toggle`, where hover and focus still preview it and a click, a tap, Enter, or Space on a button trigger pins it open, and the same press closes it. A pinned card stays until Escape, a second press, a press outside, or focus leaving; a press on its own content keeps it, and it takes Escape before the layer beneath it. On the `HoverCard` component 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 `useHoverCard` and a caller's trigger, nothing is rendered around the trigger to stop it: the trigger's own `onClick` runs, Astryx clickable rows, cards, and containers ignore the press, and a plain `onClick` element around the trigger receives it unless the trigger's `onClick` stops it. The trigger reports `aria-expanded` and `aria-controls`, and the card's `label` (else its text) is announced once when it pins, inside a modal too (`component:HoverCard` FR9 and FR10). On a trigger that is not a button, a labelled card keeps its own popup state and tap-only opening; `touchTrigger` decides taps only under `adaptive`; `hover` and `toggle` ignore it.
+
+@vjeux
diff --git a/.changeset/touch-trigger-tap-deprecated.md b/.changeset/touch-trigger-tap-deprecated.md
new file mode 100644
index 0000000000000..1f25f6803493e
--- /dev/null
+++ b/.changeset/touch-trigger-tap-deprecated.md
@@ -0,0 +1,8 @@
+---
+'@astryxdesign/core': patch
+'@astryxdesign/cli': patch
+---
+
+[feat] `touchTrigger="tap"` on Tooltip, HoverCard, `useTooltip` and `useHoverCard` is deprecated in favor of `triggerBehavior="toggle"` (DEP-0016); `touchTrigger` remains supported until the next minor; only `"tap"` is deprecated now. It keeps working as released, and beside `triggerBehavior="hover"` or `"toggle"`, `touchTrigger` is ignored. A `tap` call resolves to a `@deprecated` signature, so editors mark it with its replacement, and the docs name it. `astryx upgrade`, run explicitly, moves `touchTrigger` to `triggerBehavior` ahead of the removal, each rewrite keeping its behavior: it rewrites static `touchTrigger="tap"` to `triggerBehavior="toggle"` on an icon or span trigger with no `role`, `tabIndex`, event handler, or spread props, rewrites static `touchTrigger="none"` to `triggerBehavior="hover"`, and removes static `touchTrigger="auto"`, the default, where no props are spread; a trigger rewritten to the toggle keeps its production behavior, tap-only opening, and in development it logs the warning that only a button trigger pins. Every other `tap` site keeps its prop with a TODO naming the manual migration, and the upgrade lists each one with its file and line among the manual migrations it cannot make: a button, a link, an element with a `role`, `tabIndex`, event handler, or spread props, a trigger whose layer repeats its own text, a `useTooltip` or `useHoverCard` options object, an element it cannot identify, a computed value, or spread props, even beside a static `triggerBehavior="toggle"`; a `touchTrigger="none"` beside spread props or a computed `triggerBehavior` is listed the same way. A core codemod reports such a site by passing a `ManualMigrationRequired` to `api.report`, and still keeps its changes to the file. The table-comparison page template now uses the toggle; its modality rail becomes a button so it can pin its explanation.
+
+@vjeux
diff --git a/.changeset/trigger-behavior-toggle.md b/.changeset/trigger-behavior-toggle.md
new file mode 100644
index 0000000000000..a62594c3e4365
--- /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 and the released behavior, where hover and focus preview the tooltip and a tap opens it unless the trigger performs an action of its own; `hover`, where hover and focus preview it and no touch or pen tap opens it; or `toggle`, where hover and focus still preview it and a click, a tap, Enter, or Space on a button trigger pins it open, and the same press closes 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`, or `useTooltip` with a caller's trigger, nothing is rendered around the trigger to stop it: the trigger's own `onClick` runs, Astryx clickable rows, cards, and containers ignore the press, and a plain `onClick` element around the trigger receives it unless the trigger'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; `touchTrigger` decides taps only under `adaptive`; `hover` and `toggle` ignore it.
+
+@vjeux
diff --git a/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx b/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx
new file mode 100644
index 0000000000000..44af02f8fb498
--- /dev/null
+++ b/apps/storybook/stories/TooltipToggleBehaviorA11y.stories.tsx
@@ -0,0 +1,201 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file TooltipToggleBehaviorA11y.stories.tsx
+ * @input Uses Tooltip, HoverCard, Button, ClickableCard and Layout from core
+ * @output The fixtures the toggle-behavior 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, a trigger whose tooltip opens on no focus, one trigger per
+ * `triggerBehavior` value, and an unlabelled HoverCard of two paragraphs on
+ * a button.
+ * @position The reproduction path for the `triggerBehavior="toggle"` cases in
+ * `packages/core/src/Layer/__tests__/TouchTrigger.a11y.chromium.spec.ts`
+ * (`component:Tooltip` FR9, FR10; `component:HoverCard` 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 {HoverCard} from '@astryxdesign/core/HoverCard';
+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: () => (
+
+
+
+
+
+
+
+
+ ),
+};
+
+/** One info-only trigger per `triggerBehavior` value: a tap opens, never opens, or pins. */
+export const TriggerBehaviors: Story = {
+ render: () => (
+
+
+
+ Adaptive term
+
+
+ Hover term
+
+
+
+
+
+
+ ),
+};
+
+/**
+ * An unlabelled card of two paragraphs on a button: a press pins it, its text
+ * is announced with the paragraphs apart, and a press on that text, which
+ * moves focus to no element, keeps it pinned.
+ */
+export const HoverCardToggle: Story = {
+ render: () => (
+
+ ),
+};
diff --git a/docs/specs/components/HoverCard/HoverCard.spec.md b/docs/specs/components/HoverCard/HoverCard.spec.md
index 7c76b3929de96..6210be64be6ea 100644
--- a/docs/specs/components/HoverCard/HoverCard.spec.md
+++ b/docs/specs/components/HoverCard/HoverCard.spec.md
@@ -12,6 +12,7 @@ owners: [cixzhang]
review_triggers: [public-api, behavior, theming, accessibility]
verified_by:
[
+ packages/core/src/Layer/usePressPin.test.tsx,
packages/core/src/HoverCard/HoverCard.test.tsx,
packages/core/src/Layer/layerDismissalFamilies.test.tsx,
packages/core/src/Layer/useTouchTrigger.test.ts,
@@ -43,7 +44,7 @@ system_specs: [spec:AST-003, spec:AST-017]
| Behavior | A floating card of rich, possibly interactive content anchored to a trigger. It opens on hover after `delay`, on focus, and on a qualifying tap, stays open while the pointer is on the trigger or card, and closes on leave, Escape, or a tap outside. With `label` it is a named dialog the trigger advertises; without, a group that describes the trigger. With `triggerBehavior="hover"`, touch never 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 open the preview, move into it to use its actions, and dismiss it; screen-reader users hear it as a named popup dialog or as the trigger's description. With the toggle, a pinned card stays open while people read or use it, whatever the pointer does. |
| Builder impact | A trigger whose information lives only in the card 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 (`component:Tooltip` DEP-0016), and `component:Tooltip` 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 and `touchTrigger` removed at the next minor, `triggerBehavior="adaptive"` keeping its `auto` behavior and `triggerBehavior="hover"` its `none` behavior (`component:Tooltip` DEP-0016, CLN-0024). FR9, FR10, and AR3 are not yet built; eight further implementation gaps (see Current-state impact). |
+| Compatibility/readiness | Additive `triggerBehavior`, default `adaptive`, the released behavior; `touchTrigger="tap"` deprecated and `touchTrigger` removed at the next minor, `triggerBehavior="adaptive"` keeping its `auto` behavior and `triggerBehavior="hover"` its `none` behavior (`component:Tooltip` DEP-0016, CLN-0024). Eight implementation gaps (see Current-state impact). |
| Review checks | Reject an unnamed `dialog`, a card that cannot be hovered or dismissed (WCAG 1.4.13), Escape in the page that also closes a layer underneath, or `aria-expanded` set on a trigger whose role does not support it, a pinned card that closes on pointer leave, 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`. |
@@ -93,10 +94,6 @@ Consumer migration instructions belong in consumer docs and release notes.
so a name or ID inside a clickable card opens its card on the tap that opens
the card behind it. The walk ships with `component:Tooltip` CLN-0024 (FR5,
DEC-1).
-- **Toggle pinning is not built.** `triggerBehavior` and its three values, the
- toggle (FR9), and the trigger wiring and announcement (FR10) are specified
- and not implemented. `touchTrigger="tap"` is the shipped way to open on every
- tap, and `touchTrigger="none"` the shipped way to open on no tap.
- **A held-closed card still opens on focus.** With `isOpen={false}`, the hover
path stays shut but focus on the trigger opens the card, so a caller cannot
hold it closed. Escape then only reports `onOpenChange(false)`, which the
@@ -161,19 +158,19 @@ Consumer migration instructions belong in consumer docs and release notes.
## Behavioral and layout contract
-| ID | Invariant | Basis |
-| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
-| FR1 | The open card is one surface carrying only the `hover-card` target, padded on every logical edge, with `content` inside and the theme's body font. `xstyle`, `className`, and `style` apply to the card. | Current source 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 inside an inline-safe wrapper. | Current source and tests |
-| FR3 | With `label`, the card is `role="dialog"` named by `label`; the trigger gets `aria-haspopup="dialog"` and, only when its role supports it, `aria-expanded`, each replacing the trigger's own value while the card is wired, plus `aria-controls` naming the card while open, merged with the trigger's own; the trigger's own values return on cleanup. Without `label`, the card is `role="group"` and the trigger's `aria-describedby` gains the card's id. | Current source and tests |
-| FR4 | On a hover-capable pointer, entering the trigger opens the card after `delay` ms; leaving the trigger or the card closes it after `hideDelay` ms, and moving between them keeps it open. Any focus on the trigger, keyboard, pointer, or programmatic, opens it per `focusTrigger`, except the focus a tap leaves behind, the refocus after an Escape dismiss (FR6), and the refocus after a controlled close (FR7). Implementation gap: under `auto` the tab order is read only when the trigger's listeners attach, on mount and on a re-render that re-attaches them, 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). | WCAG 1.4.13; tests |
-| FR5 | On touch, a completed tap opens it per `triggerBehavior` when the finger lifts; a touch that becomes a scroll does not. Under `adaptive`, a tap that already acts leaves it shut, decided as `component:Tooltip` DEC-1 decides it: the innermost control from the tapped element outward, the trigger included, decides, and an `aria-disabled` control, or a label for a disabled control, is not an action (DEC-1). Until `component:Tooltip` 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 `component:Tooltip` 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 tap on the card's own content keeps it open; 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 card through the shared dismissal stack when it is the topmost layer in the page, so a layer underneath stays open. Implementation gap: a card 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). When focus was inside the card, it returns to the trigger, and that refocus does not reopen the card. | WCAG 1.4.13; tests |
-| FR7 | `isOpen` forces the card shown or hidden, and `isOpen={true}` suppresses hover and focus hiding; a controlled card answers Escape by calling `onOpenChange(false)` without hiding, and setting `isOpen={false}` while focus is inside the card returns focus to the trigger. `isDefaultOpen` opens it on mount, still dismissible. Implementation gap: with `isOpen={false}`, focus on the trigger still opens the card, and Escape then only reports `onOpenChange(false)` (Current-state impact). `isEnabled={false}` disables the triggers. `onOpenChange` reports each show and hide. | Current source and tests |
-| FR8 | Content renders only while open; server markup and the first client render match, including a default-open card. `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 `