@enigmax/primitives 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Notification queue: ordering, timers, dedupe and pause. No rendering, no styles.
3
+ *
4
+ * The timer is the part that is always wrong when hand-rolled. A dismiss timer
5
+ * that keeps counting while the tab is hidden fires the moment the visitor comes
6
+ * back and they never see the message, so the remaining time is held, not run.
7
+ */
8
+
9
+ export type NotificationTone = "info" | "success" | "warning" | "error" | "loading";
10
+
11
+ /** One button on a notification. Anything more belongs on the page, not in a toast. */
12
+ export interface NotificationAction {
13
+ label: string;
14
+ onSelect: () => void;
15
+ /** Dismiss once it has been pressed. On by default. */
16
+ dismiss?: boolean;
17
+ }
18
+
19
+ export interface NotificationInput {
20
+ /** Reuses the slot of a live notification with the same key instead of stacking. */
21
+ key?: string;
22
+ title: string;
23
+ body?: string;
24
+ tone?: NotificationTone;
25
+ /** ms before it dismisses itself. 0 or Infinity keeps it until dismissed. */
26
+ duration?: number;
27
+ action?: NotificationAction;
28
+ /** Arbitrary payload for the renderer: an href, an icon name. */
29
+ data?: unknown;
30
+ }
31
+
32
+ export interface Notification extends Required<Omit<NotificationInput, "body" | "data" | "key" | "action">> {
33
+ id: string;
34
+ key?: string;
35
+ body?: string;
36
+ action?: NotificationAction;
37
+ data?: unknown;
38
+ createdAt: number;
39
+ }
40
+
41
+ /** What `promise()` shows at each stage. A function receives the resolved value or the error. */
42
+ export interface PromiseMessages<T> {
43
+ loading: string | NotificationInput;
44
+ success: string | ((value: T) => string | NotificationInput);
45
+ error: string | ((error: unknown) => string | NotificationInput);
46
+ }
47
+
48
+ export interface NotificationsOptions {
49
+ /** Live notifications kept at once. The oldest dismissable one makes room. */
50
+ max?: number;
51
+ /** Default ms before self-dismissal. */
52
+ duration?: number;
53
+ /** Errors default to staying until dismissed. */
54
+ stickyTones?: NotificationTone[];
55
+ }
56
+
57
+ export interface Notifications {
58
+ readonly items: readonly Notification[];
59
+ notify(input: NotificationInput): string;
60
+ /** Change a live notification in place, keeping its slot and restarting its timer. */
61
+ update(id: string, patch: Partial<NotificationInput>): void;
62
+ /**
63
+ * One notification that follows an async operation from loading to its outcome, in the
64
+ * same slot. The alternative is three toasts stacking up for one action.
65
+ */
66
+ promise<T>(work: Promise<T>, messages: PromiseMessages<T>): Promise<T>;
67
+ dismiss(id: string): void;
68
+ dismissAll(): void;
69
+ /** Hold every timer, e.g. while a pointer rests on the stack. */
70
+ pause(): void;
71
+ resume(): void;
72
+ subscribe(listener: (items: readonly Notification[]) => void): () => void;
73
+ destroy(): void;
74
+ }
75
+
76
+ const DEFAULT_MAX = 4;
77
+ const DEFAULT_DURATION = 5000;
78
+
79
+ export function createNotifications(options: NotificationsOptions = {}): Notifications {
80
+ // Null outside a browser: the queue has to survive a server render untouched.
81
+ const view = typeof document === "undefined" ? null : document;
82
+ const max = options.max ?? DEFAULT_MAX;
83
+ const defaultDuration = options.duration ?? DEFAULT_DURATION;
84
+ // `loading` is sticky by definition - it ends when the work does, not on a timer.
85
+ const sticky = new Set<NotificationTone>([...(options.stickyTones ?? ["error"]), "loading"]);
86
+
87
+ let items: Notification[] = [];
88
+ let paused = false;
89
+ let sequence = 0;
90
+ const listeners = new Set<(items: readonly Notification[]) => void>();
91
+ const timers = new Map<string, { handle: ReturnType<typeof setTimeout> | null; remaining: number; startedAt: number; }>();
92
+
93
+ function emit(): void {
94
+ const snapshot = Object.freeze([...items]);
95
+ for (const listener of listeners) listener(snapshot);
96
+ }
97
+
98
+ function clearTimer(id: string): void {
99
+ const timer = timers.get(id);
100
+ if (timer?.handle) clearTimeout(timer.handle);
101
+ timers.delete(id);
102
+ }
103
+
104
+ function startTimer(id: string, remaining: number): void {
105
+ if (!Number.isFinite(remaining) || remaining <= 0) return;
106
+ const handle = paused ? null : setTimeout(() => dismiss(id), remaining);
107
+ timers.set(id, { handle, remaining, startedAt: Date.now() });
108
+ }
109
+
110
+ function dismiss(id: string): void {
111
+ const next = items.filter(item => item.id !== id);
112
+ if (next.length === items.length) return;
113
+ items = next;
114
+ clearTimer(id);
115
+ emit();
116
+ }
117
+
118
+ function notify(input: NotificationInput): string {
119
+ const tone = input.tone ?? "info";
120
+ const duration = input.duration ?? (sticky.has(tone) ? Infinity : defaultDuration);
121
+
122
+ // A repeated key replaces in place, so a retry loop does not build a wall.
123
+ const existing = input.key ? items.find(item => item.key === input.key) : undefined;
124
+ const id = existing?.id ?? `n${++sequence}`;
125
+ const notification: Notification = {
126
+ id,
127
+ key: input.key,
128
+ title: input.title,
129
+ body: input.body,
130
+ tone,
131
+ duration,
132
+ action: input.action,
133
+ data: input.data,
134
+ createdAt: Date.now()
135
+ };
136
+
137
+ items = existing
138
+ ? items.map(item => (item.id === id ? notification : item))
139
+ : [...items, notification];
140
+
141
+ if (items.length > max) {
142
+ // Never silently drop something that has no timer of its own.
143
+ const evictable = items.find(item => Number.isFinite(item.duration) && item.id !== id);
144
+ if (evictable) {
145
+ items = items.filter(item => item.id !== evictable.id);
146
+ clearTimer(evictable.id);
147
+ } else {
148
+ items = items.slice(items.length - max);
149
+ }
150
+ }
151
+
152
+ clearTimer(id);
153
+ startTimer(id, duration);
154
+ emit();
155
+ return id;
156
+ }
157
+
158
+ const onVisibility = (): void => {
159
+ if (!view) return;
160
+ // A hidden tab must not burn a notification nobody could read.
161
+ if (view.hidden) instance.pause();
162
+ else instance.resume();
163
+ };
164
+
165
+ function update(id: string, patch: Partial<NotificationInput>): void {
166
+ const current = items.find(item => item.id === id);
167
+ if (!current) return;
168
+
169
+ // In place rather than through notify(): notify dedupes by KEY, and a notification
170
+ // raised without one would be appended as a second toast instead of replaced.
171
+ const tone = patch.tone ?? current.tone;
172
+ // A patch that changes the tone takes that tone's default duration unless it names
173
+ // one, so a loading toast turning into a success stops being sticky.
174
+ const duration = patch.duration ?? (patch.tone ? (sticky.has(tone) ? Infinity : defaultDuration) : current.duration);
175
+
176
+ items = items.map(item => (item.id === id ? { ...current, ...patch, tone, duration } : item));
177
+ clearTimer(id);
178
+ startTimer(id, duration);
179
+ emit();
180
+ }
181
+
182
+ function resolveMessage<T>(message: string | NotificationInput | ((value: T) => string | NotificationInput), value: T): NotificationInput {
183
+ const resolved = typeof message === "function" ? message(value) : message;
184
+ return typeof resolved === "string" ? { title: resolved } : resolved;
185
+ }
186
+
187
+ async function promise<T>(work: Promise<T>, messages: PromiseMessages<T>): Promise<T> {
188
+ const start = typeof messages.loading === "string" ? { title: messages.loading } : messages.loading;
189
+ // A key ties every stage to the same slot, so one action is one toast.
190
+ const key = start.key ?? `p${++sequence}`;
191
+ notify({ ...start, key, tone: "loading" });
192
+ try {
193
+ const value = await work;
194
+ notify({ ...resolveMessage(messages.success, value), key, tone: "success" });
195
+ return value;
196
+ } catch (error) {
197
+ notify({ ...resolveMessage(messages.error, error), key, tone: "error" });
198
+ // Rethrown: swallowing it here would turn a failed call into a silent success
199
+ // for everything downstream of the await.
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ const instance: Notifications = {
205
+ get items() { return Object.freeze([...items]); },
206
+ notify,
207
+ update,
208
+ promise,
209
+ dismiss,
210
+ dismissAll(): void {
211
+ for (const item of items) clearTimer(item.id);
212
+ items = [];
213
+ emit();
214
+ },
215
+ pause(): void {
216
+ if (paused) return;
217
+ paused = true;
218
+ for (const [id, timer] of timers) {
219
+ if (!timer.handle) continue;
220
+ clearTimeout(timer.handle);
221
+ timers.set(id, {
222
+ handle: null,
223
+ remaining: Math.max(timer.remaining - (Date.now() - timer.startedAt), 0),
224
+ startedAt: Date.now()
225
+ });
226
+ }
227
+ },
228
+ resume(): void {
229
+ if (!paused) return;
230
+ paused = false;
231
+ for (const [id, timer] of [...timers]) {
232
+ clearTimer(id);
233
+ startTimer(id, timer.remaining);
234
+ }
235
+ },
236
+ subscribe(listener): () => void {
237
+ listeners.add(listener);
238
+ return () => { listeners.delete(listener); };
239
+ },
240
+ destroy(): void {
241
+ for (const item of items) clearTimer(item.id);
242
+ items = [];
243
+ listeners.clear();
244
+ view?.removeEventListener("visibilitychange", onVisibility);
245
+ }
246
+ };
247
+
248
+ view?.addEventListener("visibilitychange", onVisibility);
249
+ return instance;
250
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Relative time: "3 hours ago", and everything that has to be right around it.
3
+ *
4
+ * The rendering itself is `<relative-time>` (@github/relative-time-element), which already
5
+ * owns the hard parts - Intl.RelativeTimeFormat per locale, and re-rendering on a schedule
6
+ * that gets slower as the date gets older. What is here is the part every wrapper
7
+ * re-implements and usually gets wrong: parsing a timestamp that does not declare its zone,
8
+ * knowing when the date has aged past the relative threshold, and producing an absolute
9
+ * label to show while the element is still loading, or forever if scripting is off.
10
+ *
11
+ * Nothing in this file touches the DOM, so it renders on a server.
12
+ */
13
+
14
+ /** How the element should phrase it. `auto` is relative until the threshold, then a date. */
15
+ export type RelativeTimeFormat = "auto" | "relative" | "duration" | "datetime" | "micro" | "elapsed";
16
+ export type RelativeTimeTense = "auto" | "past" | "future";
17
+ export type RelativeTimePrecision = "year" | "month" | "day" | "hour" | "minute" | "second";
18
+ export type RelativeTimeStyle = "long" | "short" | "narrow";
19
+ export type NumericStyle = "numeric" | "2-digit";
20
+
21
+ export interface RelativeTimeOptions {
22
+ format?: RelativeTimeFormat;
23
+ tense?: RelativeTimeTense;
24
+ precision?: RelativeTimePrecision;
25
+ /** ISO 8601 duration. Past this age `auto` stops being relative. Default `P30D`. */
26
+ threshold?: string;
27
+ /** Word before an absolute date, e.g. "on 5 May". Empty string removes it. */
28
+ prefix?: string;
29
+ formatStyle?: RelativeTimeStyle;
30
+ /**
31
+ * BCP 47 tag. Left undefined the element reads the closest `lang` in the document,
32
+ * which is what a translated page wants - hardcoding one is how a Spanish page ends
33
+ * up saying "3 hours ago".
34
+ */
35
+ locale?: string;
36
+ /** IANA zone for the absolute rendering. Undefined means the reader's own. */
37
+ timeZone?: string;
38
+ second?: NumericStyle;
39
+ minute?: NumericStyle;
40
+ hour?: NumericStyle;
41
+ weekday?: RelativeTimeStyle;
42
+ day?: NumericStyle;
43
+ month?: NumericStyle | "short" | "long" | "narrow";
44
+ year?: NumericStyle;
45
+ timeZoneName?: "long" | "short" | "shortOffset" | "longOffset" | "shortGeneric" | "longGeneric";
46
+ /** Drop the exact timestamp the element otherwise puts in `title`. */
47
+ noTitle?: boolean;
48
+ /**
49
+ * Once the date is older than `threshold`, render it as digits (05/05/2026) instead of
50
+ * a prefixed month name. The cutoff is the threshold itself, not a guess at it.
51
+ */
52
+ numericBeyondThreshold?: boolean;
53
+ }
54
+
55
+ /** Everything derived from a date, in one pass, for whichever adapter is rendering it. */
56
+ export interface RelativeTimeView {
57
+ /** Null when the input could not be parsed - nothing else here is meaningful then. */
58
+ date: Date | null;
59
+ /** `datetime` attribute value: always UTC, always ISO. */
60
+ iso: string;
61
+ /** Absolute text. Shown until the element upgrades, and forever without scripting. */
62
+ label: string;
63
+ /** Full timestamp for `title` / `aria-label`. */
64
+ exact: string;
65
+ /** The date is older (or further ahead) than `threshold`. */
66
+ beyondThreshold: boolean;
67
+ /** Render `label` in a plain `<time>` and skip the element entirely. */
68
+ absoluteOnly: boolean;
69
+ }
70
+
71
+ const ZONED = /([zZ]|[+-]\d{2}:?\d{2})$/;
72
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
73
+ const HAS_TIME = /\d{2}:\d{2}/;
74
+ const DURATION = /^([+-])?P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
75
+
76
+ const SECOND = 1000, MINUTE = 60 * SECOND, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
77
+ /** Calendar-free approximations, matching what the element uses to compare an age. */
78
+ const WEEK = 7 * DAY, MONTH = 30 * DAY, YEAR = 365 * DAY;
79
+
80
+ /**
81
+ * A timestamp with no zone is UTC.
82
+ *
83
+ * This is the single most common defect in a date column: `2026-08-13 22:41:00` comes back
84
+ * from the database with no offset, `new Date()` reads it as LOCAL time, and every reader
85
+ * east or west of the server sees a time that is hours out - silently, because the wrong
86
+ * time is still a valid one.
87
+ *
88
+ * A date with no clock is left exactly as it is. The spec already reads a bare `YYYY-MM-DD`
89
+ * as UTC, so there is nothing to add - and `YYYY-MM-DDZ` is not in the spec's grammar at
90
+ * all, which drops it into each engine's own legacy parser. That is a portability coin
91
+ * flip on a value that was already correct.
92
+ */
93
+ export function ensureZone(text: string): string {
94
+ const trimmed = text.trim();
95
+ if (!trimmed || DATE_ONLY.test(trimmed) || ZONED.test(trimmed)) return trimmed;
96
+ const iso = trimmed.replace(" ", "T");
97
+ return HAS_TIME.test(iso) ? `${iso}Z` : iso;
98
+ }
99
+
100
+ /** Parse anything a date column or an API hands over. Null rather than an Invalid Date. */
101
+ export function normalizeDate(value: string | number | Date | null | undefined): Date | null {
102
+ if (value == null) return null;
103
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
104
+ // A bare number is epoch milliseconds; seconds would be 1970 and obviously wrong.
105
+ if (typeof value === "number") return Number.isFinite(value) ? new Date(value) : null;
106
+ const date = new Date(ensureZone(value));
107
+ return Number.isNaN(date.getTime()) ? null : date;
108
+ }
109
+
110
+ /**
111
+ * ISO 8601 duration to milliseconds, for comparing an age against `threshold`.
112
+ *
113
+ * Years and months are approximated the way the element approximates them, because the
114
+ * threshold is a rough "old enough to stop counting", not an anniversary.
115
+ */
116
+ export function parseDuration(value: string): number {
117
+ const match = DURATION.exec(value.trim());
118
+ if (!match) return 0;
119
+ const [, sign, years, months, weeks, days, hours, minutes, seconds] = match;
120
+ const n = (part: string | undefined): number => (part ? Number(part) : 0);
121
+ const total =
122
+ n(years) * YEAR + n(months) * MONTH + n(weeks) * WEEK + n(days) * DAY +
123
+ n(hours) * HOUR + n(minutes) * MINUTE + n(seconds) * SECOND;
124
+ return sign === "-" ? -total : total;
125
+ }
126
+
127
+ function dateTimeOptions(options: RelativeTimeOptions): Intl.DateTimeFormatOptions {
128
+ const { second, minute, hour, weekday, day, month, year, timeZone, timeZoneName } = options;
129
+ const parts: Intl.DateTimeFormatOptions = { timeZone, timeZoneName, second, minute, hour, weekday, day, month, year };
130
+ // An explicit `undefined` is not the same as an absent key to Intl in every engine.
131
+ for (const key of Object.keys(parts) as (keyof Intl.DateTimeFormatOptions)[]) {
132
+ if (parts[key] === undefined) delete parts[key];
133
+ }
134
+ return parts;
135
+ }
136
+
137
+ function format(date: Date, locale: string | undefined, options: Intl.DateTimeFormatOptions): string {
138
+ try {
139
+ return new Intl.DateTimeFormat(locale, options).format(date);
140
+ } catch {
141
+ // An invalid locale tag or an unsupported time zone throws rather than degrading.
142
+ return date.toISOString();
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Everything an adapter needs to render one date.
148
+ *
149
+ * `now` is a parameter so a test can pin it, and so a server render can pass the same
150
+ * instant it used elsewhere on the page.
151
+ */
152
+ export function relativeTimeView(value: string | number | Date | null | undefined, options: RelativeTimeOptions = {}, now: Date = new Date()): RelativeTimeView {
153
+ const date = normalizeDate(value);
154
+ if (!date) return { date: null, iso: "", label: "", exact: "", beyondThreshold: false, absoluteOnly: false };
155
+
156
+ const { locale, threshold = "P30D", prefix = "on", numericBeyondThreshold = false } = options;
157
+ const age = Math.abs(date.getTime() - now.getTime());
158
+ const beyondThreshold = age > parseDuration(threshold);
159
+
160
+ // Beyond the threshold the element itself would render a date, so rendering it here
161
+ // costs nothing and skips a custom element that has no work left to do.
162
+ const absoluteOnly = numericBeyondThreshold && beyondThreshold;
163
+ const parts = absoluteOnly
164
+ ? { day: "numeric" as const, month: "numeric" as const, year: "numeric" as const, timeZone: options.timeZone }
165
+ : { day: "numeric" as const, month: "short" as const, year: "numeric" as const, ...dateTimeOptions(options) };
166
+
167
+ const label = format(date, locale, parts);
168
+ return {
169
+ date,
170
+ iso: date.toISOString(),
171
+ label: !absoluteOnly && prefix ? `${prefix} ${label}` : label,
172
+ exact: format(date, locale, { dateStyle: "full", timeStyle: "long", timeZone: options.timeZone }),
173
+ beyondThreshold,
174
+ absoluteOnly
175
+ };
176
+ }
177
+
178
+ /**
179
+ * The element's attributes, kebab-cased, with anything undefined left out.
180
+ *
181
+ * Written as attributes rather than properties because that is the half of a custom
182
+ * element's API that works before it is defined - the markup is already correct when the
183
+ * definition arrives late, or never.
184
+ */
185
+ export function relativeTimeAttributes(view: RelativeTimeView, options: RelativeTimeOptions = {}): Record<string, string> {
186
+ const attributes: Record<string, string | undefined> = {
187
+ datetime: view.iso,
188
+ format: options.format,
189
+ tense: options.tense,
190
+ precision: options.precision,
191
+ threshold: options.threshold,
192
+ prefix: options.prefix,
193
+ "format-style": options.formatStyle,
194
+ second: options.second,
195
+ minute: options.minute,
196
+ hour: options.hour,
197
+ weekday: options.weekday,
198
+ day: options.day,
199
+ month: options.month,
200
+ year: options.year,
201
+ lang: options.locale,
202
+ "time-zone": options.timeZone,
203
+ "time-zone-name": options.timeZoneName,
204
+ // Boolean attributes are read by presence: "false" would still be true.
205
+ "no-title": options.noTitle ? "" : undefined
206
+ };
207
+
208
+ const out: Record<string, string> = {};
209
+ for (const [key, value] of Object.entries(attributes)) {
210
+ if (value !== undefined) out[key] = value;
211
+ }
212
+ return out;
213
+ }
package/src/index.ts CHANGED
@@ -12,3 +12,18 @@ export {
12
12
  type PasswordStrengthReport,
13
13
  type PasswordScore
14
14
  } from "@/core/password";
15
+ export { createNotifications, type Notifications, type Notification, type NotificationInput, type NotificationTone, type NotificationAction, type NotificationsOptions, type PromiseMessages } from "@/core/notifications";
16
+ export { createNetworkMonitor, SERVER_NETWORK_STATE, type NetworkState, type NetworkMonitor } from "@/core/network";
17
+ export {
18
+ relativeTimeView,
19
+ relativeTimeAttributes,
20
+ normalizeDate,
21
+ ensureZone,
22
+ parseDuration,
23
+ type RelativeTimeView,
24
+ type RelativeTimeOptions,
25
+ type RelativeTimeFormat,
26
+ type RelativeTimeTense,
27
+ type RelativeTimePrecision,
28
+ type RelativeTimeStyle
29
+ } from "@/core/relative-time";
@@ -28,3 +28,8 @@ export {
28
28
  type PasswordStrengthReport,
29
29
  type PasswordScore
30
30
  } from "@/core/password";
31
+ export { useNotifications, createNotificationQueue, defaultQueue, type UseNotificationsResult } from "@/react/use-notifications";
32
+ export { Toaster, type ToasterProps, type ToastPosition, type ToastControls } from "@/react/toaster";
33
+ export { useNetworkState } from "@/react/use-network-state";
34
+ export { type NetworkState } from "@/core/network";
35
+ export { RelativeTime, type RelativeTimeProps } from "@/react/relative-time";
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { createElement, useEffect, type HTMLAttributes, type ReactNode } from "react";
4
+ import { relativeTimeView, relativeTimeAttributes, type RelativeTimeOptions } from "@/core/relative-time";
5
+
6
+ /**
7
+ * `<RelativeTime date={...} />` - a timestamp that reads as "3 hours ago" and keeps itself
8
+ * current, rendered by @github/relative-time-element.
9
+ *
10
+ * Two things this does that a bare element does not:
11
+ *
12
+ * The absolute date is rendered as the element's child, so a server render, a page whose
13
+ * JavaScript has not arrived yet, and a reader with scripting off all see a real date
14
+ * instead of an empty box. The element replaces it the moment it upgrades.
15
+ *
16
+ * The element is imported on the client only. It is a custom element, so its class extends
17
+ * HTMLElement at module scope - importing it where there is no DOM throws before any
18
+ * component of yours runs. A failed import is not fatal either: the absolute date is
19
+ * already in the markup.
20
+ */
21
+
22
+ /** One import per page, however many timestamps are on it. */
23
+ let loading: Promise<unknown> | null = null;
24
+
25
+ function loadElement(): void {
26
+ if (loading || typeof window === "undefined") return;
27
+ loading = import("@github/relative-time-element").catch(() => {
28
+ // Not installed, or blocked. The absolute label stands in, so there is nothing to
29
+ // report to the reader and nothing to retry.
30
+ return null;
31
+ });
32
+ }
33
+
34
+ export interface RelativeTimeProps extends RelativeTimeOptions, Omit<HTMLAttributes<HTMLElement>, "prefix" | "children"> {
35
+ /** ISO string, epoch milliseconds, or a Date. A string with no zone is read as UTC. */
36
+ date: string | number | Date | null | undefined;
37
+ /** Rendered when the date cannot be parsed. Nothing, by default. */
38
+ fallback?: ReactNode;
39
+ /** Title Case The Whole Phrase. Rarely what you want; `capitalizeFirst` usually is. */
40
+ capitalize?: boolean;
41
+ /** Uppercase the first letter, so a standalone "yesterday" reads as "Yesterday". */
42
+ capitalizeFirst?: boolean;
43
+ /**
44
+ * Pin the instant the age is measured from. Pass the request's timestamp to make a
45
+ * server render and its hydration agree exactly.
46
+ */
47
+ now?: Date;
48
+ }
49
+
50
+ export function RelativeTime({
51
+ date,
52
+ fallback = null,
53
+ capitalize = false,
54
+ capitalizeFirst = true,
55
+ now,
56
+ format,
57
+ tense,
58
+ precision,
59
+ threshold,
60
+ prefix,
61
+ formatStyle,
62
+ locale,
63
+ timeZone,
64
+ second,
65
+ minute,
66
+ hour,
67
+ weekday,
68
+ day,
69
+ month,
70
+ year,
71
+ timeZoneName,
72
+ noTitle,
73
+ numericBeyondThreshold,
74
+ ...rest
75
+ }: RelativeTimeProps): ReactNode {
76
+ useEffect(loadElement, []);
77
+
78
+ const options: RelativeTimeOptions = {
79
+ format, tense, precision, threshold, prefix, formatStyle, locale, timeZone,
80
+ second, minute, hour, weekday, day, month, year, timeZoneName, noTitle, numericBeyondThreshold
81
+ };
82
+ const view = relativeTimeView(date, options, now);
83
+ if (!view.date) return fallback;
84
+
85
+ const marks = {
86
+ "data-relative-time-capitalize": capitalize ? "true" : undefined,
87
+ "data-relative-time-capitalize-first-letter": capitalizeFirst ? "true" : undefined,
88
+ // The text differs between the server's instant and the browser's, which is the
89
+ // one hydration difference React has an escape hatch for rather than a bug.
90
+ suppressHydrationWarning: true
91
+ };
92
+
93
+ // Past the threshold there is no relative phrasing left to produce, so a plain <time>
94
+ // renders the same words without waiting for a custom element to define itself.
95
+ if (view.absoluteOnly) {
96
+ return createElement("time", { dateTime: view.iso, title: noTitle ? undefined : view.exact, ...marks, ...rest }, view.label);
97
+ }
98
+
99
+ // createElement rather than JSX: `<relative-time>` is not a known intrinsic element, and
100
+ // declaring it means augmenting React's JSX namespace from inside a published package -
101
+ // which collides with any consumer that declared it too, differently.
102
+ return createElement("relative-time", { ...relativeTimeAttributes(view, options), ...marks, ...rest }, view.label);
103
+ }