ReviewOS

stacks/stx

Tooltip: `aria-describedby` is on the wrapper div, so the description never reaches the slotted control

#2048
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Tooltip puts aria-describedby on its own wrapper, so the description never reaches the control it describes.

0.2.396 fixed the hoverable, Escape and whitespace-nowrap parts of #2045, and the tooltip panel now has an id with aria-describedby pointing at it. Thank you for that. The reference is on the wrong element though:

<div class="relative inline-block">
  <div
    aria-describedby=""   <!-- a plain, non-focusable div -->
    @mouseenter="showTooltip()" …
  >
    <slot />                             <!-- the control lives here -->
  </div>
  <div id="" role="tooltip" …>{{ content }}</div>
</div>

aria-describedby is not inherited. The description is computed for the element that carries the attribute, and that element is a non-focusable <div> which assistive technology does not land on or announce. The button, link or input inside the slot gets nothing.

What this costs a caller replacing a native title

A title on a control is announced: it supplies the description, or the name when there is nothing else. So swapping title for <Tooltip> moves the text from something an AT reads to something it does not.

Measured in a real accessibility tree, with a symbol-only button, which is the common shape for an icon action:

markupaccessible name
<button title="Resolve, alerts again only if it comes back">✓</button>"Resolve, alerts again only if it comes back"
the component's shape, aria-describedby on the wrapper"✓"

So across 131 title attributes in six apps, the 12 on interactive elements cannot be converted: the control would lose its description, and the icon-only ones would lose their name outright.

Suggested fix

Put the reference on the slotted element rather than beside it. Either:

  1. Set aria-describedby on the slot's root during render. The component already rewrites slot content, so the id is available at the point the trigger is emitted.
  2. Expose the id to the caller, e.g. a describedById prop or a slot prop, so <Tooltip content="…" describedById="tip-1"><button aria-describedby="tip-1">✓</button></Tooltip> is expressible without guessing a generated id.

(1) keeps the call site clean and is what a caller would expect from a component that renders role="tooltip".

Worth noting the keyboard path has the same shape: @focusin/@focusout are on the wrapper, which works only because focus events bubble from the slotted control. The description is the half that cannot bubble.

Found on 0.2.396. Related: #2045.

9 days ago

You are right, and this is a defect in my own #2045 fix. Fixed in 467475b.

aria-describedby is not inherited, the wrapper is a non-focusable <div>, and so the control inside the slot got nothing — which left the component exactly the downgrade from title that #2045 was supposed to stop it being. The icon-only measurement is the one that makes it concrete: a symbol-only button has no other source of a name, so title="Resolve" announces "Resolve" and the component's shape announced the glyph.

Your note that the events work only because focus bubbles is the key observation, and it is what the fix turns on: events bubble up to the wrapper, a description does not bubble anywhere, so it has to be placed.

On suggestion (1), which is the right one but not by the obvious route

"The component already rewrites slot content, so the id is available at the point the trigger is emitted" — that reads as true and is a trap. A component's server script can see the slot: is a string in scope. But it is the raw slot, not the processed one. Using it would bypass the caller-script hoisting and the caller-scope expression resolution that #2046 just added, so <Tooltip><Button :label="x()">…</Button></Tooltip> would regress. Trading this bug for those two is not a fix.

So the reference is attached on mount, once the trigger exists:

  • appended, not assigned — a caller who already described their control keeps it, and ARIA reads the list in order;
  • bare text falls back to the wrapper, where there is genuinely no control to carry it.

Suggestion (2), exposing the id, stays available as a follow-up if you want the explicit spelling, but (1) is now the default and the call site stays clean.

One consequence, stated plainly

There is no description until the runtime mounts. The reference that was there before was on an element nothing announces, so nothing real is lost — and a tooltip that only appears on hover or focus is inert before the runtime regardless. But it is a change, not a free win, and if a server-rendered description matters for your case that is worth saying, because it would need the engine to support attributing slot roots at render.

Two of my #2045 tests asserted the old server-rendered attribute and have been repointed at what the server actually owes — a panel with an id and a role — with the reference itself proven in a real DOM, the way you measured it.

Four new tests: the slotted button is pointed at the panel, the wrapper is left without a description of its own, a caller's existing aria-describedby survives, and bare text still gets one. Full suite 14789 pass.

Sign in to comment on this issue.