Skip to content

feat(Tooltip): triggerBehavior="toggle" pins the tooltip on a press - #9232

Open
vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:feat/tooltip-press-trigger
Open

vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:feat/tooltip-press-trigger

Conversation

@vjeux

@vjeux vjeux commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Status: every gate under Testing passed at this head, ff2c7a3, on main 032c4a1. It merges cleanly with main 6a591d9. The record changed, so it needs /approve-spec at this head.

User need

Some tooltips are the only place their information lives, such as an info icon or a "What is this?" button. Touch, keyboard, and screen-reader users can't keep those tooltips open. Hover opens one, and leaving closes it. touchTrigger="tap" opens it on a tap, but a mouse click, Enter, or Space doesn't keep it open, and nothing pins it. That makes the information hover-only, which architecture:interaction-modality rules out for content that exists nowhere else.

Problem and solution fit

  • triggerBehavior="toggle" turns the trigger into a 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, so every input has one way to keep the tooltip.
  • The person pinned the tooltip themselves, so it takes Escape before the layer beneath it (spec:AST-003 FR4, DEC-6).
  • The press stops at its trigger, so a toggle inside a clickable row, card, or label doesn't also activate the container.

Current specification

component:Tooltip FR9, FR10, AR6 and DEC-1, plus spec:AST-003 FR4 and DEC-6, all current at 1d8696a (#9175). The prop's name and values, triggerBehavior: 'adaptive' | 'hover' | 'toggle' with default 'adaptive', and FR5's rules for them come from #9379, merged as a68e0e3. The record lines this pull request changes use #9379's wording.

Public delta and caller burden

  • triggerBehavior: 'adaptive' | 'hover' | 'toggle' on Tooltip and useTooltip, with the closed-union type TooltipTriggerBehavior. Hover and keyboard focus preview the tooltip under every value.
    • adaptive, the default, changes nothing. It is the released touch behavior: a tap opens the tooltip unless the trigger performs an action of its own. An explicit touchTrigger still decides taps under adaptive, until CLN-0024 removes touchTrigger in 0.8.0.
    • hover: no touch or pen tap opens the tooltip, and no press pins it. This is the released touchTrigger="none".
    • toggle, on a button trigger (a <button> or role="button"): a click, a tap, Enter, or Space pins the tooltip, and the same press closes it. On a role="button" element, Enter acts on keydown and Space on keyup, as on a native button.
    • A pinned tooltip stays when the pointer leaves, and a press on its own text keeps it pinned. It closes on Escape, a second press, a press outside, or a scroll that starts outside. It also closes when focus moves to an element outside the trigger and the tooltip, whatever focusTrigger is, in the trigger's own document.
    • A controlled tooltip reports the toggled value through onOpenChange and doesn't change itself.
    • The trigger carries aria-expanded (true only while pinned) and, while the tooltip shows, aria-controls. When the tooltip pins, the shared rendered-text announcer reads its text once (spec:AST-086 FR12), through useAnnounce. Inside a modal, that announcement plays in the modal's own region (FR14).
    • The press stops at its trigger. Its default action is prevented, and the trigger's own handlers still run. A clickable row, card, or container around the trigger doesn't activate, and a <label> around it doesn't operate its control. With anchorRef, or useTooltip with a caller's trigger, the caller's own handlers still receive the press. A plain onClick element around the trigger receives the press unless the trigger's onClick stops it. Astryx clickable rows, cards, and containers ignore the press.
    • A trigger that is not a button gets one development warning and keeps tap-only opening.
    • Under hover or toggle, touchTrigger is ignored.

Before and after:

// before: a tap opens it; nothing keeps it
<Tooltip content="Rows are ranked by how many places import the icon" touchTrigger="tap">
  <Button label="What is this?">?</Button>
</Tooltip>
// after: a click, tap, Enter or Space pins it; the same press closes it
<Tooltip content="Rows are ranked by how many places import the icon" triggerBehavior="toggle">
  <Button label="What is this?">?</Button>
</Tooltip>

Not in this pull request (each stacked above, in this order; all ship in the same release):

Supersedes: nothing. This pull request carries the toggle itself.

API changes

These are added public rows, decided by component:Tooltip FR9 and FR10 (#9175). They go to human review under architecture:public-component-api INV25. packages/core/api-report.md lists exactly these rows.

Change Package Signature Usage
added @astryxdesign/core TooltipProps.triggerBehavior?: TooltipTriggerBehavior (default 'adaptive') <Tooltip content="…" triggerBehavior="toggle"><Button label="What is this?">?</Button></Tooltip>
added @astryxdesign/core TooltipOptions.triggerBehavior?: TooltipTriggerBehavior (default 'adaptive') useTooltip({triggerBehavior: 'toggle'})
added @astryxdesign/core type TooltipTriggerBehavior = 'adaptive' | 'hover' | 'toggle' const mode: TooltipTriggerBehavior = 'toggle';

Design and alternatives

  • One press-pin hook serves the hover layers: Layer/usePressPin.ts. It owns the layer's touch trigger (useTouchTriggerInternal), so useTooltip makes one usePressPin call (architecture:react-component-runtime INV16). The touch trigger gets tap under toggle (the pin decides), none under hover, and the caller's touchTrigger under adaptive.
  • A pin closes through the hover layers' existing owners. The touch trigger's outside dismissal, which the pin arms, handles a press or scroll outside. The pin's own focus-out listener on the trigger handles focus moving away, whatever focus listeners the host attaches. It tests the next element against the trigger's own window, so a trigger in an iframe closes too.
  • The stop is built once: the click listener prevents the default action and marks the event, and the component's wrapper stops propagation after the trigger's own handlers.
  • Internal, by the spec owners' ruling: LayerTriggerBehavior and the touch trigger's armOutsideDismiss stay off the Layer entry point. The public useTouchTrigger return is unchanged.
  • The toggle owns aria-expanded and aria-controls while it wires a button trigger, and restores the trigger's own values when it unwires.
  • The records reject a toggle on every trigger: a non-button can't announce the toggle state, so it keeps tap-only opening.

Evidence

  • Red first:
    • With main's sources, the pinned Tooltip case in layerDismissalFamilies.test.tsx fails.
    • At this pull request's earlier heads, each case that a review asked for failed before its fix: the announcement through useAnnounce (inside a modal too), a scroll outside, a press on the tooltip's own text, a triggerBehavior change after mount, the single usePressPin call, the focusTrigger="never" close, and the hover mapping.
    • New at this head, each failing without its fix: a trigger in an iframe keeps its pin when focus moves to another control there, Space on a role="button" trigger pins on keydown, and a role="button" trigger that clicks itself on Space keyup pins and then unpins.
  • Layer/usePressPin.test.tsx (36 tests): click, tap, Enter, and Space pin. Space acts on keyup, and a trigger that clicks itself on Space keydown or keyup pins once. Pointer leave and a press on the tooltip's own text keep the pin. Escape, a second press, a press outside, a scroll starting outside, and focus leaving close it, including in another document and under focusTrigger="never". The file also covers the controlled case, the non-button warning, triggerBehavior changes, the trigger state, one announcement (inside a modal Dialog too), the stop inside a clickable row, a ClickableCard, an Item row, and a <label>, and the anchorRef cases.
  • layerDismissalFamilies.test.tsx: a pinned Tooltip over a Dialog takes Escape first, and the Dialog stays open.
  • Layer/usePressPin.calls.test.tsx: the host makes one usePressPin call per render, and every touch-trigger call comes from inside it.
  • hooks/useAnnounceRenderedText.adopters.test.tsx: a pinned Tooltip hands its text to the shared announcer and announces none of it itself (architecture:react-component-runtime INV10).
  • TouchTrigger.a11y.chromium.spec.ts, on the new a11y/Tooltip toggle behavior stories: a real tap pins and the next tap closes, a tap outside closes, Enter pins and Space closes, and inside a ClickableCard the tap pins and the card stays shut. Under a mouse, a click on the tooltip's own text keeps the pin, an anchored trigger runs its own click handler and pins, and with focusTrigger="never", Tab to another control closes a pin.
  • Records (Tooltip.spec.md):
    • The "not yet built" notes on FR9, FR10, and AR6 are dropped, and so is the "Toggle pinning is not built" current-state item.
    • FR9 gains the owners' clause for anchorRef and for useTooltip with a caller's trigger, with a FR9 (anchorRef) verification row.
    • FR9 keeps a gap note for its own-text clause, with a current-state item.
    • FR5 and FR9's triggerBehavior verification row names its evidence gap.
    • The compatibility row says DEP-0016's metadata and codemod are not yet built, plus eleven further gaps.
    • system_specs adds spec:AST-086/FR12 and /FR14, and a path-applicability block links Layer/usePressPin.ts. Those links replace the citations that its source comments dropped.
  • Overlaps with open pull requests: fix(ChatMessageList, Tooltip, HoverCard): the chat log and text trigger spans draw the shared focus ring #8876 edits Tooltip.tsx, and fix(HoverCard): focusTrigger="auto" decides when focus arrives, through Tooltip's shared check #9597 moves useTooltip's isFocusForTrigger into utils/triggerFocus.ts. Whichever lands second rebases.

Split

Four pieces stack above this one, by the maintainers' rulings, and all ship in the same release: #9406, #9361, #9291, and #9246 (see above). This one is 17 files, +2,083/−68 (2,151 lines): the shared press pin and touch-trigger wiring, Tooltip's wiring, its tests and stories, its record lines and docs, its API report rows, and its changeset. It is over 2,000 lines because the press pin, its touch-trigger wiring, and its first adopter only work together. Tests and stories are 59% of it. Rebased onto main 032c4a1, past #9366 (focus counting), #9417, #9398, #9531, #9516 (comment provenance), and #9589 (API reports).

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; the rows state what the tooltip does.
  • R2 research in the record: none.
  • R3 evidence naming a change: none; the verification cells name test files and cases.
  • 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 this head, ff2c7a3 (on main 032c4a1):

  • These pass: pnpm check:knowledge, pnpm check:changesets, pnpm check:i18n-runtime, core tsc (0 errors), ASTRYX_STRICT_LINT=1 pnpm lint (0 errors, check:repo included), Prettier on the touched files, the root build, and the Storybook build. The two touched .doc.mjs files stay unformatted, as on main.
  • The provenance baseline --check: this pull request's entry is exact. Only main's own ChatComposer, Item, layerSurfacePress, and Token entries are stale, as on main.
  • pnpm api:report --package core writes exactly this pull request's four rows. Main's own TextInput and TextArea drift stays out.
  • pnpm test:ui: 730 files, 18,150 tests pass.
  • pnpm test:node: 16,011 pass, with 2 expected failures.
  • TouchTrigger.a11y.chromium.spec.ts in Chromium: 19 of 19, twice.
  • In WebKit, Tooltip's two .a11y.browser.spec.ts specs pass ten times each: 220 of 220. They ran in the CI profile: the Playwright 1.61.1 image, 4 CPUs, CI=1, LP_NUM_THREADS=4, and one worker. TouchTrigger.a11y.chromium.spec.ts runs in Chromium only, by the config's globs.

@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 8:14pm 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
🟡 Reviewed (for maintainers only) Nov 2, 2026, 8:24 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

🔎 Preview · ff2c7a3

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
LayerProvider 62.6 kB (+1.8 kB) · 10.5 kB CSS (0) — ✓ · ✓ · ✗
lab/Schedule 🔴 61.2 kB (+3.4 kB) · 3.9 kB CSS (0) — — · — · —
Carousel 🔴 50.6 kB (+3.3 kB) · 10.5 kB CSS (0) — — · — · —
111 more components
Component Size LoC Visual · A11y · RTL
lab/TourStep 🔴 55.1 kB (+3.3 kB) · 590 B CSS (0) — — · — · —
Popover 🔴 49.7 kB (+3.2 kB) · 12.4 kB CSS (0) — — · — · —
DrawerHeader 🔴 48.4 kB (+2.7 kB) · 11.6 kB CSS (0) — — · — · —
TableSelectionToolbar 🔴 52.1 kB (+2.7 kB) · 11.5 kB CSS (0) — — · — · —
DialogHeader 🔴 48.3 kB (+2.7 kB) · 10.2 kB CSS (0) — — · — · —
lab/InfoTip 🔴 26.1 kB (+2.7 kB) · 458 B CSS (0) — — · — · —
Tooltip · Storybook 🔴 20.9 kB (+2.7 kB) · 5.0 kB CSS (0) 644 (+40) ✓ · ✓ · ✓
StatusDot 🔴 23.1 kB (+2.7 kB) · 6.3 kB CSS (0) — — · — · —
ChatLayout 🔴 48.3 kB (+2.7 kB) · 10.3 kB CSS (0) — — · — · —
Stepper 🔴 46.8 kB (+2.7 kB) · 9.9 kB CSS (0) — — · — · —
lab/DiffViewer 🔴 70.6 kB (+2.7 kB) · 2.1 kB CSS (0) — — · — · —
TopNavMenu 🔴 58.0 kB (+2.6 kB) · 10.5 kB CSS (0) — — · — · —
TopNavMegaMenu 🔴 54.1 kB (+2.6 kB) · 11.2 kB CSS (0) — — · — · —
ChatDictationButton 🔴 45.9 kB (+2.6 kB) · 9.8 kB CSS (0) — — · — · —
MobileNavToggle 🔴 45.6 kB (+2.6 kB) · 9.7 kB CSS (0) — — · — · —
FieldLabel 🔴 36.0 kB (+2.6 kB) · 5.9 kB CSS (0) — — · — · —
ToggleButton 🔴 43.1 kB (+2.6 kB) · 9.6 kB CSS (0) — — · — · —
ChatSendButton 🔴 45.0 kB (+2.6 kB) · 9.5 kB CSS (0) — — · — · —
SideNavCollapseButton 🔴 46.0 kB (+2.6 kB) · 9.8 kB CSS (0) — — · — · —
Banner 🔴 47.2 kB (+2.6 kB) · 10.3 kB CSS (0) — — · — · —
Toast 🔴 59.3 kB (+2.6 kB) · 10.1 kB CSS (0) — — · — · —
IconButton 🔴 42.2 kB (+2.6 kB) · 9.5 kB CSS (0) — — · — · —
TabMenu 🔴 55.1 kB (+2.6 kB) · 10.4 kB CSS (0) — — · — · —
Link 🔴 42.8 kB (+2.6 kB) · 7.9 kB CSS (0) — — · — · —
ChatLayoutScrollButton 🔴 45.6 kB (+2.6 kB) · 9.9 kB CSS (0) — — · — · —
Button 🔴 42.2 kB (+2.6 kB) · 9.5 kB CSS (0) — — · — · —
TopNavHeading 🔴 57.9 kB (+2.5 kB) · 10.4 kB CSS (0) — — · — · —
SideNavHeading 🔴 58.5 kB (+2.5 kB) · 10.5 kB CSS (0) — — · — · —
lab/CommentPin 🔴 47.0 kB (+2.5 kB) · 1.2 kB CSS (0) — — · — · —
SideNavItem 🔴 54.4 kB (+2.5 kB) · 10.4 kB CSS (0) — — · — · —
Avatar 🔴 34.9 kB (+2.5 kB) · 6.5 kB CSS (0) — — · — · —
SegmentedControl 🔴 36.7 kB (+2.5 kB) · 6.5 kB CSS (0) — — · — · —
AvatarGroup 🔴 37.6 kB (+2.5 kB) · 6.6 kB CSS (0) — — · — · —
AvatarGroupOverflow 🔴 38.8 kB (+2.5 kB) · 6.5 kB CSS (0) — — · — · —
AppShell 🔴 68.2 kB (+2.4 kB) · 12.1 kB CSS (0) — — · — · —
AlertDialog 🔴 56.0 kB (+2.2 kB) · 12.7 kB CSS (0) — — · — · —
SideNav 🔴 65.6 kB (+2.2 kB) · 10.9 kB CSS (0) — — · — · —
TopNav 🔴 51.7 kB (+2.1 kB) · 10.7 kB CSS (0) — — · — · —
MobileNav 🔴 50.1 kB (+2.1 kB) · 10.4 kB CSS (0) — — · — · —
Lightbox 🔴 50.3 kB (+2.0 kB) · 10.1 kB CSS (0) — — · — · —
ChatComposerInput 55.8 kB (+2.0 kB) · 10.4 kB CSS (0) — — · — · —
ChatComposerTokenElement 55.8 kB (+2.0 kB) · 10.4 kB CSS (0) — — · — · —
TimeInput 88.5 kB (+2.0 kB) · 14.3 kB CSS (0) — — · — · —
Calendar 54.1 kB (+1.9 kB) · 10.3 kB CSS (0) — — · — · —
CodeBlock 59.0 kB (+1.9 kB) · 11.1 kB CSS (0) — — · — · —
DateInput 101.1 kB (+1.9 kB) · 15.3 kB CSS (0) — — · — · —
charts/ChartTooltip 52.2 kB (+1.9 kB) · 20 B CSS (0) — — · — · —
Thumbnail 49.4 kB (+1.9 kB) · 11.3 kB CSS (0) — — · — · —
DateTimeInput 113.0 kB (+1.9 kB) · 15.8 kB CSS (0) — — · — · —
BreadcrumbItem 66.9 kB (+1.9 kB) · 11.4 kB CSS (0) — — · — · —
TableCell 76.1 kB (+1.8 kB) · 15.8 kB CSS (0) — — · — · —
DropdownMenu 72.6 kB (+1.8 kB) · 15.4 kB CSS (0) — — · — · —
richtext/RichTextView 256.7 kB (+1.8 kB) · 2.3 kB CSS (0) — — · — · —
charts/Chart 62.7 kB (+1.8 kB) · 20 B CSS (0) — — · — · —
lab/ChatReactionBar 65.3 kB (+1.8 kB) · 1.1 kB CSS (0) — — · — · —
lab/ChatEmojiPicker 64.8 kB (+1.8 kB) · 773 B CSS (0) — — · — · —
charts/ChartLegend 52.2 kB (+1.8 kB) · 20 B CSS (0) — — · — · —
ContextMenu 74.9 kB (+1.8 kB) · 15.3 kB CSS (0) — — · — · —
MoreMenu 72.9 kB (+1.8 kB) · 15.4 kB CSS (0) — — · — · —
FileInput 53.6 kB (+1.8 kB) · 10.7 kB CSS (0) — — · — · —
TableHeaderCell 75.7 kB (+1.8 kB) · 15.5 kB CSS (0) — — · — · —
TextInput 52.7 kB (+1.8 kB) · 10.9 kB CSS (0) — — · — · —
ComplexSelector 55.3 kB (+1.8 kB) · 11.1 kB CSS (0) — — · — · —
TextArea 47.1 kB (+1.8 kB) · 9.8 kB CSS (0) — — · — · —
DateRangeInput 69.4 kB (+1.8 kB) · 11.7 kB CSS (0) — — · — · —
Switch 47.0 kB (+1.8 kB) · 9.7 kB CSS (0) — — · — · —
NumberInput 54.9 kB (+1.8 kB) · 11.2 kB CSS (0) — — · — · —
CheckboxList 43.3 kB (+1.8 kB) · 8.6 kB CSS (0) — — · — · —
lab/EmojiPicker 70.1 kB (+1.8 kB) · 722 B CSS (0) — — · — · —
richtext/RichTextEditor 276.1 kB (+1.8 kB) · 2.6 kB CSS (0) — — · — · —
lab/TransferListSelector 80.5 kB (+1.8 kB) · 1.9 kB CSS (0) — — · — · —
Field 41.4 kB (+1.8 kB) · 8.3 kB CSS (0) — — · — · —
CheckboxInput 48.6 kB (+1.8 kB) · 9.7 kB CSS (0) — — · — · —
InputGroup 42.0 kB (+1.8 kB) · 8.4 kB CSS (0) — — · — · —
lab/TransferList 74.2 kB (+1.7 kB) · 1.7 kB CSS (0) — — · — · —
RadioList 42.8 kB (+1.7 kB) · 8.3 kB CSS (0) — — · — · —
RadioListItem 51.2 kB (+1.7 kB) · 10.1 kB CSS (0) — — · — · —
Slider 46.1 kB (+1.7 kB) · 8.9 kB CSS (0) — — · — · —
CheckboxListItem 53.3 kB (+1.7 kB) · 11.0 kB CSS (0) — — · — · —
ListItem 52.6 kB (+1.7 kB) · 11.0 kB CSS (0) — — · — · —
lab/MobilePowerSearch 166.6 kB (+1.6 kB) · 1.2 kB CSS (0) — — · — · —
lab/MobileTokenizer 100.7 kB (+1.5 kB) · 880 B CSS (0) — — · — · —
ChatComposer 60.7 kB (+1.5 kB) · 11.2 kB CSS (0) — — · — · —
richtext/RichTextEditorToolbar 221.3 kB (+1.5 kB) · 454 B CSS (0) — — · — · —
lab/CommentComposer 102.0 kB (+1.5 kB) · 1.9 kB CSS (0) — — · — · —
lab/Comments 231.7 kB (+1.5 kB) · 2.5 kB CSS (0) — — · — · —
BaseTypeahead 54.7 kB (+1.4 kB) · 10.2 kB CSS (0) — — · — · —
lab/CommentThread 226.4 kB (+1.4 kB) · 2.4 kB CSS (0) — — · — · —
Tokenizer 69.7 kB (+1.4 kB) · 17.1 kB CSS (0) — — · — · —
lab/ListInput 81.4 kB (+1.4 kB) · 1.6 kB CSS (0) — — · — · —
PowerSearch 182.3 kB (+1.3 kB) · 20.5 kB CSS (0) — — · — · —
MultiSelector 72.3 kB (+1.3 kB) · 15.2 kB CSS (0) — — · — · —
Selector 76.5 kB (+1.3 kB) · 15.6 kB CSS (0) — — · — · —
Typeahead 62.3 kB (+1.3 kB) · 11.8 kB CSS (0) — — · — · —
Markdown 144.9 kB (+1.3 kB) · 18.3 kB CSS (0) — — · — · —
Pagination 85.4 kB (+1.2 kB) · 15.9 kB CSS (0) — — · — · —
Table 84.2 kB (+1.2 kB) · 16.0 kB CSS (0) — — · — · —
CommandPalette 48.4 kB (+175 B) · 12.2 kB CSS (0) — — · — · —
ProgressBar 8.1 kB (+132 B) · 6.9 kB CSS (0) — — · — · —
ChatMessageList 22.8 kB (+97 B) · 7.9 kB CSS (0) — — · — · —
CommandPaletteInput 26.7 kB (+80 B) · 8.5 kB CSS (0) — — · — · —
HoverCard 18.5 kB (+54 B) · 5.0 kB CSS (0) — — · — · —
Spinner 18.5 kB (+41 B) · 6.7 kB CSS (0) — — · — · —
Timestamp 21.3 kB (+39 B) · 9.9 kB CSS (0) — — · — · —
Timer 9.6 kB (+35 B) · 7.0 kB CSS (0) — — · — · —
ChatToolCalls 25.8 kB (+32 B) · 9.3 kB CSS (0) — — · — · —
Heading 7.9 kB (+19 B) · 6.0 kB CSS (0) — — · — · —
Text 7.4 kB (+14 B) · 6.0 kB CSS (0) — — · — · —
DropdownMenuSubMenu 55.9 kB (−31 B) · 9.9 kB CSS (0) — — · — · —
layer — — — · ✓ · —
Layer — — ✓ · ✓ · —

Package size: @astryxdesign/core 5.83 MB packed (+13.5 kB); no new dependencies.

API changes: core +4 · ~0 · −0

Committed API report behind this build: core 18 rows; run pnpm api:report.

4 API changes (advisory)
Package Where Signature Usage
+ core . · Components · component Tooltip prop triggerBehavior?: TooltipTriggerBehavior = 'adaptive' <Tooltip triggerBehavior="adaptive" />
+ core . · Types · interface TooltipOptions triggerBehavior?: TooltipTriggerBehavior = 'adaptive' import type {TooltipOptions} from '@astryxdesign/core'; const x: TooltipOptions = {triggerBehavior: …}
+ core . · Types · type TooltipTriggerBehavior type TooltipTriggerBehavior = 'adaptive' | 'hover' | 'toggle' import type {TooltipTriggerBehavior} from '@astryxdesign/core'
+ core ./Tooltip · Also exported from an earlier entry point · TooltipTriggerBehavior TooltipTriggerBehavior (.) import {TooltipTriggerBehavior} from '@astryxdesign/core/Tooltip'

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@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 toggle behaves as component:Tooltip FR9 describes for button triggers, but a few gaps need fixing first.

  • A pin inside a modal Dialog is announced with announceMessage into the page-body live region, which the modal hides, so announce through useAnnounce or useAnnounceRenderedText to get spec:AST-086 FR14 routing.
  • A labelled HoverCard on a non-button trigger loses aria-controls/aria-expanded under pressTrigger="toggle" (HoverCard.tsx:380), which also makes the codemod's span rewrite change behavior; hand that state to the pin only when it wires the trigger, and test it.
  • The outside-press Effect in usePressPin duplicates useTouchTrigger's outside dismissal; please extend that owner instead.
  • The hook docs promise the press stops at the trigger (it does not without the component wrapper), touchTrigger="tap" has no @deprecated tag, and TooltipPressTrigger/HoverCardPressTrigger are missing from the entry-point exports.
  • At 2,307 authored lines, please split the DEP-0016 codemod and deprecation into a follow-up in the same release.

[Automated review]

Comment thread packages/core/src/Layer/usePressPin.ts Outdated
announcedRef.current = true;
const text = optionsRef.current.announcement()?.trim();
if (text != null && text !== '') {
announceMessage(text, 'polite');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

announceMessage always writes to the page-body live region. Inside an open modal Dialog, that region is inert, so the pin is never heard; useAnnounce() routes to the modal's own region (spec:AST-086 FR14). The shared useAnnounceRenderedText already reads a layer's rendered text and announces it through useAnnounce, so gating it on the pin would replace this Effect.


// Under the toggle, the press pin owns aria-expanded (true only while
// pinned) and aria-controls (FR10); the popup role stays here.
const ownsToggleState = pressTrigger !== '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 hands aria-controls/aria-expanded to the press pin for every trigger, but usePressPin wires only button triggers. A labelled HoverCard on a span or link trigger therefore loses HoverCard FR3's popup state, and the codemod rewrites such spans. Something like pressTrigger === 'toggle' && isPressPinTrigger(firstChild) keeps FR3 for the rest.

Comment thread packages/core/src/Layer/usePressPin.ts Outdated
Comment on lines +287 to +319
// While pinned, a press or focus outside the trigger and the layer closes it.
useEffect(() => {
if (!isToggleMode || trigger == null || !isExpanded) {
return;
}
const ownerDocument = trigger.ownerDocument;
const isInside = (target: EventTarget | null) => {
if (!(target instanceof Node)) {
return false;
}
const surface = ownerDocument.getElementById(layerId);
return (
trigger.contains(target) ||
(surface != null && surface.contains(target))
);
};
const onPointerDown = (event: PointerEvent) => {
if (!isInside(event.target)) {
close();
}
};
const onFocusIn = (event: FocusEvent) => {
if (!isInside(event.target)) {
close();
}
};
ownerDocument.addEventListener('pointerdown', onPointerDown, true);
ownerDocument.addEventListener('focusin', onFocusIn, true);
return () => {
ownerDocument.removeEventListener('pointerdown', onPointerDown, true);
ownerDocument.removeEventListener('focusin', onFocusIn, true);
};
}, [isToggleMode, trigger, isExpanded, layerId, close]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

useTouchTrigger (armOutsideDismiss), which both hooks already call, owns outside dismissal for hover layers, armed from the opening handler. This Effect adds a second path with its own containment test. Could the pin arm that owner from toggle, and could focus leaving close a pinned layer through the existing focus-leave path?

{name: 'focusTrigger', type: "'auto' | 'always' | 'never'", description: 'When focus should open the tooltip. auto only attaches focus listeners to naturally focusable elements.', default: "'auto'"},
{name: 'touchTrigger', type: "'auto' | 'tap' | 'none'", description: 'What a tap does where there is no hover. auto opens on tap unless the trigger performs an action of its own; tap always opens (an info icon rendered as a button); none never opens on touch.', default: "'auto'"},
{name: 'touchTrigger', type: "'auto' | 'tap' | 'none'", description: 'What a tap does where there is no hover. auto opens on tap unless the trigger performs an action of its own; tap always opens (deprecated, DEP-0016: use pressTrigger="toggle"); none never opens on touch. Ignored under pressTrigger="toggle".', default: "'auto'"},
{name: 'pressTrigger', type: "'none' | 'toggle'", description: 'Whether a press on the trigger pins the tooltip open. none leaves taps to touchTrigger; toggle, on a button trigger, pins on a click, a tap, Enter, or Space and closes on the same press, Escape, a press outside, or focus leaving. The trigger carries aria-expanded and aria-controls, the tooltip is announced once when it pins, and the press stops at the trigger.', default: "'none'"},

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 press stops at the trigger" holds only for the Tooltip/HoverCard components, whose wrapper calls stopPropagation. With the hook alone, a button inside <div onClick> pins the tooltip and also fires the row's handler. The same wording is in useHoverCard.doc.mjs and both hooks' JSDoc.

Comment thread packages/core/src/Tooltip/Tooltip.tsx Outdated
* as a button wants: it looks like an action to the DOM, but revealing the
* tooltip is the only thing it does
* - `tap`: tap always opens the tooltip, even on a trigger that acts.
* Deprecated (DEP-0016): pass `pressTrigger="toggle"` instead; `astryx

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 prose only. DEP-0016's warning field and spec:AST-017 FR29a ask for static @deprecated metadata on tap, and the description says it is present, but no @deprecated tag exists for it.

Comment thread packages/core/src/Tooltip/Tooltip.tsx Outdated
export type {TooltipFocusTrigger, TooltipTouchTrigger} from './useTooltip';
export type {
TooltipFocusTrigger,
TooltipPressTrigger,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TooltipPressTrigger (and HoverCardPressTrigger) is not added to Tooltip/index.ts, HoverCard/index.ts or Layer/index.ts. Those files export the FocusTrigger/TouchTrigger siblings, so the new types are unreachable from @astryxdesign/core (architecture:public-component-api INV1).

@vjeux
vjeux force-pushed the feat/tooltip-press-trigger branch from a5250c6 to c543d57 Compare November 1, 2026 02:06
@github-actions github-actions Bot removed the needs:design-review Affects visuals — Design should review label Nov 1, 2026
@vjeux vjeux changed the title feat(Tooltip, HoverCard): pressTrigger="toggle" pins the layer on a press; deprecate touchTrigger="tap" feat(Tooltip, HoverCard): pressTrigger="toggle" pins the layer on a press Nov 1, 2026
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Per the owner's ruling, DEP-0016 (the @deprecated metadata, docs and codemod) moved to a stacked follow-up that lands in the same release; the other four points are fixed in c543d57.

@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 toggle now pins and closes as component:Tooltip FR9 describes and the earlier findings are fixed, but a few gaps remain.

  • In Chromium, a press on a pinned HoverCard's (or Tooltip's) own text closes it, because the focus-leave check treats focus moving to the page body as leaving (usePressPin.ts:353-359), while HoverCard FR9 says such a press keeps the pin.
  • The pin reads textContent and announces it from its own Effect, but architecture:react-component-runtime INV10 puts that in useAnnounceRenderedText, which also keeps an unlabelled card's paragraphs apart (today it says "Ada LovelaceAnalyst, Payments").
  • The HoverCard verification row cites Enter, Space, press-outside, focus-leave, pointer-leave and unlabelled cases that no HoverCard test covers yet.
  • Smaller: the touchDismissRef bridge Effect and the trigger element kept in state in usePressPin, the duplicated stopPressPinClick, and the changeset's forward-looking DEP-0016 sentence.

[Automated review]

Comment on lines +353 to +359
if (
next instanceof Node &&
(trigger?.contains(next) === true || surface?.contains(next) === true)
) {
return true;
}
close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the person presses non-focusable text inside the pinned layer, Chromium moves focus from the trigger to the body, so relatedTarget is null and this closes the pin. A pinned HoverCard then closes on a press on its own content, against HoverCard FR9 ("a press on its own content keeps it pinned"), and a pinned Tooltip closes the same way. Could a focus move caused by a press inside the trigger or the layer be left to useTouchTrigger's outside dismissal, with this closing only when focus lands outside both?

Comment thread packages/core/src/Layer/usePressPin.ts Outdated
Comment on lines +399 to +412
useEffect(() => {
if (!isToggleMode || trigger == null || !isExpanded || !wiresState) {
announcedRef.current = false;
return;
}
if (announcedRef.current) {
return;
}
announcedRef.current = true;
const text = optionsRef.current.announcement()?.trim();
if (text != null && text !== '') {
announce(text, 'polite');
}
}, [isToggleMode, trigger, isExpanded, wiresState, announce]);

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 announces text the hosts read with textContent. architecture:react-component-runtime INV10 keeps reading and announcing a component's rendered text in hooks/useAnnounceRenderedText.ts, which skips aria-hidden content and keeps blocks apart; an unlabelled HoverCard with two paragraphs is announced here as "Ada LovelaceAnalyst, Payments".

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: DOM nodes must not be stored in React state. The trigger can live in triggerRef alone, with attach/detach setting and restoring its aria-expanded/aria-controls, which would also replace the restore Effect below.

Comment on lines +440 to +442
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 only copies useTouchTrigger's callbacks into a ref, so it synchronizes no external system (INV3), and the same bridge and hideFromTouch are copied in useHoverCard.tsx:450-452. Letting usePressPin own the touch-trigger wiring (or useTouchTrigger take the pin's close) would leave each hook one call.

Comment thread .changeset/press-trigger-toggle.md Outdated
'@astryxdesign/core': patch
---

[feat] Tooltip and HoverCard (and `useTooltip`, `useHoverCard`) take `pressTrigger` (types `TooltipPressTrigger`, `HoverCardPressTrigger`): `none` (the default, unchanged) or `toggle`. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the layer open and the same press closes it, while hover and focus still preview it. A pinned layer 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. The trigger reports `aria-expanded` and `aria-controls`, and the layer is announced once when it pins, inside a modal too (`component:Tooltip` FR9 and FR10, `component:HoverCard` 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. `touchTrigger="tap"` is deprecated in favor of `pressTrigger="toggle"` in the next patch (DEP-0016).

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 last sentence announces the DEP-0016 deprecation, which now ships in the stacked follow-up. Could it move to that follow-up's changeset so this release note describes only what this change does?

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 1, 2026
@cixzhang
cixzhang marked this pull request as ready for review November 1, 2026 03:13
@vjeux
vjeux force-pushed the feat/tooltip-press-trigger branch from c543d57 to 90ae917 Compare November 1, 2026 05:18
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 90ae917, per the spec owners' ruling:

  • A layer's rendered text is announced through useAnnounceRenderedText (spec:AST-086 FR12), with no raw textContent and no Effect of its own. A labelled HoverCard keeps FR10's label: a string, said once from the press handler through useAnnounce (FR14), again with no Effect.
  • A press on a pinned layer's own text keeps it. Focus that moves to no element is left to the outside-press dismissal; only focus landing on an element outside the trigger and the layer closes the pin. Chromium cases cover a click on a pinned tooltip's and card's own text.
  • touchDismissRef is assigned during render, and the copy Effects are gone.
  • A pressTrigger that changes after mount re-wires the trigger, with a rerender test.
  • The trigger stays in a ref, with no DOM node in state, and stopPressPinClick is shared.
  • Each case the HoverCard verification row names now has a HoverCard test. An unlabelled card's paragraphs come out apart ("Ada Lovelace Analyst, Payments"), asserted in jsdom and in Chromium.
  • LayerPressTrigger and armOutsideDismiss stay internal: neither is exported from the Layer entry point, and the public useTouchTrigger return is unchanged (useTouchTriggerInternal carries the arm).

Kept, per the spec owners' ruling: the changeset says tap is deprecated in the next patch, since #9246, which carries the deprecation in its own changeset, merges separately. The Tooltip.tsx:38 exports comment was already fixed in c543d57.

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 1, 2026

@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 toggle now behaves as component:Tooltip and component:HoverCard FR9 and FR10 describe, and the earlier findings are fixed, but three items remain.

  • The touch-trigger bridge (touchDismissRef, hideFromTouch, the usePressPin call and the touchTrigger override) is copied line for line into useTooltip.tsx:375-439 and useHoverCard.tsx:384-448; moving it into usePressPin, so each hook makes one call, keeps the two layers from drifting apart.
  • At 2,218 authored lines the change is over the 2,000-line threshold; the HoverCard wiring (about 600 lines) could land as a follow-up, or the description could show why the two are inseparable.
  • The changeset's last sentence announces DEP-0016, which ships in #9246, so it belongs in that pull request's changeset.

[Automated review]

Comment on lines +384 to +448
// A press pins the card under `pressTrigger="toggle"` (FR9, FR10). The pin
// arms and drops the touch trigger's outside dismissal, the one owner of a
// hover layer's dismissal by a press outside. That hook takes the pin's
// close as part of its hide, so the pin reaches it through a ref, assigned
// during render below and read only from handlers.
const touchDismissRef = useRef<{arm: () => void; disarm: () => void} | null>(
null,
);
const armTouchDismiss = useCallback(() => touchDismissRef.current?.arm(), []);
const disarmTouchDismiss = useCallback(
() => touchDismissRef.current?.disarm(),
[],
);

const {
isToggleTrigger,
isPinnedRef: isPressPinnedRef,
attach: attachPressPin,
unpin: unpinPress,
closeIfPinned: closePressPin,
handleFocusLeave: handlePressFocusLeave,
isPinShown,
} = usePressPin({
pressTrigger,
isEnabled,
isOpen,
isLayerOpen,
layerId,
component: 'HoverCard',
show: showNow,
hide: hideNow,
report: next => (next ? onShow?.() : onHide?.()),
label,
armOutsideDismiss: armTouchDismiss,
disarmOutsideDismiss: disarmTouchDismiss,
});

// An outside press or scroll closes a pinned layer through the pin, so the
// pin drops with it; otherwise it hides as a tap-open does.
const hideFromTouch = useCallback(() => {
if (closePressPin()) {
return;
}
hideNow();
}, [closePressPin, hideNow]);

const {
isTouchPointerRef,
isTouchInteraction,
handlePointerEnter,
handlePointerDown: handleTouchPointerDown,
clearTapOpen,
} = useTouchTrigger({
touchTrigger,
armOutsideDismiss,
} = useTouchTriggerInternal({
// Under the toggle, a trigger no press can pin keeps tap-only opening.
touchTrigger: pressTrigger === 'toggle' ? 'tap' : touchTrigger,
isEnabled,
isControlled: isOpen !== undefined,
isOpen: isLayerOpen,
layerId: layerId,
triggerRef,
show: showNow,
hide: hideNow,
hide: hideFromTouch,
});
touchDismissRef.current = {arm: armOutsideDismiss, disarm: 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 block is the same as useTooltip.tsx:375-439 apart from the component name and one option: the touchDismissRef bridge, hideFromTouch, the usePressPin call and the touchTrigger override. A fix to how a pin closes on an outside press would then have to land twice. Could usePressPin (or one Layer hook) call useTouchTriggerInternal itself and hand back the touch handles, so each hook makes one call?

Comment thread .changeset/press-trigger-toggle.md Outdated
'@astryxdesign/core': patch
---

[feat] Tooltip and HoverCard (and `useTooltip`, `useHoverCard`) take `pressTrigger` (types `TooltipPressTrigger`, `HoverCardPressTrigger`): `none` (the default, unchanged) or `toggle`. With `toggle` on a button trigger, a click, a tap, Enter, or Space pins the layer open and the same press closes it, while hover and focus still preview it. A pinned layer 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. The trigger reports `aria-expanded` and `aria-controls`, and the layer is announced once when it pins, inside a modal too (`component:Tooltip` FR9 and FR10, `component:HoverCard` 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. `touchTrigger="tap"` is deprecated in favor of `pressTrigger="toggle"` in the next patch (DEP-0016).

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 last sentence announces DEP-0016, which ships in #9246 with its own changeset. If both land together the notes say it twice, and if #9246 slips they promise a deprecation that has not happened. Could it move to #9246?

@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 needs an engineering owner's spec decision first. The new anchorRef sentence in component:Tooltip FR9 lets a plain onClick container receive the pinning press, and the current FR9 says the press stops at its trigger. An engineering owner records approval by commenting /approve-spec 26d7af948c0d9d8fa2fee0c4d123c93adf9945ea; the author may do this, as an engineering owner. Please also say whether bare useTooltip triggers fall under the same exception. Two changes are needed either way:

  • Replace the module-level warnedTriggers set in usePressPin.ts with useDevWarning, which already owns once-per-mount development warnings.
  • Bring the 2,074 authored lines to 2,000 or under, for example by moving the caller-less repeatsTriggerText option to the pull request that first uses it.

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

The new anchorRef sentence ("a plain onClick element around the anchor receives it unless the anchor's onClick stops it") narrows this row's own stop: "it does not reach a clickable row, card, or container around it". As a requirement change it needs an engineering owner's /approve-spec on this head. Bare useTooltip triggers behave the same way today, and FR9 does not mention them.

Comment thread packages/core/src/Layer/usePressPin.ts Outdated
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.

Core already owns once-per-mount development warnings in useDevWarning (hooks/useDevWarning.ts). Since offeredKind is render state, useDevWarning(options.component, message, isToggleMode && offeredKind === 1 && wiresState) could replace this set and the warn branch in wire, and the "warns once per trigger" test still holds.

@cixzhang

cixzhang commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 26d7af9

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 1, 2026
@vjeux
vjeux force-pushed the feat/tooltip-press-trigger branch from 26d7af9 to 38e6fa2 Compare November 1, 2026 16:26
@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 38e6fa2:

  • usePressPin warns through useDevWarning, once per mount while a non-button trigger is offered under the toggle. The module-level WeakSet and its own devWarn call are gone.
  • Size: this pull request is now 1,955 lines. FR9's own-text trigger unit moves to the own-text pull request (branch feat/tooltip-own-text-pin), stacked right above it and shipping in the same release. That unit is the internal repeatsTriggerText option, useTooltipInternal, and useClickableContainer ignoring a press-pin click, with their tests.
    • The guard alone is 5 lines, and at this head it guards nothing reachable: the toggle wires only button triggers, and Astryx clickable containers already skip a click on a nested button.
    • FR9's own-text clause is recorded here as not built.
  • The Tooltip record's verification table keeps its column widths, so its diff changes only the rows it means to change.

@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 needs an engineering owner's spec decision first. This revision's component:Tooltip FR9 lets a plain onClick container receive the pinning press from an anchorRef or bare useTooltip trigger, while the current FR9 says the press stops at its trigger, and the earlier /approve-spec covered only the previous revision's anchorRef wording. An engineering owner records approval of this wording by commenting /approve-spec 38e6fa2bd8c82e9404b4739a64d0506c7961f503; the author may do this as an engineering owner. One change is needed either way:

  • The changeset says the press stops before a clickable row for both Tooltip and useTooltip, but a bare useTooltip trigger inside a plain onClick row still runs it, so name useTooltip beside anchorRef in that sentence.

[Automated review]

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.

This names useTooltip beside Tooltip, then says the press stops before a clickable row with only anchorRef as the exception. A bare useTooltip trigger has no wrapper to stop it, so a plain onClick row around it still runs on the pinning press (the hook docs say so). Could the anchorRef sentence also name useTooltip on a caller's own trigger?

| 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: the own-text clause is not built in Core 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") narrows this row's own stop at the trigger, and this revision adds the useTooltip case to the wording approved earlier. It needs an engineering owner's /approve-spec for this revision.

@cixzhang

cixzhang commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 38e6fa2

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 1, 2026
@vjeux
vjeux force-pushed the feat/tooltip-press-trigger branch from 38e6fa2 to 3870f5e Compare November 2, 2026 00:57
@vjeux vjeux changed the title feat(Tooltip): pressTrigger="toggle" pins the tooltip on a press feat(Tooltip): triggerBehavior="toggle" pins the tooltip on a press Nov 2, 2026
@github-actions github-actions Bot removed the needs:design-review Affects visuals — Design should review label Nov 2, 2026
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Updated in 3870f5e, on main with #9379 merged:

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 3870f5e

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026

@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: triggerBehavior behaves as component:Tooltip FR9 and FR10 describe, and FR9's anchorRef and useTooltip exception is approved for this revision, but the record's evidence for the new values is left stale. Please:

  • Restate the FR5, FR9 (triggerBehavior) verification row (Tooltip.spec.md:308), which still says the evidence is a gap until built while line 140 records the behavior built, as a named gap for the cases it lists and for FR9's useTooltip clause, or add those tests here.
  • Rebase onto main: the branch conflicts in useTooltip.tsx, useTooltip.doc.mjs and Tooltip.spec.md.

[Automated review]

| FR9, FR10, AR6 | Evidence gap until built: a press-toggle suite in `Tooltip.test.tsx` and `useTooltip.test.tsx`, and a real-touch and keyboard case in `TouchTrigger.a11y.chromium.spec.ts` | Click, Enter, Space, and tap pin; second press, press outside, focus leaving, and Escape close; pointer leave keeps the pin; controlled; non-button trigger; a truncated Text trigger whose tooltip repeats its text; a toggle trigger inside a ClickableCard, an Item row, and a `<label>` | A press that does not pin or close as stated, a pin lost on pointer leave, a missing or stale `aria-expanded` or `aria-controls`, a second announcement, a non-button trigger without the warning or its tap fallback, an own-text trigger that warns, gets `aria-expanded`, or is announced, or a pinning press that also activates the card, row, or labelled control around the trigger, fails. | `audit:Tooltip/behavior` |
| FR9, FR10, AR6 | `usePressPin.test.tsx` Tooltip suites; `TouchTrigger.a11y.chromium.spec.ts` toggle-behavior and mouse cases; `layerDismissalFamilies.test.tsx` pinned Tooltip over a Dialog; own text: evidence gap until built | Click, Enter, Space, and tap pin; second press, press outside, focus leaving, and Escape close; pointer leave keeps the pin; controlled; non-button trigger; a truncated Text trigger whose tooltip repeats its text; a toggle trigger inside a ClickableCard, an Item row, and a `<label>` | A press that does not pin or close as stated, a pin lost on pointer leave, a missing or stale `aria-expanded` or `aria-controls`, a second announcement, a non-button trigger without the warning or its tap fallback, an own-text trigger that warns, gets `aria-expanded`, or is announced, or a pinning press that also activates the card, row, or labelled control around a child trigger, fails. | `audit:Tooltip/behavior` |
| FR9 (`anchorRef`) | `usePressPin.test.tsx` `anchorRef` suite; `TouchTrigger.a11y.chromium.spec.ts` anchored case | An anchor with its own `onClick`, inside a ClickableCard, an Item row, a Table row that expands on click, and a plain `onClick` row | An anchor's own `onClick` that does not run, or an Astryx card, row, or Table row around the anchor that activates, fails. | `audit:Tooltip/behavior` |
| FR5, FR9 (`triggerBehavior`) | Evidence gap until built: a trigger-behavior suite in `Tooltip.test.tsx` and `useTooltip.test.tsx`, and a real-touch and pen case in `TouchTrigger.a11y.chromium.spec.ts` | `adaptive` by default and explicitly, `hover`, and `toggle`, each with mouse hover, keyboard focus, touch, and pen; `touchTrigger` beside each value | Under any value, a lost hover or keyboard-focus preview; under `adaptive`, an info-only tap that leaves the tooltip shut or a press that pins it; under `hover`, a touch or pen tap that opens it or a press that pins it; under `toggle`, a click, tap, Enter, or Space that does not pin or close it as FR9 states; or a `touchTrigger` that changes `hover` or `toggle`, fails. | `audit:Tooltip/behavior` |

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 still says the evidence is a gap until built, while line 140 now records triggerBehavior as built. The cases it names (a pen under each value, adaptive set explicitly, keyboard preview under hover and toggle, touchTrigger beside each value) have no tests in this pull request, and neither does FR9's approved useTooltip clause, which the FR9 (anchorRef) row above does not name. Could the Evidence cell name those as a remaining gap, or could the tests come here?

`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. On a `role="button"` element, Enter
  acts on keydown and Space on keyup, as on a native button.
- 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, in the
  trigger's own document); 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: its default action is prevented, and the
  trigger's own handlers still run. 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).
- `TooltipTriggerBehavior` is a closed union; the API report lists it.
- 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.
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Updated in ff2c7a3.

  1. The FR5, FR9 (triggerBehavior) verification row now names its evidence gap: a trigger-behavior suite in Tooltip.test.tsx, a touch and pen case in TouchTrigger.a11y.chromium.spec.ts, and FR9's useTooltip clause in useTooltip.test.tsx. Its states add a useTooltip trigger inside an Astryx clickable card and inside a plain onClick element. test(Tooltip): triggerBehavior on every input #9406 carries those tests.
  2. Rebased onto main 032c4a1, past fix(Tooltip): under focusTrigger="auto", decide when focus arrives whether it opens the tooltip #9366, spec(Tooltip, HoverCard): the touchTrigger codemod is available before the 0.8.0 removal #9417, spec(layer-runtime): a click inside a layer stops at the layer root #9398, refactor(Item, Token, ChatComposer): drop the per-site tooltip surface checks the layer boundary replaces #9531, chore(eslint): report provenance in source comments, with a baseline of current sites #9516 and chore(api): regenerate the core and CLI API reports #9589. FR11 takes main's text, and FR9 and FR10 are unchanged. The compatibility row now counts eleven further gaps.

Also in this head: TooltipTriggerBehavior is a closed union, with its API report rows. The pin's focus-out check uses the trigger's own window, so a trigger in an iframe closes its pin. Space on a role="button" trigger acts on keyup. Source comments drop their record citations, and the record links usePressPin.ts instead.

@vjeux
vjeux force-pushed the feat/tooltip-press-trigger branch from 3870f5e to ff2c7a3 Compare November 2, 2026 20:02
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec ff2c7a3

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026

@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: triggerBehavior behaves as component:Tooltip FR9 and FR10 describe, and an engineering owner approved the record change for this revision, but the pull request now counts 2,151 authored lines, over the 2,000-line limit. Please move at least 152 separable lines into one of the stacked pull requests (the role="button" Space-on-keyup timing and its tests are one candidate), or explain in the description why no remaining part can stack. An optional cleanup is noted inline.

[Automated review]

// What the host offered last: 0 is nothing yet, 1 a trigger that is not a
// button, 2 a button. It is a primitive, so a host that attaches the same
// trigger on every commit renders nothing.
const [offeredKind, setOfferedKind] = useState<0 | 1 | 2>(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional (non-blocking): a named union would make this and the isWired line explain themselves, so both comments can go:

const [offeredKind, setOfferedKind] = useState<'none' | 'other' | 'button'>('none');
// …
const isWired = isToggleMode && offeredKind === 'button';

This branch was successfully deployed

1 active deployment
Preview — ff2c7a3e 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

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants