Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/feat-cui-283-text-truncate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clickhouse/click-ui': minor
---

Add `TextTruncate`, which truncates a single line of text with an ellipsis at the end or in the middle (`ellipsisPosition="middle"` keeps file extensions and ID suffixes visible). While the text is cut off, the full text shows in a tooltip on hover, and the text becomes a tab stop that shows it on keyboard focus. Inside a button, link, tab, menu item or option it adds no tab stop, so the tooltip is hover-only there. Supports `maxWidth`, `showTooltip={false}` for large lists, a custom `tooltipContent`, and a polymorphic `component`. It warns in the console when `children` holds a link or button and no `tooltipContent` is given.

Add the `useIsTruncated` hook, which tells whether an element's single line of text is cut off and re-measures on resize and content changes.

`EllipsisContent` and `EllipsisContentProps` are deprecated in favor of `TextTruncate`. They keep working unchanged. The internal `MiddleTruncator` behind `Table`'s `overflowMode="truncate-middle"` and the `FileUpload` file name is deprecated too; both keep their current look and behavior for now.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,11 @@
"import": "./dist/esm/components/TextField/index.js",
"require": "./dist/cjs/components/TextField/index.cjs"
},
"./TextTruncate": {
"types": "./dist/types/components/TextTruncate/index.d.ts",
"import": "./dist/esm/components/TextTruncate/index.js",
"require": "./dist/cjs/components/TextTruncate/index.cjs"
},
"./Title": {
"types": "./dist/types/components/Title/index.d.ts",
"import": "./dist/esm/components/Title/index.js",
Expand Down
8 changes: 8 additions & 0 deletions src/components/EllipsisContent/EllipsisContent.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ const meta: Meta<typeof EllipsisContent> = {
component: EllipsisContent,
title: 'Display/EllipsisContent',
tags: ['ellipsis-content', 'autodocs'],
parameters: {
docs: {
description: {
component:
'**Deprecated:** superseded by `TextTruncate`, which adds keyboard access, a hoverable tooltip, live re-measuring and middle truncation. `EllipsisContent` keeps working unchanged and will be removed in a future version.',
},
},
},
// EllipsisContent fills its parent's width and truncates overflowing text with
// an ellipsis. The decorator constrains the available width so the truncation
// (or lack of it) is measurable in the snapshot.
Expand Down
3 changes: 3 additions & 0 deletions src/components/EllipsisContent/EllipsisContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ const EllipsisContentComponent = <T extends ElementType = 'div'>(
);
};

/**
* @deprecated Use `TextTruncate` instead. This component will be removed in a future version.
*/
export const EllipsisContent: EllipsisPolymorphicComponent = forwardRef(
EllipsisContentComponent
);
3 changes: 3 additions & 0 deletions src/components/EllipsisContent/EllipsisContent.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ElementType } from 'react';
import type { TooltipContentProps } from '@/components/Tooltip/Tooltip.types';

/**
* @deprecated Use `TextTruncateProps` instead. This type will be removed in a future version.
*/
export interface EllipsisContentProps<T extends ElementType = 'div'> {
component?: T;
/**
Expand Down
8 changes: 8 additions & 0 deletions src/components/MiddleTruncator/MiddleTruncator.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ const meta: Meta<StoryArgs> = {
component: MiddleTruncator,
title: 'Display/MiddleTruncator',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'**Deprecated:** superseded by `TextTruncate` with `ellipsisPosition="middle"`. Internal component, still used by `Table` (`overflowMode="truncate-middle"`) and `FileUpload`; it will be removed in a future version.',
},
},
},
argTypes: {
text: {
control: { type: 'text' },
Expand Down
3 changes: 3 additions & 0 deletions src/components/MiddleTruncator/MiddleTruncator.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import styles from './MiddleTruncator.module.css';

/**
* @deprecated Use `<TextTruncate ellipsisPosition="middle">` instead. This component will be removed in a future version.
*/
export const MiddleTruncator = ({
text,
trailingChars = 10,
Expand Down
66 changes: 66 additions & 0 deletions src/components/TextTruncate/TextTruncate.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
.text-truncate {
--text-truncate-max-width: initial;

min-width: 0;
max-width: var(--text-truncate-max-width, 100%);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

/* Doubled so `display` beats the base class of a `component` such as Link, whatever the bundle order. */
.text-truncate.text-truncate {
display: block;
}

.text-truncate_pos-middle.text-truncate_pos-middle {
display: flex;
}

/* Isolated so the full-text copy at z-index -1 stays above the backgrounds of ancestors. */
.text-truncate_split {
position: relative;
isolation: isolate;
}

/* Inset so ancestors with `overflow: hidden` cannot clip the ring. */
.text-truncate:focus-visible {
outline: 2px solid var(--click-global-color-outline-default);
outline-offset: -2px;
}

.text-truncate__start {
overflow: hidden;
flex-shrink: 1;
text-overflow: ellipsis;
}

.text-truncate__end {
max-width: 100%;
overflow: hidden;
flex-shrink: 0;
text-overflow: ellipsis;
}

.text-truncate_split .text-truncate__start,
.text-truncate_split .text-truncate__end {
pointer-events: none;
/* stylelint-disable-next-line property-no-vendor-prefix -- Safari still needs the prefix and the build has no autoprefixer */
-webkit-user-select: none;
user-select: none;
}

/* Full text behind the halves: what screen readers read and what a click, triple-click or copy selects. */
.text-truncate__label {
position: absolute;
z-index: -1;
inset: 0;
overflow: hidden;
color: transparent;
forced-color-adjust: none;
}

/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- iOS Safari ignores `::selection` and draws its own selection UI */
.text-truncate__label::selection {
background-color: highlight;
}
214 changes: 214 additions & 0 deletions src/components/TextTruncate/TextTruncate.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { Meta, StoryObj } from '@storybook/react-vite';
import { Badge } from '@/components/Badge';
import { Container } from '@/components/Container';
import { Icon } from '@/components/Icon';
import { IconButton } from '@/components/IconButton';
import { Link } from '@/components/Link';
import { Text } from '@/components/Text';
import { TextTruncate } from '@/components/TextTruncate';

const LONG_TEXT = 'This is a very long line of text that should be truncated';
const FILE_NAME = 'console.clickhouse.cloud_Archive.01-01-1975.lorem-ipsum-01.csv';

const meta: Meta<typeof TextTruncate> = {
component: TextTruncate,
title: 'Display/TextTruncate',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Truncates a single line of text with an ellipsis at the end or in the middle. While the text is cut off, it shows the full text in a tooltip on hover, and becomes a tab stop that shows it on keyboard focus. Inside a button, link, tab, menu item or option it adds no tab stop of its own, so the tooltip is hover-only there. Replaces `EllipsisContent` and `MiddleTruncator`.',
},
},
},
argTypes: {
ellipsisPosition: { control: 'inline-radio', options: ['end', 'middle'] },
trailingChars: { control: 'number' },
maxWidth: { control: 'text' },
showTooltip: { control: 'boolean' },
},
decorators: [
Story => (
<div
data-testid="text-truncate-harness"
style={{
width: '200px',
padding: '8px',
border: '1px dashed var(--click-global-color-stroke-default)',
color: 'var(--click-global-color-text-default)',
font: 'var(--typography-styles-product-text-normal-md)',
}}
>
<Story />
</div>
),
],
};

export default meta;

type Story = StoryObj<typeof TextTruncate>;

export const Playground: Story = {
args: {
children: LONG_TEXT,
ellipsisPosition: 'end',
showTooltip: true,
},
};

export const Truncated: Story = {
args: { children: LONG_TEXT },
};

export const NotTruncated: Story = {
args: { children: 'Short text' },
parameters: {
docs: {
description: {
story: 'Text that fits has no tooltip and is not a tab stop.',
},
},
},
};

export const Middle: Story = {
args: { ellipsisPosition: 'middle', children: FILE_NAME },
parameters: {
docs: {
description: {
story:
'Keeps the end of the text visible: file names, IDs, host names, paths. Text is not split when the kept end would be longer than the rest. In a box narrower than the kept end, the end gets its own ellipsis; use `ellipsisPosition="end"` or fewer `trailingChars` there.',
},
},
},
};

export const MiddleTrailingChars: Story = {
args: { ellipsisPosition: 'middle', trailingChars: 14, children: FILE_NAME },
};

export const MaxWidth: Story = {
args: { maxWidth: '15ch', children: LONG_TEXT },
};

export const WithoutTooltip: Story = {
args: { showTooltip: false, children: LONG_TEXT },
parameters: {
docs: {
description: {
story:
'No measuring, tooltip or tab stop. Use it in large lists and in grid cells, where the grid owns keyboard focus.',
},
},
},
};

export const TooltipContent: Story = {
args: {
tooltipContent: 'Owned by the data-platform-observability team',
children: (
<>
Owned by <Link href="#team">the data-platform-observability team</Link>
</>
),
},
parameters: {
docs: {
description: {
story:
'When `children` holds links or buttons, pass plain text as `tooltipContent` so the tooltip does not copy them.',
},
},
},
};

export const TooltipOnRight: Story = {
args: { tooltipProps: { side: 'right' }, children: LONG_TEXT },
};

export const AsText: Story = {
render: () => (
<TextTruncate
component={Text}
size="sm"
weight="mono"
>
{FILE_NAME}
</TextTruncate>
),
parameters: {
docs: {
description: {
story:
'Use `component={Text}` for typography. A `<Text>` child would be a block inside the root and get clipped without an ellipsis.',
},
},
},
};

export const InFlexRow: Story = {
render: () => (
<Container
orientation="horizontal"
gap="xs"
>
<Icon
name="table"
size="sm"
aria-hidden
/>
<TextTruncate style={{ flex: 1 }}>{LONG_TEXT}</TextTruncate>
<IconButton
icon="dots-vertical"
size="sm"
type="ghost"
aria-label="More actions"
/>
</Container>
),
parameters: {
docs: {
description: {
story:
'Truncates as a flex item without extra props. Pass `style={{ flex: 1 }}` or a `className` to push the next item to the end of the row.',
},
},
},
};

export const InBadge: Story = {
render: () => (
<Badge
ellipsisContent={false}
text={<TextTruncate maxWidth="12ch">{FILE_NAME}</TextTruncate>}
/>
),
parameters: {
docs: {
description: {
story: 'Pass `ellipsisContent={false}` so Badge does not truncate twice.',
},
},
},
};

export const InsideLink: Story = {
render: () => (
<Link
href="#file"
style={{ maxWidth: '100%' }}
>
<TextTruncate ellipsisPosition="middle">{FILE_NAME}</TextTruncate>
</Link>
),
parameters: {
docs: {
description: {
story:
'Inside a control the link is the only tab stop; the tooltip still opens on hover. Link and Badge are `inline-flex` and grow with their text, so cap their width (here `maxWidth: 100%`) or there is nothing to truncate against.',
},
},
},
};
Loading
Loading