diff --git a/.eslintrc.cjs b/.eslintrc.cjs index cc0cfe38f..b7c0a8c92 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -23,6 +23,7 @@ module.exports = { 'react/jsx-uses-vars': 'error', 'react/jsx-uses-react': 'error', '@typescript-eslint/no-non-null-assertion': 'error', + 'jest/no-focused-tests': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ 'error', diff --git a/packages/react-spectrum-charts/src/VegaChart.tsx b/packages/react-spectrum-charts/src/VegaChart.tsx index ac0d70434..b0a8cbb29 100644 --- a/packages/react-spectrum-charts/src/VegaChart.tsx +++ b/packages/react-spectrum-charts/src/VegaChart.tsx @@ -17,7 +17,12 @@ import { Options as TooltipOptions } from 'vega-tooltip'; import { TABLE } from '@spectrum-charts/constants'; -import { expressionFunctions, formatLocaleCurrency, formatTimeDurationLabels } from './expressionFunctions'; +import { + expressionFunctions, + formatLocaleCurrency, + formatShortNumber, + formatTimeDurationLabels, +} from './expressionFunctions'; import { useDebugSpec } from './hooks/useDebugSpec'; import { extractValues, isVegaData } from './hooks/useSpec'; import { ChartData, ChartProps } from './types'; @@ -57,6 +62,12 @@ export const VegaChart: FC = ({ const chartView = useRef(); const { number: numberLocale, time: timeLocale } = useMemo(() => getLocale(locale), [locale]); + const localeCode = useMemo(() => { + if (typeof locale === 'string') { + return locale; + } + return locale?.number; + }, [locale]); // Need to de a deep copy of the data because vega tries to transform the data const chartData = useMemo(() => { @@ -96,6 +107,7 @@ export const VegaChart: FC = ({ ...expressionFunctions, formatTimeDurationLabels: formatTimeDurationLabels(numberLocale), formatLocaleCurrency: formatLocaleCurrency(numberLocale), + formatShortNumber: formatShortNumber(localeCode), }, formatLocale: numberLocale as unknown as Record, // these are poorly typed by vega-embed height, @@ -134,6 +146,7 @@ export const VegaChart: FC = ({ spec, tooltip, width, + localeCode, ]); return
; diff --git a/packages/react-spectrum-charts/src/expressionFunctions.test.ts b/packages/react-spectrum-charts/src/expressionFunctions.test.ts index 0a4d47ee4..e351c2d0f 100644 --- a/packages/react-spectrum-charts/src/expressionFunctions.test.ts +++ b/packages/react-spectrum-charts/src/expressionFunctions.test.ts @@ -14,6 +14,7 @@ import { expressionFunctions, formatHorizontalTimeAxisLabels, formatLocaleCurrency, + formatShortNumber, formatTimeDurationLabels, formatVerticalAxisTimeLabels, } from './expressionFunctions'; @@ -157,3 +158,45 @@ describe('formatVerticalAxisTimeLabels()', () => { expect(formatter({ index: 1, label: '2024 \u2000Feb', value: 1 })).toBe('Feb'); }); }); + +describe('formatShortNumber()', () => { + test('should revturn the correst string based on the value', () => { + expect(formatShortNumber('en-US')(123)).toBe('123'); + expect(formatShortNumber('en-US')(1234)).toBe('1.2K'); + expect(formatShortNumber('en-US')(12345)).toBe('12K'); + expect(formatShortNumber('en-US')(123456)).toBe('123K'); + expect(formatShortNumber('en-US')(1234567)).toBe('1.2M'); + expect(formatShortNumber('en-US')(12345678)).toBe('12M'); + expect(formatShortNumber('en-US')(123456789)).toBe('123M'); + expect(formatShortNumber('en-US')(1234567890)).toBe('1.2B'); + expect(formatShortNumber('en-US')(12345678900)).toBe('12B'); + expect(formatShortNumber('en-US')(123456789000)).toBe('123B'); + expect(formatShortNumber('en-US')(1234567890000)).toBe('1.2T'); + expect(formatShortNumber('en-US')(12345678900000)).toBe('12T'); + expect(formatShortNumber('en-US')(123456789000000)).toBe('123T'); + expect(formatShortNumber('en-US')(1234567890000000)).toBe('1235T'); + }); + test('should return the correct string based on locale', () => { + expect(formatShortNumber('en-US')(123456789)).toBe('123M'); + expect(formatShortNumber('es-ES')(123456789)).toBe('123\u00a0M'); + expect(formatShortNumber('fr-FR')(123456789)).toBe('123\u00a0M'); + expect(formatShortNumber('de-DE')(123456789)).toBe('123\u00a0Mio.'); + expect(formatShortNumber('ja-JP')(123456789)).toBe('1.2億'); + expect(formatShortNumber('zh-CN')(123456789)).toBe('1.2亿'); + expect(formatShortNumber('zh-TW')(123456789)).toBe('1.2億'); + expect(formatShortNumber('ko-KR')(123456789)).toBe('1.2억'); + expect(formatShortNumber('ru-RU')(123456789)).toBe('123\u00a0млн'); + expect(formatShortNumber('pt-BR')(123456789)).toBe('123\u00a0mi'); + }); + test('should use custom decimal symbol if provided', () => { + expect( + formatShortNumber({ + decimal: ',', + thousands: '\u00a0', + grouping: [3], + currency: ['', '\u00a0€'], + percent: '\u202f%', + })(1234567) + ).toBe('1,2M'); + }); +}); diff --git a/packages/react-spectrum-charts/src/expressionFunctions.ts b/packages/react-spectrum-charts/src/expressionFunctions.ts index b9dd18979..350bc5548 100644 --- a/packages/react-spectrum-charts/src/expressionFunctions.ts +++ b/packages/react-spectrum-charts/src/expressionFunctions.ts @@ -23,6 +23,28 @@ export interface LabelDatum { value: string | number; } +/** + * Formats a number using the compact notation. + * @param numberLocale + * @returns formatted string + */ +export const formatShortNumber = (numberLocale?: string | FormatLocaleDefinition) => { + const locale = typeof numberLocale === 'string' ? numberLocale : navigator.language; + const customDecimalSymbol = typeof numberLocale === 'object' ? numberLocale.decimal : undefined; + return (value: number) => { + // get the decimal symbol for the locale by formatting a number with decimals + const decimalSymbol = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + .format(1.1) + .replace(/\d/g, ''); + + const shortNumber = Intl.NumberFormat(locale, { notation: 'compact' }).format(value); + if (customDecimalSymbol) { + return shortNumber.replace(decimalSymbol, customDecimalSymbol); + } + return shortNumber; + }; +}; + /** * Formats currency values using a currency specific locale and currency code for the position and * type of currency symbol. diff --git a/packages/react-spectrum-charts/src/stories/components/DonutSummary/DonutSummary.test.tsx b/packages/react-spectrum-charts/src/stories/components/DonutSummary/DonutSummary.test.tsx index d923cb4dd..2189e3289 100644 --- a/packages/react-spectrum-charts/src/stories/components/DonutSummary/DonutSummary.test.tsx +++ b/packages/react-spectrum-charts/src/stories/components/DonutSummary/DonutSummary.test.tsx @@ -23,19 +23,19 @@ describe('DonutSummary renders properly', () => { test('metric value should be centered if there is not a label', async () => { render(); - const metricValue = await screen.findByText('40.4K'); + const metricValue = await screen.findByText('40K'); expect(metricValue).toHaveAttribute('transform', 'translate(175,190)'); }); test('metric value should be above center if there is a label', async () => { render(); - const metricValue = await screen.findByText('40.4K'); + const metricValue = await screen.findByText('40K'); expect(metricValue).toHaveAttribute('transform', 'translate(175,175)'); }); test('metric label text should be 1/2 the size of the metric value text', async () => { render(); - const metricValue = await screen.findByText('40.4K'); + const metricValue = await screen.findByText('40K'); expect(metricValue).toHaveAttribute('font-size', '28px'); const metricLabel = await screen.findByText('Visitors'); expect(metricLabel).toHaveAttribute('font-size', '14px'); @@ -58,62 +58,62 @@ describe('Responsive font sizes should snap to correct font size based on inner // 160 / 2 * 0.85 * 0.35 = 23.8 // this should be clamped to the min of 28 render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '28px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '28px'); }); test('should snap to 28 if 28 < target < 32', async () => { // 200 / 2 * 0.85 * 0.35 = 29.75 // this is less than 32 and greater than 28 so it should snap to 28 render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '28px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '28px'); }); test('should snap to 32 if 32 < target < 36', async () => { // 240 / 2 * 0.85 * 0.35 = 35.7 // this is less than 36 and greater than 32 so it should snap to 32 render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '32px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '32px'); }); test('should snap to 36 if 36 < target < 40', async () => { // 260 / 2 * 0.85 * 0.35 = 38.675 // this is less than 40 and greater than 36 so it should snap to 36 render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '36px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '36px'); }); test('should snap to 40 if 40 < target < 45', async () => { // 300 / 2 * 0.85 * 0.35 = 44.625 // this is less than 44 and greater than 40 so it should snap to 40 render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '40px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '40px'); }); test('should snap to 45 if 45 < target < 50', async () => { // 320 / 2 * 0.85 * 0.35 = 47.6 // this is less than 50 and greater than 45 so it should snap to 45 render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '45px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '45px'); }); test('should snap to 50 if 50 < target < 60', async () => { // 400 / 2 * 0.85 * 0.35 = 59 // this is less than 60 and greater than 50 so it should snap to 50 render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '50px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '50px'); }); test('should snap to max if target > 60', async () => { // 600 / 2 * 0.85 * 0.35 = 89.25 // this is greater than 60 so it should snap to 60 render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '60px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '60px'); }); }); describe('Small radius', () => { test('should hide the summary if the donut inner radius is < DONUT_SUMMARY_MIN_RADIUS', async () => { render(); - expect(await screen.findByText('40.4K')).toHaveAttribute('font-size', '0px'); + expect(await screen.findByText('40K')).toHaveAttribute('font-size', '0px'); }); }); diff --git a/packages/react-spectrum-charts/src/stories/components/Legend/Legend.test.tsx b/packages/react-spectrum-charts/src/stories/components/Legend/Legend.test.tsx index 2eceb069a..d862e64a7 100644 --- a/packages/react-spectrum-charts/src/stories/components/Legend/Legend.test.tsx +++ b/packages/react-spectrum-charts/src/stories/components/Legend/Legend.test.tsx @@ -196,7 +196,7 @@ describe('Legend', () => { expect(view).toBeInTheDocument(); }); - test.only('Popover renders properly', async () => { + test('Popover renders properly', async () => { render(); const chart = await findChart(); const entries = getAllLegendEntries(chart); diff --git a/packages/react-spectrum-charts/src/stories/components/SegmentLabel/SegmentLabel.test.tsx b/packages/react-spectrum-charts/src/stories/components/SegmentLabel/SegmentLabel.test.tsx index 67e9e58dc..4af54f61c 100644 --- a/packages/react-spectrum-charts/src/stories/components/SegmentLabel/SegmentLabel.test.tsx +++ b/packages/react-spectrum-charts/src/stories/components/SegmentLabel/SegmentLabel.test.tsx @@ -59,8 +59,8 @@ describe('SegmentLabel', () => { const chart = await findChart(); expect(chart).toBeInTheDocument(); - expect(screen.getByText('10.4K')).toBeInTheDocument(); - expect(screen.getByText('7.05K')).toBeInTheDocument(); + expect(screen.getByText('10K')).toBeInTheDocument(); + expect(screen.getByText('7K')).toBeInTheDocument(); expect(screen.getByText('4.2K')).toBeInTheDocument(); }); diff --git a/packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts b/packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts index c7e104284..670f17b55 100644 --- a/packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts +++ b/packages/vega-spec-builder/src/axis/axisSpecBuilder.test.ts @@ -52,8 +52,8 @@ const defaultAxis: Axis = { update: { text: [ { - test: "isNumber(datum['value']) && abs(datum['value']) >= 1000", - signal: "upper(replace(format(datum['value'], '.3~s'), /(\\d+)G/, '$1B'))", + test: "isNumber(datum['value'])", + signal: "formatShortNumber(datum['value'])", }, { signal: 'datum.value' }, ], diff --git a/packages/vega-spec-builder/src/axis/axisUtils.test.ts b/packages/vega-spec-builder/src/axis/axisUtils.test.ts index d38b74b24..375ba6f98 100644 --- a/packages/vega-spec-builder/src/axis/axisUtils.test.ts +++ b/packages/vega-spec-builder/src/axis/axisUtils.test.ts @@ -82,8 +82,8 @@ describe('getDefaultAxis()', () => { update: { text: [ { - test: "isNumber(datum['value']) && abs(datum['value']) >= 1000", - signal: "upper(replace(format(datum['value'], '.3~s'), /(\\d+)G/, '$1B'))", + test: "isNumber(datum['value'])", + signal: "formatShortNumber(datum['value'])", }, { signal: 'datum.value', @@ -144,8 +144,8 @@ describe('getDefaultAxis()', () => { update: { text: [ { - test: "isNumber(datum['value']) && abs(datum['value']) >= 1000", - signal: "upper(replace(format(datum['value'], '.3~s'), /(\\d+)G/, '$1B'))", + test: "isNumber(datum['value'])", + signal: "formatShortNumber(datum['value'])", }, { signal: 'datum.value', diff --git a/packages/vega-spec-builder/src/donut/donutSummaryUtils.test.tsx b/packages/vega-spec-builder/src/donut/donutSummaryUtils.test.tsx index 535640802..458c822de 100644 --- a/packages/vega-spec-builder/src/donut/donutSummaryUtils.test.tsx +++ b/packages/vega-spec-builder/src/donut/donutSummaryUtils.test.tsx @@ -117,8 +117,8 @@ describe('getSummaryValueText()', () => { const result = getSummaryValueText(defaultDonutSummaryOptions); expect(result).toEqual([ { - signal: "upper(replace(format(datum['sum'], '.3~s'), /(\\d+)G/, '$1B'))", - test: "isNumber(datum['sum']) && abs(datum['sum']) >= 1000", + signal: "formatShortNumber(datum['sum'])", + test: "isNumber(datum['sum'])", }, { field: 'sum' }, ]); diff --git a/packages/vega-spec-builder/src/textUtils.test.ts b/packages/vega-spec-builder/src/textUtils.test.ts index c48e80e48..62619954e 100644 --- a/packages/vega-spec-builder/src/textUtils.test.ts +++ b/packages/vega-spec-builder/src/textUtils.test.ts @@ -15,7 +15,7 @@ describe('getTextNumberFormat()', () => { test('should return correct signal for shortNumber', () => { const format = getTextNumberFormat('shortNumber'); expect(format).toHaveLength(1); - expect(format[0]).toHaveProperty('signal', "upper(replace(format(datum['value'], '.3~s'), /(\\d+)G/, '$1B'))"); + expect(format[0]).toHaveProperty('signal', "formatShortNumber(datum['value'])"); }); test('should return correct signal for shortCurrency', () => { const format = getTextNumberFormat('shortCurrency'); diff --git a/packages/vega-spec-builder/src/textUtils.ts b/packages/vega-spec-builder/src/textUtils.ts index 174704d20..e4b25a49b 100644 --- a/packages/vega-spec-builder/src/textUtils.ts +++ b/packages/vega-spec-builder/src/textUtils.ts @@ -29,12 +29,7 @@ export const getTextNumberFormat = ( } & TextValueRef)[] => { const test = `isNumber(datum['${datumProperty}'])`; if (numberFormat === 'shortNumber') { - return [ - { - test: `${test} && abs(datum['${datumProperty}']) >= 1000`, - signal: `upper(replace(format(datum['${datumProperty}'], '.3~s'), /(\\d+)G/, '$1B'))`, - }, - ]; + return [{ test, signal: `formatShortNumber(datum['${datumProperty}'])` }]; } if (numberFormat === 'shortCurrency') { return [