Skip to content

feat(Ellipsis): add component - #2690

Open
PahaN47 wants to merge 7 commits into
mainfrom
feat/ellipsis-component
Open

PahaN47 wants to merge 7 commits into
mainfrom
feat/ellipsis-component

Conversation

@PahaN47

@PahaN47 PahaN47 commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary by Sourcery

Add a reusable Ellipsis text component with configurable truncation behavior and RTL-safe styling, including stories and theme support for flow direction.

New Features:

  • Introduce an Ellipsis React component that supports start, center, and end truncation of long strings with optional offsets and separators.
  • Add a center-ellipsis hook that dynamically measures available space and computes a middle truncation responsive to container resize events.
  • Provide Storybook stories demonstrating Ellipsis usage with adjustable width, different positions, and separator-based truncation.

Enhancements:

  • Extend common theme flow-direction variables to include an opposite-flow variable used for RTL-safe text ellipsis styling.

@PahaN47
PahaN47 requested review from ValeraS, amje and korvin89 as code owners May 22, 2026 16:01
@sourcery-ai

sourcery-ai Bot commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a new Ellipsis React component that supports start/center/end truncation with optional segment-based offsets, using a ResizeObserver-driven hook for center-ellipsis measurement and updates theme flow variables to support direction-aware behavior.

File-Level Changes

Change Details Files
Introduce a reusable center-ellipsis measurement hook that computes visible text based on container width using ResizeObserver and binary search.
  • Implements a shared ResizeObserver instance and a map from span elements to measurement parameters to recalculate truncated content on resize.
  • Uses a hidden measurement span to compare full and candidate truncated strings against available width via getBoundingClientRect.
  • Performs a binary search over the collapsible middle segment to maximize visible characters while fitting within the container.
  • Provides a useCenterEllipsis hook that manages refs, text updates, font-loading readiness, and subscription/unsubscription to the observer.
src/components/Ellipsis/hooks.ts
Add Ellipsis component with start, center, and end ellipsis modes, including offset and separator-based segmenting plus copy-behavior correction for center mode.
  • Defines Ellipsis component API (position, offsetStart, offsetEnd, separator, children) and wiring for external ref and DOM props.
  • Implements segment splitting logic based on offsets and optional single or multiple separators to preserve path-like segments at edges.
  • Renders a CenterEllipsis subcomponent that uses useCenterEllipsis for dynamic middle truncation, and static start/end ellipsis variants using directional isolation characters to avoid RTL shuffling.
  • Overrides copy behavior for center mode so that copying the visually truncated text yields the original full string when appropriate and sets aria-label for accessibility.
src/components/Ellipsis/Ellipsis.tsx
Add Storybook stories demonstrating Ellipsis behavior across positions, separators, and responsive widths.
  • Configures Storybook meta for Ellipsis with default args and controls for key props.
  • Implements a WithWidthControl helper with a Slider to adjust available width interactively.
  • Adds stories for default behavior, all positions, single-separator truncation, and multiple-separator truncation scenarios.
src/components/Ellipsis/__stories__/Ellipsis.stories.tsx
Add styles for Ellipsis component and support direction-aware start-ellipsis via new CSS variable.
  • Defines base .ellipsis layout styles and inner __ellipsis modifiers for start, center, and end modes including overflow and measuring span styling.
  • Uses CSS custom property --g-flow-opposite to set direction for start ellipsis content so that text-overflow works correctly in RTL and LTR.
  • Extends theme common index to define --g-flow-opposite for default, [dir='ltr'], and [dir='rtl'] cases.
src/components/Ellipsis/Ellipsis.scss
styles/themes/common/_index.scss

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've found 3 issues, and left some high level feedback:

  • In useCenterEllipsis, measureRef is declared as React.useRef<HTMLSpanElement>(null), which is incompatible with null as the initial value under strict typing; consider changing it to React.useRef<HTMLSpanElement | null>(null) (and adjusting usages) to avoid TypeScript errors.
  • The separator handling in Ellipsis uses Array.from(separator) and then includes(char) on each character of the text; this effectively only supports single-character separators even when an array is passed—if you intend to support multi-character separators, you may want to clarify the prop type or adjust the splitting logic to operate on substrings instead of individual characters.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `useCenterEllipsis`, `measureRef` is declared as `React.useRef<HTMLSpanElement>(null)`, which is incompatible with `null` as the initial value under strict typing; consider changing it to `React.useRef<HTMLSpanElement | null>(null)` (and adjusting usages) to avoid TypeScript errors.
- The separator handling in `Ellipsis` uses `Array.from(separator)` and then `includes(char)` on each character of the text; this effectively only supports single-character separators even when an array is passed—if you intend to support multi-character separators, you may want to clarify the prop type or adjust the splitting logic to operate on substrings instead of individual characters.

## Individual Comments

### Comment 1
<location path="src/components/Ellipsis/hooks.ts" line_range="156-162" />
<code_context>
+    );
+
+    // does the same as useLayoutEffect, but does not trigger warnings
+    const containerRefCallback = React.useCallback<React.RefCallback<HTMLSpanElement>>(
+        (node) => {
+            if (!node) {
+                return;
+            }
+
+            if (!containerRef.current) {
+                document.fonts.ready.then(() => handleTextChange(node));
+            }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Avoid double work and potential race between `document.fonts.ready` and the `useEffect` that also triggers `handleTextChange`.

Right now `handleTextChange` can run twice on initial mount (from `document.fonts.ready` and from the `useEffect` that runs on mount/text change), and `fonts.ready` can still resolve after unmount, calling `handleTextChange(node)` on a cleaned-up component.

Consider:
- Using a `mounted` ref and early-returning in the `fonts.ready` callback if unmounted, and/or
- Moving the `fonts.ready` logic into the same effect that does the initial measurement so only one path runs it.

This avoids duplicate work and prevents lifecycle races.

Suggested implementation:

```typescript
    );

    const mountedRef = React.useRef(false);
    const initialMeasureDoneRef = React.useRef(false);

    React.useEffect(() => {
        mountedRef.current = true;

        return () => {
            mountedRef.current = false;
        };
    }, []);

    // does the same as useLayoutEffect, but does not trigger warnings
    const containerRefCallback = React.useCallback<React.RefCallback<HTMLSpanElement>>(
        (node) => {
            containerRef.current = node;

            if (!node) {
                return;
            }

            // Schedule initial measurement once fonts are ready, but only once
            if (typeof document !== 'undefined' && 'fonts' in document) {
                document.fonts.ready.then(() => {
                    if (!mountedRef.current || initialMeasureDoneRef.current) {
                        return;
                    }

                    initialMeasureDoneRef.current = true;
                    handleTextChange(node);
                });
            } else if (!initialMeasureDoneRef.current) {
                initialMeasureDoneRef.current = true;
                handleTextChange(node);
            }
        },
        [handleTextChange],
    );

const ELLIPSIS_CHAR = '\u2026';

```

To fully avoid duplicate work and races, update the `useEffect` that currently calls `handleTextChange` on mount/text change to guard with `initialMeasureDoneRef` as well. For example, wrap its `handleTextChange(container)` call with:

```ts
if (!initialMeasureDoneRef.current && container) {
    initialMeasureDoneRef.current = true;
    handleTextChange(container);
}
```

This ensures that either the `fonts.ready` path or the effect does the initial measure, but never both, and no calls happen after unmount.
</issue_to_address>

### Comment 2
<location path="src/components/Ellipsis/hooks.ts" line_range="13" />
<code_context>
+
+const ELLIPSIS_CHAR = '\u2026';
+
+let observer: ResizeObserver | null = null;
+const resizeParamsMap: Map<HTMLSpanElement, ResizeParams> = new Map();
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring this hook to use per-instance resize observers and a pure helper for computing visible text instead of shared globals and mixed concerns.

You can simplify this hook a lot by pushing the observer and resize state into the hook instance and extracting the pure “compute visible text” logic.

### 1. Remove global `observer`, `resizeParamsMap`, and `callId`

You only ever use a single container per hook instance. You can keep all needed params in refs and have a per-instance `ResizeObserver`. That removes the Map, bookkeeping helpers, and the re-entrancy guard.

**Instead of:**

```ts
let observer: ResizeObserver | null = null;
const resizeParamsMap: Map<HTMLSpanElement, ResizeParams> = new Map();
let callId = 0;

const observerCallback = async (entries: ResizeObserverEntry[]) => {
  callId++;
  const currentCallId = callId;
  for (const {target} of entries) {
    handleResize(target as HTMLSpanElement);

    if (currentCallId !== callId) {
      return;
    }
  }
};

const subscribeResize = (container: HTMLSpanElement | null) => { /* ... */ }
const unsubscribeResize = (container: HTMLSpanElement | null) => { /* ... */ }
const setResizeParams = (...) => { /* ... */ }
const deleteResizeParams = (...) => { /* ... */ }
```

**Use a per-hook setup:**

```ts
export const useCenterEllipsis = ({ text, startOffset = '', endOffset = '' }: CenterEllipsisProps) => {
  const containerRef = React.useRef<HTMLSpanElement | null>(null);
  const measureRef = React.useRef<HTMLSpanElement | null>(null);
  const observerRef = React.useRef<ResizeObserver | null>(null);

  const resizeParamsRef = React.useRef<ResizeParams>({
    text,
    startOffset,
    endOffset,
    setVisibleText: () => {},
    measure: null,
  });

  const [visibleText, setVisibleText] = React.useState(text);

  // keep params up-to-date
  React.useEffect(() => {
    resizeParamsRef.current = {
      text,
      startOffset,
      endOffset,
      setVisibleText,
      measure: measureRef.current,
    };
  }, [text, startOffset, endOffset]);

  const handleResize = React.useCallback(() => {
    const container = containerRef.current;
    const { text, startOffset, endOffset, setVisibleText, measure } = resizeParamsRef.current;
    if (!container || !measure) return;

    const next = computeVisibleText({ container, measure, text, startOffset, endOffset });
    setVisibleText(next);
  }, []);

  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const observer = new ResizeObserver(() => {
      handleResize(); // no callId needed; idempotent
    });
    observerRef.current = observer;
    observer.observe(container);

    return () => {
      observer.disconnect();
      observerRef.current = null;
    };
  }, [handleResize]);

  // ...
};
```

This keeps all lifecycle and state local to the hook and removes the need for `callId` and the global `Map`.

### 2. Extract a pure `computeVisibleText` function

`handleResize` currently mixes DOM reading with the binary-search ellipsis algorithm. Extract the algorithm into a pure helper that takes minimal inputs and returns the final string.

**Extract the core algorithm:**

```ts
interface ComputeVisibleTextArgs {
  text: string;
  startOffset: string;
  endOffset: string;
  measureWidth: (candidate: string) => number;
  availableWidth: number;
}

const ELLIPSIS_CHAR = '\u2026';

const computeVisibleTextString = ({
  text,
  startOffset,
  endOffset,
  measureWidth,
  availableWidth,
}: ComputeVisibleTextArgs): string => {
  const collapsibleStartIndex = startOffset.length;
  const collapsibleEndIndex = -endOffset.length || text.length;
  const collapsibleText = text.slice(collapsibleStartIndex, collapsibleEndIndex);

  // full text fits
  if (measureWidth(text) <= availableWidth) {
    return text;
  }

  let minCharacters = 0;
  let maxCharacters = collapsibleText.length;
  let result = startOffset + ELLIPSIS_CHAR + endOffset;

  while (minCharacters <= maxCharacters) {
    const currentLength = Math.floor((minCharacters + maxCharacters) * 0.5);
    const start = collapsibleText.slice(0, Math.ceil(currentLength * 0.5));
    const end = collapsibleText.slice(collapsibleText.length - Math.floor(currentLength * 0.5));
    const candidate = startOffset + start + ELLIPSIS_CHAR + end + endOffset;

    if (measureWidth(candidate) <= availableWidth) {
      result = candidate;
      minCharacters = currentLength + 1;
    } else {
      maxCharacters = currentLength - 1;
    }
  }

  return result;
};
```

**Then `handleResize` becomes a thin wrapper:**

```ts
const computeVisibleText = ({
  container,
  measure,
  text,
  startOffset,
  endOffset,
}: {
  container: HTMLSpanElement;
  measure: HTMLSpanElement;
  text: string;
  startOffset: string;
  endOffset: string;
}) => {
  const availableWidth = container.getBoundingClientRect().width;
  if (availableWidth <= 0) return text;

  const measureWidth = (candidate: string) => {
    measure.textContent = candidate;
    return measure.getBoundingClientRect().width;
  };

  return computeVisibleTextString({
    text,
    startOffset,
    endOffset,
    measureWidth,
    availableWidth,
  });
};
```

Now:

- All cross-instance coordination is gone.
- The resize logic is easier to reason about (no `callId`, no global `Map`).
- The core ellipsis algorithm is testable in isolation without DOM/React.
</issue_to_address>

### Comment 3
<location path="src/components/Ellipsis/Ellipsis.tsx" line_range="69" />
<code_context>
+
+    const isCenterPosition = position === 'center';
+
+    const [startOffset, ellipsis, endOffset] = React.useMemo<[string, string, string]>(() => {
+        const textLength = text.length;
+        if (!separator) {
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the offset calculation, edge-ellipsis rendering, and copy-handling logic into small helpers/hooks to make the Ellipsis component primarily simple wiring rather than dense inline logic.

You can keep the current feature set but reduce perceived complexity with a few small extractions and a clearer separator model.

### 1. Split the `useMemo` into small helpers + normalized separators

Right now the `useMemo` does:
- negative index handling
- separator/string[] handling (with `Array.from`)
- scanning from both ends
- slicing

You can make this more readable by:
- normalizing `separator` once
- delegating to small helpers for separator vs non‑separator logic

```ts
type Offsets = [string, string, string];

function computeOffsetsNoSeparator(
    text: string,
    offsetStart: number,
    offsetEnd: number,
): Offsets {
    const textLength = text.length;
    const offsetEndLocal = -offsetEnd || textLength;

    return [
        text.slice(0, offsetStart),
        text.slice(offsetStart, offsetEndLocal),
        text.slice(offsetEndLocal),
    ];
}

function computeOffsetsWithSeparators(
    text: string,
    separators: string[],
    offsetStart: number,
    offsetEnd: number,
): Offsets {
    const textLength = text.length;

    let startPartsLeft = offsetStart;
    let startOffsetEnd = 0;
    let endPartsLeft = offsetEnd;
    let endOffsetStart = textLength;

    for (let i = 0; i < textLength && (startPartsLeft || endPartsLeft); i++) {
        const charStart = text[i];
        const charEnd = text[textLength - i - 1];

        if (startPartsLeft && separators.includes(charStart)) {
            startPartsLeft--;
            startOffsetEnd = i;
        }
        if (endPartsLeft && separators.includes(charEnd)) {
            endPartsLeft--;
            endOffsetStart = textLength - i;
        }
    }

    return [
        text.slice(0, startOffsetEnd),
        text.slice(startOffsetEnd, endOffsetStart),
        text.slice(endOffsetStart),
    ];
}
```

Then inside the component:

```ts
const separators = React.useMemo(
    () => (separator ? (Array.isArray(separator) ? separator : [separator]) : []),
    [separator],
);

const [startOffset, ellipsis, endOffset] = React.useMemo<Offsets>(() => {
    if (!separators.length) {
        return computeOffsetsNoSeparator(text, offsetStart, offsetEnd);
    }

    return computeOffsetsWithSeparators(text, separators, offsetStart, offsetEnd);
}, [text, separators, offsetStart, offsetEnd]);
```

This keeps behavior intact but makes each piece much easier to follow.

### 2. Extract the non‑center render path

The conditional JSX for center vs edge ellipsis duplicates structure and mixes layout + special bidi handling. Extracting the non‑center branch into a tiny component or render function lets the main component stay “routing only”:

```ts
interface EdgeEllipsisProps {
    position: EllipsisPosition;
    startOffset: string;
    ellipsis: string;
    endOffset: string;
}

function EdgeEllipsis({position, startOffset, ellipsis, endOffset}: EdgeEllipsisProps) {
    return (
        <>
            <span aria-hidden>{startOffset}</span>
            <span className={b('ellipsis', {[position]: true})} aria-hidden>
                <span className={b('ellipsis-content')}>{`${FSI}${ellipsis}${PDI}`}</span>
            </span>
            <span aria-hidden>{endOffset}</span>
        </>
    );
}
```

Usage:

```tsx
return (
    <span
        className={b(null, className)}
        style={style}
        ref={ref}
        onCopy={handleCopy}
        aria-label={text}
    >
        {isCenterPosition ? (
            <CenterEllipsis
                startOffset={startOffset}
                endOffset={endOffset}
                ref={ellipsisContentRef}
            >
                {text}
            </CenterEllipsis>
        ) : (
            <EdgeEllipsis
                position={position}
                startOffset={startOffset}
                ellipsis={ellipsis}
                endOffset={endOffset}
            />
        )}
    </span>
);
```

### 3. Move copy logic into a hook

The copy behavior is useful but cross‑cutting. Pulling it into a hook reduces noise in the component and localizes the clipboard logic:

```ts
function useRestoreFullTextOnCopy(
    enabled: boolean,
    text: string,
    startOffset: string,
    endOffset: string,
    ellipsisContentRef: React.RefObject<HTMLSpanElement>,
) {
    return React.useCallback(
        (e: React.ClipboardEvent<HTMLSpanElement>) => {
            if (!enabled) {
                return;
            }

            const clipboardText = (window.getSelection()?.toString() || '').trim();
            const currentText =
                startOffset + (ellipsisContentRef.current?.textContent || '') + endOffset;

            if (currentText === clipboardText) {
                e.preventDefault();
                e.clipboardData.setData('text/plain', text);
            }
        },
        [enabled, text, startOffset, endOffset],
    );
}
```

Then in `Ellipsis`:

```ts
const handleCopy = useRestoreFullTextOnCopy(
    isCenterPosition,
    text,
    startOffset,
    endOffset,
    ellipsisContentRef,
);
```

These extractions keep all current behavior but make the main component mostly wiring, which directly addresses the “too much in one place” concern without changing semantics.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/components/Ellipsis/hooks.ts Outdated
Comment thread src/components/Ellipsis/hooks.ts Outdated
Comment thread src/components/Ellipsis/Ellipsis.tsx Outdated
@gravity-ui

gravity-ui Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Preview is ready.

@gravity-ui

gravity-ui Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

🎭 Component Tests Report is ready.

@korvin89

korvin89 commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

@PahaN47 Hi! We looked (me and @amje ) at the stories and the general approach in the code. Before review, we'd like to discuss the following questions:

  1. Why is the separator prop needed? It's unclear from the stories what it does.
  2. In the default story, let's replace the range slider component with a resizable div (like here).
  3. We suggest replacing offsetStart and offsetEnd with a single offset that can accept negative values and will only be responsible for the position of the ellipsis.
  4. For setting the minimum number of elements from the start or end, we suggest introducing minCharsStart and minCharsEnd props.
  5. Use a shared hook for working with ResizeObserver. Your optimization can optionally be added to it, but it should be done via WeakMap rather than Map.
  6. Consider dropping --g-flow-opposite. You named the variable using the public convention, which implies it can be mutated by the user — but the user shouldn't be able to control this value from their side.
  7. When using native overflow: ellipsis, a gap appears around the ellipsis (on the left or right). We suggest keeping this approach only when position=end is used and the minCharsEnd prop is absent — this should cover most cases. For the rest, use JS-based calculations.

@PahaN47
PahaN47 force-pushed the feat/ellipsis-component branch from 8640a93 to af6e672 Compare July 16, 2026 11:47
@PahaN47

PahaN47 commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor Author

@korvin89

  1. Why is the separator prop needed? It's unclear from the stories what it does.

By default the ellipsis is offset by a certain number of characters, but if we wanted to use it for, say, file paths and wanted to always keep the actual file name with the extension visible, we would not know the exact number of characters we would need to use for this. So when there is a separator, we split the text into parts delimited by it, and then apply offset to these parts, not the characters. This way we could specify to separate the text with '/' and set offsetEnd={1} so that the actual file name is always displayed.

  1. We suggest replacing offsetStart and offsetEnd with a single offset that can accept negative values and will only be responsible for the position of the ellipsis.

Then we would not be able to specify both at the same time. For instance, if we always wanted to keep the first and last 3 characters of the text.

  1. For setting the minimum number of elements from the start or end, we suggest introducing minCharsStart and minCharsEnd props.

Is this not the same as offset?

  1. Consider dropping --g-flow-opposite. You named the variable using the public convention, which implies it can be mutated by the user — but the user shouldn't be able to control this value from their side.

The variable is needed for the start ellipsis. In order to make it work without involving JS, we need to know the opposite direction to the one that is used by default. Any suggestions on how I could rename it maybe?

  1. When using native overflow: ellipsis, a gap appears around the ellipsis (on the left or right). We suggest keeping this approach only when position=end is used and the minCharsEnd prop is absent — this should cover most cases. For the rest, use JS-based calculations.

I was trying to avoid JS as much as possible, as the calculations are quite heavy. The only case that could not be implemented without it is the center position, so this is the only one that uses JS. I am very hesitant to expand JS usage as compared to accepting the small gap. One day, JS could even be dropped entirely, if text-overflow: <string> gets widely supported

@korvin89 korvin89 left a comment

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.

🤖 AI generated

This review was done with AI assistance. I validated every comment myself, but the wording — including the points I found myself — was written by the AI.

Severity legend

  • 🔴 blocker — should be fixed before merge.
  • 🟠 should-fix — a real problem; worth fixing before or right after merge. If you can't get to it now, please open an issue and leave a comment linking it.
  • 🔵 nit — minor; author's discretion.
  • ❓ question — I need more context; not necessarily a defect.

Comment thread src/components/Ellipsis/hooks.ts Outdated
return;
}

measure.textContent ||= text;

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.

🤖 AI generated

🔴 blocker

measure.textContent ||= text; only fills the probe element while it is empty, so once it holds a value it is never refreshed. When the text prop changes on a mounted instance, the "does the whole text fit?" check on line 79 measures the previous text.

If the old text fitted, the check passes and the new, longer text is rendered in full and hard-clipped by overflow: hidden with no ellipsis — and it does not recover, because every later resize short-circuits on the same stale measurement. Reproduced in Chromium with position="center" in a 200px box: changing short.txt to a 386px-wide string leaves the probe at short.txt.

This looks like a regression from 9363713 — before that commit the assignment was unconditional:

8f83443c  measure.textContent = text;
86dd0e8b  measure.textContent = text;
af6e672d  measure.textContent = text;
9363713f  measure.textContent ||= text;   ← here

Reverting to measure.textContent = text; should fix it; the write is cheap, and calculateCollapsedText already does exactly that on line 59.

Comment thread src/components/Ellipsis/Ellipsis.tsx Outdated
style={style}
ref={ref}
onCopy={handleCopy}
aria-label={text}

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.

🤖 AI generated

🔴 blocker

aria-label={text} sits on a <span> with no role (implicit generic), while every descendant is marked aria-hidden (lines 32, 52, 57, 62). ARIA prohibits aria-label on generic, so the component may end up with no accessible name at all — and there is nothing else left to announce, since all the text is hidden.

I ran axe-core@4.10.3 (the version in this repo's node_modules) against the rendered markup and it reports aria-prohibited-attr with impact serious. .storybook/test-runner.ts:26 calls checkA11y on every story, so I would expect the Storybook test run to go red on this.

Two fixes both come back clean under axe:

  • add role="img" alongside the aria-label, or
  • drop the aria-label and render a visually-hidden <span> carrying the full text next to the aria-hidden visual part.

Worth noting that for position="start" / "end" the complete text is already in the DOM, so the aria-hidden + aria-label pair is a pure loss there — hiding it and re-labelling only removes information.

Comment thread src/components/Ellipsis/Ellipsis.tsx Outdated
const offsetEndLocal = -offsetEnd || textLength;

return [
text.slice(0, offsetStart),

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.

🤖 AI generated

🔴 blocker

Neither getPartsWithoutSeparator nor getPartsWithSeparator checks that the two offsets do not cross, so overlapping offsets silently duplicate characters. Reproduced against the component itself:

<Ellipsis offsetStart={4} offsetEnd={7}>a.tar.gz</Ellipsis>
// rendered:   "a.ta.tar.gz"
// aria-label: "a.tar.gz"

Same class of bug on the separator path — path/to/file with separator="/" and offsets 3/3 renders path/toto/file.

This is reachable straight from the example in the README (offsetEnd={7} for a .tar.gz extension) as soon as the filename is short, and the rendered text then disagrees with aria-label.

Would it make sense to clamp before slicing — endOffsetStart = Math.max(endOffsetStart, startOffsetEnd) on the separator path and the equivalent on the character path? Negative offsets are unguarded too, if that is worth covering while you are there.

Comment thread src/components/Ellipsis/Ellipsis.tsx Outdated
];
};

export const Ellipsis = React.forwardRef<HTMLSpanElement, EllipsisProps>(function Ellipsis(

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.

🤖 AI generated

🔴 blocker

The component bypasses the library's standard prop plumbing:

  • no useDefaultProps('Ellipsis', rawProps), and no entry in ComponentDefaultPropsMap (src/components/theme/PrivateDefaultPropsProvider.tsx) — 63 components go through this;
  • EllipsisProps does not extend AriaLabelingProps (line 67);
  • no ...restProps + filterDOMProps(restProps, {labelable: true}) spread.

src/components/Divider/Divider.tsx:20-46 is a compact reference for all three.

The practical effect on the public contract is that a consumer cannot pass id, title, data-*, aria-describedby, or any event handler, and cannot override the hard-coded aria-label — which is also the escape hatch that would let them work around the accessibility problem flagged separately.

Comment thread styles/themes/common/_index.scss Outdated
@at-root body#{&} {
// default document direction is ltr
--g-flow-direction: 1;
--g-flow-opposite: rtl;

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.

🤖 AI generated

🔴 blocker

Following up on point 6 from the earlier thread — I do not think this variable needs to exist at all.

Beyond the naming concern (a --g-* name reads as something a consumer may set, and this is not), it is also subtly incorrect: it is defined only on .g-root / body.g-root, while direction is an inherited property that legitimately changes mid-tree. An element carrying its own dir="ltr" inside .g-root[dir="rtl"] still resolves --g-flow-opposite: ltr — the same direction it already has — and position="start" silently degrades into end-truncation.

The repo already has a pattern for direction-specific rules that follows the actual dir in the tree rather than the root (Progress.scss:58, SliderTooltip.scss:7). Applied here:

&_start {
    #{$ellipsis}-content {
        @include mixins.overflow-ellipsis();

        // Flip the writing direction so the browser puts the ellipsis
        // at the inline start.
        direction: rtl;

        [dir='rtl'] & {
            direction: ltr;
        }
    }
}

That keeps the flip local to the component, fixes the nested-dir case, and lets styles/themes/common/_index.scss stay untouched.

Comment thread src/components/Ellipsis/Ellipsis.scss Outdated
&_start {
#{$ellipsis}-content {
direction: var(--g-flow-opposite);
overflow: hidden;

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.

🤖 AI generated

🟠 should-fix

overflow: hidden; text-overflow: ellipsis; is spelled out twice (lines 27-28 and 34-35) where styles/mixins.scss already provides it:

@include mixins.overflow-ellipsis();

That is what Text, Table, User, Select and Breadcrumbs use, so it would be good to stay consistent with them.


$ellipsis: &;

&_start {

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.

🤖 AI generated

🟠 should-fix

Coming back to point 7 from the earlier thread, since I do not think it is settled yet.

I take the point that JS measurement is expensive and that text-overflow: <string> would eventually make all of this unnecessary. But the gap is not uniformly harmless — it shows in two specific places:

  • position="end" with a non-zero trailing offset. The ellipsis lands at the clip edge of the middle span with leftover space up to roughly a character wide, and the pinned tail span follows immediately after, so there is a visible gap between … and the tail. This is also the most common use of the component — keeping the file extension.
  • position="start". In the approved Positions-1 snapshot in this PR the start row is already indented by about one character relative to the center and end rows below it, even though the labels are flush: the direction: rtl flip right-aligns the line and the sub-character remainder lands on the visible edge. In a list where rows are meant to line up, that is noticeable.

So the suggestion is not "use JS everywhere" — it is to narrow the pure-CSS path to the combinations where the gap genuinely is not visible (position="end" with no trailing offset covers most real usage) and measure for the rest. If the measurement cost is the blocker, it would help to see numbers for a realistic case — a list of a few hundred rows — so we can decide on evidence rather than on the concern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a few hundred lines with js noticeably lag. based on my personal testing, about ~100 lines or so is the bearable amount

Comment thread src/components/Ellipsis/Ellipsis.tsx Outdated
children: string;
}

const CenterEllipsis = React.memo(

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.

🤖 AI generated

🔵 nit

React.memo on CenterEllipsis (and on EdgeEllipsis, line 44) does not buy much: all of their props are derived from the parent's own props, so they change whenever the parent re-renders for a reason that matters. The only case it skips is a className / style / qa-only change. Not a problem, just probably not worth the wrapper.

Comment thread src/components/Ellipsis/Ellipsis.tsx Outdated

const isCenterPosition = position === 'center';

const [startOffset, collapsibleText, endOffset] = React.useMemo<[string, string, string]>(

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.

🤖 AI generated

🔵 nit

Three small readability points around this block:

  • EllipsisContentProps.children (line 17) means "the full text" for CenterEllipsis and "the collapsible middle" for EdgeEllipsis — one type carrying two different meanings.
  • The labelled tuple TextParts is declared on line 75 but not used at the one call site that would benefit; this useMemo re-spells <[string, string, string]>.
  • collapsibleText is computed here and then discarded for position="center", where hooks.ts:28-31 recomputes the same slice from startOffset.length / endOffset.length — the same rule expressed in two places.


const LONG_TEXT = 'a-very-long-long-text-that-should-be-truncated-in-somewhere.tar.gz';

// The `center` position relies on ResizeObserver and document.fonts, which jsdom does

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.

🤖 AI generated

🔵 nit

This comment says center relies on document.fonts, but the current implementation does not use it at all — that went away with the rework in 9363713. As written it gives a reason for the coverage gap that no longer holds.

Rather than explaining why center is not covered, it would be better to actually cover it in the Playwright suite (see the separate note on this file) and drop the caveat.

@PahaN47
PahaN47 force-pushed the feat/ellipsis-component branch from 358cfd8 to 068b328 Compare September 15, 2026 11:09
@PahaN47
PahaN47 force-pushed the feat/ellipsis-component branch from 068b328 to c4f86c4 Compare September 22, 2026 08:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants