diff --git a/.stylelintrc.json b/.stylelintrc.json
index 370aefe972..fd24feef96 100644
--- a/.stylelintrc.json
+++ b/.stylelintrc.json
@@ -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",
diff --git a/src/Truncate/README.md b/src/Truncate/README.md
index fd847ddd84..3e2c5147f4 100644
--- a/src/Truncate/README.md
+++ b/src/Truncate/README.md
@@ -5,11 +5,9 @@ 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.
@@ -17,34 +15,34 @@ A Truncate component can help you crop multiline text. There will be three dots
## Basic Usage
```jsx live
-
+
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.
-
+
```
### With the custom ellipsis
```jsx live
-
+
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.
-
+
```
### With the onTruncate
```jsx live
- console.log('onTruncate')}>
+
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.
-
+
```
### Example usage in Card
@@ -61,22 +59,22 @@ A Truncate component can help you crop multiline text. There will be three dots
/>
+
Using Enhanced Capabilities In Your Course
- }
+ }
/>
-
+
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.
-
+
+
Using Enhanced Capabilities In Your Course
- }
+ }
>
@@ -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
-
+Learners, course teams, researchers, developers: the edX community includes groups with a range of reasons for using the platform and objectives to accomplish.
-
+
```
diff --git a/src/Truncate/Truncate.test.jsx b/src/Truncate/Truncate.test.jsx
index d1cc120c85..15236131e6 100644
--- a/src/Truncate/Truncate.test.jsx
+++ b/src/Truncate/Truncate.test.jsx
@@ -1,27 +1,65 @@
import React from 'react';
+import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
-import Truncate from '.';
-
-describe('', () => {
- render(
-
- Learners, course teams, researchers, developers.
- ,
- );
- 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({testContent});
+
+ 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(
-
- Learners, course teams, researchers, developers.
- ,
- );
- expect(mockFn).toHaveBeenCalledTimes(2);
+
+ it('should render with custom line clamp value', () => {
+ const testContent = 'Another long string here.';
+ const customLines = 5;
+ render({testContent});
+
+ 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({testContent});
+
+ expect(assembleStringFromChildrenArray).not.toHaveBeenCalled();
+ });
+
+ it('should call assembleStringFromChildrenArray if children is complex', () => {
+ // Complex children structure (an array of elements)
+ const complexChildren = [
+ Part A,
+ Part B,
+ 'Part C',
+ ];
+
+ (assembleStringFromChildrenArray).mockReturnValue('This is the mocked full string.');
+
+ render({complexChildren});
+
+ expect(assembleStringFromChildrenArray).toHaveBeenCalledTimes(1);
+
+ expect(assembleStringFromChildrenArray).toHaveBeenCalledWith(complexChildren);
});
});
diff --git a/src/Truncate/Truncate.tsx b/src/Truncate/Truncate.tsx
new file mode 100644
index 0000000000..fa421b68f6
--- /dev/null
+++ b/src/Truncate/Truncate.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export default Truncate;
diff --git a/src/Truncate/index.jsx b/src/Truncate/TruncateDeprecated.jsx
similarity index 86%
rename from src/Truncate/index.jsx
rename to src/Truncate/TruncateDeprecated.jsx
index 292b7e2b7f..e4274039f5 100644
--- a/src/Truncate/index.jsx
+++ b/src/Truncate/TruncateDeprecated.jsx
@@ -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;
@@ -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;
diff --git a/src/Truncate/TruncateDeprecated.test.jsx b/src/Truncate/TruncateDeprecated.test.jsx
new file mode 100644
index 0000000000..d1cc120c85
--- /dev/null
+++ b/src/Truncate/TruncateDeprecated.test.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import Truncate from '.';
+
+describe('', () => {
+ render(
+
+ Learners, course teams, researchers, developers.
+ ,
+ );
+ 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(
+
+ Learners, course teams, researchers, developers.
+ ,
+ );
+ expect(mockFn).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/Truncate/index.js b/src/Truncate/index.js
new file mode 100644
index 0000000000..0499710bd0
--- /dev/null
+++ b/src/Truncate/index.js
@@ -0,0 +1,6 @@
+import Truncate from './Truncate';
+import TruncateDeprecated from './TruncateDeprecated';
+
+Truncate.Deprecated = TruncateDeprecated;
+
+export default Truncate;
diff --git a/src/Truncate/index.scss b/src/Truncate/index.scss
new file mode 100644
index 0000000000..a2f9fbde1f
--- /dev/null
+++ b/src/Truncate/index.scss
@@ -0,0 +1,7 @@
+.truncate-text {
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+ line-clamp: 1;
+}
diff --git a/src/Truncate/utils.js b/src/Truncate/utils.deprecated.js
similarity index 100%
rename from src/Truncate/utils.js
rename to src/Truncate/utils.deprecated.js
diff --git a/src/Truncate/utils.test.js b/src/Truncate/utils.deprecated.test.js
similarity index 99%
rename from src/Truncate/utils.test.js
rename to src/Truncate/utils.deprecated.test.js
index 82ff116eb9..9e50f7dd8c 100644
--- a/src/Truncate/utils.test.js
+++ b/src/Truncate/utils.deprecated.test.js
@@ -1,4 +1,4 @@
-import { constructChildren, cropText, truncateLines } from './utils';
+import { constructChildren, cropText, truncateLines } from './utils.deprecated';
const createElementMock = {
parentNode: {
diff --git a/src/Truncate/utils.test.ts b/src/Truncate/utils.test.ts
new file mode 100644
index 0000000000..86d7ec6a3b
--- /dev/null
+++ b/src/Truncate/utils.test.ts
@@ -0,0 +1,73 @@
+import { assembleStringFromChildrenArray, ElementDataEntry } from './utils';
+
+const mockElement = (type: string, props: Record, 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
+ });
+ });
+});
diff --git a/src/Truncate/utils.ts b/src/Truncate/utils.ts
new file mode 100644
index 0000000000..e0c67bcab2
--- /dev/null
+++ b/src/Truncate/utils.ts
@@ -0,0 +1,74 @@
+import React from 'react';
+
+export interface ElementDataEntry {
+ type: React.ElementType | string | null;
+ props: Record | null;
+ start: number;
+ end: number;
+ children: ElementDataEntry[] | null;
+}
+
+/**
+ * Retrieves plain string from children array and collects data
+ * to be able to restore original children in the future.
+ *
+ * @param {array} children
+ * @param {array} elementsData original data to restore children
+ * @returns string
+ */
+export function assembleStringFromChildrenArray(
+ children: Array,
+ elementsData: Array = [],
+): string {
+ let result = '';
+
+ children?.forEach(child => {
+ const isStringOrNumber = typeof child === 'string' || typeof child === 'number';
+ const isElement = React.isValidElement(child);
+
+ // Default values if the child is a simple string/number
+ let currentChildren = null;
+ let childProps: Record | null = null;
+ let childType: React.ElementType | string | null = null;
+
+ const start = result.length;
+
+ if (isStringOrNumber) {
+ result += String(child);
+ } else if (isElement) {
+ childProps = (child as React.ReactElement).props;
+ childType = (child as React.ReactElement).type;
+
+ const elementChildren = childProps?.children;
+ const isElementChildrenStringOrNumber = typeof elementChildren === 'string' || typeof elementChildren === 'number';
+
+ if (isElementChildrenStringOrNumber) {
+ result += String(elementChildren);
+ } else if (elementChildren) {
+ const nestedChildrenData: ElementDataEntry[] = [];
+
+ const childrenArray = Array.isArray(elementChildren)
+ ? elementChildren
+ : [elementChildren]; // If it's a single element, wrap it in an array
+
+ result += assembleStringFromChildrenArray(
+ childrenArray as Array,
+ nestedChildrenData,
+ );
+
+ currentChildren = nestedChildrenData;
+ }
+ }
+ const end = result.length;
+
+ elementsData.push({
+ type: childType,
+ props: childProps,
+ start,
+ end,
+ children: currentChildren,
+ });
+ });
+
+ return result;
+}
diff --git a/src/index.scss b/src/index.scss
index 6b13fdd902..1ecdf93fd1 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -36,6 +36,7 @@
@import "./Stepper";
@import "./StatefulButton";
@import "./Tooltip";
+@import "./Truncate";
@import "./DataTable";
@import "./TransitionReplace";
@import "./ValidationMessage";