diff --git a/src/components/EllipsisText/EllipsisText.scss b/src/components/EllipsisText/EllipsisText.scss new file mode 100644 index 0000000000..2b94aeaaeb --- /dev/null +++ b/src/components/EllipsisText/EllipsisText.scss @@ -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; + 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 { + #{$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; + } + } + } +} diff --git a/src/components/EllipsisText/EllipsisText.tsx b/src/components/EllipsisText/EllipsisText.tsx new file mode 100644 index 0000000000..58ea0e8570 --- /dev/null +++ b/src/components/EllipsisText/EllipsisText.tsx @@ -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( + function CenterEllipsisText({children: text, startOffset, endOffset}, ellipsisContentRef) { + const {visibleText, containerRef, measureRef} = useCenterEllipsisText({ + text, + startOffset, + endOffset, + }); + + return ( + + + + {visibleText} + + + ); + }, +); + +const EdgeEllipsisText = React.memo(function EdgeEllipsisText({ + position, + startOffset, + endOffset, + children: collapsibleText, +}: EllipsisTextContentProps & {position: Exclude}) { + return ( + + {startOffset && {startOffset}} + + + {/* Isolate the text from the direction flipped for start truncation. */} + {collapsibleText} + + + {endOffset && {endOffset}} + + ); +}); + +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( + 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(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) => { + 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 ( + + {text} + {isCenterPosition ? ( + + {text} + + ) : ( + + {collapsibleText} + + )} + + ); + }, +); diff --git a/src/components/EllipsisText/README-ru.md b/src/components/EllipsisText/README-ru.md new file mode 100644 index 0000000000..a931cd4b05 --- /dev/null +++ b/src/components/EllipsisText/README-ru.md @@ -0,0 +1,84 @@ + + +# EllipsisText + + + +```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` измеряет текст и пересобирает его, чтобы оставить видимыми оба края. + + + +## Отступы + +Свойства `offsetStart` и `offsetEnd` задают фиксированное количество символов (или разделённых частей, см. ниже) в начале и конце текста, которые не обрезаются. Это удобно для сохранения значимых краёв, например расширения файла. + + + +## Разделитель + +По умолчанию отступы считают видимые символы (графемы), сохраняя эмодзи и комбинируемые знаки целиком. В средах без `Intl.Segmenter` используются кодовые точки Unicode, что также сохраняет суррогатные пары. С `separator` они считают части, как при `split(separator)`; массив задаёт альтернативные разделители. Первые `offsetStart` и последние `offsetEnd` частей сохраняются с исходными разделителями между ними. Остальной текст может обрезаться внутри части. + +Если совпадений нет, строка считается одной частью. Отступы, охватывающие все части, сохраняют всю строку без дублирования. Поддерживаются многосимвольные разделители; поиск идёт слева направо с выбором самого длинного совпадения. Пустые разделители игнорируются; если их не осталось, отступы считаются в символах. + + + +## Свойства + +| Имя | Описание | Тип | Значение по умолчанию | +| :---------- | :--------------------------------------------------------------------------------- | :--------------------------: | :-------------------: | +| 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` | | diff --git a/src/components/EllipsisText/README.md b/src/components/EllipsisText/README.md new file mode 100644 index 0000000000..d73e4603b8 --- /dev/null +++ b/src/components/EllipsisText/README.md @@ -0,0 +1,84 @@ + + +# EllipsisText + + + +The `EllipsisText` component truncates a single line of text that is too long to fit its container, replacing the hidden part with an ellipsis character (`…`). Unlike a plain CSS `text-overflow: ellipsis`, it lets you choose where the text is cut off — at the `start`, `center`, or `end` — and keeps the full text available for screen readers (via visually hidden text) and for copying. + +```tsx +import {EllipsisText} from '@gravity-ui/uikit'; +``` + +As with CSS ellipsis, truncation requires a definite width somewhere in the layout. + +## Position + +Use the `position` property to control where the text is truncated. The default value is `end`. The possible values are `start`, `center`, and `end`. + +The `start` and `end` positions are rendered with pure CSS, while `center` measures the text and rebuilds it to keep both ends visible. + + + +## Offsets + +Use the `offsetStart` and `offsetEnd` properties to keep a fixed number of characters (or separator-delimited parts, see below) at the beginning and end of the text untruncated. This is useful for preserving meaningful edges such as a file extension. + + + +## Separator + +By default, offsets count user-perceived characters (grapheme clusters), keeping emoji and combining marks together. Environments without `Intl.Segmenter` fall back to Unicode code points, which still preserve surrogate pairs. With `separator`, they count parts as with `split(separator)`; an array supplies alternative separator tokens. The first `offsetStart` and last `offsetEnd` parts are kept with their original separators between them. The remaining text can be truncated within a part. + +No match means one part. Offsets covering all parts preserve the whole string without duplication. Multi-character tokens are supported; matching proceeds left to right, preferring the longest match. Empty tokens are ignored; if none remain, offsets count characters. + + + +## Properties + +| Name | Description | Type | Default | +| :---------- | :----------------------------------------------------------------------------- | :--------------------------: | :-----: | +| children | Text to be truncated | `string` | | +| position | Where the text is truncated | `'start'` `'center'` `'end'` | `'end'` | +| offsetStart | Number of leading characters (or `separator` parts) to keep untruncated | `number` | `0` | +| offsetEnd | Number of trailing characters (or `separator` parts) to keep untruncated | `number` | `0` | +| separator | Separator(s) used to count `offsetStart`/`offsetEnd` in parts instead of chars | `string` `string[]` | `''` | +| className | Custom CSS class for the root element | `string` | | +| style | HTML `style` attribute | `React.CSSProperties` | | +| qa | `data-qa` HTML attribute, used for testing | `string` | | diff --git a/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Default-1-chromium-linux.png b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Default-1-chromium-linux.png new file mode 100644 index 0000000000..8c6047790e Binary files /dev/null and b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Default-1-chromium-linux.png differ diff --git a/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Default-2-chromium-linux.png b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Default-2-chromium-linux.png new file mode 100644 index 0000000000..c6bbefaafe Binary files /dev/null and b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Default-2-chromium-linux.png differ diff --git a/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Positions-1-chromium-linux.png b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Positions-1-chromium-linux.png new file mode 100644 index 0000000000..bffe5ddcf3 Binary files /dev/null and b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Positions-1-chromium-linux.png differ diff --git a/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Positions-2-chromium-linux.png b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Positions-2-chromium-linux.png new file mode 100644 index 0000000000..fc0f93b7b5 Binary files /dev/null and b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-Positions-2-chromium-linux.png differ diff --git a/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-WithSeparator-1-chromium-linux.png b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-WithSeparator-1-chromium-linux.png new file mode 100644 index 0000000000..23f92956fb Binary files /dev/null and b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-WithSeparator-1-chromium-linux.png differ diff --git a/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-WithSeparator-2-chromium-linux.png b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-WithSeparator-2-chromium-linux.png new file mode 100644 index 0000000000..e54251b289 Binary files /dev/null and b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-render-story-WithSeparator-2-chromium-linux.png differ diff --git a/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-smoke-1-chromium-linux.png b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-smoke-1-chromium-linux.png new file mode 100644 index 0000000000..2f0adafb6c Binary files /dev/null and b/src/components/EllipsisText/__snapshots__/EllipsisText.visual.test.tsx-snapshots/EllipsisText-smoke-1-chromium-linux.png differ diff --git a/src/components/EllipsisText/__stories__/Docs.mdx b/src/components/EllipsisText/__stories__/Docs.mdx new file mode 100644 index 0000000000..8ead8ef2d6 --- /dev/null +++ b/src/components/EllipsisText/__stories__/Docs.mdx @@ -0,0 +1,9 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; + +import * as Stories from './EllipsisText.stories'; + +import Readme from '../README.md?raw'; + + + +{Readme} diff --git a/src/components/EllipsisText/__stories__/EllipsisText.stories.tsx b/src/components/EllipsisText/__stories__/EllipsisText.stories.tsx new file mode 100644 index 0000000000..b7f5ca6fc3 --- /dev/null +++ b/src/components/EllipsisText/__stories__/EllipsisText.stories.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; + +import type {Meta, StoryObj} from '@storybook/react-webpack5'; + +import {Slider} from '../../Slider'; +import {Flex} from '../../layout'; +import {EllipsisText} from '../EllipsisText'; + +const meta: Meta = { + title: 'Components/Data Display/EllipsisText', + component: EllipsisText, + args: { + children: 'a-very-long-long-text-that-should-be-truncated-in-somewhere.tar.gz', + position: 'end', + offsetStart: 0, + offsetEnd: 0, + separator: '', + }, + argTypes: { + position: { + control: {type: 'inline-radio'}, + options: ['start', 'center', 'end'], + }, + offsetStart: {control: {type: 'number', min: 0}}, + offsetEnd: {control: {type: 'number', min: 0}}, + separator: {control: {type: 'text'}}, + children: {control: {type: 'text'}}, + }, +}; + +export default meta; + +type Story = StoryObj; + +const MIN_WIDTH = 0; +const MAX_WIDTH = 500; +const DEFAULT_WIDTH = 240; + +const WithWidthControl = ({ + children, + defaultWidth = DEFAULT_WIDTH, +}: { + children: (width: number) => React.ReactNode; + defaultWidth?: number; +}) => { + const [width, setWidth] = React.useState(defaultWidth); + + return ( +
+ + {children(width)} + + width: {width}px + + setWidth(v as number)} + aria-label="Wrapper width" + /> +
+ ); +}; + +export const Default: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const Positions: Story = { + render: (args) => ( + + {(width) => ( + + {(['start', 'center', 'end'] as const).map((position) => ( + + + position="{position}" + +
+ +
+
+ ))} +
+ )} +
+ ), +}; + +export const WithSeparator: Story = { + args: { + children: 'path/to/some/deeply/nested/folder/file-name.tsx', + separator: '/', + offsetStart: 1, + offsetEnd: 1, + position: 'center', + }, + render: (args) => ( + + {(width) => ( +
+ +
+ )} +
+ ), +}; + +export const WithMultipleSeparators: Story = { + args: { + children: 'src.components.EllipsisText/EllipsisText-tsx', + separator: ['.', '/', '-'], + offsetStart: 1, + offsetEnd: 1, + position: 'center', + }, + render: (args) => ( + + {(width) => ( +
+ +
+ )} +
+ ), +}; diff --git a/src/components/EllipsisText/__tests__/EllipsisText.test.tsx b/src/components/EllipsisText/__tests__/EllipsisText.test.tsx new file mode 100644 index 0000000000..6847f78ac2 --- /dev/null +++ b/src/components/EllipsisText/__tests__/EllipsisText.test.tsx @@ -0,0 +1,249 @@ +import {render, screen} from '../../../../test-utils/utils'; +import {EllipsisText} from '../EllipsisText'; + +const LONG_TEXT = 'a-very-long-long-text-that-should-be-truncated-in-somewhere.tar.gz'; + +// General center-truncation layout is covered by visual tests; focused cases mock widths below. +describe('EllipsisText', () => { + test.each(['start', 'end'] as const)('exposes the full text with position %s', (position) => { + render( + , + ); + + expect(screen.getByTestId('ellipsis')).not.toHaveAttribute('aria-label'); + expect(screen.getByRole('button')).toHaveAccessibleName(LONG_TEXT); + }); + + test('applies className and style to the root element', () => { + render( + + {LONG_TEXT} + , + ); + + const root = screen.getByTestId('ellipsis'); + expect(root).toHaveClass('custom'); + expect(root).toHaveStyle({color: 'red'}); + }); + + test.each(['start', 'end'] as const)( + 'keeps unmodified text for copying with position %s', + (position) => { + render( + + {LONG_TEXT} + , + ); + + expect( + screen.getByText(LONG_TEXT, { + selector: '.g-ellipsis-text__ellipsis-content bdi', + }), + ).toBeInTheDocument(); + }, + ); + + describe('offsets', () => { + test.each([ + {text: 'a👍b', offsetStart: 0, offsetEnd: 2, start: '', middle: 'a', end: '👍b'}, + {text: '👍🏽abc👨‍👩‍👧‍👦', offsetStart: 1, offsetEnd: 1, start: '👍🏽', middle: 'abc', end: '👨‍👩‍👧‍👦'}, + ])('keeps Unicode characters intact in $text', ({text, start, middle, end, ...offsets}) => { + render({text}); + + if (start) { + expect(screen.getByText(start)).toBeInTheDocument(); + } + expect(screen.getByText(middle, {selector: 'bdi'})).toBeInTheDocument(); + expect(screen.getByText(end)).toBeInTheDocument(); + }); + + test('preserves surrogate pairs without Intl.Segmenter', () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const descriptor = Object.getOwnPropertyDescriptor(Intl, 'Segmenter')!; + Object.defineProperty(Intl, 'Segmenter', {value: undefined, configurable: true}); + try { + render(a👍b); + expect(screen.getByText('a', {selector: 'bdi'})).toBeInTheDocument(); + expect(screen.getByText('👍b')).toBeInTheDocument(); + } finally { + Object.defineProperty(Intl, 'Segmenter', descriptor); + } + }); + + test.each([ + {text: '123456', offsetStart: 4, offsetEnd: 4, separator: ''}, + {text: '123456', offsetStart: 10, offsetEnd: 10, separator: ''}, + {text: '123456', offsetStart: 0, offsetEnd: 10, separator: ''}, + {text: '', offsetStart: 2, offsetEnd: 2, separator: ''}, + ])('does not duplicate text for $text with offsets $offsetStart/$offsetEnd', (props) => { + for (const position of ['start', 'end'] as const) { + const {unmount} = render( + + {props.text} + , + ); + + // The root contains one accessible copy and one visual copy of the full text. + expect(screen.getByTestId('ellipsis').textContent).toBe(props.text.repeat(2)); + unmount(); + } + }); + + test('keeps offsetStart/offsetEnd characters untruncated at the edges', () => { + render( + + 1234567890 + , + ); + + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('890')).toBeInTheDocument(); + }); + }); + + test('updates center truncation without splitting Unicode characters', () => { + const measure = jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + return { + width: this.classList.contains('g-ellipsis-text__ellipsis-measure') + ? Array.from(this.textContent || '').length * 10 + : 50, + } as DOMRect; + }); + try { + const {rerender} = render( + {'👍🏽abcdefe\u0301'}, + ); + expect( + screen.getByText('👍🏽…e\u0301', {selector: '.g-ellipsis-text__ellipsis-content'}), + ).toBeInTheDocument(); + + rerender(abcdef); + expect( + screen.getByText('ab…ef', {selector: '.g-ellipsis-text__ellipsis-content'}), + ).toBeInTheDocument(); + + rerender( + + abcdef + , + ); + expect( + screen.getByText('abcd…', {selector: '.g-ellipsis-text__ellipsis-content'}), + ).toBeInTheDocument(); + } finally { + measure.mockRestore(); + } + }); + + describe('separator', () => { + test('keeps fully protected text unchanged in a narrow center container', () => { + const text = 'a/b/c'; + const measure = jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + return { + width: this.classList.contains('g-ellipsis-text__ellipsis-measure') + ? 100 + : 1, + } as DOMRect; + }); + try { + render( + + {text} + , + ); + expect( + screen.getByText(text, {selector: '.g-ellipsis-text__ellipsis-content'}), + ).toBeInTheDocument(); + } finally { + measure.mockRestore(); + } + }); + + test.each([ + {text: 'hello', offsetStart: 1, offsetEnd: 0}, + {text: 'hello', offsetStart: 0, offsetEnd: 1}, + {text: 'a/b/c', offsetStart: 3, offsetEnd: 0}, + {text: 'a/b/c', offsetStart: 0, offsetEnd: 10}, + {text: 'a/b/c', offsetStart: 1, offsetEnd: 2}, + {text: 'a/b/c', offsetStart: 2, offsetEnd: 2}, + ])( + 'preserves all parts of $text with offsets $offsetStart/$offsetEnd', + ({text, ...offsets}) => { + render( + + {text} + , + ); + + expect(screen.getByText('', {selector: 'bdi'})).toBeEmptyDOMElement(); + // One accessible copy and one visual copy, without missing or duplicated characters. + expect(screen.getByTestId('ellipsis').textContent).toBe(text.repeat(2)); + }, + ); + + test.each([ + { + separator: '::', + text: 'left:part::middle::right', + start: 'left:part', + middle: '::middle::', + end: 'right', + }, + { + separator: ['', '::', '->'], + text: 'left::middle->right', + start: 'left', + middle: '::middle->', + end: 'right', + }, + { + separator: '😀', + text: 'left😀middle😀right', + start: 'left', + middle: '😀middle😀', + end: 'right', + }, + { + separator: ['-', '->'], + text: 'left->middle->right', + start: 'left', + middle: '->middle->', + end: 'right', + }, + {separator: '::', text: 'a:::b:::c', start: 'a', middle: ':::b::', end: ':c'}, + {separator: [''], text: 'abc', start: 'a', middle: 'b', end: 'c'}, + ])( + 'matches complete tokens in $text with $separator', + ({separator, text, start, middle, end}) => { + render( + + {text} + , + ); + + expect(screen.getByText(start)).toBeInTheDocument(); + expect(screen.getByText(end)).toBeInTheDocument(); + expect(screen.getByText(middle, {selector: 'bdi'})).toBeInTheDocument(); + }, + ); + + test('counts offsets in separator-delimited parts', () => { + render( + + path/to/some/file.tsx + , + ); + + expect(screen.getByText('path')).toBeInTheDocument(); + expect(screen.getByText('file.tsx')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/EllipsisText/__tests__/EllipsisText.visual.test.tsx b/src/components/EllipsisText/__tests__/EllipsisText.visual.test.tsx new file mode 100644 index 0000000000..141f688316 --- /dev/null +++ b/src/components/EllipsisText/__tests__/EllipsisText.visual.test.tsx @@ -0,0 +1,197 @@ +import {createSmokeScenarios} from '@gravity-ui/playwright-tools/component-tests'; + +import {expect, test} from '~playwright/core'; + +import type {EllipsisTextProps} from '../EllipsisText'; +import {EllipsisText} from '../EllipsisText'; + +import {offsetEndCases, offsetStartCases, positionCases, separatorCases} from './cases'; +import {TestEllipsisText} from './helpers'; +import {EllipsisTextStories} from './stories'; + +test.describe('EllipsisText', {tag: '@EllipsisText'}, () => { + test.describe('behavior', () => { + // Clipboard contents are shared across browser contexts. + test.describe.configure({mode: 'serial'}); + + test('recalculates center truncation after width and text changes', async ({ + mount, + page, + }) => { + const text = 'a-very-long-file-name-with-many-parts-and-an-extension.txt'; + const component = await mount( +
+ {text} +
, + ); + await page.evaluate(() => document.fonts.ready); + const content = component.locator('.g-ellipsis-text__ellipsis-content'); + await expect(content).toContainText('…'); + const wideText = await content.innerText(); + + await component.update( +
+ {text} +
, + ); + await expect + .poll(async () => (await content.innerText()).length) + .toBeLessThan(wideText.length); + + await component.update( +
+ {text} +
, + ); + await expect(content).toHaveText(wideText); + + await component.update( +
+ short +
, + ); + await expect(content).toHaveText('short'); + + await component.update( +
+ + new-name-with-a-different-length.json + +
, + ); + await expect(content).toHaveText(/^new.*….*json$/); + await expect + .poll(() => + content.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + return range.getBoundingClientRect().width; + }), + ) + .toBeLessThanOrEqual(101); + }); + + test('start truncation follows nested direction overrides', async ({mount}) => { + const component = await mount( +
+
+ a-very-long-file-name.txt +
+
, + ); + const content = component.locator('.g-ellipsis-text__ellipsis-content'); + await expect(content).toHaveCSS('direction', 'rtl'); + await expect(content).toHaveCSS('text-overflow', 'ellipsis'); + + await component.update( +
+
+ a-very-long-file-name.txt +
+
, + ); + await expect(content).toHaveCSS('direction', 'ltr'); + }); + + for (const position of ['start', 'center', 'end'] as const) { + test(`copies full and partial text with position ${position}`, async ({ + mount, + page, + context, + }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const text = 'prefix-a-very-long-file-name-that-will-be-truncated.txt'; + const component = await mount( +
+ + {text} + +
, + ); + if (position === 'center') { + await expect( + component.locator('.g-ellipsis-text__ellipsis-content'), + ).toContainText('…'); + } + + await component.locator('.g-ellipsis-text').evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + }); + await page.keyboard.press('ControlOrMeta+c'); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(text); + + const partialText = await component + .locator('.g-ellipsis-text__ellipsis-content') + .evaluate((element) => { + const range = document.createRange(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = document + .createTreeWalker(element, NodeFilter.SHOW_TEXT) + .nextNode()!; + range.setStart(node, 0); + range.setEnd(node, 2); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + return selection.toString(); + }); + await page.keyboard.press('ControlOrMeta+c'); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(partialText); + }); + } + }); + + test('render story: ', async ({mount, expectScreenshot}) => { + const component = await mount(); + await expectScreenshot({locator: component}); + }); + + test('render story: ', async ({mount, expectScreenshot}) => { + const component = await mount(); + await expectScreenshot({locator: component}); + }); + + test('render story: ', async ({mount, expectScreenshot}) => { + const component = await mount(); + await expectScreenshot({locator: component}); + }); + + test('smoke', {tag: ['@smoke']}, async ({mount, expectScreenshot}) => { + const smokeScenarios = createSmokeScenarios>( + {}, + { + position: positionCases, + offsetStart: offsetStartCases, + offsetEnd: offsetEndCases, + separator: separatorCases, + }, + ); + + await mount( +
+ {smokeScenarios.map(([title, props]) => ( +
+

{title}

+
+ +
+
+ ))} +
, + ); + + await expectScreenshot({ + themes: ['light'], + }); + }); +}); diff --git a/src/components/EllipsisText/__tests__/cases.tsx b/src/components/EllipsisText/__tests__/cases.tsx new file mode 100644 index 0000000000..f27ca74c91 --- /dev/null +++ b/src/components/EllipsisText/__tests__/cases.tsx @@ -0,0 +1,14 @@ +import type {Cases, CasesWithName} from '@gravity-ui/playwright-tools/component-tests'; + +import type {EllipsisTextProps} from '../EllipsisText'; + +export const positionCases: Cases = ['start', 'center', 'end']; + +export const offsetStartCases: Cases = [0, 4]; +export const offsetEndCases: Cases = [0, 7]; + +export const separatorCases: CasesWithName = [ + ['none', ''], + ['slash', '/'], + ['multiple', ['.', '/', '-']], +]; diff --git a/src/components/EllipsisText/__tests__/helpers.tsx b/src/components/EllipsisText/__tests__/helpers.tsx new file mode 100644 index 0000000000..8a3e04403e --- /dev/null +++ b/src/components/EllipsisText/__tests__/helpers.tsx @@ -0,0 +1,17 @@ +import {EllipsisText} from '../EllipsisText'; +import type {EllipsisTextProps} from '../EllipsisText'; + +const wrapDivStyles = {width: '200px', display: 'grid'} as const; + +const DEFAULT_TEXT = 'a-very-long-long-text-that-should-be-truncated-in-somewhere.tar.gz'; + +export const TestEllipsisText = ({ + children = DEFAULT_TEXT, + ...props +}: Partial) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/EllipsisText/__tests__/stories.ts b/src/components/EllipsisText/__tests__/stories.ts new file mode 100644 index 0000000000..5cfef674f9 --- /dev/null +++ b/src/components/EllipsisText/__tests__/stories.ts @@ -0,0 +1,5 @@ +import {composeStories} from '@storybook/react-webpack5'; + +import * as Stories from '../__stories__/EllipsisText.stories'; + +export const EllipsisTextStories = composeStories(Stories); diff --git a/src/components/EllipsisText/hooks.ts b/src/components/EllipsisText/hooks.ts new file mode 100644 index 0000000000..7b00d9bcf3 --- /dev/null +++ b/src/components/EllipsisText/hooks.ts @@ -0,0 +1,140 @@ +import * as React from 'react'; + +import {useLayoutEffect, useResizeObserver} from '../../hooks'; + +import {splitText} from './utils'; + +interface ResizeParams { + text: string; + startOffset: string; + endOffset: string; + setVisibleText: (text: string) => void; + containerRef: React.RefObject; + measureRef: React.RefObject; +} + +const ELLIPSIS_CHAR = '\u2026'; + +const getElementWidth = (element: HTMLElement) => element.getBoundingClientRect().width; + +const calculateCollapsedText = ( + availableSpace: number, + {text, startOffset, endOffset, measureRef}: ResizeParams, +): string | null => { + const measure = measureRef.current; + + if (!measure) { + return null; + } + + const collapsibleStartIndex = startOffset.length; + const collapsibleEndIndex = -endOffset.length || text.length; + + const collapsibleText = splitText(text.slice(collapsibleStartIndex, collapsibleEndIndex)); + + let minCharacters = 0; + let maxCharacters = collapsibleText.length; + let result = startOffset + ELLIPSIS_CHAR + endOffset; + + let currentLength: number; + let start: string; + let end: string; + let currentResult: string; + while (minCharacters <= maxCharacters) { + currentLength = Math.floor((minCharacters + maxCharacters) * 0.5); + + start = collapsibleText.slice(0, Math.ceil(currentLength * 0.5)).join(''); + end = collapsibleText + .slice(collapsibleText.length - Math.floor(currentLength * 0.5)) + .join(''); + + currentResult = startOffset + start + ELLIPSIS_CHAR + end + endOffset; + + measure.textContent = currentResult; + if (getElementWidth(measure) <= availableSpace) { + result = currentResult; + minCharacters = currentLength + 1; + } else { + maxCharacters = currentLength - 1; + } + } + + // Measure is observed only for font changes, so we keep the text the same + measure.textContent = text; + + return result; +}; + +const handleResize = (params: ResizeParams) => { + const {text, setVisibleText, containerRef, measureRef} = params; + const container = containerRef.current; + const measure = measureRef.current; + + if (!container || !measure) { + return; + } + + const availableSpace = getElementWidth(container); + if (availableSpace <= 0) { + return; + } + + measure.textContent = text; + if ( + params.startOffset.length + params.endOffset.length >= text.length || + getElementWidth(measure) <= availableSpace + ) { + setVisibleText(text); + return; + } + + const collapsedText = calculateCollapsedText(availableSpace, params); + + if (collapsedText === null) { + return; + } + + setVisibleText(collapsedText); +}; + +const makeOnResize = (params: ResizeParams) => () => { + handleResize(params); +}; + +export interface CenterEllipsisTextProps { + text: string; + startOffset?: string; + endOffset?: string; +} + +export const useCenterEllipsisText = ({ + text, + startOffset = '', + endOffset = '', +}: CenterEllipsisTextProps) => { + const containerRef = React.useRef(null); + const measureRef = React.useRef(null); + + const [visibleText, setVisibleText] = React.useState(text); + + const onResize = React.useMemo( + () => + makeOnResize({ + text, + startOffset, + endOffset, + setVisibleText, + measureRef, + containerRef, + }), + [text, startOffset, endOffset, setVisibleText], + ); + + useLayoutEffect(onResize, [onResize]); + + useResizeObserver({ref: containerRef, onResize}); + // The measurement element also resizes when fonts change. + useResizeObserver({ref: measureRef, onResize}); + + return {visibleText, containerRef, measureRef}; +}; diff --git a/src/components/EllipsisText/index.ts b/src/components/EllipsisText/index.ts new file mode 100644 index 0000000000..a512590f7d --- /dev/null +++ b/src/components/EllipsisText/index.ts @@ -0,0 +1 @@ +export * from './EllipsisText'; diff --git a/src/components/EllipsisText/utils.ts b/src/components/EllipsisText/utils.ts new file mode 100644 index 0000000000..366e49afb3 --- /dev/null +++ b/src/components/EllipsisText/utils.ts @@ -0,0 +1,10 @@ +let segmenter: Intl.Segmenter | undefined; + +export function splitText(text: string): string[] { + if (typeof Intl.Segmenter !== 'function') { + return Array.from(text); + } + + segmenter ??= new Intl.Segmenter(undefined, {granularity: 'grapheme'}); + return Array.from(segmenter.segment(text), ({segment}) => segment); +} diff --git a/src/components/index.ts b/src/components/index.ts index 30eef4effe..055b6c6fe5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -22,6 +22,7 @@ export * from './Disclosure'; export * from './Divider'; export * from './Drawer'; export * from './DropdownMenu'; +export * from './EllipsisText'; export * from './FilePreview'; export * from './HelpMark'; export * from './Hotkey'; diff --git a/src/components/theme/DefaultPropsProvider.tsx b/src/components/theme/DefaultPropsProvider.tsx index 117aa760ee..bbfa6d70ee 100644 --- a/src/components/theme/DefaultPropsProvider.tsx +++ b/src/components/theme/DefaultPropsProvider.tsx @@ -22,6 +22,7 @@ import type {DisclosureProps} from '../Disclosure'; import type {DividerProps} from '../Divider'; import type {DrawerProps} from '../Drawer'; import type {DropdownMenuProps} from '../DropdownMenu'; +import type {EllipsisTextProps} from '../EllipsisText'; import type {FilePreviewProps} from '../FilePreview'; import type {HelpMarkProps} from '../HelpMark'; import type {HotkeyProps} from '../Hotkey'; @@ -88,6 +89,7 @@ export interface ComponentDefaultPropsMap { Divider?: Partial; Drawer?: Partial; DropdownMenu?: Partial>; + EllipsisText?: Partial; FilePreview?: Partial; HelpMark?: Partial; Hotkey?: Partial; diff --git a/src/hooks/useResizeObserver/useResizeObserver.ts b/src/hooks/useResizeObserver/useResizeObserver.ts index 76826e3170..3322584cd1 100644 --- a/src/hooks/useResizeObserver/useResizeObserver.ts +++ b/src/hooks/useResizeObserver/useResizeObserver.ts @@ -43,5 +43,5 @@ export function useResizeObserver({ return () => { observer.disconnect(); }; - }, [ref, onResize, box]); + }, [onResize, box, ref]); }