diff --git a/projects/js-packages/charts/changelog/add-bar-chart-label-overflow-ellipsis b/projects/js-packages/charts/changelog/add-bar-chart-label-overflow-ellipsis new file mode 100644 index 000000000000..9a9d90ae8342 --- /dev/null +++ b/projects/js-packages/charts/changelog/add-bar-chart-label-overflow-ellipsis @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add labelOverflow ellipsis option to truncate long axis labels for bar chart. diff --git a/projects/js-packages/charts/src/charts/bar-chart/private/index.ts b/projects/js-packages/charts/src/charts/bar-chart/private/index.ts index 51a486b2cefa..9e0dd549ff06 100644 --- a/projects/js-packages/charts/src/charts/bar-chart/private/index.ts +++ b/projects/js-packages/charts/src/charts/bar-chart/private/index.ts @@ -1 +1,2 @@ export { useBarChartOptions } from './use-bar-chart-options'; +export { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component'; diff --git a/projects/js-packages/charts/src/charts/bar-chart/private/truncated-tick-component.tsx b/projects/js-packages/charts/src/charts/bar-chart/private/truncated-tick-component.tsx new file mode 100644 index 000000000000..59afc79f064a --- /dev/null +++ b/projects/js-packages/charts/src/charts/bar-chart/private/truncated-tick-component.tsx @@ -0,0 +1,157 @@ +import { DataContext } from '@visx/xychart'; +import { useContext } from 'react'; +import { isSafari } from '../../../utils'; +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 ) => { + return scale && 'bandwidth' in scale ? scale.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' ) { + 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 = { + /** + * SVG elements are vertically aligned to the baseline by default, but HTML
elements inside + * are positioned relative to the top-left corner. To visually align the tick label like SVG text, + * we shift the div up by 100% of its height and adjust by twice the SVG dy value (from visx) to approximate original placement. + */ + transform: `translateY(calc(-100% + ${ dy ?? '0' } * 2))`, + // Safari doesn't work well with foreignObject positioning. Use position: fixed as a workaround. + ...( isSafari() ? { position: 'fixed' as const } : {} ), + // 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 ( + +
+ { formattedValue } +
+
+ ); +}; + +/** + * Factory function to create a truncated tick component for a specific axis. + * Returns a component that can be passed to visx's tickComponent prop. + * + * @param axis - The axis this tick component is for ('x' or 'y') + * @return A tick component function compatible with visx's TickRendererProps + */ +const createTruncatedTickComponent = ( axis: 'x' | 'y' ) => ( props: TickRendererProps ) => { + return ; +}; + +/** + * Pre-created tick components for x and y axes. + * These functions are created once at module initialization and reused, + * avoiding repeated factory calls when configuring axes. + */ +export const TruncatedXTickComponent = createTruncatedTickComponent( 'x' ); +export const TruncatedYTickComponent = createTruncatedTickComponent( 'y' ); diff --git a/projects/js-packages/charts/src/charts/bar-chart/private/use-bar-chart-options.ts b/projects/js-packages/charts/src/charts/bar-chart/private/use-bar-chart-options.ts index b3b6e631bb7a..b141d0ef6ad2 100644 --- a/projects/js-packages/charts/src/charts/bar-chart/private/use-bar-chart-options.ts +++ b/projects/js-packages/charts/src/charts/bar-chart/private/use-bar-chart-options.ts @@ -1,5 +1,6 @@ import { formatNumberCompact } from '@automattic/number-formatters'; import { useMemo } from 'react'; +import { TruncatedXTickComponent, TruncatedYTickComponent } 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'; @@ -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, @@ -115,13 +119,15 @@ export function useBarChartOptions( orientation: 'bottom' as const, numTicks: 4, tickFormat: xTickFormat, - ...( options.axis?.x || {} ), + ...( xLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedXTickComponent } : {} ), + ...xAxisOptions, }, y: { orientation: 'left' as const, numTicks: 4, tickFormat: yTickFormat, - ...( options.axis?.y || {} ), + ...( yLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedYTickComponent } : {} ), + ...yAxisOptions, }, }, barGroup: { diff --git a/projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx index b3ce32c2e66a..f7836100d798 100644 --- a/projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/bar-chart/stories/index.stories.tsx @@ -419,3 +419,64 @@ 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: () => ( +
+
+

Without labelOverflow (Default - Labels Overlap)

+

+ Default behavior: long labels overlap and become unreadable at narrow widths. +

+
+ +
+
+
+

With labelOverflow: 'ellipsis' (Labels Truncated)

+

+ With labelOverflow: 'ellipsis', labels are truncated to fit the + available bandwidth. Hover over a label to see the full text. +

+
+ +
+
+
+ ), + 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.", + }, + }, + }, +}; diff --git a/projects/js-packages/charts/src/charts/bar-chart/test/bar-chart.test.tsx b/projects/js-packages/charts/src/charts/bar-chart/test/bar-chart.test.tsx index 019238461fea..af3c5334e280 100644 --- a/projects/js-packages/charts/src/charts/bar-chart/test/bar-chart.test.tsx +++ b/projects/js-packages/charts/src/charts/bar-chart/test/bar-chart.test.tsx @@ -560,6 +560,147 @@ describe( 'BarChart', () => { /* eslint-enable testing-library/no-node-access */ + describe( 'Label Overflow Ellipsis', () => { + const longLabelData = [ + { + label: 'Series A', + data: [ + { label: 'Very Long Category Label One', value: 100 }, + { label: 'Very Long Category Label Two', value: 200 }, + { label: 'Very Long Category Label Three', value: 150 }, + ], + options: {}, + }, + ]; + + test( 'renders chart with labelOverflow ellipsis option', () => { + renderWithTheme( { + data: longLabelData, + options: { + axis: { + x: { + labelOverflow: 'ellipsis', + }, + }, + }, + } ); + + expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument(); + } ); + + test( 'truncates labels with CSS text-overflow ellipsis', () => { + renderWithTheme( { + width: 300, // Narrow width to force truncation + data: longLabelData, + options: { + axis: { + x: { + labelOverflow: 'ellipsis', + }, + }, + }, + } ); + + // Labels should be rendered with truncation styles + const label = screen.getByText( /Very Long Category Label One/i ); + expect( label ).toHaveStyle( { textOverflow: 'ellipsis' } ); + expect( label ).toHaveStyle( { overflow: 'hidden' } ); + expect( label ).toHaveStyle( { whiteSpace: 'nowrap' } ); + } ); + + test( 'sets title attribute for hover tooltips on truncated labels', () => { + renderWithTheme( { + width: 300, + data: longLabelData, + options: { + axis: { + x: { + labelOverflow: 'ellipsis', + }, + }, + }, + } ); + + // Title attribute should show full text on hover + const label = screen.getByText( /Very Long Category Label One/i ); + expect( label ).toHaveAttribute( 'title', 'Very Long Category Label One' ); + } ); + + test( 'applies truncation to x-axis for vertical bar charts', () => { + renderWithTheme( { + width: 300, + data: longLabelData, + orientation: 'vertical', + options: { + axis: { + x: { + labelOverflow: 'ellipsis', + }, + }, + }, + } ); + + // X-axis labels should have truncation + const label = screen.getByText( /Very Long Category Label One/i ); + expect( label ).toHaveStyle( { textOverflow: 'ellipsis' } ); + } ); + + test( 'applies truncation to y-axis for horizontal bar charts', () => { + renderWithTheme( { + width: 300, + data: longLabelData, + orientation: 'horizontal', + options: { + axis: { + y: { + labelOverflow: 'ellipsis', + }, + }, + }, + } ); + + // Y-axis labels should have truncation in horizontal mode + const label = screen.getByText( /Very Long Category Label One/i ); + expect( label ).toHaveStyle( { textOverflow: 'ellipsis' } ); + } ); + + test( 'handles very small chart widths gracefully', () => { + renderWithTheme( { + width: 100, // Very small width + data: longLabelData, + options: { + axis: { + x: { + labelOverflow: 'ellipsis', + }, + }, + }, + } ); + + // Chart should still render without errors + expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument(); + + // Labels should still be present and have minimum width applied + const label = screen.getByText( /Very Long Category Label One/i ); + expect( label ).toBeInTheDocument(); + } ); + + test( 'does not apply truncation styles when labelOverflow is not set', () => { + renderWithTheme( { + width: 300, + data: longLabelData, + } ); + + // Without labelOverflow, labels should use default SVG text rendering + // which doesn't have CSS text-overflow + const labels = screen.getAllByText( /Very Long Category Label/i ); + labels.forEach( label => { + // SVG text elements don't have textOverflow style + expect( label.tagName.toLowerCase() ).not.toBe( 'div' ); + } ); + } ); + } ); + describe( 'Interactive Legend', () => { it( 'filters series when interactive legend is enabled and series is toggled', async () => { const user = userEvent.setup(); diff --git a/projects/js-packages/charts/src/types.ts b/projects/js-packages/charts/src/types.ts index 87ddff7f7174..1737d5692797 100644 --- a/projects/js-packages/charts/src/types.ts +++ b/projects/js-packages/charts/src/types.ts @@ -307,6 +307,18 @@ declare type AxisOptions = { * For more control over rendering or to add event handlers to datum, pass a function as children. */ children?: ( renderProps: AxisRendererProps< AxisScale > ) => ReactNode; + /** + * Controls tick label overflow (bar charts only): + * + * - 'ellipsis': Truncate with ellipsis and fit to available space. Labels show full text + * on hover via native tooltip. Note: A minimum width (20px) is enforced for readability. + * On very dense charts (bandwidth < 20px), adjacent labels may overlap. To mitigate, use `numTicks` + * to reduce labels or `tickFormat` to abbreviate text. + * - undefined: No truncation; labels may overlap. + * + * Default: No truncation; labels may overlap. + */ + labelOverflow?: 'ellipsis'; }; export type ScaleOptions = {