Repository navigation
feat(bulma-ui): let a Tooltip be hovered, dismissed with Escape, and opened by a tap - #1033
Conversation
WCAG 1.4.13 asks hover and focus content to be dismissible, hoverable
and persistent. Tooltip missed the first two: nothing hid it on Escape,
and the pointer couldn't reach it, since the content ignored pointer
events and closed as the pointer crossed the gap. It also tracked hover
and focus as one flag, so a pointer leaving hid a tooltip keyboard focus
still held.
Tooltip now runs on useHoverOpen, the hook HoverCard uses, and the hook
needed no new option. Its delays map to openDelay and closeDelay, and an
`active` tooltip turns the hook's asks to open down, so Escape leaves it
shown and goes on to whatever listens. Opening on keyboard focus only,
rather than any focus, keeps a clicked trigger from holding its tooltip
once the pointer leaves. The handler chaining HoverCard kept to itself
moves into the hook's module so both share it, and the wrapper's own
pointer and focus handlers now run alongside the tooltip's instead of
replacing them.
The stylesheet lets the pointer onto a shown tooltip only, and a
transparent ::after bridges the gap to the trigger in each position, so
closeDelay={0} still reaches it. A hidden tooltip still lets the pointer
through.
…led one closes WCAG's dismissible condition is about the user not having to move the pointer or focus, so the Dismissible bullet now says "without you moving". In controlled use Escape only asks to close through onOpenChange, so the bullet adds that the card closes when the caller sets open to false. It also says that with openOnTap, the next tap on the trigger opens a card Escape closed.
|
deep-review: fresh This makes Tooltip meet WCAG 1.4.13 by putting it on the |
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 31 minutes. View limit details
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughTooltip now uses shared hover-open handling for pointer and keyboard-focus interactions, delayed visibility, Escape behavior, and active state. It adds optional touch activation and allows pointer interaction with visible content. Tests, examples, and documentation cover these interactions. HoverCard reuses shared event handling and has additional portal interaction tests. ChangesTooltip and HoverCard interactions
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~30 minutes Change: Feature Sequence Diagram(s)sequenceDiagram
participant Trigger
participant Tooltip
participant useHoverOpen
participant TooltipContent
Trigger->>Tooltip: Send pointer, focus, or tap event
Tooltip->>useHoverOpen: Call composed interaction handler
useHoverOpen-->>Tooltip: Request visibility change
Tooltip->>TooltipContent: Render updated visibility
Merge Risk: ⚪ Minimal · up to The reviewed interaction changes have no established issue that needs resolution before merging. Pre-merge checks |
|
Preview DeploymentPreview URL: https://512bd7e7.bestax.pages.dev |
…eir shadow root from the page React makes onPointerEnter out of the pointerout on the element the pointer left, and skips the pointerover that follows when its relatedTarget is an element React renders. When a page portals content into a shadow root, as the docs site's live examples do, the pointerout the page gets on the way in names only the shadow host, so the enter stops there, and the pointerover inside names page content React owns, so React skips it. A HoverCard or Tooltip in the shadow root never heard the pointer arrive straight from the page. It did from anywhere inside the preview, which made the docs look flaky. useHoverOpen now also takes onPointerOver from the element that holds the trigger. A pointerover whose relatedTarget is outside that element and the floating element counts as the pointer arriving, when no enter already did, and touch still doesn't. One from inside them is the pointer moving about within, so a card Escape closed still stays closed until the pointer leaves and comes back. HoverCard and Tooltip wire it alongside their other handlers. Leaving works already, since the pointerout inside the shadow root names the page element itself. Two React roots, nested or side by side, and a second copy of React for the page get their enter as before.
There was a problem hiding this comment.
Deep review — 3 blocking · 3 advisory
| # | Severity | Area | Finding | Location |
|---|---|---|---|---|
| 1 | 🟠 Major | Correctness | Wrapper-as-triggerRef lets the document capture listener stop Escape before React sees it, so a focusable child's own onKeyDown never runs — a Dropdown inside a Tooltip needs two presses to close its menu |
bulma-ui/src/components/Tooltip.tsx:141 |
| 2 | 🟡 Minor | Correctness | pointer-events: auto is inherited by the arrow, whose box is 1–3px taller than the bridge, so it captures clicks on the trigger's leading edge |
bulma-ui/src/scss/components/_tooltip.scss:117 |
| 3 | 🟡 Minor | Correctness | Dropping active while the pointer is on the trigger leaves the tooltip hidden until the pointer leaves and comes back (the old code showed it) |
bulma-ui/src/components/Tooltip.tsx:135 |
| 4 | 🔵 Advisory | Accessibility | "nothing in the tooltip takes focus" is load-bearing for two hook behaviours, but content is arbitrary ReactNode; with a focusable node Escape drops focus to <body> |
bulma-ui/src/components/Tooltip.tsx:139 |
| 5 | 🔵 Advisory | Accessibility | Touch users lose the tooltip entirely — a tap used to show it — with no openOnTap-style opt-in |
bulma-ui/src/components/Tooltip.tsx:72 |
| 6 | 🔵 Advisory | Correctness | The bridge spans the tooltip's width rather than the trigger's, and active tooltips carry it permanently, so a band of page content becomes unclickable |
bulma-ui/src/scss/components/_tooltip.scss:149 |
Overall: The change is sound and unusually well evidenced — reusing useHoverOpen rather than growing a second Escape/touch/delay implementation is the right call, the three 1.4.13 conditions really are met in the code, and the new SCSS and SSR test files extend what jsdom can see rather than restating it. I ran the Tooltip, styles, SSR, HoverCard and useHoverOpen suites: 236 passing, useHoverOpen at 100%, Tooltip.tsx at 100% lines with only the pre-existing ref guard uncovered. The riskiest part isn't the hook wiring but the two places the shortcut "the wrapper stands in for the trigger" leaks — it hands the hook's Escape-stop to every focusable child (finding 1, the one to look at first, since it breaks composition with this library's own Dropdown), and it makes the hook's focus-restore branch unreachable (finding 4). After that, the two CSS hit-area items: the arrow overhang is a one-line fix, the bridge width is a design call.
Residual risk:
- Hoverable could still fail if the overflow correction shifts the tooltip far enough that the trigger leaves the content's horizontal span — the bridge is anchored to the content's left and right edges, so it would stop covering the path from the trigger. Partly refuted:
--tooltip-arrow-offsetis set to the negation of the shift, so the arrow, and with it the trigger's centre, stays inside the content while the shift is at most half the content's width. Open beyond that, which needs a wide tooltip on a trigger within roughly half its width of the viewport edge. - Hoverable depends on
.tooltip-content::afterbeing unclaimed. That pseudo-element had no rule before this PR, so a consumer stylesheet or atooltipClassNamethat uses it silently removes the bridge, and a close delay of zero goes back to closing as the pointer crosses the gap. No check here can see that; the arrow has the same exposure through::beforeand always has. - Dismissible does not apply to
activetooltips — deliberate, documented, and tested, including the listener-leak case. 1.4.13 governs content that hover or focus produced, so this is accurate rather than a gap. - A tooltip clipped by an
overflow: hiddenancestor still can't be hovered, since Tooltip renders in place with no portal. Pre-existing and untouched by this diff. - Unmount, hydration, class-prefix and nested-overlay paths —
Modal,Popover,HoverCard— each gained a test and all pass; thechainmove is a pure relocation and HoverCard's suite is unchanged and green.
🏄 Tooltip finally learned to let you paddle out onto it and to bail out on Escape — clean reuse of the HoverCard hook instead of hand-rolling a second one. Just watch that little arrow nipping at clicks on the trigger, and the fact that Escape now gets eaten before your children hear it, and this one's a keeper.
The Tooltip's wrapper holds its children, so with focus on any of them the hook's capture listener stopped Escape at the document. A child's own Escape never ran: a Dropdown in a Tooltip needed a second Escape to close its menu, and so did any control with its own key handling. useHoverOpen takes a consumeEscape option, on by default so HoverCard keeps its own Escape. Tooltip turns it off: Escape still hides the tooltip, and the key always carries on to whatever has focus, or to a Modal or Popover around it, since a tooltip only adds to what's there.
The arrow inherited pointer events from a shown tooltip, and its box is taller than the gap the bridge covers, so its edge sat over the trigger and caught clicks meant for it. The bridge already covers the gap, so the arrow takes no pointer now.
…dropped An active Tooltip turned down the hook's asks to open, so the hook settled on "held, and it asked" while the tooltip stayed closed in its eyes. Dropping active with the pointer on the trigger then left the tooltip hidden until the pointer left and came back. The hook now tracks the pointer and focus as they are, and active only adds to what's rendered. An active tooltip still shows whatever the pointer, focus and Escape do, and Escape over it still goes on, since a Tooltip never consumes it.
… HoverCard or Popover
A touch screen has no hover and a tap's focus isn't keyboard focus, so a phone or tablet never shows a Tooltip. openOnTap, off by default as on HoverCard, shows it with a tap on the trigger and hides it on the next tap, a tap anywhere else, or Escape. It's for a trigger with no tap action of its own, since the tap still does whatever the trigger does. The Tooltip page says plainly that by default a tap shows nothing, so anything a touch user needs can't live only in a tooltip, and shows the prop on a button with no onClick. Tooltip now also passes a caller's onPointerDown and onPointerUp through alongside its own.
…d only while open The bridge that lets the pointer cross to a Tooltip spanned the whole tooltip, which can be far wider than the trigger, so it caught clicks on page content beside the trigger. It and the tooltip's pointer events also stayed on for as long as an active tooltip was shown. The wrapper now draws the bridge, exactly the trigger's width across the gap, and a Tooltip takes an is-open class while the pointer, focus or a tap has it open. Only then do the tooltip and the bridge take the pointer, so a tooltip only active shows lets it through as it used to. The gap's size lives on .tooltip-content, out of the wrapper's reach, so the bridge takes the same defaults for each size.
|
deep-review: verify Every open thread has a reply: fixed, with the commit, or refuted with a reason. This settles them on 6d1ddb9. |
Preview DeploymentPreview URL: https://59f6ab5c.bestax.pages.dev |
There was a problem hiding this comment.
Deep review (verify) — 6 resolved · 0 open
| # | Severity | Area | Finding | Resolution |
|---|---|---|---|---|
| 1 | 🟠 Major | Correctness | Wrapper as triggerRef made the Tooltip swallow Escape for every focusable child |
✅ Verified fixed — consumeEscape: false (bulma-ui/src/components/Tooltip.tsx:171) |
| 2 | 🟡 Minor | Correctness | The arrow ate clicks on the trigger, sticking out past the bridge | ✅ Verified fixed — pointer-events: none (bulma-ui/src/scss/components/_tooltip.scss:101) |
| 3 | 🟡 Minor | Correctness | Dropping active under the pointer left the tooltip stuck hidden |
✅ Verified fixed — turn-down removed (bulma-ui/src/components/Tooltip.tsx:161) |
| 4 | 🔵 Advisory | Accessibility | “nothing in the tooltip takes focus” is an invariant the public API does not enforce | ✅ Accepted — recorded in the content TSDoc (bulma-ui/src/components/Tooltip.tsx:32-39) |
| 5 | 🔵 Advisory | Accessibility | Touch users lost the tooltip entirely, with no opt-in | ✅ Verified fixed — openOnTap prop + docs note (bulma-ui/src/components/Tooltip.tsx:80) |
| 6 | 🔵 Advisory | Correctness | The bridge was as wide as the tooltip, and an active tooltip kept it forever |
✅ Verified fixed — bridge moved to the wrapper, gated on is-open (bulma-ui/src/scss/components/_tooltip.scss:210) |
Overall: This pass settled threads and reviewed no commits. All six of my open threads — the three blocking findings and all three advisories — are resolved against 6d1ddb9c: four were fixed in code, one advisory was fixed beyond what it asked for (openOnTap), and one was accepted on a reason that holds, recorded in the public TSDoc. Nothing is left open, so there is nothing for a human to arbitrate from this reviewer. For a review of the five commits pushed since the original pass, a steer starting deep-review: fresh is what asks for one.
Residual risk: scoped to the threads this pass settled, since a verify pass raises nothing new.
- Escape no longer consumed (thread 1): refuted as a regression source for HoverCard — a grep for
consumeEscapeacrossbulma-ui/src/showsTooltip.tsx:171as the only non-test caller, andconsumes: () => latestRef.current.consumeEscape !== false(useHoverOpen.ts:343) leaves every other caller on the consuming path. 393 HoverCard/Modal/Popover/Dropdown tests pass unchanged. - The accepted cost of thread 1 — one Escape now hides the tooltip and closes a
Modal/Popoveraround it — is a deliberate behaviour choice, stated in the component TSDoc and pinned by the Modal and Popover tests, not an unhandled path. - Bridge variable drift (thread 6): open and disclosed by the author. The bridge reads the Sass defaults because
--bulma-tooltip-arrow-sizeand-marginare declared on.tooltip-content, out of the wrapper’s reach, so a theme overriding those two on.tooltip-contentmoves the gap but not the bridge.Tooltip.styles.test.tsx:176-186holds the two equal at every size variant, which catches drift in this repo but cannot see a consumer’s override. The alternative — declaring them on the wrapper too — would change their documented home and the generated variables table, which is a reasonable thing to decline. - Empirical basis: 153 tests across
Tooltip.test.tsx,Tooltip.styles.test.tsxanduseHoverOpen.test.tsx, plus 393 in the neighbouring suites, all green. I could not run a mutation check (reverting the twoTooltip.tsxlines to confirm the new tests fail) — writes to the working tree were not permitted in this session — so the load-bearingness of each fix is established by reading the assertions rather than by watching them fail.
🏄 Six threads, six clean closeouts — the gnarliest one even picked the braver half of the trade-off and wrote it on the board instead of burying it. Paddle this one in, it is glassy.
…-1413 # Conflicts: # bestax-mcp/data/components/HoverCard.json
|
deep-review: fresh Main is merged in to clear a conflict in the generated HoverCard MCP entry after #1032 (portal container) landed, so c33a894 needs a fresh review behind it. Since the last summary, this PR also carries the fix for HoverCard and Tooltip not opening when the pointer reaches their shadow root from light-DOM page content (an |
Preview DeploymentPreview URL: https://05900448.bestax.pages.dev |
There was a problem hiding this comment.
Deep review — 1 blocking · 2 advisory
| # | Severity | Area | Finding | Location |
|---|---|---|---|---|
| 1 | 🟡 Minor | Accessibility | "stays hidden until the pointer or focus leaves and comes back" is false with openOnTap — a tap reopens it at once; shipped in the .d.ts, the docs Dismissible bullet and the MCP summary |
bulma-ui/src/components/Tooltip.tsx:92 |
| 2 | 🔵 Advisory | Correctness | The bridge's size is Sass-fixed while the gap reads --bulma-tooltip-arrow-size/-margin, both advertised themeable on .tooltip-content, so a documented override reopens the Hoverable failure |
bulma-ui/src/scss/components/_tooltip.scss:196 |
| 3 | 🔵 Advisory | Correctness | PR body: separate React roots of one React copy were affected by the shadow-root bug — the over-path bail keys off the per-copy fiber key, not the root | PR body (anchored on bestax-mcp/data/catalog.json:1107) |
Overall: Sound, and the fresh pass over c33a8946 holds up everything the six settled threads claimed. The riskiest piece is the new onPointerOver fallback, and it is the one I spent the most time on: I read React 19's EnterLeaveEventPlugin to confirm the bail condition the comment describes (react-dom-client.development.js:21154-21162), confirmed SimpleEventPlugin dispatches onPointerOver before the enter so the s.pointerIn guard is never what keeps it from double-firing (settle()'s held !== s.held is), and confirmed the two hook tests at useHoverOpen.test.tsx:295-311 are a real proof rather than a restatement — both fire a bare pointerover whose relatedTarget React owns, which is precisely the shape React skips, so the open in the first can only come from the fallback and the second pins the guard against reopening. 267 tests pass across the six suites; useHoverOpen.ts and HoverCard.tsx at 100% on every metric, Tooltip.tsx at 100% lines with only the pre-existing ref guard at :192 uncovered; typecheck:tests clean; and the generated MCP index and docs tables match what the generators derive from the TSDoc (propCount 13 to 14, exampleCount 10 to 11, and the overview region correctly stays one sentence, as HoverCard's does). Look at finding 1 first — it is a one-clause deletion, and it is the asymmetry this very PR corrected on HoverCard's side.
Residual risk:
relatedTarget === nulltreated as the pointer arriving (alt-tab back, the pointer re-entering from browser chrome, a hit target removed under a stationary pointer) — refuted: React's bail is gated onrelatedTargetbeing truthy (react-dom-client.development.js:21154-21162), so with a null one React dispatches the enter itself and the outcome is identical with or without the fallback. The fallback neither widens nor narrows that path.- A boundary event reopening something Escape just closed, while the pointer never left — refuted both ways. Tooltip's content stays mounted, so the floating-element guard catches a
pointeroverwhose source is the tooltip, andis-openplus the bridge drop together so the new hit target is page content outside the wrapper. HoverCard's card unmounts, but it is anchored off the trigger bymarginGap, so the element that comes under the pointer is page content, not the holder — and the in-holder variant is pinned by HoverCard's "stays closed after Escape while the pointer moves between the trigger's own children". - A consumer's
childrenportaling elsewhere would make apointeroverin that portal read as the pointer arriving — refuted as new: React's enter/leave already walks the fiber tree through portals, soonPointerEnterfires for that case today; the fallback adds no path there. - Hoverable can still fail if the overflow correction shifts the content so far that the trigger leaves its horizontal span, since the bridge is anchored to the trigger and the content is not — open, and unchanged by this diff. Bounded as before:
--tooltip-arrow-offsetis set to the negation of the shift, keeping the arrow, and with it the trigger's centre, inside the content, and the shift is capped byedgePadding. - A stale
requestAnimationFramein the placement effect can apply a shift after the tooltip hid, and compound on the next show — pre-existing; the effect and its deps are byte-identical tomain, and the shift is invisible while hidden. Listed only becauseisVisiblenow changes on pointer events that did not previously drive it. - A mouse entering and leaving a tap-opened tooltip closes it on the hover timer, which
UseHoverOpenOptions.openOnTapreads as not happening — pre-existing from #1030, and Tooltip's own prop description says "Mouse and pen work the same either way", so the public contract holds.
🏄 Dude, this one's been through the wash cycle twice and came out cleaner — the hover bridge actually catches you now, Escape stops body-blocking the key, and that shadow-root fix is the kind of gnarly React-internals paddle-out most people would have just shrugged at. One stale sentence about taps to tidy up and it is good to go.
…nTap Tooltip's summary and its Dismissible bullet said a tooltip Escape hid stays hidden until the pointer or focus leaves and comes back. With openOnTap the next tap on the trigger shows it again, so both now end the way HoverCard's do, and a test pins the tap.
…ross it The bridge that carries the pointer onto a Tooltip is sized from the arrow variables' Sass defaults, while the gap reads the themeable --bulma-tooltip-arrow-size and --bulma-tooltip-arrow-margin. Raising either opens a band the bridge doesn't cover. The trade-off stays, and the closeDelay TSDoc, the Tooltip page's CSS & Sass Variables section and the theming skill now say to give such a tooltip a closeDelay long enough to cross the wider gap.
|
deep-review: verify Every open thread has a reply: fixed, with the commit, or refuted with a reason. This settles them on f940611. |
Preview DeploymentPreview URL: https://338ca616.bestax.pages.dev |
There was a problem hiding this comment.
Deep review (verify) — 3 resolved · 0 open
| # | Thread | Severity | Finding | Outcome |
|---|---|---|---|---|
| 1 | bulma-ui/src/components/Tooltip.tsx (TSDoc summary) |
🟡 Minor · Accessibility | "stays hidden until the pointer or focus leaves and comes back" was false with openOnTap |
✅ Verified fixed — 62e5281 |
| 2 | bulma-ui/src/scss/components/_tooltip.scss:196 |
🔵 Advisory · Correctness | A themed arrow gap outruns the compile-time hover bridge | ✅ Verified fixed — 41975da |
| 3 | bestax-mcp/data/catalog.json:1107 (PR body) |
🔵 Advisory · Correctness | Body claimed separate React roots were never affected by the shadow-root bug | ✅ Verified fixed — PR description |
What I checked for each
1 — openOnTap after Escape. Tooltip.tsx:93-94 and tooltip.md:310 now both end
"…or, with openOnTap, until the next tap on the trigger", matching the HoverCard wording
rather than dropping the clause. The regenerated index carries it in three places in
bestax-mcp/data/components/Tooltip.json (summary, the sub-component summary, and
accessibility). The new test shows again on the next tap after Escape hid it pins the
behaviour the sentence now describes; jest --testPathPatterns "Tooltip|useHoverOpen|HoverCard"
is 274 passed, 8 suites.
2 — themed arrow gap vs. the bridge. The trade-off stays, and the caveat now sits in all
three places a reader with a theme in hand would be looking: the closeDelay TSDoc
(Tooltip.tsx:67-73, which lands in the props table at tooltip.md:359, the published
.d.ts, and Tooltip.json:169), a paragraph after the generated CSS and Sass Variables table
(tooltip.md:396-400), and the theming skill Tooltip entry immediately above the instruction
to override those vars on .tooltip-content (css-variables.md:481-483). Each statement
checks out against the code: the gap is
calc(getVar("tooltip-arrow-size") + getVar("tooltip-arrow-margin")) (_tooltip.scss:136),
the bridge is calc($arrow-size + $tooltip-arrow-margin) (:196) — Sass defaults only. That
was the second of the two fixes the thread offered, so it is settled.
3 — PR body. The "Pointer coming into a shadow root" paragraph now stops at "Leaving
already worked." The unsupported clause is gone, so the stated blast radius of the fix no
longer rules out a multi-root app.
Overall: This pass settled threads and reviewed no commits. All three remaining findings
were documentation-accuracy items and all three are genuinely addressed — the two code-adjacent
ones are now pinned by a test and by three consistent prose surfaces that match what the SCSS
and the hook actually do. Nothing is left open; a human reviewing this now only needs to decide
whether the documented theme-override trade-off on the hover bridge (thread 2) is the right
long-term answer, or whether a follow-up should move the arrow vars onto .tooltip.
Residual risk: not re-assessed — a verify pass settles open threads and raises no new
findings. The residual-risk analysis from the earlier full review stands; a reader who wants
the current code reviewed afresh should steer deep-review: fresh.
🏄 Dude, every last thread got paddled in and parked on the sand — three for three, tests
green, and the prose finally tells the same story the code does. Clean set, no dropped waves.
Good to go.
# [5.30.0](https://github.com/allxsmith/bestax/compare/@allxsmith/bestax-bulma@5.29.0...@allxsmith/bestax-bulma@5.30.0) (2026-10-12) ### Bug Fixes * **bestax-migrate:** carry size to Buttons, Tags and Message ([#1038](#1038)) ([7549264](7549264)) * **bulma-ui:** give TimeInput's AM/PM column a real name for screen readers ([2b1122f](2b1122f)) * **bulma-ui:** keep a picker label's default when its override is undefined ([aa88e50](aa88e50)) ### Features * **bulma-ui:** add ContextMenu, a menu that opens on right-click or long press ([#1036](#1036)) ([85a3e77](85a3e77)) * **bulma-ui:** add ToggleButton, a button that stays pressed ([#1041](#1041)) ([582aff0](582aff0)) * **bulma-ui:** let a Tooltip be hovered, dismissed with Escape, and opened by a tap ([#1033](#1033)) ([52aeeda](52aeeda))
|
🎉 This PR is included in version 5.30.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
## [2.25.3](https://github.com/allxsmith/bestax/compare/bestax-migrate@2.25.2...bestax-migrate@2.25.3) (2026-10-12) ### Bug Fixes * **bestax-mcp:** release with every bulma-ui release so the published index keeps up ([#1027](#1027)) ([618e483](618e483)) * **bestax-migrate:** carry size to Buttons, Tags and Message ([#1038](#1038)) ([7549264](7549264)) * **bulma-ui:** accept material-symbols 0.47 in the peer range ([539cca2](539cca2)) * **bulma-ui:** drop a Field label's for when nothing it holds takes it ([#1020](#1020)) ([3604c14](3604c14)) * **bulma-ui:** give TimeInput's AM/PM column a real name for screen readers ([2b1122f](2b1122f)) * **bulma-ui:** keep a Notification's text clear of its close button ([#1017](#1017)) ([91ebb63](91ebb63)) * **bulma-ui:** keep a picker label's default when its override is undefined ([aa88e50](aa88e50)) * **bulma-ui:** keep Theme's component-variable check out of production ([537d90d](537d90d)) * **bulma-ui:** let a caller's aria-label or aria-labelledby name both range Slider thumbs ([047ff7c](047ff7c)), closes [#974](#974) * **bulma-ui:** name a composed DateRangeInputBase's group from a Field label ([75aa6cb](75aa6cb)) * **bulma-ui:** name a range Slider's thumbs from a hand-wired Field label's id ([355065b](355065b)) * **bulma-ui:** name Autocomplete's list from a hand-wired Field label's id ([ad46c06](ad46c06)) * **bulma-ui:** name Autocomplete's suggestion list from a surrounding Field's label ([190183f](190183f)), closes [#998](#998) * **bulma-ui:** name each range Slider thumb from its label ([6b962e3](6b962e3)), closes [#981](#981) * **bulma-ui:** name the date and time picker bases from a Field label ([e7ae797](e7ae797)) * **bulma-ui:** say a label placed in Field.Label reaches no thumbs or list ([07c2a99](07c2a99)) * **bulma-ui:** say FieldLabel renders the label column, not a label ([2a6ab10](2a6ab10)) * **bulma-ui:** say the label prop needs wiring to reach thumbs or list in an inner Field ([9b33d8d](9b33d8d)) * **bulma-ui:** say which controls a hand-wired Field label's id reaches ([514127f](514127f)) * **bulma-ui:** warn when Theme is given a variable Bulma sets on the component ([bb8f9b8](bb8f9b8)), closes [#1021](#1021) * **create-bestax:** announce the starter's notifications through a status region ([91dbfd5](91dbfd5)) * **create-bestax:** give each starter notification a status region of its own ([f1ef4df](f1ef4df)) * **create-bestax:** keep focus on the counter when Reset disables itself ([733e505](733e505)) * **create-bestax:** keep the reason for the stylesheet order in every scaffolded entry file ([f4591e0](f4591e0)) * **create-bestax:** pin material-symbols to the newest range bestax-bulma accepts ([ff4f820](ff4f820)) * **create-bestax:** return focus when the starter's notification closes, and tighten guards ([67e3678](67e3678)) * **create-bestax:** say which helper props render nothing under the no-helpers flavors ([b29985b](b29985b)) * **create-bestax:** stop the no-helpers CLAUDE.md offering helper props as the way out ([a96c203](a96c203)) * **create-bestax:** stop the vite-ts build shadowing its config, and fix the starter page ([1dd964d](1dd964d)) * **docs:** give the mixin note the modifier caveat its lead carries ([1427f08](1427f08)) * **docs:** keep the modifier caveat for mixin variables in the MCP index ([e227663](e227663)) * **docs:** name the one edge of the rule for which variables Theme reaches ([b33ebfa](b33ebfa)) * **docs:** qualify the guide and the theming skill on what Theme reaches ([9c2347b](9c2347b)) * **docs:** read only a component's own mixin as its home ([f40cd2c](f40cd2c)) * **docs:** refuse a CSS-variable scope the page cannot fully word ([c815602](c815602)) * **docs:** say where component-scoped Bulma variables have to be set ([771d5bd](771d5bd)), closes [#1021](#1021) ### Features * **bulma-ui:** add ContextMenu, a menu that opens on right-click or long press ([#1036](#1036)) ([85a3e77](85a3e77)) * **bulma-ui:** add HoverCard, a card that opens on hover or keyboard focus ([#1030](#1030)) ([0eb41df](0eb41df)) * **bulma-ui:** add ToggleButton, a button that stays pressed ([#1041](#1041)) ([582aff0](582aff0)) * **bulma-ui:** let a Tooltip be hovered, dismissed with Escape, and opened by a tap ([#1033](#1033)) ([52aeeda](52aeeda)) * **bulma-ui:** let ConfigProvider set where portaled content goes ([#1032](#1032)) ([5e928ae](5e928ae))
|
🎉 This PR is included in version 2.25.3 🎉 The release is available on: Your semantic-release bot 📦🚀 |
## [1.14.7](https://github.com/allxsmith/bestax/compare/bestax-mcp@1.14.6...bestax-mcp@1.14.7) (2026-10-12) ### Bug Fixes * **bestax-migrate:** carry size to Buttons, Tags and Message ([#1038](#1038)) ([7549264](7549264)) * **bulma-ui:** give TimeInput's AM/PM column a real name for screen readers ([2b1122f](2b1122f)) * **bulma-ui:** keep a picker label's default when its override is undefined ([aa88e50](aa88e50)) ### Features * **bulma-ui:** add ContextMenu, a menu that opens on right-click or long press ([#1036](#1036)) ([85a3e77](85a3e77)) * **bulma-ui:** add ToggleButton, a button that stays pressed ([#1041](#1041)) ([582aff0](582aff0)) * **bulma-ui:** let a Tooltip be hovered, dismissed with Escape, and opened by a tap ([#1033](#1033)) ([52aeeda](52aeeda))
|
🎉 This PR is included in version 1.14.7 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Tooltip didn't meet WCAG's Content on Hover or Focus criterion. You couldn't move the pointer onto it, and nothing dismissed it short of moving the pointer or focus away. This fixes both by putting Tooltip on the same hover hook HoverCard uses, plus a stylesheet change. It also adds one opt-in prop,
openOnTap, so touch users have a way to see a tooltip, and fixes HoverCard and Tooltip not opening when the pointer comes into a shadow root straight from the page, which is what made the docs previews look flaky.A few behaviors change along the way, and two of them can break a test or surprise someone, so they're spelled out under Behavior changes below.
It builds on the
useHoverOpenhook that came in with HoverCard (#1030).What failed
.tooltip-contenthadpointer-events: none, so the pointer went straight through it. There's also a gap between the trigger and the tooltip for the arrow, andcloseDelaydefaults to0, so even a tooltip that took pointer events would have closed the moment the pointer left the trigger.I checked all of that in Chromium on the built Storybook before touching anything, and it failed the way the code says it would.
What changed
Tooltip runs on
useHoverOpen. The hand-rolled timers and the single hover flag are gone. The hook already does what 1.4.13 asks: pointer enter and leave (ignoring touch), keyboard focus as the browser reports it through:focus-visible, both delays, a floating element the pointer can be on, and Escape.delaymaps toopenDelayandcloseDelaytocloseDelay. The positioning andautomeasuring code is untouched.Escape hides it and carries on. Escape hides a shown tooltip wherever focus is, and the key always goes on to whatever has focus, or to a Modal or Popover around it. A tooltip only adds to its trigger, so it shouldn't stand between a control and its own key: a
Dropdownin a Tooltip closes on the first Escape, and the tooltip hides on the same one. The hook gained aconsumeEscapeoption for this, on by default, so HoverCard keeps taking the key when focus is in the card or on its trigger.The stylesheet. A tooltip takes the pointer only while the pointer, focus or a tap has it open, which Tooltip marks with an
is-openclass. A hidden tooltip, or one that onlyactiveshows, lets the pointer through to whatever is under it, as it always did. While it's open, the wrapper draws a transparent bridge exactly as wide as the trigger across the gap, so the pointer can go straight from the trigger onto the tooltip without leaving. That's whycloseDelay={0}still works. Page content beside the trigger stays clickable, and the arrow takes no pointer, so it never covers the trigger's edge. The gap's size lives on.tooltip-content, out of the wrapper's reach, so the bridge takes the same defaults for each size, and a test keeps the two in step.active. An always-shown tooltip is the page's choice rather than something hover or focus brought, so it's outside 1.4.13. The hook tracks the pointer and focus as they are, andactiveonly adds to what's rendered. So hover, focus and Escape leave anactivetooltip shown, Escape over it goes on as it does for any tooltip, and droppingactivewhile the pointer rests on the trigger leaves it shown until the pointer leaves.openOnTap. By default a touch screen never shows a tooltip: there's no hover, and a tap's focus isn't keyboard focus.openOnTap, off by default like HoverCard's, shows it with a tap on the trigger and hides it on the next tap, a tap anywhere else, or Escape. The tap still does whatever the trigger does, so it's for a trigger with no tap action of its own. The Tooltip page now opens with a note saying a tap shows nothing by default, so anything a touch user needs can't live only in a tooltip.Pointer coming into a shadow root. React makes
onPointerEnterout of thepointerouton the element the pointer left, and skips thepointeroverthat follows when itsrelatedTargetis an element React renders. When a page portals content into a shadow root, as the docs site's live examples do throughreact-shadow, thepointeroutthe page gets on the way in names only the shadow host, so React's enter stops there, and thepointeroverinside names page content React owns, so React skips it. A HoverCard or Tooltip in the preview never heard the pointer arrive straight from the page, only from inside the preview, which is why it looked flaky. The hook now also takesonPointerOver: one whoserelatedTargetis outside the holder and the floating element counts as the pointer arriving, when no enter already did, and touch still doesn't. One from inside is the pointer moving about within, so a tooltip or card Escape closed stays closed until the pointer leaves and comes back. Leaving already worked.The wrapper stands in for the trigger. The hook expects the element that takes its handlers to hold only the trigger and the floating element. Tooltip's wrapper holds just the children and the tooltip, so it passes the wrapper as the trigger. That only works while nothing in the tooltip takes focus, so the
contentTSDoc now says tooltip content is for reading, with links and buttons in a HoverCard or Popover.Handlers. HoverCard's little
chainhelper moved intouseHoverOpen.tsso both components share it. That fixes a quiet bug too: anonFocusoronBluryou passed to Tooltip used to replace its own, so focus stopped showing it. Now yours runs alongside, and so do your pointer handlers.TSDoc for
content,delay,closeDelay,active,openOnTapand the component itself is updated, the docs page's Accessibility section walks through the three conditions, there's an "On Touch Screens" example and aTouchOpenstory, and the HoverCard page and one HoverCard test title lose an "unlike a tooltip" that stopped being true. No skill describes Tooltip's behavior, so there's nothing to update there. The regenerated props table and MCP index are in their own commits.One HoverCard docs fix rides along, promised in a review thread on #1030 before it merged. Its Dismissible bullet now says Escape closes the card "without you moving the pointer or focus", which is WCAG's sense of it, and that in controlled use Escape asks to close through
onOpenChange, so the card closes when you setopentofalse. It also says a card Escape closed stays closed until the pointer or focus leaves and comes back, or, withopenOnTap, until the next tap on the trigger.Why reuse the hook rather than patch Tooltip
I looked at whether Tooltip needed a hook option to open on any focus, since that's what Tooltip did. It doesn't, and adding it would have made things worse. With hover and focus tracked separately (which persistence needs), "any focus" means clicking a button leaves its tooltip stuck up after the pointer moves away. Opening on keyboard focus only, the
:focus-visiblerule the hook already uses, matches what a mouse user saw before and is what keyboard users need.A direct fix in Tooltip would have been a second copy of the Escape listener, the touch filtering, the delay bookkeeping and now the shadow-root fallback. That's exactly the drift one hook exists to prevent. The hook gained
consumeEscape,onPointerOverand the sharedchain, and HoverCard's existing tests pass as they were.Behavior changes
These are the things that act differently afterward.
Tests that only fire
mouseEnterstop showing the tooltip. Tooltip listens for pointer events now, not mouse events, sofireEvent.mouseEnter(...)on its own does nothing. Use user-event'shover(andunhover), which sends pointer events along with the mouse ones, or firepointerOverandpointerOutyourself:A test that advances fake timers after hovering is fine as it is. One that checks for
is-activeright after the event withdelay={0}needs to let a zero-length timer run first, since the show now goes through one.A click or a tap doesn't show a tooltip any more.
openOnTap. So anything a touch user needs to read shouldn't live only in a tooltip.Tests see this differently from a browser, though. Tooltip asks the browser whether focus is keyboard focus through
:focus-visible, and jsdom matches that on any focus. So in a jsdom test,user.tab(), a plainfocus(),user.click()and a touch tap that focuses the trigger all show the tooltip, and a clicked trigger keeps it up afteruser.unhover(). A test that wants the browser's behavior for a click has to make the trigger'smatches(':focus-visible')answerfalsewhile it takes focus, which is what Tooltip's own tests do.The rest you'd only notice by looking:
activetooltip nobody is hovering lets clicks through, as before.Not in here
The trigger isn't linked to the tooltip for screen readers. There's no
idon the content and noaria-describedbyanywhere, so focusing the trigger announces its own name and not the tooltip. Fixing that means Tooltip reaching intochildrenwithcloneElement, which only works for a single element child that passes the attribute through to the DOM, and has to merge with anaria-describedbythe child already has. That's a design call (HoverCard sidesteps it with atriggerprop), so it's its own issue, #1031. The Accessibility section says it plainly for now, and suggests anaria-labelon icon-only triggers.How it was verified
closeDelay, no timer while the pointer is on it, pen), touch (no show from a tap or its focus, a touch pointer leaving doesn't close what the mouse holds, andopenOnTapshowing it on a tap and hiding it on the next one or a tap elsewhere), Escape (from the trigger with focus kept, with focus in a field elsewhere and the key reaching that field, a child's own Escape handler still running, aDropdownin the trigger closing on the same key, one Escape closing a Modal or Popover around it too, cancelling a delayed show, and a tooltip inside an open HoverCard hiding before the card), no reopen until the pointer or focus leaves and comes back, a trigger in a shadow root reached straight from the page,active(shown whatever happens, and still shown under the pointer once dropped),autoresolving when hover shows it, overflow correction clearing on Escape, aConfigProviderprefix sweep, the wrapper's own handlers, nothing held after unmount, and hydration.consumeEscapetest.Tooltip.styles.test.tsxcompiles the real partial under a class prefix and checks pointer events hidden,activeonly and open, the arrow out of hit testing, and the bridge on the wrapper in each position, trigger-wide, with its size matching the tooltip's gap for each size.Tooltip.ssr.test.tsxrenders on the node environment, hidden andactive.is-openclass, the press handlers, the oldactiveturn-down and the handler chaining.useHoverOpenstay fully covered, and full bulma-ui coverage clears the thresholds. Tooltip's lines are all covered, and the one statement left is the same unreachable ref guard that was uncovered before.autoin every spot of the AutoPlacement story plus a short viewport that forces it left and right, multiline, and the dashed text trigger; the gap taking the pointer while open and the gap band beside the trigger staying the page's; the trigger's edge under the arrow staying the trigger's at every size; Escape with the pointer on the trigger and on the tooltip, with keyboard focus, and with focus in the form story's password field, each hiding the tooltip and letting the key through; anactivetooltip nobody hovers letting clicks through; keyboard focus holding it after the pointer leaves; a clicked trigger not holding it; and taps with and withoutopenOnTap.mouseEnteralone,pointerOver, user-event'shover,unhover,tabandclick, a plainfocus(), and a user-event touch tap. Each did what that section says.