diff --git a/packages/core/src/intl.context.ts b/packages/core/src/intl.context.ts index dd01357..12a1149 100644 --- a/packages/core/src/intl.context.ts +++ b/packages/core/src/intl.context.ts @@ -7,6 +7,7 @@ import { type IntlMessage } from './types/intl-message'; export type IntlContextValue = { message?: IntlMessage; locale: string; + now?: Date; timeZone?: string; formats?: Partial; onError: (error: IntlError) => void; diff --git a/packages/core/src/intl.provider.tsx b/packages/core/src/intl.provider.tsx index 6bd26f3..e73aa14 100644 --- a/packages/core/src/intl.provider.tsx +++ b/packages/core/src/intl.provider.tsx @@ -12,7 +12,7 @@ function defaultGetMessageFallback({ key, namespace }: DefaultGetMessageFallback } type IntlProviderProps = React.PropsWithChildren< - Pick & + Pick & Partial> >; diff --git a/packages/core/src/use-intl.ts b/packages/core/src/use-intl.ts index 2037dde..21df63a 100644 --- a/packages/core/src/use-intl.ts +++ b/packages/core/src/use-intl.ts @@ -41,7 +41,7 @@ function getRelativeTimeFormatConfig(seconds: number) { } export function useIntl() { - const { formats, locale, timeZone, onError } = useIntlContext(); + const { formats, locale, now: globalNow, timeZone, onError } = useIntlContext(); function resolveFormatOrOptions( typeFormats: Record | undefined, @@ -120,14 +120,26 @@ export function useIntl() { }); } - function formatRelativeTime(date: number | Date, now: number | Date) { - const dateDate = date instanceof Date ? date : new Date(date); - const nowDate = now instanceof Date ? now : new Date(now); + function formatRelativeTime(date: number | Date, now?: number | Date) { + try { + if (now == null) { + if (globalNow != null) { + now = globalNow; + } else { + throw new Error( + process.env.NODE_ENV !== 'production' + ? `The \`now\` parameter wasn't provided to \`formatRelativeTime\` and there was no global fallback configured on the provider.` + : undefined, + ); + } + } - const seconds = (dateDate.getTime() - nowDate.getTime()) / 1_000; - const { unit, value } = getRelativeTimeFormatConfig(seconds); + const dateDate = date instanceof Date ? date : new Date(date); + const nowDate = now instanceof Date ? now : new Date(now); + + const seconds = (dateDate.getTime() - nowDate.getTime()) / 1_000; + const { unit, value } = getRelativeTimeFormatConfig(seconds); - try { return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(value, unit); } catch (_error) { const error = _error as SystemError; diff --git a/packages/core/src/use-now.ts b/packages/core/src/use-now.ts new file mode 100644 index 0000000..824cfba --- /dev/null +++ b/packages/core/src/use-now.ts @@ -0,0 +1,30 @@ +import React from 'react'; + +import { useIntlContext } from './intl.provider'; + +type UseNowOptions = { + updateInterval?: number; +}; + +export function useNow(options?: UseNowOptions) { + const updateInterval = options?.updateInterval; + + const { now: globalNow } = useIntlContext(); + const [now, setNow] = React.useState(globalNow || new Date()); + + React.useEffect(() => { + if (Boolean(updateInterval) === false) { + return undefined; + } + + const intervalId = setInterval(() => { + setNow(new Date()); + }, updateInterval); + + return () => { + clearInterval(intervalId); + }; + }, [updateInterval]); + + return now; +} diff --git a/packages/core/test/use-intl.test.tsx b/packages/core/test/use-intl.test.tsx index bc2be80..8b37c14 100644 --- a/packages/core/test/use-intl.test.tsx +++ b/packages/core/test/use-intl.test.tsx @@ -19,7 +19,7 @@ describe('formatDateTime', () => { } render( - + , ); @@ -91,7 +91,7 @@ describe('formatDateTime', () => { } render( - + , ); @@ -114,7 +114,7 @@ describe('formatDateTime', () => { } render( - + , ); @@ -123,6 +123,22 @@ describe('formatDateTime', () => { }); }); + it('can use a global `now` fallback', () => { + function Component() { + const intl = useIntl(); + const mockDate = new Date('1984-11-20T10:36:00.000Z'); + return <>{intl.formatRelativeTime(mockDate)}; + } + + render( + + + , + ); + + screen.getByText('34 years ago'); + }); + describe('error handling', () => { it('handles missing formats', () => { const onError = vi.fn(); @@ -133,7 +149,7 @@ describe('formatDateTime', () => { } const { container } = render( - + , ); @@ -155,7 +171,7 @@ describe('formatDateTime', () => { } const { container } = render( - + , ); @@ -167,6 +183,28 @@ describe('formatDateTime', () => { ); expect(container.textContent).toMatch(/Oct 01 2023/); }); + + it('throws when no `now` value is available', () => { + const onError = vi.fn(); + + function Component() { + const intl = useIntl(); + const mockDate = new Date('1984-11-20T10:36:00.000Z'); + return <>{intl.formatRelativeTime(mockDate)}; + } + + render( + + + , + ); + + const error: IntlError = onError.mock.calls[0][0]; + expect(error.code).toBe(IntlErrorCode.FORMATTING_ERROR); + expect(error.message).toBe( + "FORMATTING_ERROR: The `now` parameter wasn't provided to `formatRelativeTime` and there was no global fallback configured on the provider.", + ); + }); }); }); @@ -178,7 +216,7 @@ describe('formatNumber', () => { } render( - + , ); @@ -225,7 +263,7 @@ describe('formatNumber', () => { } const { container } = render( - + , ); @@ -247,7 +285,7 @@ describe('formatNumber', () => { } const { container } = render( - + , ); @@ -268,7 +306,7 @@ describe('formatRelativeTime', () => { } render( - + , ); @@ -332,7 +370,7 @@ describe('formatRelativeTime', () => { } const { container } = render( - + , ); diff --git a/packages/core/test/use-now.test.tsx b/packages/core/test/use-now.test.tsx new file mode 100644 index 0000000..6220e0d --- /dev/null +++ b/packages/core/test/use-now.test.tsx @@ -0,0 +1,72 @@ +import { render, waitFor } from '@testing-library/react'; +import { expect, it } from 'vitest'; + +import { IntlProvider } from '../src/intl.provider'; +import { useNow } from '../src/use-now'; + +it('returns the current time', () => { + function Component() { + return

{useNow().toISOString()}

; + } + + const { container } = render( + + + , + ); + expect(container.textContent).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/); +}); + +it('can use a globally defined `now` value', () => { + function Component() { + return

{useNow().toISOString()}

; + } + + const { container } = render( + + + , + ); + expect(container.textContent).toBe('2018-10-06T10:36:01.516Z'); +}); + +it('can update a globally defined `now` value after the initial render', async () => { + function Component() { + return

{useNow({ updateInterval: 100 }).toISOString()}

; + } + + const { container } = render( + + + , + ); + expect(container.textContent).toBe('2018-10-06T10:36:01.516Z'); + + await waitFor(() => { + if (!container.textContent) throw new Error(); + const curYear = parseInt(container.textContent); + + expect(curYear).toBeGreaterThan(2020); + }); +}); + +it('can update based on an interval', async () => { + function Component() { + return

{useNow({ updateInterval: 100 }).toISOString()}

; + } + + const { container } = render( + + + , + ); + if (!container.textContent) throw new Error(); + const initial = new Date(container.textContent).getTime(); + + await waitFor(() => { + if (!container.textContent) throw new Error(); + const now = new Date(container.textContent).getTime(); + expect(now - 900).toBeGreaterThanOrEqual(initial); + expect(4).toBeGreaterThanOrEqual(3); + }); +});