Skip to content

feat(legend): Add option to truncate legend labels in the middle#2771

Draft
awahab07 wants to merge 49 commits into
elastic:mainfrom
awahab07:1925_middle-text-truncation-for-legend-labels
Draft

feat(legend): Add option to truncate legend labels in the middle#2771
awahab07 wants to merge 49 commits into
elastic:mainfrom
awahab07:1925_middle-text-truncation-for-legend-labels

Conversation

@awahab07
Copy link
Copy Markdown
Collaborator

@awahab07 awahab07 commented Jan 19, 2026

Summary

Legend labels can now be truncated in the middle, preserving both the beginning and end of long labels for better readability. This is especially useful for service names, URLs, and file paths where distinguishing information often appears at both ends. Note that the middle truncation will be the default behavior going forward. (After a discussion, it's been decided not to set it by default, so the existing behavior, truncationPosition'end' will be set by default)

The new truncationPosition option in theme.legend.labelOptions supports:

  • 'middle': Truncates in the middle → enterprise-au…ion-service
  • 'end' (default): Traditional CSS truncation → enterprise-authentication-an…

The story Legend -> Label Truncation demonstrate the feature:
image

1925_legend-middle-truncation.mov
1925_Cross-Browser_legend-middle-truncation.mov

Details

  • Added truncationPosition property to LegendLabelOptions interface ('middle' | 'end')
  • Iterative refinement algorithm: Uses canvas measureText with max 5 iterations for accurate text fitting
  • Cross-browser compatibility: Tested on Chrome, Firefox, Safari, and Edge
    • Safari: Added resetWrapText mixin to fix single-line end truncation
    • All browsers: line-break: anywhere for accurate multiline breaking
  • The following stories have also been updated to show truncationPosition knob
    • Legend -> Piechart
    • Legend -> Actions
    • Legend -> Single Series
    • Legend -> Tabular Data

Usage

<Settings
  showLegend
  theme={{
    legend: {
      labelOptions: {
        maxLines: 1,
        truncationPosition: 'middle', // 'middle' | 'end', default: 'end'
      },
    },
  }}
/>

Issues

Implements the upstream (elastic-charts) side of #1925

Checklist

  • The proper chart type label has been added (e.g. :xy, :partition)
  • The proper feature labels have been added (e.g. :interactions, :axis)
  • All related issues have been linked (i.e. closes #123, fixes #123)
  • New public API exports have been added to packages/charts/src/index.ts
  • Unit tests have been added or updated to match the most common scenarios
  • The proper documentation and/or storybook story has been added or updated
  • The code has been checked for cross-browser compatibility (Chrome, Firefox, Safari, Edge)
  • Visual changes have been tested with light and dark themes

- Add truncationPosition option to LegendLabelOptions ('middle' | 'end')
- Default to 'middle' truncation which preserves start and end of labels
- Implement useMiddleTruncatedLabel hook using avg char width estimation
- Add Storybook story demonstrating truncation behavior
- Update all theme files with new default
- Add truncationPosition knob to Legend Actions story
- Add truncationPosition knob to Tabular Data story
- Rename control groups to 'Legend options' for consistency
@awahab07 awahab07 added the :legend Legend related issue label Jan 19, 2026
@delanni
Copy link
Copy Markdown
Member

delanni commented Jan 19, 2026

buildkite test this

awahab07 and others added 7 commits January 22, 2026 09:35
- Use requestAnimationFrame for deferred computation to avoid blocking initial render
- Implement iterative refinement algorithm for accurate text fitting (max 5 iterations)
- Add buffer (1x ellipsis width) for consistent right-side alignment
- Use targetWidth consistently for all comparisons to prevent overflow
- Add isComputed state to toggle CSS classes only when JS truncation is ready
- Use -webkit-line-clamp: none for multiline to disable CSS truncation
- Add line-break: anywhere for accurate character-level line breaking
- Progressive rendering: show CSS-truncated text until JS computation completes
- Wrap computeTruncatedLabel in startTransition for interruptible updates
- Add fallback for React 16/17 compatibility (direct execution)
- Debounce ResizeObserver callbacks with cancelAnimationFrame
- Prevents queuing multiple computations during rapid resize
…. This is to prevent '...x' effect on legends labels when width is scarce.
@awahab07
Copy link
Copy Markdown
Collaborator Author

awahab07 commented Jan 28, 2026

When available width is too narrow (only a few characters can fit), middle truncation won't happen (see). As otherwise the legend labels end up showing '...x' or ....

image

This considers character count rather than actual width. So if the width is narrow, but multiple lines can fit more characters, middle truncation would still occur.
image

elastic-datavis Bot and others added 9 commits January 28, 2026 11:10
…uncation-for-legend-labels

# Conflicts:
#	e2e/screenshots/all.test.ts-snapshots/baselines/legend/actions-chrome-linux.png
#	e2e/screenshots/all.test.ts-snapshots/baselines/legend/tabular-data-chrome-linux.png
#	e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/should-all-values-in-stacked-chart-with-filtered-series-nick-chrome-linux.png
#	e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/should-render-legend-action-on-mouse-hover-chrome-linux.png
This reverts all VRT screenshot updates to force a clean update from CI.
The buildkite VRT bot will regenerate all affected screenshots with the latest code changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
…uncation-for-legend-labels

# Conflicts:
#	e2e/screenshots/all.test.ts-snapshots/baselines/legend/changing-specs-chrome-linux.png
#	e2e/screenshots/chart.test.ts-snapshots/chart/sizing/should-accommodate-chart-description-only-chrome-linux.png
#	e2e/screenshots/chart.test.ts-snapshots/chart/sizing/should-accommodate-chart-title-and-description-legend-bottom-chrome-linux.png
#	e2e/screenshots/chart.test.ts-snapshots/chart/sizing/should-accommodate-chart-title-and-description-legend-top-chrome-linux.png
#	e2e/screenshots/chart.test.ts-snapshots/chart/sizing/should-accommodate-chart-title-only-chrome-linux.png
#	e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/should-render-non-split-series-chrome-linux.png
#	e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/tooltip-placement-with-legend/should-render-tooltip-with-bottom-legend-chrome-linux.png
#	e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/tooltip-placement-with-legend/should-render-tooltip-with-top-legend-chrome-linux.png
#	packages/charts/api/charts.api.md
#	packages/charts/src/components/legend/label.tsx
#	packages/charts/src/utils/themes/theme.ts
@awahab07
Copy link
Copy Markdown
Collaborator Author

This PR now needs to address merge conflicts with #2784. The functionality may have conflicting behaviors as both PRs target legend rendering, thus the who functionality needs to be tested again.

@awahab07 awahab07 marked this pull request as draft February 26, 2026 10:51
@awahab07
Copy link
Copy Markdown
Collaborator Author

awahab07 commented Mar 3, 2026

@maryam-saeidi given the change in #2784 is of the same nature and had conflicts, could you smoke tests if things work as expected? (I've tested the changes introduced in this PR and they LGTM)

elastic-datavis Bot and others added 4 commits March 3, 2026 16:41
…uncation-for-legend-labels

# Conflicts:
#	packages/charts/api/charts.api.md
#	packages/charts/src/utils/themes/amsterdam_dark_theme.ts
#	packages/charts/src/utils/themes/amsterdam_light_theme.ts
#	packages/charts/src/utils/themes/dark_theme.ts
#	packages/charts/src/utils/themes/legacy_dark_theme.ts
#	packages/charts/src/utils/themes/legacy_light_theme.ts
#	packages/charts/src/utils/themes/light_theme.ts
#	packages/charts/src/utils/themes/theme.ts
#	storybook/stories/legend/11_legend_actions.story.tsx
#	storybook/stories/legend/17_tabular_data.story.tsx
@awahab07
Copy link
Copy Markdown
Collaborator Author

Both issues mentioned in #2771 (comment) has been dealt with.

  1. When large legend labels are present and Line limit is 1, and legend Position is "Inside", legends go off the chart boundary:

Resolved in 5d6a890

BeforeAfter

1925_large_legends_going_off_the_chart

1925_large_legends_going_off_the_chart_FIX

  1. There's another behavior (not a bug but the way it's currently implemented) which affects this PR and need to be tackled is, when large legend labels are present, sizing doesn't affect the legend width:

Resolved in aa42c29

BeforeAfter

1925_width_does_not_apply_on_large_legends

1925_width_does_not_apply_on_large_legends_FIX

@gvnmagni
Copy link
Copy Markdown

gvnmagni commented Apr 2, 2026

@awahab07 pointed out a very important detail, which is that all existing legends would be affected by this new settings if we apply it by default. Therefore, I would feel safer to keep truncation at the end as default option, just for now, and evaluate eventually how and when apply the truncation at the middle (maybe when we recognize very long labels that are similar at the end, something smart), but that should probably happens at Lens level.

Long story short, I suggest to keep truncation at end as default while having truncation in the middle as an additional option available to user

awahab07 and others added 6 commits April 9, 2026 19:55
…uncation-for-legend-labels

# Conflicts:
#	e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/should-all-values-in-stacked-chart-with-filtered-series-nick-chrome-linux.png
#	e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/should-render-legend-action-on-mouse-hover-chrome-linux.png
#	packages/charts/src/components/legend/label.tsx
#	storybook/stories/legend/2_legend_layout.story.tsx
@awahab07
Copy link
Copy Markdown
Collaborator Author

buildkite update vrt

@elastic-datavis
Copy link
Copy Markdown
Contributor

@awahab07 awahab07 added the :all Applies to all chart types label Apr 11, 2026
Copy link
Copy Markdown
Collaborator Author

@awahab07 awahab07 Apr 11, 2026

Choose a reason for hiding this comment

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

ℹ️ These area chart snapshots have been updated because the PR fixes a behavior where legend labels overflow the chart in floating mode (legend inside chart layout). Notice the legends in the before snapshots extending out the chart borders. (see)

const scrollBarDimension = legendHeight > parentDimensions.height ? SCROLL_BAR_WIDTH : 0;
const staticWidth = spacingBuffer + actionDimension + scrollBarDimension;

const minWidth = parentDimensions.width * 0.05;
Copy link
Copy Markdown
Collaborator Author

@awahab07 awahab07 Apr 11, 2026

Choose a reason for hiding this comment

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

Setting 30% of total chart width as minimum for legend size is quite a large number, and may not be desirable for cases where just a few letters needs to be shown in legends. Hence reducing to 5%. (See for example)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we can probably set a fixed min in width? instead of a percentage, because for smaller chart this can be very small

Copy link
Copy Markdown
Collaborator Author

@awahab07 awahab07 Apr 11, 2026

Choose a reason for hiding this comment

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

Reason for style adjustments in this file (and also in .../legend/position_style.ts and .../legend/legend.tsx above).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This snapshot test has been renamed, hence old snapshot is deleted (also see a similar "../position-right/..." below).

@awahab07 awahab07 marked this pull request as ready for review April 11, 2026 17:33
@awahab07 awahab07 linked an issue Apr 12, 2026 that may be closed by this pull request
*
* @internal
*/
export function useMiddleTruncatedLabel({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should this also include some debouncing check? or can we check that all external resizes are decounbed already?

@include euiTextTruncate;

// When using middle truncation, JS handles the ellipsis. `clip` will disable euiTextTruncate
&--middle {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: This creates --singleline--middle which isn't proper BEM terminology. Could we do --middle as a separate class and apply both or would that breat things?

Comment on lines +98 to +100
const computedStyle = window.getComputedStyle(element);
const { font, fontSize } = getFontFromComputedStyle(computedStyle);
const containerWidth = element.clientWidth;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This triggers page reflows and you don't need these as you already have these infos:

  • the font and fontSize are already around
  • the legend width is already computed

Comment on lines +127 to +153
useLayoutEffect(() => {
if (!shouldTruncate) {
setTruncatedLabel(label);
return;
}

computeTruncatedLabel();

const element = labelRef.current;
if (!element) return;

// Per-element ResizeObserver is necessary since label width varies by rendering mode (list, table, horizontal)
const resizeObserver = new ResizeObserver(() => {
computeTruncatedLabel();
});

resizeObserver.observe(element);

return () => {
resizeObserver.disconnect();
};
}, [computeTruncatedLabel, shouldTruncate, label]);

return {
labelRef: shouldTruncate ? labelRef : undefined,
truncatedLabel,
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

when computing things with canvas measureText you don't need all that, you can do it upfront

});
});

http: test.describe('Legend tabular data', () => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

interesting fist time seeing a label used in JS. I don't think this is used at all

Comment on lines +230 to +231
const computedStyle = window.getComputedStyle(element);
const { font, fontSize } = getFontFromComputedStyle(computedStyle);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we need to compute the font size? can we use a static one?
if this is because we depend on EUI and other font configured outside, we can probably bring this font configuration inside charts and be sure we don't need to compute it.
This is specially computationally intestive since it also run inside a useLayoutEffect()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Considering that this also run for each label, it seems too much. If really required this should be moved at an higer level

const scrollBarDimension = legendHeight > parentDimensions.height ? SCROLL_BAR_WIDTH : 0;
const staticWidth = spacingBuffer + actionDimension + scrollBarDimension;

const minWidth = parentDimensions.width * 0.05;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we can probably set a fixed min in width? instead of a percentage, because for smaller chart this can be very small

const element = labelRef.current;
if (!element) return;

const width = getStableContainerWidth(element);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In a condition where we have many labels this connected with the getComputedStyle below and useLayoutEffect create a regression in performance for the legend.
Can't we derive this value or at least derive it just one time for the entire legend instead for each?

@awahab07 awahab07 marked this pull request as draft April 28, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:all Applies to all chart types :legend Legend related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow text truncation in the middle for legend labels

6 participants