├── .dockerignore ├── .eslintrc.js ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── .prettierignore ├── .release-it.json ├── .travis.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── __tests__ ├── .eslintrc.js ├── app │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ └── pages │ │ │ ├── atom.js │ │ │ ├── blocks.js │ │ │ ├── custom-ellipsis-function.js │ │ │ ├── custom-ellipsis-object.js │ │ │ ├── custom-ellipsis-string.js │ │ │ ├── custom-ellipsis.js │ │ │ ├── long-word.js │ │ │ ├── no-truncation.js │ │ │ ├── one-line-words.js │ │ │ ├── one-line.js │ │ │ ├── react-components.js │ │ │ ├── resize.js │ │ │ └── three-line.js │ └── yarn.lock ├── browser.js ├── screenshot.js ├── screenshots │ ├── atom.png │ ├── blocks.png │ ├── custom-ellipsis-function.png │ ├── custom-ellipsis-object.png │ ├── custom-ellipsis-string.png │ ├── custom-ellipsis.png │ ├── long-word.png │ ├── no-truncation.png │ ├── one-line-words.png │ ├── one-line.png │ ├── react-components.png │ ├── resize-resized.png │ ├── resize.png │ └── three-line.png └── utils.js ├── demo └── src │ ├── Avatar.js │ ├── index.js │ └── styles.css ├── index.d.ts ├── nwb.config.js ├── package.json ├── prettier.config.js ├── src ├── atom.js ├── index.js └── tokenize-rules.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /es 3 | /lib 4 | /umd 5 | /demo 6 | public 7 | npm-debug.log* 8 | package-lock.json 9 | yarn-error.log 10 | .cache 11 | .git -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['react-app', 'prettier', 'prettier/react'], 3 | parser: 'babel-eslint', 4 | env: { 5 | browser: true, 6 | es6: true, 7 | jest: true, 8 | }, 9 | settings: { 10 | ecmascript: 6, 11 | jsx: true, 12 | }, 13 | rules: { 14 | 'no-return-assign': [2, 'except-parens'], 15 | 'newline-before-return': 2, 16 | 'import/no-extraneous-dependencies': [ 17 | 2, 18 | { devDependencies: ['demo/**/*.js', '__tests__/**/*.js'] }, 19 | ], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Please create a simple repro case using codesandbox.io (or similar online tool) to help us speed up the process. 15 | 16 | Reports with no online repro case will likely not move forward before such a case is provided. 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | */screenshots/*-diff.png 2 | node_modules 3 | /es 4 | /lib 5 | /umd 6 | /demo/dist 7 | public 8 | npm-debug.log* 9 | package-lock.json 10 | yarn-error.log 11 | .cache 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | build/ 3 | dist/ 4 | node_modules/ 5 | __snapshots__ 6 | README.md 7 | __tests__/app/.cache 8 | __tests__/app/public 9 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "npm run build", 3 | "github": { 4 | "release": false 5 | }, 6 | "prompt": { 7 | "src": { 8 | "publish": true, 9 | "release": false 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: node_js 3 | node_js: 4 | - node 5 | services: 6 | - docker 7 | before_install: 8 | - docker build -t react-truncate-markup . 9 | - docker images 10 | install: 11 | - yarn 12 | script: 13 | - yarn test:ci 14 | notifications: 15 | email: false 16 | branches: 17 | only: master 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We’d love to accept your contributions to this project. There are just a few small guidelines you need to follow. 4 | 5 | 6 | ## Project setup 7 | 8 | 1. Fork and clone the repo 9 | 2. `yarn` or `npm install` to install project's dependencies 10 | 3. `yarn start` to start the demo app to see what affect your changes have 11 | 12 | ## Development 13 | 14 | There is a testing app available in `__tests__/app` - after installing its dependencies, run `yarn develop` in the `__tests__/app` directory. It will start a local development server that will autoupdate on any source code change. 15 | 16 | There are few testing scenarios created in `__tests__/app/src/pages` or you can create your own by creating a new file in the `pages` directory. 17 | 18 | ## Testing and linting 19 | 20 | We use [Puppeteer](https://github.com/GoogleChrome/puppeteer) for screenshot testing, and because of (font) rendering inconsistencies between platforms, [Docker](https://www.docker.com/community-edition) image is used to keep the testing environment consistent. 21 | 22 | Once Docker is installed and running locally, run `yarn test` to make sure tests and linter are passing before committing your changes. 23 | 24 | To add a new screenshot test scenario, create a new file in `__tests__/app/src/pages` and it will be automatically picked up in the next test run, creating a new screenshot snapshot in the process. 25 | 26 | ## Code reviews 27 | 28 | All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. See [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 2 | RUN apt-get update 3 | RUN apt-get install -yyq libappindicator1 libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 4 | 5 | RUN mkdir /app 6 | ADD . /app 7 | WORKDIR /app 8 | 9 | RUN yarn 10 | RUN cd __tests__/app && yarn 11 | 12 | CMD cd __tests__/app && yarn build && cd ../.. && yarn lint-src && yarn jest screenshot.js 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Patrik Piskay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Truncate Markup 2 | 3 | [![Travis](https://img.shields.io/travis/patrik-piskay/react-truncate-markup.svg?style=flat-square)](https://travis-ci.org/patrik-piskay/react-truncate-markup) 4 | [![version](https://img.shields.io/npm/v/react-truncate-markup.svg?style=flat-square)](https://www.npmjs.com/package/react-truncate-markup) 5 | [![License](https://img.shields.io/npm/l/react-truncate-markup.svg?style=flat-square)](https://github.com/patrik-piskay/react-truncate-markup/blob/master/LICENSE.md) 6 | 7 | React component for truncating JSX markup. 8 | 9 | [Examples with code snippets](https://react-truncate-markup.patrik-piskay.now.sh) 10 | [CodeSandbox demo](https://codesandbox.io/s/4w2jrplym4) 11 | 12 | ## Why? 13 | 14 | Few use cases for using JS truncating instead of the CSS one: 15 | 16 | - you need to support IE, Firefox or Edge (and cannot use `webkit-line-clamp`) for multi-line truncation 17 | - you need a custom ellipsis, potentially with more text (`show more` link, indicator of how many records were hidden by truncation, etc.) 18 | 19 | --- 20 | 21 | Most solutions that already exist (like [react-truncate](https://github.com/One-com/react-truncate) or [React-Text-Truncate](https://github.com/ShinyChang/React-Text-Truncate)) use HTML5 `canvas` (and its `measureText` method) for measuring text width to determine whether (and where) the provided text should be truncated. 22 | 23 | While this approach is valid, it has its limitations - it works only for **plain text**, and not for **JSX markup**. You might want to use JSX when parts of the text have different style (like `color` or `font-weight`). 24 | 25 | ## How? 26 | 27 | Because we need to determine how to truncate provided content _after_ all the layout and styles were applied, we need to actually render it in browser (instead of rendering it off-screen in canvas). 28 | 29 | By using a binary search approach (_splitting JSX in half and checking if the text + ellipsis fit the container, and if not, splitting it in half again, and so on_), depending on the size (and depth) of the markup, it usually takes only a few rerenders to get the final, truncated markup. 30 | 31 | Performance was not an issue for our use cases (e.g. using `TruncateMarkup` twice per list item in a dropdown list containing dozens of items), there is no text movement visible on the screen (but YMMV). 32 | 33 | > **_Note:_** Because this package depends on browser rendering, all elements inside `` need to be visible. If you need to hide or show some parts of your UI, consider conditionally rendering them instead of setting `display: none`/`display: block` style on the elements. 34 | 35 | ## Installation 36 | 37 | ```bash 38 | npm install --save react-truncate-markup 39 | # or 40 | yarn add react-truncate-markup 41 | ``` 42 | 43 | > This package also depends on `react` and `prop-types`. Please make sure you have those installed as well. 44 | 45 | Importing: 46 | 47 | ```js 48 | // using ES6 modules 49 | import TruncateMarkup from 'react-truncate-markup'; 50 | 51 | // using CommonJS modules 52 | const TruncateMarkup = require('react-truncate-markup').default; 53 | ``` 54 | 55 | Or using script tags and globals: 56 | 57 | ```html 58 | 59 | ``` 60 | 61 | And accessing the global variable: 62 | 63 | ```js 64 | const TruncateMarkup = ReactTruncateMarkup.default; 65 | ``` 66 | 67 | ## Usage 68 | 69 | ```jsx 70 |
/* or any wrapper */ 71 | 72 |
73 | /* ... any markup ... */ 74 | 75 | {this.props.subject}: 76 | 77 | {` `} 78 | {this.props.message} 79 |
80 |
81 |
82 | ``` 83 | 84 | > #### :warning: Warning 85 | > 86 | > Only inlined [DOM elements](https://reactjs.org/docs/dom-elements.html) are supported when using this library. When trying to truncate React components (class or function), `` will warn about it, skip truncation and display the whole content instead. For more details, please read [this comment](https://github.com/patrik-piskay/react-truncate-markup/issues/12#issuecomment-444761758). 87 | > 88 | > Or, since version 5, you can take advantage of the [`` component](#truncatemarkupatom-). 89 | 90 | ## Props 91 | 92 | ### `children` 93 | 94 | It's required that only 1 element is passed as `children`. 95 | 96 | > Correct: 97 | 98 | ```jsx 99 | 100 |
101 | /* ... markup ... */ 102 |
103 |
104 | ``` 105 | 106 | > Incorrect: 107 | 108 | ```jsx 109 | 110 | /* ... markup ... */ 111 |
/* ... */
112 |
/* ... */
113 |
114 | ``` 115 | 116 | ### `lines` 117 | 118 | > default value: `1` 119 | 120 | Maximum number of displayed lines of text. 121 | 122 | ### `ellipsis` 123 | 124 | > default value: `...` 125 | 126 | Appended to the truncated text. 127 | 128 | One of type: `[string, JSX Element, function]` 129 | 130 | - `string`: `...` 131 | - `JSX Element`: `... ` 132 | - `function`: `function(jsxElement) { /* ... */ }` 133 | 134 | Ellipsis callback function receives new _(truncated)_ `` children as an argument so it can be used for determining what the final ellipsis should look like. 135 | 136 | ```jsx 137 | const originalText = '/* ... */'; 138 | 139 | const wordsLeftEllipsis = (rootEl) => { 140 | const originalWordCount = originalText.match(/\S+/g).length; 141 | const newTruncatedText = rootEl.props.children; 142 | const currentWordCount = newTruncatedText.match(/\S+/g).length; 143 | 144 | return `... (+${originalWordCount - currentWordCount} words)`; 145 | } 146 | 147 | 148 |
149 | {originalText} 150 |
151 |
152 | ``` 153 | 154 | ### `lineHeight` 155 | 156 | > default value: auto-detected 157 | 158 | Numeric value for desired line height in pixels. Generally it will be auto-detected but it can be useful in some cases when the auto-detected value needs to be overridden. 159 | 160 | ### `onTruncate` 161 | 162 | > function(wasTruncated: bool) | optional 163 | 164 | A callback that gets called after truncation. It receives a bool value - `true` if the input markup was truncated, `false` when no truncation was needed. 165 | 166 | > _Note_: To prevent infinite loops, _onTruncate_ callback gets called only after the initial run (on mount), any subsequent props/children updates will trigger a recomputation, but _onTruncate_ won't get called for these updates. 167 | > 168 | > If you, however, wish to have _onTruncate_ called after some update, [change the `key` prop](https://reactjs.org/docs/reconciliation.html#keys) on the `` component - it will make React to remount the component, instead of updating it. 169 | 170 | ### `tokenize` 171 | 172 | > default value: `characters` 173 | 174 | By default, any single character is considered the smallest, undividable entity, so the input markup can be truncated at any point (even midword). 175 | To override this behaviour, you can set the `tokenize` prop to following values: 176 | - `characters` - _[default]_ the input text can be truncated at any point 177 | - `words` - each word, separated by a whitespace character, is undividable entity. The only exception to this are words separated by the ` ` character, which are still honored and can be used in case you want to keep the words together 178 | 179 | ## `` 180 | 181 | Atoms serve as a way to let `` know that the content they contain is not splittable - it either renders in full or does not render at all. 182 | 183 | There are two main applications of Atoms: 184 | 1. you want to control at what level the truncation happens (and splitting on the word level using `tokenize="word"` is not enough), e.g. split text by paragraphs 185 | 2. you want/need to use other components inside `` 186 | 187 | On itself, `` will not truncate any content that contains other components (see the [warning box](#warning-warning) above). But it's still a useful feature. 188 | 189 | Consider this case: 190 | We want to render a list of avatars and if we run out of space, we want to render however many avatars fit, plus a custom message "+X more users", with X being the number of users that are not rendered. 191 | 192 | ```jsx 193 | {/* renders "+X more users" */}}> 194 |
195 | {props.users.map((user) => ( 196 | 197 | ))} 198 |
199 |
200 | ``` 201 | 202 | This would not work because `` cannot split anything inside other components _(in this case, ``)_, so it bails out and doesn't even attempt to truncate. But by explicitely wrapping these components in `` we say we are ok with it being treated as a single piece (rendered either in full or not rendered at all), whether they contain other components or not. 203 | 204 | ```jsx 205 | {/* renders "+X more users" */}}> 206 |
207 | {props.users.map((user) => ( 208 | 209 | 210 | 211 | ))} 212 |
213 |
214 | ``` 215 | 216 | You can see this example in action in the [examples/demo app](#react-truncate-markup). 217 | 218 | ## Contributing 219 | 220 | Read more about project setup and contributing in [CONTRIBUTING.md](https://github.com/patrik-piskay/react-truncate-markup/blob/master/CONTRIBUTING.md) 221 | 222 | ## License 223 | 224 | Released under MIT license. 225 | 226 | Copyright © 2022-present Patrik Piskay. 227 | -------------------------------------------------------------------------------- /__tests__/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | jasmine: true, 6 | }, 7 | settings: { 8 | ecmascript: 6, 9 | jsx: true, 10 | }, 11 | rules: { 12 | 'no-console': 0, 13 | 14 | 'react/no-multi-comp': 0, 15 | 'react/prop-types': 0, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /__tests__/app/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 gatsbyjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /__tests__/app/README.md: -------------------------------------------------------------------------------- 1 | # gatsby-starter-default 2 | The default Gatsby starter. 3 | 4 | For an overview of the project structure please refer to the [Gatsby documentation - Building with Components](https://www.gatsbyjs.org/docs/building-with-components/). 5 | 6 | ## Install 7 | 8 | Make sure that you have the Gatsby CLI program installed: 9 | ```sh 10 | npm install --global gatsby-cli 11 | ``` 12 | 13 | And run from your CLI: 14 | ```sh 15 | gatsby new gatsby-example-site 16 | ``` 17 | 18 | Then you can run it by: 19 | ```sh 20 | cd gatsby-example-site 21 | gatsby develop 22 | ``` 23 | 24 | ## Deploy 25 | 26 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/gatsbyjs/gatsby-starter-default) 27 | -------------------------------------------------------------------------------- /__tests__/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-starter-default", 3 | "description": "Gatsby default starter", 4 | "version": "1.0.0", 5 | "author": "Kyle Mathews ", 6 | "dependencies": { 7 | "gatsby": "^1.9.247", 8 | "react": "^16.4.1", 9 | "react-dom": "^16.4.1" 10 | }, 11 | "keywords": [ 12 | "gatsby" 13 | ], 14 | "license": "MIT", 15 | "scripts": { 16 | "build": "gatsby build", 17 | "develop": "gatsby develop", 18 | "format": "prettier --write 'src/**/*.js'", 19 | "test": "echo \"Error: no test specified\" && exit 1" 20 | }, 21 | "devDependencies": { 22 | "prettier": "^1.12.0" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/gatsbyjs/gatsby-starter-default" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/atom.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const LoremIpsumPart = () => ( 10 | consectetur adipiscing elit, sed do eiusmod tempor incididunt 11 | ); 12 | 13 | const TestCase = () => ( 14 |
15 |
16 | 17 |
18 | Lorem ipsum dolor sit amet, 19 | 20 | 21 | 22 | ut labore et dolore magna aliqua. 23 |
24 |
25 |
26 |
27 | 28 |
29 | Lorem ipsum dolor sit amet, 30 | 31 | consectetur (skipping...) tempor incididunt 32 | 33 | ut labore et dolore magna aliqua. 34 |
35 |
36 |
37 |
38 | ); 39 | 40 | export default TestCase; 41 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/blocks.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const TestCase = () => ( 5 |
6 | 7 | 8 |
line1
9 |
line2
10 |
11 |
12 | 13 | 14 | 15 |
16 | line1
line2
17 |
18 |
19 |
20 | 21 | 22 | 23 | line1
line2
24 |
25 |
26 |
27 | ); 28 | 29 | export default TestCase; 30 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/custom-ellipsis-function.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const userRoles = ['Admin', 'Editor', 'Collaborator', 'User']; 10 | 11 | class TestCase extends React.Component { 12 | rolesLeftEllipsis = (node) => { 13 | const displayedRoles = node.props.children[1]; 14 | 15 | const originalRolesCount = userRoles.length; 16 | const displayedRolesCount = displayedRoles 17 | ? displayedRoles.split(', ').filter(Boolean).length 18 | : 0; 19 | 20 | return ... (+{originalRolesCount - displayedRolesCount} roles); 21 | }; 22 | 23 | render() { 24 | return ( 25 |
26 | 27 |
28 | User roles: 29 | {userRoles.join(', ')} 30 |
31 |
32 |
33 | ); 34 | } 35 | } 36 | 37 | export default TestCase; 38 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/custom-ellipsis-object.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const link = { 10 | color: 'blue', 11 | textDecoration: 'underline', 12 | cursor: 'pointer', 13 | }; 14 | 15 | const longText = 16 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 17 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 18 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 19 | 20 | class TestCase extends React.Component { 21 | render() { 22 | const readMoreEllipsis = ( 23 | 24 | ...{' '} 25 | 26 | read more 27 | 28 | 29 | ); 30 | 31 | return ( 32 |
33 | 34 |
{longText}
35 |
36 |
37 | ); 38 | } 39 | } 40 | 41 | export default TestCase; 42 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/custom-ellipsis-string.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const longText = 10 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 11 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 12 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 13 | 14 | class TestCase extends React.Component { 15 | render() { 16 | return ( 17 |
18 | 19 |
{longText}
20 |
21 |
22 | ); 23 | } 24 | } 25 | 26 | export default TestCase; 27 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/custom-ellipsis.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const link = { 10 | color: 'blue', 11 | textDecoration: 'underline', 12 | cursor: 'pointer', 13 | }; 14 | 15 | const longText = 16 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 17 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 18 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 19 | 20 | class TestCase extends React.Component { 21 | state = { 22 | shouldTruncate: true, 23 | }; 24 | 25 | toggleTruncate = () => 26 | this.setState(({ shouldTruncate }) => ({ 27 | shouldTruncate: !shouldTruncate, 28 | })); 29 | 30 | readMoreEllipsis = () => ( 31 | 32 | ...{' '} 33 | 34 | read more 35 | 36 | 37 | ); 38 | 39 | render() { 40 | return ( 41 |
42 | {this.state.shouldTruncate ? ( 43 | 44 |
{longText}
45 |
46 | ) : ( 47 |
48 | {longText} 49 | 50 | {' show less'} 51 | 52 |
53 | )} 54 |
55 | ); 56 | } 57 | } 58 | 59 | export default TestCase; 60 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/long-word.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const TestCase = () => ( 10 |
11 |

One line

12 |
13 | 14 |
VeryLongWord-----------------------------------------------
15 |
16 |
17 | 18 |

Two line - fits

19 |
20 | 21 |
VeryLongWord-----------------------------------------------
22 |
23 |
24 | 25 |

Two line - truncate

26 |
27 | 28 |
29 | VeryLongWord---------------------------------------------------------------------------------------------- 30 |
31 |
32 |
33 |
34 | ); 35 | 36 | export default TestCase; 37 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/no-truncation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const TestCase = () => ( 5 |
6 | 7 |
Short text
8 |
9 |
10 | ); 11 | 12 | export default TestCase; 13 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/one-line-words.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const userRoles = 'Admin, Editor, Collaborator, User'; 10 | const userRolesPreceededByNbsps = 11 | '\xA0\xA0\xA0\xA0Admin, Editor, Collaborator, User'; 12 | const userRolesNbsp = ' \xA0Admin, Editor,\xA0Collaborator, User '; 13 | const userRolesAsJSX = ( 14 |  Admin, Editor, Collaborator, User 15 | ); 16 | const arrayOfUserRoles = ['Admin,', ' ', 'Editor, Collaborator, ', 'User']; 17 | const userRolesWithLineBreaks = userRoles.replace(/ /g, '\n'); 18 | 19 | const TestCase = () => ( 20 |
21 |

Basic String

22 |
23 | 24 |
25 | User roles: 26 | {userRoles} 27 |
28 |
29 |
30 | 31 |

String where Admin is preceeded by multiple  s

32 |
33 | 34 |
35 | User roles: 36 | {userRolesPreceededByNbsps} 37 |
38 |
39 |
40 | 41 |

String with: Editor, Collaborator

42 |
43 | 44 |
45 | User roles: 46 | {userRolesNbsp} 47 |
48 |
49 |
50 | 51 |

JSX with: Editor, Collaborator

52 |
53 | 54 |
55 | User roles: 56 | {userRolesAsJSX} 57 |
58 |
59 |
60 | 61 |

Array of strings

62 |
63 | 64 |
65 | {[User roles: ].concat(arrayOfUserRoles)} 66 |
67 |
68 |
69 | 70 |

String with words separated by newline characters \n

71 |
72 | 73 |
74 | User roles: 75 | {userRolesWithLineBreaks} 76 |
77 |
78 |
79 |
80 | ); 81 | 82 | export default TestCase; 83 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/one-line.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const userRoles = ['Admin', 'Editor', 'Collaborator', 'User']; 10 | 11 | const TestCase = () => ( 12 |
13 | 14 |
15 | User roles: 16 | {userRoles.join(', ')} 17 |
18 |
19 |
20 | ); 21 | 22 | export default TestCase; 23 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/react-components.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | marginBottom: '20px', 8 | }; 9 | 10 | const longText = 11 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 12 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 13 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 14 | 15 | const Comp1 = () => {longText}; 16 | 17 | class Comp2 extends React.Component { 18 | render() { 19 | return {longText}; 20 | } 21 | } 22 | 23 | const TestCase = () => ( 24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 |
32 |
33 | 34 |
35 | 36 |
37 |
38 |
39 |
40 | ); 41 | 42 | export default TestCase; 43 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/resize.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '25%', 7 | }; 8 | 9 | const longText = 10 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 11 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 12 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 13 | 14 | const TestCase = () => ( 15 |
16 | 17 |
{longText}
18 |
19 |
20 | ); 21 | 22 | export default TestCase; 23 | -------------------------------------------------------------------------------- /__tests__/app/src/pages/three-line.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TruncateMarkup from '../../../../src'; 3 | 4 | const style = { 5 | border: '1px dashed #c7c7c7', 6 | width: '250px', 7 | }; 8 | 9 | const longText = 10 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 11 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 12 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 13 | 14 | const TestCase = () => ( 15 |
16 | 17 |
{longText}
18 |
19 |
20 | ); 21 | 22 | export default TestCase; 23 | -------------------------------------------------------------------------------- /__tests__/browser.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import expect from 'expect'; 4 | 5 | import TruncateMarkup from '../src'; 6 | 7 | const consoleErrorMsg = (componentName) => 8 | `ReactTruncateMarkup tried to render <${componentName} />, but truncating React components is not supported, the full content is rendered instead. Only DOM elements are supported. Alternatively, you can take advantage of the component (see more in the docs https://github.com/patrik-piskay/react-truncate-markup/blob/master/README.md#truncatemarkupatom-).`; 9 | 10 | let div; 11 | 12 | const renderIntoDocument = (ReactComponent) => { 13 | ReactDOM.render(ReactComponent, div); 14 | }; 15 | 16 | describe('TruncateMarkup', () => { 17 | beforeEach(() => { 18 | div = document.createElement('div'); 19 | document.documentElement.appendChild(div); 20 | 21 | expect.spyOn(console, 'error'); 22 | expect.spyOn(console, 'warn'); 23 | }); 24 | 25 | afterEach(() => { 26 | document.documentElement.removeChild(div); 27 | 28 | expect.restoreSpies(); 29 | }); 30 | 31 | describe('Warnings', () => { 32 | it('should not warn when not using React components inside ', () => { 33 | renderIntoDocument( 34 | 35 |
36 | Some text 37 | More text 38 |
39 |
, 40 | ); 41 | 42 | expect(console.error).toNotHaveBeenCalled(); 43 | }); 44 | 45 | it('should not warn when using React components inside ', () => { 46 | const FnComponent = () => text; 47 | 48 | renderIntoDocument( 49 | 50 |
51 | Some text 52 | 53 | 54 | 55 |
56 |
, 57 | ); 58 | 59 | expect(console.error).toNotHaveBeenCalled(); 60 | }); 61 | 62 | it('should warn about using React function components inside ', () => { 63 | const FnComponent = () => text; 64 | 65 | renderIntoDocument( 66 | 67 |
68 | Some text 69 | 70 |
71 |
, 72 | ); 73 | 74 | expect(console.error).toHaveBeenCalledWith( 75 | consoleErrorMsg('FnComponent'), 76 | ); 77 | }); 78 | 79 | it('should warn about using React class components inside ', () => { 80 | class ClassComponent extends React.Component { 81 | render() { 82 | return text; 83 | } 84 | } 85 | 86 | renderIntoDocument( 87 | 88 |
89 | Some text 90 | 91 |
92 |
, 93 | ); 94 | 95 | expect(console.error).toHaveBeenCalledWith( 96 | consoleErrorMsg('ClassComponent'), 97 | ); 98 | }); 99 | }); 100 | 101 | describe('Warnings for tokenize prop', () => { 102 | it('should not warn when using a proper value for tokenize prop', () => { 103 | renderIntoDocument( 104 | 105 |
106 | Some text 107 | More text 108 |
109 |
, 110 | ); 111 | 112 | expect(console.warn).toNotHaveBeenCalled(); 113 | }); 114 | 115 | it('should warn when using unknown value for tokenize prop', () => { 116 | renderIntoDocument( 117 | 118 |
119 | Some text 120 | More text 121 |
122 |
, 123 | ); 124 | 125 | expect(console.error).toHaveBeenCalled(); 126 | }); 127 | }); 128 | 129 | describe('onAfterTruncate callback', () => { 130 | it('should be called with wasTruncated = false once', () => { 131 | const onTruncateCb = expect.createSpy(); 132 | 133 | renderIntoDocument( 134 | 135 |
136 | Some text 137 |
138 |
, 139 | ); 140 | 141 | expect(onTruncateCb.calls.length).toBe(1); 142 | expect(onTruncateCb.calls[0].arguments).toEqual([false]); 143 | }); 144 | 145 | it('should be called with wasTruncated = true once', () => { 146 | const onTruncateCb = expect.createSpy(); 147 | 148 | const longText = 149 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 150 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 151 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 152 | 153 | renderIntoDocument( 154 | 155 |
{longText}
156 |
, 157 | ); 158 | 159 | expect(onTruncateCb.calls.length).toBe(1); 160 | expect(onTruncateCb.calls[0].arguments).toEqual([true]); 161 | }); 162 | 163 | it('should not be called after update', () => { 164 | const onTruncateCb = expect.createSpy(); 165 | const onMount = expect.createSpy(); 166 | const onUpdate = expect.createSpy(); 167 | 168 | const shortText = 'Short text'; 169 | const longText = 170 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 171 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 172 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 173 | 174 | class TestCase extends React.Component { 175 | componentDidMount() { 176 | onMount(); 177 | } 178 | 179 | componentDidUpdate() { 180 | onUpdate(this.props.text); 181 | } 182 | 183 | render() { 184 | return ( 185 | 186 |
{this.props.text}
187 |
188 | ); 189 | } 190 | } 191 | 192 | renderIntoDocument(); 193 | renderIntoDocument(); 194 | 195 | expect(onTruncateCb.calls.length).toBe(1); 196 | expect(onTruncateCb.calls[0].arguments).toEqual([false]); 197 | 198 | expect(onMount.calls.length).toBe(1); 199 | expect(onUpdate.calls.length).toBe(1); 200 | expect(onUpdate.calls[0].arguments).toEqual([longText]); 201 | }); 202 | }); 203 | 204 | describe('children validation', () => { 205 | it('handles multiple arrays as children', () => { 206 | const instance = new TruncateMarkup({ 207 | children: ( 208 |
209 | {['1']} 210 | {['2']} 211 |
212 | ), 213 | }); 214 | 215 | expect(() => { 216 | expect(instance.isValid).toBe(true); 217 | }).toNotThrow(); 218 | }); 219 | 220 | it('handles nested arrays as children', () => { 221 | const instance = new TruncateMarkup({ 222 | children: ( 223 |
224 | {[]} 225 | {['1', '2', ['3', []]]} 226 |
227 | ), 228 | }); 229 | 230 | expect(() => { 231 | expect(instance.isValid).toBe(true); 232 | }).toNotThrow(); 233 | }); 234 | 235 | it('handles number as children', () => { 236 | const instance = new TruncateMarkup({ 237 | children:
{1}
, 238 | }); 239 | 240 | expect(() => { 241 | expect(instance.isValid).toBe(true); 242 | }).toNotThrow(); 243 | }); 244 | }); 245 | 246 | describe('truncate updates complexity', () => { 247 | const EIGHT_DIGITS = '12345678'; 248 | const spyOnPrototype = (cls, methodName) => { 249 | const fn = cls.prototype[methodName]; 250 | 251 | return expect.spyOn(cls.prototype, methodName).andCall(fn); 252 | }; 253 | 254 | let didMountSpy, didUpdateSpy, truncateFnSpy; 255 | 256 | beforeEach(() => { 257 | didMountSpy = spyOnPrototype(TruncateMarkup, 'componentDidMount'); 258 | didUpdateSpy = spyOnPrototype(TruncateMarkup, 'componentDidUpdate'); 259 | truncateFnSpy = spyOnPrototype(TruncateMarkup, 'truncate'); 260 | }); 261 | 262 | it('calls `truncate` only in componentDidMount() when everything fits', () => { 263 | renderIntoDocument( 264 | 265 |
1
266 |
, 267 | ); 268 | 269 | expect(didMountSpy.calls.length).toBe(1); 270 | expect(didUpdateSpy).toNotHaveBeenCalled(); 271 | expect(truncateFnSpy.calls.length).toBe(1); 272 | }); 273 | 274 | it('calls componentDidUpdate() 7 times for 16 characters', (done) => { 275 | renderIntoDocument( 276 | 277 | {/* even with too small space, 1 character will be displayed on 1st line */} 278 |
279 | {EIGHT_DIGITS.split('').join(' ')} 280 |
281 |
, 282 | ); 283 | function assertion(wasTruncated) { 284 | expect(wasTruncated).toBeTruthy(); 285 | // (5 as in 2^(5-1) === 16 chars) + 2 extra checks 286 | expect(didUpdateSpy.calls.length).toBe(7); 287 | done(); 288 | } 289 | }); 290 | 291 | it('calls componentDidUpdate() 8 times for 32 characters', (done) => { 292 | renderIntoDocument( 293 | 294 | {/* even with too small space, 1 character will be displayed on 1st line */} 295 |
296 | {(EIGHT_DIGITS + EIGHT_DIGITS).split('').join(' ')} 297 |
298 |
, 299 | ); 300 | function assertion(wasTruncated) { 301 | expect(wasTruncated).toBeTruthy(); 302 | // (6 as in 2^(6-1) === 32 chars) + 2 extra checks 303 | expect(didUpdateSpy.calls.length).toBe(8); 304 | done(); 305 | } 306 | }); 307 | 308 | it('calls componentDidUpdate() 6 times for 16 Atoms', (done) => { 309 | renderIntoDocument( 310 | 311 | {/* even with too small space, 1 atom will be displayed on 1st line */} 312 |
313 | {(EIGHT_DIGITS + EIGHT_DIGITS).split('').map((c) => ( 314 | {c + ' '} 315 | ))} 316 |
317 |
, 318 | ); 319 | function assertion(wasTruncated) { 320 | expect(wasTruncated).toBeTruthy(); 321 | // (5 as in 2^(5-1) === 16 atoms) + 1 extra check 322 | expect(didUpdateSpy.calls.length).toBe(6); 323 | done(); 324 | } 325 | }); 326 | }); 327 | }); 328 | -------------------------------------------------------------------------------- /__tests__/screenshot.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { startServer } = require('polyserve'); 3 | const puppeteer = require('puppeteer'); 4 | const ScreenshotTester = require('puppeteer-screenshot-tester'); 5 | 6 | const screenshotDiff = async (page, name) => { 7 | const tester = await ScreenshotTester(); 8 | 9 | return tester(page, name, { 10 | path: `${__dirname}/screenshots/${name}.png`, 11 | }); 12 | }; 13 | 14 | describe('Screenshot tests', () => { 15 | let server; 16 | let serverUrl; 17 | let browser; 18 | let page; 19 | 20 | beforeAll(async () => { 21 | server = await startServer({ 22 | root: `${__dirname}/app/public`, 23 | port: 4000, 24 | }); 25 | 26 | serverUrl = server.address(); 27 | }); 28 | 29 | afterAll(() => { 30 | server.close(); 31 | }); 32 | 33 | beforeEach(async () => { 34 | browser = await puppeteer.launch({ 35 | args: ['--no-sandbox'], 36 | }); 37 | page = await browser.newPage(); 38 | page.setViewport({ width: 800, height: 600 }); 39 | }); 40 | 41 | afterEach(() => browser.close()); 42 | 43 | fs.readdirSync(`${__dirname}/app/src/pages`).forEach((file) => { 44 | const [name] = file.split('.js'); 45 | 46 | it(`Screen: ${name}`, async () => { 47 | await page.goto(`http://${serverUrl.address}:${serverUrl.port}/${name}`); 48 | 49 | expect(await screenshotDiff(page, name)).toBe(true); 50 | }); 51 | }); 52 | 53 | it(`Screen: resize - resized`, async () => { 54 | page.setViewport({ width: 600, height: 600 }); 55 | await page.goto(`http://${serverUrl.address}:${serverUrl.port}/resize`); 56 | 57 | expect(await screenshotDiff(page, 'resize-resized')).toBe(true); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /__tests__/screenshots/atom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/atom.png -------------------------------------------------------------------------------- /__tests__/screenshots/blocks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/blocks.png -------------------------------------------------------------------------------- /__tests__/screenshots/custom-ellipsis-function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/custom-ellipsis-function.png -------------------------------------------------------------------------------- /__tests__/screenshots/custom-ellipsis-object.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/custom-ellipsis-object.png -------------------------------------------------------------------------------- /__tests__/screenshots/custom-ellipsis-string.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/custom-ellipsis-string.png -------------------------------------------------------------------------------- /__tests__/screenshots/custom-ellipsis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/custom-ellipsis.png -------------------------------------------------------------------------------- /__tests__/screenshots/long-word.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/long-word.png -------------------------------------------------------------------------------- /__tests__/screenshots/no-truncation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/no-truncation.png -------------------------------------------------------------------------------- /__tests__/screenshots/one-line-words.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/one-line-words.png -------------------------------------------------------------------------------- /__tests__/screenshots/one-line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/one-line.png -------------------------------------------------------------------------------- /__tests__/screenshots/react-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/react-components.png -------------------------------------------------------------------------------- /__tests__/screenshots/resize-resized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/resize-resized.png -------------------------------------------------------------------------------- /__tests__/screenshots/resize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/resize.png -------------------------------------------------------------------------------- /__tests__/screenshots/three-line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrik-piskay/react-truncate-markup/e4e823709c38dd76d07ba0441e90eeed560f7744/__tests__/screenshots/three-line.png -------------------------------------------------------------------------------- /__tests__/utils.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | import TOKENIZE_POLICY from '../src/tokenize-rules'; 4 | 5 | describe('TOKENIZE_POLICY', () => { 6 | describe('words: isAtomic', () => { 7 | it('checks empty string', () => { 8 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(''); 9 | expect(isAtomic).toBeTruthy(); 10 | }); 11 | it('checks string with only spaces', () => { 12 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(' '); 13 | expect(isAtomic).toBeTruthy(); 14 | }); 15 | it('checks string with spaces and  ', () => { 16 | const isAtomic = TOKENIZE_POLICY.words.isAtomic('\xA0 \xA0\xA0 '); 17 | expect(isAtomic).toBeTruthy(); 18 | }); 19 | it('checks word with no space', () => { 20 | const isAtomic = TOKENIZE_POLICY.words.isAtomic('word'); 21 | expect(isAtomic).toBeTruthy(); 22 | }); 23 | it('checks word surrounded by spaces', () => { 24 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(' word '); 25 | expect(isAtomic).toBeTruthy(); 26 | }); 27 | it('checks word surrounded by  ', () => { 28 | const isAtomic = TOKENIZE_POLICY.words.isAtomic('\xA0word\xA0'); 29 | expect(isAtomic).toBeTruthy(); 30 | }); 31 | it('checks word surrounded by spaces and then  ', () => { 32 | const isAtomic = TOKENIZE_POLICY.words.isAtomic('\xA0 word \xA0'); 33 | expect(isAtomic).toBeTruthy(); 34 | }); 35 | it('checks multiple   separated by spaces', () => { 36 | const isAtomic = TOKENIZE_POLICY.words.isAtomic('\xA0 \xA0 \xA0'); 37 | expect(isAtomic).toBeTruthy(); 38 | }); 39 | }); 40 | describe('words: tokenizeString', () => { 41 | it('checks three words', () => { 42 | const string = 'foo and bar'; 43 | const expectedTokens = ['foo', ' and', ' bar']; 44 | 45 | const actualTokens = TOKENIZE_POLICY.words.tokenizeString(string); 46 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(string); 47 | expect(isAtomic).toBeFalsy(); 48 | expect(actualTokens).toEqual(expectedTokens); 49 | }); 50 | it('checks three words surrounded by spaces', () => { 51 | const string = ' foo and bar '; 52 | const expectedTokens = [' foo', ' and', ' bar']; 53 | 54 | const actualTokens = TOKENIZE_POLICY.words.tokenizeString(string); 55 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(string); 56 | expect(isAtomic).toBeFalsy(); 57 | expect(actualTokens).toEqual(expectedTokens); 58 | }); 59 | it('checks three words surrounded by  ', () => { 60 | const string = '\xA0foo and bar\xA0'; 61 | const expectedTokens = ['\xA0foo', ' and', ' bar\xA0']; 62 | 63 | const actualTokens = TOKENIZE_POLICY.words.tokenizeString(string); 64 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(string); 65 | expect(isAtomic).toBeFalsy(); 66 | expect(actualTokens).toEqual(expectedTokens); 67 | }); 68 | it('checks three words surrounded by   and then spaces', () => { 69 | const string = ' \xA0foo and bar\xA0 '; 70 | const expectedTokens = [' \xA0foo', ' and', ' bar\xA0']; 71 | 72 | const actualTokens = TOKENIZE_POLICY.words.tokenizeString(string); 73 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(string); 74 | expect(isAtomic).toBeFalsy(); 75 | expect(actualTokens).toEqual(expectedTokens); 76 | }); 77 | it('checks three words surrounded by spaces and then   - trims trailing spaces', () => { 78 | const string = '\xA0 foo and bar \xA0'; 79 | const expectedTokens = ['\xA0 foo', ' and', ' bar']; 80 | 81 | const actualTokens = TOKENIZE_POLICY.words.tokenizeString(string); 82 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(string); 83 | expect(isAtomic).toBeFalsy(); 84 | expect(actualTokens).toEqual(expectedTokens); 85 | }); 86 | it('checks three words where first two have multiple spaces and   inbetween', () => { 87 | const string = 'foo \xA0 \xA0 and bar'; 88 | const expectedTokens = ['foo', ' \xA0 \xA0 and', ' bar']; 89 | 90 | const actualTokens = TOKENIZE_POLICY.words.tokenizeString(string); 91 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(string); 92 | expect(isAtomic).toBeFalsy(); 93 | expect(actualTokens).toEqual(expectedTokens); 94 | }); 95 | it('checks three words where first two have   inbetween', () => { 96 | const string = 'foo\xA0and bar'; 97 | const expectedTokens = ['foo\xA0and', ' bar']; 98 | 99 | const actualTokens = TOKENIZE_POLICY.words.tokenizeString(string); 100 | const isAtomic = TOKENIZE_POLICY.words.isAtomic(string); 101 | expect(isAtomic).toBeFalsy(); 102 | expect(actualTokens).toEqual(expectedTokens); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /demo/src/Avatar.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function Avatar({ user }) { 4 | return ( 5 |
6 | {user.name} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { render } from 'react-dom'; 3 | import { ResizableBox } from 'react-resizable'; 4 | import Prism from 'prismjs'; 5 | import Avatar from './Avatar'; 6 | import 'prismjs/components/prism-javascript'; 7 | import 'prismjs/components/prism-jsx'; 8 | import 'prismjs/themes/prism-tomorrow.css'; 9 | import 'react-resizable/css/styles.css'; 10 | 11 | import './styles.css'; 12 | 13 | import TruncateMarkup from '../../src'; 14 | 15 | const style = { 16 | border: '1px dashed #c7c7c7', 17 | width: '250px', 18 | }; 19 | const link = { 20 | color: 'blue', 21 | textDecoration: 'underline', 22 | cursor: 'pointer', 23 | }; 24 | 25 | const longText = 26 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ' + 27 | 'ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + 28 | 'laboris nisi ut aliquip ex ea commodo consequat.'; 29 | 30 | const userRoles = ['Admin', 'Editor', 'Collaborator', 'User']; 31 | 32 | const wordCountEllipsis = (node) => { 33 | const originalWordCount = longText.match(/\S+/g).length; 34 | const currentWordCount = node.props.children.match(/\S+/g).length; 35 | 36 | return `... (+${originalWordCount - currentWordCount} words)`; 37 | }; 38 | 39 | const rolesLeftEllipsis = (node) => { 40 | const displayedRoles = node.props.children[1]; 41 | 42 | const originalRolesCount = userRoles.length; 43 | const displayedRolesCount = displayedRoles 44 | ? displayedRoles.split(', ').filter(Boolean).length 45 | : 0; 46 | 47 | return `... (+${originalRolesCount - displayedRolesCount} roles)`; 48 | }; 49 | 50 | class OnTruncateCallback extends Component { 51 | state = { onTruncateCalledCount: 0 }; 52 | 53 | incrementTruncateCalledCount = (wasTruncated) => { 54 | this.setState((state) => ({ 55 | onTruncateCalledCount: state.onTruncateCalledCount + 1, 56 | lastWasTruncated: wasTruncated, 57 | })); 58 | }; 59 | render() { 60 | return ( 61 | 62 | 69 | 73 |
74 | User roles: 75 | {userRoles.join(', ')} 76 |
77 |
78 |
79 |
80 | onTruncate called:{' '} 81 | 82 | {this.state.onTruncateCalledCount}x 83 | 84 |
85 | {this.state.lastWasTruncated !== undefined && ( 86 |
87 | Did truncate? {this.state.lastWasTruncated ? 'Yes' : 'No'} 88 |
89 | )} 90 |
91 | ); 92 | } 93 | } 94 | 95 | const OnTruncateCallbackCodeHighlight = ( 96 |
 97 |      {
106 |     this.setState(state => ({
107 |       onTruncateCalledCount:
108 |         state.onTruncateCalledCount + 1,
109 |       lastWasTruncated: wasTruncated,
110 |     }));
111 |   }}
112 | >
113 |   
114 | User roles: 115 | {userRoles.join(', ')} 116 |
117 | 118 | 119 |
120 | onTruncate called: {this.state.onTruncateCalledCount}x 121 | Did truncate? {this.state.lastWasTruncated ? 'Yes' : 'No'} 122 |
123 | `, 124 | Prism.languages.javascript, 125 | ), 126 | }} 127 | /> 128 |
129 | ); 130 | 131 | const AvatarList = () => { 132 | const user = { 133 | name: 'Patrik Piskay', 134 | image: 135 | 'https://avatars2.githubusercontent.com/u/966953?s=460&u=6e5b5f2a85a02ace66548f5cf1a105a22f73fc71', 136 | }; 137 | 138 | const users = Array(6) 139 | .fill(user) 140 | .map((user, index) => ({ ...user, id: index })); 141 | 142 | const usersLeftEllipsis = (node) => { 143 | const usersRendered = node.props.children; 144 | 145 | return `+${users.length - usersRendered.length} more`; 146 | }; 147 | 148 | return ( 149 | 156 | 157 |
160 | {users.map((user) => ( 161 | 162 | 163 | 164 | ))} 165 |
166 |
167 |
168 | ); 169 | }; 170 | 171 | const Foo = (props) => props.children; 172 | 173 | class Demo extends Component { 174 | state = { shouldTruncate: true }; 175 | 176 | toggleTruncate = () => { 177 | this.setState((state) => ({ shouldTruncate: !state.shouldTruncate })); 178 | }; 179 | 180 | render() { 181 | const readMoreEllipsis = ( 182 | 183 | ...{' '} 184 | 185 | read more 186 | 187 | 188 | ); 189 | 190 | return ( 191 |
192 | 210 |
211 |

{``} Examples

212 | 213 |

Without truncating

214 | 215 |
216 |
217 |
218 | User roles: 219 | {userRoles.join(', ')} 220 |
221 |
222 | 223 |
224 |
225 |                 
232 |   User roles: 
233 |   {userRoles.join(', ')}
234 | 
235 | `, 236 | Prism.languages.javascript, 237 | ), 238 | }} 239 | /> 240 | 241 |
242 |
243 | 244 |

1 line truncating

245 | 246 |
247 |
248 | 249 |
250 | User roles: 251 | {userRoles.join(', ')} 252 |
253 |
254 |
255 | 256 |
257 |
258 |                 
265 |   
266 | User roles: 267 | {userRoles.join(', ')} 268 |
269 | 270 | `, 271 | Prism.languages.javascript, 272 | ), 273 | }} 274 | /> 275 |
276 |
277 |
278 | 279 |

3 line truncating

280 | 281 |
282 |
283 | 284 |
{longText}
285 |
286 |
287 | 288 |
289 |
290 |                 
295 |   
296 | {'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do ' + 297 | 'eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ' + 298 | 'ad minim veniam, quis nostrud exercitation ullamco laboris nisi ' + 299 | 'ut aliquip ex ea commodo consequat.'} 300 |
301 | 302 | `, 303 | Prism.languages.javascript, 304 | ), 305 | }} 306 | /> 307 |
308 |
309 |
310 | 311 |

With custom ellipsis

312 | 313 |
314 |
315 | {this.state.shouldTruncate ? ( 316 | 317 |
{longText}
318 |
319 | ) : ( 320 |
321 | {longText} 322 | 323 | {' show less'} 324 | 325 |
326 | )} 327 |
328 | 329 |
330 |
331 |                 
343 |     ...{' '}
344 |     
345 |       read more
346 |     
347 |   
348 | );
349 | 
350 | {this.state.shouldTruncate ? (
351 |   
352 |     
353 | {longText} 354 |
355 |
356 | ) : ( 357 |
358 | {longText} 359 | 360 | {' show less'} 361 | 362 |
363 | )} 364 | `, 365 | Prism.languages.javascript, 366 | ), 367 | }} 368 | /> 369 |
370 |
371 |
372 | 373 |

With ellipsis callback

374 | 375 |
376 |
377 | 378 |
379 | User roles: 380 | {userRoles.join(', ')} 381 |
382 |
383 |
384 | 385 |
386 |
387 |                  {
394 |   const displayedRoles = node.props.children[1];
395 | 
396 |   const originalRolesCount = userRoles.length;
397 |   const displayedRolesCount = displayedRoles
398 |     ? displayedRoles.split(', ').filter(Boolean).length
399 |     : 0;
400 | 
401 |   return \`... (+\${originalRolesCount - displayedRolesCount} roles)\`;
402 | };
403 | 
404 | 
405 |   
406 | User roles: 407 | {userRoles.join(', ')} 408 |
409 |
410 | `, 411 | Prism.languages.javascript, 412 | ), 413 | }} 414 | /> 415 |
416 |
417 |
418 | 419 |
420 |
421 | 422 |
{longText}
423 |
424 |
425 | 426 |
427 |
428 |                  {
439 |   const originalWordCount = longText.match(/\\S+/g).length;
440 |   const currentWordCount = node.props.children.match(/\\S+/g).length;
441 | 
442 |   return \`... (+\${originalWordCount - currentWordCount} words)\`;
443 | };
444 | 
445 | 
446 |   
447 | {longText} 448 |
449 |
450 | `, 451 | Prism.languages.javascript, 452 | ), 453 | }} 454 | /> 455 |
456 |
457 |
458 | 459 |

More markup

460 | 461 |
462 |
463 | 464 |
465 | 466 | Lorem ipsum dolor sit amet,{' '} 467 | 468 | 469 | consectetur adipiscing elit,{' '} 470 | 471 | 472 | sed do eiusmod tempor incididunt{' '} 473 | 474 | 475 | ut labore et dolore magna aliqua.{' '} 476 | 477 | 478 | Ut enim ad minim veniam, quis nostrud exercitation ullamco 479 | 480 |
481 |
482 |
483 | 484 |
485 |
486 |                 
491 |   
492 | 493 | Lorem ipsum dolor sit amet,{' '} 494 | 495 | 496 | consectetur adipiscing elit,{' '} 497 | 498 | 499 | sed do eiusmod tempor incididunt{' '} 500 | 501 | 502 | ut labore et dolore magna aliqua.{' '} 503 | 504 | 505 | Ut enim ad minim veniam, quis nostrud exercitation ullamco 506 | 507 |
508 | 509 | `, 510 | Prism.languages.javascript, 511 | ), 512 | }} 513 | /> 514 |
515 |
516 |
517 | 518 |

In resizable box

519 | 520 |
521 |
522 | 529 | 530 |
531 | User roles: 532 | {userRoles.join(', ')} 533 |
534 |
535 |
536 |
537 | 538 |
539 |
540 |                  {
547 |   const displayedRoles = node.props.children[1];
548 | 
549 |   const originalRolesCount = userRoles.length;
550 |   const displayedRolesCount = displayedRoles
551 |     ? displayedRoles.split(', ').filter(Boolean).length
552 |     : 0;
553 | 
554 |   return \`... (+\${originalRolesCount - displayedRolesCount} roles)\`;
555 | };
556 | 
557 | 
558 |   
559 | User roles: 560 | {userRoles.join(', ')} 561 |
562 |
563 | `, 564 | Prism.languages.javascript, 565 | ), 566 | }} 567 | /> 568 |
569 |
570 |
571 | 572 |
573 |
574 | 581 | 582 |
{longText}
583 |
584 |
585 |
586 | 587 |
588 |
589 |                  {
600 |   const originalWordCount = longText.match(/\\S+/g).length;
601 |   const currentWordCount = node.props.children.match(/\\S+/g).length;
602 | 
603 |   return \`... (+\${originalWordCount - currentWordCount} words)\`;
604 | };
605 | 
606 | 
607 |   
{longText}
608 |
609 | `, 610 | Prism.languages.javascript, 611 | ), 612 | }} 613 | /> 614 |
615 |
616 |
617 | 618 |

onTruncate callback

619 | 620 |
621 |
622 | 623 |
624 | 625 |
{OnTruncateCallbackCodeHighlight}
626 |
627 | 628 |

Tokenize: words

629 | 630 |
631 |
632 | 639 | 644 |
{longText}
645 |
646 |
647 |
648 | 649 |
650 |
651 |                  {
662 |   const originalWordCount = longText.match(/\\S+/g).length;
663 |   const currentWordCount = node.props.children.match(/\\S+/g).length;
664 | 
665 |   return \`... (+\${originalWordCount - currentWordCount} words)\`;
666 | };
667 | 
668 | 
669 |   
{longText}
670 |
671 | `, 672 | Prism.languages.javascript, 673 | ), 674 | }} 675 | /> 676 |
677 |
678 |
679 | 680 |

TruncateMarkup.Atom

681 |
682 |
683 | 690 | 691 |
692 | 693 | (NORMAL text - splittable anywhere) 694 | 695 | 696 | (atomic, not splittable) 697 | 698 | 699 | 700 | (Foo Component that can be used, not splittable) 701 | 702 | 703 |
704 |
705 |
706 |
707 |
708 |
709 |                 
714 |   
715 | 716 | (NORMAL text - splittable anywhere) 717 | 718 | 719 | 720 | (atomic, not splittable) 721 | 722 | 723 | 724 | 725 | (Foo Component that can be used, not splittable) 726 | 727 | 728 |
729 | 730 | `, 731 | Prism.languages.javascript, 732 | ), 733 | }} 734 | /> 735 |
736 |
737 |
738 | 739 |

Avatars example

740 |
741 |
742 | 743 |
744 | 745 |
746 |
747 |                  ({ ...user, id: index }));
760 | 
761 | const usersLeftEllipsis = (node) => {
762 |   const usersRendered = node.props.children;
763 | 
764 |   return \`+\${users.length - usersRendered.length} more\`;
765 | };
766 | 
767 | 
768 |   
771 | {users.map((user) => ( 772 | 773 | 774 | 775 | ))} 776 |
777 |
778 | `, 779 | Prism.languages.javascript, 780 | ), 781 | }} 782 | /> 783 |
784 |
785 |
786 |
787 | 788 | ); 789 | } 790 | } 791 | 792 | render(, document.querySelector('#demo')); 793 | -------------------------------------------------------------------------------- /demo/src/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: Helvetica, sans-serif, Arial; 3 | } 4 | 5 | body { 6 | max-width: 1200px; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | .site { 12 | display: flex; 13 | } 14 | 15 | .side { 16 | position: fixed; 17 | top: 0; 18 | bottom: 0; 19 | width: 230px; 20 | padding-left: 10px; 21 | background-color: #eee; 22 | font-size: 14px; 23 | line-height: 1.8; 24 | z-index: 1; 25 | } 26 | .side .title { 27 | color: #525252; 28 | font-weight: bold; 29 | margin-top: 20px; 30 | text-transform: uppercase; 31 | } 32 | .side .links { 33 | padding-left: 10px; 34 | } 35 | .side .links .indented { 36 | padding-left: 15px; 37 | } 38 | .side a { 39 | display: block; 40 | color: black; 41 | text-decoration: none; 42 | } 43 | .side a:hover { 44 | text-decoration: underline; 45 | } 46 | 47 | .main { 48 | flex: 1; 49 | margin: 15px 0 15px 270px; 50 | padding: 0 15px; 51 | } 52 | 53 | .block { 54 | display: flex; 55 | flex-direction: column; 56 | } 57 | 58 | .block.margin { 59 | margin-top: 20px; 60 | } 61 | 62 | h1 { 63 | font-size: 30px; 64 | font-weight: 300; 65 | } 66 | 67 | h2 { 68 | font-size: 22px; 69 | line-height: 24px; 70 | margin: 25px 0 15px; 71 | padding-top: 20px; 72 | font-weight: 300; 73 | } 74 | 75 | .box { 76 | border: 1px solid #a1a8a8; 77 | padding: 5px; 78 | } 79 | 80 | .eval { 81 | background-color: #f8f8f8; 82 | padding: 10px; 83 | } 84 | 85 | .code { 86 | font-size: 14px; 87 | } 88 | 89 | pre { 90 | margin: 0 !important; 91 | } 92 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface TruncateProps { 4 | children?: React.ReactNode; 5 | lines?: number; 6 | ellipsis?: React.ReactNode | ((element: React.ReactNode) => React.ReactNode); 7 | lineHeight?: number | string; 8 | tokenize?: string; 9 | onTruncate?: (wasTruncated: boolean) => any; 10 | } 11 | 12 | declare class TruncateMarkup extends React.Component { 13 | static Atom: React.ComponentType<{ children: React.ReactNode }>; 14 | } 15 | 16 | export default TruncateMarkup; 17 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-component', 3 | npm: { 4 | esModules: true, 5 | umd: { 6 | global: 'ReactTruncateMarkup', 7 | externals: { 8 | react: 'React', 9 | }, 10 | }, 11 | }, 12 | karma: { 13 | testFiles: ['__tests__/browser.js', '__tests__/utils.js'], 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-truncate-markup", 3 | "version": "5.1.2", 4 | "description": "React component for truncating JSX markup", 5 | "main": "lib/index.js", 6 | "jsnext:main": "es/index.js", 7 | "types": "index.d.ts", 8 | "module": "es/index.js", 9 | "files": [ 10 | "es", 11 | "lib", 12 | "umd", 13 | "index.d.ts" 14 | ], 15 | "keywords": [ 16 | "react", 17 | "truncate", 18 | "markup", 19 | "jsx", 20 | "ellipsis" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/patrik-piskay/react-truncate-markup" 25 | }, 26 | "publishConfig": { 27 | "registry": "https://registry.npmjs.org/" 28 | }, 29 | "lint-staged": { 30 | "*.{js,ts}": [ 31 | "prettier --write", 32 | "git add" 33 | ] 34 | }, 35 | "scripts": { 36 | "precommit": "lint-staged", 37 | "start": "nwb serve-react-demo", 38 | "build": "nwb build-react-component", 39 | "clean": "nwb clean-module && nwb clean-demo", 40 | "test": "yarn lint && yarn browser-test && yarn screenshot-test", 41 | "test:ci": "yarn lint && yarn browser-test && docker run -t react-truncate-markup", 42 | "lint": "yarn lint-src && yarn lint-demo", 43 | "lint-src": "eslint src/**/*.js", 44 | "lint-demo": "eslint demo/src/*.js", 45 | "browser-test": "yarn nwb test", 46 | "browser-test:watch": "nwb test-react --server", 47 | "screenshot-test": "docker build -t react-truncate-markup . && docker run -t -v ${PWD}:/app -v /app/node_modules -v /app/__tests__/app/node_modules react-truncate-markup", 48 | "release": "release-it", 49 | "prettier": "prettier --write src demo __tests__" 50 | }, 51 | "peerDependencies": { 52 | "react": ">=16.3" 53 | }, 54 | "devDependencies": { 55 | "@typescript-eslint/eslint-plugin": "1.x", 56 | "@typescript-eslint/parser": "1.x", 57 | "babel-eslint": "10.x", 58 | "eslint": "6.x", 59 | "eslint-config-prettier": "2.6.0", 60 | "eslint-config-react-app": "^5.0.1", 61 | "eslint-plugin-flowtype": "3.x", 62 | "eslint-plugin-import": "2.x", 63 | "eslint-plugin-jsx-a11y": "6.x", 64 | "eslint-plugin-react": "7.x", 65 | "eslint-plugin-react-hooks": "1.x", 66 | "expect": "^23.6.0", 67 | "husky": "0.14.3", 68 | "jest": "^23.6.0", 69 | "lint-staged": "4.2.3", 70 | "nwb": "0.19.0", 71 | "polyserve": "^0.27.12", 72 | "prettier": "^2.0.2", 73 | "prismjs": "1.8.1", 74 | "puppeteer": "^1.5.0", 75 | "puppeteer-screenshot-tester": "^1.0.4", 76 | "react": "^16.10.2", 77 | "react-dom": "^16.10.2", 78 | "react-resizable": "1.7.5", 79 | "release-it": "3.1.2" 80 | }, 81 | "dependencies": { 82 | "line-height": "0.3.1", 83 | "memoize-one": "^5.1.1", 84 | "prop-types": "^15.6.0", 85 | "resize-observer-polyfill": "1.5.x" 86 | }, 87 | "author": "Patrik Piskay", 88 | "license": "MIT" 89 | } 90 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | 6 | /* defaults */ 7 | semi: true, 8 | useTabs: false, 9 | tabWidth: 2, 10 | bracketSpacing: true, 11 | jsxBracketSameLine: false, 12 | }; 13 | -------------------------------------------------------------------------------- /src/atom.js: -------------------------------------------------------------------------------- 1 | export const Atom = (props) => { 2 | return props.children || null; 3 | }; 4 | Atom.__rtm_atom = true; 5 | 6 | export const isAtomComponent = (reactEl) => { 7 | return !!(reactEl && reactEl.type && reactEl.type.__rtm_atom === true); 8 | }; 9 | 10 | export const ATOM_STRING_ID = ''; 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import memoizeOne from 'memoize-one'; 3 | import PropTypes from 'prop-types'; 4 | import getLineHeight from 'line-height'; 5 | import ResizeObserver from 'resize-observer-polyfill'; 6 | import TOKENIZE_POLICY from './tokenize-rules'; 7 | import { Atom, isAtomComponent, ATOM_STRING_ID } from './atom'; 8 | 9 | const SPLIT = { 10 | LEFT: true, 11 | RIGHT: false, 12 | }; 13 | 14 | const toString = (node, string = '') => { 15 | if (!node) { 16 | return string; 17 | } else if (typeof node === 'string') { 18 | return string + node; 19 | } else if (isAtomComponent(node)) { 20 | return string + ATOM_STRING_ID; 21 | } 22 | const children = Array.isArray(node) ? node : node.props.children || ''; 23 | 24 | return ( 25 | string + React.Children.map(children, (child) => toString(child)).join('') 26 | ); 27 | }; 28 | 29 | const cloneWithChildren = (node, children, isRootEl, level) => { 30 | const getDisplayStyle = () => { 31 | if (isRootEl) { 32 | return { 33 | // root element cannot be an inline element because of the line calculation 34 | display: (node.props.style || {}).display || 'block', 35 | }; 36 | } else if (level === 2) { 37 | return { 38 | // level 2 elements (direct children of the root element) need to be inline because of the ellipsis. 39 | // if level 2 element was a block element, ellipsis would get rendered on a new line, breaking the max number of lines 40 | display: (node.props.style || {}).display || 'inline-block', 41 | }; 42 | } else return {}; 43 | }; 44 | 45 | return { 46 | ...node, 47 | props: { 48 | ...node.props, 49 | style: { 50 | ...node.props.style, 51 | ...getDisplayStyle(), 52 | }, 53 | children, 54 | }, 55 | }; 56 | }; 57 | 58 | const validateTree = (node) => { 59 | if ( 60 | node == null || 61 | ['string', 'number'].includes(typeof node) || 62 | isAtomComponent(node) 63 | ) { 64 | return true; 65 | } else if (typeof node.type === 'function') { 66 | if (process.env.NODE_ENV !== 'production') { 67 | /* eslint-disable no-console */ 68 | console.error( 69 | `ReactTruncateMarkup tried to render <${node.type.name} />, but truncating React components is not supported, the full content is rendered instead. Only DOM elements are supported. Alternatively, you can take advantage of the component (see more in the docs https://github.com/patrik-piskay/react-truncate-markup/blob/master/README.md#truncatemarkupatom-).`, 70 | ); 71 | /* eslint-enable */ 72 | } 73 | 74 | return false; 75 | } 76 | 77 | if (node.props && node.props.children) { 78 | return React.Children.toArray(node.props.children).reduce( 79 | (isValid, child) => isValid && validateTree(child), 80 | true, 81 | ); 82 | } 83 | 84 | return true; 85 | }; 86 | 87 | export default class TruncateMarkup extends React.Component { 88 | static Atom = Atom; 89 | 90 | static propTypes = { 91 | children: PropTypes.element.isRequired, 92 | lines: PropTypes.number, 93 | ellipsis: PropTypes.oneOfType([ 94 | PropTypes.element, 95 | PropTypes.string, 96 | PropTypes.func, 97 | ]), 98 | lineHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 99 | onTruncate: PropTypes.func, 100 | // eslint-disable-next-line 101 | onAfterTruncate: (props, propName, componentName) => { 102 | if (props[propName]) { 103 | return new Error( 104 | `${componentName}: Setting \`onAfterTruncate\` prop is deprecated, use \`onTruncate\` instead.`, 105 | ); 106 | } 107 | }, 108 | tokenize: (props, propName, componentName) => { 109 | const tokenizeValue = props[propName]; 110 | 111 | if (typeof tokenizeValue !== 'undefined') { 112 | if (!TOKENIZE_POLICY[tokenizeValue]) { 113 | /* eslint-disable no-console */ 114 | return new Error( 115 | `${componentName}: Unknown option for prop 'tokenize': '${tokenizeValue}'. Option 'characters' will be used instead.`, 116 | ); 117 | /* eslint-enable */ 118 | } 119 | } 120 | }, 121 | }; 122 | 123 | static defaultProps = { 124 | lines: 1, 125 | ellipsis: '...', 126 | lineHeight: '', 127 | onTruncate: () => {}, 128 | tokenize: 'characters', 129 | }; 130 | 131 | constructor(props) { 132 | super(props); 133 | 134 | this.state = { 135 | text: this.childrenWithRefMemo(this.props.children), 136 | }; 137 | } 138 | 139 | lineHeight = null; 140 | splitDirectionSeq = []; 141 | shouldTruncate = true; 142 | wasLastCharTested = false; 143 | endFound = false; 144 | latestThatFits = null; 145 | onTruncateCalled = false; 146 | 147 | toStringMemo = memoizeOne(toString); 148 | childrenWithRefMemo = memoizeOne(this.childrenElementWithRef); 149 | validateTreeMemo = memoizeOne(validateTree); 150 | 151 | get isValid() { 152 | return this.validateTreeMemo(this.props.children); 153 | } 154 | get origText() { 155 | return this.childrenWithRefMemo(this.props.children); 156 | } 157 | get policy() { 158 | return TOKENIZE_POLICY[this.props.tokenize] || TOKENIZE_POLICY.characters; 159 | } 160 | 161 | componentDidMount() { 162 | if (!this.isValid) { 163 | return; 164 | } 165 | 166 | // get the computed line-height of the parent element 167 | // it'll be used for determining whether the text fits the container or not 168 | this.lineHeight = this.props.lineHeight || getLineHeight(this.el); 169 | this.truncate(); 170 | } 171 | 172 | UNSAFE_componentWillReceiveProps(nextProps) { 173 | this.shouldTruncate = false; 174 | this.latestThatFits = null; 175 | 176 | this.setState( 177 | { 178 | text: this.childrenWithRefMemo(nextProps.children), 179 | }, 180 | () => { 181 | if (!this.isValid) { 182 | return; 183 | } 184 | 185 | this.lineHeight = nextProps.lineHeight || getLineHeight(this.el); 186 | this.shouldTruncate = true; 187 | this.truncate(); 188 | }, 189 | ); 190 | } 191 | 192 | componentDidUpdate() { 193 | if (this.shouldTruncate === false || this.isValid === false) { 194 | return; 195 | } 196 | 197 | if (this.endFound) { 198 | // we've found the end where we cannot split the text further 199 | // that means we've already found the max subtree that fits the container 200 | // so we are rendering that 201 | if ( 202 | this.latestThatFits !== null && 203 | this.state.text !== this.latestThatFits 204 | ) { 205 | /* eslint-disable react/no-did-update-set-state */ 206 | this.setState({ 207 | text: this.latestThatFits, 208 | }); 209 | 210 | return; 211 | /* eslint-enable */ 212 | } 213 | 214 | this.onTruncate(/* wasTruncated */ true); 215 | 216 | return; 217 | } 218 | 219 | if (this.splitDirectionSeq.length) { 220 | if (this.fits()) { 221 | this.latestThatFits = this.state.text; 222 | // we've found a subtree that fits the container 223 | // but we need to check if we didn't cut too much of it off 224 | // so we are changing the last splitting decision from splitting and going left 225 | // to splitting and going right 226 | this.splitDirectionSeq.splice( 227 | this.splitDirectionSeq.length - 1, 228 | 1, 229 | SPLIT.RIGHT, 230 | SPLIT.LEFT, 231 | ); 232 | } else { 233 | this.splitDirectionSeq.push(SPLIT.LEFT); 234 | } 235 | 236 | this.tryToFit(this.origText, this.splitDirectionSeq); 237 | } 238 | } 239 | 240 | componentWillUnmount() { 241 | this.lineHeight = null; 242 | this.latestThatFits = null; 243 | this.splitDirectionSeq = []; 244 | } 245 | 246 | onTruncate = (wasTruncated) => { 247 | if (!this.onTruncateCalled) { 248 | this.onTruncateCalled = true; 249 | this.props.onTruncate(wasTruncated); 250 | } 251 | }; 252 | 253 | handleResize = (el, prevResizeObserver) => { 254 | // clean up previous observer 255 | if (prevResizeObserver) { 256 | prevResizeObserver.disconnect(); 257 | } 258 | 259 | // unmounting or just unsetting the element to be replaced with a new one later 260 | if (!el) return null; 261 | 262 | /* Wrapper element resize handing */ 263 | let initialRender = true; 264 | const resizeCallback = () => { 265 | if (initialRender) { 266 | // ResizeObserer cb is called on initial render too so we are skipping here 267 | initialRender = false; 268 | } else { 269 | // wrapper element has been resized, recalculating with the original text 270 | this.shouldTruncate = false; 271 | this.latestThatFits = null; 272 | 273 | this.setState( 274 | { 275 | text: this.origText, 276 | }, 277 | () => { 278 | this.shouldTruncate = true; 279 | this.onTruncateCalled = false; 280 | this.truncate(); 281 | }, 282 | ); 283 | } 284 | }; 285 | 286 | const resizeObserver = 287 | prevResizeObserver || new ResizeObserver(resizeCallback); 288 | 289 | resizeObserver.observe(el); 290 | 291 | return resizeObserver; 292 | }; 293 | 294 | truncate() { 295 | if (this.fits()) { 296 | // the whole text fits on the first try, no need to do anything else 297 | this.shouldTruncate = false; 298 | this.onTruncate(/* wasTruncated */ false); 299 | 300 | return; 301 | } 302 | 303 | this.truncateOriginalText(); 304 | } 305 | 306 | setRef = (el) => { 307 | const isNewEl = this.el !== el; 308 | this.el = el; 309 | 310 | // whenever we obtain a new element, attach resize handler 311 | if (isNewEl) { 312 | this.resizeObserver = this.handleResize(el, this.resizeObserver); 313 | } 314 | }; 315 | 316 | childrenElementWithRef(children) { 317 | const child = React.Children.only(children); 318 | 319 | return React.cloneElement(child, { 320 | ref: this.setRef, 321 | style: { 322 | wordWrap: 'break-word', 323 | ...child.props.style, 324 | }, 325 | }); 326 | } 327 | 328 | truncateOriginalText() { 329 | this.endFound = false; 330 | this.splitDirectionSeq = [SPLIT.LEFT]; 331 | this.wasLastCharTested = false; 332 | 333 | this.tryToFit(this.origText, this.splitDirectionSeq); 334 | } 335 | 336 | /** 337 | * Splits rootEl based on instructions and updates React's state with the returned element 338 | * After React rerenders the new text, we'll check if the new text fits in componentDidUpdate 339 | * @param {ReactElement} rootEl - the original children element 340 | * @param {Array} splitDirections - list of SPLIT.RIGHT/LEFT instructions 341 | */ 342 | tryToFit(rootEl, splitDirections) { 343 | if (!rootEl.props.children) { 344 | // no markup in container 345 | return; 346 | } 347 | 348 | const newRootEl = this.split(rootEl, splitDirections, /* isRootEl */ true); 349 | 350 | let ellipsis = 351 | typeof this.props.ellipsis === 'function' 352 | ? this.props.ellipsis(newRootEl) 353 | : this.props.ellipsis; 354 | 355 | ellipsis = 356 | typeof ellipsis === 'object' 357 | ? React.cloneElement(ellipsis, { key: 'ellipsis' }) 358 | : ellipsis; 359 | 360 | const newChildren = newRootEl.props.children; 361 | const newChildrenWithEllipsis = [].concat(newChildren, ellipsis); 362 | 363 | // edge case tradeoff EC#1 - on initial render it doesn't fit in the requested number of lines (1) so it starts truncating 364 | // - because of truncating and the ellipsis position, div#lvl2 will have display set to 'inline-block', 365 | // causing the whole body to fit in 1 line again 366 | // - if that happens, ellipsis is not needed anymore as the whole body is rendered 367 | // - NOTE this could be fixed by checking for this exact case and handling it separately so it renders
foo {ellipsis}
368 | // 369 | // Example: 370 | // 371 | //
372 | // foo 373 | //
bar
374 | //
375 | //
376 | const shouldRenderEllipsis = 377 | toString(newChildren) !== this.toStringMemo(this.props.children); 378 | 379 | this.setState({ 380 | text: { 381 | ...newRootEl, 382 | props: { 383 | ...newRootEl.props, 384 | children: shouldRenderEllipsis 385 | ? newChildrenWithEllipsis 386 | : newChildren, 387 | }, 388 | }, 389 | }); 390 | } 391 | 392 | /** 393 | * Splits JSX node based on its type 394 | * @param {null|string|Array|Object} node - JSX node 395 | * @param {Array} splitDirections - list of SPLIT.RIGHT/LEFT instructions 396 | * @return {null|string|Array|Object} - split JSX node 397 | */ 398 | split(node, splitDirections, isRoot = false, level = 1) { 399 | if (!node || isAtomComponent(node)) { 400 | this.endFound = true; 401 | 402 | return node; 403 | } else if (typeof node === 'string') { 404 | return this.splitString(node, splitDirections, level); 405 | } else if (Array.isArray(node)) { 406 | return this.splitArray(node, splitDirections, level); 407 | } 408 | 409 | const newChildren = this.split( 410 | node.props.children, 411 | splitDirections, 412 | /* isRoot */ false, 413 | level + 1, 414 | ); 415 | 416 | return cloneWithChildren(node, newChildren, isRoot, level); 417 | } 418 | 419 | splitString(string, splitDirections = [], level) { 420 | if (!splitDirections.length) { 421 | return string; 422 | } 423 | 424 | if (splitDirections.length && this.policy.isAtomic(string)) { 425 | // allow for an extra render test with the current character included 426 | // in most cases this variation was already tested, but some edge cases require this check 427 | // NOTE could be removed once EC#1 is taken care of 428 | if (!this.wasLastCharTested) { 429 | this.wasLastCharTested = true; 430 | } else { 431 | // we are trying to split further but we have nowhere to go now 432 | // that means we've already found the max subtree that fits the container 433 | this.endFound = true; 434 | } 435 | 436 | return string; 437 | } 438 | 439 | if (this.policy.tokenizeString) { 440 | const wordsArray = this.splitArray( 441 | this.policy.tokenizeString(string), 442 | splitDirections, 443 | level, 444 | ); 445 | 446 | // in order to preserve the input structure 447 | return wordsArray.join(''); 448 | } 449 | 450 | const [splitDirection, ...restSplitDirections] = splitDirections; 451 | const pivotIndex = Math.ceil(string.length / 2); 452 | const beforeString = string.substring(0, pivotIndex); 453 | 454 | if (splitDirection === SPLIT.LEFT) { 455 | return this.splitString(beforeString, restSplitDirections, level); 456 | } 457 | const afterString = string.substring(pivotIndex); 458 | 459 | return ( 460 | beforeString + this.splitString(afterString, restSplitDirections, level) 461 | ); 462 | } 463 | 464 | splitArray(array, splitDirections = [], level) { 465 | if (!splitDirections.length) { 466 | return array; 467 | } 468 | 469 | if (array.length === 1) { 470 | return [this.split(array[0], splitDirections, /* isRoot */ false, level)]; 471 | } 472 | 473 | const [splitDirection, ...restSplitDirections] = splitDirections; 474 | const pivotIndex = Math.ceil(array.length / 2); 475 | const beforeArray = array.slice(0, pivotIndex); 476 | 477 | if (splitDirection === SPLIT.LEFT) { 478 | return this.splitArray(beforeArray, restSplitDirections, level); 479 | } 480 | const afterArray = array.slice(pivotIndex); 481 | 482 | return beforeArray.concat( 483 | this.splitArray(afterArray, restSplitDirections, level), 484 | ); 485 | } 486 | 487 | fits() { 488 | const { lines: maxLines } = this.props; 489 | const { height } = this.el.getBoundingClientRect(); 490 | const computedLines = Math.round(height / parseFloat(this.lineHeight)); 491 | 492 | return maxLines >= computedLines; 493 | } 494 | 495 | render() { 496 | return this.state.text; 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /src/tokenize-rules.js: -------------------------------------------------------------------------------- 1 | const TOKENIZE_POLICY = { 2 | characters: { 3 | tokenizeString: null, 4 | isAtomic: (str) => str.length <= 1, 5 | }, 6 | words: { 7 | tokenizeString: (str) => str.match(/(\s*\S[\S\xA0]*)/g), 8 | isAtomic: (str) => /^\s*[\S\xA0]*\s*$/.test(str), 9 | }, 10 | }; 11 | 12 | export default TOKENIZE_POLICY; 13 | --------------------------------------------------------------------------------