Skip to content

feat(Tooltip, HoverCard): tap to open where there is no hover - #5248

Merged
imdreamrunner merged 2 commits into
mainfrom
rubycheung/hover-layers-touch-triggers
Aug 21, 2026
Merged

feat(Tooltip, HoverCard): tap to open where there is no hover#5248
imdreamrunner merged 2 commits into
mainfrom
rubycheung/hover-layers-touch-triggers

Conversation

@rubyycheung

@rubyycheung rubyycheung commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Hover is the one trigger a touch screen cannot express, and both components were answering that badly. Tooltip suppressed itself on any device reporting (hover: none), so its content — often the only label an icon button has — was simply unreachable on a phone. HoverCard did nothing at all: the mouseenter a tap synthesizes opened the card on every tap of its trigger, over the control the user was aiming at, with no gesture that closed it again.

What changed

Both now take a touchTrigger prop, and what the trigger does decides the default:

Value Behavior
auto (default) A tap opens the layer unless the trigger performs an action of its own — a button, a link, a form control, or an explicit action role. Those keep their tap: it already has somewhere to go, and a hint about a control the user just operated is noise.
tap Always opens on tap, even on a trigger that acts.
none Never opens on touch.

A tap-opened layer opens with no show delay — a tap is a decision, not the hover intent the delay exists to filter — and is dismissed by a tap outside it, Escape, or a second tap on the trigger. A tap inside a HoverCard's own content leaves it open.

Hover-capable devices are unaffected, hybrid ones included: the decision is made per interaction from the pointer type rather than once per device from a media query, so the same trigger opens on hover under a mouse and on tap under a finger. A stylus is a hover device by the same rule — a pen in detection range fires hover events with nothing in contact, so it opens the layer on hover, and only a pen that lands counts as a tap. Neither layer opens from the focus a tap leaves behind any more, which was the second way a tap could bury the control it activated; on Tooltip that is the tapped text fields, which match :focus-visible by design and so slipped past the selector alone.

InfoTip opts into touchTrigger="tap". Its trigger is a real button, so auto would hand the tap to the control — but revealing the tooltip is that button's only purpose, and suppressing it left an InfoTip's content unreachable on a phone.

The shared behavior lives in Layer/useTouchTrigger.ts so the two hooks cannot drift, and because the tap-versus-suppress decision rests entirely on isActionTrigger, which is the part a new element type silently falls through.

Test plan

  • 161 tests pass across Layer, Tooltip, HoverCard, and InfoTip — 33 of them new: the action/inert predicate per element type, and the gestures on each component (tap opens an inert trigger, stays shut on an action trigger, opens an action trigger under tap, second tap closes on both components, outside tap closes, a tap inside HoverCard content does not, a hovering pen opens while a landing pen tap-opens, the focus a tap leaves behind is ignored while keyboard focus still opens, and a real mouse hover still opens after a tap). Each of the six added after review was verified red against the code without its fix.
  • tsc --noEmit, eslint, and every check:repo script — clean, re-verified after rebasing onto a7ba3a09 (which touched useLayer.tsx).
  • Full core + lab suite: 7464 pass, aside from three unrelated flakes that move between runs.
  • Checked by hand in Storybook with touch emulation, via new Touch Triggers stories on Tooltip and HoverCard and a Touch story on InfoTip.

Hover is the one trigger a touch screen cannot express, and both
components were answering that badly. `Tooltip` suppressed itself on any
device reporting `(hover: none)`, so its content — often the only label
an icon button has — was simply unreachable on a phone. `HoverCard` did
nothing at all: the `mouseenter` a tap synthesizes opened the card on
every tap of its trigger, over the control the user was aiming at, with
no gesture that closed it again.

Both now take a `touchTrigger` prop, and what the trigger DOES decides
the default. A trigger that performs an action — a button, a link, a
form control, or an explicit action `role` — keeps its tap under `auto`:
the layer stays shut, because the tap already has somewhere to go and a
hint about a control the user just operated is noise. A trigger that
performs no action — an info icon, an abbreviation, a truncated label —
has nothing to lose, so the tap opens the layer, with no show delay (a
tap is a decision, not the hover intent the delay exists to filter) and
a tap outside to dismiss it. `tap` and `none` state the choice outright.

Hover-capable devices are unaffected, hybrid ones included: the decision
is made per interaction from the pointer type rather than once per
device from a media query, so the same trigger opens on hover under a
mouse and on tap under a finger. `HoverCard` also no longer opens from
the focus a tap leaves behind, which was the second way a tap could bury
the control it activated.

`InfoTip` opts into `touchTrigger="tap"`. Its trigger is a real button,
so `auto` would hand the tap to the control — but revealing the tooltip
is that button's only purpose, and suppressing it left an InfoTip's
content unreachable on a phone.

The shared behavior lives in `Layer/useTouchTrigger.ts` so the two hooks
cannot drift, and because the tap-versus-suppress decision rests
entirely on `isActionTrigger`, which is the part a new element type
silently falls through.

Test Plan:
- `vitest run packages/core/src/Layer packages/core/src/Tooltip
  packages/core/src/HoverCard packages/lab/src/InfoTip` — 155 pass, 27
  of them new: the action/inert predicate per element type, and the
  gestures on each component (tap opens an inert trigger, stays shut on
  an action trigger, opens an action trigger under `tap`, second tap
  closes, outside tap closes, a tap inside HoverCard content does not,
  and a real mouse hover still opens after a tap).
- `tsc --project packages/core/tsconfig.json --noEmit`, `eslint` on every
  touched file, and every `check:repo` script — all clean.
- Full core suite: 6969 pass (the two `Table.perf` budget failures are
  load-dependent and pass on their own).
- Storybook by hand with touch emulation, via new `Touch Triggers`
  stories on Tooltip and HoverCard and a `Touch` story on InfoTip.

Note: committed with --no-verify because pnpm is unavailable in this
environment (corepack cannot reach the npm registry), so the husky hook
could not run. Its two steps were run by hand instead: lint-staged's
`eslint --cache --fix` and `prettier --write` over the staged files, and
every script in `check:repo` individually.
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 21, 2026 12:45am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 20, 2026
@github-actions github-actions Bot added the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

HoverCard (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 548 -
Complexity N/A Very High (57) -
Layer (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 989 -
Complexity N/A Very High (87) -
Tooltip (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 517 -
Complexity N/A Very High (55) -
InfoTip (@astryxdesign/lab) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 87 -
Complexity N/A Low (4) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB
@astryxdesign/lab N/A 3.6KB 1.5KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

github-actions Bot added a commit that referenced this pull request Aug 20, 2026
@imdreamrunner

Copy link
Copy Markdown
Contributor

Reviewed this end to end — the design is right, and I'd like to see it land. Per-interaction pointer type instead of a per-device media query is the correct call, the auto rule is a good default, and the capture-phase reasoning behind the outside-dismiss listener holds up (the opening tap's document capture pass has already run by the time the listener is armed).

One blocking issue, then two things that can follow later.

Blocking: a hovering stylus loses its tooltip

TOUCH_POINTER_TYPES includes 'pen', and handlePointerEnter sets isTouchPointerRef from it. But a pen in detection range hovers — it fires pointerenter/pointermove with no contact, exactly as a mouse does, and (hover: hover) matches on those devices. So the flag goes true on arrival, handleMouseEnter bails, and the tooltip never opens for a stylus user.

Measured in Chromium against the real built Tooltip, driving a pen pointer with no contact (Input.dispatchMouseEvent, pointerType: 'pen', buttons: 0), mouse hover as a control:

a7ba3a09 (before) this branch
pen hover opens does not open
mouse hover opens opens

That's a regression against today's behavior, and a silent one — nothing on screen explains why the tooltip stopped appearing. Surface and iPad-with-Pencil users are the ones affected.

The fix is to treat only a finger as hoverless on arrival, and keep pen in the pointer-down path, where a pen that lands really is a tap:

const handlePointerEnter = useCallback((event: PointerEvent) => {
  // A pen hovers: in detection range it fires pointerenter with no contact,
  // exactly as a mouse does, and `(hover: hover)` matches. Only a finger is
  // hoverless on arrival; a pen that presses is a tap, handled below.
  isTouchPointerRef.current = event.pointerType === 'touch';
}, []);

handlePointerDown keeps TOUCH_POINTER_TYPES unchanged. I applied exactly this locally and re-measured — pen hover opens, pen tap still tap-opens, mouse hover unaffected, and all 155 tests across Layer, Tooltip, HoverCard, and InfoTip still pass. Worth a test case too; nothing in the suite currently exercises a pen.

Non-blocking: Tooltip's focus path didn't get the guard HoverCard got

useHoverCard.handleFocusIn gained if (touch.isTouchInteraction()) return;. useTooltip.handleFocusIn still gates on :focus-visible alone — and a tapped text input matches :focus-visible (measured under Pixel 7 emulation: <input> and contenteditable report focus-visible=true on a tap; <button> and <span tabindex=0> report false).

So on an <input>, <textarea>, or contenteditable trigger, the touch path correctly resolves autononehide(), and then focusin calls layer.show() anyway. Those are two of the element types isActionTrigger exists to protect.

This is not a regression — that focus path was already unguarded — so it needn't block. But it's auto not delivering on exactly the triggers where a tooltip is most in the way, and it's the same one-line guard in a file already being touched.

Non-blocking: an older copy of this feature stays behind

packages/core/src/hooks/useInputStatusIcon.tsx already hand-rolls tap-to-open for the input status tooltip — tapOpen state, a matchMedia('(hover: none)') click toggle, a blur reset, a mirrored Escape listener — driving useTooltip in controlled mode, on a button whose only job is revealing a tooltip. That's InfoTip's case, and this PR just built the shared answer for it. I traced it and nothing breaks (controlled mode short-circuits the touch path), but the changeset's own argument against the media query applies to code left standing. Better as its own PR; migrating it deletes ~40 lines.

Smaller

  • The HoverCard focus fix is a headline in the description but has no test, and neither does second-tap-closes on HoverCard (Tooltip has it).
  • The tap() test helper skips the pointerenter{touch} a real finger fires first. Harmless — pointerdown sets the same flag — but it leaves that path uncovered, which is where the pen bug lives.
  • isActionTrigger returns false for a <div onClick> with no role, so it gets tap-to-open and its click. That's fine, but worth stating in the doc comment that the tap is never swallowed, only ignored — "that tap belongs to the control" reads like a preventDefault the code correctly never calls.

Gates, re-run locally

tsc --noEmit clean · lint:strict 0 errors (56 pre-existing warnings) · 155 tests in the four affected areas · 7458 across core + lab, all green. Docs cover docs, docsZh, and docsDense.propDescriptions for both components; changeset present.

Review found a regression: `TOUCH_POINTER_TYPES` included `pen`, and
`handlePointerEnter` read it, so a stylus arriving over a trigger set the
touch flag and the hover path bailed — a Surface or iPad-with-Pencil user
got no tooltip at all, with nothing on screen to explain why. A pen in
detection range hovers: it fires pointerenter/pointermove with no contact,
exactly as a mouse does, on a device where `(hover: hover)` matches.

Arrival now marks only a finger. `handlePointerDown` keeps the full set, so
a pen that lands is still a tap — it hovers to open, and presses to
tap-open an inert trigger. Two new cases cover both, and the `tap()` helpers
now fire the `pointerenter{touch}` a real finger sends first, so the arrival
path is exercised rather than skipped.

`useTooltip.handleFocusIn` gains the guard `useHoverCard.handleFocusIn`
already had. `:focus-visible` alone does not filter out a tap: an `<input>`
or contenteditable matches it when tapped, deliberately, so those triggers
resolved `auto` to `none`, closed — and then reopened from the focus. Both
components now have a test that the tap's focus is ignored while keyboard
focus still opens, and its jsdom `:focus-visible` is stubbed to the measured
browser answer, so the case can actually fail.

Also: HoverCard was missing tests for second-tap-closes and for the focus
fix the description headlines, and `isActionTrigger`'s doc now says outright
that a true only keeps the layer shut — the tap is never swallowed, so a
`<div onClick>` gets both its handler and its layer.
@rubyycheung

Copy link
Copy Markdown
Contributor Author

Thanks — the pen case is a real regression and I'm glad you measured it. All fixed in d23d14c.

Blocking: hovering stylus

Applied your fix exactly: handlePointerEnter now sets the flag from event.pointerType === 'touch' alone, and handlePointerDown keeps TOUCH_POINTER_TYPES intact, so a pen hovers to open and taps to tap-open. The set's doc comment now says why the two paths disagree, so the next person doesn't "helpfully" re-align them.

Two tests, both of which fail without the change:

  • opens on a hovering pen, which is a hover and not a tappointerenter{pen, buttons: 0} then mouseenter, on a button trigger auto would otherwise suppress. Verified red on the pre-fix code.
  • tap-opens when a pen lands on an inert trigger — hover first, then contact, which is the sequence a real pen produces.

Tooltip's focus path

Guarded, same one line HoverCard has. You were right that the case needed standing up rather than just asserting: jsdom doesn't model a tapped <input> matching :focus-visible, so the obvious test passes either way. The test now stubs :focus-visible to true on the trigger — the answer you measured in Chromium — and it goes red without the guard. Its pair, still opens on keyboard focus of a trigger a finger touched, holds the other side: the guard is on the gesture in flight, not on the device the trigger last saw.

Missing tests

Both added on HoverCard, and both verified red with the guard removed:

  • closes on a second tap of the trigger
  • ignores the focus a tap leaves behind, but not keyboard focus

tap() helper

All three helpers now fire pointerenter{touch} first, so the arrival path the pen bug lived in is exercised rather than skipped.

isActionTrigger doc

Reworded: true only decides the layer stays shut, and nothing in the hook calls preventDefault or stopPropagation — a <div onClick> with no role gets both its handler and its layer.

useInputStatusIcon

Agreed, and left alone here — separate PR, since it deletes code rather than changing this one's behavior.

Gates

161 tests across Layer, Tooltip, HoverCard, InfoTip (155 + 6 new) · core typecheck clean · eslint strict clean on the touched files · check:repo clean · 7464 across core + lab, with three unrelated flakes that move between runs (SideNav focus return, a streaming-parse perf assertion, a drafts case) and none in the touched areas. Changeset updated for the pen rule and Tooltip's focus guard.

github-actions Bot added a commit that referenced this pull request Aug 21, 2026
@imdreamrunner
imdreamrunner enabled auto-merge (squash) August 21, 2026 01:57

@imdreamrunner imdreamrunner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving — the pen regression is gone, and I re-measured rather than taking the diff's word for it.

Rebuilt the real Tooltip from d23d14cf6 and drove Chromium through every pointer that matters, pen via CDP with pointerType: 'pen', finger via Pixel 7 emulation, at the components' real default delays:

gesture expected result
pen hover over inert trigger opens ✅ opens
pen hover over button opens ✅ opens
pen lands on inert trigger tap-opens ✅ opens
pen dwells, then presses dismisses (as on main) ✅ dismisses
mouse hover opens ✅ opens
finger tap on inert trigger opens ✅ opens
finger tap on button stays shut ✅ shut
finger tap on text input stays shut ✅ shut

The last row is the focus fix, and it's the one I'd flagged as non-blocking — good to see it in. The fourth row is worth calling out too: a pen that dwells and then presses dismisses rather than re-opening, which is main's press-to-dismiss behavior preserved, not a new quirk.

I also mutation-tested both new guards, since a test that cannot fail is worse than no test:

  • put pen back in the arrival path → opens on a hovering pen, which is a hover and not a tap goes red
  • drop the isTouchInteraction() guard from useTooltip.handleFocusIndoes not reopen from the focus a tapped text field takes goes red

Both genuinely hold the line. Stubbing :focus-visible to the browser-measured answer was the right call — without it that test passes whatever the focus path does.

Gates on my end: tsc --noEmit clean, lint:strict 0 errors (56 pre-existing warnings), build clean, 7464 tests green across core + lab, no flakes in this run.

Agreed on leaving useInputStatusIcon for its own PR — it deletes code rather than changing this one's behavior, and bundling it would have made this harder to review. Happy to review that one when you open it.

Nice work on the doc comments explaining why the two pointer paths deliberately disagree; that's the part that would otherwise get "helpfully" re-aligned in six months.

@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Aug 21, 2026
@imdreamrunner
imdreamrunner merged commit 5d8ece1 into main Aug 21, 2026
21 checks passed
@github-actions
github-actions Bot deleted the rubycheung/hover-layers-touch-triggers branch August 21, 2026 06:12
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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants