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:
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user