feat(legend): Add option to truncate legend labels in the middle#2771
feat(legend): Add option to truncate legend labels in the middle#2771awahab07 wants to merge 49 commits into
Conversation
- 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
|
buildkite test this |
- 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.
|
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
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. |
…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
|
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. |
|
@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) |
…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
|
Both issues mentioned in #2771 (comment) has been dealt with.
Resolved in 5d6a890
Resolved in aa42c29
|
…hart's width at minimum.
|
@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 Long story short, I suggest to keep |
…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
…uncation-for-legend-labels
|
buildkite update vrt |
There was a problem hiding this comment.
ℹ️ 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; |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
we can probably set a fixed min in width? instead of a percentage, because for smaller chart this can be very small
There was a problem hiding this comment.
Reason for style adjustments in this file (and also in .../legend/position_style.ts and .../legend/legend.tsx above).
There was a problem hiding this comment.
This snapshot test has been renamed, hence old snapshot is deleted (also see a similar "../position-right/..." below).
| * | ||
| * @internal | ||
| */ | ||
| export function useMiddleTruncatedLabel({ |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
| const computedStyle = window.getComputedStyle(element); | ||
| const { font, fontSize } = getFontFromComputedStyle(computedStyle); | ||
| const containerWidth = element.clientWidth; |
There was a problem hiding this comment.
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
| 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, | ||
| }; |
There was a problem hiding this comment.
when computing things with canvas measureText you don't need all that, you can do it upfront
| }); | ||
| }); | ||
|
|
||
| http: test.describe('Legend tabular data', () => { |
There was a problem hiding this comment.
interesting fist time seeing a label used in JS. I don't think this is used at all
| const computedStyle = window.getComputedStyle(element); | ||
| const { font, fontSize } = getFontFromComputedStyle(computedStyle); |
There was a problem hiding this comment.
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()
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?






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
truncationPositionoption intheme.legend.labelOptionssupports:'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:

1925_legend-middle-truncation.mov
1925_Cross-Browser_legend-middle-truncation.mov
Details
truncationPositionproperty toLegendLabelOptionsinterface ('middle'|'end')measureTextwith max 5 iterations for accurate text fittingresetWrapTextmixin to fix single-line end truncationline-break: anywherefor accurate multiline breakingUsage
Issues
Implements the upstream (elastic-charts) side of #1925
Checklist
:xy,:partition):interactions,:axis)closes #123,fixes #123)packages/charts/src/index.tslightanddarkthemes