@astryxdesign/core 0.6.3-canary.e9b8aa1 → 0.6.3-canary.ea2f048
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.
- package/dist/Timer/Timer.d.ts +49 -0
- package/dist/Timer/Timer.d.ts.map +1 -0
- package/dist/Timer/Timer.js +158 -0
- package/dist/Timer/index.d.ts +9 -0
- package/dist/Timer/index.d.ts.map +1 -0
- package/dist/Timer/index.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/package.json +8 -3
- package/src/Timer/Timer.doc.mjs +183 -0
- package/src/Timer/Timer.spec.md +215 -0
- package/src/Timer/Timer.test.tsx +302 -0
- package/src/Timer/Timer.tsx +258 -0
- package/src/Timer/index.ts +11 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { BaseProps } from '../BaseProps';
|
|
2
|
+
import type { TextColor, TextSize, TextType, TextWeight } from '../theme/types';
|
|
3
|
+
export type TimerFormat = 'elapsed' | 'clock';
|
|
4
|
+
export interface TimerProps extends Omit<BaseProps<HTMLTimeElement>, 'dateTime'> {
|
|
5
|
+
/** Ref forwarded to the rendered `<time>` element. */
|
|
6
|
+
ref?: React.Ref<HTMLTimeElement>;
|
|
7
|
+
/**
|
|
8
|
+
* Unix time in milliseconds when the measured operation began. Omit it to
|
|
9
|
+
* start counting from this Timer's mount.
|
|
10
|
+
*/
|
|
11
|
+
startTime?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Standard duration representation.
|
|
14
|
+
* @default 'elapsed'
|
|
15
|
+
*/
|
|
16
|
+
format?: TimerFormat;
|
|
17
|
+
/**
|
|
18
|
+
* Semantic text type. Matches Timestamp typography behavior.
|
|
19
|
+
* @default 'supporting'
|
|
20
|
+
*/
|
|
21
|
+
type?: TextType;
|
|
22
|
+
/** Explicit font size override. Overrides the size from `type`. */
|
|
23
|
+
size?: TextSize;
|
|
24
|
+
/**
|
|
25
|
+
* Text color.
|
|
26
|
+
* @default 'secondary'
|
|
27
|
+
*/
|
|
28
|
+
color?: TextColor;
|
|
29
|
+
/** Font weight override. */
|
|
30
|
+
weight?: TextWeight;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Displays a standardized elapsed duration without scheduling React tick renders.
|
|
34
|
+
*
|
|
35
|
+
* Timer writes changing text and its ISO 8601 duration directly to the owned
|
|
36
|
+
* `<time>` node. Use `elapsed` for compact duration text or `clock` for a
|
|
37
|
+
* stopwatch-like reading.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```
|
|
41
|
+
* <Timer />
|
|
42
|
+
* <Timer format="clock" />
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function Timer({ startTime, format, type, size, color, weight, ref, xstyle, className, style, ...rest }: TimerProps): import("react").JSX.Element;
|
|
46
|
+
export declare namespace Timer {
|
|
47
|
+
var displayName: string;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=Timer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Timer.d.ts","sourceRoot":"","sources":["../../src/Timer/Timer.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAG5C,OAAO,KAAK,EAAC,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAC,MAAM,gBAAgB,CAAC;AAsB9E,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AA8E9C,MAAM,WAAW,UAAW,SAAQ,IAAI,CACtC,SAAS,CAAC,eAAe,CAAC,EAC1B,UAAU,CACX;IACC,sDAAsD;IACtD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IACjC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;OAGG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,mEAAmE;IACnE,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,4BAA4B;IAC5B,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,KAAK,CAAC,EACpB,SAAS,EACT,MAAkB,EAClB,IAAmB,EACnB,IAAI,EACJ,KAAmB,EACnB,MAAM,EACN,GAAG,EACH,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,UAAU,+BA0EZ;yBAtFe,KAAK"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file Timer.tsx
|
|
7
|
+
* @input Uses an optional start time, standardized format, Timestamp typography, BaseProps, and React ref
|
|
8
|
+
* @output Exports Timer, TimerProps, and TimerFormat with non-rendering elapsed-time updates
|
|
9
|
+
* @position Core content primitive for elapsed duration in active operations
|
|
10
|
+
*
|
|
11
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
12
|
+
* - /packages/core/src/Timer/Timer.spec.md
|
|
13
|
+
* - /packages/core/src/Timer/Timer.doc.mjs
|
|
14
|
+
* - /packages/core/src/Timer/Timer.test.tsx
|
|
15
|
+
* - /packages/core/src/Timer/index.ts
|
|
16
|
+
* - /apps/storybook/stories/Timer.stories.tsx
|
|
17
|
+
* - /packages/cli/assets/templates/blocks/components/Timer/
|
|
18
|
+
*/
|
|
19
|
+
import { useEffect, useRef, useState } from 'react';
|
|
20
|
+
import * as stylex from '@stylexjs/stylex';
|
|
21
|
+
import { useMergedRefs } from "../hooks/useMergedRefs.js";
|
|
22
|
+
import { Text } from "../Text/index.js";
|
|
23
|
+
import { mergeProps } from "../utils/index.js";
|
|
24
|
+
import { themeProps } from "../utils/themeProps.js";
|
|
25
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
26
|
+
const ONE_SECOND_MS = 1000;
|
|
27
|
+
const ONE_MINUTE_MS = 60 * ONE_SECOND_MS;
|
|
28
|
+
const ONE_HOUR_SECONDS = 60 * 60;
|
|
29
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
30
|
+
function pad(value) {
|
|
31
|
+
return String(value).padStart(2, '0');
|
|
32
|
+
}
|
|
33
|
+
function resolveFormat(format) {
|
|
34
|
+
return format === 'clock' ? 'clock' : 'elapsed';
|
|
35
|
+
}
|
|
36
|
+
function getElapsedMilliseconds(now, startTime) {
|
|
37
|
+
return Math.max(0, now - startTime);
|
|
38
|
+
}
|
|
39
|
+
function getPresentation(elapsedMilliseconds, format) {
|
|
40
|
+
const elapsedSeconds = Math.floor(elapsedMilliseconds / ONE_SECOND_MS);
|
|
41
|
+
if (format === 'clock') {
|
|
42
|
+
const hours = Math.floor(elapsedSeconds / ONE_HOUR_SECONDS);
|
|
43
|
+
const minutes = Math.floor(elapsedSeconds % ONE_HOUR_SECONDS / 60);
|
|
44
|
+
const seconds = elapsedSeconds % 60;
|
|
45
|
+
return {
|
|
46
|
+
dateTime: `PT${elapsedSeconds}S`,
|
|
47
|
+
text: hours > 0 ? `${String(hours)}:${pad(minutes)}:${pad(seconds)}` : `${String(minutes)}:${pad(seconds)}`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (elapsedSeconds < 60) {
|
|
51
|
+
return {
|
|
52
|
+
dateTime: `PT${elapsedSeconds}S`,
|
|
53
|
+
text: `${String(elapsedSeconds)}s`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const totalMinutes = Math.floor(elapsedSeconds / 60);
|
|
57
|
+
if (totalMinutes < 60) {
|
|
58
|
+
return {
|
|
59
|
+
dateTime: `PT${elapsedSeconds}S`,
|
|
60
|
+
text: `${String(totalMinutes)}m ${pad(elapsedSeconds % 60)}s`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const representedSeconds = totalMinutes * 60;
|
|
64
|
+
return {
|
|
65
|
+
dateTime: `PT${representedSeconds}S`,
|
|
66
|
+
text: `${String(Math.floor(totalMinutes / 60))}h ${pad(totalMinutes % 60)}m`
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function getMillisecondsUntilNextChange(now, startTime, elapsedMilliseconds, format) {
|
|
70
|
+
if (now < startTime) {
|
|
71
|
+
return Math.min(startTime - now + ONE_SECOND_MS, MAX_TIMEOUT_MS);
|
|
72
|
+
}
|
|
73
|
+
const precision = format === 'elapsed' && elapsedMilliseconds >= ONE_HOUR_SECONDS * ONE_SECOND_MS ? ONE_MINUTE_MS : ONE_SECOND_MS;
|
|
74
|
+
return precision - elapsedMilliseconds % precision;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Displays a standardized elapsed duration without scheduling React tick renders.
|
|
78
|
+
*
|
|
79
|
+
* Timer writes changing text and its ISO 8601 duration directly to the owned
|
|
80
|
+
* `<time>` node. Use `elapsed` for compact duration text or `clock` for a
|
|
81
|
+
* stopwatch-like reading.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```
|
|
85
|
+
* <Timer />
|
|
86
|
+
* <Timer format="clock" />
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function Timer({
|
|
90
|
+
startTime,
|
|
91
|
+
format = 'elapsed',
|
|
92
|
+
type = 'supporting',
|
|
93
|
+
size,
|
|
94
|
+
color = 'secondary',
|
|
95
|
+
weight,
|
|
96
|
+
ref,
|
|
97
|
+
xstyle,
|
|
98
|
+
className,
|
|
99
|
+
style,
|
|
100
|
+
...rest
|
|
101
|
+
}) {
|
|
102
|
+
const [mountTime] = useState(() => Date.now());
|
|
103
|
+
const timerRef = useRef(null);
|
|
104
|
+
const mergedRef = useMergedRefs(ref, timerRef);
|
|
105
|
+
const resolvedFormat = resolveFormat(format);
|
|
106
|
+
const initialPresentation = getPresentation(0, resolvedFormat);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const resolvedStartTime = startTime !== undefined && Number.isFinite(startTime) ? startTime : mountTime;
|
|
109
|
+
let timeoutID;
|
|
110
|
+
let previousDateTime;
|
|
111
|
+
let previousText;
|
|
112
|
+
const tick = () => {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const elapsedMilliseconds = getElapsedMilliseconds(now, resolvedStartTime);
|
|
115
|
+
const presentation = getPresentation(elapsedMilliseconds, resolvedFormat);
|
|
116
|
+
const node = timerRef.current;
|
|
117
|
+
if (node != null) {
|
|
118
|
+
if (presentation.text !== previousText) {
|
|
119
|
+
node.textContent = presentation.text;
|
|
120
|
+
previousText = presentation.text;
|
|
121
|
+
}
|
|
122
|
+
if (presentation.dateTime !== previousDateTime) {
|
|
123
|
+
node.dateTime = presentation.dateTime;
|
|
124
|
+
previousDateTime = presentation.dateTime;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
timeoutID = setTimeout(tick, getMillisecondsUntilNextChange(now, resolvedStartTime, elapsedMilliseconds, resolvedFormat));
|
|
128
|
+
};
|
|
129
|
+
tick();
|
|
130
|
+
return () => {
|
|
131
|
+
if (timeoutID !== undefined) {
|
|
132
|
+
clearTimeout(timeoutID);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}, [mountTime, resolvedFormat, startTime]);
|
|
136
|
+
const timerProps = mergeProps(themeProps('timer'), {
|
|
137
|
+
className,
|
|
138
|
+
style
|
|
139
|
+
});
|
|
140
|
+
return /*#__PURE__*/_jsx(Text, {
|
|
141
|
+
type: type,
|
|
142
|
+
size: size,
|
|
143
|
+
color: color,
|
|
144
|
+
weight: weight,
|
|
145
|
+
xstyle: xstyle,
|
|
146
|
+
...timerProps,
|
|
147
|
+
children: /*#__PURE__*/_jsx("time", {
|
|
148
|
+
...rest,
|
|
149
|
+
ref: mergedRef,
|
|
150
|
+
dateTime: "PT0S",
|
|
151
|
+
...{
|
|
152
|
+
className: "x1heor9g xt0psk2 xjb2p0i x1qlqyl8 x1j61x8r xss6m8b x1pd3egz x15bjb6t"
|
|
153
|
+
},
|
|
154
|
+
children: initialPresentation.text
|
|
155
|
+
})
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
Timer.displayName = 'Timer';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file index.ts
|
|
3
|
+
* @input Imports Timer, TimerProps, and TimerFormat
|
|
4
|
+
* @output Public Timer component barrel
|
|
5
|
+
* @position Component subpath entry point for @astryxdesign/core/Timer
|
|
6
|
+
*/
|
|
7
|
+
export { Timer } from './Timer';
|
|
8
|
+
export type { TimerFormat, TimerProps } from './Timer';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/Timer/index.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,OAAO,EAAC,KAAK,EAAC,MAAM,SAAS,CAAC;AAC9B,YAAY,EAAC,WAAW,EAAE,UAAU,EAAC,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file index.ts
|
|
5
|
+
* @input Imports Timer, TimerProps, and TimerFormat
|
|
6
|
+
* @output Public Timer component barrel
|
|
7
|
+
* @position Component subpath entry point for @astryxdesign/core/Timer
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { Timer } from "./Timer.js";
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -133,6 +133,9 @@ export * from "./Spinner/index.js";
|
|
|
133
133
|
// Timestamp display
|
|
134
134
|
export * from "./Timestamp/index.js";
|
|
135
135
|
|
|
136
|
+
// Elapsed timer display
|
|
137
|
+
export * from "./Timer/index.js";
|
|
138
|
+
|
|
136
139
|
// Overlay
|
|
137
140
|
export * from "./Overlay/index.js";
|
|
138
141
|
export * from "./Outline/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.6.3-canary.
|
|
3
|
+
"version": "0.6.3-canary.ea2f048",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -539,6 +539,11 @@
|
|
|
539
539
|
"types": "./dist/TimeInput/index.d.ts",
|
|
540
540
|
"default": "./dist/TimeInput/index.js"
|
|
541
541
|
},
|
|
542
|
+
"./Timer": {
|
|
543
|
+
"source": "./src/Timer/index.ts",
|
|
544
|
+
"types": "./dist/Timer/index.d.ts",
|
|
545
|
+
"default": "./dist/Timer/index.js"
|
|
546
|
+
},
|
|
542
547
|
"./Timestamp": {
|
|
543
548
|
"source": "./src/Timestamp/index.ts",
|
|
544
549
|
"types": "./dist/Timestamp/index.d.ts",
|
|
@@ -687,8 +692,8 @@
|
|
|
687
692
|
"react-dom": ">=19.0.0"
|
|
688
693
|
},
|
|
689
694
|
"devDependencies": {
|
|
690
|
-
"@astryxdesign/a11y-spec": "0.6.3-canary.
|
|
691
|
-
"@astryxdesign/cli": "0.6.3-canary.
|
|
695
|
+
"@astryxdesign/a11y-spec": "0.6.3-canary.ea2f048",
|
|
696
|
+
"@astryxdesign/cli": "0.6.3-canary.ea2f048",
|
|
692
697
|
"@babel/cli": "^7.29.7",
|
|
693
698
|
"@babel/core": "^7.29.7",
|
|
694
699
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').ComponentAnatomyElement[]} */
|
|
4
|
+
const anatomy = [
|
|
5
|
+
{
|
|
6
|
+
name: 'Elapsed time',
|
|
7
|
+
required: true,
|
|
8
|
+
description:
|
|
9
|
+
'Semantic time element containing a standardized elapsed duration.',
|
|
10
|
+
},
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
|
|
14
|
+
export const docs = {
|
|
15
|
+
name: 'Timer',
|
|
16
|
+
displayName: 'Timer',
|
|
17
|
+
category: 'Content',
|
|
18
|
+
keywords: [
|
|
19
|
+
'timer',
|
|
20
|
+
'elapsed',
|
|
21
|
+
'duration',
|
|
22
|
+
'seconds',
|
|
23
|
+
'minutes',
|
|
24
|
+
'hours',
|
|
25
|
+
'stopwatch',
|
|
26
|
+
'waiting',
|
|
27
|
+
'loading',
|
|
28
|
+
'processing',
|
|
29
|
+
],
|
|
30
|
+
props: [
|
|
31
|
+
{
|
|
32
|
+
name: 'startTime',
|
|
33
|
+
type: 'number',
|
|
34
|
+
description:
|
|
35
|
+
"Unix time in milliseconds when the measured operation began. Omit it to start from this Timer's mount.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'format',
|
|
39
|
+
type: "'elapsed' | 'clock'",
|
|
40
|
+
description:
|
|
41
|
+
'Standard duration representation. Elapsed uses compact units and drops seconds after one hour; clock uses m:ss or h:mm:ss.',
|
|
42
|
+
default: "'elapsed'",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'type',
|
|
46
|
+
type: "'body' | 'large' | 'label' | 'supporting' | 'code' | 'display-1' | 'display-2' | 'display-3' | 'inherit'",
|
|
47
|
+
description:
|
|
48
|
+
'Semantic text type. Uses the same typography behavior as Timestamp.',
|
|
49
|
+
default: "'supporting'",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'size',
|
|
53
|
+
type: "'4xs' | '3xs' | '2xs' | 'xsm' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'",
|
|
54
|
+
description: 'Explicit font size override. Overrides the size from type.',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'color',
|
|
58
|
+
type: "'primary' | 'secondary' | 'disabled' | 'placeholder' | 'accent' | 'inherit'",
|
|
59
|
+
description: 'Text color.',
|
|
60
|
+
default: "'secondary'",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'weight',
|
|
64
|
+
type: "'normal' | 'medium' | 'semibold' | 'bold'",
|
|
65
|
+
description: 'Font weight override.',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'xstyle',
|
|
69
|
+
type: 'StyleXStyles',
|
|
70
|
+
description:
|
|
71
|
+
'StyleX styles for the Text wrapper. Must be a stylex.create() value.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'className',
|
|
75
|
+
type: 'string',
|
|
76
|
+
description:
|
|
77
|
+
'CSS class name for the Text wrapper. Prefer xstyle for styling.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'style',
|
|
81
|
+
type: 'CSSProperties',
|
|
82
|
+
description:
|
|
83
|
+
'Inline styles for the Text wrapper. Prefer xstyle for styling.',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
examples: [
|
|
87
|
+
{
|
|
88
|
+
label: 'Elapsed duration',
|
|
89
|
+
code: '<Timer />',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
label: 'Stopwatch clock',
|
|
93
|
+
code: '<Timer format="clock" />',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: 'Operation that started before mount',
|
|
97
|
+
code: '<Timer startTime={operationStartedAt} />',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
label: 'Match surrounding text',
|
|
101
|
+
code: `<Text>
|
|
102
|
+
Processing for <Timer type="inherit" color="inherit" />
|
|
103
|
+
</Text>`,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
label: 'Prominent elapsed time',
|
|
107
|
+
code: '<Timer type="body" size="lg" color="primary" weight="semibold" />',
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
theming: {
|
|
111
|
+
targets: [{className: 'astryx-timer'}],
|
|
112
|
+
},
|
|
113
|
+
usage: {
|
|
114
|
+
anatomy,
|
|
115
|
+
description:
|
|
116
|
+
'Displays a standardized elapsed duration for active work without scheduling a React render on every tick. Elapsed format updates by second below one hour and by minute after one hour; clock format remains second-precise.',
|
|
117
|
+
bestPractices: [
|
|
118
|
+
{
|
|
119
|
+
guidance: true,
|
|
120
|
+
description:
|
|
121
|
+
'Use elapsed for compact duration text that may span seconds, minutes, or hours.',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
guidance: true,
|
|
125
|
+
description:
|
|
126
|
+
'Use clock for stopwatch-like surfaces where seconds remain meaningful after an hour.',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
guidance: true,
|
|
130
|
+
description:
|
|
131
|
+
'Pass startTime when the operation began before Timer mounted so the display reflects the complete wait.',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
guidance: false,
|
|
135
|
+
description:
|
|
136
|
+
'Do not use Timer for dates, time zones, or relative calendar language; use Timestamp instead.',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
guidance: false,
|
|
140
|
+
description:
|
|
141
|
+
'Do not add aria-live unless hearing an announcement every tick is appropriate for the specific task.',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
|
|
148
|
+
export const docsDense = {
|
|
149
|
+
description:
|
|
150
|
+
'Standardized elapsed or stopwatch duration with clock-derived, non-rendering DOM updates.',
|
|
151
|
+
propDescriptions: {
|
|
152
|
+
startTime:
|
|
153
|
+
"operation start as Unix milliseconds; omit to count from Timer's mount",
|
|
154
|
+
format: 'elapsed compact units or clock stopwatch notation',
|
|
155
|
+
type: 'semantic text type; defaults to supporting like Timestamp',
|
|
156
|
+
size: 'explicit font size override',
|
|
157
|
+
color: 'text color; defaults to secondary like Timestamp',
|
|
158
|
+
weight: 'font weight override',
|
|
159
|
+
xstyle: 'StyleX styles for the Text wrapper',
|
|
160
|
+
className: 'CSS class for the Text wrapper',
|
|
161
|
+
style: 'inline styles for the Text wrapper',
|
|
162
|
+
},
|
|
163
|
+
usage: {
|
|
164
|
+
anatomy,
|
|
165
|
+
description:
|
|
166
|
+
'Use for active-operation elapsed time when periodic React renders would add avoidable work.',
|
|
167
|
+
bestPractices: [
|
|
168
|
+
{
|
|
169
|
+
guidance: true,
|
|
170
|
+
description:
|
|
171
|
+
'Use elapsed for compact durations and clock for stopwatch UI.',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
guidance: true,
|
|
175
|
+
description: 'Pass startTime for work that began before mount.',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
guidance: false,
|
|
179
|
+
description: 'Use Timestamp for dates and relative calendar language.',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
---
|
|
2
|
+
schema_version: 3
|
|
3
|
+
template_version: 5
|
|
4
|
+
kind: component
|
|
5
|
+
id: component:Timer
|
|
6
|
+
authority: current
|
|
7
|
+
archive_reason: null
|
|
8
|
+
superseded_by: null
|
|
9
|
+
approved_by: cixzhang
|
|
10
|
+
approved_at: 2026-09-22
|
|
11
|
+
owners: [cixzhang]
|
|
12
|
+
review_triggers: [public-api, behavior, theming, accessibility, react-runtime]
|
|
13
|
+
verified_by:
|
|
14
|
+
[
|
|
15
|
+
packages/core/src/Timer/Timer.test.tsx,
|
|
16
|
+
packages/core/src/theme/themingTargets.test.ts,
|
|
17
|
+
scripts/check-knowledge.mjs,
|
|
18
|
+
]
|
|
19
|
+
modules: []
|
|
20
|
+
families: []
|
|
21
|
+
design_specs: []
|
|
22
|
+
architecture:
|
|
23
|
+
[
|
|
24
|
+
architecture:public-component-api,
|
|
25
|
+
architecture:component-theming-surface,
|
|
26
|
+
architecture:react-component-runtime,
|
|
27
|
+
]
|
|
28
|
+
contributing: []
|
|
29
|
+
system_specs:
|
|
30
|
+
[
|
|
31
|
+
spec:AST-002/DEC-1,
|
|
32
|
+
spec:AST-037/DEC-1,
|
|
33
|
+
spec:AST-037/DEC-2,
|
|
34
|
+
spec:AST-037/DEC-3,
|
|
35
|
+
]
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
# Timer component contract
|
|
39
|
+
|
|
40
|
+
## Contract at a glance
|
|
41
|
+
|
|
42
|
+
| Area | Contract |
|
|
43
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| Public contract | `Timer`, `TimerProps`, `TimerFormat`, optional `startTime` Unix milliseconds, `elapsed` or `clock` format, Timestamp-equivalent `type`, `size`, `color`, and `weight`, `<time>` ref, and `BaseProps<HTMLTimeElement>`. |
|
|
45
|
+
| Behavior | One semantic duration uses a standardized seconds-to-hours ladder and updates its owned DOM node without React tick renders; elapsed output drops to minute cadence after one hour. |
|
|
46
|
+
| End-user impact | People watching active work see a stable, consistent duration without unnecessary timer work competing with the surrounding interface. |
|
|
47
|
+
| Builder impact | Zero-config starts on mount with Timestamp's supporting typography; callers choose only an earlier origin, standard format, or standard typography override. |
|
|
48
|
+
| Compatibility/readiness | Additive first release implementing current `spec:AST-037`; no existing API or behavior changes. |
|
|
49
|
+
| Review checks | Reject custom formatters/cadence, state-driven ticks, callback-count drift, post-hour elapsed second wakes, typography divergence from Timestamp, leaked resources, negative output, lost passthrough/ref behavior, unsolicited live announcements, or extra anatomy/targets. |
|
|
50
|
+
| Governing rules | `spec:AST-037`; `architecture:public-component-api`; `architecture:react-component-runtime`; `architecture:component-theming-surface`. |
|
|
51
|
+
|
|
52
|
+
This table is a review projection; the body below is authoritative.
|
|
53
|
+
|
|
54
|
+
## Intent
|
|
55
|
+
|
|
56
|
+
Timer is the stable Core projection of `spec:AST-037`. It presents a standardized
|
|
57
|
+
elapsed duration for active work while keeping clock ticks outside React's render
|
|
58
|
+
lifecycle. Its typography surface and defaults match Timestamp so the two temporal
|
|
59
|
+
content components behave consistently.
|
|
60
|
+
|
|
61
|
+
## Compatibility and migration
|
|
62
|
+
|
|
63
|
+
- Released default preserved: `not yet released`
|
|
64
|
+
- Compatibility class: additive component and type exports
|
|
65
|
+
- Controlled/uncontrolled behavior: not applicable
|
|
66
|
+
- Migration decision: `spec:AST-037/DEC-1`, `spec:AST-037/DEC-2`, and
|
|
67
|
+
`spec:AST-037/DEC-3`
|
|
68
|
+
|
|
69
|
+
Consumer migration instructions belong in consumer docs and release notes.
|
|
70
|
+
|
|
71
|
+
## Ownership boundary
|
|
72
|
+
|
|
73
|
+
**Owns**
|
|
74
|
+
|
|
75
|
+
- Elapsed calculation from mount or a finite caller origin.
|
|
76
|
+
- The `elapsed` and `clock` representation ladders and their visible precision.
|
|
77
|
+
- One `<time>` element, its visible value, synchronized ISO duration, and timer
|
|
78
|
+
resource lifecycle.
|
|
79
|
+
- Avoiding React update commits for clock ticks.
|
|
80
|
+
- Timestamp-equivalent `type`, `size`, `color`, and `weight` behavior.
|
|
81
|
+
- The `timer` theming target on the Text wrapper.
|
|
82
|
+
|
|
83
|
+
**Does not own / non-goals**
|
|
84
|
+
|
|
85
|
+
- Loading indicators, waiting copy, status, or visibility — product composition.
|
|
86
|
+
- Dates, relative calendar language, time zones, or absolute instants — Timestamp.
|
|
87
|
+
- Pause, resume, countdown, deadlines, alarms, laps, sub-second precision, custom
|
|
88
|
+
formatters, or caller-controlled scheduling cadence.
|
|
89
|
+
- Automatic live-region announcements.
|
|
90
|
+
|
|
91
|
+
Countdown is deferred to a later contract. The standard formatting and scheduling
|
|
92
|
+
helpers remain direction-independent so that extension does not require replacing
|
|
93
|
+
the current format API.
|
|
94
|
+
|
|
95
|
+
## Public concepts
|
|
96
|
+
|
|
97
|
+
| Concept | Closed values or states | Meaning | Availability | Default | Owner | Stability | Invalid-value behavior |
|
|
98
|
+
| --------------- | ----------------------------------------- | ---------------------------------------------------- | ------------ | ------------------------------------------------------------------------------ | ----------------- | --------- | ---------------------------------------------------------- |
|
|
99
|
+
| Elapsed origin | Mount time or finite `startTime` | Unix-millisecond origin for elapsed duration | Always | Mount time | `component:Timer` | Stable | Non-finite values fall back to mount time |
|
|
100
|
+
| Duration format | `elapsed`, `clock` | Compact units or stopwatch notation | Always | `elapsed` | `component:Timer` | Stable | Types reject other values; runtime falls back to `elapsed` |
|
|
101
|
+
| Typography | `type`, `size`, `color`, `weight` | Same Text-backed surface and resolution as Timestamp | Always | Timestamp defaults: supporting type, secondary color, type-derived size/weight | Timestamp/Text | Stable | Existing Text type behavior |
|
|
102
|
+
| Time surface | `<time>` with visible text and `dateTime` | Semantic elapsed duration and time-element props/ref | Always | Selected format's zero value and `PT0S` before client synchronization | `component:Timer` | Stable | Timer-owned `dateTime` wins |
|
|
103
|
+
|
|
104
|
+
## Behavioral and layout contract
|
|
105
|
+
|
|
106
|
+
| ID | Candidate invariant | Basis | Draft review state |
|
|
107
|
+
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------ |
|
|
108
|
+
| FR1 | Timer MUST render one `<time>` element with selected-format text and matching non-negative ISO 8601 duration. | `spec:AST-037` FR1, FR3, FR5 | Settled |
|
|
109
|
+
| FR2 | Omitted or non-finite `startTime` MUST use the mount origin; a finite value MUST use that caller origin. | `spec:AST-037` FR2 | Settled |
|
|
110
|
+
| FR3 | Ticks MUST recompute from the clock and origin, directly update the owned node, and produce zero React update commits. | `spec:AST-037` FR3–FR4 | Settled |
|
|
111
|
+
| FR4 | Prop changes MUST take effect without remounting or duplicate resources; every setup MUST clean up its resource. | `spec:AST-037` FR6–FR7 | Settled |
|
|
112
|
+
| FR5 | `type`, `size`, `color`, `weight`, `xstyle`, `className`, and `style` MUST resolve on the Text wrapper exactly as Timestamp does; other admitted DOM, data, ARIA, and event props and the ref MUST reach `<time>`. | `spec:AST-037` FR8 | Settled |
|
|
113
|
+
| FR6 | Timer MUST NOT add live-region semantics by default. | `spec:AST-037` FR9 | Settled |
|
|
114
|
+
| FR7 | The Text wrapper MUST carry exactly the `timer` target for this component. | `spec:AST-037` FR10 | Settled |
|
|
115
|
+
| FR8 | Initial markup MUST use the selected format's zero value and `PT0S` without exposing a clock read in rendered markup. | `spec:AST-037` FR11 | Settled |
|
|
116
|
+
| FR9 | `elapsed` and `clock` MUST update on second boundaries below one hour; `elapsed` MUST update on minute boundaries at or above one hour while `clock` remains second-precise. A future origin MUST schedule toward its first visible change rather than waking each second at zero. | `spec:AST-037` FR12 | Settled |
|
|
117
|
+
| FR10 | Timer SHOULD skip DOM writes while the represented text and duration are unchanged. | `spec:AST-037` FR13 | Settled |
|
|
118
|
+
|
|
119
|
+
### Allowed variation
|
|
120
|
+
|
|
121
|
+
- **AV1 — Scheduling mechanism.** The private browser scheduling mechanism may
|
|
122
|
+
change while format-aware cadence, clock derivation, no-render ticks, and
|
|
123
|
+
complete cleanup remain true.
|
|
124
|
+
- **AV2 — Composition.** Callers may use the supported typography and styling
|
|
125
|
+
inputs without changing timing behavior.
|
|
126
|
+
|
|
127
|
+
### Representative states
|
|
128
|
+
|
|
129
|
+
| State | Required invariant | Allowed variation |
|
|
130
|
+
| ---------------- | -------------------------------------------------------------- | --------------------------------------- |
|
|
131
|
+
| Initial elapsed | `0s` and `PT0S` on one `<time>` | Root props and styling |
|
|
132
|
+
| Initial clock | `0:00` and `PT0S` on one `<time>` | Root props and styling |
|
|
133
|
+
| Seconds/minutes | `34s`, `2m 08s`, `0:34`, or `2:08` with whole-second semantics | Finite elapsed value |
|
|
134
|
+
| Hour-scale | `1h 02m` at minute cadence or `1:02:33` at second cadence | Format |
|
|
135
|
+
| Delayed callback | Catch up to clock without accumulated drift | Delay length |
|
|
136
|
+
| Prop update | New origin or format applies on the same `<time>` | Parent render cause |
|
|
137
|
+
| Replay/unmount | Each acquired resource is released | Development replay count |
|
|
138
|
+
| Typography | Timestamp defaults or explicit Text-family overrides | Supported type, size, color, and weight |
|
|
139
|
+
|
|
140
|
+
### Transformation and precedence order
|
|
141
|
+
|
|
142
|
+
- **ORD1 — Resolve → calculate → clamp → floor to visible precision → format →
|
|
143
|
+
write → schedule next visible boundary.** Apply the pipeline from `spec:AST-037`
|
|
144
|
+
to visible text and machine-readable duration.
|
|
145
|
+
- **ORD2 — Timestamp styling split.** Typography, `xstyle`, `className`, `style`,
|
|
146
|
+
and the `timer` target compose on the Text wrapper. Time-element props and the ref
|
|
147
|
+
compose on `<time>`; Timer owns `dateTime`.
|
|
148
|
+
|
|
149
|
+
### Performance and resources
|
|
150
|
+
|
|
151
|
+
- **PR1 — No tick renders.** Advancing time produces zero React update commits.
|
|
152
|
+
- **PR2 — One resource.** Each mounted Timer owns at most one active timer resource,
|
|
153
|
+
and each setup cleanup releases its resource.
|
|
154
|
+
- **PR3 — Visible cadence.** Elapsed output at or above one hour does not wake for
|
|
155
|
+
hidden second changes; clock output remains second-precise.
|
|
156
|
+
- **PR4 — Bounded writes.** Unchanged represented text and duration are not rewritten.
|
|
157
|
+
|
|
158
|
+
## Accessibility contract
|
|
159
|
+
|
|
160
|
+
- **AR1 — Semantic duration.** The `<time>` element exposes the current non-negative
|
|
161
|
+
ISO 8601 duration at the same precision as its visible output.
|
|
162
|
+
- **AR2 — Quiet by default.** No role or `aria-live` value is added automatically;
|
|
163
|
+
deliberate caller ARIA passes through.
|
|
164
|
+
- **AR3 — Perceivable text.** The formatted duration remains real text content.
|
|
165
|
+
|
|
166
|
+
## Design relationships
|
|
167
|
+
|
|
168
|
+
| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
|
|
169
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | -------------- | ----------------------- |
|
|
170
|
+
| Elapsed time | One stable inline duration with Timestamp-equivalent supporting typography by default and standard Text overrides. | `spec:AST-037/DEC-1`, `spec:AST-037/DEC-3` | Supporting | FR1, FR3, FR5, AR1, AR3 |
|
|
171
|
+
|
|
172
|
+
### Theming anatomy
|
|
173
|
+
|
|
174
|
+
<!-- anatomy-theming:v1 -->
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"Elapsed time": {"target": "timer"}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Family and system relationships
|
|
183
|
+
|
|
184
|
+
- Timer and Timestamp are temporal Text-family content components: Timer owns
|
|
185
|
+
durations; Timestamp owns instants. Their typography API and defaults match.
|
|
186
|
+
- `spec:AST-037` owns the public behavior, API, accessibility, performance, and
|
|
187
|
+
first-Core-admission decisions projected here.
|
|
188
|
+
- `architecture:public-component-api` owns exports, BaseProps, styling, ref, and
|
|
189
|
+
caller-choice rules.
|
|
190
|
+
- `architecture:react-component-runtime` owns Effect and resource lifecycle.
|
|
191
|
+
- `architecture:component-theming-surface` owns target qualification.
|
|
192
|
+
|
|
193
|
+
## Verification map
|
|
194
|
+
|
|
195
|
+
| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
|
|
196
|
+
| --------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
|
197
|
+
| FR1–FR4, FR8–FR10, AR1, AR3 | `Timer.test.tsx` controlled-clock tests | Initial formats, origin variants including future scheduling, delayed callback, format change, second/minute/hour boundaries | State ticks, callback accumulation, wrong ladder/padding, negative values, needless pre-origin wakes, stale semantics, wrong cadence, or clock-derived initial markup fail | `audit:Timer/behavior` |
|
|
198
|
+
| FR3, PR1 | React Profiler commit-count test | Several ticks | A state-based implementation adds update commits and fails | `audit:Timer/performance` |
|
|
199
|
+
| FR4, PR2 | Resource spies under rerender, StrictMode, and unmount | Setup, dependency change, replay, cleanup | Duplicate or leaked resources fail counts | `audit:Timer/resources` |
|
|
200
|
+
| FR5 | Public import, Timestamp-default typography, overrides, BaseProps, and ref tests | Default and explicit typography, ref, event, ARIA, class, style | Missing export, styling divergence, or dropped input fails | `audit:Timer/api` |
|
|
201
|
+
| FR6, AR2 | Accessibility attribute tests | Default and deliberate caller ARIA | Unsolicited live semantics or dropped ARIA fails | `audit:Timer/accessibility` |
|
|
202
|
+
| FR7 | `themingTargets.test.ts` and `scripts/check-knowledge.mjs` | One wrapper target | Missing, extra, or misplaced target fails | `audit:Timer/theming` |
|
|
203
|
+
|
|
204
|
+
## Decision log
|
|
205
|
+
|
|
206
|
+
No additional decision. This component projects `spec:AST-037` without widening it.
|
|
207
|
+
|
|
208
|
+
## Open questions
|
|
209
|
+
|
|
210
|
+
None.
|
|
211
|
+
|
|
212
|
+
## Content boundary
|
|
213
|
+
|
|
214
|
+
This file does not duplicate consumer examples, implementation code, private
|
|
215
|
+
scheduling mechanics, measurements, or system rules. It links to their owners.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Timer.test.tsx
|
|
5
|
+
* @input Uses React Testing Library, fake clocks, React Profiler, and Timer
|
|
6
|
+
* @output Verifies formats, adaptive cadence, render isolation, typography, resources, semantics, and passthrough
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {Profiler, StrictMode, createRef} from 'react';
|
|
10
|
+
import {renderToString} from 'react-dom/server';
|
|
11
|
+
import {act, fireEvent, render, screen} from '@testing-library/react';
|
|
12
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
13
|
+
import {Timer} from './Timer';
|
|
14
|
+
|
|
15
|
+
describe('Timer', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
vi.setSystemTime(new Date('2026-09-22T00:00:00Z'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.useRealTimers();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders deterministic zero-duration markup for each standard format', () => {
|
|
27
|
+
const {rerender} = render(<Timer data-testid="timer" />);
|
|
28
|
+
const timer = screen.getByTestId('timer');
|
|
29
|
+
|
|
30
|
+
expect(timer.tagName).toBe('TIME');
|
|
31
|
+
expect(timer).toHaveTextContent('0s');
|
|
32
|
+
expect(timer).toHaveAttribute('datetime', 'PT0S');
|
|
33
|
+
|
|
34
|
+
rerender(<Timer format="clock" data-testid="timer" />);
|
|
35
|
+
expect(timer).toHaveTextContent('0:00');
|
|
36
|
+
expect(timer).toHaveAttribute('datetime', 'PT0S');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps server markup independent of the clock', () => {
|
|
40
|
+
const firstMarkup = renderToString(<Timer startTime={0} />);
|
|
41
|
+
const firstClockMarkup = renderToString(
|
|
42
|
+
<Timer startTime={0} format="clock" />,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
vi.setSystemTime(new Date('2030-01-01T00:00:00Z'));
|
|
46
|
+
const secondMarkup = renderToString(<Timer startTime={0} />);
|
|
47
|
+
const secondClockMarkup = renderToString(
|
|
48
|
+
<Timer startTime={0} format="clock" />,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(firstMarkup).toBe(secondMarkup);
|
|
52
|
+
expect(firstMarkup).toContain('dateTime="PT0S"');
|
|
53
|
+
expect(firstMarkup).toContain('>0s</time>');
|
|
54
|
+
expect(firstClockMarkup).toBe(secondClockMarkup);
|
|
55
|
+
expect(firstClockMarkup).toContain('>0:00</time>');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('derives elapsed time from the clock without accumulating callback count', () => {
|
|
59
|
+
let scheduledTick: (() => void) | undefined;
|
|
60
|
+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(callback => {
|
|
61
|
+
scheduledTick = callback as () => void;
|
|
62
|
+
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
render(<Timer data-testid="timer" />);
|
|
66
|
+
|
|
67
|
+
vi.setSystemTime(new Date('2026-09-22T00:00:08.750Z'));
|
|
68
|
+
act(() => {
|
|
69
|
+
scheduledTick?.();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const timer = screen.getByTestId('timer');
|
|
73
|
+
expect(timer).toHaveTextContent('8s');
|
|
74
|
+
expect(timer).toHaveAttribute('datetime', 'PT8S');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('formats elapsed durations across seconds, minutes, and hours', () => {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const {rerender} = render(
|
|
80
|
+
<Timer startTime={now - 34_000} data-testid="timer" />,
|
|
81
|
+
);
|
|
82
|
+
const timer = screen.getByTestId('timer');
|
|
83
|
+
expect(timer).toHaveTextContent('34s');
|
|
84
|
+
expect(timer).toHaveAttribute('datetime', 'PT34S');
|
|
85
|
+
|
|
86
|
+
rerender(<Timer startTime={now - 128_000} data-testid="timer" />);
|
|
87
|
+
expect(timer).toHaveTextContent('2m 08s');
|
|
88
|
+
expect(timer).toHaveAttribute('datetime', 'PT128S');
|
|
89
|
+
|
|
90
|
+
rerender(<Timer startTime={now - 3_753_000} data-testid="timer" />);
|
|
91
|
+
expect(timer).toHaveTextContent('1h 02m');
|
|
92
|
+
expect(timer).toHaveAttribute('datetime', 'PT3720S');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('formats clock durations across minutes and hours', () => {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const {rerender} = render(
|
|
98
|
+
<Timer format="clock" startTime={now - 128_000} data-testid="timer" />,
|
|
99
|
+
);
|
|
100
|
+
const timer = screen.getByTestId('timer');
|
|
101
|
+
expect(timer).toHaveTextContent('2:08');
|
|
102
|
+
expect(timer).toHaveAttribute('datetime', 'PT128S');
|
|
103
|
+
|
|
104
|
+
rerender(
|
|
105
|
+
<Timer format="clock" startTime={now - 3_753_000} data-testid="timer" />,
|
|
106
|
+
);
|
|
107
|
+
expect(timer).toHaveTextContent('1:02:33');
|
|
108
|
+
expect(timer).toHaveAttribute('datetime', 'PT3753S');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('uses second cadence until elapsed format reaches an hour, then minute cadence', () => {
|
|
112
|
+
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
113
|
+
const startTime = Date.now() - 3_599_000;
|
|
114
|
+
render(<Timer startTime={startTime} data-testid="timer" />);
|
|
115
|
+
const timer = screen.getByTestId('timer');
|
|
116
|
+
|
|
117
|
+
expect(timer).toHaveTextContent('59m 59s');
|
|
118
|
+
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 1000);
|
|
119
|
+
|
|
120
|
+
act(() => {
|
|
121
|
+
vi.advanceTimersByTime(1000);
|
|
122
|
+
});
|
|
123
|
+
expect(timer).toHaveTextContent('1h 00m');
|
|
124
|
+
expect(timer).toHaveAttribute('datetime', 'PT3600S');
|
|
125
|
+
expect(setTimeoutSpy).toHaveBeenLastCalledWith(
|
|
126
|
+
expect.any(Function),
|
|
127
|
+
60_000,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('keeps clock format on second cadence after an hour', () => {
|
|
132
|
+
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
133
|
+
render(
|
|
134
|
+
<Timer
|
|
135
|
+
format="clock"
|
|
136
|
+
startTime={Date.now() - 3_723_000}
|
|
137
|
+
data-testid="timer"
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(screen.getByTestId('timer')).toHaveTextContent('1:02:03');
|
|
142
|
+
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 1000);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('counts from a finite caller-provided start time', () => {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
render(<Timer startTime={now - 12_400} data-testid="timer" />);
|
|
148
|
+
|
|
149
|
+
expect(screen.getByTestId('timer')).toHaveTextContent('12s');
|
|
150
|
+
expect(screen.getByTestId('timer')).toHaveAttribute('datetime', 'PT12S');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns to the original mount origin when startTime is removed', () => {
|
|
154
|
+
const mountedAt = Date.now();
|
|
155
|
+
const {rerender} = render(
|
|
156
|
+
<Timer startTime={mountedAt - 10_000} data-testid="timer" />,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
act(() => {
|
|
160
|
+
vi.advanceTimersByTime(2000);
|
|
161
|
+
});
|
|
162
|
+
rerender(<Timer data-testid="timer" />);
|
|
163
|
+
|
|
164
|
+
expect(screen.getByTestId('timer')).toHaveTextContent('2s');
|
|
165
|
+
expect(screen.getByTestId('timer')).toHaveAttribute('datetime', 'PT2S');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('falls back to mount time for a non-finite start time', () => {
|
|
169
|
+
render(<Timer startTime={Number.NaN} data-testid="timer" />);
|
|
170
|
+
|
|
171
|
+
act(() => {
|
|
172
|
+
vi.advanceTimersByTime(2000);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(screen.getByTestId('timer')).toHaveTextContent('2s');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('clamps a future origin to zero and schedules its first visible change directly', () => {
|
|
179
|
+
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
180
|
+
render(<Timer startTime={Date.now() + 5000} data-testid="timer" />);
|
|
181
|
+
|
|
182
|
+
expect(screen.getByTestId('timer')).toHaveTextContent('0s');
|
|
183
|
+
expect(screen.getByTestId('timer')).toHaveAttribute('datetime', 'PT0S');
|
|
184
|
+
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 6000);
|
|
185
|
+
|
|
186
|
+
act(() => {
|
|
187
|
+
vi.advanceTimersByTime(6000);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(screen.getByTestId('timer')).toHaveTextContent('1s');
|
|
191
|
+
expect(screen.getByTestId('timer')).toHaveAttribute('datetime', 'PT1S');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('updates format without remounting or losing current elapsed time', () => {
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
const {rerender} = render(
|
|
197
|
+
<Timer startTime={now - 3_723_000} data-testid="timer" />,
|
|
198
|
+
);
|
|
199
|
+
const timer = screen.getByTestId('timer');
|
|
200
|
+
expect(timer).toHaveTextContent('1h 02m');
|
|
201
|
+
expect(timer).toHaveAttribute('datetime', 'PT3720S');
|
|
202
|
+
|
|
203
|
+
rerender(
|
|
204
|
+
<Timer format="clock" startTime={now - 3_723_000} data-testid="timer" />,
|
|
205
|
+
);
|
|
206
|
+
expect(screen.getByTestId('timer')).toBe(timer);
|
|
207
|
+
expect(timer).toHaveTextContent('1:02:03');
|
|
208
|
+
expect(timer).toHaveAttribute('datetime', 'PT3723S');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not schedule React update commits as time advances', () => {
|
|
212
|
+
const onRender = vi.fn();
|
|
213
|
+
render(
|
|
214
|
+
<Profiler id="timer" onRender={onRender}>
|
|
215
|
+
<Timer />
|
|
216
|
+
</Profiler>,
|
|
217
|
+
);
|
|
218
|
+
expect(onRender).toHaveBeenCalledTimes(1);
|
|
219
|
+
|
|
220
|
+
act(() => {
|
|
221
|
+
vi.advanceTimersByTime(5000);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(onRender).toHaveBeenCalledTimes(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('owns one timer resource and cleans up under StrictMode replay', () => {
|
|
228
|
+
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
|
|
229
|
+
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
|
|
230
|
+
const {unmount} = render(
|
|
231
|
+
<StrictMode>
|
|
232
|
+
<Timer />
|
|
233
|
+
</StrictMode>,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(setTimeoutSpy).toHaveBeenCalledTimes(2);
|
|
237
|
+
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
|
238
|
+
|
|
239
|
+
unmount();
|
|
240
|
+
expect(clearTimeoutSpy).toHaveBeenCalledTimes(2);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('matches Timestamp typography defaults and accepts overrides', () => {
|
|
244
|
+
const {rerender} = render(<Timer data-testid="timer" />);
|
|
245
|
+
const timer = screen.getByTestId('timer');
|
|
246
|
+
const text = timer.parentElement;
|
|
247
|
+
|
|
248
|
+
expect(text).toHaveClass('astryx-text');
|
|
249
|
+
expect(text).toHaveClass('astryx-timer');
|
|
250
|
+
expect(text).toHaveAttribute('data-type', 'supporting');
|
|
251
|
+
expect(text).toHaveAttribute('data-color', 'secondary');
|
|
252
|
+
|
|
253
|
+
rerender(
|
|
254
|
+
<Timer
|
|
255
|
+
type="body"
|
|
256
|
+
size="lg"
|
|
257
|
+
color="primary"
|
|
258
|
+
weight="bold"
|
|
259
|
+
data-testid="timer"
|
|
260
|
+
/>,
|
|
261
|
+
);
|
|
262
|
+
expect(text).toHaveAttribute('data-type', 'body');
|
|
263
|
+
expect(text).toHaveAttribute('data-size', 'lg');
|
|
264
|
+
expect(text).toHaveAttribute('data-color', 'primary');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('forwards its time ref and preserves Timestamp-style root styling and time props', () => {
|
|
268
|
+
const ref = createRef<HTMLTimeElement>();
|
|
269
|
+
const onClick = vi.fn();
|
|
270
|
+
render(
|
|
271
|
+
<Timer
|
|
272
|
+
ref={ref}
|
|
273
|
+
id="elapsed"
|
|
274
|
+
className="custom-class"
|
|
275
|
+
style={{color: 'rgb(1, 2, 3)'}}
|
|
276
|
+
aria-live="polite"
|
|
277
|
+
onClick={onClick}
|
|
278
|
+
data-testid="timer"
|
|
279
|
+
/>,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const timer = screen.getByTestId('timer');
|
|
283
|
+
const text = timer.parentElement;
|
|
284
|
+
expect(ref.current).toBe(timer);
|
|
285
|
+
expect(timer).toHaveAttribute('id', 'elapsed');
|
|
286
|
+
expect(text).toHaveClass('astryx-timer');
|
|
287
|
+
expect(text).toHaveClass('custom-class');
|
|
288
|
+
expect(text).toHaveStyle({color: 'rgb(1, 2, 3)'});
|
|
289
|
+
expect(timer).toHaveAttribute('aria-live', 'polite');
|
|
290
|
+
|
|
291
|
+
fireEvent.click(timer);
|
|
292
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('does not add live-region semantics by default', () => {
|
|
296
|
+
render(<Timer data-testid="timer" />);
|
|
297
|
+
const timer = screen.getByTestId('timer');
|
|
298
|
+
|
|
299
|
+
expect(timer).not.toHaveAttribute('aria-live');
|
|
300
|
+
expect(timer).not.toHaveAttribute('role');
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file Timer.tsx
|
|
7
|
+
* @input Uses an optional start time, standardized format, Timestamp typography, BaseProps, and React ref
|
|
8
|
+
* @output Exports Timer, TimerProps, and TimerFormat with non-rendering elapsed-time updates
|
|
9
|
+
* @position Core content primitive for elapsed duration in active operations
|
|
10
|
+
*
|
|
11
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
12
|
+
* - /packages/core/src/Timer/Timer.spec.md
|
|
13
|
+
* - /packages/core/src/Timer/Timer.doc.mjs
|
|
14
|
+
* - /packages/core/src/Timer/Timer.test.tsx
|
|
15
|
+
* - /packages/core/src/Timer/index.ts
|
|
16
|
+
* - /apps/storybook/stories/Timer.stories.tsx
|
|
17
|
+
* - /packages/cli/assets/templates/blocks/components/Timer/
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {useEffect, useRef, useState} from 'react';
|
|
21
|
+
import * as stylex from '@stylexjs/stylex';
|
|
22
|
+
import type {BaseProps} from '../BaseProps';
|
|
23
|
+
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
24
|
+
import {Text} from '../Text';
|
|
25
|
+
import type {TextColor, TextSize, TextType, TextWeight} from '../theme/types';
|
|
26
|
+
import {mergeProps} from '../utils';
|
|
27
|
+
import {themeProps} from '../utils/themeProps';
|
|
28
|
+
|
|
29
|
+
const ONE_SECOND_MS = 1000;
|
|
30
|
+
const ONE_MINUTE_MS = 60 * ONE_SECOND_MS;
|
|
31
|
+
const ONE_HOUR_SECONDS = 60 * 60;
|
|
32
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
33
|
+
|
|
34
|
+
const styles = stylex.create({
|
|
35
|
+
time: {
|
|
36
|
+
color: 'inherit',
|
|
37
|
+
display: 'inline',
|
|
38
|
+
fontFamily: 'inherit',
|
|
39
|
+
fontSize: 'inherit',
|
|
40
|
+
fontStyle: 'normal',
|
|
41
|
+
fontVariantNumeric: 'tabular-nums',
|
|
42
|
+
fontWeight: 'inherit',
|
|
43
|
+
lineHeight: 'inherit',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type TimerFormat = 'elapsed' | 'clock';
|
|
48
|
+
|
|
49
|
+
type TimerPresentation = {
|
|
50
|
+
dateTime: string;
|
|
51
|
+
text: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function pad(value: number): string {
|
|
55
|
+
return String(value).padStart(2, '0');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveFormat(format: TimerFormat): TimerFormat {
|
|
59
|
+
return format === 'clock' ? 'clock' : 'elapsed';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getElapsedMilliseconds(now: number, startTime: number): number {
|
|
63
|
+
return Math.max(0, now - startTime);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getPresentation(
|
|
67
|
+
elapsedMilliseconds: number,
|
|
68
|
+
format: TimerFormat,
|
|
69
|
+
): TimerPresentation {
|
|
70
|
+
const elapsedSeconds = Math.floor(elapsedMilliseconds / ONE_SECOND_MS);
|
|
71
|
+
|
|
72
|
+
if (format === 'clock') {
|
|
73
|
+
const hours = Math.floor(elapsedSeconds / ONE_HOUR_SECONDS);
|
|
74
|
+
const minutes = Math.floor((elapsedSeconds % ONE_HOUR_SECONDS) / 60);
|
|
75
|
+
const seconds = elapsedSeconds % 60;
|
|
76
|
+
return {
|
|
77
|
+
dateTime: `PT${elapsedSeconds}S`,
|
|
78
|
+
text:
|
|
79
|
+
hours > 0
|
|
80
|
+
? `${String(hours)}:${pad(minutes)}:${pad(seconds)}`
|
|
81
|
+
: `${String(minutes)}:${pad(seconds)}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (elapsedSeconds < 60) {
|
|
86
|
+
return {
|
|
87
|
+
dateTime: `PT${elapsedSeconds}S`,
|
|
88
|
+
text: `${String(elapsedSeconds)}s`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const totalMinutes = Math.floor(elapsedSeconds / 60);
|
|
93
|
+
if (totalMinutes < 60) {
|
|
94
|
+
return {
|
|
95
|
+
dateTime: `PT${elapsedSeconds}S`,
|
|
96
|
+
text: `${String(totalMinutes)}m ${pad(elapsedSeconds % 60)}s`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const representedSeconds = totalMinutes * 60;
|
|
101
|
+
return {
|
|
102
|
+
dateTime: `PT${representedSeconds}S`,
|
|
103
|
+
text: `${String(Math.floor(totalMinutes / 60))}h ${pad(totalMinutes % 60)}m`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getMillisecondsUntilNextChange(
|
|
108
|
+
now: number,
|
|
109
|
+
startTime: number,
|
|
110
|
+
elapsedMilliseconds: number,
|
|
111
|
+
format: TimerFormat,
|
|
112
|
+
): number {
|
|
113
|
+
if (now < startTime) {
|
|
114
|
+
return Math.min(startTime - now + ONE_SECOND_MS, MAX_TIMEOUT_MS);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const precision =
|
|
118
|
+
format === 'elapsed' &&
|
|
119
|
+
elapsedMilliseconds >= ONE_HOUR_SECONDS * ONE_SECOND_MS
|
|
120
|
+
? ONE_MINUTE_MS
|
|
121
|
+
: ONE_SECOND_MS;
|
|
122
|
+
return precision - (elapsedMilliseconds % precision);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface TimerProps extends Omit<
|
|
126
|
+
BaseProps<HTMLTimeElement>,
|
|
127
|
+
'dateTime'
|
|
128
|
+
> {
|
|
129
|
+
/** Ref forwarded to the rendered `<time>` element. */
|
|
130
|
+
ref?: React.Ref<HTMLTimeElement>;
|
|
131
|
+
/**
|
|
132
|
+
* Unix time in milliseconds when the measured operation began. Omit it to
|
|
133
|
+
* start counting from this Timer's mount.
|
|
134
|
+
*/
|
|
135
|
+
startTime?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Standard duration representation.
|
|
138
|
+
* @default 'elapsed'
|
|
139
|
+
*/
|
|
140
|
+
format?: TimerFormat;
|
|
141
|
+
/**
|
|
142
|
+
* Semantic text type. Matches Timestamp typography behavior.
|
|
143
|
+
* @default 'supporting'
|
|
144
|
+
*/
|
|
145
|
+
type?: TextType;
|
|
146
|
+
/** Explicit font size override. Overrides the size from `type`. */
|
|
147
|
+
size?: TextSize;
|
|
148
|
+
/**
|
|
149
|
+
* Text color.
|
|
150
|
+
* @default 'secondary'
|
|
151
|
+
*/
|
|
152
|
+
color?: TextColor;
|
|
153
|
+
/** Font weight override. */
|
|
154
|
+
weight?: TextWeight;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Displays a standardized elapsed duration without scheduling React tick renders.
|
|
159
|
+
*
|
|
160
|
+
* Timer writes changing text and its ISO 8601 duration directly to the owned
|
|
161
|
+
* `<time>` node. Use `elapsed` for compact duration text or `clock` for a
|
|
162
|
+
* stopwatch-like reading.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```
|
|
166
|
+
* <Timer />
|
|
167
|
+
* <Timer format="clock" />
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export function Timer({
|
|
171
|
+
startTime,
|
|
172
|
+
format = 'elapsed',
|
|
173
|
+
type = 'supporting',
|
|
174
|
+
size,
|
|
175
|
+
color = 'secondary',
|
|
176
|
+
weight,
|
|
177
|
+
ref,
|
|
178
|
+
xstyle,
|
|
179
|
+
className,
|
|
180
|
+
style,
|
|
181
|
+
...rest
|
|
182
|
+
}: TimerProps) {
|
|
183
|
+
const [mountTime] = useState(() => Date.now());
|
|
184
|
+
const timerRef = useRef<HTMLTimeElement>(null);
|
|
185
|
+
const mergedRef = useMergedRefs(ref, timerRef);
|
|
186
|
+
const resolvedFormat = resolveFormat(format);
|
|
187
|
+
const initialPresentation = getPresentation(0, resolvedFormat);
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
const resolvedStartTime =
|
|
191
|
+
startTime !== undefined && Number.isFinite(startTime)
|
|
192
|
+
? startTime
|
|
193
|
+
: mountTime;
|
|
194
|
+
let timeoutID: ReturnType<typeof setTimeout> | undefined;
|
|
195
|
+
let previousDateTime: string | undefined;
|
|
196
|
+
let previousText: string | undefined;
|
|
197
|
+
|
|
198
|
+
const tick = () => {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
const elapsedMilliseconds = getElapsedMilliseconds(
|
|
201
|
+
now,
|
|
202
|
+
resolvedStartTime,
|
|
203
|
+
);
|
|
204
|
+
const presentation = getPresentation(elapsedMilliseconds, resolvedFormat);
|
|
205
|
+
const node = timerRef.current;
|
|
206
|
+
|
|
207
|
+
if (node != null) {
|
|
208
|
+
if (presentation.text !== previousText) {
|
|
209
|
+
node.textContent = presentation.text;
|
|
210
|
+
previousText = presentation.text;
|
|
211
|
+
}
|
|
212
|
+
if (presentation.dateTime !== previousDateTime) {
|
|
213
|
+
node.dateTime = presentation.dateTime;
|
|
214
|
+
previousDateTime = presentation.dateTime;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
timeoutID = setTimeout(
|
|
219
|
+
tick,
|
|
220
|
+
getMillisecondsUntilNextChange(
|
|
221
|
+
now,
|
|
222
|
+
resolvedStartTime,
|
|
223
|
+
elapsedMilliseconds,
|
|
224
|
+
resolvedFormat,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
tick();
|
|
230
|
+
return () => {
|
|
231
|
+
if (timeoutID !== undefined) {
|
|
232
|
+
clearTimeout(timeoutID);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}, [mountTime, resolvedFormat, startTime]);
|
|
236
|
+
|
|
237
|
+
const timerProps = mergeProps(themeProps('timer'), {className, style});
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<Text
|
|
241
|
+
type={type}
|
|
242
|
+
size={size}
|
|
243
|
+
color={color}
|
|
244
|
+
weight={weight}
|
|
245
|
+
xstyle={xstyle}
|
|
246
|
+
{...timerProps}>
|
|
247
|
+
<time
|
|
248
|
+
{...rest}
|
|
249
|
+
ref={mergedRef}
|
|
250
|
+
dateTime="PT0S"
|
|
251
|
+
{...stylex.props(styles.time)}>
|
|
252
|
+
{initialPresentation.text}
|
|
253
|
+
</time>
|
|
254
|
+
</Text>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
Timer.displayName = 'Timer';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file index.ts
|
|
5
|
+
* @input Imports Timer, TimerProps, and TimerFormat
|
|
6
|
+
* @output Public Timer component barrel
|
|
7
|
+
* @position Component subpath entry point for @astryxdesign/core/Timer
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {Timer} from './Timer';
|
|
11
|
+
export type {TimerFormat, TimerProps} from './Timer';
|
package/src/index.ts
CHANGED