Skip to content

Commit

Permalink
Add Timestamp component
Browse files Browse the repository at this point in the history
  • Loading branch information
connor-baer committed Dec 6, 2024
1 parent 80b7f33 commit cce9b76
Show file tree
Hide file tree
Showing 7 changed files with 506 additions and 0 deletions.
3 changes: 3 additions & 0 deletions packages/circuit-ui/components/Timestamp/Timestamp.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.base {
font-variant-numeric: tabular-nums;
}
137 changes: 137 additions & 0 deletions packages/circuit-ui/components/Timestamp/Timestamp.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<Timestamp {...baseProps} className={className} />);
const element = screen.getByRole('time');
expect(element?.className).toContain(className);
});

it('should forward a ref to the outer element', () => {
const ref = createRef<HTMLTimeElement>();
render(<Timestamp {...baseProps} ref={ref} />);
const element = screen.getByRole('time');
expect(ref.current).toBe(element);
});

it('should have a valid `datetime` attribute', () => {
render(<Timestamp {...baseProps} />);
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(
<Timestamp {...baseProps} variant="absolute" formatStyle="narrow" />,
);
const element = screen.getByRole('time');
expect(element).toHaveTextContent('1/1/20');
});

it('should display a short human-readable date time', () => {
render(
<Timestamp {...baseProps} variant="absolute" formatStyle="short" />,
);
const element = screen.getByRole('time');
expect(element).toHaveTextContent('Jan 1, 2020');
});

it('should display a long human-readable date time', () => {
render(
<Timestamp {...baseProps} variant="absolute" formatStyle="long" />,
);
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(
<Timestamp {...baseProps} variant="relative" formatStyle="narrow" />,
);
const element = screen.getByRole('time');
expect(element).toHaveTextContent('1h ago');
});

it('should display a short human-readable date time', () => {
render(
<Timestamp {...baseProps} variant="relative" formatStyle="short" />,
);
const element = screen.getByRole('time');
expect(element).toHaveTextContent('1 hr. ago');
});

it('should display a long human-readable date time', () => {
render(
<Timestamp {...baseProps} variant="relative" formatStyle="long" />,
);
const element = screen.getByRole('time');
expect(element).toHaveTextContent('1 hour ago');
});

it('should update the time after an interval', () => {
render(
<Timestamp
datetime="2020-01-01T01:01+01:00[Europe/Berlin]"
variant="relative"
formatStyle="narrow"
/>,
);

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(<Timestamp {...baseProps} />);
const actual = await axe(container);
expect(actual).toHaveNoViolations();
});
});
83 changes: 83 additions & 0 deletions packages/circuit-ui/components/Timestamp/Timestamp.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => <Timestamp {...args} />;

Base.args = {
datetime: datetimes[2].toString(),
};

export const Relative = (args: TimestampProps) => (
<Stack>
{locales.map((locale) => (
<Stack vertical key={locale}>
{datetimes.map((datetime) => (
<Timestamp
{...args}
key={datetime.toString()}
datetime={datetime.toString()}
locale={locale}
/>
))}
</Stack>
))}
</Stack>
);

Relative.args = {
variant: 'relative',
};

export const Absolute = (args: TimestampProps) => (
<Stack>
{locales.map((locale) => (
<Stack vertical key={locale}>
{datetimes.map((datetime) => (
<Timestamp
{...args}
key={datetime.toString()}
datetime={datetime.toString()}
locale={locale}
/>
))}
</Stack>
))}
</Stack>
);

Absolute.args = {
variant: 'absolute',
};
121 changes: 121 additions & 0 deletions packages/circuit-ui/components/Timestamp/Timestamp.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTimeElement> {
/**
* 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<HTMLTimeElement, TimestampProps>(
(
{
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 (
<time
ref={ref}
dateTime={zonedDateTime.toString({ timeZoneName: 'never' })}
title={zonedDateTime.toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
})}
className={clsx(className, classes.base)}
{...props}
>
{state.label}
</time>
);
},
);
Loading

0 comments on commit cce9b76

Please sign in to comment.