Skip to content

Improve gutenberg compatibility for tooltips#748

Open
stokesman wants to merge 15 commits intosybrew:masterfrom
stokesman:add/gbc-tooltip
Open

Improve gutenberg compatibility for tooltips#748
stokesman wants to merge 15 commits intosybrew:masterfrom
stokesman:add/gbc-tooltip

Conversation

@stokesman
Copy link
Copy Markdown

@stokesman stokesman commented Nov 19, 2025

Meant to robustly fix #741. This adds some overriding of tsfTT and applies it via tsfGBC. It makes tooltips be rendered into a child of the body so they’ll have no clipping ancestors (i.e. no boundaries other than the window). It utilizes WP’s Popover component as that handles the placement of the tooltip.

Why do it this way?

  • It allows avoiding any gutenberg specific logic from from direct addition to tt.js – e.g. boundaries.
  • In the future, you could consider making all tooltips use the WP components Popover and slim down tt.js a bit by removing its positioning logic. In WP 6.9 the wp-components is going to be loaded on every admin screen because they're adding the “Command palette” everywhere.

Show & tell

added-gbc-tooltip.mp4

While testing this out I found that it fixes a related issue with the social image popover that occurs for me in both the classic and the block editor. See below:

Before After
classic-social-image-before classic-social-image-after

Copy link
Copy Markdown
Author

@stokesman stokesman left a comment

Choose a reason for hiding this comment

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

Some hopefully helpful review notes.

'id' => 'tsf-gbc',
'type' => 'js',
'deps' => [ 'jquery', 'tsf', 'tsf-utils', 'wp-editor', 'wp-data', 'react' ],
'deps' => [ 'jquery', 'tsf', 'tsf-utils', 'wp-editor', 'wp-data', 'react', 'wp-element', 'wp-components' ],
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Tangential note: I don’t think 'react' was needed in the deps. It’s certainly not now that 'wp-element' is in there.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I've used nesting in the CSS, which I didn’t see elsewhere. Here, I mostly like it for visually conveying hierarchy and it’s by no means necessary. It’s supported in all evergreen browsers since 2023.

Copy link
Copy Markdown
Owner

@sybrew sybrew Nov 21, 2025

Choose a reason for hiding this comment

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

Let's defer nesting until a major release. I'm in favor, but mixed coding style changes can be confusing to maintain.

Also, so much extra CSS to accommodate a specific page type for the tooltip, which otherwise works universally, seems like a workaround for a bug—are you certain this isn't an upstream issue?

Copy link
Copy Markdown
Owner

@sybrew sybrew Nov 21, 2025

Choose a reason for hiding this comment

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

The tooltip colors are currently handled via The_SEO_Framework\Admin\Script\Registry::convert_color_css_declaration(), where we also calculate relative font colors. Working with var(--wp...) indeed is a solid workaround as per the issue you mentioned here, but what you're ultimately resolving is an upstream bug, where #999 should be #888.

I only apply patches when there's literally no feasible fix/workaround upstream.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Oh, I hadn’t used var(--wp…) to patch the contrast issue, but to avoid having to add to the inline CSS for the GB tooltip arrow—although that would be a small addition. It also seemed like it might be okay to have it match the used accent color in GB which is different from classic (and seemingly with intention).

Now, as a way to keep colors as before without having to add to the inline CSS, I've pushed c8cacba, that proposes to use CSS variables. No sweat if that’s off the table for any reason—it seemed worth a try.

I also put in a brief effort to understand why the GB admin accent color isn’t the same as what TSF pulls from the global. I didn’t find out but I did find that contrast issue is seemingly not considered one upstream:
“Historically, only the default color scheme is meant to be accessible” WordPress/gutenberg#6438 (review)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Also, so much extra CSS to accommodate a specific page type for the tooltip, which otherwise works universally, seems like a workaround for a bug—are you certain this isn't an upstream issue?

While it might be considered an upstream issue I doubt it would be considered a bug. If their Popover component’s API had a few more features this could likely be reduced. There’s another approach that could be tried but I can’t say if it’d reduce this by much. It would also require :has() which I still hesitate to use.

box-sizing: border-box;
display: flex;
flex: 1 1 auto;
/* flex: 1 1 auto;*/ /* Is this ever in a flex-container? */
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I kept noticing the dev tools telling me this has no effect because the parent element is not a flex container. Perhaps it’s there for a known case but if not I just hoped to remove it and tidy up.

Copy link
Copy Markdown
Owner

@sybrew sybrew Nov 21, 2025

Choose a reason for hiding this comment

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

It was probably accommodating an old version of the Extension Manager or the SEO Bar pre-TSF v5.0. I'll investigate. Or, most likely, a bad habit of mine where I applied flex normalization everywhere to work around old Safari and Firefox bugs.

Comment on lines +690 to +693
imageObject.style = "max-width:100%;max-height:100%;object-fit:contain;aspect-ratio:1.91;height:auto;border-radius:3px;display:block;";
// Add dimensions to prevent jumping.
imageObject.width = imageObject.naturalWidth;
imageObject.height = imageObject.naturalHeight;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Adding the width and height attributes seems to be what fixes the tooltip placement even outside of gutenberg. I’d be glad to extract those to a separate PR if you’d like (or in case this PR isn’t favored). The style attribute changes are for a few reasons:

  • max-width/max-height need not be specific pixel values
  • It seems nice to use the recommended aspect ratio and object-fit to prevent distorting the images as can happen currently.

Looking at this again maybe the min-width/min-height would still be good to keep. I just thought if they were primarily to "prevent jumping" then the dimension attributes made them extraneous.

Copy link
Copy Markdown
Owner

@sybrew sybrew Nov 21, 2025

Choose a reason for hiding this comment

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

To consider if min-width/min-height is still necessary, we need to test changing images while keeping the tooltip rendered.

i.e., Hover the cursor over the image tooltip, then type/paste a new Social Image URL (the example is incorrect since we get an error, but hopefully illustrates the point).

Image

...why isn't the GIF looping?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It’s looking stable in my testing:

Gutenberg:

social-image-jump-test.mp4

Category edit:

social-image-category-jump-test.mp4

// Yes, I'm too lazy to copy/paste whatever's above again to prepend a dot, so I spent half an hour figuring this.
const ttSelectors = Object.fromEntries( Object.entries( ttNames ).map( ( [ i, v ] ) => [ i, `.${v}` ] ) );

const ttMap = new Map();
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This maps tooltip items to tooltips. It’s added to make getTooltip ignorant of where to find tooltips because querying within the item element won’t work for the body-level rendered tooltips.

Comment on lines +122 to +133
get: ( element ) => {
if ( ! _activeTooltipElements.tooltip ) {
const tooltip = getTooltip( element );
if ( ! tooltip ) return {};

Object.assign( _activeTooltipElements, {
tooltip,
arrow: tooltip.querySelector( ttSelectors.arrow ),
wrap: element.closest( ttSelectors.wrap ) || element.parentNode,
} );
}
return _activeTooltipElements;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This was first added because at first I thought finding the arrow elements in Gutenberg would need to be overridden. I found how to avoid that but I kept this around because I liked the accompanying changes within animate for being a little more concise. The only thing here that’s critical is not querying within element to find tooltip. Looking at this again, I suppose that can be optimized by using ttMap.get directly instead of getTooltip.

Copy link
Copy Markdown
Owner

@sybrew sybrew Nov 21, 2025

Choose a reason for hiding this comment

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

If we go the Map route (which is super performant), then _activeTooltipElements and _pointer should be replaced with Maps entirely. Since Maps are pointers, we should reset them, just in case, inside the init callback of tsf-tooltip-reset. This is needed when we spawn new objects that can initiate tooltips, such as a new Term's SEO Bar, or Post/Term quick-edit fields.

In any case, this "get" function's Object assignment seems to patch an underlying issue. Should it not just default to "no tooltip" if the registry (Map) is empty, without trying to find and register one anyway?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Should it not just default to "no tooltip" if the registry (Map) is empty, without trying to find and register one anyway?

In 9a3d561, I think I did what you suggest (just the quoted bit).

I'm not sure I get the idea with maps for _activeTooltipElements and _pointer. I actually don’t see the need for a map for the tooltips and have tested doing without but the map makes sense with being more congruent with what’s being done already.

Comment on lines -213 to +225
if ( boundaryRight + adjust > _activeTooltipElements.wrap.offsetWidth ) {
if ( boundaryRight + adjust > wrapRect.right ) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This fixed an issue in Gutenberg tooltips that were near the right edge of the window. The arrow was not being allowed to go far enough to the right. The wrap rect was already being used for mousex so this actually saves a DOM read.

* @return {Promise<Boolean>} True on success, false otherwise.
*/
function doTooltip( event, element, desc ) {
async function doTooltip( event, element, desc ) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Just noting this was already being await’d from _activeTooltipElements.pointerEnter. Now it actually is async for Gutenberg (the _renderTooltip override is).

Comment on lines -679 to +695
* @param {!jQuery|Element|string} element
* @param {!jQuery|Element} element
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I could see no handling of strings for element so I figured this is out of date.

Copy link
Copy Markdown
Owner

@sybrew sybrew Nov 21, 2025

Choose a reason for hiding this comment

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

That's actually a bug inside the function from when we converted from jQuery to native JS. But, since this has been such a long-standing bug, let's turn it into a feature. So, yes, we should remove the string type mention.

Comment on lines +385 to +387
document.dispatchEvent(
new CustomEvent( 'tsf-gutenberg-tt', { detail: overrideTooltip } )
);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Using an event for this just seemed nice to not add publicly accessible members though of course it’s still public 🤷.

@stokesman stokesman marked this pull request as ready for review November 20, 2025 18:01
> .tsf-tooltip-wrap {
/* In gutenberg, this ensures the wrap has the size of its contents, not sure why it’s
not required in the classic editor. */
display: inline-block;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I cannot reproduce this error on Chromium. What browser are you using?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Firefox, Safari and Chrome all show me the same thing. Probably, it’s just not clear what to look for. Here’s a recording that should clarify:

social-image-tt-wrap-size.mp4

Apparently the popover component in Gutenberg reads the wrap’s rect differently. So, of course, this isn’t required in regular admin screens - the comment could have been better. I suppose this style should move to gbc.css and maybe applied generically to all wraps if it doesn’t mess up any other wraps.

);
}

function GBCTooltip( { element, desc, setTooltip, tt } ) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Same as https://github.com/sybrew/the-seo-framework/pull/748/files#r2549062861: Is this workaround necessary? Is this not a bug upstream? The tooltips work perfectly everywhere else. I'm also not in favor of creating new HTML wrappers, since tt.js is supposed to handle all wrapping autonomously.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think it might not be clear how much responsibility this wrapper has in GB. The tooltips work everywhere else because they can position themselves to their relative positioned wrap. Once rendered apart from their wraps the positioning task is trickier and this wrapper (react component) handles that. This includes keeping the position updated through scrolling or sizing changes.

It’s probably true that ideally the wp-components package could expose a (react) hook, that enables applying the popover positioning system (which is floating-ui) without the Popover component. Speaking of that, before I tried this PR, I tried a branch using floating-ui instead. It’s great in some ways but I doubted it would be favorable because it adds ~20kb.

Since the last time I worked on this I happened to remember that the web standard Popover related APIs are coming along nicely. That seems like the ideal thing to base tt.js on in the near future. I mention that in case it helps to think of this a temporary measure which can be ripped out later. That said, I’d understand if you want to look at doing a even smaller temporary measure.

}

return true;
return tooltip;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

We shouldn't need to manipulate the tooltip outside this function.

ttMap.set( element,
// It’s async when Gutenberging.
await _renderTooltip( event, element, desc )
);
Copy link
Copy Markdown
Owner

@sybrew sybrew Nov 21, 2025

Choose a reason for hiding this comment

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

I think ttMap.set() should be moved inside _renderTooltip, so we can keep this function synchronous. This, however, ignores your comment above regarding Gutenberg's patch.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I’m confused on this. The jsdoc says it’s asynchronous (since 4.2.0) and returns a promise. I guess that’s not accurate but the only place it’s invoked within the plugin it’s awaited as if already async:

return await doTooltip( event, event.target, desc );

Anyway, I did give it shot and it can work synchronously. Apart from whether sync or async, I only dislike how it requires reproducing one more bit of logic (ttMap.set()) in the gbc _renderTooltip override. There’s already a little duplication there that might ideally be obviated but I’d not stressed it since this is speculative. I didn’t push the changes but the patch follows.

Patch to move ttMap.set() inside _renderTooltip and make Gutenberg _renderTooltip override work synchronously. Also removes seemingly unneeded await and async to beg the question.
diff --git a/lib/js/gbc.js b/lib/js/gbc.js
index 2f2c06ac..2e2ab3ef 100644
--- a/lib/js/gbc.js
+++ b/lib/js/gbc.js
@@ -346,21 +346,21 @@ window.tsfGBC = function( $ ) {
 		);
 		base.ttSelectors.arrow = '.components-popover__arrow';
 		return {
-			_renderTooltip: async ( _event, element, desc ) => {
+			_renderTooltip: ( _event, element, desc ) => {
 				element.dataset.hasTooltip = 1;
-				const tooltipPromise = new Promise( ( resolve ) => {
-					const setTooltip = ( tooltip ) => resolve( tooltip );
+				const setTooltip = tooltip => base.ttMap.set( element, tooltip );
+				wp.element.flushSync( () =>
 					root.render(
 						wp.element.createElement(
 							GBCTooltip,
 							{ element, desc, setTooltip, tt: base }
 						)
-					);
-				} );
-				return await tooltipPromise;
+					)
+				);
+				return true;
 			},
 			removeTooltip: ( element ) => {
-				root.render( null );
+				root.render( null ); // Should this also be wrapped in flushSync? — it tests okay as is…
 				base.removeTooltip( element );
 			}
 		};
diff --git a/lib/js/tt.js b/lib/js/tt.js
index 36460ace..6a9b302b 100644
--- a/lib/js/tt.js
+++ b/lib/js/tt.js
@@ -62,7 +62,7 @@ window.tsfTT = function () {
 				event.target.dispatchEvent( new Event( 'mousemove' ) ); // event time: <.3ms.
 			}
 		},
-		pointerEnter: async event => {
+		pointerEnter: event => {
 			let desc = event.target.dataset.desc || event.target.title || '';
 
 			// Don't create tooltip if bubbled.
@@ -72,7 +72,7 @@ window.tsfTT = function () {
 				// Clear title to prevent default browser tooltip.
 				event.target.removeAttribute( 'title' );
 
-				return await doTooltip( event, event.target, desc );
+				return doTooltip( event, event.target, desc );
 			}
 
 			return false;
@@ -339,7 +339,7 @@ window.tsfTT = function () {
 		 * @function
 		 * @param {Event} event
 		 */
-		const loadToolTip = async event => {
+		const loadToolTip = event => {
 
 			if ( event.target.dataset.hasTooltip ) return;
 
@@ -365,7 +365,7 @@ window.tsfTT = function () {
 
 			_cancelArrowAnimation();
 
-			if ( ! ( await _activeToolTipHandles.pointerEnter( event ) ) ) return;
+			if ( ! ( _activeToolTipHandles.pointerEnter( event ) ) ) return;
 
 			// Initiate arrow placement directly.
 			_activeToolTipHandles.pointerMove( event );
@@ -471,6 +471,7 @@ window.tsfTT = function () {
 		);
 
 		element.prepend( tooltip );
+		ttMap.set( element, tooltip );
 
 		const boundary =
 			   element.closest( ttSelectors.boundary )
@@ -631,7 +632,7 @@ window.tsfTT = function () {
 			tooltip.style.bottom = `${tooltipHeight - offsetTop}px`;
 		}
 
-		return tooltip;
+		return true;
 	}
 
 	/**
@@ -652,7 +653,7 @@ window.tsfTT = function () {
 	 * @param {string}  desc    The tooltip, may contain renderable HTML.
 	 * @return {Promise<Boolean>} True on success, false otherwise.
 	 */
-	async function doTooltip( event, element, desc ) {
+	function doTooltip( event, element, desc ) {
 
 		// Backward compatibility for jQuery vs ES.
 		if ( element?.[0] )
@@ -666,10 +667,7 @@ window.tsfTT = function () {
 
 		if ( ! desc.length ) return false;
 
-		ttMap.set( element,
-			// It’s async when Gutenberging.
-			await _renderTooltip( event, element, desc )
-		);
+		_renderTooltip( event, element, desc );
 
 		return true;
 	}
@@ -792,7 +790,7 @@ window.tsfTT = function () {
 
 			document.addEventListener( 'tsf-gutenberg-tt', ( event ) => {
 				( { _renderTooltip, removeTooltip } =
-					event.detail( { removeTooltip, ttNames, ttSelectors } ) );
+					event.detail( { removeTooltip, ttMap, ttNames, ttSelectors } ) );
 			} );
 		},
 	}, {

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds Gutenberg (block editor) compatibility for tooltips by rendering them into the document body using WordPress's Popover component, preventing clipping issues caused by overflow and positioning constraints in the block editor. The implementation overrides tooltip rendering logic specifically for Gutenberg while keeping the classic editor behavior unchanged.

  • Introduces a tooltip map (ttMap) to track tooltip elements and enable external override of rendering logic
  • Implements GBCTooltip React component that wraps tooltips in WP's Popover for proper positioning
  • Updates image preview tooltip styling to use flexible dimensions with aspect ratio constraints

Reviewed Changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
lib/js/tt.js Adds tooltip map for external overrides, refactors element retrieval into getter method, makes doTooltip async, removes Gutenberg-specific boundary selector
lib/js/gbc.js Implements React-based tooltip override using WP Popover component, dispatches custom event to inject override into tt.js
lib/js/media.js Updates image preview tooltip styling to use percentage-based sizing with aspect ratio and natural dimensions
lib/css/tt.css Comments out flex property with question about its necessity
lib/css/post.css Removes Gutenberg-specific overflow fixes that are no longer needed
lib/css/media.css Adds inline-block display for tooltip wrap in Gutenberg context
lib/css/gbc.css New stylesheet with Popover-specific tooltip styling for Gutenberg
inc/classes/admin/script/loader.class.php Adds wp-element and wp-components dependencies, registers new gbc.css stylesheet


// Use textWidth for right boundary if adjustment exceeds.
if ( boundaryRight + adjust > _activeTooltipElements.wrap.offsetWidth ) {
if ( boundaryRight + adjust > wrapRect.right ) {
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

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

The comparison on this line is incorrect. boundaryRight + adjust is a width value in pixels, while wrapRect.right is an absolute viewport coordinate. This should likely compare against wrapRect.width or similar dimension property instead.

Suggested change
if ( boundaryRight + adjust > wrapRect.right ) {
if ( boundaryRight + adjust > wrapRect.width ) {

Copilot uses AI. Check for mistakes.
lib/js/gbc.js Outdated

if ( ! desc ) return;

const __html = `<span class=${ttNames.textWrap}><span class=${ttNames.text}>${desc}</span></span><div class=${ttNames.arrow} style=will-change:left></div>`
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

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

Missing semicolon at the end of this line. According to project standards, statements should end with semicolons.

Copilot generated this review using guidance from repository custom instructions.
@sybrew sybrew changed the title Add gutenberg compatibility for tooltips Improve gutenberg compatibility for tooltips Nov 21, 2025
Copy link
Copy Markdown
Author

@stokesman stokesman left a comment

Choose a reason for hiding this comment

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

I addressed what feedback seemed directly actionable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Oh, I hadn’t used var(--wp…) to patch the contrast issue, but to avoid having to add to the inline CSS for the GB tooltip arrow—although that would be a small addition. It also seemed like it might be okay to have it match the used accent color in GB which is different from classic (and seemingly with intention).

Now, as a way to keep colors as before without having to add to the inline CSS, I've pushed c8cacba, that proposes to use CSS variables. No sweat if that’s off the table for any reason—it seemed worth a try.

I also put in a brief effort to understand why the GB admin accent color isn’t the same as what TSF pulls from the global. I didn’t find out but I did find that contrast issue is seemingly not considered one upstream:
“Historically, only the default color scheme is meant to be accessible” WordPress/gutenberg#6438 (review)

ttMap.set( element,
// It’s async when Gutenberging.
await _renderTooltip( event, element, desc )
);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I’m confused on this. The jsdoc says it’s asynchronous (since 4.2.0) and returns a promise. I guess that’s not accurate but the only place it’s invoked within the plugin it’s awaited as if already async:

return await doTooltip( event, event.target, desc );

Anyway, I did give it shot and it can work synchronously. Apart from whether sync or async, I only dislike how it requires reproducing one more bit of logic (ttMap.set()) in the gbc _renderTooltip override. There’s already a little duplication there that might ideally be obviated but I’d not stressed it since this is speculative. I didn’t push the changes but the patch follows.

Patch to move ttMap.set() inside _renderTooltip and make Gutenberg _renderTooltip override work synchronously. Also removes seemingly unneeded await and async to beg the question.
diff --git a/lib/js/gbc.js b/lib/js/gbc.js
index 2f2c06ac..2e2ab3ef 100644
--- a/lib/js/gbc.js
+++ b/lib/js/gbc.js
@@ -346,21 +346,21 @@ window.tsfGBC = function( $ ) {
 		);
 		base.ttSelectors.arrow = '.components-popover__arrow';
 		return {
-			_renderTooltip: async ( _event, element, desc ) => {
+			_renderTooltip: ( _event, element, desc ) => {
 				element.dataset.hasTooltip = 1;
-				const tooltipPromise = new Promise( ( resolve ) => {
-					const setTooltip = ( tooltip ) => resolve( tooltip );
+				const setTooltip = tooltip => base.ttMap.set( element, tooltip );
+				wp.element.flushSync( () =>
 					root.render(
 						wp.element.createElement(
 							GBCTooltip,
 							{ element, desc, setTooltip, tt: base }
 						)
-					);
-				} );
-				return await tooltipPromise;
+					)
+				);
+				return true;
 			},
 			removeTooltip: ( element ) => {
-				root.render( null );
+				root.render( null ); // Should this also be wrapped in flushSync? — it tests okay as is…
 				base.removeTooltip( element );
 			}
 		};
diff --git a/lib/js/tt.js b/lib/js/tt.js
index 36460ace..6a9b302b 100644
--- a/lib/js/tt.js
+++ b/lib/js/tt.js
@@ -62,7 +62,7 @@ window.tsfTT = function () {
 				event.target.dispatchEvent( new Event( 'mousemove' ) ); // event time: <.3ms.
 			}
 		},
-		pointerEnter: async event => {
+		pointerEnter: event => {
 			let desc = event.target.dataset.desc || event.target.title || '';
 
 			// Don't create tooltip if bubbled.
@@ -72,7 +72,7 @@ window.tsfTT = function () {
 				// Clear title to prevent default browser tooltip.
 				event.target.removeAttribute( 'title' );
 
-				return await doTooltip( event, event.target, desc );
+				return doTooltip( event, event.target, desc );
 			}
 
 			return false;
@@ -339,7 +339,7 @@ window.tsfTT = function () {
 		 * @function
 		 * @param {Event} event
 		 */
-		const loadToolTip = async event => {
+		const loadToolTip = event => {
 
 			if ( event.target.dataset.hasTooltip ) return;
 
@@ -365,7 +365,7 @@ window.tsfTT = function () {
 
 			_cancelArrowAnimation();
 
-			if ( ! ( await _activeToolTipHandles.pointerEnter( event ) ) ) return;
+			if ( ! ( _activeToolTipHandles.pointerEnter( event ) ) ) return;
 
 			// Initiate arrow placement directly.
 			_activeToolTipHandles.pointerMove( event );
@@ -471,6 +471,7 @@ window.tsfTT = function () {
 		);
 
 		element.prepend( tooltip );
+		ttMap.set( element, tooltip );
 
 		const boundary =
 			   element.closest( ttSelectors.boundary )
@@ -631,7 +632,7 @@ window.tsfTT = function () {
 			tooltip.style.bottom = `${tooltipHeight - offsetTop}px`;
 		}
 
-		return tooltip;
+		return true;
 	}
 
 	/**
@@ -652,7 +653,7 @@ window.tsfTT = function () {
 	 * @param {string}  desc    The tooltip, may contain renderable HTML.
 	 * @return {Promise<Boolean>} True on success, false otherwise.
 	 */
-	async function doTooltip( event, element, desc ) {
+	function doTooltip( event, element, desc ) {
 
 		// Backward compatibility for jQuery vs ES.
 		if ( element?.[0] )
@@ -666,10 +667,7 @@ window.tsfTT = function () {
 
 		if ( ! desc.length ) return false;
 
-		ttMap.set( element,
-			// It’s async when Gutenberging.
-			await _renderTooltip( event, element, desc )
-		);
+		_renderTooltip( event, element, desc );
 
 		return true;
 	}
@@ -792,7 +790,7 @@ window.tsfTT = function () {
 
 			document.addEventListener( 'tsf-gutenberg-tt', ( event ) => {
 				( { _renderTooltip, removeTooltip } =
-					event.detail( { removeTooltip, ttNames, ttSelectors } ) );
+					event.detail( { removeTooltip, ttMap, ttNames, ttSelectors } ) );
 			} );
 		},
 	}, {

> .tsf-tooltip-wrap {
/* In gutenberg, this ensures the wrap has the size of its contents, not sure why it’s
not required in the classic editor. */
display: inline-block;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Firefox, Safari and Chrome all show me the same thing. Probably, it’s just not clear what to look for. Here’s a recording that should clarify:

social-image-tt-wrap-size.mp4

Apparently the popover component in Gutenberg reads the wrap’s rect differently. So, of course, this isn’t required in regular admin screens - the comment could have been better. I suppose this style should move to gbc.css and maybe applied generically to all wraps if it doesn’t mess up any other wraps.

Comment on lines +690 to +693
imageObject.style = "max-width:100%;max-height:100%;object-fit:contain;aspect-ratio:1.91;height:auto;border-radius:3px;display:block;";
// Add dimensions to prevent jumping.
imageObject.width = imageObject.naturalWidth;
imageObject.height = imageObject.naturalHeight;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It’s looking stable in my testing:

Gutenberg:

social-image-jump-test.mp4

Category edit:

social-image-category-jump-test.mp4

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Also, so much extra CSS to accommodate a specific page type for the tooltip, which otherwise works universally, seems like a workaround for a bug—are you certain this isn't an upstream issue?

While it might be considered an upstream issue I doubt it would be considered a bug. If their Popover component’s API had a few more features this could likely be reduced. There’s another approach that could be tried but I can’t say if it’d reduce this by much. It would also require :has() which I still hesitate to use.

);
}

function GBCTooltip( { element, desc, setTooltip, tt } ) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think it might not be clear how much responsibility this wrapper has in GB. The tooltips work everywhere else because they can position themselves to their relative positioned wrap. Once rendered apart from their wraps the positioning task is trickier and this wrapper (react component) handles that. This includes keeping the position updated through scrolling or sizing changes.

It’s probably true that ideally the wp-components package could expose a (react) hook, that enables applying the popover positioning system (which is floating-ui) without the Popover component. Speaking of that, before I tried this PR, I tried a branch using floating-ui instead. It’s great in some ways but I doubted it would be favorable because it adds ~20kb.

Since the last time I worked on this I happened to remember that the web standard Popover related APIs are coming along nicely. That seems like the ideal thing to base tt.js on in the near future. I mention that in case it helps to think of this a temporary measure which can be ripped out later. That said, I’d understand if you want to look at doing a even smaller temporary measure.

Comment on lines +122 to +133
get: ( element ) => {
if ( ! _activeTooltipElements.tooltip ) {
const tooltip = getTooltip( element );
if ( ! tooltip ) return {};

Object.assign( _activeTooltipElements, {
tooltip,
arrow: tooltip.querySelector( ttSelectors.arrow ),
wrap: element.closest( ttSelectors.wrap ) || element.parentNode,
} );
}
return _activeTooltipElements;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Should it not just default to "no tooltip" if the registry (Map) is empty, without trying to find and register one anyway?

In 9a3d561, I think I did what you suggest (just the quoted bit).

I'm not sure I get the idea with maps for _activeTooltipElements and _pointer. I actually don’t see the need for a map for the tooltips and have tested doing without but the map makes sense with being more congruent with what’s being done already.

Comment on lines -712 to +736
return element?.classList.contains( ttNames.base )
? element
: element?.querySelector( ttSelectors.base );
return ttMap.get( element ) || ttMap.get(
element?.querySelector( ttSelectors.base )
);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

If I'm not mistaken I just realized this change isn’t BC. The existing logic returns element if it is a tooltip. The changed logic tries to lookup a tooltip by the element, so if element is a tooltip itself, then it’d return undefined.

'border-bottom-color:{{$bg_accent}}',
':root' => [
'--tsf-user-colors-bg-accent:{{$bg_accent}}',
'--tsf-user-colors-fg-accent:{{$rel_bg_accent}}',
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Smart!

sybrew added a commit that referenced this pull request Dec 8, 2025
Added support for the new Gutenberg sidebar container ID in WordPress 6.9 to prevent tooltip content from overflowing or being clipped. Updated tooltip boundary detection logic and documented the fix in the changelog.
sybrew added a commit that referenced this pull request Jan 18, 2026
Refactored admin pool to expose seobar and scripts subpools, and made Loader and Builder classes extensible. Improved tooltip accessibility and usability, including better focus handling and max-width enforcement. Updated CSS for SEO bar and tooltips, and clarified description generation messaging.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Clipped tooltips of SEO Bar in the meta box

3 participants