Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 67 additions & 0 deletions src/components/EllipsisText/EllipsisText.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
@use '../variables';
@use '../../../styles/mixins';

$block: '.#{variables.$ns}ellipsis-text';

#{$block} {
display: inline-flex;
min-width: 0;
max-width: 100%;
white-space: nowrap;
overflow-wrap: break-word;

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-wrap: break-word and word-break: break-all cannot take effect here — white-space: nowrap on line 9 suppresses all wrapping, so both declarations are inert. I would suggest removing them, so the rule does not imply a wrapping behaviour that never happens.

word-break: break-all;

&__accessible-text {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
white-space: nowrap;
user-select: none;
}

&__ellipsis {
display: inline-flex;
min-width: 0;
overflow: hidden;

&-content {
max-width: fit-content;
}

$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

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

// Flip the inherited HTML direction to truncate at the inline start.
direction: rtl;

&:dir(rtl) {
direction: ltr;
}
}
}

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

&_center {
position: relative;
flex-grow: 1;

#{$ellipsis}-measure {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
}
}
}
}
217 changes: 217 additions & 0 deletions src/components/EllipsisText/EllipsisText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import * as React from 'react';

import {useDefaultProps} from '../theme/useDefaultProps';
import type {AriaLabelingProps, DOMProps, QAProps} from '../types';
import {block} from '../utils/cn';
import {filterDOMProps} from '../utils/filterDOMProps';

import {useCenterEllipsisText} from './hooks';
import {splitText} from './utils';

import './EllipsisText.scss';

const b = block('ellipsis-text');

export type EllipsisTextPosition = 'start' | 'center' | 'end';

interface EllipsisTextContentProps {
startOffset: string;
endOffset: string;
children: string;
}

const CenterEllipsisText = React.forwardRef<HTMLSpanElement, EllipsisTextContentProps>(
function CenterEllipsisText({children: text, startOffset, endOffset}, ellipsisContentRef) {
const {visibleText, containerRef, measureRef} = useCenterEllipsisText({
text,
startOffset,
endOffset,
});

return (
<span ref={containerRef} className={b('ellipsis', {center: true})} aria-hidden>
<span ref={measureRef} className={b('ellipsis-measure')} />
<span className={b('ellipsis-content')} ref={ellipsisContentRef}>
{visibleText}
</span>
</span>
);
},
);

const EdgeEllipsisText = React.memo(function EdgeEllipsisText({
position,
startOffset,
endOffset,
children: collapsibleText,
}: EllipsisTextContentProps & {position: Exclude<EllipsisTextPosition, 'center'>}) {
return (
<React.Fragment>
{startOffset && <span aria-hidden>{startOffset}</span>}
<span
className={b('ellipsis', {
[position]: true,
})}
aria-hidden
>
<span className={b('ellipsis-content')}>
{/* Isolate the text from the direction flipped for start truncation. */}
<bdi>{collapsibleText}</bdi>
</span>
</span>
{endOffset && <span aria-hidden>{endOffset}</span>}
</React.Fragment>
);
});

export interface EllipsisTextProps extends AriaLabelingProps, DOMProps, QAProps {
id?: string;
position?: EllipsisTextPosition;
offsetStart?: number;
offsetEnd?: number;
separator?: string | string[];
children: string;
}

type TextParts = [startOffset: string, collapsibleText: string, endOffset: string];

const getPartsWithoutSeparator = (
text: string,
offsetStart: number,
offsetEnd: number,
): TextParts => {
const characters = splitText(text);
const textLength = characters.length;
const startOffsetEnd = Math.min(textLength, Math.max(0, offsetStart));
const endOffsetStart = Math.max(startOffsetEnd, textLength - Math.max(0, offsetEnd));

return [
characters.slice(0, startOffsetEnd).join(''),
characters.slice(startOffsetEnd, endOffsetStart).join(''),
characters.slice(endOffsetStart).join(''),
];
};

const getPartsWithSeparator = (
text: string,
separator: string | string[],
offsetStart: number,
offsetEnd: number,
): TextParts => {
const textLength = text.length;
const tokens = (typeof separator === 'string' ? [separator] : separator)
.filter(Boolean)
.sort((sep1, sep2) => sep2.length - sep1.length);

if (!tokens.length) {
return getPartsWithoutSeparator(text, offsetStart, offsetEnd);
}

const boundaries: {start: number; end: number}[] = [];
for (let i = 0; i < textLength; ) {
const token = tokens.find((candidate) => text.startsWith(candidate, i));
if (token) {
boundaries.push({start: i, end: i + token.length});
i += token.length;
} else {
i++;
}
}

// There is one more split part than separator boundaries, including empty parts.
const startOffsetEnd = offsetStart > 0 ? (boundaries[offsetStart - 1]?.start ?? textLength) : 0;
const requestedEndOffsetStart = Math.max(
startOffsetEnd,
offsetEnd > 0 ? (boundaries[boundaries.length - offsetEnd]?.end ?? 0) : textLength,
);
const endOffsetStart =
offsetStart + offsetEnd >= boundaries.length + 1 ? startOffsetEnd : requestedEndOffsetStart;

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

export const EllipsisText = React.forwardRef<HTMLSpanElement, EllipsisTextProps>(
function EllipsisText(rawProps, ref) {
const props = useDefaultProps('EllipsisText', rawProps);
const {
className,
style,
position = 'end',
offsetStart = 0,
offsetEnd = 0,
separator = '',
children: text,
qa,
...restProps
} = props;
const ellipsisContentRef = React.useRef<HTMLSpanElement>(null);

const isCenterPosition = position === 'center';

const [startOffset, collapsibleText, endOffset] = React.useMemo(
() =>
separator.length
? getPartsWithSeparator(text, separator, offsetStart, offsetEnd)
: getPartsWithoutSeparator(text, offsetStart, offsetEnd),
[text, separator, offsetStart, offsetEnd],
);

const handleCopy = React.useCallback(
(e: React.ClipboardEvent<HTMLSpanElement>) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount !== 1) {
return;
}

// DOM text avoids layout-generated line breaks between the visual fragments.
const selectedContent = selection.getRangeAt(0).cloneContents();
selectedContent
.querySelectorAll(`.${b('accessible-text')}, .${b('ellipsis-measure')}`)
.forEach((node) => node.remove());
const currentText = isCenterPosition
? ellipsisContentRef.current?.textContent
: text;

if (currentText === selectedContent.textContent) {
e.preventDefault();
e.clipboardData.setData('text/plain', text);
}
},
[text, isCenterPosition],
);

return (
<span
{...filterDOMProps(restProps, {labelable: true})}
className={b(null, className)}
style={style}
ref={ref}
onCopy={handleCopy}
data-qa={qa}
>
<span className={b('accessible-text')}>{text}</span>
{isCenterPosition ? (
<CenterEllipsisText
startOffset={startOffset}
endOffset={endOffset}
ref={ellipsisContentRef}
>
{text}
</CenterEllipsisText>
) : (
<EdgeEllipsisText
startOffset={startOffset}
endOffset={endOffset}
position={position}
>
{collapsibleText}
</EdgeEllipsisText>
)}
</span>
);
},
);
84 changes: 84 additions & 0 deletions src/components/EllipsisText/README-ru.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<!--GITHUB_BLOCK-->

# EllipsisText

<!--/GITHUB_BLOCK-->

```tsx
import {EllipsisText} from '@gravity-ui/uikit';
```

Компонент `EllipsisText` обрезает однострочный текст, который не помещается в контейнер, заменяя скрытую часть символом многоточия (`…`). В отличие от обычного CSS-свойства `text-overflow: ellipsis`, он позволяет выбрать место обрезки текста — в начале (`start`), середине (`center`) или конце (`end`) — и сохраняет полный текст доступным для программ чтения с экрана (через визуально скрытый текст) и для копирования.

Как и для CSS-многоточия, для обрезки текста нужна определённая ширина на одном из уровней раскладки.

## Позиция

Свойство `position` управляет местом обрезки текста. Значение по умолчанию — `end`. Возможные значения: `start`, `center` и `end`.

Позиции `start` и `end` реализованы средствами CSS, а `center` измеряет текст и пересобирает его, чтобы оставить видимыми оба края.

<!--SANDBOX
import {EllipsisText} from '@gravity-ui/uikit';

export default function () {
return (
<div style={{width: 200, display: 'grid', gap: 8}}>
<EllipsisText position="start">a-very-long-long-text-that-should-be-truncated-in-somewhere.tar.gz</EllipsisText>
<EllipsisText position="center">a-very-long-long-text-that-should-be-truncated-in-somewhere.tar.gz</EllipsisText>
<EllipsisText position="end">a-very-long-long-text-that-should-be-truncated-in-somewhere.tar.gz</EllipsisText>
</div>
);
}
SANDBOX-->

## Отступы

Свойства `offsetStart` и `offsetEnd` задают фиксированное количество символов (или разделённых частей, см. ниже) в начале и конце текста, которые не обрезаются. Это удобно для сохранения значимых краёв, например расширения файла.

<!--SANDBOX
import {EllipsisText} from '@gravity-ui/uikit';

export default function () {
return (
<div style={{width: 200, display: 'grid'}}>
<EllipsisText position="center" offsetEnd={7}>
a-very-long-long-text-that-should-be-truncated-in-somewhere.tar.gz
</EllipsisText>
</div>
);
}
SANDBOX-->

## Разделитель

По умолчанию отступы считают видимые символы (графемы), сохраняя эмодзи и комбинируемые знаки целиком. В средах без `Intl.Segmenter` используются кодовые точки Unicode, что также сохраняет суррогатные пары. С `separator` они считают части, как при `split(separator)`; массив задаёт альтернативные разделители. Первые `offsetStart` и последние `offsetEnd` частей сохраняются с исходными разделителями между ними. Остальной текст может обрезаться внутри части.

Если совпадений нет, строка считается одной частью. Отступы, охватывающие все части, сохраняют всю строку без дублирования. Поддерживаются многосимвольные разделители; поиск идёт слева направо с выбором самого длинного совпадения. Пустые разделители игнорируются; если их не осталось, отступы считаются в символах.

<!--SANDBOX
import {EllipsisText} from '@gravity-ui/uikit';

export default function () {
return (
<div style={{width: 240, display: 'grid'}}>
<EllipsisText position="center" separator="/" offsetStart={1} offsetEnd={1}>
path/to/some/deeply/nested/folder/file-name.tsx
</EllipsisText>
</div>
);
}
SANDBOX-->

## Свойства

| Имя | Описание | Тип | Значение по умолчанию |
| :---------- | :--------------------------------------------------------------------------------- | :--------------------------: | :-------------------: |
| children | Обрезаемый текст. | `string` | |
| position | Место обрезки текста. | `'start'` `'center'` `'end'` | `'end'` |
| offsetStart | Количество начальных символов (или частей `separator`), которые не обрезаются. | `number` | `0` |
| offsetEnd | Количество конечных символов (или частей `separator`), которые не обрезаются. | `number` | `0` |
| separator | Разделитель(и) для подсчёта `offsetStart`/`offsetEnd` по частям, а не по символам. | `string` `string[]` | `''` |
| className | Пользовательский CSS-класс корневого элемента. | `string` | |
| style | HTML-атрибут `style`. | `React.CSSProperties` | |
| qa | HTML-атрибут `data-qa`, используется для тестирования. | `string` | |
Loading
Loading