Files
Tom Moor 054404d716 fix: Missing + on shared doc shortcut display (#12013)
* fix: Missing + on shared doc shortcut display

* fix: Show "+" between shortcut keys on Windows

Add shared `shortcutSeparator` constant and use it across all shortcut
renderers so Windows displays "Ctrl+K" instead of "CtrlK".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:04:18 -04:00

293 lines
8.0 KiB
TypeScript

import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { transparentize } from "polished";
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { s, depths } from "@shared/styles";
import { shortcutSeparator } from "@shared/utils/keyboard";
import useMobile from "~/hooks/useMobile";
import { useTooltipContext } from "./TooltipContext";
export type Props = {
/** The content to display in the tooltip. */
content?: React.ReactChild | React.ReactChild[];
/** A keyboard shortcut to display next to the content */
shortcut?: React.ReactNode;
/** Whether to show the shortcut on a new line */
shortcutOnNewline?: boolean;
/** The preferred side of the trigger to render against when open */
side?: "top" | "right" | "bottom" | "left";
/** The distance in pixels from the trigger */
sideOffset?: number;
/** The preferred alignment against the trigger */
align?: "start" | "center" | "end";
/** An offset in pixels from the "start" or "end" alignment options */
alignOffset?: number;
/** When true, overrides the side and align preferences to prevent collisions with boundary edges */
avoidCollisions?: boolean;
/** The element used as the collision boundary */
collisionBoundary?: Element | null | Array<Element | null>;
/** The distance in pixels from the boundary edges where collision detection should occur */
collisionPadding?:
| number
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
/** Whether the tooltip should be open by default */
defaultOpen?: boolean;
/** The controlled open state of the tooltip */
open?: boolean;
/** Event handler called when the open state of the tooltip changes */
onOpenChange?: (open: boolean) => void;
/** The duration from when the mouse enters the trigger until the tooltip gets opened */
delayDuration?: number;
/** How much time a user has to enter another trigger without incurring a delay again */
skipDelayDuration?: number;
/** Prevents the tooltip from opening */
disableHoverableContent?: boolean;
/** The children that will trigger the tooltip */
children?: React.ReactNode;
/** Whether to disable the tooltip entirely */
disabled?: boolean;
/** Custom offset for the tooltip */
offset?: [number, number];
/** Placement prop for backward compatibility with Tippy */
placement?:
| "top"
| "right"
| "bottom"
| "left"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end";
/** Delay prop for backward compatibility with Tippy */
delay?: number | [number, number];
};
/**
* Tooltip component using Radix UI primitives.
* Displays a tooltip with optional keyboard shortcut.
* Optionally displays a keyboard shortcut next to the content.
*
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
* provider instance (delay, animation, etc).
*/
function Tooltip({
shortcut,
shortcutOnNewline,
content: tooltip,
side = "top",
sideOffset = 8,
align = "center",
alignOffset = 0,
avoidCollisions = true,
collisionBoundary,
collisionPadding = 8,
defaultOpen,
open,
onOpenChange,
delayDuration = 500,
skipDelayDuration = 300,
disableHoverableContent = false,
children,
disabled = false,
offset,
placement,
delay,
...rest
}: Props): React.ReactElement | null {
const isMobile = useMobile();
const isInProvider = useTooltipContext();
// Handle backward compatibility with Tippy props
let finalSide = side;
let finalAlign = align;
let finalDelayDuration = delayDuration;
let finalSideOffset = sideOffset;
// Convert placement prop to side/align for backward compatibility
if (placement) {
const [placementSide, placementAlign] = placement.split("-");
finalSide = placementSide as "top" | "right" | "bottom" | "left";
if (placementAlign) {
finalAlign = placementAlign as "start" | "center" | "end";
}
}
// Handle delay prop for backward compatibility
if (delay !== undefined) {
if (typeof delay === "number") {
finalDelayDuration = delay;
} else if (Array.isArray(delay)) {
finalDelayDuration = delay[0];
}
}
// Handle offset prop for backward compatibility
if (offset) {
finalSideOffset = offset[1] || sideOffset;
}
let content = <>{tooltip}</>;
if (!tooltip || isMobile || disabled) {
return (children as React.ReactElement) ?? null;
}
if (shortcut) {
content = (
<>
{tooltip}
{shortcutOnNewline ? <br /> : " "}
{typeof shortcut === "string" ? (
shortcut.split("+").flatMap((key, i, arr) => {
const el = (
<Shortcut key={`${key}${i}`}>
{key.length === 1 ? key.toUpperCase() : key}
</Shortcut>
);
return i < arr.length - 1 && shortcutSeparator
? [el, shortcutSeparator]
: [el];
})
) : (
<Shortcut>{shortcut}</Shortcut>
)}
</>
);
}
const tooltipContent = (
<TooltipPrimitive.Root
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
delayDuration={isInProvider ? undefined : finalDelayDuration}
disableHoverableContent={disableHoverableContent}
>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<StyledContent
side={finalSide}
sideOffset={finalSideOffset}
align={finalAlign}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
{...rest}
>
{content}
</StyledContent>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
// If we're already in a provider, don't wrap with another one
if (isInProvider) {
return tooltipContent;
}
// Otherwise, wrap with a provider for standalone usage
return (
<TooltipPrimitive.Provider
delayDuration={finalDelayDuration}
skipDelayDuration={skipDelayDuration}
>
{tooltipContent}
</TooltipPrimitive.Provider>
);
}
const slideUpAndFade = keyframes`
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const slideRightAndFade = keyframes`
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
`;
const slideDownAndFade = keyframes`
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const slideLeftAndFade = keyframes`
from {
opacity: 0;
transform: translateX(8px);
}
to {
opacity: 1;
transform: translateX(0);
}
`;
const Shortcut = styled.kbd`
position: relative;
top: -1px;
margin-left: 2px;
display: inline-block;
padding: 2px 4px;
font-size: 12px;
font-family: ${s("fontFamilyMono")};
line-height: 10px;
color: ${s("tooltipText")};
border: 1px solid ${(props) => transparentize(0.75, props.theme.tooltipText)};
vertical-align: middle;
border-radius: 3px;
`;
const StyledContent = styled(TooltipPrimitive.Content)`
position: relative;
background-color: ${s("tooltipBackground")};
color: ${s("tooltipText")};
border-radius: 4px;
font-size: 13px;
line-height: 1.4;
white-space: normal;
outline: 0;
padding: 5px 9px;
z-index: ${depths.tooltip};
max-width: calc(100vw - 10px);
/* Animation */
&[data-state="delayed-open"][data-side="top"] {
animation: ${slideUpAndFade} 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-state="delayed-open"][data-side="right"] {
animation: ${slideLeftAndFade} 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-state="delayed-open"][data-side="bottom"] {
animation: ${slideDownAndFade} 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-state="delayed-open"][data-side="left"] {
animation: ${slideRightAndFade} 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
`;
export default Tooltip;