Skip to content

Commit b7456f9

Browse files
feat(Truncate): added logic to truncate based on max characters (#11742)
* feat(Truncate): added logic to truncate based on max characters * Converted existing examples to TS * Wrote tests for max chars logic * Wrapped visible content in classless spans * Updated classes based on core updates
1 parent ede98ac commit b7456f9

File tree

9 files changed

+310
-60
lines changed

9 files changed

+310
-60
lines changed

packages/react-core/src/components/Truncate/Truncate.tsx

+109-22
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,18 @@ export interface TruncateProps extends React.HTMLProps<HTMLSpanElement> {
2222
className?: string;
2323
/** Text to truncate */
2424
content: string;
25-
/** The number of characters displayed in the second half of the truncation */
25+
/** The number of characters displayed in the second half of a middle truncation. This will be overridden by
26+
* the maxCharsDisplayed prop.
27+
*/
2628
trailingNumChars?: number;
29+
/** The maximum number of characters to display before truncating. This will always truncate content
30+
* when its length exceeds the value passed to this prop, and container width/resizing will not affect truncation.
31+
*/
32+
maxCharsDisplayed?: number;
33+
/** The content to use to signify omission of characters when using the maxCharsDisplayed prop.
34+
* By default this will render an ellipsis.
35+
*/
36+
omissionContent?: string;
2737
/** Where the text will be truncated */
2838
position?: 'start' | 'middle' | 'end';
2939
/** Tooltip position */
@@ -49,25 +59,33 @@ export interface TruncateProps extends React.HTMLProps<HTMLSpanElement> {
4959
refToGetParent?: React.RefObject<any>;
5060
}
5161

52-
const sliceContent = (str: string, slice: number) => [str.slice(0, str.length - slice), str.slice(-slice)];
62+
const sliceTrailingContent = (str: string, slice: number) => [str.slice(0, str.length - slice), str.slice(-slice)];
5363

5464
export const Truncate: React.FunctionComponent<TruncateProps> = ({
5565
className,
5666
position = 'end',
5767
tooltipPosition = 'top',
5868
trailingNumChars = 7,
69+
maxCharsDisplayed,
70+
omissionContent = '\u2026',
5971
content,
6072
refToGetParent,
6173
...props
6274
}: TruncateProps) => {
6375
const [isTruncated, setIsTruncated] = useState(true);
6476
const [parentElement, setParentElement] = useState<HTMLElement>(null);
6577
const [textElement, setTextElement] = useState<HTMLElement>(null);
78+
const [shouldRenderByMaxChars, setShouldRenderByMaxChars] = useState(maxCharsDisplayed > 0);
6679

6780
const textRef = useRef<HTMLElement>(null);
6881
const subParentRef = useRef<HTMLDivElement>(null);
6982
const observer = useRef(null);
7083

84+
if (maxCharsDisplayed <= 0) {
85+
// eslint-disable-next-line no-console
86+
console.warn('Truncate: the maxCharsDisplayed must be greater than 0, otherwise no content will be visible.');
87+
}
88+
7189
const getActualWidth = (element: Element) => {
7290
const computedStyle = getComputedStyle(element);
7391

@@ -100,7 +118,7 @@ export const Truncate: React.FunctionComponent<TruncateProps> = ({
100118
}, [textRef, subParentRef, textElement, parentElement]);
101119

102120
useEffect(() => {
103-
if (textElement && parentElement && !observer.current) {
121+
if (textElement && parentElement && !observer.current && !shouldRenderByMaxChars) {
104122
const totalTextWidth = calculateTotalTextWidth(textElement, trailingNumChars, content);
105123
const textWidth = position === 'middle' ? totalTextWidth : textElement.scrollWidth;
106124

@@ -115,31 +133,100 @@ export const Truncate: React.FunctionComponent<TruncateProps> = ({
115133
observer();
116134
};
117135
}
118-
}, [textElement, parentElement, trailingNumChars, content, position]);
136+
}, [textElement, parentElement, trailingNumChars, content, position, shouldRenderByMaxChars]);
119137

120-
const truncateBody = (
121-
<span ref={subParentRef} className={css(styles.truncate, className)} {...props}>
122-
{(position === TruncatePosition.end || position === TruncatePosition.start) && (
123-
<span ref={textRef} className={truncateStyles[position]}>
124-
{content}
125-
{position === TruncatePosition.start && <Fragment>&lrm;</Fragment>}
126-
</span>
127-
)}
128-
{position === TruncatePosition.middle && content.length - trailingNumChars > minWidthCharacters && (
129-
<Fragment>
130-
<span ref={textRef} className={styles.truncateStart}>
131-
{sliceContent(content, trailingNumChars)[0]}
138+
useEffect(() => {
139+
if (shouldRenderByMaxChars) {
140+
setIsTruncated(content.length > maxCharsDisplayed);
141+
}
142+
}, [shouldRenderByMaxChars]);
143+
144+
useEffect(() => {
145+
setShouldRenderByMaxChars(maxCharsDisplayed > 0);
146+
}, [maxCharsDisplayed]);
147+
148+
const renderResizeObserverContent = () => {
149+
if (position === TruncatePosition.end || position === TruncatePosition.start) {
150+
return (
151+
<>
152+
<span ref={textRef} className={truncateStyles[position]}>
153+
{content}
154+
{position === TruncatePosition.start && <Fragment>&lrm;</Fragment>}
132155
</span>
133-
<span className={styles.truncateEnd}>{sliceContent(content, trailingNumChars)[1]}</span>
134-
</Fragment>
135-
)}
136-
{position === TruncatePosition.middle && content.length - trailingNumChars <= minWidthCharacters && (
156+
</>
157+
);
158+
}
159+
160+
const shouldSliceContent = content.length - trailingNumChars > minWidthCharacters;
161+
return (
162+
<>
137163
<Fragment>
138164
<span ref={textRef} className={styles.truncateStart}>
139-
{content}
165+
{shouldSliceContent ? sliceTrailingContent(content, trailingNumChars)[0] : content}
140166
</span>
167+
{shouldSliceContent && (
168+
<span className={styles.truncateEnd}>{sliceTrailingContent(content, trailingNumChars)[1]}</span>
169+
)}
141170
</Fragment>
142-
)}
171+
</>
172+
);
173+
};
174+
175+
const renderMaxDisplayContent = () => {
176+
const renderVisibleContent = (contentToRender: string) => (
177+
<span className={`${styles.truncate}__text`}>{contentToRender}</span>
178+
);
179+
if (!isTruncated) {
180+
return renderVisibleContent(content);
181+
}
182+
183+
const omissionElement = (
184+
<span className={`${styles.truncate}__omission`} aria-hidden="true">
185+
{omissionContent}
186+
</span>
187+
);
188+
const renderVisuallyHiddenContent = (contentToHide: string) => (
189+
<span className="pf-v6-screen-reader">{contentToHide}</span>
190+
);
191+
192+
if (position === TruncatePosition.start) {
193+
return (
194+
<>
195+
{renderVisuallyHiddenContent(content.slice(0, maxCharsDisplayed * -1))}
196+
{omissionElement}
197+
{renderVisibleContent(content.slice(maxCharsDisplayed * -1))}
198+
</>
199+
);
200+
}
201+
if (position === TruncatePosition.end) {
202+
return (
203+
<>
204+
{renderVisibleContent(content.slice(0, maxCharsDisplayed))}
205+
{omissionElement}
206+
{renderVisuallyHiddenContent(content.slice(maxCharsDisplayed))}
207+
</>
208+
);
209+
}
210+
211+
const trueMiddleStart = Math.floor(maxCharsDisplayed / 2);
212+
const trueMiddleEnd = Math.ceil(maxCharsDisplayed / 2) * -1;
213+
return (
214+
<>
215+
{renderVisibleContent(content.slice(0, trueMiddleStart))}
216+
{omissionElement}
217+
{renderVisuallyHiddenContent(content.slice(trueMiddleStart, trueMiddleEnd))}
218+
{renderVisibleContent(content.slice(trueMiddleEnd))}
219+
</>
220+
);
221+
};
222+
223+
const truncateBody = (
224+
<span
225+
ref={subParentRef}
226+
className={css(styles.truncate, shouldRenderByMaxChars && styles.modifiers.fixed, className)}
227+
{...props}
228+
>
229+
{!shouldRenderByMaxChars ? renderResizeObserverContent() : renderMaxDisplayContent()}
143230
</span>
144231
);
145232

packages/react-core/src/components/Truncate/__tests__/Truncate.test.tsx

+70-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { render, screen, within } from '@testing-library/react';
22
import { Truncate } from '../Truncate';
33
import styles from '@patternfly/react-styles/css/components/Truncate/truncate';
44
import '@testing-library/jest-dom';
@@ -24,7 +24,7 @@ test(`renders with class ${styles.truncate}`, () => {
2424

2525
const test = screen.getByLabelText('test-id');
2626

27-
expect(test).toHaveClass(styles.truncate);
27+
expect(test).toHaveClass(styles.truncate, { exact: true });
2828
});
2929

3030
test('renders with custom class name passed via prop', () => {
@@ -148,3 +148,71 @@ test('renders with inherited element props spread to the component', () => {
148148

149149
expect(screen.getByTestId('test-id')).toHaveAccessibleName('labelling-id');
150150
});
151+
152+
describe('Truncation with maxCharsDisplayed', () => {
153+
test(`Does not render with class ${styles.modifiers.fixed} when maxCharsDisplayed is 0`, () => {
154+
render(<Truncate maxCharsDisplayed={0} data-testid="truncate-component" content="Test content" />);
155+
156+
expect(screen.getByTestId('truncate-component')).not.toHaveClass(styles.modifiers.fixed);
157+
});
158+
159+
test(`Renders with class ${styles.modifiers.fixed} when maxCharsDisplayed is greater than 0`, () => {
160+
render(<Truncate maxCharsDisplayed={1} data-testid="truncate-component" content="Test content" />);
161+
162+
expect(screen.getByTestId('truncate-component')).toHaveClass(styles.modifiers.fixed);
163+
});
164+
165+
test('Renders with hidden truncated content at end by default when maxCharsDisplayed is passed', () => {
166+
render(<Truncate content="Default end position content truncated" maxCharsDisplayed={6} />);
167+
168+
expect(screen.getByText('Defaul')).toHaveClass(`${styles.truncate}__text`, { exact: true });
169+
expect(screen.getByText('t end position content truncated')).toHaveClass('pf-v6-screen-reader');
170+
});
171+
172+
test('Renders with hidden truncated content at middle position when maxCharsDisplayed is passed and position="middle"', () => {
173+
render(<Truncate position="middle" content="Middle position contents being truncated" maxCharsDisplayed={10} />);
174+
175+
expect(screen.getByText('Middl')).toHaveClass(`${styles.truncate}__text`, { exact: true });
176+
expect(screen.getByText('e position contents being trun')).toHaveClass('pf-v6-screen-reader');
177+
expect(screen.getByText('cated')).toHaveClass(`${styles.truncate}__text`, { exact: true });
178+
});
179+
180+
test('Renders with hidden truncated content at start when maxCharsDisplayed is passed and position="start"', () => {
181+
render(<Truncate position="start" content="Start position content truncated" maxCharsDisplayed={6} />);
182+
183+
expect(screen.getByText('Start position content tru')).toHaveClass('pf-v6-screen-reader');
184+
expect(screen.getByText('ncated')).toHaveClass(`${styles.truncate}__text`, { exact: true });
185+
});
186+
187+
test('Renders full content when maxCharsDisplayed exceeds the length of the content', () => {
188+
render(<Truncate content="This full content is rendered" maxCharsDisplayed={90} />);
189+
190+
expect(screen.getByText('This full content is rendered')).toHaveClass(`${styles.truncate}__text`, { exact: true });
191+
});
192+
193+
test('Renders ellipsis as omission content by default', () => {
194+
render(<Truncate content="Test truncation content" maxCharsDisplayed={5} />);
195+
196+
expect(screen.getByText('\u2026')).toHaveClass(`${styles.truncate}__omission`, { exact: true });
197+
expect(screen.getByText('\u2026')).toHaveAttribute('aria-hidden', 'true');
198+
});
199+
200+
test('Renders custom omission content when omissionContent is passed', () => {
201+
render(<Truncate omissionContent="---" content="Test truncation content" maxCharsDisplayed={5} />);
202+
203+
expect(screen.getByText('---')).toHaveClass(`${styles.truncate}__omission`, { exact: true });
204+
expect(screen.getByText('---')).toHaveAttribute('aria-hidden', 'true');
205+
});
206+
207+
test('Does not render omission content when maxCharsDisplayed exceeds the length of the content ', () => {
208+
render(<Truncate content="Test truncation content" maxCharsDisplayed={99} />);
209+
210+
expect(screen.queryByText('\u2026')).not.toBeInTheDocument();
211+
});
212+
213+
test('Matches snapshot with default position', () => {
214+
const { asFragment } = render(<Truncate content="Test truncation content" maxCharsDisplayed={3} />);
215+
216+
expect(asFragment()).toMatchSnapshot();
217+
});
218+
});

packages/react-core/src/components/Truncate/__tests__/__snapshots__/Truncate.test.tsx.snap

+37
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,42 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`Truncation with maxCharsDisplayed Matches snapshot with default position 1`] = `
4+
<DocumentFragment>
5+
<div
6+
data-testid="Tooltip-mock"
7+
>
8+
<div
9+
data-testid="Tooltip-mock-content-container"
10+
>
11+
Test Test truncation content
12+
</div>
13+
<p>
14+
position: top
15+
</p>
16+
<span
17+
class="pf-v6-c-truncate pf-m-fixed"
18+
>
19+
<span
20+
class="pf-v6-c-truncate__text"
21+
>
22+
Tes
23+
</span>
24+
<span
25+
aria-hidden="true"
26+
class="pf-v6-c-truncate__omission"
27+
>
28+
29+
</span>
30+
<span
31+
class="pf-v6-screen-reader"
32+
>
33+
t truncation content
34+
</span>
35+
</span>
36+
</div>
37+
</DocumentFragment>
38+
`;
39+
340
exports[`renders default truncation 1`] = `
441
<DocumentFragment>
542
<div

packages/react-core/src/components/Truncate/examples/Truncate.md

+32-36
Original file line numberDiff line numberDiff line change
@@ -9,50 +9,46 @@ import './TruncateExamples.css';
99

1010
## Examples
1111

12+
The default behavior of the `Truncate` component is to truncate based on whether the content can fit within the width of its parent container, and to prevent text from wrapping. The following examples that use this default behavior render the `<Truncate>` component inside a resizable container, allowing you to see how the parent container width affects the truncation.
13+
1214
### Default
13-
```js
14-
import { Truncate } from '@patternfly/react-core';
15-
16-
<div className="truncate-example-resize">
17-
<Truncate
18-
content={'Vestibulum interdum risus et enim faucibus, sit amet molestie est accumsan.'}
19-
/>
20-
</div>
15+
16+
By default content will be truncated at its end when it cannot fit entirely inside its parent container.
17+
18+
```ts file="./TruncateDefault.tsx"
19+
2120
```
2221

2322
### Middle
24-
```js
25-
import { Truncate } from '@patternfly/react-core';
26-
27-
<div className="truncate-example-resize">
28-
<Truncate
29-
content={'redhat_logo_black_and_white_reversed_simple_with_fedora_container.zip'}
30-
trailingNumChars={10}
31-
position={'middle'}
32-
/>
33-
</div>
23+
24+
When passing a `position` property with a value of "middle", the position of the truncation will change based on the parent container's width and the amount of `trailingNumChars` passed in. The `trailingNumChars` will always be displayed, while the rest of the content will be truncated based on the parent container width.
25+
26+
```ts file="./TruncateMiddle.tsx"
27+
3428
```
3529

3630
### Start
37-
```js
38-
import { Truncate } from '@patternfly/react-core';
39-
40-
<div className="truncate-example-resize">
41-
<Truncate
42-
content={'Vestibulum interdum risus et enim faucibus, sit amet molestie est accumsan.'}
43-
position={'start'}
44-
/>
45-
</div>
31+
32+
You can truncate content at its start by passing the `position` property with a value of "start". This can be useful if you have several strings to truncate that have similar text at the start, but unique text at the end that you want to have visible.
33+
34+
```ts file="./TruncateStart.tsx"
35+
4636
```
4737

48-
### Default with tooltip at the bottom
49-
```js
50-
import { Truncate } from '@patternfly/react-core';
38+
### With custom tooltip position
39+
40+
You can customize the position of the `<Tooltip>` that is rendered by passing in the `tooltipPosition` property. The following example overrides the default "top" position with a "bottom" position.
41+
42+
```ts file="./TruncateCustomTooltipPosition.tsx"
43+
44+
```
45+
46+
### Based on max characters
47+
48+
Rather than observing container width, you can have truncation be based on a maximum amount of characters that should always be displayed via the `maxCharsDisplayed` property. While the content's parent container width will not have an affect on whether truncation occurs, it will affect whether the content wraps. This property must be set to a value larger than `0`, otherwise the component will fall back to observing container width.
49+
50+
Truncating based on a maximum amount of characters will truncate the content at the end by default. When the `position` property is set to "middle", the truncation will split the content as evenly as possible, providing a more "true middle" truncation.
51+
52+
```ts file="./TruncateMaxChars.tsx"
5153

52-
<div className="truncate-example-resize">
53-
<Truncate
54-
content={'Vestibulum interdum risus et enim faucibus, sit amet molestie est accumsan.'}
55-
tooltipPosition={'bottom'}
56-
/>
57-
</div>
5854
```

0 commit comments

Comments
 (0)