Skip to content

feat(Tooltip): a trigger whose tooltip repeats its own text pins without toggle state - #9361

Draft
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:feat/tooltip-own-text-pin
Draft

vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:feat/tooltip-own-text-pin

Conversation

@vjeux

@vjeux vjeux commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Status: draft, stacked on #9406, which stacks on #9232. It contains their commits (3870f5e, 169e82b) and this change (f3f40ea) until they merge. Every gate under Testing finished at this head, on main a68e0e3.

Review only this pull request's change: vjeux/astryx@169e82b...f3f40ea

User need

A truncated Text that is its own tab stop has a tooltip that repeats its full text. Under triggerBehavior="toggle", component:Tooltip FR9 lets that trigger pin its tooltip without being a button, so touch and keyboard users can keep the full text open. It gets no toggle state and no announcement, since its text is already the trigger's content.

Problem and solution fit

  • An internal repeatsTriggerText option on the shared press pin, passed through useTooltipInternal, pins such a trigger on a press. It gets no development warning, no aria-expanded or aria-controls, and no announcement (FR9's own-text clause).
  • Such a trigger is not a nested button, so an Astryx clickable container would not skip its press on its own. useClickableContainer ignores a press-pin click, so the card or row around the trigger stays inactive even where no component wrapper stops the press.

Current specification

component:Tooltip FR9, its own-text clause, current at 1d8696a (#9175). The prop's name and values, triggerBehavior ('adaptive' | 'hover' | 'toggle', default 'adaptive'), are the records pull request #9379's, merged as a68e0e3; this pull request is built on it, and the record lines it changes already use #9379's wording.

Public delta and caller burden

None. The option is internal (useTooltipInternal is @internal), no component passes it yet, and useTooltip keeps its public signature.

API changes

None.

Design and alternatives

  • The option lives on the shared press pin, beside the button test it relaxes: a repeat trigger gets the press listeners but no toggle state.
  • The container guard reads the mark the pin already sets on a press it answers (isPressPinClick). Every Astryx clickable surface built on useClickableContainer ignores that press: ClickableCard, Item, SelectableCard, TreeListItem, TokenLink, FileInput and Overlay.

Evidence

  • Red-first: with main's useClickableContainer, the new ClickableCard case fails, because the card opens.
  • Layer/usePressPin.test.tsx:
    • a trigger whose tooltip repeats its own text pins without being a button, with no warning, toggle state or announcement, and Enter closes it;
    • inside an Astryx ClickableCard, with no wrapper to stop the press, the card does not open.
  • Records: Tooltip's FR9 gap note returns to "no component reaches the own-text clause yet". Its current-state item returns to "No trigger reaches FR9's own-text pin yet" (an internal option that no component passes yet), and its verification row drops the own-text evidence-gap note.

Why it is split

#9232 was over the 2,000-line review threshold, and this unit separates cleanly. No component passes the option. The guard serves only this trigger, since Astryx clickable containers already skip a click on a nested button, the only kind of trigger #9232's toggle wires. This pull request is 5 files, +137/−26. All five (#9232, #9406, this one, #9291 and #9246) ship in the same release.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Focused tests ship with the feature; it is internal, so there are no consumer docs or migration.
  • 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; the gap note and the current-state item state what is built and what no component reaches yet.
  • R2 research in the record: none.
  • R3 evidence naming a change: none; the verification cell names test files.
  • 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 f3f40ea (on the evidence pull request's 169e82b and #9232's 3870f5e, on main a68e0e3):

  • 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.
  • pnpm test:ui: 17,559 tests pass.
  • pnpm test:node: 15,664 pass. Besides the 2 release cache.test.mjs failures, which 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), 8 tests in component-size.test.mjs, doctor-integration.test.mjs and component.test.mjs hit their 30-second timeout on a machine at a load average near 66; those three files pass on a rerun at this head (78 of 78), and this change touches none of their code.
  • TouchTrigger.a11y.chromium.spec.ts passes in Chromium, twice (42 of 42).
  • In WebKit, Tooltip's two .a11y.browser.spec.ts specs pass ten times each on one worker with 4 CPUs: 220 of 220.

@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 2, 2026 12:52am 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 2, 2026, 1:17 AM UTC

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

This needs an engineering-owner decision before it can be approved. The head rewrites component:Tooltip FR9 so that, with anchorRef or a useTooltip caller's trigger, a plain onClick ancestor still receives the pinning press, and the current FR9 stop clause does not allow that. An engineering owner records the decision by commenting /approve-spec followed by the full head SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:

  • An own-text trigger inside a Table row that expands on click also expands the row. The press-pin guard is only in useClickableContainer, not in the Table row handlers that share its checks.
  • The changeset says a clickable row around a useTooltip trigger does nothing, but a plain onClick row still receives the press from the hook.
  • With #9232's commit included, this is 2,068 authored lines. Landing #9232 first and rebasing would leave only this change's 163 lines.

[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`, or `useTooltip` with a caller's trigger, 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.

The new sentence "With anchorRef, or useTooltip with a caller's trigger, … a plain onClick element around the anchor receives it" is a carve-out from the current FR9 stop clause ("does not reach a clickable row, card, or container around it"). An engineering owner needs to approve it, or the anchorRef and hook paths need to conform.

if (disabled) {
// A press that pinned or closed a hover layer stops at its trigger: the
// container does not act on it (component:Tooltip FR9).
if (disabled || isPressPinClick(event.nativeEvent)) {

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 guard only covers the hook's own onClick. The Table row-expansion and tree-data row clicks share this hook's checks through hasInteractiveAncestor and miss it, so an own-text trigger in a clickable Table row both pins and expands the row. Moving the check into the shared predicate, or adding it to both row handlers, keeps the three surfaces aligned.

Comment thread .changeset/press-trigger-toggle.md Outdated
'@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.

For useTooltip, "a clickable row, card, or label around it does nothing" is not what the hook does: a plain onClick row still receives the press (the head's useTooltip.doc.mjs says so). Naming useTooltip beside anchorRef in the exception sentence fixes it.

vjeux added 3 commits November 1, 2026 12:08
`component:Tooltip` FR9 and FR10, `spec:AST-003` FR4:

- `triggerBehavior` on Tooltip and useTooltip, with the type
  TooltipTriggerBehavior: `adaptive`, the default and the released behavior
  (a tap opens the tooltip unless the trigger acts; an explicit `touchTrigger`
  still decides taps under it until the next minor); `hover` (touch never
  opens it); or `toggle`. Hover and focus preview it under every value. With
  `toggle` on a button trigger, a click, a tap, Enter, or Space pins the
  tooltip and the same press closes 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 `triggerBehavior` that changes after mount wires or unwires the
  trigger, and a tooltip whose `triggerBehavior` turns to `adaptive` 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 (`Layer/usePressPin.ts`, the hover layers' shared press pin).
- LayerTriggerBehavior 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:Tooltip` FR5 and FR9's `triggerBehavior` verification row:

- Tooltip.test.tsx: `adaptive` by default and explicitly, `hover`, and
  `toggle`, each under a mouse hover and keyboard focus (both preview it)
  and a touch and a pen tap (which opens it, never opens it, or pins it);
  an explicit `touchTrigger` decides taps beside `adaptive` and is ignored
  beside `hover` and `toggle`.
- TouchTrigger.a11y.chromium.spec.ts: a real finger and a pen-typed press on
  the new TriggerBehaviors story: a tap never opens `hover`, opens
  `adaptive`, and pins `toggle`.
- The Tooltip record's `triggerBehavior` verification row names this
  evidence.
…out toggle state

`component:Tooltip` FR9's own-text clause, on the shared press pin
(`Layer/usePressPin.ts`):

- usePressPin's internal `repeatsTriggerText` option, passed through
  useTooltipInternal (useTooltip with Core's internal options), pins a
  trigger whose tooltip repeats its own visible text, such as a truncated
  Text that is its own tab stop, without a button trigger: no development
  warning, no aria-expanded or aria-controls, and no announcement.
- Astryx clickable containers ignore a press-pin click (`isPressPinClick`
  in useClickableContainer), so such a trigger, which is not a nested
  button, does not activate the card or row around it when no component
  wrapper stops the press.
- No component passes the option yet; the record keeps that implementation
  gap.

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

This needs an engineering-owner decision before it can be approved: the head adds a sentence to component:Tooltip FR9 under which a plain onClick element around an anchorRef or useTooltip trigger still receives the pinning press, and the current FR9 stop clause does not allow that. An engineering owner records the decision by commenting /approve-spec followed by the full head SHA on this pull request (the author may do so as an engineering owner), or asks for the anchorRef and hook paths to conform. Whatever the decision, these still need changes:

  • An own-text trigger pins and also activates an Item or TreeList row with onClick or a Table row that expands on click, and an anchored role="button" trigger activates an Item row, because only useClickableContainer reads the press-pin mark.
  • The re-wire Effect in usePressPin.ts leaves repeatsTriggerText out of its settle key and dependencies, so turning the option on after mount never wires the trigger.
  • With #9232 and #9406 included this is 2,286 authored lines; landing them first and rebasing onto main, which also clears the conflicts in Tooltip.spec.md, useTooltip.tsx and useTooltip.doc.mjs, leaves this change's 163 lines.

[Automated review]

if (disabled) {
// A press that pinned or closed a hover layer stops at its trigger: the
// container does not act on it (component:Tooltip FR9).
if (disabled || isPressPinClick(event.nativeEvent)) {

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 is the only place the press-pin mark is read. An Item row with onClick (Item.tsx handleContainerClick), a TreeList row with onClick (TreeListItem.tsx handleClick) and the Table row-expansion and tree-data rows don't go through this onClick, so an own-text trigger pins and also activates those rows, and an anchored role="button" trigger activates an Item row. Reading the mark in the walk they share (or in each handler) keeps FR9's stop clause for all of them.

// it here: the host's ref callback does not run again for that change.
useEffect(() => {
wire(offeredRef.current);
}, [triggerBehavior, 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.

wire reads repeatsTriggerText, but neither this Effect's dependencies nor the settle key (element and triggerBehavior) include it, so a tooltip whose repeatsTriggerText turns true after mount never wires its trigger. Adding it to both fixes that.

| 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 `<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`, or `useTooltip` with a caller's trigger, 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: every completed tap opens the tooltip, 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 `triggerBehavior="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.

The new sentence "With anchorRef, or useTooltip with a caller's trigger, … a plain onClick element around the anchor receives it" is an exception to the current stop clause ("does not reach a clickable row, card, or container around it"). It needs an engineering owner's /approve-spec for this head, or the anchorRef and hook paths should conform.

This branch was successfully deployed

1 active deployment
Preview — f3f40ea6 Deployed Nov 2, 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:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant