diff --git a/packages/circuit-ui/components/Timestamp/Timestamp.module.css b/packages/circuit-ui/components/Timestamp/Timestamp.module.css new file mode 100644 index 0000000000..185a90b100 --- /dev/null +++ b/packages/circuit-ui/components/Timestamp/Timestamp.module.css @@ -0,0 +1,3 @@ +.base { + font-variant-numeric: tabular-nums; +} diff --git a/packages/circuit-ui/components/Timestamp/Timestamp.spec.tsx b/packages/circuit-ui/components/Timestamp/Timestamp.spec.tsx new file mode 100644 index 0000000000..851ec21eca --- /dev/null +++ b/packages/circuit-ui/components/Timestamp/Timestamp.spec.tsx @@ -0,0 +1,137 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import MockDate from 'mockdate'; +import { createRef } from 'react'; + +import { render, screen, axe, act } from '../../util/test-utils.js'; + +import { Timestamp } from './Timestamp.js'; + +describe('Calendar', () => { + beforeEach(() => { + MockDate.set('2020-01-01T01:00+01:00'); + }); + + const baseProps = { + datetime: '2020-01-01T00:00+01:00[Europe/Berlin]', + }; + + it('should merge a custom class name with the default ones', () => { + const className = 'foo'; + render(); + const element = screen.getByRole('time'); + expect(element?.className).toContain(className); + }); + + it('should forward a ref to the outer element', () => { + const ref = createRef(); + render(); + const element = screen.getByRole('time'); + expect(ref.current).toBe(element); + }); + + it('should have a valid `datetime` attribute', () => { + render(); + const element = screen.getByRole('time'); + expect(element).toHaveAttribute('datetime', '2020-01-01T00:00:00+01:00'); + }); + + describe('absolute variant', () => { + it('should display a narrow human-readable date time', () => { + render( + , + ); + const element = screen.getByRole('time'); + expect(element).toHaveTextContent('1/1/20'); + }); + + it('should display a short human-readable date time', () => { + render( + , + ); + const element = screen.getByRole('time'); + expect(element).toHaveTextContent('Jan 1, 2020'); + }); + + it('should display a long human-readable date time', () => { + render( + , + ); + const element = screen.getByRole('time'); + expect(element).toHaveTextContent('January 1, 2020 at 12:00 AM'); + }); + }); + + describe('relative variant', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should display a narrow human-readable date time', () => { + render( + , + ); + const element = screen.getByRole('time'); + expect(element).toHaveTextContent('1h ago'); + }); + + it('should display a short human-readable date time', () => { + render( + , + ); + const element = screen.getByRole('time'); + expect(element).toHaveTextContent('1 hr. ago'); + }); + + it('should display a long human-readable date time', () => { + render( + , + ); + const element = screen.getByRole('time'); + expect(element).toHaveTextContent('1 hour ago'); + }); + + it('should update the time after an interval', () => { + render( + , + ); + + const element = screen.getByRole('time'); + expect(element).toHaveTextContent('in 1m'); + + act(() => { + vi.advanceTimersByTime(10 * 1000); + }); + + expect(element).toHaveTextContent('in 50s'); + }); + }); + + it('should meet accessibility guidelines', async () => { + const { container } = render(); + const actual = await axe(container); + expect(actual).toHaveNoViolations(); + }); +}); diff --git a/packages/circuit-ui/components/Timestamp/Timestamp.stories.tsx b/packages/circuit-ui/components/Timestamp/Timestamp.stories.tsx new file mode 100644 index 0000000000..908dc18944 --- /dev/null +++ b/packages/circuit-ui/components/Timestamp/Timestamp.stories.tsx @@ -0,0 +1,83 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Temporal } from 'temporal-polyfill'; + +import { Stack } from '../../../../.storybook/components/Stack.js'; + +import { Timestamp, type TimestampProps } from './Timestamp.js'; + +export default { + title: 'Components/Timestamp', + component: Timestamp, +}; + +const datetimes = [ + Temporal.Now.zonedDateTimeISO().add({ months: 4 }), + Temporal.Now.zonedDateTimeISO().add({ seconds: 64 }), + Temporal.Now.zonedDateTimeISO().subtract({ minutes: 7 }), + Temporal.Now.zonedDateTimeISO().subtract({ days: 1 }), + Temporal.Now.zonedDateTimeISO().subtract({ weeks: 5 }), +]; + +const locales = ['en-US', 'de-DE', 'pt-BR']; + +export const Base = (args: TimestampProps) => ; + +Base.args = { + datetime: datetimes[2].toString(), +}; + +export const Relative = (args: TimestampProps) => ( + + {locales.map((locale) => ( + + {datetimes.map((datetime) => ( + + ))} + + ))} + +); + +Relative.args = { + variant: 'relative', +}; + +export const Absolute = (args: TimestampProps) => ( + + {locales.map((locale) => ( + + {datetimes.map((datetime) => ( + + ))} + + ))} + +); + +Absolute.args = { + variant: 'absolute', +}; diff --git a/packages/circuit-ui/components/Timestamp/Timestamp.tsx b/packages/circuit-ui/components/Timestamp/Timestamp.tsx new file mode 100644 index 0000000000..4783693537 --- /dev/null +++ b/packages/circuit-ui/components/Timestamp/Timestamp.tsx @@ -0,0 +1,121 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import { forwardRef, useEffect, useState, type HTMLAttributes } from 'react'; +import { Temporal } from 'temporal-polyfill'; + +import type { Locale } from '../../util/i18n.js'; +import { clsx } from '../../styles/clsx.js'; + +import { getInitialState, getState } from './TimestampService.js'; +import classes from './Timestamp.module.css'; + +export interface TimestampProps extends HTMLAttributes { + /** + * TODO: Write description + */ + datetime: string; + /** + * TODO: Write description + * + * @default 'auto' + */ + variant?: 'auto' | 'relative' | 'absolute'; + /** + * TODO: Write description + * + * @default 'P1M' // 1 month + */ + threshold?: string; + /** + * TODO: Write description + * + * @default 'short' + */ + formatStyle?: 'long' | 'short' | 'narrow'; + /** + * One or more [IETF BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) + * locale identifiers such as `'de-DE'` or `['GB', 'en-US']`. + * When passing an array, the first supported locale is used. + * Defaults to `navigator.language` in supported environments. + */ + locale?: Locale; +} + +// TODO: initial, server-rendered label should include timezone and respect format style + +/** + * TODO: + */ +export const Timestamp = forwardRef( + ( + { + datetime, + variant = 'auto', + formatStyle = 'short', + threshold = 'P1M', + locale, + className, + ...props + }, + ref, + ) => { + const zonedDateTime = Temporal.ZonedDateTime.from(datetime); + const [state, setState] = useState( + getInitialState(datetime, locale, formatStyle), + ); + + // Update state on props change + useEffect(() => { + setState(getState(datetime, locale, formatStyle, variant, threshold)); + }, [datetime, variant, formatStyle, locale, threshold]); + + // Update state in regular intervals for relative times + useEffect(() => { + if (!state.interval) { + return undefined; + } + + const timer = setInterval(() => { + setState(getState(datetime, locale, formatStyle, variant, threshold)); + }, state.interval); + + return () => { + clearInterval(timer); + }; + }, [state.interval, datetime, variant, formatStyle, locale, threshold]); + + return ( + + ); + }, +); diff --git a/packages/circuit-ui/components/Timestamp/TimestampService.ts b/packages/circuit-ui/components/Timestamp/TimestampService.ts new file mode 100644 index 0000000000..01c1e803bd --- /dev/null +++ b/packages/circuit-ui/components/Timestamp/TimestampService.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Temporal } from 'temporal-polyfill'; +import { + formatDateTime, + formatRelativeTime, + isRelativeTimeFormatSupported, +} from '@sumup-oss/intl'; + +import type { Locale } from '../../util/i18n.js'; + +export const DATE_TIME_STYLE_MAP = { + long: { dateStyle: 'long', timeStyle: 'short' }, + short: { dateStyle: 'medium' }, + narrow: { dateStyle: 'short' }, +} satisfies Record; + +const UNITS = [ + { + name: 'years', + duration: Temporal.Duration.from('P1Y'), + interval: 300000, + }, + { + name: 'months', + duration: Temporal.Duration.from('P1M'), + interval: 300000, + }, + { + name: 'weeks', + duration: Temporal.Duration.from('P1W'), + interval: 300000, + }, + { + name: 'days', + duration: Temporal.Duration.from('P1D'), + interval: 300000, + }, + { + name: 'hours', + duration: Temporal.Duration.from('PT1H'), + interval: 60000, + }, + { + name: 'minutes', + duration: Temporal.Duration.from('PT1M'), + interval: 5000, + }, + { + name: 'seconds', + duration: Temporal.Duration.from('PT1S'), + interval: 1000, + }, +] satisfies { + name: Temporal.SmallestUnit; + duration: Temporal.Duration; + interval: number; // in milliseconds +}[]; + +export type State = { + label: string; + interval: number | null; +}; + +export function getInitialState( + datetime: string, + locale: Locale | undefined, + formatStyle: 'long' | 'short' | 'narrow', +): State { + const zonedDateTime = Temporal.ZonedDateTime.from(datetime); + const options = DATE_TIME_STYLE_MAP[formatStyle]; + return { + label: formatDateTime(zonedDateTime.toPlainDateTime(), locale, options), + interval: null, + }; +} + +export function getState( + datetime: string, + locale: Locale | undefined, + formatStyle: 'long' | 'short' | 'narrow', + variant: 'auto' | 'relative' | 'absolute', + threshold: string, +): State { + const zonedDateTime = Temporal.ZonedDateTime.from(datetime); + const now = Temporal.Now.zonedDateTimeISO(); + const duration = zonedDateTime.since(now); + + const isBeyondThreshold = + Temporal.Duration.compare( + duration.abs(), + Temporal.Duration.from(threshold), + { relativeTo: now }, + ) > 0; + + if ( + variant === 'absolute' || + (variant === 'auto' && isBeyondThreshold) || + !isRelativeTimeFormatSupported + ) { + const options = DATE_TIME_STYLE_MAP[formatStyle]; + return { + label: formatDateTime(zonedDateTime.toPlainDateTime(), locale, options), + interval: null, + }; + } + + const bestUnitIndex = UNITS.findIndex( + (unit) => + Temporal.Duration.compare(duration.abs(), unit.duration, { + relativeTo: now, + }) >= 0, + ); + // Use 'seconds' when no unit was found, i.e. when the duration is less than a second + const unitIndex = bestUnitIndex >= 0 ? bestUnitIndex : UNITS.length - 1; + const unit = UNITS[unitIndex]; + const value = duration.round({ + smallestUnit: unit.name, + relativeTo: now, + })[unit.name]; + const options = { style: formatStyle }; + + return { + label: formatRelativeTime(value, unit.name, locale, options), + interval: unit.interval, + }; +} diff --git a/packages/circuit-ui/components/Timestamp/index.ts b/packages/circuit-ui/components/Timestamp/index.ts new file mode 100644 index 0000000000..9bad3b62a2 --- /dev/null +++ b/packages/circuit-ui/components/Timestamp/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Timestamp } from './Timestamp.js'; + +export type { TimestampProps } from './Timestamp.js'; diff --git a/packages/circuit-ui/experimental.ts b/packages/circuit-ui/experimental.ts index d88415a6bb..dfe91e41b7 100644 --- a/packages/circuit-ui/experimental.ts +++ b/packages/circuit-ui/experimental.ts @@ -13,3 +13,7 @@ * limitations under the License. */ +export { + Timestamp, + type TimestampProps, +} from './components/Timestamp/index.js';