Conversation
Reviewer's GuideAdds 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
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In
useCenterEllipsis,measureRefis declared asReact.useRef<HTMLSpanElement>(null), which is incompatible withnullas the initial value under strict typing; consider changing it toReact.useRef<HTMLSpanElement | null>(null)(and adjusting usages) to avoid TypeScript errors. - The separator handling in
EllipsisusesArray.from(separator)and thenincludes(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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
Preview is ready. |
|
🎭 Component Tests Report is ready. |
|
@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:
|
8640a93 to
af6e672
Compare
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
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.
Is this not the same as offset?
The variable is needed for the
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 |
korvin89
left a comment
There was a problem hiding this comment.
🤖 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.
| return; | ||
| } | ||
|
|
||
| measure.textContent ||= text; |
There was a problem hiding this comment.
🤖 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.
| style={style} | ||
| ref={ref} | ||
| onCopy={handleCopy} | ||
| aria-label={text} |
There was a problem hiding this comment.
🤖 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 thearia-label, or - drop the
aria-labeland render a visually-hidden<span>carrying the full text next to thearia-hiddenvisual 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.
| const offsetEndLocal = -offsetEnd || textLength; | ||
|
|
||
| return [ | ||
| text.slice(0, offsetStart), |
There was a problem hiding this comment.
🤖 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.
| ]; | ||
| }; | ||
|
|
||
| export const Ellipsis = React.forwardRef<HTMLSpanElement, EllipsisProps>(function Ellipsis( |
There was a problem hiding this comment.
🤖 AI generated
🔴 blocker
The component bypasses the library's standard prop plumbing:
- no
useDefaultProps('Ellipsis', rawProps), and no entry inComponentDefaultPropsMap(src/components/theme/PrivateDefaultPropsProvider.tsx) — 63 components go through this; EllipsisPropsdoes not extendAriaLabelingProps(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.
| @at-root body#{&} { | ||
| // default document direction is ltr | ||
| --g-flow-direction: 1; | ||
| --g-flow-opposite: rtl; |
There was a problem hiding this comment.
🤖 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.
| &_start { | ||
| #{$ellipsis}-content { | ||
| direction: var(--g-flow-opposite); | ||
| overflow: hidden; |
There was a problem hiding this comment.
🤖 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 { |
There was a problem hiding this comment.
🤖 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 approvedPositions-1snapshot in this PR thestartrow is already indented by about one character relative to thecenterandendrows below it, even though the labels are flush: thedirection: rtlflip 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.
There was a problem hiding this comment.
a few hundred lines with js noticeably lag. based on my personal testing, about ~100 lines or so is the bearable amount
| children: string; | ||
| } | ||
|
|
||
| const CenterEllipsis = React.memo( |
There was a problem hiding this comment.
🤖 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.
|
|
||
| const isCenterPosition = position === 'center'; | ||
|
|
||
| const [startOffset, collapsibleText, endOffset] = React.useMemo<[string, string, string]>( |
There was a problem hiding this comment.
🤖 AI generated
🔵 nit
Three small readability points around this block:
EllipsisContentProps.children(line 17) means "the full text" forCenterEllipsisand "the collapsible middle" forEdgeEllipsis— one type carrying two different meanings.- The labelled tuple
TextPartsis declared on line 75 but not used at the one call site that would benefit; thisuseMemore-spells<[string, string, string]>. collapsibleTextis computed here and then discarded forposition="center", wherehooks.ts:28-31recomputes the same slice fromstartOffset.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 |
There was a problem hiding this comment.
🤖 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.
358cfd8 to
068b328
Compare
068b328 to
c4f86c4
Compare
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:
Enhancements: