Show a tooltip when hovering over any DateTime (#2003)

* Adds a tooltip to display the UTC, timezone and local time for DateTime

* Made it safer for SSR

* Makes the copy function into a hook and separate button + adds copy dateTime button to the date tooltip

* Adds an extra-small size copy button

* Show the offset (UTC +1) next to your local time

* Optionally don’t show the date tooltip. Defaults to true (always show)

* Tidy imports

* Refactor the tooltip content to remove duplicated markup

* Coderabbit suggestion to include support for minutes for some timezones like Nepal
This commit is contained in:
James Ritchie
2025-05-01 10:00:42 +01:00
committed by GitHub
parent af019473cd
commit 553a03580f
6 changed files with 247 additions and 118 deletions
@@ -1,8 +1,6 @@
import { CheckIcon } from "@heroicons/react/20/solid";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { CopyButton } from "./CopyButton";
const variants = {
"primary/small": {
@@ -10,61 +8,55 @@ const variants = {
"flex items-center text-text-dimmed font-mono rounded border bg-charcoal-750 text-xs transition hover:bg-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "primary/small" as const,
buttonVariant: "primary" as const,
size: "small" as const,
button: "rounded-l-none",
iconSize: "h-3 w-3",
iconPadding: "pl-1",
},
"secondary/small": {
container:
"flex items-center text-text-dimmed font-mono rounded border bg-charcoal-750 text-xs transition hover:bg-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "tertiary/small" as const,
buttonVariant: "tertiary" as const,
size: "small" as const,
button: "rounded-l-none border-l border-charcoal-750",
iconSize: "h-3 w-3",
iconPadding: "pl-1",
},
"tertiary/small": {
container:
"group/clipboard flex items-center text-text-dimmed font-mono rounded bg-transparent border border-transparent text-xs transition duration-150 hover:border-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent",
buttonVariant: "minimal/small" as const,
buttonVariant: "minimal" as const,
size: "small" as const,
button:
"rounded-l-none border-l border-transparent transition group-hover/clipboard:border-charcoal-700",
iconSize: "h-3 w-3",
iconPadding: "pl-1",
},
"primary/medium": {
container:
"flex items-center text-text-dimmed font-mono rounded border bg-charcoal-750 text-sm transition hover:bg-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "primary/medium" as const,
buttonVariant: "primary" as const,
size: "medium" as const,
button: "rounded-l-none",
iconSize: "h-4 w-4",
iconPadding: "pl-2",
},
"secondary/medium": {
container:
"flex items-center text-text-dimmed font-mono rounded bg-charcoal-750 text-sm transition hover:bg-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "tertiary/medium" as const,
buttonVariant: "tertiary" as const,
size: "medium" as const,
button: "rounded-l-none border-l border-charcoal-750",
iconSize: "h-4 w-4",
iconPadding: "pl-2",
},
"tertiary/medium": {
container:
"group flex items-center text-text-dimmed font-mono rounded bg-transparent border border-transparent text-sm transition hover:border-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent",
input:
"bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent",
buttonVariant: "minimal/medium" as const,
buttonVariant: "minimal" as const,
size: "medium" as const,
button: "rounded-l-none border-l border-transparent transition group-hover:border-charcoal-700",
iconSize: "h-4 w-4",
iconPadding: "pl-2",
},
};
@@ -88,36 +80,19 @@ export function ClipboardField({
fullWidth = true,
}: ClipboardFieldProps) {
const [isSecure, setIsSecure] = useState(secure !== undefined && secure);
const [copied, setCopied] = useState(false);
const copy = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[value]
);
const inputIcon = useRef<HTMLInputElement>(null);
const { container, input, buttonVariant, button, size } = variants[variant];
useEffect(() => {
setIsSecure(secure !== undefined && secure);
}, [secure]);
const { container, input, buttonVariant, button } = variants[variant];
const iconClassName = variants[variant].iconSize;
const iconPosition = variants[variant].iconPadding;
const inputIcon = useRef<HTMLInputElement>(null);
return (
<span className={cn(container, fullWidth ? "w-full" : "max-w-fit", className)}>
{icon && (
<span
onClick={() => inputIcon.current && inputIcon.current.focus()}
className={cn(iconPosition, "flex items-center")}
className="flex items-center pl-1"
>
{icon}
</span>
@@ -132,51 +107,26 @@ export function ClipboardField({
fullWidth ? "w-full" : "max-w-fit",
input
)}
// size={value.length}
// maxLength={3}
onFocus={(e) => {
if (secure) {
setIsSecure((i) => false);
setIsSecure(false);
}
e.currentTarget.select();
}}
onBlur={() => {
if (secure) {
setIsSecure((i) => true);
setIsSecure(true);
}
}}
/>
{iconButton ? (
<Button
variant={buttonVariant}
onClick={copy}
className={cn("shrink grow-0 px-1.5", button)}
>
{copied ? (
<ClipboardCheckIcon
className={cn(
"h-4 w-4",
buttonVariant === "primary/small" || buttonVariant === "primary/medium"
? "text-background-dimmed"
: "text-green-500"
)}
/>
) : (
<ClipboardIcon
className={cn(
"h-4 w-4",
buttonVariant === "primary/small" || buttonVariant === "primary/medium"
? "text-background-dimmed"
: "text-text-dimmed"
)}
/>
)}
</Button>
) : (
<Button variant={buttonVariant} onClick={copy} className={cn("shrink-0 grow-0", button)}>
{copied ? <CheckIcon className="mx-[0.4rem] h-4 w-4 text-green-500" /> : "Copy"}
</Button>
)}
<CopyButton
value={value}
variant={iconButton ? "icon" : "button"}
buttonVariant={buttonVariant}
size={size}
buttonClassName={button}
showTooltip={false}
/>
</span>
);
}
@@ -0,0 +1,100 @@
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
import { SimpleTooltip } from "./Tooltip";
const sizes = {
"extra-small": {
icon: "size-3",
button: "h-5 px-1",
},
small: {
icon: "size-3.5",
button: "h-6 px-1",
},
medium: {
icon: "size-4",
button: "h-8 px-1.5",
},
};
type CopyButtonProps = {
value: string;
variant?: "icon" | "button";
size?: keyof typeof sizes;
className?: string;
buttonClassName?: string;
showTooltip?: boolean;
buttonVariant?: "primary" | "secondary" | "tertiary" | "minimal";
};
export function CopyButton({
value,
variant = "button",
size = "medium",
className,
buttonClassName,
showTooltip = true,
buttonVariant = "tertiary",
}: CopyButtonProps) {
const { copy, copied } = useCopy(value);
const { icon: iconSize, button: buttonSize } = sizes[size];
const button =
variant === "icon" ? (
<span
onClick={copy}
className={cn(
buttonSize,
"flex items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright",
buttonClassName
)}
>
{copied ? (
<ClipboardCheckIcon className={iconSize} />
) : (
<ClipboardIcon className={iconSize} />
)}
</span>
) : (
<Button
variant={`${buttonVariant}/${size === "extra-small" ? "small" : size}`}
onClick={copy}
className={cn("shrink-0", buttonClassName)}
>
{copied ? (
<ClipboardCheckIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
)}
/>
) : (
<ClipboardIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
)}
/>
)}
</Button>
);
if (!showTooltip) return <span className={className}>{button}</span>;
return (
<span className={className}>
<SimpleTooltip
button={button}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
/>
</span>
);
}
@@ -1,24 +1,12 @@
import { useCallback, useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
export function CopyableText({ value, className }: { value: string; className?: string }) {
const [isHovered, setIsHovered] = useState(false);
const [copied, setCopied] = useState(false);
const copy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[value]
);
const { copy, copied } = useCopy(value);
return (
<span
@@ -1,5 +1,10 @@
import { Fragment, useEffect, useState } from "react";
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
import { Laptop } from "lucide-react";
import { Fragment, type ReactNode, useEffect, useState } from "react";
import { CopyButton } from "./CopyButton";
import { useLocales } from "./LocaleProvider";
import { Paragraph } from "./Paragraph";
import { SimpleTooltip } from "./Tooltip";
type DateTimeProps = {
date: Date | string;
@@ -7,6 +12,7 @@ type DateTimeProps = {
includeSeconds?: boolean;
includeTime?: boolean;
showTimezone?: boolean;
showTooltip?: boolean;
previousDate?: Date | string | null; // Add optional previous date for comparison
};
@@ -16,41 +22,61 @@ export const DateTime = ({
includeSeconds = true,
includeTime = true,
showTimezone = false,
showTooltip = true,
}: DateTimeProps) => {
const locales = useLocales();
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
const realDate = typeof date === "string" ? new Date(date) : date;
const initialFormattedDateTime = formatDateTime(
realDate,
timeZone ?? "UTC",
locales,
includeSeconds,
includeTime
);
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
useEffect(() => {
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setLocalTimeZone(resolvedOptions.timeZone);
}, []);
setFormattedDateTime(
formatDateTime(
const tooltipContent = (
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-2.5 pb-1">
{timeZone && timeZone !== "UTC" && (
<DateTimeTooltipContent
title={timeZone}
dateTime={formatDateTime(realDate, timeZone, locales, true, true)}
isoDateTime={formatDateTimeISO(realDate, timeZone)}
icon={<GlobeAmericasIcon className="size-4 text-purple-500" />}
/>
)}
<DateTimeTooltipContent
title="UTC"
dateTime={formatDateTime(realDate, "UTC", locales, true, true)}
isoDateTime={formatDateTimeISO(realDate, "UTC")}
icon={<GlobeAltIcon className="size-4 text-blue-500" />}
/>
<DateTimeTooltipContent
title="Local"
dateTime={formatDateTime(realDate, localTimeZone, locales, true, true)}
isoDateTime={formatDateTimeISO(realDate, localTimeZone)}
icon={<Laptop className="size-4 text-green-500" />}
/>
</div>
</div>
);
const formattedDateTime = (
<Fragment>
{formatDateTime(
realDate,
timeZone ?? resolvedOptions.timeZone,
timeZone ?? localTimeZone,
locales,
includeSeconds,
includeTime
)
);
}, [locales, includeSeconds, realDate]);
return (
<Fragment>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
).replace(/\s/g, String.fromCharCode(32))}
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
</Fragment>
);
if (!showTooltip) return formattedDateTime;
return <SimpleTooltip button={formattedDateTime} content={tooltipContent} side="right" />;
};
export function formatDateTime(
@@ -71,6 +97,10 @@ export function formatDateTime(
}).format(date);
}
export function formatDateTimeISO(date: Date, timeZone: string): string {
return new Date(date.toLocaleString("en-US", { timeZone })).toISOString();
}
// New component that only shows date when it changes
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => {
const locales = useLocales();
@@ -226,3 +256,42 @@ function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): s
return formattedDateTime;
}
type DateTimeTooltipContentProps = {
title: string;
dateTime: string;
isoDateTime: string;
icon: ReactNode;
};
function DateTimeTooltipContent({
title,
dateTime,
isoDateTime,
icon,
}: DateTimeTooltipContentProps) {
const getUtcOffset = () => {
if (title !== "Local") return "";
const offset = -new Date().getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const hours = Math.abs(Math.floor(offset / 60));
const minutes = Math.abs(offset % 60);
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
};
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1 text-sm">
{icon}
<span className="font-medium">{title}</span>
<span className="font-normal text-text-dimmed">{getUtcOffset()}</span>
</div>
<div className="flex items-center justify-between gap-2">
<Paragraph variant="extra-small" className="text-text-dimmed">
{dateTime}
</Paragraph>
<CopyButton value={isoDateTime} variant="icon" size="extra-small" showTooltip={false} />
</div>
</div>
);
}
@@ -1,7 +1,7 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/utils/cn";
import { InformationCircleIcon } from "@heroicons/react/20/solid";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "~/utils/cn";
const variantClasses = {
basic:
@@ -115,4 +115,4 @@ export function InfoIconTooltip({
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow, SimpleTooltip };
export { SimpleTooltip, Tooltip, TooltipArrow, TooltipContent, TooltipProvider, TooltipTrigger };
+22
View File
@@ -0,0 +1,22 @@
import { useCallback, useState } from "react";
export function useCopy(value: string, duration = 1500) {
const [copied, setCopied] = useState(false);
const copy = useCallback(
(e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, duration);
},
[value, duration]
);
return { copy, copied };
}