Skip to content

Conversation

@MisRob
Copy link
Member

@MisRob MisRob commented Sep 3, 2025

Description

Proposes solution for the next version of KTooltip and also has broader exploration of how we could approach floating elements in the future.

Not for merging. After this proof-of-concept stage, selected parts will be finalized through new PRs.

Reviewer guidance

  • (1) See the preview for context and demos.
  • (2) See the implementation notes below.
  • (3) Code review
    • High-level feedback for changes planned in shorter term: useKFloatingInteraction, useKFloatingPosition, KTooltip/next, KIconButton, and v-focusable (majority is in this commit)
    • The rest is long-term or experimental - just use to assess whether the approach suggested here will scale well as future solution for all floating elements

As for a11y, @radinamatic would you please provide feedback whether some techniques I'm playing with in the live examples on this page could be an interesting direction for us and perhaps one step towards better experience? The APG tooltip pattern is so far just very basic and work in progress.

References

Related #745

Library choice and browser support

Oct 20 update: Note that based on the limitations mentioned below we decided to improve our browserlist and ability to test against it easily - which will change the way we think about the final choice. Feel free to skip this section.

Given our needs for customization, accessibility, performance, and maintainability, I think it'd be suitable to use a third-party library only for low-level position calculations. Candidates include Floating UI or its predecessor Popper v2 (or perhaps even v1).

Floating UI is a modern, actively maintained, and widely used library. However, its browser support is far away from our browserlist.

I previewed one instance of the next KTooltip using useKFloatingPosition built with both Floating UI and Popper 2 on Browserstack and didn't notice any differences between them. In the browsers below, they worked as expected, except one hiccup:

Platform / Browser Floating UI (blue tooltip) Popper 2 (gray tooltip)
Win 7 Chrome 49 tooltip text cut off * tooltip text cut off *
Win 7 Edge 80 ok ok
Win 7 Firefox 52 ok ok
Win 7 Opera 67 ok ok
Mac High Sierra Safari 11.1 tooltip text cut off * tooltip text cut off *
Samsung Galaxy S9 Samsung 25 ok ok
Motorola Moto G7 Play UC Browser 12 ok ok

*

However:

  • I couldn't fully test our browserlist on BrowserStack; some older devices and browser versions aren't available.
  • A simple tooltip test only covers a small subset of library features. This doesn't guarantee everything will work in the final implementation of the many floating features in our apps, not only informative ones, but also essential interactive components, such as dropdowns.
  • Developer experience is similar for both; each approach has its pros and cons (see Floating version in this commit).
  • We'd need to pin the Floating UI version or thoroughly test each upgrade against our browserlist.
  • The authors recommended me Popper
  • It's unclear if we've ever tested Popper 1 against our browserlist.

Based on this, one approach could be:

  • Start with Popper 2.
  • If strict browserlist support is needed, ask QA team for support with full coverage of final implementations in Kolibri.
  • Depending on results, stay with Popper 2, downgrade to its earlier version, or switch to Popper 1 (which is currently used in via tippy.js in some of our implementations)

Once our browserlist allows, upgrading to Floating UI should be straightforward. In the meantime, we can prepare ground by removing other third-party dependencies in favor of useKFloating....

Performance

I looked into performance metrics such as repaints/reflows, JS heap size, number of DOM nodes, number of event listeners, and scripting time.

Number of DOM nodes

In the past, enabling lazy on VTooltips in Studio has reduced performance issues from excessive DOM nodes. Therefore, the lazy option is supported in the next KTooltip.

Number of event listeners

For floating elements that can appear in large numbers, it's important to consider where event listeners controlling their visibility are attached. A common approach is to add listeners to each trigger element. For example, every icon showing a tooltip listening for mouseover and possible other events. On pages with many floating elements, this can lead to hundreds or thousands of listeners. Event delegation is a technique to reduce the number by attaching a single listener to a container or the window/document, handling events for its children. However, this means the handler runs for non-trigger elements too, which can increase scripting time, especially for frequent events like mouseenter.

Even though mouseenter is optimized and the handler is low-cost (exits unless the event target has the data-floating-id attribute), on pages with only a few trigger elements (= smileys), event delegation still feels like unnecessary overhead:

On the other hand, on pages with many trigger elements, event delegation often pays off, reducing the number of listeners by hundreds or even thousands:

For this reason, useKFloatingInteraction and the next KTooltip (or any other floating components) don't enforce a specific approach, but only provide an optional delegateTo parameter. KDS documentation will offer guidance on how to choose the best approach.

The general rule of thumb is to avoid delegation for a small number of tooltips on a page. For larger numbers of tooltips, it's best to profile performance with and without delegation to choose the optimal approach.

Measurements

In addition, useKFloatingInteraction optimizes other areas, such as using an elements cache and attaching leave event listeners only when needed. Here’s a test of the next KTooltip on a page with 1000 tooltips:

VTooltip, lazy next KTooltip, lazy, events delegated to document/window
Page reload 01 02
Hover 50 tooltips 03 04

These results were consistent across repeated measurements.

I tried testing on the aforementioned African Storybook page too, but due to many factors and thousands of listeners, it's nearly impossible to get clear numbers—e.g. repeated measurements of the current version show fluctuations in listener counts in the thousands for reasons unrelated to tooltips. One consistent finding was that, despite event delegation, scripting time was even better than before, suggesting that using mouseover on the whole window should be just fine on this page.

Finally, after examining large channel pages, I believe that applying similar strategies to any elements appearing in large numbers, not just floating ones, will lead to many more improvements.

@rtibbles
Copy link
Member

rtibbles commented Sep 3, 2025

delegateTo parameter

I love the deliberateness and thought of this approach - allowing us to tune the performance in the edge cases of large numbers of elements feels so helpful.

@MisRob
Copy link
Member Author

MisRob commented Sep 3, 2025

@radinamatic

For the first few examples with icons or icon + text. General recommendation is not to make non-interactive elements focusable (even though activating a tooltip may perhaps be considered as interaction?) Also Heydon says that, with recommendation not to use tooltips at all. And in his guide for tooltips, he really doesn't work with non-focusable elements.

On the other hand, APG pattern clearly requests that

Focus stays on the triggering element while the tooltip is displayed.

and it's the only way to make them accessible for keyboard users.

Also, there are some resources that actually suggest making trigger elements focusable, even though they are not interactive in a typical sense

I don't know how reputable those are. But it suggest people use it in some ways.

It seems that the main Heydon's issue with it is

Even if you consider the showing of the tooltip an interactive 'action', it happening on focus makes little sense, especially to unsighted screen reader users who won't know anything has happened.

which I've tried to address by labeling via describedby or labelledby, depending on context. That seems what is suggested in the APG pattern as well.

This article https://www.a11y-collective.com/blog/tooltips-in-web-accessibility/ is quite interesting as it tries to balance many perspectives.

With our current patterns, we will inevitably have to break one of the rules. So take it as some ideas that may perhaps help or not - it's probably best to just pragmatically try out and then evaluate whether it can add some value to the current experience, or not.

@MisRob
Copy link
Member Author

MisRob commented Sep 3, 2025

Thank you @rtibbles. That's result of my despair after looking into our many use-cases 😁

@radinamatic
Copy link
Member

Hey @MisRob!

The amount of thought, information and effort that went into investigating possible implementation for this this new approach was simply astounding! 😍

Can't say I was surprised, being familiar with your thoroughness, but I was still 🙇🏽‍♀️

I may have some further thoughts on the sources you mentioned above, but given that we are navigating an overcharted territory where much more experienced a11y colleagues at APG have not managed to arrive to consensus after 9 years, and even without understanding all that happens under the hood, I very much appreciate the approach you've taken to accommodate various needs.

Let's start with the test results of the NVDA output in latest Chrome and Firefox on Windows 10.

KTooltip

? icon

Upon focusing, NVDA announces the Tooltip text correctly in Chrome, but in Firefox it only reads blank

Captions and subtitles

On both Chrome and Firefox NVDA announces:
Captions and subtitles heading Supported formats: '.vtt' level 4

For the best (expected) experience it should be
Captions and subtitles Supported formats: '.vtt' heading level 4

Lazy KTooltip

Same as for KTooltip on both Chrome and Firefox.

KTooltip with delegated events

No live examples.

KLabeledIcon with KTooltip

On both Chrome and Firefox NVDA only announces 9, which seems to indicate that the content of the tooltip is not read at all.

KIconButton with KTooltip

On both Chrome and Firefox NVDA correctly announces Add bookmark/Remove bookmark button 🎉

KTextbox with KTooltip

On both Chrome and Firefox NVDA announces:
Aggregator edit Website or org hosting the content collection but not necessarily the creator or copyright holder. blank

I believe a better experience would be:
Aggregator Website or org hosting the content collection but not necessarily the creator or copyright holder. edit blank
or
Aggregator edit blank Website or org hosting the content collection but not necessarily the creator or copyright holder.

Note the different tooltip position between Chrome and Firefox (better imho)

Chrome Firefox
chrome firefox

Context menu

Clickable area is not focusable by keyboard, so not sure what is envisioned here to be the equivalent interaction workflow for the keyboard user... 🤔 Even with the mouse, left click does nothing.

@MisRob
Copy link
Member Author

MisRob commented Oct 15, 2025

Thanks a lot for your time on this @radinamatic.

High-level, that's good news - sounds we may benefit from some of those explorations. One of the first next steps will be a new version of KTooltip, and as I will be preparing documentation, I will include a11y guidance based on what you saw here for relevant parts then. And then, as we're migrating tooltips, we can follow it, which should resolve some a11y issues with the current version. We can consider it as first steps, and if you get any new ideas in the future, we can improve further.

I also appreciate detailed testing with screenreaders that are definitely more common than my Orca. As part of the new tooltip work, I will return to this part and see what we can do.

Apologies for the context menu - that wasn't meant for a11y testing, but rather for preview for developers on how code would look like. I need to remember to be more explicit for you.

Copy link
Member

@LianaHarris360 LianaHarris360 left a comment

Choose a reason for hiding this comment

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

Thanks for the clear writeup and demos Misha, it’s very helpful context. Overall the new composables (useKFloatingPosition, useKFloatingInteraction) make sense and feel like the right foundation and I agree with the proposed approach to start with Popper 2 and, depending on the results, downgrading if necessary, and keeping Floating UI in mind for later. Since these composables might be the base for all floating elements later, maybe we could test adding one other floating component (like a dropdown) using them just to be sure the APIs won’t need to change much down the road?

* 'hover', 'touch', 'focus', 'keyboardfocus'.
* Default: ['hover'].
*
* @param {String} delegateTo Optional. 'root' or ID of the element to delegate
Copy link
Member

Choose a reason for hiding this comment

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

I like that this event delegation is optional, and that the events are automatically attached to the trigger element if this isn't specified

Lazy rendering is for pages with many tooltips to prevent performance problems. Unlike
regular tooltips, lazy tooltips are not immediately present in the DOM, so using
<code>aria-labelledby</code> or <code>aria-describedby</code> does not work correctly.
Instead, <code>aria-label</code> or <code>aria-description</code> is a better choice.
Copy link
Member

Choose a reason for hiding this comment

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

The aria-describedby vs. aria-description pattern looks like a great approach, it would be nice to have this documented side by side in a ready-to-copy table if possible, so that devs can know exactly which to use for lazy vs. non-lazy tooltips at a glance.

Copy link
Member Author

Choose a reason for hiding this comment

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

Definitely - I hope to use some of the drafts here for the final documentation

Besides delegating to the root, ID of any element can be used as the delegation target. This
is useful when many tooltips are children of a container smaller than the
document—delegating to the container prevents unnecessary execution of handlers elsewhere in
the document.
Copy link
Member

Choose a reason for hiding this comment

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

I really like that the lazy rendering and event delegation are optional. It would be useful if we could define clear guidance in the docs, like:
“Use lazy if there are 100+ tooltips on a page.”
“Use delegation when tooltips share a parent container.”
I think this will help everyone stay consistent later when implementing tooltips.

Copy link
Member Author

Choose a reason for hiding this comment

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

From my observations so far, this was very hard to generalize. There's more conditions to evaluate. Ultimately I think it's best to do a performance measurement of both options and choose the best in a given context.

In any case, I will think about it more. I hear that right now it's a very vague guidance. Perhaps I could suggest few example numbers that generally should work quite well, but still emphasize the recommendation to do measurements.

* DOM node right next to the reference DOM node"
* https://popper.js.org/docs/v2/modifiers/event-listeners/
*/
/* function updatePosition(floatingId) {
Copy link
Member

Choose a reason for hiding this comment

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

If this will eventually be exposed, it would be helpful to document exactly when it should be used (even if it is rarely needed)

Copy link
Member Author

Choose a reason for hiding this comment

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

Makes sense

Copy link
Member

@rtibbles rtibbles left a comment

Choose a reason for hiding this comment

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

A few comments - nothing is standing out to me as concerning in the approach.

expect(_floatingInteractions['floating-2']).toBeUndefined();
});

it(`updates '_floatingCahe'`, async () => {
Copy link
Member

Choose a reason for hiding this comment

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

typo!

Copy link
Member Author

Choose a reason for hiding this comment

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

thans!


// If 'delegateTo' is 'root', this map is used to decide
// whether an event should be attached to Document or Window
const DELEGATE_ROOT = 'root';
Copy link
Member

Choose a reason for hiding this comment

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

Just for my understanding, what is the use case for not delegating to root? Is it because delegating to a more proximate ancestor in the DOM tree will allow us to segment the floating elements more effectively? Does this give us any performance gains, or is it just a better developer experience?

Copy link
Member Author

@MisRob MisRob Oct 17, 2025

Choose a reason for hiding this comment

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

You could imagine a page where the first half of a page has many many tooltips, and another half has none tooltips while containing many elements. In that case it'd be best to delegate only to the element what wraps the first half, so that we don't trigger many mouse event listeners unnecessarily when interacting with the second half.

Anyway, I don't know if we have such a page :) I was just aware that generally this may be useful for balancing both sides, and architecture-wise it felt a bit more flexible compared to hardcoding window/document.

function decreaseDelegateUsage(delegateTo, eventType) {
const usage = _delegateUsage[delegateTo];
if (usage) {
usage[eventType] -= 1;
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to do an existence check here as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I should check on this everywhere in the final version

// whether an event should be attached to Document or Window
const DELEGATE_ROOT = 'root';
const DELEGATE_ROOT_TARGET = {
[MOUSEENTER]: 'document',
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason these couldn't just be direct references to document and window?

Copy link
Member Author

Choose a reason for hiding this comment

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

Would you explain a bit more please? I don't get the question.

Copy link
Member

Choose a reason for hiding this comment

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

@rtibbles can correct if I'm wrong but I think he's asking why strings as opposed to the window or document objects themselves - for example:

    const DELEGATE_ROOT_TARGET = { [MOUSEENTER]: document, [FOCUS]: window, ... };

  // Further down below
  if (delegateTo === DELEGATE_ROOT) {
    //const target = DELEGATE_ROOT_TARGET[eventType];
    //return target === 'window' ? window : document;
    
    // This instead:
    return DELEGATE_ROOT_TARGET[event];

}
if (delegateTo === DELEGATE_ROOT) {
const target = DELEGATE_ROOT_TARGET[eventType];
return target === 'window' ? window : document;
Copy link
Member

Choose a reason for hiding this comment

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

My question about the DELEGATE_ROOT_TARGET may mean that target would already be this?

Copy link
Member Author

Choose a reason for hiding this comment

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

I still don't follow - let see if your answer in the related comment helps :)

}

function areInteractionsValid(interactions) {
return interactions.every(i => SUPPORTED_INTERACTIONS.includes(i));
Copy link
Member

Choose a reason for hiding this comment

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

Maybe check that interactions is even an Array first?

Not a huge deal, but I suppose SUPPORTED_INTERACTIONS could be a set, and we could just check that a set of interactions is a subset. Looks like isSubsetOf is polyfillable by core.js so we may be able to use it directly: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/isSubsetOf

Copy link
Member Author

Choose a reason for hiding this comment

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

That sounds neat - I will have a look.

@MisRob
Copy link
Member Author

MisRob commented Oct 17, 2025

Thanks both for your time and feedback @LianaHarris360 @rtibbles.

maybe we could test adding one other floating component (like a dropdown) using them just to be sure the APIs won’t need to change much down the road?

Yes, I can add a quick experimental draft :)

// Determines if new listeners should be added to the trigger
// or the delegate elements associated with this floating element
onMounted(() => {
if (isNuxtServerSideRendering()) return;
Copy link
Member

Choose a reason for hiding this comment

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

Is this required for examples on the KDS site?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, that's a condition we generally add specifically because of the KDS site. I am not sure about the details, from what I observed, logic is mounted two times during building - and this condition prevents the first time mount attempt breaking the build.

Copy link
Member

@nucleogenesis nucleogenesis left a comment

Choose a reason for hiding this comment

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

Fantastic - the demo looks/feels great overall and the tools themselves look like they'd be nice to use and they're very well documented. I have no suggestions for improvements or changes - I think that if I used them in some code I might start developing opinions 😅 - so I look forward to this being available!

Thank you for all of your hard work on this!

@MisRob
Copy link
Member Author

MisRob commented Oct 27, 2025

Thanks for taking time to preview it @nucleogenesis - it's good to hear that on some basic level it will be hopefully straightforward to use. Yes, I think after some time, we may come up with some tweaks ;)

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.

5 participants