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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This functionality doesn't exist in the new version. My initial thought was that we should just remove this example, but I think the best thing to do would be to have 2 docs site pages (one for the new version and one for the deprecated version) until we fully remove the deprecated component.


```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.getPropertyValue('--truncate-prop-lines')).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.getPropertyValue('--truncate-prop-lines')).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);
});
});
33 changes: 33 additions & 0 deletions src/Truncate/Truncate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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) {
let initialText: string = '';
if (Array.isArray(children)) {
const { result } = assembleStringFromChildrenArray(children);
initialText = result;
} else {
initialText = String(children);
}
Comment on lines +13 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has some interesting side effects

Image

the deprecated component just doesn't work when passing in a react component

Image

which feels like a slightly better fail state than just having the title/aria-label get mangled.


return (
<p

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having this hardcoded to p seems less than ideal. The deprecated version allows consumers to configure this with elementType (which feels like it's just an odd name, we generally use "as").

The other question is what a reasonable default would be, I'm thinking span probably makes sense so people can use as a child of other elements without making funky invalid html or needing to use the as prop.

title={initialText}
aria-label={initialText}
className="truncate-text"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the deploy preview (https://deploy-preview-4037--paragon-openedx-v23.netlify.app/components/truncate/) and it didn't appear to be working at all. I think this is likely the culprit.

Suggested change
className="truncate-text"
className="pgn__truncate-text"

style={{ '--truncate-prop-lines': lines } as React.CSSProperties}
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 @@
.pgn__truncate-text {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--truncate-prop-lines);
line-clamp: var(--truncate-prop-lines);
}
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
69 changes: 69 additions & 0 deletions src/Truncate/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import { assembleStringFromChildrenArray } from './utils';

const mockElement = (type: string, props: Record<string, any>) => React.createElement(type, props);

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

const { result, elementsData } = assembleStringFromChildrenArray(inputChildren);

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

expect(elementsData[0]).toMatchObject({ start: 0, end: 5, type: null });
expect(elementsData[1]).toMatchObject({ start: 5, end: 8, type: null });
expect(elementsData[2]).toMatchObject({ start: 8, end: 15, type: null });
});

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

const { result, elementsData } = assembleStringFromChildrenArray([element]);

expect(result).toBe(elementText);
expect(elementsData).toHaveLength(1);

const dataEntry = elementsData[0];
expect(dataEntry.start).toBe(0);
expect(dataEntry.end).toBe(elementText.length);
expect(dataEntry.type).toBe('span');
// Children is null because the child was a primitive string, not an element
expect(dataEntry.children).toBeNull();
});

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

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

expect(result).toBe('Prefix: Link Suffix');
expect(elementsData).toHaveLength(3);
expect(elementsData[1].type).toBe('a');
});

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

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

expect(result).toBe('Outer Inner');

expect(elementsData).toHaveLength(1);
const divData = elementsData[0];
expect(divData.type).toBe('div');

expect(divData.children).toHaveLength(2);
expect(divData.children?.[0].type).toBeNull();
expect(divData.children?.[1].type).toBe('strong');

expect(divData.children?.[1].start).toBe(6);
expect(divData.children?.[1].end).toBe(11);
});
});
});
Loading