Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6106971
Charts: Add labelOverflow ellipsis option for bar chart axis labels
chihsuan Jan 19, 2026
be17496
Update changelog
chihsuan Jan 19, 2026
f7299d2
Address Copilot PR feedback for TruncatedTickComponent
chihsuan Jan 19, 2026
c3f157c
Refactor bar chart axis options to improve labelOverflow handling
chihsuan Jan 19, 2026
7962ef4
Enhance TruncatedTickComponent and add tests for label overflow handling
chihsuan Jan 19, 2026
ca007fa
Document MIN_TICK_LABEL_WIDTH overlap behavior and mitigation strategies
chihsuan Jan 19, 2026
e1b7e15
Add minor update to changelog for bar chart labelOverflow ellipsis op…
chihsuan Jan 19, 2026
97aff7a
Refactor bar chart exports to streamline component usage
chihsuan Jan 19, 2026
8c77d0b
Improve TruncatedTickComponent positioning for better label alignment
chihsuan Jan 19, 2026
edad9de
Add doc comment
chihsuan Jan 19, 2026
cb32e32
Update projects/js-packages/charts/src/charts/bar-chart/private/trunc…
chihsuan Jan 19, 2026
e240a6d
Update projects/js-packages/charts/src/charts/bar-chart/test/bar-char…
chihsuan Jan 19, 2026
aedc9c0
Update projects/js-packages/charts/src/charts/bar-chart/stories/index…
chihsuan Jan 19, 2026
ed18e54
Update projects/js-packages/charts/src/charts/bar-chart/stories/index…
chihsuan Jan 19, 2026
37fe880
Update projects/js-packages/charts/src/charts/bar-chart/private/trunc…
chihsuan Jan 19, 2026
affdc65
Update projects/js-packages/charts/src/charts/bar-chart/private/trunc…
chihsuan Jan 19, 2026
66eaba3
Memoize tick components and conditionally apply Safari workaround
chihsuan Jan 19, 2026
8198ce7
Fix lint
chihsuan Jan 19, 2026
e61bb0e
Update projects/js-packages/charts/src/charts/bar-chart/private/trunc…
chihsuan Jan 19, 2026
6b4d738
Update projects/js-packages/charts/src/types.ts
chihsuan Jan 19, 2026
9f0c5a6
Update projects/js-packages/charts/src/charts/bar-chart/private/trunc…
chihsuan Jan 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add labelOverflow ellipsis option to truncate long axis labels for bar chart.
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useBarChartOptions } from './use-bar-chart-options';
export { createTruncatedTickComponent } from './truncated-tick-component';
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { DataContext } from '@visx/xychart';
import { useContext } from 'react';
import type { AxisScale, TickRendererProps } from '@visx/axis';
import type { FC, CSSProperties } from 'react';

/**
* Get the bandwidth of a scale
*
* @param scale - The scale to get the bandwidth of
* @return The bandwidth of the scale
*/
const getScaleBandwidth = < Scale extends AxisScale >( scale?: Scale ) => {
const s = scale as AxisScale;
return s && 'bandwidth' in s ? s?.bandwidth() ?? 0 : 0;
};

interface TruncatedTickComponentProps extends TickRendererProps {
/** Which axis this tick belongs to */
axis: 'x' | 'y';
}

/**
* Minimum width in pixels for tick labels when scale bandwidth is very small.
* Prevents labels from collapsing to unreadable widths on dense charts.
*
* Trade-off: When bandwidth is less than this minimum (e.g., many bars in a narrow chart),
* adjacent labels may overlap since each label uses this minimum width regardless of
* available space. This prioritizes label readability over preventing overlap.
*
* For very dense charts where overlap occurs, consider:
* - Using `numTicks` option to reduce the number of displayed labels
* - Using `tickFormat` to abbreviate label text
* - Increasing chart width or reducing data points
*/
const MIN_TICK_LABEL_WIDTH = 20;

/**
* A tick component that renders labels with text truncation (ellipsis) when they exceed
* the available bandwidth. Shows the full text on hover via native title attribute.
*
* Uses foreignObject to embed HTML within SVG, enabling CSS text-overflow: ellipsis.
* Inherits text styles from tickLabelProps passed by visx Axis component.
*
* Note: A minimum label width (MIN_TICK_LABEL_WIDTH) is enforced to keep labels readable.
* On very dense charts where bandwidth < 20px, this may cause label overlap.
* See MIN_TICK_LABEL_WIDTH documentation for mitigation strategies.
*
* @param props - The props for the truncated tick component
* @param props.x - The x position of the tick
* @param props.y - The y position of the tick
* @param props.formattedValue - The formatted value of the tick
* @param props.axis - The axis this tick belongs to
* @param props.textAnchor - The text anchor of the tick
* @param props.fill - The fill color of the tick
* @param props.dy - The dy offset of the tick
*
* @return The truncated tick component
*/
export const TruncatedTickComponent: FC< TruncatedTickComponentProps > = ( {
x,
y,
formattedValue,
axis,
textAnchor,
fill,
dy,
...textProps
} ) => {
// Get max width of the tick label
const { xScale, yScale } = useContext( DataContext ) || {};
const scale = axis === 'x' ? xScale : yScale;
const bandwidth = getScaleBandwidth( scale );
const maxWidth = Math.max( bandwidth, MIN_TICK_LABEL_WIDTH );

// Map SVG textAnchor to CSS textAlign
let textAlign: 'left' | 'right' | 'center' = 'center';
if ( textAnchor === 'start' ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: how do we set the textAnchor prop for a Bar Chart Label when testing?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our interface doesn’t currently support the textAnchor prop, but you can apply it by customizing the bar chart:

<Axis
  { ...chartOptions.axis.x }
  tickLabelProps={ {
    textAnchor: 'start',
  } }
/>

textAlign = 'left';
} else if ( textAnchor === 'end' ) {
textAlign = 'right';
} else if ( textAnchor === 'middle' ) {
textAlign = 'center';
}

// Calculate x offset based on text alignment
let xOffset = 0;
if ( textAlign === 'center' ) {
xOffset = -maxWidth / 2;
} else if ( textAlign === 'right' ) {
xOffset = -maxWidth;
}

// Extract compatible style properties from SVG text props
const { fontSize, fontFamily, fontWeight, fontStyle, letterSpacing, opacity } = textProps as {
fontSize?: CSSProperties[ 'fontSize' ];
fontFamily?: CSSProperties[ 'fontFamily' ];
fontWeight?: CSSProperties[ 'fontWeight' ];
fontStyle?: CSSProperties[ 'fontStyle' ];
letterSpacing?: CSSProperties[ 'letterSpacing' ];
opacity?: CSSProperties[ 'opacity' ];
};

const textStyles: CSSProperties = {
// Offset y to convert from baseline to top-left positioning because svg text is positioned by baseline, but html div is positioned by top-left.
transform: 'translateY(-100%)',
// Apply compatible SVG text styles
fontSize,
fontFamily,
fontWeight,
fontStyle,
letterSpacing,
opacity,
// Convert svg text styles to CSS styles for the div
color: fill ?? 'inherit',
textAlign,
// Ensure text is truncated with ellipsis, remains on one line, and shows the full value in a tooltip on hover.
// The surrounding div uses CSS to handle overflow, and the 'title' attribute is set for accessibility.
width: maxWidth,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'default',
pointerEvents: 'auto',
};

return (
<foreignObject
x={ x + xOffset }
y={ y }
width={ maxWidth }
overflow="visible"
// dy * 2: The div's translateY(-100%) and visx's pre-calculated y offset
// create a compound effect that requires doubling dy to match original text position.
style={ { transform: `translateY(calc(${ dy ?? '0' } * 2))` } }
>
<div
style={ textStyles }
title={ formattedValue || undefined }
aria-label={ formattedValue || undefined }
>
{ formattedValue }
</div>
</foreignObject>
);
};

export const createTruncatedTickComponent = ( axis: 'x' | 'y' ) => ( props: TickRendererProps ) => {
return <TruncatedTickComponent { ...props } axis={ axis } />;
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { formatNumberCompact } from '@automattic/number-formatters';
import { useMemo } from 'react';
import { createTruncatedTickComponent } from './truncated-tick-component';
import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
import type { DataPointDate, BaseChartProps, SeriesData } from '../../../types';
import type { TickFormatter } from '@visx/axis';
Expand Down Expand Up @@ -102,6 +103,9 @@ export function useBarChartOptions(
? options.axis?.y?.tickFormat
: options.axis?.x?.tickFormat;

const { labelOverflow: xLabelOverflow, ...xAxisOptions } = options.axis?.x || {};
const { labelOverflow: yLabelOverflow, ...yAxisOptions } = options.axis?.y || {};

return {
gridVisibility,
xScale,
Expand All @@ -115,13 +119,19 @@ export function useBarChartOptions(
orientation: 'bottom' as const,
numTicks: 4,
tickFormat: xTickFormat,
...( options.axis?.x || {} ),
...( xLabelOverflow === 'ellipsis'
? { tickComponent: createTruncatedTickComponent( 'x' ) }
: {} ),
...xAxisOptions,
},
y: {
orientation: 'left' as const,
numTicks: 4,
tickFormat: yTickFormat,
...( options.axis?.y || {} ),
...( yLabelOverflow === 'ellipsis'
? { tickComponent: createTruncatedTickComponent( 'y' ) }
: {} ),
...yAxisOptions,
},
},
barGroup: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,65 @@ export const ZeroValueComparison: StoryObj< typeof BarChart > = {
},
},
};

// Data with long categorical labels to demonstrate overlapping issue
const longLabelData = [
{
group: 'sales',
label: 'Sales by Channel',
data: [
{ label: 'Organic Search Traffic', value: 12500 },
{ label: 'Paid Advertising Campaign', value: 8750 },
{ label: 'Social Media Marketing', value: 6250 },
{ label: 'Email Newsletter Subscribers', value: 4375 },
{ label: 'Direct Website Visitors', value: 3125 },
{ label: 'Affiliate Partner Referrals', value: 2500 },
],
},
];

export const LabelOverflowEllipsis: StoryObj< typeof BarChart > = {
render: () => (
<div style={ { display: 'grid', gap: '40px' } }>
<div>
<h3>Without labelOverflow (Default - Labels Overlap)</h3>
<p style={ { marginBottom: '20px', color: '#666' } }>
Default behavior: long labels overlap and become unreadable at narrow widths.
</p>
<div style={ { width: '350px', height: '250px', border: '1px solid #e0e0e0' } }>
<BarChart data={ longLabelData } withTooltips={ true } gridVisibility="x" />
</div>
</div>

<div>
<h3>With labelOverflow: &apos;ellipsis&apos; (Labels Truncated)</h3>
<p style={ { marginBottom: '20px', color: '#666' } }>
With <code>labelOverflow: &apos;ellipsis&apos;</code>, labels are truncated to fit the
available bandwidth. <strong>Hover over a label to see the full text.</strong>
</p>
<div style={ { width: '350px', height: '250px', border: '1px solid #e0e0e0' } }>
<BarChart
data={ longLabelData }
withTooltips={ true }
gridVisibility="x"
options={ {
axis: {
x: {
labelOverflow: 'ellipsis',
},
},
} }
/>
</div>
</div>
</div>
),
parameters: {
docs: {
description: {
story:
'Demonstrates the `labelOverflow: "ellipsis"` option that truncates long axis labels to fit the available bandwidth. The full label text is shown on hover via a native tooltip. This is useful for narrow widget contexts where space is limited.',
},
},
},
};
Loading
Loading