@@ -83,18 +83,21 @@ const HIDDEN_STATE: FloatingTooltipState = {
8383}
8484
8585/**
86- * Drives a pointer-reactive floating tooltip. `canShow` is queried on every
87- * gesture with the event target, letting the caller gate the tooltip on its own
88- * overflow measurement. Returns the current {@link FloatingTooltipState} to feed
89- * a {@link FloatingTooltip} and a stable set of {@link FloatingTooltipHandlers}
90- * to spread onto the trigger element.
86+ * Drives a pointer-reactive floating tooltip. `canShow` is checked on each
87+ * gesture and while visible, allowing callers to dismiss the tooltip when its
88+ * eligibility changes. Returns the current state and stable pointer/focus
89+ * handlers; `focusTarget` may supply a separate keyboard-focus trigger.
9190 */
9291export interface UseFloatingTooltipOptions {
9392 /**
9493 * Prefer placing the bubble above the cursor. Still flips below when the
9594 * pointer is too close to the top of the viewport.
9695 */
9796 preferAbove ?: boolean
97+ /** External control whose keyboard focus should reveal this tooltip. */
98+ focusTarget ?: HTMLElement | null
99+ /** Semantic value whose changes should revalidate a visible tooltip. */
100+ revalidateKey ?: unknown
98101}
99102
100103export function useFloatingTooltip (
@@ -126,41 +129,49 @@ export function useFloatingTooltip(
126129 setState ( ( current ) => ( current . visible ? HIDDEN_STATE : current ) )
127130 } , [ reset ] )
128131
129- const handlers = React . useMemo < FloatingTooltipHandlers > ( ( ) => {
130- const apply = ( clientX : number , clientY : number , motion : TooltipMotion ) => {
131- const next = { ...getTooltipPosition ( clientX , clientY , preferAboveRef . current ) , ...motion }
132- setState ( ( current ) =>
133- current . visible &&
134- current . x === next . x &&
135- current . y === next . y &&
136- current . alignX === next . alignX &&
137- current . alignY === next . alignY &&
138- current . skew === next . skew &&
139- current . scaleX === next . scaleX &&
140- current . scaleY === next . scaleY
141- ? current
142- : { visible : true , ...next }
143- )
144- }
132+ const apply = React . useCallback ( ( clientX : number , clientY : number , motion : TooltipMotion ) => {
133+ const next = { ...getTooltipPosition ( clientX , clientY , preferAboveRef . current ) , ...motion }
134+ setState ( ( current ) =>
135+ current . visible &&
136+ current . x === next . x &&
137+ current . y === next . y &&
138+ current . alignX === next . alignX &&
139+ current . alignY === next . alignY &&
140+ current . skew === next . skew &&
141+ current . scaleX === next . scaleX &&
142+ current . scaleY === next . scaleY
143+ ? current
144+ : { visible : true , ...next }
145+ )
146+ } , [ ] )
145147
146- /** Reveals the tooltip at the pointer, seeding velocity tracking from it. */
147- const showFromPointer = ( clientX : number , clientY : number ) => {
148+ const showFromPointer = React . useCallback (
149+ ( clientX : number , clientY : number ) => {
148150 reset ( )
149151 lastPointerRef . current = { x : clientX , y : clientY , time : performance . now ( ) }
150152 apply ( clientX , clientY , NEUTRAL_MOTION )
151- }
153+ } ,
154+ [ apply , reset ]
155+ )
152156
153- /**
154- * Reveals the tooltip anchored to an element's box rather than the pointer.
155- * Velocity tracking stays cleared: seeding it from the box would make the next
156- * `pointermove` read the box-to-cursor delta as velocity and spike the flourish
157- * when the pointer already happens to be over the trigger.
158- */
159- const showFromElement = ( clientX : number , clientY : number ) => {
157+ /**
158+ * Anchors the tooltip to an element without seeding pointer velocity; using the
159+ * box position would make the next pointer move produce a false motion spike.
160+ */
161+ const showFromElement = React . useCallback (
162+ ( target : HTMLElement ) => {
160163 reset ( )
161- apply ( clientX , clientY , NEUTRAL_MOTION )
162- }
164+ const rect = target . getBoundingClientRect ( )
165+ apply (
166+ rect . left + rect . width / 2 ,
167+ preferAboveRef . current ? rect . top : rect . bottom ,
168+ NEUTRAL_MOTION
169+ )
170+ } ,
171+ [ apply , reset ]
172+ )
163173
174+ const handlers = React . useMemo < FloatingTooltipHandlers > ( ( ) => {
164175 return {
165176 onPointerEnter : ( event ) => {
166177 if ( ! canShowRef . current ( event . currentTarget ) ) return
@@ -200,14 +211,32 @@ export function useFloatingTooltip(
200211 if ( ! canShowRef . current ( target ) ) return
201212 if ( ! isFocusVisible ( target ) ) return
202213 triggerRef . current = target
203- const rect = target . getBoundingClientRect ( )
204- /* Anchor on the edge the bubble grows away from, so a `preferAbove`
205- tooltip measures from the trigger's top rather than its bottom. */
206- showFromElement ( rect . left + rect . width / 2 , preferAboveRef . current ? rect . top : rect . bottom )
214+ showFromElement ( target )
207215 } ,
208216 onBlur : hide ,
209217 }
210- } , [ hide , reset ] )
218+ } , [ apply , hide , showFromElement , showFromPointer ] )
219+
220+ React . useEffect ( ( ) => {
221+ const target = options . focusTarget
222+ if ( ! target ) return undefined
223+ const show = ( ) => {
224+ if ( ! canShowRef . current ( target ) || ! isFocusVisible ( target ) ) return
225+ triggerRef . current = target
226+ showFromElement ( target )
227+ }
228+ target . addEventListener ( 'focus' , show )
229+ target . addEventListener ( 'blur' , hide )
230+ return ( ) => {
231+ target . removeEventListener ( 'focus' , show )
232+ target . removeEventListener ( 'blur' , hide )
233+ }
234+ } , [ hide , options . focusTarget , showFromElement ] )
235+
236+ React . useEffect ( ( ) => {
237+ const trigger = triggerRef . current
238+ if ( state . visible && ( ! trigger || ! canShowRef . current ( trigger ) ) ) hide ( )
239+ } , [ hide , options . revalidateKey , state . visible ] )
211240
212241 /**
213242 * A keyboard- or script-driven UI change can hide the trigger with no pointer or focus event —
0 commit comments