Skip to content

Commit 92ca1c0

Browse files
Feat/timestamp component (#97)
Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
1 parent 5f3dabf commit 92ca1c0

6 files changed

Lines changed: 503 additions & 1 deletion

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { PanelLabel, FormatDate, Card, CardContent } from "@eqtylab/equality";
2+
import { useMemo } from "react";
3+
4+
const MINUTE = 60 * 1000;
5+
const HOUR = 60 * MINUTE;
6+
const DAY = 24 * HOUR;
7+
const WEEK = 7 * DAY;
8+
9+
export function FormatDateRelativeDemo() {
10+
// Compute offsets from "now" once on mount so the examples read naturally.
11+
const samples = useMemo(() => {
12+
const now = new Date();
13+
const time = now.getTime();
14+
return [
15+
{ label: "Seconds ago", date: new Date(time - 10 * 1000) },
16+
{ label: "Minutes ago", date: new Date(time - 5 * MINUTE) },
17+
{ label: "Hours ago", date: new Date(time - 3 * HOUR) },
18+
{ label: "Days ago", date: new Date(time - 2 * DAY) },
19+
{ label: "Weeks ago", date: new Date(time - 2 * WEEK) },
20+
{ label: "In the future", date: new Date(time + 4 * DAY) },
21+
];
22+
}, []);
23+
24+
return (
25+
<div className="my-4">
26+
<Card>
27+
<CardContent>
28+
{samples.map((sample) => (
29+
<div key={sample.label} className="flex items-center gap-3">
30+
<PanelLabel label={sample.label} className="w-28" />
31+
<FormatDate date={sample.date} displayAs="relative" />
32+
</div>
33+
))}
34+
</CardContent>
35+
</Card>
36+
</div>
37+
);
38+
}
39+
40+
export function FormatDateTimeZoneDemo() {
41+
const date = useMemo(() => new Date("2026-06-09T18:42:03Z"), []);
42+
43+
return (
44+
<div className="my-4">
45+
<Card>
46+
<CardContent className="divide-border divide-y divide-solid">
47+
<div className="flex items-center gap-3 py-1">
48+
<PanelLabel label="UTC (default)" className="w-28" />
49+
<FormatDate date={date} displayAs="absolute" />
50+
</div>
51+
<div className="flex items-center gap-3 py-1">
52+
<PanelLabel label="New York" className="w-28" />
53+
<FormatDate
54+
date={date}
55+
displayAs="absolute"
56+
timeZone="America/New_York"
57+
/>
58+
</div>
59+
<div className="flex items-center gap-3 py-1">
60+
<PanelLabel label="Tokyo" className="w-28" />
61+
<FormatDate
62+
date={date}
63+
displayAs="absolute"
64+
timeZone="Asia/Tokyo"
65+
/>
66+
</div>
67+
<div className="flex items-center gap-3 py-1">
68+
<PanelLabel label="Auckland" className="w-28" />
69+
<FormatDate
70+
date={date}
71+
displayAs="absolute"
72+
timeZone="Pacific/Auckland"
73+
/>
74+
</div>
75+
<div className="flex items-center gap-3 py-1">
76+
<PanelLabel label="Toronto" className="w-28" />
77+
<FormatDate
78+
date={date}
79+
displayAs="absolute"
80+
timeZone="America/Toronto"
81+
/>
82+
</div>
83+
<div className="flex items-center gap-3 py-1">
84+
<PanelLabel label="Los Angeles" className="w-28" />
85+
<FormatDate
86+
date={date}
87+
displayAs="absolute"
88+
timeZone="America/Los_Angeles"
89+
/>
90+
</div>
91+
<div className="flex items-center gap-3 py-1">
92+
<PanelLabel label="Buenos Aires" className="w-28" />
93+
<FormatDate
94+
date={date}
95+
displayAs="absolute"
96+
timeZone="America/Buenos_Aires"
97+
/>
98+
</div>
99+
<div className="flex items-center gap-3 py-1">
100+
<PanelLabel label="London" className="w-28" />
101+
<FormatDate
102+
date={date}
103+
displayAs="absolute"
104+
timeZone="Europe/London"
105+
/>
106+
</div>
107+
</CardContent>
108+
</Card>
109+
</div>
110+
);
111+
}
112+
113+
export function FormatDateOptionsDemo() {
114+
const date = useMemo(() => new Date("2026-06-09T18:42:03Z"), []);
115+
116+
// Each entry overrides the default Intl.DateTimeFormat options.
117+
const samples: {
118+
label: string;
119+
options: Intl.DateTimeFormatOptions;
120+
}[] = [
121+
{
122+
label: "Date only",
123+
options: { year: "numeric", month: "long", day: "numeric" },
124+
},
125+
{
126+
label: "Time only",
127+
options: { hour: "2-digit", minute: "2-digit" },
128+
},
129+
{
130+
label: "With weekday",
131+
options: {
132+
weekday: "long",
133+
year: "numeric",
134+
month: "long",
135+
day: "numeric",
136+
},
137+
},
138+
{
139+
label: "Short numeric",
140+
options: { dateStyle: "short", timeStyle: "short" },
141+
},
142+
];
143+
144+
return (
145+
<div className="my-4">
146+
<Card>
147+
<CardContent className="divide-border divide-y divide-solid">
148+
{samples.map((sample) => (
149+
<div key={sample.label} className="flex items-center gap-3 py-1">
150+
<PanelLabel label={sample.label} className="w-28" />
151+
<FormatDate
152+
date={date}
153+
displayAs="absolute"
154+
absoluteOptions={sample.options}
155+
/>
156+
</div>
157+
))}
158+
</CardContent>
159+
</Card>
160+
</div>
161+
);
162+
}
163+
164+
export function FormatDateIsoDemo() {
165+
const date = useMemo(() => new Date("2026-06-09T18:42:03Z"), []);
166+
167+
// en-CA orders numeric dates as yyyy-mm-dd
168+
// en-US would render dd/mm/yyyy
169+
const options: Intl.DateTimeFormatOptions = {
170+
year: "numeric",
171+
month: "2-digit",
172+
day: "2-digit",
173+
};
174+
175+
return (
176+
<div className="my-4">
177+
<Card>
178+
<CardContent className="divide-border divide-y divide-solid">
179+
<div className="flex items-center gap-3 py-1">
180+
<PanelLabel label="en-CA" className="w-28" />
181+
<FormatDate
182+
date={date}
183+
displayAs="absolute"
184+
locale="en-CA"
185+
absoluteOptions={options}
186+
/>
187+
</div>
188+
<div className="flex items-center gap-3 py-1">
189+
<PanelLabel label="en-US" className="w-28" />
190+
<FormatDate
191+
date={date}
192+
displayAs="absolute"
193+
locale="en-US"
194+
absoluteOptions={options}
195+
/>
196+
</div>
197+
</CardContent>
198+
</Card>
199+
</div>
200+
);
201+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
---
2+
layout: "@demo/layouts/mdx-layout.astro"
3+
heading: "Format Date"
4+
description: "Render a date as relative or absolute time using a semantic <time> element"
5+
---
6+
7+
import { FormatDate } from "@eqtylab/equality";
8+
import {
9+
FormatDateIsoDemo,
10+
FormatDateOptionsDemo,
11+
FormatDateRelativeDemo,
12+
FormatDateTimeZoneDemo,
13+
} from "@demo/components/demo/format-date";
14+
15+
## Overview
16+
17+
The <code>FormatDate</code> component renders a date as either **relative** time (e.g. "2 weeks ago", "Just now") or **absolute** time (e.g. "Jun 9 2026, 18:42:03 UTC"). It always renders as a semantic HTML [`<time>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time) element with a `dateTime` attribute.
18+
19+
When showing relative time, a tooltip reveals the absolute time on hover or keyboard focus.
20+
21+
## Time zones
22+
23+
Absolute time is formatted in **UTC by default**. To render in a different zone, pass a `timeZone` (e.g. `"America/New_York"`). Relative time reads the same everywhere.
24+
25+
## Usage
26+
27+
Import the component:
28+
29+
```tsx
30+
import { FormatDate } from "@eqtylab/equality";
31+
```
32+
33+
Basic usage with the required `date` property, which accepts an ISO 8601 string, epoch milliseconds, or a `Date`. Each of these renders the same instant:
34+
35+
```tsx
36+
<FormatDate date="2026-06-09T18:42:03Z" />
37+
<FormatDate date={1781030523000} />
38+
<FormatDate date={new Date("2026-06-09T18:42:03Z")} />
39+
```
40+
41+
## Variants
42+
43+
### Absolute
44+
45+
The default. Renders the full date and time in the configured `timeZone` (UTC unless overridden).
46+
47+
<FormatDateTimeZoneDemo client:only="react" />
48+
49+
```tsx
50+
<FormatDate date="2026-06-09T18:42:03Z" />
51+
<FormatDate date="2026-06-09T18:42:03Z" timeZone="America/New_York" />
52+
<FormatDate date="2026-06-09T18:42:03Z" timeZone="Asia/Tokyo" />
53+
<FormatDate date="2026-06-09T18:42:03Z" timeZone="Pacific/Auckland" />
54+
<FormatDate date="2026-06-09T18:42:03Z" timeZone="America/Toronto" />
55+
<FormatDate date="2026-06-09T18:42:03Z" timeZone="America/Los_Angeles" />
56+
<FormatDate date="2026-06-09T18:42:03Z" timeZone="America/Buenos_Aires" />
57+
<FormatDate date="2026-06-09T18:42:03Z" timeZone="Europe/London" />
58+
```
59+
60+
### Relative
61+
62+
Set `displayAs="relative"` to render distance from now. The value updates automatically on an interval, and hovering or focusing the date reveals the absolute time in a tooltip.
63+
64+
<FormatDateRelativeDemo client:only="react" />
65+
66+
```tsx
67+
<FormatDate date={data.updatedAt} displayAs="relative" />
68+
```
69+
70+
Disable the tooltip with `tooltip={false}`, or stop the auto-updating with `live={false}`.
71+
72+
### Custom formatting
73+
74+
Pass `absoluteOptions` to override the [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options) options used for absolute time. For example to show a date without a time, a time without a date, or a weekday.
75+
76+
<FormatDateOptionsDemo client:only="react" />
77+
78+
```tsx
79+
<FormatDate
80+
date="2026-06-09T18:42:03Z"
81+
absoluteOptions={{ year: "numeric", month: "long", day: "numeric" }}
82+
/>
83+
<FormatDate
84+
date="2026-06-09T18:42:03Z"
85+
absoluteOptions={{ hour: "2-digit", minute: "2-digit" }}
86+
/>
87+
<FormatDate
88+
date="2026-06-09T18:42:03Z"
89+
absoluteOptions={{ weekday: "long", year: "numeric", month: "long", day: "numeric" }}
90+
/>
91+
<FormatDate
92+
date="2026-06-09T18:42:03Z"
93+
absoluteOptions={{ dateStyle: "short", timeStyle: "short" }}
94+
/>
95+
```
96+
97+
#### ISO 8601 short date
98+
99+
For a short numeric date like `2026-06-09`, use numeric options. Digit _order_ is decided by the `locale`, not the options, so the default `en-US` renders this as `06/09/2026`. Pair the preset with `locale="en-CA"` (which orders numerically as `yyyy-mm-dd`) to get true ISO 8601:
100+
101+
<FormatDateIsoDemo client:only="react" />
102+
103+
```tsx
104+
<FormatDate
105+
date="2026-06-09T18:42:03Z"
106+
locale="en-CA"
107+
absoluteOptions={{ year: "numeric", month: "2-digit", day: "2-digit" }}
108+
/>
109+
```
110+
111+
## Props
112+
113+
| Name | Description | Type | Default | Required |
114+
| ----------------- | ----------------------------------------------------------------------- | ---------------------------- | ---------- | -------- |
115+
| `date` | The date to display | `string`, `number`, `Date` |||
116+
| `displayAs` | Render relative or absolute time | `relative`, `absolute` | `absolute` ||
117+
| `timeZone` | Time zone used for absolute formatting | `string` | `UTC` ||
118+
| `locale` | BCP 47 locale used for formatting | `string` | `en-US` ||
119+
| `tooltip` | When relative, show a tooltip with the absolute time on hover/focus | `boolean` | `true` ||
120+
| `live` | When relative, re-render on an interval so the value stays current | `boolean` | `true` ||
121+
| `absoluteOptions` | Override the `Intl.DateTimeFormat` options used for absolute formatting | `Intl.DateTimeFormatOptions` |||

packages/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@eqtylab/equality",
33
"description": "EQTYLab's component and token-based design system",
44
"homepage": "https://equality.eqtylab.io/",
5-
"version": "2.0.1",
5+
"version": "2.1.0",
66
"license": "Apache-2.0",
77
"keywords": [
88
"component library",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
@reference '../../theme/theme.module.css';
2+
3+
.format-date {
4+
@apply tabular-nums;
5+
}
6+
7+
.format-date--interactive {
8+
@apply focus-ring rounded-sm;
9+
@apply cursor-default;
10+
}

0 commit comments

Comments
 (0)