ReviewOS

stacks/stx

Tooltip: `role="tooltip"` with no `aria-describedby` is never announced, plus no Escape, `pointer-events-none` and `whitespace-nowrap`

#2045
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

Tooltip is never announced, cannot be dismissed, cannot be hovered, and forces its content onto one line — adopting it is a downgrade from a native title.

All four are visible in src/ui/tooltip/Tooltip.stx as it stands on 0.2.382.

1. The tooltip is never announced (the significant one)

<div :show="isVisible() && !" class="" role="tooltip">

role="tooltip" does nothing on its own. A tooltip reaches a screen reader only when the trigger references it, via aria-describedby pointing at the tooltip's id. Nothing here generates an id for the tooltip or puts aria-describedby on the slotted trigger, so the text is unreachable.

A native title attribute is announced. So for a screen-reader user, replacing title="…" with <Tooltip> removes information that was previously available.

2. Not dismissible (WCAG 2.1 SC 1.4.13)

There is no Escape handling. 1.4.13 requires that content shown on hover or focus can be dismissed without moving the pointer or focus.

3. Not hoverable (same SC)

pointer-events-none

is in tooltipClasses. The pointer cannot enter the tooltip, so a user who moves toward it to read it dismisses it. 1.4.13 requires the content stay visible while the pointer is over it — which matters most for exactly the long tooltips that are hardest to read quickly.

4. whitespace-nowrap forces one line

Also in tooltipClasses. Our tooltips are full sentences, e.g. "The invite email was not delivered — the address was rejected by the receiving server." On a 390px viewport that renders as a single line several times the viewport width. We have a visual gate that fails on horizontal overflow, and this is precisely what it catches.

Suggested fix

  • Give the tooltip panel id="tooltip-${$uid}" and set aria-describedby to it on the trigger wrapper, so the relationship exists whether the tooltip is open or not.
  • Bind Escape on the wrapper to hideTooltip().
  • Drop pointer-events-none and add @mouseenter/@mouseleave on the panel so moving onto it keeps it open.
  • Replace whitespace-nowrap with a max-w-* and normal wrapping, or make nowrap an opt-in prop.

Note on the focus path

@focusin/@focusout on the wrapper is the right idea, but it only fires when the slotted trigger is focusable. A tooltip on a non-interactive <span> — the common case in our markup — still never opens by keyboard. Worth documenting that the trigger must be focusable, or giving the wrapper tabindex="0" when the slot contains nothing focusable.

Found on @stacksjs/components 0.2.382. Context: 119 title= attributes across six apps were the intended migration target, so this one blocks real adoption. Related: #2043 (form controls), #2044 (Badge).

9 days ago

Four of the five are fixed on 0.2.395 — verified by rendering, not by reading the source:

finding0.2.395
never announced (role="tooltip" with no aria-describedby)fixed — the trigger carries aria-describedby="" and the panel has that id
no Escape dismissfixed
whitespace-nowrap forces one linefixed
focus path needs a focusable triggerdocumented
pointer-events-none — not hoverablestill present

What remains

tooltipClasses still contains pointer-events-none, and @mouseenter / @mouseleave are on the trigger wrapper only, not on the panel:

46: export const tooltipClasses = `absolute z-50 px-3 py-2 … pointer-events-none transition-opacity …`
86:     @mouseenter="showTooltip()"
87:     @mouseleave="hideTooltip()"

So moving the pointer off the trigger and onto the tooltip to read it hides the tooltip — the pointer cannot land on it, and leaving the trigger fires hideTooltip(). That is the hoverable clause of WCAG 2.1 SC 1.4.13, and it bites hardest on the long tooltips that the whitespace-nowrap fix now makes possible to wrap.

The fix is the pair: drop pointer-events-none from the panel, and put @mouseenter="showTooltip()" / @mouseleave="hideTooltip()" on the panel as well as the trigger, so the pointer can cross the gap without the tooltip closing under it.

Thanks for the other four — the aria-describedby one was the significant one.

9 days ago

All four fixed, across two releases.

Shipped in v0.2.385 (ae7343a):

  1. Never announced. The panel takes an id from $uid and the trigger carries aria-describedby pointing at it. The panel is always in the DOM — :show toggles it — so the description resolves whether or not the tooltip is on screen, which is how a screen reader is meant to reach it. Two tooltips on one page get distinct ids, so no trigger ends up pointing at the first one.

  2. whitespace-nowrap. Now max-w-xs break-words by default; nowrap is there for a caller who knows the text is short. Your 390px example was the deciding case — a tooltip that is a full sentence is the normal case, not the exception.

Just pushed (9470a93), the SC 1.4.13 half that v0.2.385 did not touch:

  1. Not dismissible. There was no Escape handling at all. The listener is global rather than bound to the trigger, and that detail matters: 1.4.13 requires dismissal without moving the pointer or the focus, and a tooltip opened by hovering can be showing while focus is somewhere else entirely — a handler on the trigger would never see the key. It no-ops unless this tooltip is the one showing, and useEventListener drops it on teardown so a page of tooltips does not leak one listener apiece.

  2. Not hoverable. pointer-events-none is gone, and the panel now takes its own mouseenter/mouseleave so entering it keeps it open. Without the second half, removing the class alone would not have fixed it — the pointer entering the panel still counts as leaving the trigger.

Covered by packages/components/test/badge-tooltip-announcement.test.ts. Full suite 14702 pass.

Your framing was right and worth repeating: a role="tooltip" that nothing references is not merely incomplete, it is a regression from the native title it replaces, because title is announced. That is what made this worth doing before adoption rather than after.

Sign in to comment on this issue.