Repository navigation
feat(Ellipsis): add component #2690
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a9b6578
433b280
fc47735
9d792bb
74969cb
23ca25b
c4f86c4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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 (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| 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> | ||
| ); | ||
| }, | ||
| ); |
| 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` | | |
There was a problem hiding this comment.
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-wordandword-break: break-allcannot take effect here —white-space: nowrapon 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.