From f3676a619b2085ae88ac8ccb5921f717481e4e67 Mon Sep 17 00:00:00 2001 From: Michael Flanakin Date: Thu, 2 Jul 2026 10:45:13 -0700 Subject: [PATCH 1/2] feat(react-charts): allow a function for cartesian tickFormat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cartesian `tickFormat` prop is a d3-format string, applied as `d3Format(tickFormat)(value)`. A string cannot express two common value-axis needs: - A literal `%` — d3's `%` type multiplies by 100 (`format('%')(50)` → `5000%`), double-scaling an already-0–100 axis (e.g. a 100%-stacked bar); there is no literal/escape slot, so `~s%`, `%%`, `\%`, `'%'` all throw `invalid format`. - App/i18n formatting — routing ticks through the app's own currency/number formatter so axis ticks match tooltip/callout values; d3's `$` only emits its locale symbol. `HorizontalBarChartWithAxis`, whose value axis is the x-axis, is most affected. Widen `tickFormat` to `string | ((value: number | Date) => string)` and branch in `createNumericXAxis` and `createDateXAxis`. Backward compatible: a string keeps the exact current d3 behavior; only a function is new. --- ...ntui-react-charts-function-tickformat.json | 7 ++++ .../library/etc/react-charts.api.md | 4 +-- .../CommonComponents/CartesianChart.types.ts | 15 +++++--- .../src/utilities/UtilityUnitTests.test.ts | 18 ++++++++++ .../library/src/utilities/utilities.ts | 9 +++-- ...hartWithAxisFunctionTickFormat.stories.tsx | 35 +++++++++++++++++++ .../index.stories.tsx | 1 + 7 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 change/@fluentui-react-charts-function-tickformat.json create mode 100644 packages/charts/react-charts/stories/src/HorizontalBarChartWithAxis/HorizontalBarChartWithAxisFunctionTickFormat.stories.tsx diff --git a/change/@fluentui-react-charts-function-tickformat.json b/change/@fluentui-react-charts-function-tickformat.json new file mode 100644 index 00000000000000..017a71395ee78d --- /dev/null +++ b/change/@fluentui-react-charts-function-tickformat.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: allow a function for the cartesian tickFormat so the numeric value axis can render a literal % or route through an app/i18n formatter (string behavior unchanged)", + "packageName": "@fluentui/react-charts", + "email": "michael@xerilium.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charts/library/etc/react-charts.api.md b/packages/charts/react-charts/library/etc/react-charts.api.md index b841ad9d64148c..b0ac946dc7bfc0 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -237,7 +237,7 @@ export interface CartesianChartProps { strokeWidth?: number; styles?: CartesianChartStyles; svgProps?: React_2.SVGProps; - tickFormat?: string; + tickFormat?: string | ((value: number | Date) => string); tickPadding?: number; tickValues?: number[] | Date[] | string[] | undefined; timeFormatLocale?: TimeLocaleDefinition; @@ -1496,7 +1496,7 @@ export interface ModifiedCartesianChartProps extends CartesianChartProps { stringDatasetForYAxisDomain?: string[]; tickParams?: { tickValues?: number[] | Date[] | string[]; - tickFormat?: string; + tickFormat?: string | ((value: number | Date) => string); }; xAxisInnerPadding?: number; xAxisOuterPadding?: number; diff --git a/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.types.ts b/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.types.ts index 41188b05f0cd0d..f1ac49625f3a40 100644 --- a/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.types.ts +++ b/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.types.ts @@ -239,11 +239,16 @@ export interface CartesianChartProps { tickValues?: number[] | Date[] | string[] | undefined; /** - * the format for the data on x-axis. For date object this can be specified to your requirement. Eg: '%m/%d', '%d' - * Please look at https://github.com/d3/d3-time-format for all the formats supported for date axis - * Only applicable for date axis. For y-axis format use yAxisTickFormat prop. + * Format for x-axis tick labels. Accepts either a d3-format string or a `(value) => string` function. + * + * String: a d3-time-format specifier for date axes (e.g. `'%m/%d'`) or a d3-format specifier for + * numeric axes. See https://github.com/d3/d3-time-format and https://github.com/d3/d3-format. + * + * Function: formats the tick value directly — useful when a d3-format string is insufficient, + * e.g. appending a literal `%` to an already-0–100 axis (d3's `%` type multiplies by 100), + * or routing ticks through an app/i18n formatter. For y-axis format use `yAxisTickFormat`. */ - tickFormat?: string; + tickFormat?: string | ((value: number | Date) => string); /** * Width of line stroke @@ -644,7 +649,7 @@ export interface ModifiedCartesianChartProps extends CartesianChartProps { */ tickParams?: { tickValues?: number[] | Date[] | string[]; - tickFormat?: string; + tickFormat?: string | ((value: number | Date) => string); }; /** diff --git a/packages/charts/react-charts/library/src/utilities/UtilityUnitTests.test.ts b/packages/charts/react-charts/library/src/utilities/UtilityUnitTests.test.ts index 13a136b274bc90..a7d12096e46f64 100644 --- a/packages/charts/react-charts/library/src/utilities/UtilityUnitTests.test.ts +++ b/packages/charts/react-charts/library/src/utilities/UtilityUnitTests.test.ts @@ -212,6 +212,16 @@ describe('createNumericXAxis', () => { utils.createNumericXAxis(xAxisParams, {}, utils.ChartTypes.HorizontalBarChartWithAxis); expect(xAxisParams.xAxisElement).toMatchSnapshot(); }); + + it('should format value-axis ticks with a function tickFormat (e.g. a literal %)', () => { + const xAxisParams = createXAxisParams(); + const result = utils.createNumericXAxis( + xAxisParams, + { tickFormat: (value: number | Date) => `${value as number}%` }, + utils.ChartTypes.HorizontalBarChartWithAxis, + ); + expect(result.tickLabels.every((label: string) => label.endsWith('%'))).toBe(true); + }); }); conditionalDescribe(isTimezoneSet(Timezone.UTC) && env === 'TEST')('createDateXAxis', () => { @@ -274,6 +284,14 @@ conditionalDescribe(isTimezoneSet(Timezone.UTC) && env === 'TEST')('createDateXA }); expect(xAxisParams.xAxisElement).toMatchSnapshot(); }); + + it('should format date-axis ticks with a function tickFormat', () => { + const xAxisParams = createXAxisParams({ domainNRangeValues }); + const result = utils.createDateXAxis(xAxisParams, { + tickFormat: (value: number | Date) => `Y${(value as Date).getFullYear()}`, + }); + expect(result.tickLabels.every((label: string) => label.startsWith('Y'))).toBe(true); + }); }); describe('createStringXAxis', () => { diff --git a/packages/charts/react-charts/library/src/utilities/utilities.ts b/packages/charts/react-charts/library/src/utilities/utilities.ts index 936d9b2398fcdc..4936b8f16a3794 100644 --- a/packages/charts/react-charts/library/src/utilities/utilities.ts +++ b/packages/charts/react-charts/library/src/utilities/utilities.ts @@ -188,7 +188,7 @@ export interface IXAxisParams extends AxisProps { } export interface ITickParams { tickValues?: Date[] | number[] | string[]; - tickFormat?: string; + tickFormat?: string | ((value: number | Date) => string); } export interface IYAxisParams extends AxisProps { @@ -288,7 +288,9 @@ export function createNumericXAxis( return tickText[_index]; } if (tickParams.tickFormat) { - return d3Format(tickParams.tickFormat)(domainValue); + return typeof tickParams.tickFormat === 'function' + ? tickParams.tickFormat(domainValue) + : d3Format(tickParams.tickFormat)(domainValue); } const xAxisValue = typeof domainValue === 'number' ? domainValue : domainValue.valueOf(); return defaultFormat?.(xAxisValue) === '' ? '' : (formatToLocaleString(xAxisValue, culture) as string); @@ -504,6 +506,9 @@ export function createDateXAxis( if (timeFormatLocale) { return formatFn(domainValue); } + if (typeof tickParams.tickFormat === 'function') { + return tickParams.tickFormat(domainValue); + } if (culture === undefined && tickParams.tickFormat) { if (useUTC) { return d3UtcFormat(tickParams.tickFormat)(domainValue); diff --git a/packages/charts/react-charts/stories/src/HorizontalBarChartWithAxis/HorizontalBarChartWithAxisFunctionTickFormat.stories.tsx b/packages/charts/react-charts/stories/src/HorizontalBarChartWithAxis/HorizontalBarChartWithAxisFunctionTickFormat.stories.tsx new file mode 100644 index 00000000000000..62ee554db2ff6a --- /dev/null +++ b/packages/charts/react-charts/stories/src/HorizontalBarChartWithAxis/HorizontalBarChartWithAxisFunctionTickFormat.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import type { HorizontalBarChartWithAxisDataPoint } from '@fluentui/react-charts'; +import { HorizontalBarChartWithAxis, getColorFromToken, DataVizPalette } from '@fluentui/react-charts'; + +export const HorizontalBarWithAxisFunctionTickFormat = (): JSXElement => { + const points: HorizontalBarChartWithAxisDataPoint[] = [ + { x: 10, y: 'Q1', legend: 'Completion', color: getColorFromToken(DataVizPalette.color1) }, + { x: 45, y: 'Q2', legend: 'Completion', color: getColorFromToken(DataVizPalette.color2) }, + { x: 72, y: 'Q3', legend: 'Completion', color: getColorFromToken(DataVizPalette.color3) }, + { x: 95, y: 'Q4', legend: 'Completion', color: getColorFromToken(DataVizPalette.color4) }, + ]; + + return ( +
+ `${value as number}%`} + /> +
+ ); +}; + +HorizontalBarWithAxisFunctionTickFormat.parameters = { + docs: { + description: { + story: + 'Demonstrates passing a function to `tickFormat`. A d3-format `%` string would multiply the value by 100, ' + + 'double-scaling an already-0–100 axis. A function formats the tick directly, appending a literal `%`.', + }, + }, +}; diff --git a/packages/charts/react-charts/stories/src/HorizontalBarChartWithAxis/index.stories.tsx b/packages/charts/react-charts/stories/src/HorizontalBarChartWithAxis/index.stories.tsx index e353123c5fe970..adc0945642406a 100644 --- a/packages/charts/react-charts/stories/src/HorizontalBarChartWithAxis/index.stories.tsx +++ b/packages/charts/react-charts/stories/src/HorizontalBarChartWithAxis/index.stories.tsx @@ -8,6 +8,7 @@ export { HorizontalBarWithAxisStringAxisTooltip } from './HorizontalBarChartWith export { HorizontalBarWithAxisDynamic } from './HorizontalBarChartWithAxisDynamic.stories'; export { HorizontalBarWithAxisNegative } from './HorizontalBarChartWithAxisNegative.stories'; export { HorizontalBarWithAxisCategoryOrder } from './HorizontalBarChartWithAxisCategoryOrder.stories'; +export { HorizontalBarWithAxisFunctionTickFormat } from './HorizontalBarChartWithAxisFunctionTickFormat.stories'; export default { title: 'Charts/HorizontalBarChartWithAxis', From de1088b72110f65c564d5605e1f54bcdd3f689ee Mon Sep 17 00:00:00 2001 From: Michael Flanakin Date: Sun, 5 Jul 2026 01:48:27 -0700 Subject: [PATCH 2/2] fix(react-charts): address Copilot review feedback on function tickFormat - Convert NumberValue to primitive before passing to function tickFormat in createNumericXAxis so consumer formatters always receive a number - Move function tickFormat check above timeFormatLocale in createDateXAxis so callers can override locale formatting Co-Authored-By: Copilot Co-Authored-By: Claude Sonnet 4.6 --- .../react-charts/library/src/utilities/utilities.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/charts/react-charts/library/src/utilities/utilities.ts b/packages/charts/react-charts/library/src/utilities/utilities.ts index 4936b8f16a3794..10de56c4d732c2 100644 --- a/packages/charts/react-charts/library/src/utilities/utilities.ts +++ b/packages/charts/react-charts/library/src/utilities/utilities.ts @@ -289,7 +289,7 @@ export function createNumericXAxis( } if (tickParams.tickFormat) { return typeof tickParams.tickFormat === 'function' - ? tickParams.tickFormat(domainValue) + ? tickParams.tickFormat(typeof domainValue === 'number' ? domainValue : domainValue.valueOf()) : d3Format(tickParams.tickFormat)(domainValue); } const xAxisValue = typeof domainValue === 'number' ? domainValue : domainValue.valueOf(); @@ -500,15 +500,15 @@ export function createDateXAxis( if (tickParams.tickValues && tickText && typeof tickText[_index] !== 'undefined') { return tickText[_index]; } + if (typeof tickParams.tickFormat === 'function') { + return tickParams.tickFormat(domainValue); + } if (customDateTimeFormatter) { return customDateTimeFormatter(domainValue); } if (timeFormatLocale) { return formatFn(domainValue); } - if (typeof tickParams.tickFormat === 'function') { - return tickParams.tickFormat(domainValue); - } if (culture === undefined && tickParams.tickFormat) { if (useUTC) { return d3UtcFormat(tickParams.tickFormat)(domainValue);