Skip to content

feat(Tooltip, HoverCard, cli): deprecate touchTrigger="tap" (DEP-0016), with its upgrade codemod - #9246

Draft
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:feat/tooltip-touch-trigger-tap-deprecation
Draft

vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:feat/tooltip-touch-trigger-tap-deprecation

Conversation

@vjeux

@vjeux vjeux commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Status: draft, stacked on #9232 and the HoverCard pull request (#9291). It contains their commits (26d7af9, 5015adf) and this change (7290cb2) until they merge. Every local gate has finished at 7290cb2; results under Testing.

Review only this pull request's change: vjeux/astryx@5015adf...7290cb2

User need

DEP-0016 deprecates touchTrigger="tap" in favor of pressTrigger="toggle", which #9232 (Tooltip) and the HoverCard pull request (#9291) add. Callers need three things to move: to see the deprecation where they write tap, a mechanical migration for the sites where nothing else changes, and a list of every site they must migrate by hand. Without those, a release would ship the new option with no deprecation plan.

Problem and solution fit

  • Static metadata: a touchTrigger="tap" call resolves to a @deprecated signature of Tooltip, HoverCard, useTooltip and useHoverCard, so editors mark it with its replacement. auto, none, no value, and props a wrapper forwards resolve to signatures that are not deprecated. The docs (English and Chinese) and the JSDoc name DEP-0016.
  • Mechanical migration: the astryx upgrade codemod rewrites static touchTrigger="tap" to pressTrigger="toggle" only on an icon or span trigger (Core's Icon, an <svg>, a <span> of text and icons, or a lone string the layer wraps in a span), with no role, tabIndex, contentEditable, event handler or spread props on it or inside it. There the result is observably the same, since FR9 keeps a non-button toggle trigger on tap-only opening. It also removes static touchTrigger="auto" where no props are spread; beside spread props, which may set either prop, it leaves auto as it is.
  • Manual migrations, listed: every other tap site keeps its prop with a TODO naming the manual migration, and astryx upgrade lists each one with its file and line among the manual migrations it cannot make (spec:AST-017 FR29c). That covers a button, a link, an element with a role, tabIndex, event handler or spread props (FR9 would pin a role="button" one), a trigger whose layer repeats its own text, a useTooltip/useHoverCard options object (its trigger isn't visible there), an element it cannot identify, a computed value, and spread props, even beside a static pressTrigger="toggle", which a spread pressTrigger would override. The listing repeats on every run until the site is migrated.
    • To make that possible, a core codemod can now pass a ManualMigrationRequired to api.report and keep its changes to the file. Before, the only way to report one was to throw, which left the file unchanged and would have dropped the same file's rewrites.
  • Downstream migration: the CLI table-comparison page template's modality rail becomes a real <button type="button" aria-label="Model modalities"> around its icons, with pressTrigger="toggle", so it pins its explanation and carries no development warning.

Current specification

component:Tooltip DEP-0016 (its warning, migration, codemod and downstream rows), component:HoverCard (which applies DEP-0016), and spec:AST-017 FR29a, FR29c and FR29d. All are current at 1d8696a (#9175).

Public delta and caller burden

  • touchTrigger="tap" keeps working as released and is ignored beside pressTrigger="toggle". There's no runtime warning (spec:AST-017 FR29, FR29a).
  • Callers run astryx upgrade, then migrate each listed site by hand: pressTrigger="toggle" on a button whose only job is the layer; otherwise keep tap until the next minor (CLN-0024).
  • The Tooltip "Touch triggers" story's button site migrates by hand in a follow-up, since the codemod reports button sites and the codemod verify workflow requires changed consumer files to match the codemod's output.

API changes

Change Package Signature Usage
changed @astryxdesign/core Tooltip, HoverCard, useTooltip, useHoverCard: three overload signatures each. touchTrigger?: 'auto' | 'none'; touchTrigger: 'tap' is @deprecated (DEP-0016); the full props or options type for forwarded values. The implementation and every accepted value are unchanged. <Tooltip touchTrigger="tap" …> is marked deprecated in editors
added @astryxdesign/cli codemod migrate-touch-trigger-tap-to-press-trigger in the next release's astryx upgrade astryx upgrade --from <version> --apply
changed @astryxdesign/cli CodemodTransformApi.report / AstryxCodemodApi.report (signature unchanged): under astryx upgrade, a core transform that passes a ManualMigrationRequired reports a manual migration the upgrade lists, and the file keeps the transform's result api.report(new ManualMigrationRequired('Pass pressTrigger="toggle" by hand.', {line}))

Design and alternatives

  • TypeScript has no @deprecated for a single literal of a union, so the metadata rides an overload: tap resolves to a deprecated signature, and the overload that takes the full props keeps forwarded values unmarked and type-correct.
  • Comments stay the codemod's in-source report, as in the other codemods. The listing comes from the upgrade's existing manual-migration outcome, now reachable through api.report, so no new channel or receipt field is added.

Evidence

  • Red first:
    • Without the overloads (feat(Tooltip): pressTrigger="toggle" pins the tooltip on a press #9232's head), the deprecation test finds no deprecated call.
    • Without this change's codemod and adapter, the listing tests fail (2 of 2).
    • With this pull request's first codemod (b9be726), the role/tabIndex/handler/spread case fails.
    • With c21b1fb, both spread cases fail: a tap beside a static toggle and spread props was dropped with no TODO or listing, and a touchTrigger="auto" beside spread props was listed with a note to pass the toggle.
  • Layer/__tests__/touchTriggerTap.deprecated.test.ts: through the TypeScript language service, every tap call (both components, both hooks) is marked deprecated, and auto, none, no value, and forwarded props are not, with no type errors.
  • Codemod tests (15):
    • icon, span, string and svg rewrites; button, link, unknown element, own-text and anchor reports; aliased and namespace imports; a non-Core Icon; auto removed; none kept; pressTrigger wins; computed, competing and spread TODOs; hook reports; idempotent;
    • an icon or span with a role, tabIndex, handler or spread props, or one inside it, is reported, not rewritten;
    • tap beside spread props is reported even next to a static toggle, which a spread pressTrigger would override, and touchTrigger="auto" beside spread props is left as it is, with no TODO or listing;
    • every site left for a person is reported as a ManualMigrationRequired with its line, on every run.
  • api/upgrade/run/outcomes.test.mjs: manual migrations a transform reports through api.report are listed with their lines (src/sites.ts:2 - manual migration needed (…)), and the file keeps the transform's change.
  • Records:
    • Tooltip's current-state item "DEP-0016 is not built" goes, and DEP-0016's state is deprecated.
    • A new current-state item names the sites that still pass tap until their own changes in the same release move them: Core FieldLabel's info icon, Lab InfoTip (its promotion to Core, feat(core): InfoTip moves to Core as the info preset over Tooltip's press toggle #9238, uses the toggle), and the Tooltip touch-triggers story.
    • DEP-0016's verification row cites the deprecation type test, the codemod's tests and the listing test; CLN-0024 stays an evidence gap.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Writing rubric results

The rubric in docs/contributing/spec-writing.md, over the changed parts of Tooltip.spec.md:

  • R1 narration: none.
  • R2 research in the record: none.
  • R3 evidence naming a change: none.
  • R4 rejected alternatives over one line or outside a decision: none.
  • R5 pull request or issue references remaining: none.
  • R6 ruled open questions still listing options: none.

Testing

Local gates at 7290cb2 (on #9291's 5015adf, on #9232's 26d7af9, on main a55352c):

  • These pass: pnpm check:repo, pnpm check:knowledge, pnpm check:changesets, pnpm typecheck (0 errors), pnpm lint (0 errors), CI-strict ESLint on the changed files, the private-attribute baseline check, the root build, and the Storybook build.
  • Prettier passes on the touched files except three .doc.mjs files, which are unformatted on main too and outside the repository's formatting scope.
  • pnpm test:ui: 17,445 tests pass.
  • pnpm test:node: 15,118 pass. Its 2 failures, in the release cache.test.mjs, are independent of this change (fix(cli): a crashed run's release install is recognized by the Node it ran on, whatever its name #8520).
  • The codemod suite: 1,053 tests pass; the upgrade outcomes and the deprecation type test: 22 of 22.

@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 1, 2026 1:25pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 1, 2026
@astracat-bot

astracat-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 1, 2026, 1:45 PM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 1, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the deprecation overloads and the upgrade listing work as described, but the codemod and the DEP-0016 record need fixes, and the diff should be rebased onto #9232 once it merges (2,846 authored lines today).

  • The codemod rewrites <span role="button">, <svg role="button"> and <Icon role="button"> triggers to pressTrigger="toggle", which changes what a click, Enter or Space does; leave those with a TODO and list them.
  • DEP-0016 is marked deprecated with its not-built note removed while FieldLabel, Lab InfoTip and the TouchTriggers story still pass touchTrigger="tap"; migrate Lab InfoTip here and keep a current-state note for the rest.
  • A pressTrigger that changes from none to toggle after mount never wires the trigger.
  • usePressPin keeps the trigger element in React state, and useTooltip and useHoverCard use an Effect only to copy callbacks into a ref.

[Automated review]

Comment on lines +230 to +235
if (icons.has(name.name) || name.name === 'svg') {
return true;
}
if (name.name !== 'span') {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts any Core Icon, <svg> or <span> without reading its attributes, so <span role="button" tabIndex={0} onClick={open}>, <svg role="button"> and <Icon role="button" /> are rewritten to pressTrigger='toggle' with no TODO. FR9 pins a role="button" trigger, so after the upgrade a click, Enter or Space pins the layer and the trigger gains aria-expanded, unlike tap. Leaving an element with a role, tabIndex, event handler or spread for a person keeps the rewrite behavior-preserving (DEP-0016 codemod row).

@@ -96,7 +98,7 @@ in `Tooltip.doc.mjs`.
| codemod | An `astryx upgrade` codemod staged in `packages/cli/assets/codemods/transforms/next/`, with tests and fixtures, checked by the codemod verify workflow, and available in the release that deprecates `tap`: it rewrites static `touchTrigger="tap"` to `pressTrigger="toggle"` only where the trigger is statically a non-interactive icon or span, which FR9 keeps on tap-only opening, reports every other `touchTrigger="tap"` site (an own-text trigger, a button, a link, or an unknown element) with a manual-migration note and leaves it unchanged, removes static `touchTrigger="auto"`, and reports spreads and dynamic values it cannot prove (`spec:AST-017` FR29c, FR29d) |
| downstream | In the deprecating release, Core FieldLabel's info icon moves from `touchTrigger="tap"` to `component:InfoTip` with `pressTrigger="toggle"` (`component:FieldLabel` FR9), and Lab InfoTip and the CLI page templates migrate too, the templates to a button trigger with `pressTrigger="toggle"` so they carry no development warning (`spec:AST-017` FR29c); docs, stories, templates, and examples teach only `pressTrigger` |
| transition | no prior transition |
| state | `proposed` |
| state | `deprecated` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FieldLabel.tsx:316, packages/lab/src/InfoTip/InfoTip.tsx:117 and apps/storybook/stories/Tooltip.stories.tsx:373 still pass touchTrigger="tap", which DEP-0016's downstream row moves in the deprecating release. With the not-built item deleted, nothing in the record says so; keep a current-state item for the sites that cannot move here.

Comment thread packages/core/src/Layer/usePressPin.ts Outdated
}, []);

// The wired trigger, as state: its ARIA follows the pin through effects.
const [trigger, setTrigger] = useState<HTMLElement | null>(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

architecture:react-component-runtime INV1 keeps DOM nodes out of React state. triggerRef already holds the node; a boolean (wired or not) in state is enough for the ARIA Effects.

Comment on lines +290 to +292
if (element == null || current.pressTrigger !== 'toggle') {
setTrigger(null);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pressTrigger is read only when the trigger ref callback runs, and that callback does not depend on it, so a Tooltip re-rendered from none to toggle never wires its button (no pin, no aria-expanded on click).

Comment on lines +453 to +455
useEffect(() => {
touchDismissRef.current = {arm: armOutsideDismiss, disarm: clearTapOpen};
}, [armOutsideDismiss, clearTapOpen]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Effect synchronizes no external system (INV3); it only copies two callbacks into a ref read from handlers. Assigning the ref during render, as usePressPin does with optionsRef, removes it (same in useHoverCard.tsx:463).

@vjeux
vjeux force-pushed the feat/tooltip-touch-trigger-tap-deprecation branch from b9be726 to e1e56b2 Compare November 1, 2026 06:49
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in e1e56b2:

  • The codemod now leaves a trigger for a person (TODO plus listing) when it, or anything inside it, has a role, tabIndex, contentEditable, an event handler, or spread props. A new test covers <span role="button">, <svg role="button">, <Icon role="button">, tabIndex, handlers and spreads.
  • DEP-0016 stays deprecated, with a current-state item naming the sites that still pass tap until their own changes move them: FieldLabel's info icon, Lab InfoTip, and the touch-triggers story. Its verification row cites the new tests.
  • This changeset carries DEP-0016's release note; feat(Tooltip): pressTrigger="toggle" pins the tooltip on a press #9232's changeset now describes only pressTrigger.
  • The pressTrigger change after mount, the trigger element in state, and the bridge Effect are fixed in feat(Tooltip): pressTrigger="toggle" pins the tooltip on a press #9232's head, which this stacks on: usePressPin owns the touch wiring, so each hook makes one call.

Not changed:

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the codemod and record fixes from the last round hold, but a few items remain. Please land #9232 first and rebase, since the combined diff is 3,451 lines, and then address these:

  • Keep an implementation-gap note for FR9's own-text pin in Tooltip.spec.md: no component or public option reaches it yet, and <Tooltip pressTrigger="toggle"> on a text trigger warns instead of pinning.
  • Reword the changeset's "where nothing else changes": each trigger the codemod rewrites logs FR9's development warning.
  • In usePressPin.ts, stop the pressTrigger Effect from re-wiring on mount (it logs that warning twice per trigger), warn once per trigger, and drop the unmount-only unwire Effect that the trigger's ref already covers.

[Automated review]

| 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 `<button>` or `role="button"`), a click, a completed tap, Enter, or Space on the trigger pins the tooltip open, and the same press on a pinned tooltip closes it. Hover and keyboard focus still preview it per FR3 and FR4; on touch no preview opens and a tap pins it, while a touch that becomes a scroll pins nothing. A pinned tooltip stays open when the pointer leaves the trigger and the surface; Escape (FR6), a second press, a press outside the trigger and the surface, or focus moving outside them closes it. In a controlled tooltip a press reports the toggled value through `onOpenChange` and does not change the state itself (FR7). A press that pins or closes the layer stops at its trigger: it does not reach a clickable row, card, or container around it, or the control a label names. A trigger whose role is not `button` gets a development warning and keeps tap-only opening: a tap opens the tooltip as `touchTrigger="tap"` does, and no press pins it. A trigger whose tooltip repeats its own visible text, such as a truncated Text that is its own tab stop, may take `pressTrigger="toggle"` without being a button: a press pins it as on a button trigger, and it gets no development warning, no `aria-expanded` or `aria-controls`, and no announcement, since its text is already its content. | DEC-1; WCAG 1.4.13, 2.1.1 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row no longer says FR9 is unbuilt, but its own-text clause (a trigger whose tooltip repeats its text pins without being a button, with no warning) is reachable only through repeatsTriggerText on the internal useTooltipInternal, and no component passes it. <Tooltip content="Quarterly revenue by region" pressTrigger="toggle">Quarterly revenue by region</Tooltip> warns and does not pin. Please keep an implementation-gap note for that clause until something reaches it.

'@astryxdesign/cli': patch
---

[feat] `touchTrigger="tap"` on Tooltip, HoverCard, `useTooltip` and `useHoverCard` is deprecated in favor of `pressTrigger="toggle"` (DEP-0016); it keeps working as released, and beside `pressTrigger="toggle"` it is ignored. A `tap` call resolves to a `@deprecated` signature, so editors mark it with its replacement, and the docs name it. `astryx upgrade` rewrites static `touchTrigger="tap"` to `pressTrigger="toggle"` on an icon or span trigger with no `role`, `tabIndex`, event handler, or spread props, where nothing else changes, and removes static `touchTrigger="auto"`. 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. 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"where nothing else changes": every trigger this rewrites is a non-button, so in development each now logs FR9's warning (twice per mount at this head). DEP-0016's migration row says production behaviour is kept and the warning appears in development; the release note should say the same.

Comment on lines +469 to +471
useEffect(() => {
wire(offeredRef.current);
}, [pressTrigger, wire]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also runs on mount, after the host's ref already called attach, so a non-button trigger reaches devWarn twice (and again on each re-attach). Skipping the call when pressTrigger matches the mode the trigger was last wired for, and warning once per trigger (useDevWarning or warnOnce), would keep the post-mount rewiring without the repeat (INV3).

Comment thread packages/core/src/Layer/usePressPin.ts Outdated
writeAttribute(trigger, 'aria-controls', isLayerOpen ? layerId : null);
});

useEffect(() => unwire, [unwire]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hosts' ref already unwires on unmount: React calls it with null, and attachPressPin(null) reaches unwire through wire(null). This Effect names no system and repeats that detach, so it can go (INV3, INV6).

@vjeux
vjeux force-pushed the feat/tooltip-touch-trigger-tap-deprecation branch from e1e56b2 to c21b1fb Compare November 1, 2026 09:07
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in c21b1fb:

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the deprecation metadata, the codemod and the upgrade listing match DEP-0016, but the diff still carries the stacked Tooltip and HoverCard pressTrigger commits (3,546 lines in all), and the codemod mishandles spread props. Please land #9232 and #9291 first and rebase so only the deprecation commit remains, then fix these two cases in decide() and add them to the codemod tests:

  • pressTrigger="toggle" followed by {...rest} and touchTrigger="tap" loses touchTrigger without a TODO or listing, although a spread pressTrigger would override the static toggle.
  • A spread touchTrigger="auto" is listed with a note telling the caller to pass pressTrigger="toggle", which would change what a press does on a button trigger.

[Automated review]

* @returns {'rewrite' | 'remove' | 'keep' | 'value' | 'press' | 'spread'}
*/
function decide(value, press, spreads) {
if (press === 'toggle') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch runs before the spreads check, so <Tooltip pressTrigger="toggle" {...rest} touchTrigger="tap"> (and the same hook options) loses touchTrigger with no TODO or listing, although a pressTrigger in rest would override the static toggle. Checking spreads first keeps the header's promise that spread sites are reported. A spread touchTrigger="auto" also falls into SPREAD_COMMENT, whose note tells the caller to pass pressTrigger="toggle"; it needs its own outcome.

@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

#9246 is stacked on #9291 → #9232 (fork branches can't be bases), so the diff against main carries their commits until they merge. This PR's own change is the compare 36b7102...c21b1fb. After #9232 and #9291 merge it rebases onto main as DEP-0016 alone.

vjeux added 3 commits November 1, 2026 03:38
`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; a trigger whose tooltip repeats its own text pins without toggle
  state (`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.
`component:HoverCard` FR9, FR10 and AR3, on the shared press pin
(`Layer/usePressPin.ts`):

- `pressTrigger` (`none`, the default, or `toggle`) on HoverCard and
  useHoverCard, with the type HoverCardPressTrigger. With `toggle` on a button
  trigger, a click, a tap, Enter, or Space pins the card and the same press
  closes it; hover and focus still preview it.
- A pinned card stays until Escape, a second press, a press or scroll
  outside, or focus moving onto an element outside the trigger and the card
  (the pin listens for it on the trigger, whatever `focusTrigger` is); a
  press on its own content keeps it. It takes Escape before the layer
  beneath it, controlled or not. useHoverCard makes one usePressPin call,
  and a card whose `pressTrigger` turns to `none` while pinned closes on
  leave again.
- The press stops at its trigger. The trigger carries aria-expanded and
  aria-controls on a button trigger; on any other trigger a labelled card
  keeps its own (FR3). When a press pins a card with a `label`, the press
  handler says the label once through useAnnounce; otherwise the card's
  rendered text is announced once through useAnnounceRenderedText.
…), with its upgrade codemod

`component:Tooltip` DEP-0016, `spec:AST-017` FR29a and FR29c:

- `touchTrigger="tap"` resolves to a `@deprecated` overload of Tooltip,
  HoverCard, useTooltip and useHoverCard, so editors mark it with its
  replacement; the docs and JSDoc name DEP-0016. It keeps working as
  released.
- The `astryx upgrade` codemod rewrites static `touchTrigger="tap"` to
  `pressTrigger="toggle"` on an icon or span trigger with no role,
  tabIndex, event handler, or spread props, and removes static
  `touchTrigger="auto"` where no props are spread; every other tap site,
  one beside spread props and a static toggle included, keeps its prop with
  a TODO and a row in the upgrade's manual-migration listing, with its file
  and line. A core codemod reports such a site by passing a
  ManualMigrationRequired to api.report, keeping its changes to the file.
- The table-comparison page template's modality rail is a button with the
  toggle.
@vjeux
vjeux force-pushed the feat/tooltip-touch-trigger-tap-deprecation branch from c21b1fb to 7290cb2 Compare November 1, 2026 13:13
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 7290cb2:

  • decide() checks spread props before a static toggle, so <Tooltip pressTrigger="toggle" {...rest} touchTrigger="tap"> and the same hook options keep touchTrigger with a TODO and a listing row, since a spread pressTrigger would override the toggle.
  • A touchTrigger="auto" beside spread props is left as it is, with no TODO and no listing: auto is not deprecated.
  • The spread note now says to use pressTrigger="toggle" on an icon or span trigger, or a button whose only job is the layer, and otherwise to keep tap until the next minor.

Both cases are in the codemod tests, and both fail on c21b1fb.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval waits on a spec decision: Tooltip.spec.md FR9 now says a pinning press on an anchorRef trigger also reaches a plain onClick element around it, and useTooltip and useHoverCard triggers behave the same way, while the current FR9 in both the Tooltip and HoverCard records says the press stops at its trigger. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so if they are an engineering owner), or the press should stop in those paths too. Whatever is decided, these changes are still needed:

  • Land #9232 and #9291 first and rebase, since the combined diff is 3,858 lines.
  • Scope the "press stops at its trigger" sentence in .changeset/press-trigger-toggle.md and .changeset/hover-card-press-trigger.md to the components, because a plain onClick row around a useTooltip or useHoverCard trigger still receives the press.
  • Use useDevWarning for the non-button warning in usePressPin.ts instead of the module-level WeakSet.

[Automated review]

| 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 `<button>` or `role="button"`), a click, a completed tap, Enter, or Space on the trigger pins the tooltip open, and the same press on a pinned tooltip closes it. Hover and keyboard focus still preview it per FR3 and FR4; on touch no preview opens and a tap pins it, while a touch that becomes a scroll pins nothing. A pinned tooltip stays open when the pointer leaves the trigger and the surface; Escape (FR6), a second press, a press outside the trigger and the surface, or focus moving outside them closes it. In a controlled tooltip a press reports the toggled value through `onOpenChange` and does not change the state itself (FR7). A press that pins or closes the layer stops at its trigger: it does not reach a clickable row, card, or container around it, or the control a label names. 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. A trigger whose role is not `button` gets a development warning and keeps tap-only opening: a tap opens the tooltip as `touchTrigger="tap"` does, and no press pins it. A trigger whose tooltip repeats its own visible text, such as a truncated Text that is its own tab stop, may take `pressTrigger="toggle"` without being a button: a press pins it as on a button trigger, and it gets no development warning, no `aria-expanded` or `aria-controls`, and no announcement, since its text is already its content. Implementation gap: no component reaches the own-text clause yet (Current-state impact). | DEC-1; WCAG 1.4.13, 2.1.1 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an exception the current FR9 does not have: with anchorRef, "a plain onClick element around the anchor receives" the pinning press, where current FR9 says the press "does not reach a clickable row, card, or container around it". useTooltip and useHoverCard callers get the same behaviour with no record line. This needs an engineering owner's /approve-spec, or the press should stop in these paths too.

'@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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Tooltip (and useTooltip) … The press stops at its trigger, so a clickable row, card, or label around it does nothing" is not true for useTooltip: a <div onClick> row around a hook trigger receives the pinning press (the hook's JSDoc says so). Scope the sentence to the component and say a hook caller's plain onClick ancestor still receives the press, as the anchorRef sentence does.

'@astryxdesign/core': patch
---

[feat] HoverCard (and `useHoverCard`) take `pressTrigger` (type `HoverCardPressTrigger`): `none` (the default, unchanged) or `toggle`. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the card open and the same press closes it, while hover and focus still preview 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. 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. 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; beside `pressTrigger="toggle"`, `touchTrigger` is ignored.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the Tooltip changeset: the stop applies to the HoverCard component only; a plain onClick row around a useHoverCard trigger receives the pinning press.

export type LayerPressTrigger = 'none' | 'toggle';

/** Triggers already warned that a press cannot pin them: one warning each. */
const warnedTriggers = new WeakSet<Element>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utils/devWarning.ts routes per-instance warnings to useDevWarning. usePressPin already has offeredKind, isToggleMode and wiresState, so useDevWarning(options.component, message, isToggleMode && offeredKind === 1 && wiresState) can replace this set and the devWarn call in wire().

This branch was successfully deployed

1 active deployment
Preview — 7290cb22 Deployed Nov 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant