= ({color, delay = '500ms', size = 24, style, rolePresentation}) => {
const {texts, platformOverrides} = useTheme();
- color = color || vars.colors.controlActivated;
+ const isInverse = useIsInverseVariant();
+ color = color || (isInverse ? vars.colors.controlActivatedInverse : vars.colors.controlActivated);
const spinnerId = useAriaId();
const withTitle = !rolePresentation;
const title = texts.loading;
diff --git a/src/theme.tsx b/src/theme.tsx
index c9ff0725fd..6c3111b14a 100644
--- a/src/theme.tsx
+++ b/src/theme.tsx
@@ -53,6 +53,21 @@ const TEXTS_ES = {
counterQuantity: 'cantidad',
counterMinValue: 'mínimo',
counterMaxValue: 'máximo',
+ timerDaysShortLabel: 'd',
+ timerHoursShortLabel: 'h',
+ timerMinutesShortLabel: 'min',
+ timerSecondsShortLabel: 's',
+ timerAnd: 'y',
+ timerDayLongLabel: 'día',
+ timerDaysLongLabel: 'días',
+ timerHourLongLabel: 'hora',
+ timerHoursLongLabel: 'horas',
+ timerMinuteLongLabel: 'minuto',
+ timerMinutesLongLabel: 'minutos',
+ timerSecondLongLabel: 'segundo',
+ timerSecondsLongLabel: 'segundos',
+ timerDisplayMinutesLabel: 'min',
+ timerDisplaySecondsLabel: 'seg',
};
const TEXTS_EN: ThemeTexts = {
@@ -101,6 +116,21 @@ const TEXTS_EN: ThemeTexts = {
counterQuantity: 'quantity',
counterMinValue: 'minimum of',
counterMaxValue: 'maximum of',
+ timerDaysShortLabel: 'd',
+ timerHoursShortLabel: 'h',
+ timerMinutesShortLabel: 'min',
+ timerSecondsShortLabel: 's',
+ timerAnd: 'and',
+ timerDayLongLabel: 'day',
+ timerDaysLongLabel: 'days',
+ timerHourLongLabel: 'hour',
+ timerHoursLongLabel: 'hours',
+ timerMinuteLongLabel: 'minute',
+ timerMinutesLongLabel: 'minutes',
+ timerSecondLongLabel: 'second',
+ timerSecondsLongLabel: 'seconds',
+ timerDisplayMinutesLabel: 'min',
+ timerDisplaySecondsLabel: 'sec',
};
const TEXTS_DE: ThemeTexts = {
@@ -149,6 +179,21 @@ const TEXTS_DE: ThemeTexts = {
counterQuantity: 'menge',
counterMinValue: 'minimal',
counterMaxValue: 'maximal',
+ timerDaysShortLabel: 'Tg.',
+ timerHoursShortLabel: 'Std.',
+ timerMinutesShortLabel: 'Min.',
+ timerSecondsShortLabel: 'Sek.',
+ timerAnd: 'und',
+ timerDayLongLabel: 'Tag',
+ timerDaysLongLabel: 'Tage',
+ timerHourLongLabel: 'Stunde',
+ timerHoursLongLabel: 'Stunden',
+ timerMinuteLongLabel: 'Minute',
+ timerMinutesLongLabel: 'Minuten',
+ timerSecondLongLabel: 'Sekunde',
+ timerSecondsLongLabel: 'Sekunden',
+ timerDisplayMinutesLabel: 'Min.',
+ timerDisplaySecondsLabel: 'Sek.',
};
const TEXTS_PT: ThemeTexts = {
@@ -197,6 +242,21 @@ const TEXTS_PT: ThemeTexts = {
counterQuantity: 'quantidade',
counterMinValue: 'mínimo',
counterMaxValue: 'máximo',
+ timerDaysShortLabel: 'd',
+ timerHoursShortLabel: 'h',
+ timerMinutesShortLabel: 'min',
+ timerSecondsShortLabel: 's',
+ timerAnd: 'e',
+ timerDayLongLabel: 'dia',
+ timerDaysLongLabel: 'dias',
+ timerHourLongLabel: 'hora',
+ timerHoursLongLabel: 'horas',
+ timerMinuteLongLabel: 'minuto',
+ timerMinutesLongLabel: 'minutos',
+ timerSecondLongLabel: 'segundo',
+ timerSecondsLongLabel: 'segundos',
+ timerDisplayMinutesLabel: 'min',
+ timerDisplaySecondsLabel: 'seg',
};
export const getTexts = (locale: Locale): typeof TEXTS_ES => {
diff --git a/src/timer.css.ts b/src/timer.css.ts
new file mode 100644
index 0000000000..c65396b062
--- /dev/null
+++ b/src/timer.css.ts
@@ -0,0 +1,75 @@
+import {style} from '@vanilla-extract/css';
+import {sprinkles} from './sprinkles.css';
+import * as mq from './media-queries.css';
+import {pxToRem} from './utils/css';
+import {vars} from './skins/skin-contract.css';
+
+export const timerWrapper = sprinkles({display: 'inline-block'});
+
+export const inlineText = style({
+ textDecoration: 'inherit',
+});
+
+export const unitContainer = style([
+ inlineText,
+ sprinkles({
+ display: 'inline-flex',
+ justifyContent: 'center',
+ }),
+]);
+
+export const shortLabelText = style([
+ inlineText,
+ sprinkles({
+ display: 'inline-block',
+ }),
+]);
+
+export const timerDisplayValue = style([
+ sprinkles({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ }),
+]);
+
+export const boxedTimerDisplayValue = style([
+ {
+ minWidth: pxToRem(64),
+ '@media': {
+ [mq.tabletOrSmaller]: {
+ minWidth: pxToRem(56),
+ },
+ },
+ },
+]);
+
+const baseBoxedTimerValueContainer = style([
+ sprinkles({
+ paddingX: 4,
+ paddingY: 8,
+ borderRadius: vars.borderRadii.container,
+ }),
+ {
+ '@media': {
+ [mq.tabletOrSmaller]: {
+ paddingTop: 10,
+ paddingBottom: 10,
+ },
+ },
+ },
+]);
+
+export const boxedTimerValueContainer = style([
+ baseBoxedTimerValueContainer,
+ sprinkles({
+ background: vars.colors.brandLow,
+ }),
+]);
+
+export const boxedTimerValueContainerInverse = style([
+ baseBoxedTimerValueContainer,
+ sprinkles({
+ background: vars.colors.backgroundContainer,
+ }),
+]);
diff --git a/src/timer.tsx b/src/timer.tsx
new file mode 100644
index 0000000000..38910c425d
--- /dev/null
+++ b/src/timer.tsx
@@ -0,0 +1,421 @@
+'use client';
+import classNames from 'classnames';
+import * as React from 'react';
+import Box from './box';
+import {useAriaId, useIsomorphicLayoutEffect, useTheme} from './hooks';
+import Inline from './inline';
+import ScreenReaderOnly from './screen-reader-only';
+import {Text2, Text6} from './text';
+import {ThemeVariant, useThemeVariant} from './theme-variant-context';
+import * as styles from './timer.css';
+import {getPrefixedDataAttributes} from './utils/dom';
+import {isEqual} from './utils/helpers';
+import {isRunningAcceptanceTest} from './utils/platform';
+
+import type {DataAttributes} from './utils/types';
+
+const DAY_IN_HOURS = 24;
+const HOUR_IN_MINUTES = 60;
+const MINUTE_IN_SECONDS = 60;
+const SECOND_IN_MS = 1000;
+
+const MINUTE_IN_MS = MINUTE_IN_SECONDS * SECOND_IN_MS;
+const HOUR_IN_MS = HOUR_IN_MINUTES * MINUTE_IN_MS;
+const DAY_IN_MS = DAY_IN_HOURS * HOUR_IN_MS;
+
+export type TimeUnit = 'days' | 'hours' | 'minutes' | 'seconds';
+type Label = 'none' | 'short' | 'long';
+
+export interface RemainingTime {
+ days?: number;
+ hours?: number;
+ minutes?: number;
+ seconds?: number;
+}
+
+interface BaseProps {
+ endTimestamp: Date | number;
+ minTimeUnit: TimeUnit;
+ maxTimeUnit: TimeUnit;
+ dataAttributes?: DataAttributes;
+ onProgress?: (value: RemainingTime) => void;
+ 'aria-label'?: string;
+}
+
+interface TextTimerProps extends BaseProps {
+ labelType?: Label;
+}
+
+interface TimerProps extends BaseProps {
+ boxed?: boolean;
+}
+
+const shouldRenderUnit = (
+ unit: TimeUnit,
+ minTimeUnit: TimeUnit,
+ maxTimeUnit: TimeUnit,
+ labelType?: Label
+) => {
+ // If label is "none", days shouldn't be displayed
+ minTimeUnit = labelType === 'none' && minTimeUnit === 'days' ? 'hours' : minTimeUnit;
+ maxTimeUnit = labelType === 'none' && maxTimeUnit === 'days' ? 'hours' : maxTimeUnit;
+
+ const unitsOrder: Array
= ['seconds', 'minutes', 'hours', 'days'];
+
+ const minValue = unitsOrder.indexOf(minTimeUnit);
+ // If max < min, we display only the min unit
+ const maxValue = Math.max(unitsOrder.indexOf(maxTimeUnit), minValue);
+ const unitValue = unitsOrder.indexOf(unit);
+
+ return minValue <= unitValue && unitValue <= maxValue;
+};
+
+const getFilteredTimerValue = (
+ timestamp: RemainingTime,
+ minTimeUnit: TimeUnit,
+ maxTimeUnit: TimeUnit,
+ labelType?: Label
+) => {
+ return (
+ [
+ {unit: 'days', value: timestamp.days},
+ {unit: 'hours', value: timestamp.hours},
+ {unit: 'minutes', value: timestamp.minutes},
+ {unit: 'seconds', value: timestamp.seconds},
+ ] as Array<{unit: TimeUnit; value: number}>
+ ).filter((item) => shouldRenderUnit(item.unit, minTimeUnit, maxTimeUnit, labelType));
+};
+
+const getRemainingTime = (endTimestamp: Date | number) => {
+ // Always return 0 ms remaining for screenshot tests to avoid unstable values caused by delays in browser
+ if (isRunningAcceptanceTest()) {
+ return 0;
+ }
+
+ return Math.max(
+ 0,
+ (typeof endTimestamp === 'object' ? endTimestamp : new Date(endTimestamp)).valueOf() - Date.now()
+ );
+};
+
+const useTimerState = ({
+ endTimestamp,
+ labelType,
+ minTimeUnit,
+ maxTimeUnit,
+ onProgress,
+}: {
+ endTimestamp: Date | number;
+ labelType?: Label;
+ minTimeUnit: TimeUnit;
+ maxTimeUnit: TimeUnit;
+ onProgress?: (value: RemainingTime) => void;
+}) => {
+ const [remainingTime, setRemainingTime] = React.useState(getRemainingTime(endTimestamp));
+
+ useIsomorphicLayoutEffect(() => {
+ let intervalId: NodeJS.Timeout;
+
+ const updateCurrentTime = () => {
+ const currentRemainingTime = getRemainingTime(endTimestamp);
+ setRemainingTime(currentRemainingTime);
+
+ // Stop computing values if there is no time remaining
+ if (!currentRemainingTime) {
+ clearInterval(intervalId);
+ }
+ };
+
+ if (!isRunningAcceptanceTest()) {
+ updateCurrentTime();
+ intervalId = setInterval(updateCurrentTime, SECOND_IN_MS);
+ return () => clearInterval(intervalId);
+ }
+ }, [endTimestamp]);
+
+ const shouldRenderDays = shouldRenderUnit('days', minTimeUnit, maxTimeUnit, labelType);
+ const shouldRenderHours = shouldRenderUnit('hours', minTimeUnit, maxTimeUnit, labelType);
+ const shouldRenderMinutes = shouldRenderUnit('minutes', minTimeUnit, maxTimeUnit, labelType);
+
+ const maximumRenderedUnit = shouldRenderDays
+ ? 'days'
+ : shouldRenderHours
+ ? 'hours'
+ : shouldRenderMinutes
+ ? 'minutes'
+ : 'seconds';
+
+ const currentHours = Math.floor(remainingTime / HOUR_IN_MS) % 24;
+ const currentMinutes = Math.floor(remainingTime / MINUTE_IN_MS) % 60;
+ const currentSeconds = Math.floor(remainingTime / SECOND_IN_MS) % 60;
+
+ const days = Math.floor(remainingTime / DAY_IN_MS);
+
+ // if hours is the maximum unit, add remaining days
+ const hours = maximumRenderedUnit === 'hours' ? currentHours + days * DAY_IN_HOURS : currentHours;
+
+ // if minutes is the maximum unit, add remaining days and hours
+ const minutes =
+ maximumRenderedUnit === 'minutes'
+ ? currentMinutes + HOUR_IN_MINUTES * (days * DAY_IN_HOURS + hours)
+ : currentMinutes;
+
+ // if minutes is the maximum unit, add remaining days, hours and minutes
+ const seconds =
+ maximumRenderedUnit === 'seconds'
+ ? currentSeconds +
+ MINUTE_IN_SECONDS * (days * DAY_IN_HOURS * HOUR_IN_MINUTES + hours * HOUR_IN_MINUTES + minutes)
+ : currentSeconds;
+
+ const [timerValue, setTimerValue] = React.useState(
+ getFilteredTimerValue({days, hours, minutes, seconds}, minTimeUnit, maxTimeUnit, labelType)
+ );
+
+ React.useEffect(() => {
+ const currentTimerValue = getFilteredTimerValue(
+ {days, hours, minutes, seconds},
+ minTimeUnit,
+ maxTimeUnit,
+ labelType
+ );
+
+ if (!isEqual(currentTimerValue, timerValue)) {
+ setTimerValue(currentTimerValue);
+ const timestampValue: RemainingTime = {};
+ currentTimerValue.forEach((item) => (timestampValue[item.unit] = item.value));
+ onProgress?.(timestampValue);
+ }
+ }, [days, hours, minutes, seconds, labelType, minTimeUnit, maxTimeUnit, timerValue, onProgress]);
+
+ return timerValue;
+};
+
+export const TextTimer: React.FC = ({
+ endTimestamp,
+ labelType = 'none',
+ minTimeUnit,
+ maxTimeUnit,
+ onProgress,
+ dataAttributes,
+ 'aria-label': ariaLabel,
+}) => {
+ const {texts} = useTheme();
+ const labelId = useAriaId();
+
+ const timerValue = useTimerState({endTimestamp, labelType, minTimeUnit, maxTimeUnit, onProgress});
+
+ const unitShortLabel: {[key in TimeUnit]: string} = {
+ days: texts.timerDaysShortLabel,
+ hours: texts.timerHoursShortLabel,
+ minutes: texts.timerMinutesShortLabel,
+ seconds: texts.timerSecondsShortLabel,
+ };
+
+ const unitLabel: {[key in TimeUnit]: string} = {
+ days: texts.timerDayLongLabel,
+ hours: texts.timerHourLongLabel,
+ minutes: texts.timerMinuteLongLabel,
+ seconds: texts.timerSecondLongLabel,
+ };
+
+ const unitLabelPlural: {[key in TimeUnit]: string} = {
+ days: texts.timerDaysLongLabel,
+ hours: texts.timerHoursLongLabel,
+ minutes: texts.timerMinutesLongLabel,
+ seconds: texts.timerSecondsLongLabel,
+ };
+
+ const renderFormattedNumber = (value: number) => {
+ const digitCount = Math.max(String(value).length, labelType === 'long' ? 1 : 2);
+
+ // Set container's minWidth in ch to avoid it from updating it's width when numbers change
+ return (
+
+ {String(value).padStart(digitCount, '0')}
+
+ );
+ };
+
+ const renderTime = () => {
+ switch (labelType) {
+ case 'none':
+ return timerValue.map((item, index) => (
+
+ {index > 0 && ':'}
+ {renderFormattedNumber(item.value)}
+
+ ));
+
+ case 'short':
+ return timerValue.map((item, index) => (
+
+ {index > 0 && ' '}
+
+ {renderFormattedNumber(item.value)}
+ {` ${unitShortLabel[item.unit]}`}
+
+
+ ));
+
+ case 'long':
+ default:
+ return timerValue.map((item, index) => (
+
+ {index > 0 && ' '}
+ {renderFormattedNumber(item.value)}
+ {` ${item.value === 1 ? unitLabel[item.unit] : unitLabelPlural[item.unit]}`}
+ {index === timerValue.length - 2 && ` ${texts.timerAnd}`}
+ {index < timerValue.length - 2 && ','}
+
+ ));
+ }
+ };
+
+ const timerLabel = timerValue
+ .map(
+ (item, index) =>
+ `${item.value} ${item.value === 1 ? unitLabel[item.unit] : unitLabelPlural[item.unit]}${
+ index === timerValue.length - 1
+ ? ''
+ : index === timerValue.length - 2
+ ? ` ${texts.timerAnd} `
+ : ', '
+ }`
+ )
+ .join('');
+
+ return (
+
+
+ {ariaLabel ? `${ariaLabel}. ${timerLabel}` : timerLabel}
+
+
+
+ {renderTime()}
+
+
+ );
+};
+
+export const Timer: React.FC = ({
+ boxed,
+ endTimestamp,
+ minTimeUnit,
+ maxTimeUnit,
+ onProgress,
+ dataAttributes,
+ 'aria-label': ariaLabel,
+}) => {
+ const {texts} = useTheme();
+ const labelId = useAriaId();
+ const themeVariant = useThemeVariant();
+
+ const timerValue = useTimerState({endTimestamp, minTimeUnit, maxTimeUnit, onProgress});
+
+ const displayLabel: {[key in TimeUnit]: string} = {
+ days: texts.timerDayLongLabel,
+ hours: texts.timerHourLongLabel,
+ minutes: texts.timerDisplayMinutesLabel,
+ seconds: texts.timerDisplaySecondsLabel,
+ };
+
+ const displayLabelPlural: {[key in TimeUnit]: string} = {
+ days: texts.timerDaysLongLabel,
+ hours: texts.timerHoursLongLabel,
+ minutes: texts.timerDisplayMinutesLabel,
+ seconds: texts.timerDisplaySecondsLabel,
+ };
+
+ const unitLabel: {[key in TimeUnit]: string} = {
+ days: texts.timerDayLongLabel,
+ hours: texts.timerHourLongLabel,
+ minutes: texts.timerMinuteLongLabel,
+ seconds: texts.timerSecondLongLabel,
+ };
+
+ const unitLabelPlural: {[key in TimeUnit]: string} = {
+ days: texts.timerDaysLongLabel,
+ hours: texts.timerHoursLongLabel,
+ minutes: texts.timerMinutesLongLabel,
+ seconds: texts.timerSecondsLongLabel,
+ };
+
+ const renderFormattedNumber = (value: number) => {
+ const digitCount = Math.max(String(value).length, 2);
+
+ // Set container's minWidth in ch to avoid it from updating it's width when numbers change
+ return (
+
+
+ {String(value).padStart(digitCount, '0')}
+
+
+ );
+ };
+
+ const timerLabel = timerValue
+ .map(
+ (item, index) =>
+ `${item.value} ${item.value === 1 ? unitLabel[item.unit] : unitLabelPlural[item.unit]}${
+ index === timerValue.length - 1
+ ? ''
+ : index === timerValue.length - 2
+ ? ` ${texts.timerAnd} `
+ : ', '
+ }`
+ )
+ .join('');
+
+ const renderTime = () => {
+ return timerValue.map((item, index) => (
+
+
+
+ {renderFormattedNumber(item.value)}
+
+ {item.value === 1 ? displayLabel[item.unit] : displayLabelPlural[item.unit]}
+
+
+
+
+ ));
+ };
+
+ return (
+
+
+ {ariaLabel ? `${ariaLabel}. ${timerLabel}` : timerLabel}
+
+
+
+
+ {renderTime()}
+
+
+
+ );
+};
diff --git a/src/utils/__tests__/helpers-test.tsx b/src/utils/__tests__/helpers-test.tsx
index bacd935737..272f714fa1 100644
--- a/src/utils/__tests__/helpers-test.tsx
+++ b/src/utils/__tests__/helpers-test.tsx
@@ -131,6 +131,7 @@ test('isEqual happy case', () => {
n: 123,
s: 'abc',
b: true,
+ nan: NaN,
nul: null,
und: undefined,
arr: [1, false, null, undefined, new Date(1234567890), {a: 1, b: 2, c: 3}],
@@ -143,6 +144,7 @@ test('isEqual happy case', () => {
n: 123,
s: 'abc',
b: true,
+ nan: NaN,
nul: null,
und: undefined,
arr: [1, false, null, undefined, new Date(1234567890), {a: 1, b: 2, c: 3}],
diff --git a/src/utils/helpers.tsx b/src/utils/helpers.tsx
index d9b1bb33c9..a2a97f9cbe 100644
--- a/src/utils/helpers.tsx
+++ b/src/utils/helpers.tsx
@@ -141,6 +141,10 @@ export const isEqual = (a: unknown, b: unknown): boolean => {
return true;
}
+ if (typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b)) {
+ return true;
+ }
+
if (isPrimitive(a) || isPrimitive(b)) {
return false;
}