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
2 changes: 1 addition & 1 deletion .stylelintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"ignoreProperties": ["animation", "filter"]
}],
"value-no-vendor-prefix": [true, {
"ignoreValues": ["fill-available"]
"ignoreValues": ["fill-available", "box"]
}],
"function-no-unknown": null,
"number-leading-zero": "never",
Expand Down
36 changes: 18 additions & 18 deletions src/Truncate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,44 @@ components:
- Truncate
categories:
- Content
status: 'Deprecate Soon'
status: 'New'
designStatus: 'Done'
devStatus: 'Done'
notes: |
Plan to replace with native css implementation as per https://github.com/openedx/paragon/issues/3311
---

A Truncate component can help you crop multiline text. There will be three dots at the end of the text.

## Basic Usage

```jsx live
<Truncate.Deprecated lines={2}>
<Truncate lines={2}>
Learners, course teams, researchers, developers: the edX community includes groups with a range of reasons
for using the platform and objectives to accomplish. To help members of each group learn about what edX
offers, reach goals, and solve problems, edX provides a variety of information resources.
Learners, course teams, researchers, developers: the edX community includes groups with a range of reasons
for using the platform and objectives to accomplish. To help members of each group learn about what edX
offers, reach goals, and solve problems, edX provides a variety of information resources.
</Truncate.Deprecated>
</Truncate>
```

### With the custom ellipsis

```jsx live
<Truncate.Deprecated lines={2} ellipsis="🎉🎉🎉" whiteSpace>
<Truncate lines={2}>
Learners, course teams, researchers, developers: the edX community includes groups with a range of reasons
for using the platform and objectives to accomplish. To help members of each group learn about what edX
offers, reach goals, and solve problems, edX provides a variety of information resources.
</Truncate.Deprecated>
</Truncate>
```

### With the onTruncate

```jsx live
<Truncate.Deprecated lines={2} onTruncate={() => console.log('onTruncate')}>
<Truncate lines={2}>
Learners, course teams, researchers, developers: the edX community includes groups with a range of reasons
for using the platform and objectives to accomplish. To help members of each group learn about what edX
offers, reach goals, and solve problems, edX provides a variety of information resources.
</Truncate.Deprecated>
</Truncate>
```

### Example usage in Card
Expand All @@ -61,22 +59,22 @@ A Truncate component can help you crop multiline text. There will be three dots
/>
<Card.Header
title={
<Truncate.Deprecated lines={2}>
<Truncate lines={2}>
Using Enhanced Capabilities In Your Course
</Truncate.Deprecated>}
</Truncate>}
/>
<Card.Section>
<Truncate.Deprecated lines={4}>
<Truncate lines={4}>
Learners, course teams, researchers, developers: the edX community includes groups with a range of reasons
for using the platform and objectives to accomplish. To help members of each group learn about what edX
offers, reach goals, and solve problems, edX provides a variety of information resources.
</Truncate.Deprecated>
</Truncate>
</Card.Section>
<Card.Footer
textElement={
<Truncate.Deprecated lines={2}>
<Truncate lines={2}>
Using Enhanced Capabilities In Your Course
</Truncate.Deprecated>}
</Truncate>}
>
<Button style={{ minWidth: 100 }}>Action 1</Button>
</Card.Footer>
Expand All @@ -87,10 +85,12 @@ A Truncate component can help you crop multiline text. There will be three dots

### HTML markdown support

**Note**: `Truncate` supports only plain `HTML` children and not `jsx`.
**Note**: `Truncate` supports only plain `HTML` children and not `jsx`.

QUESTION FOR KEVIN: Would this fail screen readers anyway?

```jsx live
<Truncate.Deprecated lines={1}>
<Truncate lines={1}>
<a href="#">Learners</a>, course teams, researchers, developers: the edX community includes <strong class="strong-class"><i class="i-class">groups with <u>a range</u> of <q>reasons</q></i></strong> for using the platform and objectives to accomplish.
</Truncate.Deprecated>
</Truncate>
```
82 changes: 60 additions & 22 deletions src/Truncate/Truncate.test.jsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,65 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import Truncate from '.';

describe('<Truncate />', () => {
render(
<Truncate.Deprecated className="pgn__truncate">
Learners, course teams, researchers, developers.
</Truncate.Deprecated>,
);
it('render with className', () => {
const element = screen.getByText(/Learners, course teams, researchers, developers./i);
expect(element).toBeTruthy();
expect(element.className).toContain('pgn__truncate');
expect(element.getAttribute('aria-label')).toBe('Learners, course teams, researchers, developers.');
expect(element.getAttribute('title')).toBe('Learners, course teams, researchers, developers.');
import Truncate from './Truncate';
import { assembleStringFromChildrenArray } from './utils';

jest.mock('./utils', () => ({
assembleStringFromChildrenArray: jest.fn(
(children) => `Assembled text from ${children.length} elements`,
),
}));

describe('Truncate Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should render with default line clamp of 1', () => {
const testContent = 'This is a test string.';
render(<Truncate>{testContent}</Truncate>);

const element = screen.getByTestId('truncate-element');
expect(element.style.WebkitLineClamp).toBe('1');

expect(element).toHaveAttribute('title', testContent);
expect(element).toHaveAttribute('aria-label', testContent);
});
it('render with onTruncate', () => {
const mockFn = jest.fn();
render(
<Truncate.Deprecated className="pgn__truncate" onTruncate={mockFn}>
Learners, course teams, researchers, developers.
</Truncate.Deprecated>,
);
expect(mockFn).toHaveBeenCalledTimes(2);

it('should render with custom line clamp value', () => {
const testContent = 'Another long string here.';
const customLines = 5;
render(<Truncate lines={customLines}>{testContent}</Truncate>);

const element = screen.getByTestId('truncate-element');

expect(element.style.WebkitLineClamp).toBe(String(customLines));

expect(element).toHaveAttribute('title', testContent);
expect(element).toHaveAttribute('aria-label', testContent);
});

it('should not call assembleStringFromChildrenArray if children is a string', () => {
const testContent = 'Simple string content.';
render(<Truncate>{testContent}</Truncate>);

expect(assembleStringFromChildrenArray).not.toHaveBeenCalled();
});

it('should call assembleStringFromChildrenArray if children is complex', () => {
// Complex children structure (an array of elements)
const complexChildren = [
<span key="a">Part A</span>,
<strong key="b">Part B</strong>,
'Part C',
];

(assembleStringFromChildrenArray).mockReturnValue('This is the mocked full string.');

render(<Truncate>{complexChildren}</Truncate>);

expect(assembleStringFromChildrenArray).toHaveBeenCalledTimes(1);

expect(assembleStringFromChildrenArray).toHaveBeenCalledWith(complexChildren);
});
});
32 changes: 32 additions & 0 deletions src/Truncate/Truncate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { assembleStringFromChildrenArray } from './utils';

interface TruncateProps {
/** The expected text to which the ellipsis would be applied. */
children: React.ReactNode;
/** The number of lines the text to be truncated to. */
lines?: number;
}

function Truncate({ children, lines = 1 }: TruncateProps) {
const style = {
lineClamp: lines,
WebkitLineClamp: lines,
};

const initialText = Array.isArray(children) ? assembleStringFromChildrenArray(children) : String(children);

return (
<p
title={initialText}
aria-label={initialText}
className="truncate-text"
style={style}
data-testid="truncate-element"
>
{children}
</p>
);
}

export default Truncate;
15 changes: 3 additions & 12 deletions src/Truncate/index.jsx → src/Truncate/TruncateDeprecated.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, {
useLayoutEffect, useRef, useEffect,
useLayoutEffect, useRef,
} from 'react';
import PropTypes from 'prop-types';
import { truncateLines } from './utils';
import { truncateLines } from './utils.deprecated';
import useWindowSize from '../hooks/useWindowSizeHook';

const DEFAULT_TRUNCATE_LINES = 1;
Expand Down Expand Up @@ -66,13 +66,4 @@ TruncateDeprecated.defaultProps = {
onTruncate: undefined,
};

function Truncate() {
useEffect(() => {
// eslint-disable-next-line no-console
console.log('Please use Truncate.Deprecated until a replacement is created');
}, []);
return null;
}
Truncate.Deprecated = TruncateDeprecated;

export default Truncate;
export default TruncateDeprecated;
27 changes: 27 additions & 0 deletions src/Truncate/TruncateDeprecated.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import Truncate from '.';

describe('<Truncate />', () => {
render(
<Truncate.Deprecated className="pgn__truncate">
Learners, course teams, researchers, developers.
</Truncate.Deprecated>,
);
it('render with className', () => {
const element = screen.getByText(/Learners, course teams, researchers, developers./i);
expect(element).toBeTruthy();
expect(element.className).toContain('pgn__truncate');
expect(element.getAttribute('aria-label')).toBe('Learners, course teams, researchers, developers.');
expect(element.getAttribute('title')).toBe('Learners, course teams, researchers, developers.');
});
it('render with onTruncate', () => {
const mockFn = jest.fn();
render(
<Truncate.Deprecated className="pgn__truncate" onTruncate={mockFn}>
Learners, course teams, researchers, developers.
</Truncate.Deprecated>,
);
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
6 changes: 6 additions & 0 deletions src/Truncate/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Truncate from './Truncate';
import TruncateDeprecated from './TruncateDeprecated';

Truncate.Deprecated = TruncateDeprecated;

export default Truncate;
7 changes: 7 additions & 0 deletions src/Truncate/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.truncate-text {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
line-clamp: 1;
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { constructChildren, cropText, truncateLines } from './utils';
import { constructChildren, cropText, truncateLines } from './utils.deprecated';

const createElementMock = {
parentNode: {
Expand Down
73 changes: 73 additions & 0 deletions src/Truncate/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { assembleStringFromChildrenArray, ElementDataEntry } from './utils';

const mockElement = (type: string, props: Record<string, any>, key: string | null = null) => ({
type,
props,
key,
$$typeof: Symbol.for('react.element'),
});

describe('utils', () => {
describe('assembleStringFromChildrenArray', () => {
it('should correctly assemble a string from a simple array of strings and numbers', () => {
const inputChildren = ['Hello', 123, ' World!'];
const elementsData: ElementDataEntry[] = [];

const result = assembleStringFromChildrenArray(inputChildren, elementsData);

expect(result).toBe('Hello123 World!');
expect(elementsData.length).toBe(3);

expect(elementsData[0].start).toBe(0);
expect(elementsData[0].end).toBe(5);
expect(elementsData[0].type).toBeNull();

expect(elementsData[1].start).toBe(5);
expect(elementsData[1].end).toBe(8);
expect(elementsData[1].type).toBeNull();

expect(elementsData[2].start).toBe(8);
expect(elementsData[2].end).toBe(15);
expect(elementsData[2].type).toBeNull();
});

it('should handle a single React element with a simple string child', async () => {
const elementText = 'test-element-text';
const originalProps = { id: 1, children: elementText };
const element = mockElement('span', originalProps);
const elementsData: ElementDataEntry[] = [];
const result = assembleStringFromChildrenArray([element], elementsData);

expect(result).toBe(elementText);

const dataEntry = elementsData[0];

expect(dataEntry.start).toBe(0);
expect(dataEntry.end).toBe(elementText.length);
expect(dataEntry.type).toBe('span');
expect(dataEntry.props).toEqual(originalProps);
expect(dataEntry.children).toBeNull(); // No nested array since child was a string
});

it('should correctly handle a simple array of mixed strings and elements', () => {
const element1 = mockElement('a', { children: 'Link' });
const elementsData: ElementDataEntry[] = [];

const result = assembleStringFromChildrenArray(['Prefix: ', element1, ' Suffix'], elementsData);

expect(result).toBe('Prefix: Link Suffix');
expect(elementsData.length).toBe(3);
});

it('should recursively handle nested elements and collect nested data', () => {
const innerStrong = mockElement('strong', { children: 'Inner' });
const outerDiv = mockElement('div', { children: ['Outer ', innerStrong] });
const elementsData: ElementDataEntry[] = [];

const result = assembleStringFromChildrenArray([outerDiv], elementsData);

expect(result).toBe('Outer Inner');
expect(elementsData.length).toBe(1); // Only the outer div is tracked at the top level
});
});
});
Loading