@sproutsocial/seeds-react-numeral 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,169 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import Numeral from "./Numeral";
3
+
4
+ const localeOptions = [
5
+ "United States (en-US)",
6
+ "English (en)",
7
+ "Arabic (ar-EG)",
8
+ "Brazil (pt-BR)",
9
+ "India (en-IN)",
10
+ "French (fr-FR)",
11
+ "Spain (es-ES)",
12
+ "Mexico (es-MX)",
13
+ "Germany (de-DE)",
14
+ "German (de)",
15
+ "Japan (ja-JP)",
16
+ "Japanese (ja)",
17
+ ];
18
+
19
+ const localeMapping = {
20
+ "United States (en-US)": "en-US",
21
+ "English (en)": "en",
22
+ "Arabic (ar-EG)": "ar-EG",
23
+ "Brazil (pt-BR)": "pt-BR",
24
+ "India (en-IN)": "en-IN",
25
+ "French (fr-FR)": "fr-FR",
26
+ "Spain (es-ES)": "es-ES",
27
+ "Mexico (es-MX)": "es-MX",
28
+ "Germany (de-DE)": "de-DE",
29
+ "German (de)": "de",
30
+ "Japan (ja-JP)": "ja-JP",
31
+ "Japanese (ja)": "ja",
32
+ };
33
+
34
+ const currencyOptions = [
35
+ "Egyptian £",
36
+ "European €",
37
+ "Indian ₹",
38
+ "Japanese ¥",
39
+ "USA $",
40
+ ];
41
+
42
+ const currencyMapping = {
43
+ "Egyptian £": "EGP",
44
+ "European €": "EUR",
45
+ "Indian ₹": "INR",
46
+ "Japanese ¥": "JPY",
47
+ "USA $": "USD",
48
+ };
49
+
50
+ const formatOptions = ["decimal", "currency", "percent"];
51
+
52
+ const formatMapping = {
53
+ decimal: "decimal",
54
+ currency: "currency",
55
+ percent: "percent",
56
+ };
57
+
58
+ const abbreviateOptions = [
59
+ "true",
60
+ "false",
61
+ " 500",
62
+ "1_000",
63
+ "10_000",
64
+ "100_000",
65
+ ];
66
+
67
+ const abbreviateMapping = {
68
+ true: true,
69
+ false: false,
70
+ " 500": 500,
71
+ "1_000": 1000,
72
+ "10_000": 10000,
73
+ "100_000": 100000,
74
+ };
75
+
76
+ const precisionOptions = ["0", "1", "2", "3", "6", "none"];
77
+
78
+ const precisionMapping = {
79
+ "0": 0,
80
+ "1": 1,
81
+ "2": 2,
82
+ "3": 3,
83
+ "6": 6,
84
+ none: "none",
85
+ };
86
+
87
+ const meta: Meta<typeof Numeral> = {
88
+ title: "Components/Numeral",
89
+ component: Numeral,
90
+ argTypes: {
91
+ locale: {
92
+ control: "select",
93
+ options: localeOptions,
94
+ mapping: localeMapping,
95
+ },
96
+ format: {
97
+ control: "select",
98
+ options: formatOptions,
99
+ mapping: formatMapping,
100
+ },
101
+ currency: {
102
+ control: "select",
103
+ options: currencyOptions,
104
+ mapping: currencyMapping,
105
+ },
106
+ abbreviate: {
107
+ control: "select",
108
+ options: abbreviateOptions,
109
+ mapping: abbreviateMapping,
110
+ },
111
+ precision: {
112
+ control: "select",
113
+ options: precisionOptions,
114
+ mapping: precisionMapping,
115
+ },
116
+ },
117
+ args: {
118
+ color: "text.headline",
119
+ number: 12.89,
120
+ invalidNumberLabel: "Not available",
121
+ },
122
+ };
123
+ export default meta;
124
+
125
+ type Story = StoryObj<typeof Numeral>;
126
+
127
+ export const Default: Story = {};
128
+
129
+ export const Total: Story = {
130
+ args: {
131
+ number: 100,
132
+ fontWeight: "semibold",
133
+ fontSize: 500,
134
+ },
135
+ };
136
+
137
+ export const Trend: Story = {
138
+ args: {
139
+ number: 100,
140
+ color: "teal.500",
141
+ },
142
+ };
143
+
144
+ export const NoPrecision: Story = {
145
+ args: {
146
+ number: 123.45678,
147
+ precision: "none",
148
+ },
149
+ };
150
+
151
+ export const CurrencyPrecision: Story = {
152
+ args: {
153
+ number: 123.4,
154
+ format: "currency",
155
+ },
156
+ };
157
+
158
+ export const Invalid: Story = {
159
+ args: {
160
+ number: null,
161
+ },
162
+ };
163
+
164
+ export const AbbreviatedNegative: Story = {
165
+ args: {
166
+ number: -123456.789,
167
+ abbreviate: true,
168
+ },
169
+ };
@@ -0,0 +1,208 @@
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 Tooltip from "@sproutsocial/seeds-react-tooltip";
6
+ import type { TypeTextProps } from "@sproutsocial/seeds-react-text";
7
+ import { VisuallyHidden } from "@sproutsocial/seeds-react-visually-hidden";
8
+
9
+ import {
10
+ DEFAULT_THRESHOLD,
11
+ MEMO_CACHE_SIZE,
12
+ COMPARE_OBJECTS,
13
+ MAX_PRECISION,
14
+ ABBREV_PRECISION,
15
+ DefaultPrecisions,
16
+ } from "./constants";
17
+ import { AbbrContainer, Container } from "./styles";
18
+ import type { EnumNumeralFormat, TypeNumeralProps } from "./NumeralTypes";
19
+
20
+ interface TypeFormatOptions {
21
+ locale: string;
22
+ format: EnumNumeralFormat;
23
+ currency: string;
24
+ min: number;
25
+ max: number;
26
+ }
27
+
28
+ interface TypeFormatters {
29
+ standard: Intl.NumberFormat;
30
+ abbreviated: Intl.NumberFormat;
31
+ }
32
+
33
+ interface TypeArgs {
34
+ value: number;
35
+ canAbbreviate: boolean;
36
+ invalidNumberLabel?: string;
37
+ ariaLabel?: string;
38
+ options: TypeFormatOptions;
39
+ qa: object | null | undefined;
40
+ rest: Omit<TypeTextProps, "children">;
41
+ }
42
+
43
+ const _getNumberFormatters = (options: TypeFormatOptions): TypeFormatters => {
44
+ const { locale, format, currency, min, max } = options;
45
+ const compactPrecision = min === max ? min : ABBREV_PRECISION;
46
+
47
+ const _currency = format === "currency" ? currency : undefined;
48
+
49
+ const standard: Intl.NumberFormatOptions = {
50
+ style: format,
51
+ minimumFractionDigits: min,
52
+ maximumFractionDigits: max,
53
+ currency: _currency,
54
+ };
55
+ const compact: Intl.NumberFormatOptions = {
56
+ style: format,
57
+ minimumFractionDigits: compactPrecision,
58
+ maximumFractionDigits: compactPrecision,
59
+ currency: _currency,
60
+ notation: "compact",
61
+ };
62
+ // Safari 14.1 is currently throwing errors when trying to use the compact
63
+ // options of NumberFormat
64
+ // https://community.atlassian.com/t5/Trello-questions/Trello-stuck-at-loading-after-Safari-14-1-update-on-macOS-Mojave/qaq-p/1675577#M45687
65
+ let abbreviated;
66
+
67
+ try {
68
+ abbreviated = new Intl.NumberFormat(locale, compact);
69
+ } catch (error) {
70
+ abbreviated = new Intl.NumberFormat(locale, standard);
71
+ }
72
+
73
+ return {
74
+ standard: new Intl.NumberFormat(locale, standard),
75
+ abbreviated,
76
+ };
77
+ };
78
+
79
+ // Memoize to reduce the energy of creating new instances of Intl.NumberFormat
80
+ const memoizer = memoize(MEMO_CACHE_SIZE, COMPARE_OBJECTS);
81
+ const getNumberFormatters = memoizer(_getNumberFormatters);
82
+
83
+ const getThreshold = (abbreviate: boolean | number): number => {
84
+ if (typeof abbreviate === "number")
85
+ return Math.max(1000, Math.abs(abbreviate));
86
+ if (abbreviate) return DEFAULT_THRESHOLD;
87
+ return Infinity;
88
+ };
89
+
90
+ const getMinMaxPrecision = (
91
+ precision: TypeNumeralProps["precision"],
92
+ format: EnumNumeralFormat
93
+ ): [number, number] => {
94
+ if (typeof precision === "number") return [precision, precision];
95
+ if (precision === "none") return [0, MAX_PRECISION];
96
+ return DefaultPrecisions[format];
97
+ };
98
+
99
+ const isValidNumber = (value: unknown): boolean => {
100
+ return typeof value === "number" && isFinite(value);
101
+ };
102
+
103
+ const normalizeArgs = (props: TypeNumeralProps): TypeArgs => {
104
+ const {
105
+ number,
106
+ locale = "us-EN",
107
+ format = props.currency ? "currency" : "decimal",
108
+ currency = "USD",
109
+ abbreviate = true,
110
+ invalidNumberLabel,
111
+ precision,
112
+ qa,
113
+ ...rest
114
+ } = props;
115
+ const threshold = getThreshold(abbreviate);
116
+ const [min, max] = getMinMaxPrecision(precision, format);
117
+
118
+ const _number = number || 0;
119
+
120
+ const value = _number * (format === "percent" ? 0.01 : 1);
121
+ const canAbbreviate = Math.abs(_number) >= threshold;
122
+ const options = {
123
+ locale,
124
+ format,
125
+ currency,
126
+ min,
127
+ max,
128
+ };
129
+
130
+ return {
131
+ value,
132
+ canAbbreviate,
133
+ invalidNumberLabel,
134
+ options,
135
+ qa,
136
+ rest,
137
+ };
138
+ };
139
+
140
+ const getNumeral = ({
141
+ returnType,
142
+ props,
143
+ }: {
144
+ returnType: "string" | "component";
145
+ props: TypeNumeralProps;
146
+ }): string | React.ReactNode => {
147
+ const isReturnTypeString = returnType === "string";
148
+ const { value, canAbbreviate, invalidNumberLabel, options, qa, rest } =
149
+ normalizeArgs(props);
150
+
151
+ if (!isValidNumber(props.number)) {
152
+ return isReturnTypeString ? (
153
+ EM_DASH
154
+ ) : (
155
+ <>
156
+ {invalidNumberLabel && (
157
+ // Give screen readers something useful to read off + hide the em dash
158
+ <VisuallyHidden>{invalidNumberLabel}</VisuallyHidden>
159
+ )}
160
+ <Container aria-hidden {...qa}>
161
+ {EM_DASH}
162
+ </Container>
163
+ </>
164
+ );
165
+ }
166
+
167
+ const formatters = getNumberFormatters(options);
168
+ const fullText = formatters.standard.format(value);
169
+
170
+ if (canAbbreviate) {
171
+ const abbreviatedText = formatters.abbreviated.format(value);
172
+
173
+ // The following are used to debug the skipped tests which are misbehaving!!!
174
+ // console.log({ fullText, abbreviatedText });
175
+ // console.log({ abbreviated: formatters.abbreviated.resolvedOptions() });
176
+ // The following check is necessary because each locale may have differing thresholds
177
+ // for which abbreviation begins.
178
+ if (abbreviatedText !== fullText) {
179
+ return isReturnTypeString ? (
180
+ abbreviatedText
181
+ ) : (
182
+ <Tooltip content={fullText}>
183
+ <AbbrContainer {...qa} {...rest}>
184
+ {abbreviatedText}
185
+ </AbbrContainer>
186
+ </Tooltip>
187
+ );
188
+ }
189
+ }
190
+
191
+ return isReturnTypeString ? (
192
+ fullText
193
+ ) : (
194
+ <Container {...qa} {...rest}>
195
+ {fullText}
196
+ </Container>
197
+ );
198
+ };
199
+
200
+ export const formatNumeral = (props: TypeNumeralProps): string => {
201
+ return getNumeral({ returnType: "string", props }) as string;
202
+ };
203
+
204
+ const Numeral = (props: TypeNumeralProps) => {
205
+ return getNumeral({ returnType: "component", props });
206
+ };
207
+
208
+ export default Numeral;
@@ -0,0 +1,27 @@
1
+ import type { TypeTextProps } from "@sproutsocial/seeds-react-text";
2
+
3
+ export type EnumNumeralFormat = "decimal" | "currency" | "percent";
4
+
5
+ export interface TypeNumeralProps extends Omit<TypeTextProps, "children"> {
6
+ /** The number to be formatted */
7
+ number?: number | null | undefined;
8
+
9
+ /** Locale to format. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument */
10
+ locale?: string;
11
+
12
+ /* The number formatting style to use */
13
+ format?: EnumNumeralFormat;
14
+
15
+ /** The currency format to use when formatting currency */
16
+ currency?: string;
17
+
18
+ /** A boolean determining whether or not the number should be abbreviated, or a number representing the abbreviation threshold */
19
+ abbreviate?: boolean | number;
20
+
21
+ /** Text to be read off by screen readers for invalid values (i.e., any value rendered as '—' (em dash)) */
22
+ invalidNumberLabel?: string;
23
+
24
+ /** Override the default decimal precision (2 for decimals/currency, 1 for percentages), or "none" allowing unrestricted precision. */
25
+ precision?: number | "none";
26
+ qa?: object;
27
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from "react";
2
+ import Numeral from "../Numeral";
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
5
+ function NumeralTypes() {
6
+ const defaultProps = {
7
+ color: "text.headline",
8
+ number: 12.89,
9
+ };
10
+
11
+ return (
12
+ <>
13
+ <Numeral {...defaultProps} />
14
+ <Numeral
15
+ {...defaultProps}
16
+ number={100}
17
+ fontWeight="semibold"
18
+ fontSize={500}
19
+ />
20
+ <Numeral {...defaultProps} number={100} color="teal.500" />
21
+ <Numeral {...defaultProps} number={100} precision="none" />
22
+ <Numeral {...defaultProps} number={123.4} format="currency" />
23
+ <Numeral
24
+ {...defaultProps}
25
+ number={null}
26
+ invalidNumberLabel="Not available"
27
+ />
28
+ {/* @ts-expect-error - test that invalid precision is rejected */}
29
+ <Numeral {...defaultProps} precision="invalid" />
30
+ </>
31
+ );
32
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import { render } from "@sproutsocial/seeds-react-testing-library";
3
+ import Numeral from "../../Numeral";
4
+
5
+ describe("When rendering...", () => {
6
+ it("should handle A11y", async () => {
7
+ const { container, runA11yCheck } = render(<Numeral />);
8
+ expect(container).toBeTruthy();
9
+ await runA11yCheck();
10
+ });
11
+ });
@@ -0,0 +1,215 @@
1
+ import testNumeral from "./testNumeral";
2
+
3
+ describe("When setting abbreviate false...", () => {
4
+ describe("... it never abbreviates the text", () => {
5
+ const options = {
6
+ abbreviate: false,
7
+ };
8
+ // positive values
9
+ testNumeral(1, options, {
10
+ text: "1",
11
+ });
12
+ testNumeral(12, options, {
13
+ text: "12",
14
+ });
15
+ testNumeral(0.23, options, {
16
+ text: "0.23",
17
+ });
18
+ testNumeral(1234, options, {
19
+ text: "1,234",
20
+ });
21
+ testNumeral(12345, options, {
22
+ text: "12,345",
23
+ });
24
+ testNumeral(123456.789, options, {
25
+ text: "123,456.79",
26
+ });
27
+ testNumeral(1225000000000, options, {
28
+ text: "1,225,000,000,000",
29
+ });
30
+ // negative values
31
+ testNumeral(-1, options, {
32
+ text: "-1",
33
+ });
34
+ testNumeral(-12, options, {
35
+ text: "-12",
36
+ });
37
+ testNumeral(-0.23, options, {
38
+ text: "-0.23",
39
+ });
40
+ testNumeral(-1234, options, {
41
+ text: "-1,234",
42
+ });
43
+ testNumeral(-12345, options, {
44
+ text: "-12,345",
45
+ });
46
+ testNumeral(-123456.789, options, {
47
+ text: "-123,456.79",
48
+ });
49
+ testNumeral(-1225000000000, options, {
50
+ text: "-1,225,000,000,000",
51
+ });
52
+ });
53
+ });
54
+
55
+ describe("When abbreviate throws an error", () => {
56
+ describe("... it never abbreviates the text", () => {
57
+ const OriginalNumberFormat = Intl.NumberFormat;
58
+ beforeEach(() => {
59
+ class CustomNumberFormat {
60
+ constructor(locale: string, standard?: Intl.NumberFormatOptions) {
61
+ if (standard?.notation === "compact") {
62
+ throw new Error("safari bug can not use compact");
63
+ }
64
+
65
+ return new OriginalNumberFormat(locale, standard);
66
+ }
67
+ }
68
+ // @ts-expect-error - types mismatch
69
+ Intl["NumberFormat"] = CustomNumberFormat;
70
+ });
71
+
72
+ afterEach(() => {
73
+ Intl["NumberFormat"] = OriginalNumberFormat;
74
+ });
75
+ const options = {
76
+ abbreviate: true,
77
+ };
78
+ // positive values
79
+ testNumeral(1, options, {
80
+ text: "1",
81
+ });
82
+ testNumeral(12, options, {
83
+ text: "12",
84
+ });
85
+ testNumeral(0.23, options, {
86
+ text: "0.23",
87
+ });
88
+ testNumeral(1234, options, {
89
+ text: "1,234",
90
+ });
91
+ testNumeral(12345, options, {
92
+ text: "12,345",
93
+ });
94
+ testNumeral(123456.789, options, {
95
+ text: "123,456.79",
96
+ });
97
+ testNumeral(1225000000000, options, {
98
+ text: "1,225,000,000,000",
99
+ });
100
+ // negative values
101
+ testNumeral(-1, options, {
102
+ text: "-1",
103
+ });
104
+ testNumeral(-12, options, {
105
+ text: "-12",
106
+ });
107
+ testNumeral(-0.23, options, {
108
+ text: "-0.23",
109
+ });
110
+ testNumeral(-1234, options, {
111
+ text: "-1,234",
112
+ });
113
+ testNumeral(-12345, options, {
114
+ text: "-12,345",
115
+ });
116
+ testNumeral(-123456.789, options, {
117
+ text: "-123,456.79",
118
+ });
119
+ testNumeral(-1225000000000, options, {
120
+ text: "-1,225,000,000,000",
121
+ });
122
+ });
123
+ });
124
+
125
+ describe("When setting abbreviate to a threshold...", () => {
126
+ describe("... it doesn't abbreviate if under the threshold", () => {
127
+ // positive values
128
+ const positiveOptions = {
129
+ abbreviate: 2000,
130
+ };
131
+ testNumeral(1999, positiveOptions, {
132
+ text: "1,999",
133
+ });
134
+ testNumeral(2000, positiveOptions, {
135
+ text: "2.00K",
136
+ tip: "2,000",
137
+ });
138
+ testNumeral(2011, positiveOptions, {
139
+ text: "2.01K",
140
+ tip: "2,011",
141
+ });
142
+ // negative values
143
+ const negativeOptions = {
144
+ abbreviate: -2000,
145
+ };
146
+ testNumeral(-1999, negativeOptions, {
147
+ text: "-1,999",
148
+ });
149
+ testNumeral(-2000, negativeOptions, {
150
+ text: "-2.00K",
151
+ tip: "-2,000",
152
+ });
153
+ testNumeral(-2011, negativeOptions, {
154
+ text: "-2.01K",
155
+ tip: "-2,011",
156
+ });
157
+ });
158
+
159
+ describe("... or if too small to abbreviate", () => {
160
+ // positive values
161
+ const positiveOptions = {
162
+ abbreviate: 500,
163
+ };
164
+ testNumeral(999, positiveOptions, {
165
+ text: "999",
166
+ });
167
+ testNumeral(1000, positiveOptions, {
168
+ text: "1.00K",
169
+ tip: "1,000",
170
+ });
171
+ // negative values
172
+ const negativeOptions = {
173
+ abbreviate: -500,
174
+ };
175
+ testNumeral(-999, negativeOptions, {
176
+ text: "-999",
177
+ });
178
+ testNumeral(-1000, negativeOptions, {
179
+ text: "-1.00K",
180
+ tip: "-1,000",
181
+ });
182
+ });
183
+
184
+ describe("... it abbreviates if greater or equal to the threshold", () => {
185
+ const options = {
186
+ abbreviate: true,
187
+ };
188
+ // positive values
189
+ testNumeral(12345, options, {
190
+ text: "12.35K",
191
+ tip: "12,345",
192
+ });
193
+ testNumeral(123456.789, options, {
194
+ text: "123.46K",
195
+ tip: "123,456.79",
196
+ });
197
+ testNumeral(1225000000000, options, {
198
+ text: "1.23T",
199
+ tip: "1,225,000,000,000",
200
+ });
201
+ // negative values
202
+ testNumeral(-12345, options, {
203
+ text: "-12.35K",
204
+ tip: "-12,345",
205
+ });
206
+ testNumeral(-123456.789, options, {
207
+ text: "-123.46K",
208
+ tip: "-123,456.79",
209
+ });
210
+ testNumeral(-1225000000000, options, {
211
+ text: "-1.23T",
212
+ tip: "-1,225,000,000,000",
213
+ });
214
+ });
215
+ });