Replace native title tooltips with nldd-tooltip #259

Merged
robbertbos merged 5 commits from tooltips-replace-native-title into main 2026-08-08 20:48:54 +00:00
Owner

Closes #197.

A native title never fires on keyboard focus, is unreachable on touch and is
announced inconsistently, so every hint sitting on one was mouse-only.

I re-measured rather than working from the issue's inventory, which was written
before the big NLDD/retention merge and no longer matches the tree. title
turns out to be three different things here, and only one of them is a tooltip.

What changed

Nine controls now wrap in nldd-tooltip - it shows on hover and focus,
suppresses itself on touch, renders in the top layer (so overflow: hidden no
longer clips it) and sets aria-describedby itself:

Where Tooltip
QueuedRow retry / restore what the action will do
SendButton primary the keyboard shortcut
SendButton chevron why scheduling is unavailable
BubbleMenuButton when the card bubbles up
CardRow bubble tag the exact timestamp behind "Verschijnt over 2 dagen"
AttachmentPreview download the icon-only download link
AttachmentChipList the upload error
DevUserSwitcher what the dev reset does

Sixteen titles are dropped, not restyled. They repeated the accessible name
(aria-label / accessible-label) or the visible label. A description
identical to the name makes a screen reader say the same thing twice, so
re-adding it as a tooltip would have been a regression, not a fix.

Four titles stay native on purpose, each with a comment so a later sweep
does not "finish the job":

  • The two <iframe title>s are accessible names (WCAG 4.1.2), not tooltips.
    Deleting them would have been the one genuinely harmful edit in this diff.
  • The two MmSourceBreadcrumb channel names only reveal CSS ellipsis overflow
    of a string that is already the link's accessible name. A keyboard or screen
    reader user loses nothing, and a tooltip per card row costs a custom element
    per row for a purely visual reveal.

Two things worth a second opinion

SendButton gave up its hand-rolled aria-describedby. It had a
visually-hidden span + useId carrying the disabled reason. nldd-tooltip
overwrites aria-describedby on its trigger and removes the attribute when
the text empties (tooltip.js:163-202), so the two cannot coexist - it would
have silently deleted the existing wiring. The component now owns it. The cost:
the unit test can no longer verify the wiring end to end (the tag is a bare stub
in happy-dom), so it asserts we hand the right text to the tooltip and that it
wraps the chevron specifically. Verified in a real browser instead.

nldd-tooltip parks its description span on document.body, one per
instance, outside every landmark.
That tripped the axe region rule in
landmarks.spec.ts, which now excludes those spans (the selector was checked to
match only body-level tooltip spans and nothing inside <main>). On a full
inbox this is ~46 spans, one per card's bubble-up button. Worth a short note
upstream to MinBZK/storybook - happy to file it if you agree.

Verification

  • npm run typecheck, npm run build, eslint: clean
  • vitest 702 passed (701 before, +1 control test)
  • Playwright e2e 68 passed / 0 failed (the landmark failure above was caught here
    and fixed, not waved through)
  • Driven in the real preview: confirmed the tooltip appears on keyboard focus
    and that aria-describedby resolves to the right text

Not verified by eye: the CardRow bubble-up tag tooltip - the seed has no
bubbled card and I could not cheaply reach that state. It is the same mechanism
proven on the other eight.

Closes #197. A native `title` never fires on keyboard focus, is unreachable on touch and is announced inconsistently, so every hint sitting on one was mouse-only. I re-measured rather than working from the issue's inventory, which was written before the big NLDD/retention merge and no longer matches the tree. `title` turns out to be three different things here, and only one of them is a tooltip. ## What changed **Nine controls now wrap in `nldd-tooltip`** - it shows on hover *and* focus, suppresses itself on touch, renders in the top layer (so `overflow: hidden` no longer clips it) and sets `aria-describedby` itself: | Where | Tooltip | |---|---| | `QueuedRow` retry / restore | what the action will do | | `SendButton` primary | the keyboard shortcut | | `SendButton` chevron | *why* scheduling is unavailable | | `BubbleMenuButton` | when the card bubbles up | | `CardRow` bubble tag | the exact timestamp behind "Verschijnt over 2 dagen" | | `AttachmentPreview` download | the icon-only download link | | `AttachmentChipList` | the upload error | | `DevUserSwitcher` | what the dev reset does | **Sixteen titles are dropped, not restyled.** They repeated the accessible name (`aria-label` / `accessible-label`) or the visible label. A description identical to the name makes a screen reader say the same thing twice, so re-adding it as a tooltip would have been a regression, not a fix. **Four titles stay native on purpose**, each with a comment so a later sweep does not "finish the job": - The two `<iframe title>`s are **accessible names** (WCAG 4.1.2), not tooltips. Deleting them would have been the one genuinely harmful edit in this diff. - The two `MmSourceBreadcrumb` channel names only reveal CSS ellipsis overflow of a string that is already the link's accessible name. A keyboard or screen reader user loses nothing, and a tooltip per card row costs a custom element per row for a purely visual reveal. ## Two things worth a second opinion **`SendButton` gave up its hand-rolled `aria-describedby`.** It had a visually-hidden span + `useId` carrying the disabled reason. `nldd-tooltip` *overwrites* `aria-describedby` on its trigger and removes the attribute when the text empties (`tooltip.js:163-202`), so the two cannot coexist - it would have silently deleted the existing wiring. The component now owns it. The cost: the unit test can no longer verify the wiring end to end (the tag is a bare stub in happy-dom), so it asserts we hand the right text to the tooltip and that it wraps the chevron specifically. Verified in a real browser instead. **`nldd-tooltip` parks its description span on `document.body`, one per instance, outside every landmark.** That tripped the axe `region` rule in `landmarks.spec.ts`, which now excludes those spans (the selector was checked to match only body-level tooltip spans and nothing inside `<main>`). On a full inbox this is ~46 spans, one per card's bubble-up button. Worth a short note upstream to MinBZK/storybook - happy to file it if you agree. ## Verification - `npm run typecheck`, `npm run build`, eslint: clean - vitest 702 passed (701 before, +1 control test) - Playwright e2e 68 passed / 0 failed (the landmark failure above was caught here and fixed, not waved through) - Driven in the real preview: confirmed the tooltip **appears on keyboard focus** and that `aria-describedby` resolves to the right text Not verified by eye: the `CardRow` bubble-up tag tooltip - the seed has no bubbled card and I could not cheaply reach that state. It is the same mechanism proven on the other eight.
Replace native title tooltips with nldd-tooltip
Some checks failed
CI / pre-commit (pull_request) Has been cancelled
CI / backend-test (pull_request) Has been cancelled
CI / frontend-test (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / release-scripts (pull_request) Has been cancelled
security-scan / Python SCA (pip-audit) (pull_request) Has been cancelled
security-scan / Python SAST (bandit) (pull_request) Has been cancelled
security-scan / JS SCA (npm audit) (pull_request) Has been cancelled
security-scan / Filesystem scan (trivy fs) (pull_request) Has been cancelled
security-scan / SBOM (trivy) (pull_request) Has been cancelled
test-build / build (pull_request) Has been cancelled
test-build / build (backend) (pull_request) Has been cancelled
test-build / build (frontend) (pull_request) Has been cancelled
9d0317edf1
A native title never fires on keyboard focus, is unreachable on touch and
is announced inconsistently, so every hint sitting on one was mouse-only.

Nine sites now wrap their control in nldd-tooltip, which shows on hover
and on focus and wires the text to the control with aria-describedby.
Sixteen titles are dropped instead: they repeated the accessible name or
the visible label, and a description identical to the name makes a screen
reader say the same thing twice.

Four titles stay native on purpose and say why in a comment. The two
iframe titles are accessible names rather than tooltips (WCAG 4.1.2), and
the two breadcrumb channel names only reveal CSS overflow of a string
that is already the accessible name, so a tooltip per card row would cost
a custom element for no gain.

SendButton's chevron gives up its hand-rolled visually-hidden span and
useId. nldd-tooltip overwrites aria-describedby on its trigger and drops
the attribute when the text empties, so the two cannot coexist; the
component does the same job.

The landmarks check excludes nldd-tooltip's description spans: the
component parks them on document.body, one per instance, outside every
landmark. QueuedRow and ReplyComposer tests move off title selectors onto
the visible label.
Merge remote-tracking branch 'origin/main' into tooltips-replace-native-title
Some checks failed
CI / pre-commit (pull_request) Successful in 2m4s
CI / release-scripts (pull_request) Successful in 10s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 1m6s
security-scan / Python SAST (bandit) (pull_request) Successful in 43s
security-scan / JS SCA (npm audit) (pull_request) Successful in 41s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 27s
security-scan / SBOM (trivy) (pull_request) Successful in 18s
CI / frontend-test (pull_request) Successful in 6m3s
CI / e2e (pull_request) Has been cancelled
test-build / build (pull_request) Has been cancelled
test-build / build (backend) (pull_request) Has been cancelled
test-build / build (frontend) (pull_request) Has been cancelled
CI / backend-test (pull_request) Has been cancelled
2da522ee22
# Conflicts:
#	CHANGELOG.md
The send tooltip said "Cmd/Ctrl + Enter" to everyone, which is a menu of
possibilities rather than an instruction. ShortcutHelp already resolved the
real key per platform, so lift that out of the component into lib/platform.ts
and let both use it: Cmd on Apple platforms, Ctrl on Windows and Linux.

On a touch device the hint is dropped altogether. A phone cannot act on a key
combination, and an empty text is what makes nldd-tooltip suppress the popover
and its aria-describedby together - verified in the browser, not only from the
source: the description span is removed, not left orphaned.

The query is the same '(pointer: coarse)' nldd-tooltip uses to suppress itself,
so the visible tooltip and what a screen reader hears stay in step.
Give the two ambiguous icon-only buttons a tooltip
Some checks failed
CI / pre-commit (pull_request) Has been cancelled
CI / backend-test (pull_request) Has been cancelled
CI / frontend-test (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / release-scripts (pull_request) Has been cancelled
security-scan / Python SCA (pip-audit) (pull_request) Has been cancelled
security-scan / Python SAST (bandit) (pull_request) Has been cancelled
security-scan / JS SCA (npm audit) (pull_request) Has been cancelled
security-scan / Filesystem scan (trivy fs) (pull_request) Has been cancelled
security-scan / SBOM (trivy) (pull_request) Has been cancelled
test-build / build (pull_request) Has been cancelled
test-build / build (backend) (pull_request) Has been cancelled
test-build / build (frontend) (pull_request) Has been cancelled
a7ecefc0c5
A sweep of all 62 hand-rolled button and anchor elements found twelve that are
icon-only with an aria-label. Two of them are glyphs nobody can be expected to
read: the breadcrumb ellipsis, and the smiling-face-with-plus that adds a
reaction. Those get a tooltip.

The other ten are close crosses, lightbox arrows and image thumbnails. A
tooltip there buys a sighted user nothing - the glyph is conventional or the
thumbnail is its own label - while a description identical to the accessible
name makes a screen reader repeat itself. Three of them are the very titles
this branch removed for that reason, so re-adding them as tooltips would undo
the point.

Note nldd-icon-button does not face this trade: it renders the same tooltip
from accessible-label, but its aria-describedby cannot cross its shadow
boundary, so the duplication never reaches assistive tech. That makes it the
better home for the remaining hand-rolled crosses than a tooltip wrapper is.
Note the platform-aware shortcut and the two icon tooltips in the changelog
All checks were successful
CI / pre-commit (pull_request) Successful in 1m58s
CI / release-scripts (pull_request) Successful in 10s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 1m1s
CI / frontend-test (pull_request) Successful in 6m17s
security-scan / Python SAST (bandit) (pull_request) Successful in 44s
security-scan / JS SCA (npm audit) (pull_request) Successful in 41s
security-scan / SBOM (trivy) (pull_request) Successful in 20s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 30s
test-build / build (backend) (pull_request) Successful in 2m30s
test-build / build (frontend) (pull_request) Successful in 2m35s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 15m5s
CI / e2e (pull_request) Successful in 2m57s
353f76bf9a
Author
Owner

Three follow-up commits on this branch, after review feedback.

c4107ba - the send hint names the key your platform actually uses.
"⌘/Ctrl + Enter" was a menu of possibilities rather than an instruction.
ShortcutHelp already resolved the real modifier, so that moved to
lib/platform.ts and both use it: Cmd on Apple platforms, Ctrl on Windows and
Linux. On a touch device the hint is dropped entirely - a phone cannot act on a
key combination, and an empty text is what makes nldd-tooltip suppress the
popover and its aria-describedby together (checked in the browser: the
description span is removed, not orphaned). Same (pointer: coarse) query the
component uses to suppress itself, so the visual and the announced stay in step.
Nine unit tests, including Linux and the Android "Linux armv8l" platform string
that must not match /mac/.

a7ecefc - two more tooltips, and ten deliberate refusals.
Swept all 62 hand-rolled <button>/<a> elements. Twelve are icon-only with an
aria-label. Two got a tooltip: the breadcrumb ellipsis and the
smiling-face-with-plus that adds a reaction - glyphs nobody can be expected to
read. The other ten are close crosses, lightbox arrows and image thumbnails,
where the glyph is conventional or the thumbnail is its own label, and where a
description identical to the accessible name only makes a screen reader repeat
itself. Three of those ten are the very titles this branch removed for that
reason.

Worth knowing for anything similar later: nldd-icon-button does not face
that trade. It renders the same tooltip from accessible-label
(icon-button.template.js:37), but its aria-describedby cannot cross its own
shadow boundary, so the duplication never reaches assistive tech. That makes
converting the remaining hand-rolled close buttons to nldd-icon-button a
better move than wrapping them - it buys the visual tooltip without the double
announcement. Not done here; it is a refactor, not a tooltip change.

One thing that surfaced while checking: several nldd-icon-buttons carry a
description inside their name, e.g.
accessible-label="Bewerken (annuleert planning + zet als concept klaar in de kaart)". A screen reader announces that whole parenthetical as the button's
name, and the component has no separate tooltip prop to split them. Shortening
it trades the explanation for a clean name - a copy decision, so it is left
alone.

Gates after all three: typecheck, build, eslint 0 errors, vitest 711, e2e 67
passed / 0 failed.

Three follow-up commits on this branch, after review feedback. **`c4107ba` - the send hint names the key your platform actually uses.** "⌘/Ctrl + Enter" was a menu of possibilities rather than an instruction. `ShortcutHelp` already resolved the real modifier, so that moved to `lib/platform.ts` and both use it: Cmd on Apple platforms, Ctrl on Windows and Linux. On a touch device the hint is dropped entirely - a phone cannot act on a key combination, and an empty text is what makes `nldd-tooltip` suppress the popover and its `aria-describedby` together (checked in the browser: the description span is removed, not orphaned). Same `(pointer: coarse)` query the component uses to suppress itself, so the visual and the announced stay in step. Nine unit tests, including Linux and the Android "Linux armv8l" platform string that must not match `/mac/`. **`a7ecefc` - two more tooltips, and ten deliberate refusals.** Swept all 62 hand-rolled `<button>`/`<a>` elements. Twelve are icon-only with an `aria-label`. Two got a tooltip: the breadcrumb ellipsis and the smiling-face-with-plus that adds a reaction - glyphs nobody can be expected to read. The other ten are close crosses, lightbox arrows and image thumbnails, where the glyph is conventional or the thumbnail is its own label, and where a description identical to the accessible name only makes a screen reader repeat itself. Three of those ten are the very titles this branch removed for that reason. Worth knowing for anything similar later: `nldd-icon-button` does **not** face that trade. It renders the same tooltip from `accessible-label` (`icon-button.template.js:37`), but its `aria-describedby` cannot cross its own shadow boundary, so the duplication never reaches assistive tech. That makes converting the remaining hand-rolled close buttons to `nldd-icon-button` a better move than wrapping them - it buys the visual tooltip without the double announcement. Not done here; it is a refactor, not a tooltip change. One thing that surfaced while checking: several `nldd-icon-button`s carry a description inside their name, e.g. `accessible-label="Bewerken (annuleert planning + zet als concept klaar in de kaart)"`. A screen reader announces that whole parenthetical as the button's name, and the component has no separate tooltip prop to split them. Shortening it trades the explanation for a clean name - a copy decision, so it is left alone. Gates after all three: typecheck, build, eslint 0 errors, vitest 711, e2e 67 passed / 0 failed.
Drop the comments that argue for a decision instead of explaining code
All checks were successful
CI / pre-commit (pull_request) Successful in 1m27s
CI / frontend-test (pull_request) Successful in 7m15s
CI / release-scripts (pull_request) Successful in 36s
security-scan / SBOM (trivy) (pull_request) Successful in 35s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 36s
security-scan / JS SCA (npm audit) (pull_request) Successful in 38s
security-scan / Python SAST (bandit) (pull_request) Successful in 42s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 1m2s
test-build / build (frontend) (pull_request) Successful in 1m43s
test-build / build (backend) (pull_request) Successful in 1m55s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 13m42s
CI / e2e (pull_request) Successful in 2m27s
1fb9ac2295
The rationale for keeping these three native titles belongs in the commit
message, not next to the markup.
robbertbos force-pushed tooltips-replace-native-title from 1fb9ac2295
All checks were successful
CI / pre-commit (pull_request) Successful in 1m27s
CI / frontend-test (pull_request) Successful in 7m15s
CI / release-scripts (pull_request) Successful in 36s
security-scan / SBOM (trivy) (pull_request) Successful in 35s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 36s
security-scan / JS SCA (npm audit) (pull_request) Successful in 38s
security-scan / Python SAST (bandit) (pull_request) Successful in 42s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 1m2s
test-build / build (frontend) (pull_request) Successful in 1m43s
test-build / build (backend) (pull_request) Successful in 1m55s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 13m42s
CI / e2e (pull_request) Successful in 2m27s
to f9a8c65d56
Some checks failed
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 8s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 14s
security-scan / JS SCA (npm audit) (pull_request) Successful in 14s
security-scan / Python SAST (bandit) (pull_request) Successful in 21s
CI / pre-commit (pull_request) Successful in 25s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 36s
test-build / build (frontend) (pull_request) Successful in 53s
test-build / build (backend) (pull_request) Successful in 56s
test-build / build (pull_request) Successful in 0s
CI / e2e (pull_request) Has been cancelled
CI / backend-test (pull_request) Has been cancelled
CI / frontend-test (pull_request) Has been cancelled
2026-08-08 20:40:04 +00:00
Compare
robbertbos force-pushed tooltips-replace-native-title from f9a8c65d56
Some checks failed
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 8s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 14s
security-scan / JS SCA (npm audit) (pull_request) Successful in 14s
security-scan / Python SAST (bandit) (pull_request) Successful in 21s
CI / pre-commit (pull_request) Successful in 25s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 36s
test-build / build (frontend) (pull_request) Successful in 53s
test-build / build (backend) (pull_request) Successful in 56s
test-build / build (pull_request) Successful in 0s
CI / e2e (pull_request) Has been cancelled
CI / backend-test (pull_request) Has been cancelled
CI / frontend-test (pull_request) Has been cancelled
to 8e253a6651
All checks were successful
CI / release-scripts (pull_request) Successful in 9s
security-scan / SBOM (trivy) (pull_request) Successful in 14s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 17s
security-scan / JS SCA (npm audit) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 19s
CI / pre-commit (pull_request) Successful in 29s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 38s
test-build / build (frontend) (pull_request) Successful in 55s
test-build / build (backend) (pull_request) Successful in 59s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m8s
CI / backend-test (pull_request) Successful in 1m45s
CI / e2e (pull_request) Successful in 2m46s
CI / release-scripts (push) Successful in 5s
security-scan / SBOM (trivy) (push) Successful in 10s
security-scan / Filesystem scan (trivy fs) (push) Successful in 13s
security-scan / JS SCA (npm audit) (push) Successful in 16s
security-scan / Python SAST (bandit) (push) Successful in 17s
CI / pre-commit (push) Successful in 27s
security-scan / Python SCA (pip-audit) (push) Successful in 37s
publish-main / build (frontend) (push) Successful in 55s
publish-main / build (backend) (push) Successful in 1m4s
publish-main / build (push) Successful in 0s
CI / frontend-test (push) Successful in 1m9s
CI / backend-test (push) Successful in 2m17s
CI / e2e (push) Successful in 2m37s
2026-08-08 20:41:03 +00:00
Compare
robbertbos deleted branch tooltips-replace-native-title 2026-08-08 20:48:54 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
robbertbos/waggle!259
No description provided.