@sproutsocial/seeds-react-duration 1.0.0

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.
@@ -0,0 +1,82 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import Duration from "./Duration";
4
+
5
+ const localeOptions = [
6
+ "English (en-US)",
7
+ "Spanish (es-LA)",
8
+ "French (fr-FR)",
9
+ "Italian (it-IT)",
10
+ "Portuguese (pt-BR)",
11
+ ];
12
+
13
+ const localeMapping = {
14
+ "English (en-US)": "en-US",
15
+ "Spanish (es-LA)": "es-LA",
16
+ "French (fr-FR)": "fr-FR",
17
+ "Italian (it-IT)": "it-IT",
18
+ "Portuguese (pt-BR)": "pt-BR",
19
+ };
20
+
21
+ const displayOptions = ["Long", "Narrow"];
22
+
23
+ const displayMapping = {
24
+ Long: "long",
25
+ Narrow: "narrow",
26
+ };
27
+
28
+ const meta: Meta<typeof Duration> = {
29
+ title: "Components/Duration",
30
+ component: Duration,
31
+ argTypes: {
32
+ locale: {
33
+ control: "select",
34
+ options: localeOptions,
35
+ mapping: localeMapping,
36
+ },
37
+ display: {
38
+ control: "select",
39
+ options: displayOptions,
40
+ mapping: displayMapping,
41
+ },
42
+ },
43
+ args: {
44
+ milliseconds: 123456789,
45
+ },
46
+ };
47
+ export default meta;
48
+
49
+ type Story = StoryObj<typeof Duration>;
50
+
51
+ export const Default: Story = {
52
+ args: {
53
+ milliseconds: 123456789,
54
+ },
55
+ };
56
+
57
+ export const Invalid: Story = {
58
+ args: {
59
+ milliseconds: null,
60
+ },
61
+ };
62
+
63
+ export const DisplayLong: Story = {
64
+ args: {
65
+ display: "long",
66
+ },
67
+ };
68
+
69
+ export const Negative: Story = {
70
+ args: {
71
+ milliseconds: -123456789,
72
+ },
73
+ };
74
+
75
+ export const DisplayUnits: Story = {
76
+ args: {
77
+ displayUnits: {
78
+ hours: true,
79
+ minutes: true,
80
+ },
81
+ },
82
+ };
@@ -0,0 +1,151 @@
1
+ import * as React from "react";
2
+ // @ts-expect-error lru-memoize is not typed
3
+ import memoize from "lru-memoize";
4
+ import { EM_DASH } from "./constants";
5
+ import { VisuallyHidden } from "@sproutsocial/seeds-react-visually-hidden";
6
+
7
+ import {
8
+ COMPARE_OBJECTS,
9
+ DEFAULT_DISPLAY,
10
+ DEFAULT_DISPLAY_UNITS,
11
+ DEFAULT_LOCALE,
12
+ DEFAULT_MILLISECONDS,
13
+ MEMO_CACHE_SIZE,
14
+ ORDERED_UNITS,
15
+ UNITS,
16
+ } from "./constants";
17
+ import { Container } from "./styles";
18
+ import type {
19
+ TypeDurationProps,
20
+ TypeDurationLocale,
21
+ TypeDurationDisplay,
22
+ TypeDurationDisplayUnits,
23
+ } from "./DurationTypes";
24
+ import {
25
+ isValidNumber,
26
+ getLowestUnit,
27
+ splitMillisecondsIntoUnits,
28
+ } from "./utils";
29
+
30
+ const _createDurationFormatter = (
31
+ locale: TypeDurationLocale,
32
+ unitDisplay: TypeDurationDisplay,
33
+ displayUnits: TypeDurationDisplayUnits
34
+ ) => {
35
+ const timeUnitFormatter = (
36
+ locale: TypeDurationProps["locale"],
37
+ unit: string,
38
+ unitDisplay: TypeDurationProps["display"]
39
+ ) => Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay }).format;
40
+
41
+ const formatterByUnit = {
42
+ [UNITS.days]: timeUnitFormatter(locale, "day", unitDisplay),
43
+ [UNITS.hours]: timeUnitFormatter(locale, "hour", unitDisplay),
44
+ [UNITS.minutes]: timeUnitFormatter(locale, "minute", unitDisplay),
45
+ [UNITS.seconds]: timeUnitFormatter(locale, "second", unitDisplay),
46
+ [UNITS.milliseconds]: timeUnitFormatter(locale, "millisecond", unitDisplay),
47
+ };
48
+
49
+ const formatList = new Intl.ListFormat(locale, {
50
+ style: "narrow",
51
+ type: "unit",
52
+ });
53
+
54
+ return (value: number) => {
55
+ const lowestUnit = getLowestUnit(displayUnits);
56
+
57
+ // if the value is zero or negative, we just want to return 0 for the lowest unit (ex "0 minutes")
58
+ if (value <= 0) {
59
+ // @ts-ignore TS error later
60
+ return formatterByUnit[lowestUnit](0);
61
+ }
62
+
63
+ const millisecondsByUnit = splitMillisecondsIntoUnits(value, displayUnits);
64
+ const list: string[] = [];
65
+
66
+ ORDERED_UNITS.forEach((unit) => {
67
+ if (unit in millisecondsByUnit) {
68
+ // @ts-ignore TS error later
69
+ const unitValue = millisecondsByUnit[unit];
70
+
71
+ // we want to add to the list if one of two conditions are met:
72
+ // 1) the unit has a value greater than 0 OR
73
+ // 2) the unit has value 0 AND the unit is the lowest unit AND the list is already empty
74
+ if (
75
+ unitValue !== 0 ||
76
+ (unitValue === 0 && unit === lowestUnit && list.length === 0)
77
+ ) {
78
+ // @ts-ignore TS error later
79
+ list.push(formatterByUnit[unit](millisecondsByUnit[unit]));
80
+ }
81
+ }
82
+ });
83
+
84
+ return formatList.format(list);
85
+ };
86
+ };
87
+
88
+ // Memoize to reduce the energy of creating new instances of Intl.NumberFormat
89
+ const memoizer = memoize(MEMO_CACHE_SIZE, COMPARE_OBJECTS);
90
+ const createDurationFormatter = memoizer(_createDurationFormatter);
91
+
92
+ const getDuration = ({
93
+ returnType,
94
+ props,
95
+ }: {
96
+ returnType: "string" | "component";
97
+ props: TypeDurationProps;
98
+ }): string | React.ReactNode => {
99
+ const {
100
+ display = DEFAULT_DISPLAY,
101
+ displayUnits = DEFAULT_DISPLAY_UNITS,
102
+ invalidMillisecondsLabel,
103
+ locale = DEFAULT_LOCALE,
104
+ milliseconds = DEFAULT_MILLISECONDS,
105
+ qa,
106
+ } = props;
107
+ const isReturnTypeString = returnType === "string";
108
+
109
+ if (!isValidNumber(milliseconds)) {
110
+ return isReturnTypeString ? (
111
+ EM_DASH
112
+ ) : (
113
+ <>
114
+ {invalidMillisecondsLabel ? (
115
+ // Give screen readers something useful to read off + hide the em dash
116
+ <VisuallyHidden>{invalidMillisecondsLabel}</VisuallyHidden>
117
+ ) : null}
118
+ <Container aria-hidden {...qa}>
119
+ {EM_DASH}
120
+ </Container>
121
+ </>
122
+ );
123
+ }
124
+
125
+ const validatedDisplayUnits =
126
+ Object.keys(displayUnits).length === 0
127
+ ? DEFAULT_DISPLAY_UNITS
128
+ : displayUnits;
129
+
130
+ const fullText = createDurationFormatter(
131
+ locale,
132
+ display,
133
+ validatedDisplayUnits
134
+ )(milliseconds);
135
+
136
+ return isReturnTypeString ? (
137
+ fullText
138
+ ) : (
139
+ <Container {...qa}>{fullText}</Container>
140
+ );
141
+ };
142
+
143
+ export const formatDuration = (props: TypeDurationProps): string => {
144
+ return getDuration({ returnType: "string", props }) as string;
145
+ };
146
+
147
+ const Duration = (props: TypeDurationProps) => {
148
+ return getDuration({ returnType: "component", props });
149
+ };
150
+
151
+ export default Duration;
@@ -0,0 +1,34 @@
1
+ import type { TypeTextProps } from "@sproutsocial/seeds-react-text";
2
+
3
+ export type TypeDurationMilliseconds = number | null;
4
+
5
+ export type TypeDurationLocale = Intl.LocalesArgument;
6
+
7
+ export type TypeDurationDisplay = "long" | "narrow";
8
+
9
+ export interface TypeDurationDisplayUnits {
10
+ days?: boolean;
11
+ hours?: boolean;
12
+ minutes?: boolean;
13
+ seconds?: boolean;
14
+ milliseconds?: boolean;
15
+ }
16
+
17
+ export interface TypeDurationProps extends Omit<TypeTextProps, "children"> {
18
+ /** The milliseconds to be formatted */
19
+ milliseconds: TypeDurationMilliseconds;
20
+
21
+ /** Locale to format. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument */
22
+ locale?: TypeDurationLocale;
23
+
24
+ /** The style of the formatted duration */
25
+ display?: TypeDurationDisplay;
26
+
27
+ /** The units of the duration to render */
28
+ displayUnits?: TypeDurationDisplayUnits;
29
+
30
+ /** Text to be read off by screen readers for invalid values (i.e., any value rendered as '—' (em dash)) */
31
+ invalidMillisecondsLabel?: string;
32
+
33
+ qa?: object;
34
+ }
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+ import Duration from "../Duration";
3
+
4
+ const defaultProps = {
5
+ color: "text.headline",
6
+ milliseconds: 12345,
7
+ };
8
+
9
+ export const DurationTypeTest = () => (
10
+ <>
11
+ <Duration {...defaultProps} />
12
+ <Duration
13
+ {...defaultProps}
14
+ milliseconds={100}
15
+ fontWeight="semibold"
16
+ fontSize={500}
17
+ />
18
+ <Duration {...defaultProps} milliseconds={100} color="teal.500" />
19
+ <Duration {...defaultProps} milliseconds={100} locale="it-IT" />
20
+ <Duration {...defaultProps} milliseconds={123.4} display="narrow" />
21
+ <Duration
22
+ {...defaultProps}
23
+ milliseconds={null}
24
+ invalidMillisecondsLabel="Not available"
25
+ />
26
+ {/* @ts-expect-error - test that invalid display is rejected */}
27
+ <Duration {...defaultProps} display="short" />
28
+ </>
29
+ );
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import { render } from "@sproutsocial/seeds-react-testing-library";
3
+ import Duration from "../../Duration";
4
+
5
+ describe("When rendering...", () => {
6
+ it("should handle A11y", async () => {
7
+ const { container, runA11yCheck } = render(<Duration milliseconds={0} />);
8
+ expect(container).toBeTruthy();
9
+ await runA11yCheck();
10
+ });
11
+ });
@@ -0,0 +1,116 @@
1
+ import testDuration from "./testDuration";
2
+
3
+ describe("When using the default props...", () => {
4
+ describe("... it renders properly", () => {
5
+ testDuration(1, {}, { text: "0s" });
6
+ testDuration(2, {}, { text: "0s" });
7
+ testDuration(1000, {}, { text: "1s" });
8
+ testDuration(2000, {}, { text: "2s" });
9
+ testDuration(1001, {}, { text: "1s" });
10
+ testDuration(2002, {}, { text: "2s" });
11
+ testDuration(60000, {}, { text: "1m" });
12
+ testDuration(120000, {}, { text: "2m" });
13
+ testDuration(61000, {}, { text: "1m 1s" });
14
+ testDuration(122000, {}, { text: "2m 2s" });
15
+ testDuration(60001, {}, { text: "1m" });
16
+ testDuration(120002, {}, { text: "2m" });
17
+ testDuration(61001, {}, { text: "1m 1s" });
18
+ testDuration(
19
+ 122002,
20
+ {},
21
+ {
22
+ text: "2m 2s",
23
+ }
24
+ );
25
+ testDuration(3600000, {}, { text: "1h" });
26
+ testDuration(7200000, {}, { text: "2h" });
27
+ testDuration(3660000, {}, { text: "1h 1m" });
28
+ testDuration(7320000, {}, { text: "2h 2m" });
29
+ testDuration(3601000, {}, { text: "1h 1s" });
30
+ testDuration(7202000, {}, { text: "2h 2s" });
31
+ testDuration(3600001, {}, { text: "1h" });
32
+ testDuration(7200002, {}, { text: "2h" });
33
+ testDuration(3661000, {}, { text: "1h 1m 1s" });
34
+ testDuration(7322000, {}, { text: "2h 2m 2s" });
35
+ testDuration(3601001, {}, { text: "1h 1s" });
36
+ testDuration(
37
+ 7202002,
38
+ {},
39
+ {
40
+ text: "2h 2s",
41
+ }
42
+ );
43
+ testDuration(3660001, {}, { text: "1h 1m" });
44
+ testDuration(
45
+ 7320002,
46
+ {},
47
+ {
48
+ text: "2h 2m",
49
+ }
50
+ );
51
+ testDuration(3661001, {}, { text: "1h 1m 1s" });
52
+ testDuration(7322002, {}, { text: "2h 2m 2s" });
53
+ testDuration(86400000, {}, { text: "1d" });
54
+ testDuration(172800000, {}, { text: "2d" });
55
+ testDuration(90000000, {}, { text: "1d 1h" });
56
+ testDuration(180000000, {}, { text: "2d 2h" });
57
+ testDuration(86460000, {}, { text: "1d 1m" });
58
+ testDuration(172920000, {}, { text: "2d 2m" });
59
+ testDuration(86401000, {}, { text: "1d 1s" });
60
+ testDuration(172802000, {}, { text: "2d 2s" });
61
+ testDuration(86400001, {}, { text: "1d" });
62
+ testDuration(172800002, {}, { text: "2d" });
63
+ testDuration(90060000, {}, { text: "1d 1h 1m" });
64
+ testDuration(180120000, {}, { text: "2d 2h 2m" });
65
+ testDuration(90001000, {}, { text: "1d 1h 1s" });
66
+ testDuration(180002000, {}, { text: "2d 2h 2s" });
67
+ testDuration(90000001, {}, { text: "1d 1h" });
68
+ testDuration(180000002, {}, { text: "2d 2h" });
69
+ testDuration(86461000, {}, { text: "1d 1m 1s" });
70
+ testDuration(172922000, {}, { text: "2d 2m 2s" });
71
+ testDuration(86460001, {}, { text: "1d 1m" });
72
+ testDuration(
73
+ 172920002,
74
+ {},
75
+ {
76
+ text: "2d 2m",
77
+ }
78
+ );
79
+ testDuration(86401001, {}, { text: "1d 1s" });
80
+ testDuration(
81
+ 172802002,
82
+ {},
83
+ {
84
+ text: "2d 2s",
85
+ }
86
+ );
87
+ testDuration(90061000, {}, { text: "1d 1h 1m 1s" });
88
+ testDuration(
89
+ 180122000,
90
+ {},
91
+ {
92
+ text: "2d 2h 2m 2s",
93
+ }
94
+ );
95
+ testDuration(86461001, {}, { text: "1d 1m 1s" });
96
+ testDuration(172922002, {}, { text: "2d 2m 2s" });
97
+ testDuration(
98
+ 90001001,
99
+ {},
100
+ {
101
+ text: "1d 1h 1s",
102
+ }
103
+ );
104
+ testDuration(180002002, {}, { text: "2d 2h 2s" });
105
+ testDuration(
106
+ 90060001,
107
+ {},
108
+ {
109
+ text: "1d 1h 1m",
110
+ }
111
+ );
112
+ testDuration(180120002, {}, { text: "2d 2h 2m" });
113
+ testDuration(90061001, {}, { text: "1d 1h 1m 1s" });
114
+ testDuration(180122002, {}, { text: "2d 2h 2m 2s" });
115
+ });
116
+ });
@@ -0,0 +1,102 @@
1
+ import testDuration from "./testDuration";
2
+
3
+ const options = { display: "long" };
4
+
5
+ describe("When setting display long...", () => {
6
+ describe("... it renders properly", () => {
7
+ testDuration(1, options, { text: "0 seconds" });
8
+ testDuration(2, options, { text: "0 seconds" });
9
+ testDuration(1000, options, { text: "1 second" });
10
+ testDuration(2000, options, { text: "2 seconds" });
11
+ testDuration(1001, options, { text: "1 second" });
12
+ testDuration(2002, options, { text: "2 seconds" });
13
+ testDuration(60000, options, { text: "1 minute" });
14
+ testDuration(120000, options, { text: "2 minutes" });
15
+ testDuration(61000, options, { text: "1 minute 1 second" });
16
+ testDuration(122000, options, { text: "2 minutes 2 seconds" });
17
+ testDuration(60001, options, { text: "1 minute" });
18
+ testDuration(120002, options, { text: "2 minutes" });
19
+ testDuration(61001, options, { text: "1 minute 1 second" });
20
+ testDuration(122002, options, {
21
+ text: "2 minutes 2 seconds",
22
+ });
23
+ testDuration(3600000, options, { text: "1 hour" });
24
+ testDuration(7200000, options, { text: "2 hours" });
25
+ testDuration(3660000, options, { text: "1 hour 1 minute" });
26
+ testDuration(7320000, options, { text: "2 hours 2 minutes" });
27
+ testDuration(3601000, options, { text: "1 hour 1 second" });
28
+ testDuration(7202000, options, { text: "2 hours 2 seconds" });
29
+ testDuration(3600001, options, { text: "1 hour" });
30
+ testDuration(7200002, options, { text: "2 hours" });
31
+ testDuration(3661000, options, { text: "1 hour 1 minute 1 second" });
32
+ testDuration(7322000, options, { text: "2 hours 2 minutes 2 seconds" });
33
+ testDuration(3601001, options, { text: "1 hour 1 second" });
34
+ testDuration(7202002, options, {
35
+ text: "2 hours 2 seconds",
36
+ });
37
+ testDuration(3660001, options, { text: "1 hour 1 minute" });
38
+ testDuration(7320002, options, {
39
+ text: "2 hours 2 minutes",
40
+ });
41
+ testDuration(3661001, options, {
42
+ text: "1 hour 1 minute 1 second",
43
+ });
44
+ testDuration(7322002, options, {
45
+ text: "2 hours 2 minutes 2 seconds",
46
+ });
47
+ testDuration(86400000, options, { text: "1 day" });
48
+ testDuration(172800000, options, { text: "2 days" });
49
+ testDuration(90000000, options, { text: "1 day 1 hour" });
50
+ testDuration(180000000, options, { text: "2 days 2 hours" });
51
+ testDuration(86460000, options, { text: "1 day 1 minute" });
52
+ testDuration(172920000, options, { text: "2 days 2 minutes" });
53
+ testDuration(86401000, options, { text: "1 day 1 second" });
54
+ testDuration(172802000, options, { text: "2 days 2 seconds" });
55
+ testDuration(86400001, options, { text: "1 day" });
56
+ testDuration(172800002, options, { text: "2 days" });
57
+ testDuration(90060000, options, { text: "1 day 1 hour 1 minute" });
58
+ testDuration(180120000, options, { text: "2 days 2 hours 2 minutes" });
59
+ testDuration(90001000, options, { text: "1 day 1 hour 1 second" });
60
+ testDuration(180002000, options, { text: "2 days 2 hours 2 seconds" });
61
+ testDuration(90000001, options, { text: "1 day 1 hour" });
62
+ testDuration(180000002, options, { text: "2 days 2 hours" });
63
+ testDuration(86461000, options, { text: "1 day 1 minute 1 second" });
64
+ testDuration(172922000, options, { text: "2 days 2 minutes 2 seconds" });
65
+ testDuration(86460001, options, { text: "1 day 1 minute" });
66
+ testDuration(172920002, options, {
67
+ text: "2 days 2 minutes",
68
+ });
69
+ testDuration(86401001, options, { text: "1 day 1 second" });
70
+ testDuration(172802002, options, {
71
+ text: "2 days 2 seconds",
72
+ });
73
+ testDuration(90061000, options, { text: "1 day 1 hour 1 minute 1 second" });
74
+ testDuration(180122000, options, {
75
+ text: "2 days 2 hours 2 minutes 2 seconds",
76
+ });
77
+ testDuration(86461001, options, {
78
+ text: "1 day 1 minute 1 second",
79
+ });
80
+ testDuration(172922002, options, {
81
+ text: "2 days 2 minutes 2 seconds",
82
+ });
83
+ testDuration(90001001, options, {
84
+ text: "1 day 1 hour 1 second",
85
+ });
86
+ testDuration(180002002, options, {
87
+ text: "2 days 2 hours 2 seconds",
88
+ });
89
+ testDuration(90060001, options, {
90
+ text: "1 day 1 hour 1 minute",
91
+ });
92
+ testDuration(180120002, options, {
93
+ text: "2 days 2 hours 2 minutes",
94
+ });
95
+ testDuration(90061001, options, {
96
+ text: "1 day 1 hour 1 minute 1 second",
97
+ });
98
+ testDuration(180122002, options, {
99
+ text: "2 days 2 hours 2 minutes 2 seconds",
100
+ });
101
+ });
102
+ });