├── .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 | [](https://travis-ci.org/patrik-piskay/react-truncate-markup)
4 | [](https://www.npmjs.com/package/react-truncate-markup)
5 | [](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`: `... read more `
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 | [](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 |
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 |
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 |
193 |
Examples
194 |
209 |
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 |
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 |
--------------------------------------------------------------------------------