Skip to content

fix(#11242): keep mobile date tooltip on-screen#11274

Draft
binokaryg wants to merge 14 commits into
medic:masterfrom
binokaryg:mobile-tooltip-clipping
Draft

fix(#11242): keep mobile date tooltip on-screen#11274
binokaryg wants to merge 14 commits into
medic:masterfrom
binokaryg:mobile-tooltip-clipping

Conversation

@binokaryg

Copy link
Copy Markdown
Member

Description

On touch devices the date/time tooltip — the accessible way to reveal a relative date's full value — was clipped off-screen: only the tail of the string was readable (e.g. 5:30 AM instead of Tue, Jul 7th, 2026 at 5:30 AM). Most visible on the report detail "Submitted by …" line. Not language-specific — reproduced in English, Nepali, and Arabic (RTL).

Fixes #11242

Root cause. The touch tooltip was a CSS pseudo-element in webapp/src/css/tooltip.less ([title]:focus::before, with content: attr(title); desktop keeps the native title). It was anchored to a fixed edge (right: -10px, width: max-content), so it grew off the viewport, and it was also cropped by ancestor overflow: hidden scroll containers. No fixed CSS anchor can satisfy every case — the trigger can sit at either edge, the text can be wider than the space beside it, and RTL flips which edge is which. (A series of scoped CSS overrides was prototyped and each broke another context; see the issue thread.)

Fix. Replace the CSS pseudo-element with MobileTooltipDirective, which renders the title in a CDK overlay at the document level:

  • positioned with a flexible strategy (withPush) so it stays inside the viewport regardless of the trigger's position, the text length, or the direction;
  • rendered above the page, so it escapes ancestor overflow clipping;
  • direction resolved from the trigger, so start/end placement and bidirectional text are correct in RTL.

It runs only on touch / no-hover devices (desktop is untouched — native title on hover). It's hosted on the webapp app root and on the cht-form root, and the old CSS mechanism (plus its per-context min-width / overflow: visible / first-row band-aids) is removed.

Why a document-level directive and not matTooltip? The date tooltips aren't real elements a directive can bind to — the date pipes (relativeDate, taskDueDate, …) build an HTML string with the title inside it, injected via [innerHTML] in 9 places, and Angular directives can't attach to innerHTML-injected DOM. Using matTooltip would mean converting that shared date rendering into a component, reworking the interval-based relative-date live-update (RelativeDateService.updateRelativeDates), updating the 9 call sites, and rewriting the pipe tests — a broad, higher-risk refactor of a path used everywhere dates appear. This directive instead reads the title already in the DOM via one listener, leaving the pipes, templates, and live-update untouched. CDK itself is already a first-class dependency (@angular/cdk in webapp/package.json; every modal uses MatDialog, which is CDK Overlay) — this is just the first place we use the Overlay API directly rather than through Material.

Behavior on touch:

  • Focusing a date shows the tooltip — so a long-press peeks the full date without navigating.
  • It dismisses on blur, on scroll, when the trigger is removed from the DOM (e.g. the sidebar last-sync span re-rendering on a sync), and — via an IntersectionObserver — when the trigger leaves the viewport. On a single-pane layout, opening a report from the list slides the still-focused list off-screen, so the tooltip clears instead of hovering over the report; on a two-pane layout the list (and the tooltip) stay put. Watching visibility rather than router navigation is deliberate: every list-row tap fires a navigation event whether the trigger stays on screen or not, so navigation was the wrong signal.

Code review checklist

  • UI/UX backwards compatible: Test it works for the new design (enabled by default). And test it works in the old design, enable can_view_old_navigation permission to see the old design. Test it has appropriate design for RTL languages.
  • Readable: Concise, well named, follows the style guide
  • Tested: Unit and/or e2e where appropriate
  • Backwards compatible: Works with existing data and configuration or includes a migration. Any breaking changes documented in the release notes.
  • AI disclosure: Please disclose use of AI per the guidelines.

AI disclosure

Per the AI contribution guidelines:

  • Tool: Claude Code (Anthropic).
  • How it was used: investigating the clipping and its history, evaluating approaches (a CSS-anchor fix vs. the CDK overlay), implementing the directive and its tests, and iterating on the touch dismissal behavior. A code review of the directive (also AI-assisted) surfaced several issues — zone/CD cost, RTL wiring, dismissal on DOM-detach and scroll, and the cht-form gap — which were addressed. All changes were reviewed and tested by me.

License

The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license.

binokaryg and others added 14 commits July 20, 2026 20:19
MobileTooltipDirective renders a focused element's `title` in a CDK overlay,
positioned with a flexible strategy (withPush) so it stays within the viewport
regardless of the trigger's position, the text length, or the direction. It
runs only on touch / no-hover devices. Not wired in yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The touch tooltip was a CSS pseudo-element (`[title]:focus::before`) anchored to
a fixed edge with `width: max-content`, so it grew off the viewport and was
clipped in several reachable contexts — the report detail summary, the reports
list on tablet landscape, and the sync status on narrow phones — in both LTR
and RTL. No fixed CSS anchor satisfies all of them: the trigger can sit at
either edge and the tooltip can be wider than the space beside it.

Render it through MobileTooltipDirective (on the app root) instead: a CDK
overlay at the document level (escaping ancestor `overflow`) that flips/shifts
to stay within the viewport. This drops the old pseudo-element and its
per-context band-aids (min-width, first-row flip, sync-status overflow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review findings on the overlay directive:
- Register the focus/scroll listeners outside the Angular zone so they don't run
  app-wide change detection on every focus (the overlay content is rendered with
  a manual detectChanges).
- Dismiss reliably: clear any existing tooltip before the title check; dismiss on
  scroll (capture phase, so plain CSS-overflow panes are covered); and drop the
  tooltip when its trigger is detached from the DOM (focusout doesn't fire then)
  via a MutationObserver.
- Pass the trigger's direction to the overlay so start/end placement and bidi
  text are correct in RTL (the overlay lives outside the app's [dir] subtree).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cht-form compiles inbox.less (and thus the tooltip styles) but never applied the
directive, so its Enketo form tooltips were lost on touch. Apply the directive on
the cht-form root and add the @mm-directives path alias to its tsconfigs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tooltip is focus-based, so a long-press on a date shows it without
navigating (the way to peek a full date on touch). But a short tap on a list-row
date focuses it (showing the tooltip) and also opens the report; the date keeps
focus, so the tooltip lingered over the loaded report. Dismiss on router
NavigationEnd so it clears once the report has opened.

NavigationEnd, not NavigationStart: the row's routerLink emits NavigationStart
synchronously while the click is still bubbling, before the focusin handler shows
the tooltip, so only a post-show event can dismiss it. A long-press focuses
without navigating, so its tooltip stays. Router is injected optionally so
cht-form (which has no router) still works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping the date of the already-open report re-navigates to the current URL,
which the router skips (NavigationSkipped, not NavigationEnd) — so the previous
NavigationEnd-only dismissal left the tooltip lingering in exactly that case.
Handle NavigationSkipped too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation

Dismissing on router events (NavigationEnd/NavigationSkipped) broke the tooltip
on two-pane layouts (e.g. 951px): every tap on a list-row date navigates — End
for a new report, Skipped for the already-open one — so the tooltip was killed
instantly even though the list, and the still-focused trigger, remain visible.

What the navigation dismissal actually wanted to know is "did the trigger stop
being visible?" — on single-pane mobile, opening a report slides the
still-focused list off-screen (.show-content .left-pane { left: -100% }) with
no focusout, no scroll, and no DOM removal. Watch exactly that with an
IntersectionObserver on the trigger: off-viewport → dismiss (the original
lingering bug stays fixed); visible in two panes → the tooltip stays, like the
old CSS tooltip did. The router subscription (and the directive's only Router
dependency) goes away entirely; same-URL taps need no special case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…haviors

Three behaviors the branch fixed had no regression guard: show() clearing an
existing tooltip before the title check (a focusin alone, with no focusout,
must not strand the old tooltip), only one overlay existing when focus moves
between titled elements, and the overlay resolving its direction from the
trigger so RTL anchoring and bidi text survive a refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
readonly on the never-reassigned constructor-injected members (S2933), and
dedicated chai matchers (.to.be.null / .to.have.lengthOf) over generic
equality assertions in the spec (S5906).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gating on `(hover: none), (pointer: coarse)` showed two tooltips on devices
that match `pointer: coarse` but can still hover (e.g. touchscreen laptops):
click focused the trigger (overlay tooltip) while the mouse hover revealed the
native `title`. The overlay is only warranted where hovering is impossible, so
gate on `hover: none` alone — where hover works, the native tooltip does too,
and the directive stays out of the way.

Also evaluate the query per focus instead of once at startup, so
input-capability changes (DevTools device emulation toggled, a convertible
docking/undocking) apply without a reload. The document listeners are now
always registered, but they run outside the Angular zone and no-op after a
boolean check, so hover-capable devices pay nothing meaningful.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Mobile date tooltip clipped off the left edge of the viewport (report detail)

1 participant