Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions desktop/src/features/messages/ui/ComposerIconButton.tsx
Original file line number Diff line number Diff line change
@@ -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,
) => (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button ref={ref} size={size} type={type} {...props} />
</TooltipTrigger>
<TooltipContent
data-composer-tooltip
className={cn("pointer-events-none select-none", tooltipClassName)}
>
{tooltip}
</TooltipContent>
</Tooltip>
),
);
ComposerIconButton.displayName = "ComposerIconButton";

export { ComposerIconButton };
19 changes: 17 additions & 2 deletions desktop/src/features/messages/ui/FormattingToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
return (
<div className="flex items-center gap-0.5">
{items.map((item) => (
<Tooltip key={item.label}>
<Tooltip key={item.label} disableHoverableContent>
<TooltipTrigger asChild>
<button
type="button"
Expand All @@ -291,7 +291,22 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
<item.icon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{/* 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
<button>s with custom active styling, so we apply the overrides
here rather than swap in ComposerIconButton. */}
<TooltipContent
data-composer-tooltip
className="pointer-events-none select-none"
>
{"shortcut" in item
? `${item.label} (${item.shortcut})`
: item.label}
Expand Down
178 changes: 71 additions & 107 deletions desktop/src/features/messages/ui/MessageComposerToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import {
X,
} from "lucide-react";

import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { Button } from "@/shared/ui/button";
import { ComposerIconButton } from "./ComposerIconButton";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
import {
FormattingToolbar,
Expand Down Expand Up @@ -133,23 +133,17 @@ export const MessageComposerToolbar = React.memo(
exit={{ x: 8, opacity: 0 }}
transition={presenceSpring}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Toggle formatting"
aria-pressed={isFormattingOpen}
disabled={composerDisabled}
onClick={() => onFormattingToggle(!isFormattingOpen)}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant={isFormattingOpen ? "default" : "ghost"}
>
<ALargeSmall />
</Button>
</TooltipTrigger>
<TooltipContent>Formatting</TooltipContent>
</Tooltip>
<ComposerIconButton
aria-label="Toggle formatting"
aria-pressed={isFormattingOpen}
disabled={composerDisabled}
onClick={() => onFormattingToggle(!isFormattingOpen)}
onMouseDown={onCaptureSelection}
tooltip="Formatting"
variant={isFormattingOpen ? "default" : "ghost"}
>
<ALargeSmall />
</ComposerIconButton>
</motion.div>
<motion.div
className="flex items-center gap-1"
Expand All @@ -158,23 +152,17 @@ export const MessageComposerToolbar = React.memo(
exit={{ opacity: 0, scale: 0.95 }}
transition={{ ...presenceSpring, delay: 0.15 }}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Close formatting"
disabled={composerDisabled}
onClick={() => onFormattingToggle(false)}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant="ghost"
className="shrink-0"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>Close formatting</TooltipContent>
</Tooltip>
<ComposerIconButton
aria-label="Close formatting"
disabled={composerDisabled}
onClick={() => onFormattingToggle(false)}
onMouseDown={onCaptureSelection}
variant="ghost"
className="shrink-0"
tooltip="Close formatting"
>
<X />
</ComposerIconButton>
<div className="mx-1 h-5 w-px shrink-0 bg-border/60" />
</motion.div>
<motion.div
Expand Down Expand Up @@ -203,90 +191,66 @@ export const MessageComposerToolbar = React.memo(
exit={{ opacity: 0, x: -12 }}
transition={presenceSpring}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Mention someone"
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant="ghost"
>
<AtSign />
</Button>
</TooltipTrigger>
<TooltipContent>Mention someone</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Attach image"
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant="ghost"
>
<Paperclip />
</Button>
</TooltipTrigger>
<TooltipContent>Attach image</TooltipContent>
</Tooltip>
<ComposerIconButton
aria-label="Mention someone"
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
variant="ghost"
tooltip="Mention someone"
>
<AtSign />
</ComposerIconButton>
<ComposerIconButton
aria-label="Attach image"
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
onMouseDown={onCaptureSelection}
variant="ghost"
tooltip="Attach image"
>
<Paperclip />
</ComposerIconButton>
<ComposerEmojiPicker
disabled={composerDisabled}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}
onTriggerMouseDown={onCaptureSelection}
open={isEmojiPickerOpen}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Spoiler"
aria-pressed={isSpoilerActive}
className={cn(
isSpoilerActive &&
"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
)}
disabled={composerDisabled || !editor || isUploading}
onClick={handleSpoilerClick}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant={isSpoilerActive ? "default" : "ghost"}
>
<HatGlasses />
</Button>
</TooltipTrigger>
<TooltipContent>Spoiler</TooltipContent>
</Tooltip>
<ComposerIconButton
aria-label="Spoiler"
aria-pressed={isSpoilerActive}
className={cn(
isSpoilerActive &&
"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
)}
disabled={composerDisabled || !editor || isUploading}
onClick={handleSpoilerClick}
onMouseDown={onCaptureSelection}
variant={isSpoilerActive ? "default" : "ghost"}
tooltip="Spoiler"
>
<HatGlasses />
</ComposerIconButton>
<motion.div
initial={{ x: -8, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -8, opacity: 0 }}
transition={presenceSpring}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Toggle formatting"
aria-pressed={isFormattingOpen}
disabled={composerDisabled}
onClick={() => onFormattingToggle(!isFormattingOpen)}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant={isFormattingOpen ? "default" : "ghost"}
>
<ALargeSmall />
</Button>
</TooltipTrigger>
<TooltipContent>Formatting</TooltipContent>
</Tooltip>
<ComposerIconButton
aria-label="Toggle formatting"
aria-pressed={isFormattingOpen}
disabled={composerDisabled}
onClick={() => onFormattingToggle(!isFormattingOpen)}
onMouseDown={onCaptureSelection}
tooltip="Formatting"
variant={isFormattingOpen ? "default" : "ghost"}
>
<ALargeSmall />
</ComposerIconButton>
</motion.div>
</motion.div>
)}
Expand Down
22 changes: 22 additions & 0 deletions desktop/src/shared/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading