Skip to content

fix(composer): make composer icon-button tooltips click-through#1272

Draft
tellaho wants to merge 3 commits into
mainfrom
tho/composer-tooltip-pointer-events
Draft

fix(composer): make composer icon-button tooltips click-through#1272
tellaho wants to merge 3 commits into
mainfrom
tho/composer-tooltip-pointer-events

Conversation

@tellaho

@tellaho tellaho commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Category: fix
User Impact: Tooltips on the message composer's toolbar buttons no longer block clicks into the controls or the text field underneath them.

Problem: Radix/shadcn TooltipContent renders as a hoverable Portal popup with pointer-events: auto. When a composer toolbar tooltip is visible, the popup sits on top of the message textarea and intercepts the mouse, so clicking into the field while the tooltip shows fails. Reported on the composer's formatting/action buttons.

Solution: Make the fix localized to the composer rather than app-wide. A new ComposerIconButton component owns the full Tooltip → Trigger → Button → Content shape and bakes pointer-events-none onto the tooltip content (popup) only — never the trigger. Because the button owns its own tooltip, every current and future composer icon button inherits the click-through behavior and the override can't be forgotten. The shared tooltip.tsx is left untouched. The formatting sub-toolbar takes a matching content-only knob (its buttons are raw <button>s with custom active-state styling, so wrapping them was too heavy).

This satisfies the WCAG content-on-hover-or-focus constraints: pointer-events-none only affects the floating popup, so trigger focus-to-show (screen-magnification accommodation) and the hover/show lifecycle are unchanged. The caller className is merged last via tailwind-merge so the override stays adjustable. Composer labels are succinct and non-interactive.

File changes

desktop/src/features/messages/ui/ComposerIconButton.tsx (new)
forwardRef component owning Tooltip → Trigger → Button → Content. Bakes cn("pointer-events-none", tooltipClassName) onto the tooltip content only; trigger/button untouched. Defaults size="icon", type="button". Doc comment covers the three WCAG content-on-hover-or-focus constraints.

desktop/src/features/messages/ui/MessageComposerToolbar.tsx
Swapped the 6 composer icon buttons (formatting toggle ×2, close, mention, attach, spoiler) to ComposerIconButton. Send stays a raw submit Button (no tooltip).

desktop/src/features/messages/ui/FormattingToolbar.tsx
Sub-toolbar (Bold/Italic/Strikethrough/Code/Link/lists/Quote) TooltipContent takes the content-only pointer-events-none knob, with an on-site comment explaining the content-knob path for these raw active-state buttons.

desktop/playwright.config.ts
Registered the composer-scoped e2e spec in the smoke project (it was previously dangling).

desktop/e2e/screenshot-tooltip-pointer-events.spec.ts (new)
e2e cases for both surfaces: assert tooltip visible AND pointerEvents === 'none', and confirm typed text lands in the editor beneath the visible tooltip.

Reproduction Steps

  1. Open a channel in the desktop app and bring up the message composer.
  2. Hover (or focus) one of the composer's toolbar buttons (Mention, Attach, Spoiler, Formatting) so its tooltip appears over the textarea.
  3. Open the formatting sub-toolbar and hover a button (Bold, Italic, etc.) so its tooltip shows.
  4. Click on the textarea region covered by either tooltip — before this change the click was eaten; now the field receives the click and the tooltip is click-through.
  5. Tab to a button to confirm focus still reveals the tooltip (focus-to-show preserved).

Screenshots/Demos

Click-through confirmed during review — tooltip renders over the textarea and the field receives input through it:

composer tooltip click-through


Follow-up: dismiss composer tooltips when the cursor leaves the trigger (commit c2a3b64f)

Problem (gap 2): With click-through fixed, the composer tooltips still persisted when you slid the cursor off the trigger onto the popup — you could camp on a tooltip and even select its text. Expected behavior: mouse off the trigger and the tooltip dismisses immediately. Root cause is Radix Tooltip's hoverable-content "safe bridge", which is on by default and deliberately keeps the popup alive as the cursor moves from trigger toward content. This is a separate lever from the pointer-events-none click-through fix above, which holds unchanged.

Solution: Set disableHoverableContent on the composer Tooltip Root in both composer surfaces — ComposerIconButton.tsx and FormattingToolbar.tsx. Root-level overrides the app-wide TooltipProvider default, so the change is genuinely scoped to the composer: the shared tooltip.tsx and the app-wide provider are untouched, and there's no app-wide behavior change. The trigger keeps its normal hover/focus-to-show lifecycle, so WCAG content-on-hover-or-focus still holds. On-site comments explain the knob.

Verification: biome check ., tsc --noEmit, and pnpm build clean; rebuilt bundle shows disableHoverableContent:!0 exactly twice (one per surface). A behavioral spec confirms both surfaces: hover trigger → tooltip shows → move cursor onto the popup → it dismisses instead of camping. The existing click-through tests still pass — no regression.

Toolbar tooltips rendered with pointer-events:auto, so while visible they sat over the message textarea and swallowed clicks meant for the editor beneath. Fix the click-through on the composer surface only — where labels are known-short — rather than promising it app-wide for every tooltip.

Add ComposerIconButton (desktop/src/features/messages/ui): a forwardRef component owning the Tooltip -> Trigger -> Button -> Content shape that bakes pointer-events-none onto the tooltip content (the floating popup) only. The trigger keeps pointer/focus so focus-to-show still works (WCAG content-on-hover-or-focus). Because the button owns its tooltip, the override can't be forgotten and future composer icon buttons inherit it. Swap the 5 main-toolbar icon buttons (formatting toggle/close, mention, attach, spoiler) to it.

The formatting sub-toolbar (Bold/Italic/lists/Quote) maps over raw <button>s with custom active-state styling, not the shared Button, so bake pointer-events-none onto its single TooltipContent there instead of restructuring to ComposerIconButton — keeping the override in one obvious place for that row. Shared tooltip.tsx is untouched. Adds composer-scoped e2e cases asserting both surfaces' tooltips are visible but click-through, and registers the spec in the smoke project.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34h <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho tellaho force-pushed the tho/composer-tooltip-pointer-events branch from 2987fc4 to 80c7633 Compare June 25, 2026 16:31
@tellaho tellaho changed the title fix(tooltip): make tooltip popups click-through so they don't block underlying controls fix(composer): make composer icon-button tooltips click-through Jun 25, 2026
@tellaho tellaho marked this pull request as draft June 25, 2026 22:09
@tellaho

tellaho commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

moving down to draft because it's not behaving as expected

npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w and others added 2 commits June 25, 2026 15:36
The click-through fix (pointer-events-none) stopped the popup from swallowing clicks but left Radix Tooltips hover-to-persist safe bridge intact, so the cursor could slide off the trigger onto the popup, keep it alive, and select its text. Set disableHoverableContent on the composer Tooltip Roots (ComposerIconButton + the FormattingToolbar map) so these label tooltips dismiss the instant the pointer leaves the trigger.

Scoped to the composer Roots only — the shared TooltipProvider and tooltip.tsx keep their app-wide defaults, and the trigger keeps its hover/focus-to-show lifecycle (WCAG content-on-hover-or-focus).

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…ntent

disableHoverableContent + pointer-events-none on the inner TooltipContent still let the tooltip camp: Radix wraps content in a positioned [data-radix-popper-content-wrapper] DIV it styles with pointer-events:auto and never exposes to props. That wrapper overlaps the trigger, so a real cursor sliding off the trigger lands on it, persists the tooltip, and can select text. The inner pointer-events-none was one level too shallow.

Tag the composer TooltipContent with data-composer-tooltip and add a scoped globals.css rule that reaches the wrapper via :has(> [data-composer-tooltip]), setting pointer-events:none + user-select:none on the actual camp surface. Scoped to composer tooltips only — shared tooltip.tsx and the app-wide TooltipProvider untouched; trigger keeps hover/focus-to-show (WCAG content-on-hover-or-focus). Inner pointer-events-none/select-none kept as belt-and-suspenders.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.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.

1 participant