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";