diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 018ac1471..ed3b0d352 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ "**/file-attachment.spec.ts", "**/video-attachment.spec.ts", "**/spoiler.spec.ts", + "**/screenshot-tooltip-pointer-events.spec.ts", "**/mentions.spec.ts", "**/relay-reconnect.spec.ts", "**/relay-reconnect-affordance.spec.ts", diff --git a/desktop/src/features/messages/ui/ComposerIconButton.tsx b/desktop/src/features/messages/ui/ComposerIconButton.tsx new file mode 100644 index 000000000..4318e9c4e --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerIconButton.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; + +import { Button, type ButtonProps } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +/** + * An icon button for the chat composer toolbar, with its label tooltip baked + * in. + * + * The composer's icon buttons surface short, label-only tooltips that float + * over the message textarea. Radix renders TooltipContent as a hoverable + * Portal popup, so by default it intercepts the mouse and you can't click into + * the field underneath while a tooltip is visible. + * + * ComposerIconButton owns the whole `Tooltip → TooltipTrigger → Button → + * TooltipContent` shape and applies `pointer-events-none` to the popup, so the + * tooltip is click-through on this surface only — we don't make an app-wide + * promise about every tooltip. Because the button itself owns its tooltip, + * every current and future composer icon button gets the click-through + * behavior without re-deriving the override at each call site. + * + * `pointer-events-none` touches the floating popup exclusively; the trigger + * keeps its pointer/focus behavior, so focus-to-show (screen-magnification + * accommodation) and the hover/show lifecycle are untouched (WCAG + * content-on-hover-or-focus). It only fits short, non-interactive labels — + * keep interactive content out of the tooltip. + * + * `disableHoverableContent` turns off Radix's "safe bridge" — the keep-alive + * window that normally lets the cursor slide off the trigger onto the popup + * and persist it. Scoped to this composer Root only — the shared + * TooltipProvider keeps its app-wide default. + * + * The `data-composer-tooltip` marker is what actually defeats the camp: Radix + * wraps TooltipContent in a positioned [data-radix-popper-content-wrapper] DIV + * that it styles with `pointer-events: auto` and never exposes to React props. + * That wrapper overlaps the trigger, so a real cursor sliding off the trigger + * lands on it and keeps the tooltip alive — `pointer-events-none` on the inner + * content (below) is one level too shallow. A scoped rule in globals.css keys + * off this marker via `:has()` to kill pointer-events/select on the wrapper + * itself. The inner `pointer-events-none select-none` stays as belt-and- + * suspenders. + */ +export interface ComposerIconButtonProps extends ButtonProps { + /** Short, non-interactive label shown in the click-through tooltip. */ + tooltip: React.ReactNode; + /** Optional className for the tooltip popup (e.g. to tune placement). */ + tooltipClassName?: string; +} + +const ComposerIconButton = React.forwardRef< + HTMLButtonElement, + ComposerIconButtonProps +>( + ( + { tooltip, tooltipClassName, size = "icon", type = "button", ...props }, + ref, + ) => ( + + + - + {/* pointer-events-none + select-none keep these label tooltips + click-through and non-selectable so they don't block the message + textarea floating beneath them, matching ComposerIconButton. + Content only — the trigger button keeps its pointer/focus behavior + (WCAG content-on-hover-or-focus). disableHoverableContent (on the + Root above) kills Radix's hover-to-persist safe bridge; the + data-composer-tooltip marker lets a scoped globals.css rule reach + the positioned [data-radix-popper-content-wrapper] (pe:auto, + overlaps the trigger) that's the real camp surface — see + ComposerIconButton's doc comment. These formatting buttons are raw + - - Formatting - + onFormattingToggle(!isFormattingOpen)} + onMouseDown={onCaptureSelection} + tooltip="Formatting" + variant={isFormattingOpen ? "default" : "ghost"} + > + + - - - - - Close formatting - + onFormattingToggle(false)} + onMouseDown={onCaptureSelection} + variant="ghost" + className="shrink-0" + tooltip="Close formatting" + > + +
- - - - - Mention someone - - - - - - Attach image - + + + + + + - - - - - Spoiler - + + + - - - - - Formatting - + onFormattingToggle(!isFormattingOpen)} + onMouseDown={onCaptureSelection} + tooltip="Formatting" + variant={isFormattingOpen ? "default" : "ghost"} + > + + )} diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index ff63f3051..9a9eb7da8 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -2229,3 +2229,25 @@ transform: translateX(400%); } } + +/* + * Composer tooltip click-through + non-camp. + * + * Radix renders TooltipContent inside a positioned + * [data-radix-popper-content-wrapper] DIV that it styles directly with + * pointer-events: auto and never exposes to React props. That wrapper is the + * outermost popup surface and it overlaps the trigger, so a real cursor sliding + * off the trigger lands on the wrapper, keeps the tooltip alive (camps on it), + * and can select its text — pointer-events-none on the inner content alone is + * one level too shallow to stop it. + * + * We tag the composer tooltips' content with data-composer-tooltip and reach + * the wrapper via :has(), so pointer-events/select are killed on the actual + * camp surface. Scoped to composer tooltips only — no app-wide tooltip change. + * The trigger keeps its hover/focus-to-show lifecycle (WCAG + * content-on-hover-or-focus); we only make the floating popup inert. + */ +[data-radix-popper-content-wrapper]:has(> [data-composer-tooltip]) { + pointer-events: none; + user-select: none; +} diff --git a/desktop/tests/e2e/screenshot-tooltip-pointer-events.spec.ts b/desktop/tests/e2e/screenshot-tooltip-pointer-events.spec.ts new file mode 100644 index 000000000..abf8f7859 --- /dev/null +++ b/desktop/tests/e2e/screenshot-tooltip-pointer-events.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +test("composer tooltip is visible but click-through (pointer-events:none)", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Hover the "Mention someone" toolbar trigger to surface its tooltip. + const trigger = page.getByTestId("message-insert-mention"); + await trigger.hover(); + + // Radix renders the tooltip content in a portal as role=tooltip. + const tip = page.getByRole("tooltip", { name: "Mention someone" }); + await expect(tip).toBeVisible(); + + // The money check: computed pointer-events on the visible tooltip popup. + const pe = await tip.evaluate((el) => getComputedStyle(el).pointerEvents); + expect(pe).toBe("none"); + + await page.screenshot({ + path: "test-results/tooltip-pe/composer-tooltip-visible.png", + clip: { x: 0, y: 360, width: 900, height: 360 }, + }); + + // Prove click-through: aim the click at the tooltip's own bounding box. + // pointer-events:none means the click should fall through to whatever is + // underneath rather than being swallowed by the popup. + const box = await tip.boundingBox(); + if (!box) throw new Error("no tooltip box"); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + // After clicking through, the editor should be focusable/usable: type and + // confirm the text lands in the ProseMirror editor under the toolbar. + const editor = page.locator(".ProseMirror"); + await editor.click(); + await page.keyboard.type("clickthrough-ok"); + await expect(editor).toContainText("clickthrough-ok"); +}); + +test("formatting sub-toolbar tooltip is visible but click-through (pointer-events:none)", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Open the formatting sub-toolbar (Bold / Italic / lists / Quote …). + await page.getByRole("button", { name: "Toggle formatting" }).first().click(); + + // Hover a formatting icon button to surface its label tooltip. + const bold = page.getByRole("button", { name: "Bold" }); + await expect(bold).toBeVisible(); + await bold.hover(); + + // Tooltip text is "