feat(Tooltip, HoverCard): tap to open where there is no hover - #5248
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsHoverCard (@astryxdesign/core) · View in Storybook
Layer (@astryxdesign/core) · View in Storybook
Tooltip (@astryxdesign/core) · View in Storybook
InfoTip (@astryxdesign/lab) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
|
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 One blocking issue, then two things that can follow later. Blocking: a hovering stylus loses its tooltip
Measured in Chromium against the real built
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';
}, []);
Non-blocking: Tooltip's focus path didn't get the guard HoverCard got
So on an This is not a regression — that focus path was already unguarded — so it needn't block. But it's Non-blocking: an older copy of this feature stays behind
Smaller
Gates, re-run locally
|
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.
|
Thanks — the pen case is a real regression and I'm glad you measured it. All fixed in d23d14c. Blocking: hovering stylusApplied your fix exactly: Two tests, both of which fail without the change:
Tooltip's focus pathGuarded, same one line HoverCard has. You were right that the case needed standing up rather than just asserting: jsdom doesn't model a tapped Missing testsBoth added on HoverCard, and both verified red with the guard removed:
|
imdreamrunner
left a comment
There was a problem hiding this comment.
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
penback in the arrival path →opens on a hovering pen, which is a hover and not a tapgoes red - drop the
isTouchInteraction()guard fromuseTooltip.handleFocusIn→does not reopen from the focus a tapped text field takesgoes 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.
Hover is the one trigger a touch screen cannot express, and both components were answering that badly.
Tooltipsuppressed itself on any device reporting(hover: none), so its content — often the only label an icon button has — was simply unreachable on a phone.HoverCarddid nothing at all: themouseentera 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
touchTriggerprop, and what the trigger does decides the default:auto(default)role. Those keep their tap: it already has somewhere to go, and a hint about a control the user just operated is noise.tapnoneA 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
Tooltipthat is the tapped text fields, which match:focus-visibleby design and so slipped past the selector alone.InfoTipopts intotouchTrigger="tap". Its trigger is a real button, soautowould 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.tsso the two hooks cannot drift, and because the tap-versus-suppress decision rests entirely onisActionTrigger, which is the part a new element type silently falls through.Test plan
Layer,Tooltip,HoverCard, andInfoTip— 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 undertap, 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 everycheck:reposcript — clean, re-verified after rebasing ontoa7ba3a09(which toucheduseLayer.tsx).Touch Triggersstories on Tooltip and HoverCard and aTouchstory on InfoTip.