diff --git a/cypress/component/Truncate.cy.tsx b/cypress/component/Truncate.cy.tsx new file mode 100644 index 0000000000..70c21ede44 --- /dev/null +++ b/cypress/component/Truncate.cy.tsx @@ -0,0 +1,303 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React from 'react' +import { expect } from 'chai' + +import '../support/component' +import { within } from '@instructure/ui-utils' +import truncate from '../../packages/ui-truncate-text/src/TruncateText/utils/truncate' + + +describe('truncate', () => { + const defaultText = 'Hello world! This is a long string that should truncate' + const baseStyle = { + fontSize: '16px', + fontFamily: 'Arial', + fontWeight: 'normal', + fontStyle: 'normal', + letterSpacing: 'normal' + } + + it('should truncate text when no options are given', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('#stage').then(($stage) => { + truncate($stage[0]) + const text = $stage.text() + + expect(text.indexOf('truncate')).to.equal(-1) + expect(text.indexOf('\u2026')).to.not.equal(-1) + }) + + cy.get('#stage').should('have.text', 'Hello world! This is a long…') + }) + + it('should truncate in the middle of a string', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('#stage').then(($stage) => { + truncate($stage[0], { position: 'middle' }) + const text = $stage.text() + + expect(text.indexOf('long')).to.equal(-1) + expect(text.indexOf('Hello')).to.not.equal(-1) + expect(text.indexOf('truncate')).to.not.equal(-1) + expect(text.indexOf('\u2026')).to.not.equal(-1) + }) + + cy.get('#stage').should('have.text', 'Hello world! …ould truncate') + }) + + it('should truncate at words', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('#stage').then(($stage) => { + truncate($stage[0], { truncate: 'word' }) + const text = $stage.text() + + expect(text.indexOf('string')).to.equal(-1) + expect(text.indexOf('st')).to.equal(-1) + expect(text.indexOf('long')).to.not.equal(-1) + }) + + cy.get('#stage').should('have.text', 'Hello world! This is a long …') + }) + + it('should allow custom ellipsis', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('#stage').then(($stage) => { + truncate($stage[0], { ellipsis: '(...)' }) + const text = $stage.text() + + expect(text!.slice(-5)).to.equal('(...)') + }) + + cy.get('#stage').should('have.text', 'Hello world! This is a lon(...)') + }) + + it('should preserve node structure', async () => { + cy.mount( +
+

+ Hello world! This is a long string that{' '} + should truncate +

+
+ ) + + cy.get('#stage').then(($stage) => { + truncate($stage[0]) + + cy.wrap($stage[0].childNodes[1].nodeType).should('equal', 1) + cy.wrap($stage[0].childNodes[2].nodeType).should('equal', 3) + cy.wrap($stage[0].children.length).should('equal', 2) + cy.wrap($stage[0].className).should('equal', 'testClass') + cy.wrap($stage[0].tagName).should('equal', 'P') + }) + + cy.get('strong').should('exist') + cy.get('#stage').should('have.text', 'Hello world! This is a lon…') + }) + + it('should preserve attributes on nodes', async () => { + cy.mount( +
+ + This is a{' '} + + text link + {' '} + with classes and an href. + +
+ ) + + cy.get('#stage').then(($stage) => { + truncate($stage[0]) + }) + + cy.get('#link') + .should('have.attr', 'href', 'http://google.com') + .and('have.attr', 'class', 'tester') + .and('have.attr', 'id', 'link') + + cy.get('#link').then(($link) => { + const attributesLength = $link[0].attributes.length + expect(attributesLength).to.equal(3) + }) + }) + + it('should calculate max width properly', async () => { + cy.mount( +
+
+ {defaultText} +
+
{defaultText}
+
+
+
+ ) + + cy.get('#stage').then(($stage) => { + const result = truncate($stage[0]) + const maxWidth = result!.constraints.width + + cy.get('#textContainer').then(($textContainer) => { + const actualMax = $textContainer[0].getBoundingClientRect().width + + expect(within(maxWidth, actualMax, 1)).to.equal(true) + }) + }) + }) + + it('should calculate `maxLines: auto` correctly', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('#stage').then(($stage) => { + const result = truncate($stage[0], { maxLines: 'auto' }) + const text = $stage.text() + + cy.wrap(text).should('not.equal', defaultText) + cy.wrap(text.length).should('not.equal', 1) + cy.wrap(result!.constraints.lines).should('equal', 4) + }) + }) + + it('should calculate height correctly when `maxLines` is not `auto`', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('#stage').then(($stage) => { + const result = truncate($stage[0]) + const text = $stage.text() + + cy.wrap(text.length).should('not.equal', 1) + + cy.wrap(text).should('not.equal', defaultText) + cy.wrap(result!.constraints.height).should('equal', 22.4) + }) + }) + + it('should escape node content', async () => { + cy.spy(console, 'log').as('consoleLogSpy') + const content = '">' + + cy.mount( +
+ {content} +
+ ) + + cy.get('#stage').then(($stage) => { + truncate($stage[0]) + + cy.wrap($stage.text()).should('equal', content) + cy.get('@consoleLogSpy').should('not.have.been.calledWith', 'hello world') + }) + }) + + it('should truncate when visually hidden', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('#stage').then(($stage) => { + truncate($stage[0]) + const text = $stage.text() + + expect(text.indexOf('truncate')).to.equal(-1) + expect(text.indexOf('\u2026')).to.not.equal(-1) + }) + + cy.get('#stage-wrapper').should('have.css', 'opacity', '0') + cy.get('#stage').should('have.text', 'Hello world! This is a long…') + }) + + it('should account for font size styles', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('#stage').then(($stageInitial) => { + truncate($stageInitial[0]) + }) + + cy.get('#stage').should('have.text', 'Hello world! This is a long…') + + // Update font size + cy.get('#stage').invoke('css', { ...baseStyle, width: '200px', fontSize: '24px' }) + + cy.get('#stage').then(($stageUpdated) => { + truncate($stageUpdated[0]) + }) + + cy.get('#stage').should('have.text', 'Hello world! This…') + }) +}) diff --git a/cypress/component/TruncateText.cy.tsx b/cypress/component/TruncateText.cy.tsx new file mode 100644 index 0000000000..3a7ecfb8df --- /dev/null +++ b/cypress/component/TruncateText.cy.tsx @@ -0,0 +1,206 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React from 'react' +import { TruncateText, Text } from '@instructure/ui' +import { expect } from 'chai' +import '../support/component' + + +describe('', () => { + const defaultText = 'Hello world! This is a long string that should truncate' + const baseStyle = { + fontSize: '16px', + fontFamily: 'Arial', + fontWeight: 'normal', + fontStyle: 'normal', + letterSpacing: 'normal' + } + + it('should truncate text', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('[class$="-truncateText"]') + .invoke('text') + .should('not.contain', 'truncate') + .and('contain', '\u2026') + }) + + it('should recalculate when parent width changes', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('[class$="-truncateText"]').invoke('text').as('text1') + + cy.get('[data-testid="container"]').invoke('css', { ...baseStyle, width: '100px'}) + + cy.get('[class$="-truncateText"]').invoke('text').as('text2') + + cy.get('@text2').then((text2) => { + cy.get('@text1').should('not.equal', text2) + }) + + cy.get('[data-testid="container"]').invoke('css', { ...baseStyle, width: '400px'}) + + + cy.get('[class$="-truncateText"]').invoke('text').as('text3') + + cy.get('@text3').then((text3) => { + cy.get('@text2').should('not.equal', text3) + }) + }) + + it('should preserve node structure', async () => { + cy.mount( +
+ +

+ Hello world! This is a long string that{' '} + should truncate +

+
+
+ ) + + cy.get('p.testClass') + .as('paragraph') + .should('exist') + .and('have.class', 'testClass') + .within(() => { + cy.get('strong').should('exist') + cy.get('em').should('exist') + }) + + cy.get('@paragraph').then((paragraph) => { + expect(paragraph[0].children.length).to.equal(3) + }) + }) + + it('should recalculate if props change', async () => { + cy.mount( +
+ {defaultText} +
+ ) + + cy.get('[class$="-truncateText"]') + .invoke('text') + .then((textBeforeUpdate) => { + // Set props: position, ellipsis + cy.mount( +
+ + {defaultText} + +
+ ) + + cy.get('[class$="-truncateText"]') + .invoke('text') + .should('not.equal', textBeforeUpdate) + }) + }) + + it('should re-render with new children if children change', async () => { + cy.mount( + + {defaultText} + + ) + + cy.get('[class$="-truncateText"]') + .invoke('text') + .then((textBeforeUpdate) => { + // Set child + cy.mount( +
+ + This is a different string of text + +
+ ) + + cy.get('[class$="-truncateText"]') + .invoke('text') + .should('not.equal', textBeforeUpdate) + }) + }) + + it('should call onUpdate when text changes', async () => { + const onUpdate = cy.stub() + + cy.mount( +
+ {defaultText} +
+ ) + cy.wrap(onUpdate).should('not.have.been.called') + + // Set container width + cy.get('[data-testid="container"]').invoke('css', { ...baseStyle, width: '100px'}) + cy.wrap(onUpdate).should('have.been.calledWith', true) + + // Set container width + cy.get('[data-testid="container"]').invoke('css', { ...baseStyle, width: '800px'}) + cy.wrap(onUpdate).should('have.been.calledWith', false) + }) + + it('should render text at any size with no lineHeight set', async () => { + cy.mount( +
+ + + xsmall + + + small + + + medium + + + large + + + xlarge + + + xxlarge + + +
+ ) + + cy.get('[data-testid="container"]').should( + 'contain', + 'xsmallsmallmediumlargexlargexxlarge' + ) + }) +}) diff --git a/package-lock.json b/package-lock.json index 440cd7343a..813c62ac4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44116,12 +44116,16 @@ "prop-types": "^15.8.1" }, "devDependencies": { + "@instructure/ui-axe-check": "10.11.0", "@instructure/ui-babel-preset": "10.11.0", "@instructure/ui-color-utils": "10.11.0", "@instructure/ui-test-utils": "10.11.0", "@instructure/ui-text": "10.11.0", "@instructure/ui-themes": "10.11.0", - "@types/escape-html": "^1.0.4" + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@types/escape-html": "^1.0.4", + "vitest": "^2.1.8" }, "peerDependencies": { "react": ">=16.14 <=18" diff --git a/packages/ui-truncate-text/package.json b/packages/ui-truncate-text/package.json index 41d3924eec..0fd9c08968 100644 --- a/packages/ui-truncate-text/package.json +++ b/packages/ui-truncate-text/package.json @@ -36,12 +36,16 @@ "prop-types": "^15.8.1" }, "devDependencies": { + "@instructure/ui-axe-check": "10.11.0", "@instructure/ui-babel-preset": "10.11.0", "@instructure/ui-color-utils": "10.11.0", "@instructure/ui-test-utils": "10.11.0", "@instructure/ui-text": "10.11.0", "@instructure/ui-themes": "10.11.0", - "@types/escape-html": "^1.0.4" + "@types/escape-html": "^1.0.4", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "vitest": "^2.1.8" }, "peerDependencies": { "react": ">=16.14 <=18" diff --git a/packages/ui-truncate-text/src/TruncateText/__new-tests__/TruncateText.test.tsx b/packages/ui-truncate-text/src/TruncateText/__new-tests__/TruncateText.test.tsx new file mode 100644 index 0000000000..92aaf197b3 --- /dev/null +++ b/packages/ui-truncate-text/src/TruncateText/__new-tests__/TruncateText.test.tsx @@ -0,0 +1,99 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { vi, expect } from 'vitest' +import type { MockInstance } from 'vitest' + +import '@testing-library/jest-dom' +import { runAxeCheck } from '@instructure/ui-axe-check' +import { TruncateText } from '../index' + +const defaultText = 'Hello world! This is a long string that should truncate' + +describe('', () => { + let consoleErrorMock: ReturnType + + beforeEach(() => { + // Mocking console to prevent test output pollution and expect for messages + consoleErrorMock = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) as MockInstance + }) + + afterEach(() => { + consoleErrorMock.mockRestore() + }) + + it('should warn if children prop receives too deep of a node tree', async () => { + render( +
+ + Hello world!{' '} + + This is a + {' '} + long string that should truncate + +
+ ) + + const expectedErrorMessage = + 'Some children are too deep in the node tree and will not render.' + + await waitFor(() => { + expect(consoleErrorMock).toHaveBeenCalledWith( + expect.stringContaining(expectedErrorMessage), + expect.any(String) + ) + }) + }) + + it('should handle the empty string as a child', async () => { + let error = false + + try { + const { rerender } = render({''}) + + rerender({'hello world'}) + rerender({''}) + } catch (_e) { + error = true + } + + expect(error).toBe(false) + }) + + it('should meet a11y standards', async () => { + const { container } = render( +
+ {defaultText} +
+ ) + const axeCheck = await runAxeCheck(container) + + expect(axeCheck).toBe(true) + }) +}) diff --git a/packages/ui-truncate-text/src/TruncateText/__tests__/TruncateText.test.tsx b/packages/ui-truncate-text/src/TruncateText/__tests__/TruncateText.test.tsx deleted file mode 100644 index 1fd731ed70..0000000000 --- a/packages/ui-truncate-text/src/TruncateText/__tests__/TruncateText.test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 - present Instructure, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import React from 'react' -import { expect, mount, stub, wait, within } from '@instructure/ui-test-utils' -import { Text } from '@instructure/ui-text' - -import { TruncateText } from '../index' - -describe('', async () => { - const defaultText = 'Hello world! This is a long string that should truncate' - - it('should truncate text', async () => { - const subject = await mount( -
- {defaultText} -
, - { strictMode: false } - ) - - const renderedContent = within(subject.getDOMNode()) - const text = renderedContent.getTextContent()! - - expect(text.indexOf('truncate')).to.equal(-1) - expect(text.indexOf('\u2026')).to.not.equal(-1) - }) - - it('should recalculate when parent width changes', async () => { - let container: HTMLElement - - const subject = await mount( -
{ - container = el - }} - > - {defaultText} -
- ) - - const renderedContent = within(subject.getDOMNode()) - const text1 = renderedContent.getTextContent() - - container!.style.width = '100px' - - let text2: string | null - await wait(() => { - text2 = renderedContent.getTextContent() - expect(text1).to.not.equal(text2) - }) - - container!.style.width = '400px' - - await wait(() => { - expect(renderedContent.getTextContent()).to.not.equal(text2) - }) - }) - - it('should preserve node structure', async () => { - const subject = await mount( -
- -

- Hello world! This is a long string that{' '} - should truncate -

-
-
, - { strictMode: false } - ) - - const renderedContent = within(subject.getDOMNode()) - const paragraph = await renderedContent.find('p') - - expect(await paragraph.find('strong')).to.exist() - expect(await paragraph.find('em')).to.exist() - - expect(paragraph.hasClass('testClass')).to.be.true() - expect(paragraph.getDOMNode().children.length).to.equal(3) - }) - - it('should recalculate if props change', async () => { - const subject = await mount( -
- {defaultText} -
- ) - - const renderedContent = within(subject.getDOMNode()) - const text = renderedContent.getTextContent() - - await subject.setProps({ - children: ( - - {defaultText} - - ) - }) - - await wait(() => { - expect(renderedContent.getTextContent()).to.not.equal(text) - }) - }) - - it('should re-render with new children if children change', async () => { - const subject = await mount( - - {defaultText} - - ) - - const renderedContent = within(subject.getDOMNode()) - const text = renderedContent.getTextContent() - - await subject.setProps({ - children: This is a different string of text - }) - - await wait(() => { - expect(renderedContent.getTextContent()).to.not.equal(text) - }) - }) - - it('should call onUpdate when text changes', async () => { - const onUpdate = stub() - - let container: HTMLElement - await mount( -
{ - container = el - }} - > - {defaultText} -
- ) - - expect(onUpdate).to.not.have.been.called() - - container!.style.width = '100px' - await wait(() => { - expect(onUpdate).to.have.been.calledWith(true) - }) - - container!.style.width = '800px' - await wait(() => { - expect(onUpdate).to.have.been.calledWith(false) - }) - }) - - it('should warn if children prop receives too deep of a node tree', async () => { - const consoleError = stub(console, 'error') - const warning = - 'Some children are too deep in the node tree and will not render.' - await mount( -
- - Hello world!{' '} - - This is a - {' '} - long string that should truncate - -
- ) - await wait(() => { - expect(consoleError).to.have.been.calledWithMatch(warning) - }) - }) - - it('should render text at any size with no lineHeight set', async () => { - let stage: HTMLSpanElement | null - await mount( -
- { - stage = el - }} - > - - xsmall - - - small - - - medium - - - large - - - xlarge - - - xxlarge - - -
- ) - - expect(stage!.textContent).to.equal('xsmallsmallmediumlargexlargexxlarge') - }) - - it('should handle the empty string as a child', async () => { - let error = false - - try { - const subject = await mount({''}) - - await subject.setProps({ children: 'hello world' }) - await subject.setProps({ children: '' }) - } catch (e) { - error = true - } - - expect(error).to.be.false() - }) - - it('should meet a11y standards', async () => { - const subject = await mount( -
- {defaultText} -
- ) - const renderedContent = within(subject.getDOMNode()) - expect(await renderedContent.accessible()).to.be.true() - }) -}) diff --git a/packages/ui-truncate-text/src/TruncateText/utils/__tests__/cleanData.test.ts b/packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/cleanData.test.tsx similarity index 97% rename from packages/ui-truncate-text/src/TruncateText/utils/__tests__/cleanData.test.ts rename to packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/cleanData.test.tsx index 91c1043a5c..7a08b54798 100644 --- a/packages/ui-truncate-text/src/TruncateText/utils/__tests__/cleanData.test.ts +++ b/packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/cleanData.test.tsx @@ -21,12 +21,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -import { expect } from '@instructure/ui-test-utils' +import { expect } from 'chai' +import '@testing-library/jest-dom' import cleanData from '../cleanData' import { CleanDataOptions } from '../../props' -describe('cleanData', async () => { +describe('cleanData', () => { it('should remove spaces from the end of character data', async () => { const data = [['T', 'e', 's', 't', ' ', '...']] const options: Required = { diff --git a/packages/ui-truncate-text/src/TruncateText/utils/__tests__/cleanString.test.ts b/packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/cleanString.test.tsx similarity index 95% rename from packages/ui-truncate-text/src/TruncateText/utils/__tests__/cleanString.test.ts rename to packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/cleanString.test.tsx index bc57a7af52..13fbba801e 100644 --- a/packages/ui-truncate-text/src/TruncateText/utils/__tests__/cleanString.test.ts +++ b/packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/cleanString.test.tsx @@ -22,10 +22,11 @@ * SOFTWARE. */ -import { expect } from '@instructure/ui-test-utils' +import { expect } from 'chai' +import '@testing-library/jest-dom' import cleanString from '../cleanString' -describe('cleanSring', async () => { +describe('cleanSring', () => { it('should remove spaces from start and end of string', async () => { const string = ' Hello world ' diff --git a/packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/measureText.test.tsx b/packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/measureText.test.tsx new file mode 100644 index 0000000000..74f667c9b0 --- /dev/null +++ b/packages/ui-truncate-text/src/TruncateText/utils/__new-tests__/measureText.test.tsx @@ -0,0 +1,173 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react' +import { render, screen } from '@testing-library/react' +import { vi, expect } from 'vitest' + +import '@testing-library/jest-dom' +import measureText from '../measureText' + +const baseStyle = { + fontSize: '16px', + fontFamily: 'Arial', + fontWeight: 'normal', + fontStyle: 'normal', + letterSpacing: 'normal' +} + +const getNodes = (root: Element) => + Array.from(root.childNodes).filter( + (node) => + node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE + ) + +describe('measureText', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( + () => { + return { + font: '', + measureText: vi.fn((text: string) => { + return { width: text.length * 10 } + }) + } as unknown as CanvasRenderingContext2D + } + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should calculate the width of a single text node correctly', () => { + const node = document.createElement('div') + Object.assign(node.style, baseStyle) + node.textContent = 'Hello' + + const width = measureText([node]) + + expect(width).toBe(50) // 5 characters * 10 (mocked width per character) + }) + + it('should return zero for empty nodes', () => { + const node = document.createElement('div') + Object.assign(node.style, baseStyle) + node.textContent = '' + + const width = measureText([node]) + + expect(width).toBe(0) + }) + + it('should calculate text width correctly', () => { + render( +
+ Lorem ipsum DOLOR SIT AMET. +
+ ) + const stage = screen.getByTestId('stage') + const nodes = getNodes(stage) + + const width = measureText(nodes, stage) + + expect(width).toBe(270) + }) + + it('should account for different nodes', async () => { + const { rerender } = render( +
+ Lorem ipsum DOLOR SIT AMET. +
+ ) + const stage = screen.getByTestId('stage') + const nodes = getNodes(stage) + + const width = measureText(nodes, stage) + + // Set child + rerender( +
+ Lorem ipsum DOLOR SIT AMET. +
+ ) + const stage2 = screen.getByTestId('stage') + const nodes2 = getNodes(stage2) + + const width2 = measureText(nodes2, stage2) + + expect(width).toEqual(width2) + }) + + it('should call measureText on a properly configured canvas 2d context', () => { + const mockedStyle = { + fontSize: '20px', + fontWeight: 'bold', + fontStyle: 'italic', + fontFamily: 'Arial' + } + + render( +
+ Lorem ipsum +
+ ) + const stage = screen.getByTestId('stage') + const nodes = getNodes(stage) + + measureText(nodes, stage) + + // The width calculation depends on the modified canvas context + const getContextSpy = HTMLCanvasElement.prototype.getContext as any + const context = getContextSpy.mock.results[0].value + + expect(context.measureText).toHaveBeenCalledTimes(1) + expect(context.font).toBe('bold italic 20px Arial') + }) + + it('should account for letter spacing styles', async () => { + const { rerender } = render( +
+ Lorem ipsum +
+ ) + const stage = screen.getByTestId('stage') + const nodes = getNodes(stage) + const width = measureText(nodes, stage) + + expect(width).toBe(110) // default mocked width (text.length * 10) + + // Set letterSpacing + rerender( +
+ Lorem ipsum +
+ ) + const stage2 = screen.getByTestId('stage2') + const nodes2 = getNodes(stage2) + const width2 = measureText(nodes2, stage2) + + expect(width2).toBe(165) // default mocked width + letterOffset (text.length * letterSpacing) + }) +}) diff --git a/packages/ui-truncate-text/src/TruncateText/utils/__tests__/measureText.test.tsx b/packages/ui-truncate-text/src/TruncateText/utils/__tests__/measureText.test.tsx deleted file mode 100644 index dfa0a74aeb..0000000000 --- a/packages/ui-truncate-text/src/TruncateText/utils/__tests__/measureText.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 - present Instructure, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import React from 'react' -import { expect, mount } from '@instructure/ui-test-utils' - -import measureText from '../measureText' - -describe('measureText', async () => { - const getNodes = (root: Element) => - Array.from(root.childNodes).filter( - (node) => - node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE - ) - - it('should calculate width', async () => { - let stage: HTMLElement - await mount( -
{ - stage = el - }} - > - Lorem ipsum DOLOR SIT AMET. -
- ) - - const nodes = getNodes(stage!) - - const width = measureText(nodes, stage!) - expect(width).to.not.equal(0) - }) - - it('should account for different nodes', async () => { - let stage: HTMLElement - const subject = await mount( -
{ - stage = el - }} - > - Lorem ipsum DOLOR SIT AMET. -
- ) - - const nodes = getNodes(stage!) - - const width = measureText(nodes, stage!) - - await subject.setProps({ - children: 'Lorem ipsum DOLOR SIT AMET.' - }) - - const nodes2 = getNodes(stage!) - const width2 = measureText(nodes2, stage!) - - expect(Math.floor(width)).to.equal(Math.floor(width2)) - }) - - it('should account for font size styles', async () => { - let stage: HTMLElement - - const subject = await mount( -
{ - stage = el - }} - > - Lorem ipsum DOLOR SIT AMET. -
- ) - - const nodes = getNodes(stage!) - const width = measureText(nodes, stage!) - - await subject.setProps({ - style: { fontSize: '24px' } - }) - - const width2 = measureText(nodes, stage!) - expect(width).to.not.equal(width2) - }) - - it('should account for letter spacing styles', async () => { - let stage: HTMLElement - - const subject = await mount( -
{ - stage = el - }} - > - Lorem ipsum DOLOR SIT AMET. -
- ) - - const nodes = getNodes(stage!) - const width = measureText(nodes, stage!) - - await subject.setProps({ - style: { letterSpacing: '5px' } - }) - - const width2 = measureText(nodes, stage!) - expect(width).to.not.equal(width2) - }) -}) diff --git a/packages/ui-truncate-text/src/TruncateText/utils/__tests__/truncate.test.tsx b/packages/ui-truncate-text/src/TruncateText/utils/__tests__/truncate.test.tsx deleted file mode 100644 index a8a59b6de4..0000000000 --- a/packages/ui-truncate-text/src/TruncateText/utils/__tests__/truncate.test.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015 - present Instructure, Inc. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import React from 'react' -import { expect, mount, spy } from '@instructure/ui-test-utils' -import { within } from '@instructure/ui-utils' - -import truncate from '../truncate' - -describe('truncate', () => { - const defaultText = 'Hello world! This is a long string that should truncate' - - it('should truncate text when no options are given', async () => { - let stage: Element | null - await mount( -
- { - stage = el - }} - > - {defaultText} - -
- ) - truncate(stage!) - const text = stage!.textContent! - - expect(text.indexOf('truncate')).to.equal(-1) - expect(text.indexOf('\u2026')).to.not.equal(-1) - }) - - it('should truncate in the middle of a string', async () => { - let stage: Element | null - await mount( -
- { - stage = el - }} - > - {defaultText} - -
- ) - - truncate(stage!, { position: 'middle' }) - const text = stage!.textContent! - - expect(text.indexOf('long')).to.equal(-1) - expect(text.indexOf('Hello')).to.not.equal(-1) - expect(text.indexOf('truncate')).to.not.equal(-1) - expect(text.indexOf('\u2026')).to.not.equal(-1) - }) - - it('should truncate at words', async () => { - let stage: Element | null - await mount( -
- { - stage = el - }} - > - {defaultText} - -
- ) - - truncate(stage!, { truncate: 'word' }) - - const text = stage!.textContent! - - expect(text.indexOf('string')).to.equal(-1) - expect(text.indexOf('st')).to.equal(-1) - expect(text.indexOf('long')).to.not.equal(-1) - }) - - it('should allow custom ellipsis', async () => { - let stage: Element | null - await mount( -
- { - stage = el - }} - > - {defaultText} - -
- ) - - truncate(stage!, { ellipsis: '(...)' }) - const text = stage!.textContent - - expect(text!.slice(-5)).to.equal('(...)') - }) - - it('should preserve node structure', async () => { - let stage: Element | null - await mount( -
-

{ - stage = el - }} - className="testClass" - > - Hello world! This is a long string that{' '} - should truncate -

-
- ) - - truncate(stage!) - - expect(stage!.childNodes[1].nodeType).to.equal(1) - expect(stage!.childNodes[2].nodeType).to.equal(3) - expect(stage!.children.length).to.equal(2) - expect(stage!.nodeName).to.equal('P') - expect(stage!.className).to.equal('testClass') - }) - - it('should preserve attributes on nodes', async () => { - let stage: Element | null - let link: HTMLAnchorElement | null - await mount( -
- { - stage = el - }} - > - This is a{' '} - { - link = el - }} - href="http://google.com" - className="tester" - > - text link - {' '} - with classes and an href. - -
- ) - - truncate(stage!) - const attr: Record = link!.attributes! - expect(attr.length).to.equal(2) - expect(attr.href).to.exist() - expect(attr.class).to.exist() - }) - - it('should calculate max width properly', async () => { - let textContainer: Element | null - let stage: Element | null - await mount( -
-
- { - textContainer = el - }} - > - {defaultText} - -
-
{ - stage = el - }} - > - {defaultText} -
-
-
-
- ) - - const result = truncate(stage!) - - const maxWidth = result!.constraints.width - const actualMax = textContainer!.getBoundingClientRect().width - - expect(within(maxWidth, actualMax, 1)).to.be.true() - }) - - it('should calculate `maxLines: auto` correctly', async () => { - let stage: Element | null - await mount( -
- { - stage = el - }} - > - {defaultText} - -
- ) - - const result = truncate(stage!, { maxLines: 'auto' }) - const text = stage!.textContent - - expect(text).to.not.equal({ defaultText }) - expect(text!.length).to.not.equal(1) - expect(result!.constraints.lines).to.equal(4) - }) - - it('should calculate height correctly when `maxLines` is not `auto`', async () => { - let stage: Element | null - await mount( -
- { - stage = el - }} - > - {defaultText} - -
- ) - - const result = truncate(stage!)! - const text = stage!.textContent! - - expect(text.length).to.not.equal(1) - expect(result.constraints.height).to.equal(22.4) - }) - - it('should escape node content', async () => { - const log = spy(console, 'log') - const content = '">' - - let stage: Element | null - await mount( -
- { - stage = el - }} - > - {content} - -
- ) - - truncate(stage!) - - expect(stage!.textContent).to.equal(content) - expect(log).to.not.have.been.calledWith('hello world') - }) - - it('should truncate when visually hidden', async () => { - let stage: Element | null - await mount( -
- { - stage = el - }} - > - {defaultText} - -
- ) - truncate(stage!) - const text = stage!.textContent! - - expect(text.indexOf('truncate')).to.equal(-1) - expect(text.indexOf('\u2026')).to.not.equal(-1) - }) -}) diff --git a/packages/ui-truncate-text/tsconfig.build.json b/packages/ui-truncate-text/tsconfig.build.json index c8261fee48..b81fbe7792 100644 --- a/packages/ui-truncate-text/tsconfig.build.json +++ b/packages/ui-truncate-text/tsconfig.build.json @@ -7,6 +7,7 @@ }, "include": ["./src"], "references": [ + { "path": "../ui-axe-check/tsconfig.build.json" }, { "path": "../console/tsconfig.build.json" }, { "path": "../debounce/tsconfig.build.json" }, { "path": "../emotion/tsconfig.build.json" },