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..372b6a9ef4 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.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( - - 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.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({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..fac0155dc0 --- /dev/null +++ b/src/Truncate/Truncate.tsx @@ -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); + } + + 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..9c7eb03205 --- /dev/null +++ b/src/Truncate/index.scss @@ -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); +} 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..e56c910d47 --- /dev/null +++ b/src/Truncate/utils.test.ts @@ -0,0 +1,69 @@ +import React from 'react'; +import { assembleStringFromChildrenArray } from './utils'; + +const mockElement = (type: string, props: Record) => 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); + }); + }); +}); diff --git a/src/Truncate/utils.ts b/src/Truncate/utils.ts new file mode 100644 index 0000000000..8adf64d4cc --- /dev/null +++ b/src/Truncate/utils.ts @@ -0,0 +1,72 @@ +import React from 'react'; + +export interface ElementDataEntry { + type: React.ElementType | string | null; + props: Record | null; + start: number; + end: number; + children: ElementDataEntry[] | null; +} + +export interface AssemblyResult { + result: string; + elementsData: ElementDataEntry[]; +} + +/** + * 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, +): AssemblyResult { + let finalResult = ''; + const finalElementsData: ElementDataEntry[] = []; + + children?.forEach(child => { + const isStringOrNumber = typeof child === 'string' || typeof child === 'number'; + const isElement = React.isValidElement(child); + + let currentChildren: ElementDataEntry[] | null = null; + let childProps: Record | null = null; + let childType: React.ElementType | string | null = null; + + const start = finalResult.length; + + if (isStringOrNumber) { + finalResult += String(child); + } else if (isElement) { + childProps = (child as React.ReactElement).props; + childType = (child as React.ReactElement).type; + + const elementChildren = childProps?.children; + + if (typeof elementChildren === 'string' || typeof elementChildren === 'number') { + finalResult += String(elementChildren); + } else if (elementChildren) { + const childrenArray = Array.isArray(elementChildren) ? elementChildren : [elementChildren]; + + const { result, elementsData } = assembleStringFromChildrenArray(childrenArray); + + finalResult += result; + currentChildren = elementsData; + } + } + + const end = finalResult.length; + + finalElementsData.push({ + type: childType, + props: childProps, + start, + end, + children: currentChildren, + }); + }); + + return { result: finalResult, elementsData: finalElementsData }; +} 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";