diff --git a/package-lock.json b/package-lock.json
index 2427b3a4..99b9c063 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -81,6 +81,7 @@
"watch": "^1.0.2"
},
"peerDependencies": {
+ "dayjs": "^1.11.13",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"styled-components": ">= 5"
@@ -9927,6 +9928,12 @@
"url": "https://github.com/sponsors/kossnocorp"
}
},
+ "node_modules/dayjs": {
+ "version": "1.11.13",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
+ "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+ "peer": true
+ },
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
diff --git a/package.json b/package.json
index 02893d5e..6e33d09a 100644
--- a/package.json
+++ b/package.json
@@ -115,6 +115,7 @@
"watch": "^1.0.2"
},
"peerDependencies": {
+ "dayjs": "^1.11.13",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"styled-components": ">= 5"
diff --git a/src/components/DateDetails/DateDetails.stories.tsx b/src/components/DateDetails/DateDetails.stories.tsx
new file mode 100644
index 00000000..5df2549b
--- /dev/null
+++ b/src/components/DateDetails/DateDetails.stories.tsx
@@ -0,0 +1,54 @@
+import { Args } from "@storybook/react";
+import { DateDetails } from "./DateDetails";
+
+export default {
+ argTypes: {
+ side: {
+ control: {
+ type: "select",
+ },
+ options: ["top", "right", "left", "bottom"],
+ },
+ date: {
+ control: "date",
+ },
+ systemTimeZone: {
+ options: [
+ "America/Denver",
+ "America/Los_Angeles",
+ "America/New_York",
+ "Asia/Shanghai",
+ "Asia/Tokyo",
+ "Europe/London",
+ "Europe/Berlin",
+ "Europe/Moscow",
+ "Europe/Rome",
+ ],
+ control: {
+ type: "select",
+ },
+ },
+ },
+ component: DateDetails,
+ title: "Display/DateDetails",
+ tags: ["autodocs"],
+};
+
+export const Playground = {
+ args: {
+ date: new Date(),
+ side: "top",
+ systemTimeZone: "America/Los_Angeles",
+ title: "DateDetails",
+ },
+ render: (args: Args) => {
+ const date = args.date ? new Date(args.date) : new Date();
+ return (
+
+ );
+ },
+};
diff --git a/src/components/DateDetails/DateDetails.test.tsx b/src/components/DateDetails/DateDetails.test.tsx
new file mode 100644
index 00000000..8c337c47
--- /dev/null
+++ b/src/components/DateDetails/DateDetails.test.tsx
@@ -0,0 +1,115 @@
+import { DateDetails } from "@/components/DateDetails/DateDetails";
+import { renderCUI } from "@/utils/test-utils";
+import { fireEvent } from "@testing-library/dom";
+
+describe("DateDetails", () => {
+ const actualTZ = process.env.TZ;
+
+ beforeAll(() => {
+ global.ResizeObserver = vi.fn(() => {
+ return {
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+ };
+ });
+
+ process.env.TZ = "America/New_York";
+ });
+
+ afterAll(() => {
+ process.env.TZ = actualTZ;
+ });
+
+ it("renders the DateDetails component with relevant timezone information", () => {
+ const baseDate = new Date("2024-12-24 11:45:00 AM");
+ const systemTimeZone = "America/Los_Angeles";
+ vi.setSystemTime(baseDate);
+
+ const fiveMinutesAgo = new Date("2024-12-24 11:40:00 AM");
+
+ const { getByText } = renderCUI(
+
+ );
+
+ const trigger = getByText("5 minutes ago");
+ expect(trigger).toBeInTheDocument();
+
+ fireEvent.click(trigger);
+ expect(
+ getByText(content => {
+ return content.includes("EST");
+ })
+ ).toBeInTheDocument();
+ expect(
+ getByText(content => {
+ return content.includes("PST");
+ })
+ ).toBeInTheDocument();
+ expect(getByText("Dec 24, 4:40 p.m.")).toBeInTheDocument();
+ expect(getByText("Dec 24, 11:40 a.m. (EST)")).toBeInTheDocument();
+ expect(getByText("Dec 24, 8:40 a.m. (PST)")).toBeInTheDocument();
+ expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument();
+ });
+
+ it("only shows the date if the previous date isn't in this year", () => {
+ const baseDate = new Date("2025-02-07 11:45:00 AM");
+ const systemTimeZone = "America/Los_Angeles";
+ vi.setSystemTime(baseDate);
+
+ const oneYearAgo = new Date("2024-02-07 11:45:00 AM");
+
+ const { getByText } = renderCUI(
+
+ );
+
+ const trigger = getByText("1 year ago");
+ expect(trigger).toBeInTheDocument();
+
+ fireEvent.click(trigger);
+ expect(getByText("Feb 7, 2024, 4:45 p.m.")).toBeInTheDocument();
+ expect(getByText("Feb 7, 2024, 11:45 a.m. (EST)")).toBeInTheDocument();
+ expect(getByText("Feb 7, 2024, 8:45 a.m. (PST)")).toBeInTheDocument();
+ expect(getByText(oneYearAgo.getTime() / 1000)).toBeInTheDocument();
+ });
+
+ it("handles Daylight Savings Time", () => {
+ const baseDate = new Date("2024-07-04 11:45:00 AM");
+ const systemTimeZone = "America/Los_Angeles";
+ vi.setSystemTime(baseDate);
+
+ const fiveMinutesAgo = new Date("2024-07-04 11:40:00 AM");
+
+ const { getByText } = renderCUI(
+
+ );
+
+ const trigger = getByText("5 minutes ago");
+ expect(trigger).toBeInTheDocument();
+
+ fireEvent.click(trigger);
+ expect(
+ getByText(content => {
+ return content.includes("EDT");
+ })
+ ).toBeInTheDocument();
+ expect(
+ getByText(content => {
+ return content.includes("PDT");
+ })
+ ).toBeInTheDocument();
+ expect(getByText("Jul 4, 3:40 p.m.")).toBeInTheDocument();
+ expect(getByText("Jul 4, 11:40 a.m. (EDT)")).toBeInTheDocument();
+ expect(getByText("Jul 4, 8:40 a.m. (PDT)")).toBeInTheDocument();
+ expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/DateDetails/DateDetails.tsx b/src/components/DateDetails/DateDetails.tsx
new file mode 100644
index 00000000..a00736ab
--- /dev/null
+++ b/src/components/DateDetails/DateDetails.tsx
@@ -0,0 +1,199 @@
+import dayjs, { Dayjs } from "dayjs";
+import advancedFormat from "dayjs/plugin/advancedFormat";
+import duration from "dayjs/plugin/duration";
+import localizedFormat from "dayjs/plugin/localizedFormat";
+import relativeTime from "dayjs/plugin/relativeTime";
+import timezone from "dayjs/plugin/timezone";
+import updateLocale from "dayjs/plugin/updateLocale";
+import utc from "dayjs/plugin/utc";
+import { styled } from "styled-components";
+
+import { Popover } from "@/components/Popover/Popover";
+import { Text } from "@/components/Typography/Text/Text";
+import { linkStyles, StyledLinkProps } from "@/components/Link/common";
+import { GridContainer } from "@/components/GridContainer/GridContainer";
+import { Container } from "@/components/Container/Container";
+
+dayjs.extend(advancedFormat);
+dayjs.extend(duration);
+dayjs.extend(localizedFormat);
+dayjs.extend(updateLocale);
+dayjs.extend(utc);
+
+const thresholds = [
+ { l: "s", r: 1, d: "second" },
+ { l: "ss", r: 56, d: "second" },
+ { l: "m", r: 90, d: "second" },
+ { l: "mm", r: 55, d: "minute" },
+ { l: "h", r: 90, d: "minute" },
+ { l: "hh", r: 22, d: "hour" },
+ { l: "d", r: 40, d: "hour" },
+ { l: "dd", r: 31, d: "day" },
+ { l: "M", r: 45, d: "day" },
+ { l: "MM", r: 11, d: "month" },
+ { l: "y", r: 17, d: "month" },
+ { l: "yy", r: 2, d: "year" },
+];
+
+dayjs.extend(relativeTime, { thresholds });
+
+dayjs.updateLocale("en", {
+ relativeTime: {
+ future: "In %s",
+ past: "%s ago",
+ s: "a few seconds",
+ ss: "%d seconds",
+ m: "1 minute",
+ mm: "%d minutes",
+ h: "1 hour",
+ hh: "%d hours",
+ d: "1 day",
+ dd: "%d days",
+ w: "1 week",
+ ww: "%d weeks",
+ M: "1 month",
+ MM: "%d months",
+ y: "1 year",
+ yy: "%d years",
+ },
+});
+
+const UnderlinedTrigger = styled(Popover.Trigger)`
+ ${linkStyles}
+`;
+
+const formatDateDetails = (date: Dayjs, timezone?: string): string => {
+ const isCurrentYear = dayjs().year() === date.year();
+ const formatForCurrentYear = "MMM D, h:mm a";
+ const formatForPastYear = "MMM D, YYYY, h:mm a";
+
+ if (isCurrentYear) {
+ if (timezone) {
+ const dateWithTimezone = date.tz(timezone);
+ return dateWithTimezone
+ .format(formatForCurrentYear)
+ .replace("am", "a.m.")
+ .replace("pm", "p.m.");
+ }
+
+ return date.format(formatForCurrentYear).replace("am", "a.m.").replace("pm", "p.m.");
+ }
+
+ if (timezone) {
+ const dateWithTimezone = date.tz(timezone);
+ return dateWithTimezone
+ .format(formatForPastYear)
+ .replace("am", "a.m.")
+ .replace("pm", "p.m.");
+ }
+ return date.format(formatForPastYear).replace("am", "a.m.").replace("pm", "p.m.");
+};
+
+const formatTimezone = (date: Dayjs, timezone: string): string => {
+ return (
+ new Intl.DateTimeFormat(undefined, {
+ timeZone: timezone,
+ timeZoneName: "short",
+ })
+ .formatToParts(date.toDate())
+ .find(part => part.type === "timeZoneName")?.value ?? date.format("z")
+ );
+};
+
+export type ArrowPosition = "top" | "right" | "left" | "bottom";
+
+export interface DateDetailsProps {
+ date: Date;
+ side?: ArrowPosition;
+ systemTimeZone?: string;
+}
+
+export const DateDetails = ({
+ date,
+ side = "top",
+ systemTimeZone = "America/New_York",
+}: DateDetailsProps) => {
+ const dayjsDate = dayjs(date);
+
+ let systemTime;
+ if (systemTimeZone) {
+ dayjs.extend(timezone);
+ try {
+ systemTime = dayjsDate.tz(systemTimeZone);
+ } catch {
+ systemTime = dayjsDate.tz("America/New_York");
+ }
+ }
+
+ return (
+
+
+ {dayjs.utc(date).fromNow()}
+
+
+
+
+ Local
+
+
+
+ {formatDateDetails(dayjsDate)} (
+ {formatTimezone(dayjsDate, dayjs.tz.guess())})
+
+
+
+ {systemTime && (
+ <>
+
+ System
+
+
+
+
+ {formatDateDetails(systemTime, systemTimeZone)} (
+ {formatTimezone(systemTime, systemTimeZone)})
+
+
+ >
+ )}
+
+
+ UTC
+
+
+ {formatDateDetails(dayjsDate.utc(), "UTC")}
+
+
+
+ Unix
+
+
+ {Math.round(date.getTime() / 1000)}
+
+
+
+
+ );
+};
diff --git a/src/components/Popover/Popover.tsx b/src/components/Popover/Popover.tsx
index 71d2a0c1..68887a7f 100644
--- a/src/components/Popover/Popover.tsx
+++ b/src/components/Popover/Popover.tsx
@@ -11,11 +11,12 @@ export const Popover = ({ children, ...props }: RadixPopover.PopoverProps) => {
};
const Trigger = styled(RadixPopover.Trigger)`
- width: fit-content;
- font: inherit;
- color: inherit;
background: inherit;
border: none;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ width: fit-content;
`;
interface TriggerProps extends RadixPopover.PopoverTriggerProps {
anchor?: ReactNode;
diff --git a/src/components/index.ts b/src/components/index.ts
index 84933dbe..6f4ecf98 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -14,22 +14,23 @@ export { Badge } from "./Badge/Badge";
export { BigStat } from "./BigStat/BigStat";
export { ButtonGroup } from "./ButtonGroup/ButtonGroup";
export { Button } from "./Button/Button";
-export { CardSecondary } from "./CardSecondary/CardSecondary";
-export { CardPrimary } from "./CardPrimary/CardPrimary";
export { CardHorizontal } from "./CardHorizontal/CardHorizontal";
+export { CardPrimary } from "./CardPrimary/CardPrimary";
export { CardPromotion } from "./CardPromotion/CardPromotion";
+export { CardSecondary } from "./CardSecondary/CardSecondary";
export { Checkbox } from "./Checkbox/Checkbox";
export { CodeBlock } from "./CodeBlock/CodeBlock";
+export { ConfirmationDialog } from "./ConfirmationDialog/ConfirmationDialog";
+export { ContextMenu } from "./ContextMenu/ContextMenu";
export { Container } from "./Container/Container";
+export { DateDetails } from "@/components/DateDetails/DateDetails";
export { DatePicker } from "./DatePicker/DatePicker";
export { Dialog } from "./Dialog/Dialog";
-export { ConfirmationDialog } from "./ConfirmationDialog/ConfirmationDialog";
export { EllipsisContent } from "./EllipsisContent/EllipsisContent";
export { Flyout } from "./Flyout/Flyout";
export { FormContainer } from "./FormContainer/FormContainer";
export { GridContainer } from "./GridContainer/GridContainer";
export { InlineCodeBlock } from "./CodeBlock/InlineCodeBlock";
-export { ContextMenu } from "./ContextMenu/ContextMenu";
export { default as Flags } from "./icons/Flags";
export { Grid } from "./Grid/Grid";
export { HoverCard } from "./HoverCard/HoverCard";
diff --git a/vite.config.ts b/vite.config.ts
index 938ae4de..6a3426d6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -40,6 +40,7 @@ export default defineConfig({
rollupOptions: {
// Add _all_ external dependencies here
external: [
+ "dayjs",
"react",
"react-dom",
"styled-components",
@@ -51,6 +52,7 @@ export default defineConfig({
],
output: {
globals: {
+ dayjs: "dayjs",
react: "React",
"styled-components": "styled",
"react-dom": "ReactDOM",