Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
3600a4c
Add middle text truncation for charts legend labels
awahab07 Jan 18, 2026
adde860
Add truncationPosition option to legend stories
awahab07 Jan 18, 2026
2b2650b
Add 4% safety margin for Firefox text rendering differences
awahab07 Jan 18, 2026
a6e8f6c
Revert changing action group in story.
awahab07 Jan 19, 2026
b12791b
Improve middle truncation with iterative refinement algorithm
awahab07 Jan 22, 2026
c00b88a
Add startTransition for low-priority legend truncation in React 18
awahab07 Jan 22, 2026
e041d76
Fix typings
awahab07 Jan 22, 2026
9dd1b8d
Fix Safari single-line end truncation with resetWrapText mixin
awahab07 Jan 22, 2026
666ee61
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Jan 22, 2026
0f0961e
Update API docs.
awahab07 Jan 28, 2026
884f1c8
Do not truncate in the middle if legend available width is too narrow…
awahab07 Jan 28, 2026
49da38b
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Jan 28, 2026
da843bd
Merge branch 'main' into 1925_middle-text-truncation-for-legend-labels
elasticmachine Feb 4, 2026
425aec4
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Feb 4, 2026
f9ebd91
chore: trigger CI after VRT update
awahab07 Feb 4, 2026
76efdd5
Merge remote-tracking branch 'upstream/main' into 1925_middle-text-tr…
awahab07 Feb 19, 2026
a4a4140
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Feb 19, 2026
b433cfe
test(vrt): revert all screenshots to upstream main baseline
awahab07 Feb 19, 2026
c5fecb8
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Feb 19, 2026
8f0bfc0
Merge remote-tracking branch 'upstream/main' into 1925_middle-text-tr…
awahab07 Feb 26, 2026
cd1f423
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Mar 3, 2026
89d78ea
Fix typo in comment.
awahab07 Mar 3, 2026
1621252
Merge remote-tracking branch 'upstream/main' into 1925_middle-text-tr…
awahab07 Mar 10, 2026
9ba7a8e
Remove default.
awahab07 Mar 10, 2026
2b398a5
Prevent inifite ResizeObserver loop.
awahab07 Mar 11, 2026
5f55058
Prefer `element.style.maxWidth` over `window.getComputedStyle(element…
awahab07 Mar 11, 2026
c60ab74
Add `truncationPosition` dropdown to Legend Layout story.
awahab07 Mar 11, 2026
55cd67c
Add `legendLayout` input to Label Truncation story.
awahab07 Mar 11, 2026
9b51d67
Update API documentation.
awahab07 Mar 11, 2026
7f0ea01
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Mar 11, 2026
1981921
Add "Hide large labels" knob to Legend -> Label Truncation story.
awahab07 Mar 11, 2026
85cbe84
Simplify legend size in the story by having only one "Legend size" in…
awahab07 Mar 14, 2026
1007f14
Avoid using ResizeObserver which could result in infinite loop when p…
awahab07 Mar 14, 2026
5d6a890
Restrict legend from going off the chart container bounds.
awahab07 Mar 14, 2026
aa42c29
When `legendSize` is provided, make sure it always takes affect, rega…
awahab07 Mar 14, 2026
4994565
Add e2e snapthost test.
awahab07 Mar 15, 2026
5667c59
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Mar 15, 2026
fdbb58b
Respect configured width and ensure legends always maintain a 5% of c…
awahab07 Mar 15, 2026
5a1c0f4
Use `minmax(0, auto)` for `gridTemplateColumns`.
awahab07 Mar 15, 2026
7e3cf54
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Mar 15, 2026
873a67d
Delete outdated snapshots.
awahab07 Mar 15, 2026
4be1d9a
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Mar 15, 2026
686324d
Synchronous middle text truncation computation.
awahab07 Mar 16, 2026
6ae77af
Merge remote-tracking branch 'upstream/main' into 1925_middle-text-tr…
awahab07 Apr 9, 2026
ad65ceb
Account for 'px' mode for middle truncation.
awahab07 Apr 10, 2026
c36bd88
Use `'end'` as the default label `truncationPosition`.
awahab07 Apr 10, 2026
4c445a1
test(vrt): update screenshots [skip ci]
elastic-datavis[bot] Apr 10, 2026
42f4200
Remove deleted/renamed test snapshots.
awahab07 Apr 10, 2026
c971199
Merge remote-tracking branch 'upstream/main' into 1925_middle-text-tr…
awahab07 Apr 10, 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
4 changes: 4 additions & 0 deletions packages/charts/api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1849,8 +1849,12 @@ export type LegendItemValue = {
// @public (undocumented)
export interface LegendLabelOptions {
maxLines: number;
truncationPosition: LegendLabelTruncationPosition;
}

// @public (undocumented)
export type LegendLabelTruncationPosition = 'end' | 'middle';

// @public
export type LegendPath = LegendPathElement[];

Expand Down
10 changes: 10 additions & 0 deletions packages/charts/src/components/legend/_legend_item.scss
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ $legendItemHeight: #{$euiFontSizeXS * $euiLineHeight};

&--singleline {
@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?

text-overflow: clip;
}
}

// div to prevent changing to button
Expand All @@ -97,6 +102,11 @@ $legendItemHeight: #{$euiFontSizeXS * $euiLineHeight};
-webkit-line-clamp: 2; // number of lines to show, overridden in element styles
}

// When using middle truncation, JS handles the ellipsis so disable line-clamp
&--multiline--middle:is(div) {
-webkit-line-clamp: unset;
}

&--clickable:hover {
cursor: pointer;
text-decoration: underline;
Expand Down
49 changes: 42 additions & 7 deletions packages/charts/src/components/legend/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
*/

import classNames from 'classnames';
import type { KeyboardEventHandler, MouseEventHandler } from 'react';
import type { KeyboardEventHandler, MouseEventHandler, CSSProperties } from 'react';
import React, { useCallback } from 'react';

import { useMiddleTruncatedLabel } from './use_truncated_label';
import { isRTLString } from '../../utils/common';
import type { LegendLabelOptions } from '../../utils/themes/theme';

Expand Down Expand Up @@ -75,6 +76,13 @@ export function Label({
hiddenSeriesCount,
totalSeriesCount,
}: LabelProps) {
const shouldTruncateMiddle = options.truncationPosition === 'middle' && options.maxLines > 0;
const { labelRef, truncatedLabel } = useMiddleTruncatedLabel({
label,
maxLines: options.maxLines,
shouldTruncate: shouldTruncateMiddle,
});

const { className, dir, clampStyles } = getSharedProps(label, options, !!onToggle);

const onClick: MouseEventHandler = useCallback(
Expand All @@ -94,6 +102,7 @@ export function Label({
// This div is required to allow multiline text truncation, all ARIA requirements are still met
// https://stackoverflow.com/questions/68673034/webkit-line-clamp-does-not-apply-to-buttons
<div
ref={labelRef}
role="button"
tabIndex={0}
dir={dir}
Expand All @@ -106,35 +115,61 @@ export function Label({
aria-label={`${label}; ${getInteractivityAriaLabel(!isSeriesHidden, hiddenSeriesCount, totalSeriesCount)}`}
data-testid="echLegendItemLabel"
>
{label}
{truncatedLabel}
</div>
) : (
<div dir={dir} className={className} title={label} style={clampStyles} data-testid="echLegendItemLabel">
{label}
<div
ref={labelRef}
dir={dir}
className={className}
title={label}
style={clampStyles}
data-testid="echLegendItemLabel"
>
{truncatedLabel}
</div>
);
}

/** @internal */
export function NonInteractiveLabel({ label, options }: { label: string; options: LegendLabelOptions }) {
const shouldTruncateMiddle = options.truncationPosition === 'middle' && options.maxLines > 0;
const { labelRef, truncatedLabel } = useMiddleTruncatedLabel({
label,
maxLines: options.maxLines,
shouldTruncate: shouldTruncateMiddle,
});

const { className, dir, clampStyles } = getSharedProps(label, options);

return (
<div dir={dir} className={className} title={label} style={clampStyles} data-testid="echLegendItemLabel">
{label}
<div
ref={labelRef}
dir={dir}
className={className}
title={label}
style={clampStyles}
data-testid="echLegendItemLabel"
>
{truncatedLabel}
</div>
);
}

function getSharedProps(label: string, options: LegendLabelOptions, isToggleable?: boolean) {
const maxLines = Math.abs(options.maxLines);
const shouldTruncateMiddle = options.truncationPosition === 'middle' && maxLines > 0;

const className = classNames('echLegendItem__label', {
'echLegendItem__label--clickable': Boolean(isToggleable),
'echLegendItem__label--singleline': maxLines === 1,
'echLegendItem__label--singleline--middle': maxLines === 1 && shouldTruncateMiddle,
'echLegendItem__label--multiline': maxLines > 1,
'echLegendItem__label--multiline--middle': maxLines > 1 && shouldTruncateMiddle,
});

const dir = isRTLString(label) ? 'rtl' : 'ltr'; // forced for individual labels in case mixed charset
const clampStyles = maxLines > 1 ? { WebkitLineClamp: maxLines } : {};
const clampStyles: CSSProperties = maxLines > 1 ? { WebkitLineClamp: maxLines } : {};

return { className, dir, clampStyles };
}
154 changes: 154 additions & 0 deletions packages/charts/src/components/legend/use_truncated_label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { useCallback, useLayoutEffect, useRef, useState } from 'react';

import type { Font } from '../../common/text_utils';
import { withTextMeasure } from '../../utils/bbox/canvas_text_bbox_calculator';

const ELLIPSIS = '…';

interface UseMiddleTruncatedLabelProps {
label: string;
maxLines: number;
/**
* When true, applies middle truncation via JS.
* When false, returns the original label (CSS will handle end truncation).
*/
shouldTruncate: boolean;
}

interface UseMiddleTruncatedLabelResult {
/** Ref to attach to the label element. Undefined when truncation is disabled. */
labelRef: React.RefObject<HTMLDivElement> | undefined;
/** The truncated label - middle (JS) or end (CSS) truncated based on shouldTruncate */
truncatedLabel: string;
}

/**
* Extracts font properties from computed styles to create a Font object
* compatible with the TextMeasure utilities.
*/
function getFontFromComputedStyle(computedStyle: CSSStyleDeclaration): {
font: Omit<Font, 'textColor'>;
fontSize: number;
} {
const fontSize = parseFloat(computedStyle.fontSize) || 12;
const fontWeight = computedStyle.fontWeight as Font['fontWeight'];

const font: Omit<Font, 'textColor'> = {
fontStyle: (computedStyle.fontStyle || 'normal') as Font['fontStyle'],
fontVariant: (computedStyle.fontVariant || 'normal') as Font['fontVariant'],
fontWeight: fontWeight || 'normal',
fontFamily: computedStyle.fontFamily || 'sans-serif',
};

return { font, fontSize };
}

/**
* Simple middle truncation based on character count.
* Splits the label to show beginning and end with ellipsis in the middle.
*/
function truncateMiddle(label: string, maxChars: number): string {
if (label.length <= maxChars) return label;
if (maxChars < 2) return ELLIPSIS;

// Reserve 1 char for ellipsis
const availableChars = maxChars - 1;
const startChars = Math.ceil(availableChars / 2);
const endChars = Math.floor(availableChars / 2);

return `${label.slice(0, startChars)}${ELLIPSIS}${label.slice(-endChars)}`;
}

/**
* Hook to compute middle-truncated label text based on container width.
* Uses average character width estimation for a simple and efficient approach (O(1) complexity).
*
* Middle truncation preserves both the beginning and end of the label text.
*
* @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?

label,
maxLines,
shouldTruncate,
}: UseMiddleTruncatedLabelProps): UseMiddleTruncatedLabelResult {
const labelRef = useRef<HTMLDivElement>(null);
const [truncatedLabel, setTruncatedLabel] = useState(label);

const computeTruncatedLabel = useCallback(() => {
if (!shouldTruncate || maxLines === 0) {
setTruncatedLabel(label);
return;
}

const element = labelRef.current;
if (!element) {
setTruncatedLabel(label);
return;
}

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


if (containerWidth <= 0) {
setTruncatedLabel(label);
return;
}

const result = withTextMeasure((measure) => {
const fullTextWidth = measure(label, font, fontSize).width;

// If text fits, no truncation needed
if (fullTextWidth <= containerWidth * maxLines) {
return label;
}

// Use average character width estimation - simple and loop-free
const avgCharWidth = measure('x', font, fontSize).width;
const charsPerLine = Math.floor(containerWidth / avgCharWidth);
// Apply 4% safety margin to account for browser rendering differences (e.g., Firefox)
const totalChars = Math.floor(charsPerLine * maxLines * 0.96);

return truncateMiddle(label, totalChars);
});

setTruncatedLabel(result);
}, [label, maxLines, shouldTruncate]);

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

}
1 change: 1 addition & 0 deletions packages/charts/src/utils/themes/amsterdam_dark_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export const AMSTERDAM_DARK_THEME: Theme = {
margin: 0,
labelOptions: {
maxLines: 1,
truncationPosition: 'middle',
},
},
crosshair: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const AMSTERDAM_LIGHT_THEME: Theme = {
margin: 0,
labelOptions: {
maxLines: 1,
truncationPosition: 'middle',
},
},
crosshair: {
Expand Down
1 change: 1 addition & 0 deletions packages/charts/src/utils/themes/dark_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export const DARK_THEME: Theme = {
margin: 0,
labelOptions: {
maxLines: 1,
truncationPosition: 'middle',
},
},
crosshair: {
Expand Down
1 change: 1 addition & 0 deletions packages/charts/src/utils/themes/legacy_dark_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export const LEGACY_DARK_THEME: Theme = {
margin: 0,
labelOptions: {
maxLines: 1,
truncationPosition: 'middle',
},
},
crosshair: {
Expand Down
1 change: 1 addition & 0 deletions packages/charts/src/utils/themes/legacy_light_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export const LEGACY_LIGHT_THEME: Theme = {
margin: 0,
labelOptions: {
maxLines: 1,
truncationPosition: 'middle',
},
},
crosshair: {
Expand Down
1 change: 1 addition & 0 deletions packages/charts/src/utils/themes/light_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export const LIGHT_THEME: Theme = {
margin: 0,
labelOptions: {
maxLines: 1,
truncationPosition: 'middle',
},
},
crosshair: {
Expand Down
12 changes: 12 additions & 0 deletions packages/charts/src/utils/themes/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,9 @@ export interface BackgroundStyle {
fallbackColor: Color;
}

/** @public */
export type LegendLabelTruncationPosition = 'end' | 'middle';

/** @public */
export interface LegendLabelOptions {
/**
Expand All @@ -403,6 +406,15 @@ export interface LegendLabelOptions {
* @defaultValue 1
*/
maxLines: number;
/**
* Position where text is truncated when it overflows.
*
* - `'middle'`: Truncates in the middle, preserving start and end (e.g., `enterprise-au…ion-service`)
* - `'end'`: Traditional truncation at the end (e.g., `enterprise-authentication-an…`)
*
* @defaultValue 'middle'
*/
truncationPosition: LegendLabelTruncationPosition;
}

/** @public */
Expand Down
11 changes: 10 additions & 1 deletion storybook/stories/legend/11_legend_actions.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Side Public License, v 1.
*/

import { boolean, number } from '@storybook/addon-knobs';
import { boolean, number, select } from '@storybook/addon-knobs';
import React from 'react';

import type { LegendLabelOptions } from '@elastic/charts';
Expand All @@ -24,6 +24,15 @@ const getLabelOptionKnobs = (): LegendLabelOptions => {

return {
maxLines: number('max label lines', 1, { min: 0, step: 1 }, group),
truncationPosition: select(
'truncationPosition',
{
middle: 'middle',
end: 'end',
},
'middle',
group,
),
};
};

Expand Down
Loading