├── .babelrc ├── .editorconfig ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .prettierrc ├── .travis.yml ├── README.md ├── now.json ├── package.json ├── rollup.config.js ├── src ├── .eslintrc ├── __tests__ │ ├── ReactTruncatedComponent.test.js │ ├── getLevelInfo.test.js │ └── truncateLevel.test.js ├── index.js ├── resultBox.js ├── treeUtils.js ├── types.js ├── updater.js └── utils.js ├── website ├── .babelrc ├── README.md ├── next.config.js ├── package.json ├── pages │ ├── _document.js │ └── index │ │ ├── docs.md │ │ ├── docsContainer.js │ │ ├── ellipsis.js │ │ ├── githubLogo.js │ │ ├── index.js │ │ ├── introContainer.js │ │ ├── introContentContainer.js │ │ ├── introTitle.js │ │ ├── liveResult.js │ │ ├── liveResultBox.js │ │ ├── liveSettingsContainer.js │ │ ├── logo.js │ │ ├── numberOfLinesInput.js │ │ ├── prism.css │ │ ├── topBar.js │ │ └── truncationEnabled.js ├── static │ ├── 7xT19rLg5W.gif │ ├── favicon.ico │ ├── index.html │ ├── logo.png │ └── manifest.json ├── utils │ └── mediaTemplates.js └── yarn.lock └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "modules": false 7 | } 8 | ], 9 | "@babel/preset-react", 10 | "@babel/preset-flow" 11 | ], 12 | "plugins": [["@babel/proposal-class-properties", { "loose": true }]] 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "react-app", 4 | "plugin:prettier/recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | .next 12 | 13 | # misc 14 | .DS_Store 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all" 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - stable 5 | cache: 6 | yarn: true 7 | directories: 8 | - $HOME/.yarn-cache 9 | - node_modules 10 | script: 11 | - npm test 12 | deploy: 13 | - provider: npm 14 | email: $NPM_EMAIL 15 | api_key: $NPM_AUTH_TOKEN 16 | skip_cleanup: true 17 | on: 18 | tags: true 19 | branch: master 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | styled-components 4 | 5 |
6 | 7 |
8 | 9 |
10 | React component to truncate your text with format and paragraphs support included. 11 |
12 |
13 |
14 | 15 | [![NPM](https://img.shields.io/npm/v/react-truncated-component.svg)](https://www.npmjs.com/package/react-truncated-component) [![Build](https://travis-ci.com/juangl/react-truncated-component.svg?branch=master)](https://travis-ci.com/juangl/react-truncated-component) 16 | 17 |
18 | 19 | styled-components 20 | 21 |
22 | 23 | ## Install 24 | 25 | ```bash 26 | npm install --save react-truncated-component 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```javascript 32 | function TruncatedText() { 33 | return ( 34 | 35 |

{/* 36 | 37 | 38 | put your long text right here 39 | 40 | 41 | */}

42 |
43 | ); 44 | } 45 | ``` 46 | 47 | ## Docs 48 | 49 | [To see the documentation visit the website.](https://react-truncated-component.now.sh) 50 | 51 | ## License 52 | 53 | MIT © [Juan Garcia](https://github.com/juangl) 54 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "alias": "react-truncated-component.now.sh", 4 | "name": "react-truncated-component", 5 | "builds": [{ "src": "website/next.config.js", "use": "@now/next" }], 6 | "routes": [{ "src": "/(.*)", "dest": "/website/$1" }] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-truncated-component", 3 | "version": "1.0.4", 4 | "description": "A React component to truncate text with format", 5 | "author": "juangl", 6 | "license": "MIT", 7 | "repository": "juangl/react-truncated-component", 8 | "main": "dist/index.js", 9 | "module": "dist/index.es.js", 10 | "jsnext:main": "dist/index.es.js", 11 | "engines": { 12 | "node": ">=8", 13 | "npm": ">=5" 14 | }, 15 | "scripts": { 16 | "test": "cross-env CI=1 SKIP_PREFLIGHT_CHECK=true react-scripts test", 17 | "flow": "flow check --max-warnings=0 src && flow check example", 18 | "test:watch": "react-scripts test", 19 | "build": "rollup -c", 20 | "start": "rollup -c -w", 21 | "prepare": "yarn run build", 22 | "prettier": "prettier --write '**/*.{js,json,css,md}'", 23 | "eslint-check-config": "eslint --print-config . | eslint-config-prettier-check", 24 | "lint": "eslint src" 25 | }, 26 | "husky": { 27 | "hooks": { 28 | "pre-commit": "lint-staged" 29 | } 30 | }, 31 | "lint-staged": { 32 | "src/*.js": [ 33 | "eslint" 34 | ], 35 | "**/*.{js,json,css,md}": [ 36 | "prettier --write", 37 | "yarn test --findRelatedTests", 38 | "git add" 39 | ] 40 | }, 41 | "peerDependencies": { 42 | "react": "^15.0.0 || ^16.0.0 || ^17.0.0", 43 | "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.0.0", 47 | "@babel/plugin-proposal-class-properties": "^7.0.0", 48 | "@babel/preset-env": "^7.0.0", 49 | "@babel/preset-flow": "^7.0.0", 50 | "@babel/preset-react": "^7.0.0", 51 | "@svgr/rollup": "^2.4.1", 52 | "@testing-library/react": "^8.0.1", 53 | "babel-eslint": "^9.0.0", 54 | "cross-env": "^5.1.4", 55 | "eslint": "^5.10.0", 56 | "eslint-config-prettier": "^3.3.0", 57 | "eslint-config-react-app": "^3.0.5", 58 | "eslint-plugin-flowtype": "^3.2.0", 59 | "eslint-plugin-import": "^2.14.0", 60 | "eslint-plugin-jsx-a11y": "^6.1.2", 61 | "eslint-plugin-node": "^7.0.1", 62 | "eslint-plugin-prettier": "^3.0.0", 63 | "eslint-plugin-react": "^7.11.1", 64 | "flow-bin": "^0.89.0", 65 | "husky": "^1.2.1", 66 | "jest-dom": "^3.4.0", 67 | "lint-staged": "^8.1.0", 68 | "prettier": "1.15.3", 69 | "react": "^16.8.6", 70 | "react-dom": "^16.8.6", 71 | "react-scripts": "^3.0.1", 72 | "rollup": "^0.67.4", 73 | "rollup-plugin-babel": "^4.1.0", 74 | "rollup-plugin-commonjs": "^9.1.3", 75 | "rollup-plugin-node-resolve": "^3.3.0", 76 | "rollup-plugin-peer-deps-external": "^2.2.0", 77 | "rollup-plugin-postcss": "^1.6.2", 78 | "rollup-plugin-url": "^1.4.0" 79 | }, 80 | "files": [ 81 | "dist" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import external from "rollup-plugin-peer-deps-external"; 4 | import postcss from "rollup-plugin-postcss"; 5 | import resolve from "rollup-plugin-node-resolve"; 6 | import url from "rollup-plugin-url"; 7 | import svgr from "@svgr/rollup"; 8 | 9 | import pkg from "./package.json"; 10 | 11 | export default { 12 | input: "src/index.js", 13 | output: [ 14 | { 15 | file: pkg.main, 16 | format: "cjs", 17 | sourcemap: true, 18 | }, 19 | { 20 | file: pkg.module, 21 | format: "es", 22 | sourcemap: true, 23 | }, 24 | ], 25 | plugins: [ 26 | external(), 27 | postcss({ 28 | modules: true, 29 | }), 30 | url(), 31 | svgr(), 32 | babel({ 33 | exclude: "node_modules/**", 34 | }), 35 | resolve(), 36 | commonjs(), 37 | ], 38 | }; 39 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/__tests__/ReactTruncatedComponent.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, cleanup } from "@testing-library/react"; 3 | import "jest-dom/extend-expect"; 4 | 5 | import ReactTruncatedComponent from "../"; 6 | 7 | describe("", () => { 8 | afterEach(cleanup); 9 | test("Using cache key", () => { 10 | const { container } = render( 11 |
12 | 18 |

HELLO!

19 |
20 |
, 21 | ); 22 | 23 | const { container: container2 } = render( 24 |
25 | 31 |

HELLO!

32 |
33 |
, 34 | ); 35 | 36 | expect(container.innerHTML === container2.innerHTML).toBe(true); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/__tests__/getLevelInfo.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { getLevelInfo } from "../treeUtils"; 3 | 4 | describe("getLevelInfo", () => { 5 | test("basic usage", () => { 6 | const tree = ( 7 | <> 8 |

9 |

text

10 | 11 | ); 12 | 13 | const childrenArray = tree.props.children; 14 | 15 | expect(getLevelInfo(childrenArray, 0)).toEqual({ length: 2 }); 16 | expect(getLevelInfo(childrenArray, 1)).toEqual({ length: 1 }); 17 | expect(getLevelInfo(childrenArray, 2)).toEqual({ length: 4 }); 18 | expect(getLevelInfo(childrenArray, 3)).toEqual({ length: 0 }); 19 | }); 20 | 21 | test("getLevelInfo with falsy children", () => { 22 | const tree = ( 23 | <> 24 |

25 |

26 | 27 | ); 28 | 29 | const childrenArray = tree.props.children; 30 | 31 | expect(getLevelInfo(childrenArray, 1)).toEqual({ length: 0 }); 32 | }); 33 | 34 | test("getLevelInfo with arrays as children", () => { 35 | const tree = ( 36 | <> 37 |

38 |

{["text",

text

]}

39 | 40 | ); 41 | 42 | const childrenArray = tree.props.children; 43 | 44 | expect(getLevelInfo(childrenArray, 0)).toEqual({ length: 2 }); 45 | expect(getLevelInfo(childrenArray, 1)).toEqual({ length: 2 }); 46 | expect(getLevelInfo(childrenArray, 2)).toEqual({ length: 1 }); 47 | expect(getLevelInfo(childrenArray, 3)).toEqual({ length: 4 }); 48 | }); 49 | 50 | test("getLevelInfo with arrays as children and text", () => { 51 | const tree = ( 52 | <> 53 |

54 |

{["text"]} text

55 |

56 | 57 | ); 58 | 59 | const childrenArray = tree.props.children; 60 | 61 | expect(getLevelInfo(childrenArray, 0)).toEqual({ length: 1 }); 62 | expect(getLevelInfo(childrenArray, 1)).toEqual({ length: 1 }); 63 | expect(getLevelInfo(childrenArray, 2)).toEqual({ length: 2 }); 64 | expect(getLevelInfo(childrenArray, 3)).toEqual({ length: 5 }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/__tests__/truncateLevel.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { getLevelInfo, truncateLevel } from "../treeUtils"; 3 | 4 | describe("TruncateLevel", () => { 5 | const tree = ( 6 | <> 7 |

8 |

{["text",

text

]}

9 | 10 | ); 11 | 12 | const childrenArray = tree.props.children; 13 | 14 | test("level 0", () => { 15 | expect(getLevelInfo(childrenArray, 0)).toEqual({ length: 2 }); 16 | expect(truncateLevel(childrenArray, 0)).toMatchInlineSnapshot(`

`); 17 | }); 18 | 19 | test("level 1", () => { 20 | expect(getLevelInfo(childrenArray, 1)).toEqual({ length: 2 }); 21 | expect(truncateLevel(childrenArray, 1)).toMatchInlineSnapshot(` 22 | Array [ 23 |

, 24 |

25 | text 26 |

, 27 | ] 28 | `); 29 | }); 30 | 31 | test("level 2", () => { 32 | expect(getLevelInfo(childrenArray, 2)).toEqual({ length: 1 }); 33 | expect(truncateLevel(childrenArray, 2)).toMatchInlineSnapshot(` 34 | Array [ 35 |

, 36 |

37 | text 38 |

, 39 | ] 40 | `); 41 | }); 42 | 43 | test("level 3", () => { 44 | expect(getLevelInfo(childrenArray, 3)).toEqual({ length: 4 }); 45 | expect(truncateLevel(childrenArray, 3)).toMatchInlineSnapshot(` 46 | Array [ 47 |

, 48 |

49 | text 50 |

51 | tex 52 |

53 |

, 54 | ] 55 | `); 56 | }); 57 | 58 | test("Truncating an array shound always keep the children array", () => { 59 | const tree = ( 60 | <> 61 |

{["text", "test"]}

62 | 63 | ); 64 | 65 | const childrenArray = tree.props.children; 66 | 67 | expect(getLevelInfo(childrenArray, 1)).toEqual({ length: 2 }); 68 | const truncate1 = truncateLevel(childrenArray, 1); 69 | expect(getLevelInfo(truncate1, 1)).toEqual({ length: 1 }); 70 | }); 71 | 72 | test("truncateLevel to specific index ", () => { 73 | const tree = ( 74 | <> 75 |

76 |

text

77 |

78 | 79 | ); 80 | 81 | const childrenArray = tree.props.children; 82 | 83 | expect(truncateLevel(childrenArray, 3, 1)).toMatchInlineSnapshot(` 84 |

85 |

86 | t 87 |

88 |

89 | `); 90 | }); 91 | 92 | test("truncateLevel removes void elements", () => { 93 | const tree = ( 94 | <> 95 |

96 |

text

97 |

98 | 99 | ); 100 | 101 | const childrenArray = tree.props.children; 102 | 103 | expect(truncateLevel(childrenArray, 2)).toMatchInlineSnapshot(`null`); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from "react"; 3 | 4 | import ResultBox from "./resultBox"; 5 | import { getLevelInfo } from "./treeUtils"; 6 | import updater from "./updater"; 7 | import { didContentChange } from "./utils"; 8 | import type { TruncatedComponentProps, TruncatedComponentState } from "./types"; 9 | 10 | class TruncatedComponent extends React.Component< 11 | TruncatedComponentProps, 12 | TruncatedComponentState, 13 | > { 14 | static defaultProps = { 15 | numberOfLines: undefined, 16 | onTruncate: () => {}, 17 | ellipsis: "...", 18 | }; 19 | 20 | state = updater.getInitialState(this.props.children, this.props.cacheKey); 21 | 22 | containerRef: ?React.ElementRef<"div">; 23 | 24 | componentDidMount() { 25 | if (this.state.isTruncationCompleted) { 26 | this.callOnTruncate(); 27 | } 28 | } 29 | 30 | componentDidUpdate( 31 | prevProps: TruncatedComponentProps, 32 | prevState: TruncatedComponentState, 33 | ) { 34 | const { isTruncationCompleted } = this.state; 35 | if (isTruncationCompleted && !prevState.isTruncationCompleted) { 36 | this.callOnTruncate(); 37 | } 38 | 39 | this.reRunTruncationIfNeeded(prevProps); 40 | } 41 | 42 | componentWillUnmount() { 43 | const { cacheKey } = this.props; 44 | if (!this.state.isTruncationCompleted) { 45 | updater.clearCache(cacheKey); 46 | } 47 | } 48 | 49 | reRunTruncationIfNeeded(prevProps: TruncatedComponentProps) { 50 | if ( 51 | didContentChange(prevProps, this.props) || 52 | this.props.numberOfLines !== prevProps.numberOfLines 53 | ) { 54 | this.setState( 55 | updater.getInitialState(this.props.children, this.props.cacheKey), 56 | ); 57 | } 58 | } 59 | 60 | callOnTruncate() { 61 | this.props.onTruncate(this.state.isTruncated); 62 | } 63 | 64 | getCurrentNumberOfLines(heightByParagraph: Array) { 65 | const { lineHeight } = this.props; 66 | return heightByParagraph.reduce((acc, measure) => { 67 | return acc + measure / lineHeight; 68 | }, 0); 69 | } 70 | 71 | // this works using a binary-search across each level of the React tree 72 | // to find the most amount of text that fits in the desired number 73 | // of lines. 74 | // We can think of a React tree as a structure where React.Node is the type 75 | // of the node and it could have and array of children (`node.props.children`) 76 | // and leaf nodes are usually `string`. 77 | onMeasure = (heightByParagraph: Array) => { 78 | const { numberOfLines, cacheKey } = this.props; 79 | const { currentTree, previousTree, level, startOffset } = this.state; 80 | 81 | const currentLines = this.getCurrentNumberOfLines(heightByParagraph); 82 | const currentLevelInfo = getLevelInfo(currentTree, level); 83 | 84 | // $FlowFixMe suppresses the curretLines error since it could be null 85 | if (currentLines <= numberOfLines) { 86 | if (this.state.isTruncated) { 87 | // this is not the first measurement 88 | const deeperLevelInfo = getLevelInfo(previousTree, level + 1); 89 | const lastLevelInfo = getLevelInfo(previousTree, level); 90 | 91 | if (startOffset + 1 >= lastLevelInfo.length) { 92 | // this level is done 93 | updater.updateLevelCache( 94 | cacheKey, 95 | level, 96 | deeperLevelInfo.length 97 | ? lastLevelInfo.length 98 | : currentLevelInfo.length, 99 | ); 100 | 101 | if (deeperLevelInfo.length) { 102 | this.setState(updater.goToDeeperLevel); 103 | } else { 104 | // we can't go deeper. Truncation has done 105 | this.setState(updater.truncationCompleted); 106 | } 107 | } else { 108 | // reduce the range of search 109 | 110 | // $FlowFixMe at this point, truncatedAt shouldn't be null 111 | this.setState(updater.setStartOffset); 112 | } 113 | } else { 114 | // first measurement. Fits without truncate 115 | this.setState(updater.truncationCompleted); 116 | } 117 | } else { 118 | if (currentLevelInfo.length) { 119 | this.setState(updater.truncateTotheMidddle(currentLevelInfo)); 120 | } 121 | } 122 | }; 123 | 124 | render() { 125 | const shouldMeasure = 126 | !this.state.isTruncationCompleted && !!this.props.numberOfLines; 127 | 128 | return ( 129 |
{ 131 | this.containerRef = ref; 132 | }} 133 | style={{ 134 | lineHeight: `${this.props.lineHeight}px`, 135 | }} 136 | > 137 | { 138 | 144 | {this.state.currentTree} 145 | 146 | } 147 |
148 | ); 149 | } 150 | } 151 | 152 | export default TruncatedComponent; 153 | -------------------------------------------------------------------------------- /src/resultBox.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from "react"; 3 | 4 | import { didContentChange } from "./utils"; 5 | 6 | const getChildrenInArray = element => { 7 | const { children } = element.props; 8 | return React.Children.toArray(children); 9 | }; 10 | 11 | const addEllipsis = (children, ellipsis) => { 12 | const paragraphs = React.Children.toArray(children); 13 | 14 | if (!paragraphs.length) { 15 | return null; 16 | } 17 | 18 | const lastParagraph = paragraphs[paragraphs.length - 1]; 19 | const lastParagraphChildren = getChildrenInArray(lastParagraph); 20 | 21 | // add the ellipsis at the end of the last paragraph 22 | paragraphs[paragraphs.length - 1] = React.cloneElement( 23 | lastParagraph, 24 | undefined, 25 | ...lastParagraphChildren, 26 | ellipsis, 27 | ); 28 | 29 | return paragraphs; 30 | }; 31 | 32 | const NESTED_UPDATE_LIMIT = 50; 33 | 34 | type Props = {| 35 | ellipsis: React.Node, 36 | children: React.Node, 37 | onMeasure: (Array) => void, 38 | shouldMeasure: boolean, 39 | isTruncated: boolean, 40 | |}; 41 | 42 | class ResultBox extends React.Component { 43 | paragraphRefs = []; 44 | containerRef: ?React.ElementRef<"div"> = null; 45 | nestedUpdatesCount = 0; 46 | 47 | shouldComponentUpdate(nextProps: Props) { 48 | return didContentChange(this.props, nextProps); 49 | } 50 | 51 | componentDidMount() { 52 | if (this.props.shouldMeasure) { 53 | this.measureParagraphs(); 54 | } 55 | } 56 | 57 | componentDidUpdate(prevProps: Props) { 58 | if (didContentChange(this.props, prevProps) && this.props.shouldMeasure) { 59 | this.measureParagraphs(); 60 | } 61 | } 62 | 63 | measureParagraphs = () => { 64 | const measureByParagraph = []; 65 | const children = this.containerRef ? this.containerRef.children : []; 66 | 67 | for (const paragraph of children) { 68 | let containerheight = Math.floor(paragraph.clientHeight); 69 | measureByParagraph.push(containerheight); 70 | } 71 | 72 | this.nestedUpdatesCount++; 73 | if (this.nestedUpdatesCount === NESTED_UPDATE_LIMIT) { 74 | requestAnimationFrame(() => { 75 | this.nestedUpdatesCount = 0; 76 | this.props.onMeasure(measureByParagraph); 77 | }); 78 | } else { 79 | this.props.onMeasure(measureByParagraph); 80 | } 81 | }; 82 | 83 | render() { 84 | const { isTruncated, ellipsis, children } = this.props; 85 | 86 | let newChildren = children; 87 | if (isTruncated) { 88 | newChildren = addEllipsis(children, ellipsis); 89 | } 90 | 91 | return
(this.containerRef = ref)}>{newChildren}
; 92 | } 93 | } 94 | 95 | export default ResultBox; 96 | -------------------------------------------------------------------------------- /src/treeUtils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from "react"; 3 | 4 | type visitorCallback = ( 5 | Node: React.Node, 6 | parent: React.Node, 7 | level: number, 8 | ) => void; 9 | 10 | type visitorObject = {| 11 | enter?: visitorCallback, 12 | exit?: visitorCallback, 13 | |}; 14 | 15 | export function isIterableChildren(node: React.Node): boolean %checks { 16 | return Array.isArray(node) || typeof node === "string"; 17 | } 18 | 19 | export function getChildren(node: React.Node): ?Array { 20 | if ( 21 | node && 22 | React.isValidElement(node) && 23 | // in the next line node.props couldn't be access 24 | // because props is missing in React.Portal 25 | // $FlowFixMe 26 | node.props && 27 | node.props.children 28 | ) { 29 | // `toArray` will flatten the array. We need that for consistency 30 | return React.Children.toArray(node.props.children); 31 | } 32 | 33 | // no children 34 | return null; 35 | } 36 | 37 | // function to traverse only the right-most branch of the tree 38 | // the visitor callback receives `node` which is: 39 | // 1. `currentNode.props.children` if currentNode is an element 40 | // 2. `currentNode` otherwise (string, in most cases). 41 | // this means that `node` is expected to be either `Array` or `string`. 42 | // TODO: Are we running into unexpected edge cases when currentNode is e.g `boolean`? 43 | export function traverseToTheRight(tree: React.Node, visitor: visitorObject) { 44 | function traverseChildren( 45 | node: React.Node, 46 | parent: React.Node, 47 | levelCount: number, 48 | ) { 49 | let children = getChildren(node); 50 | 51 | let nodeToVisit = children ? children : node; 52 | 53 | if (visitor.enter) { 54 | visitor.enter(nodeToVisit, parent, levelCount); 55 | } 56 | 57 | if (Array.isArray(children)) { 58 | if (children.length > 0) { 59 | traverseChildren(children[children.length - 1], node, levelCount + 1); 60 | } 61 | } 62 | 63 | if (visitor.exit) { 64 | visitor.exit(node, parent, levelCount); 65 | } 66 | } 67 | 68 | traverseChildren(<>{tree}, null, 0); 69 | } 70 | 71 | // traverses the right-most branch and return the number of 72 | // children on a specific level 73 | export function getLevelInfo(tree: React.Node, level: number) { 74 | let result = { length: 0 }; 75 | traverseToTheRight(tree, { 76 | enter(node, parent, currentLevel) { 77 | if (currentLevel === level) { 78 | const children = getChildren(node); 79 | let length = 0; 80 | if (children) { 81 | length = children.length; 82 | } else if (isIterableChildren(node)) { 83 | length = node.length; 84 | } 85 | result = { length }; 86 | } 87 | }, 88 | }); 89 | return result; 90 | } 91 | 92 | export function truncateLevel( 93 | tree: React.Node, 94 | level: number, 95 | index: number = -1, 96 | ) { 97 | function truncateNode(node: React.Node, currentLevel: number): ?React.Node { 98 | let newChildren = React.isValidElement(node) ? getChildren(node) : node; 99 | 100 | if (currentLevel === level) { 101 | if (newChildren && isIterableChildren(newChildren)) { 102 | newChildren = newChildren.slice(0, index); 103 | } 104 | } else if (Array.isArray(newChildren) && newChildren.length > 0) { 105 | newChildren = [ 106 | ...newChildren.slice(0, -1), 107 | truncateNode(newChildren[newChildren.length - 1], currentLevel + 1), 108 | ]; 109 | } 110 | 111 | if (React.isValidElement(node)) { 112 | // if children is an array 113 | if (Array.isArray(newChildren)) { 114 | // remove falsy children 115 | newChildren = newChildren.filter(Boolean); 116 | // discards node if children is empty (leaf node) 117 | if (newChildren.filter(Boolean).length < 1) { 118 | return null; 119 | } 120 | return React.cloneElement( 121 | // $FlowFixMe node is validated as a valid element above 122 | node, 123 | { children: undefined }, 124 | ...newChildren, 125 | ); 126 | } 127 | 128 | // no children present 129 | if (!newChildren) { 130 | return null; 131 | } 132 | 133 | // children is a single element 134 | return React.cloneElement( 135 | // $FlowFixMe 136 | node, 137 | { children: undefined }, 138 | newChildren, 139 | ); 140 | } 141 | 142 | return newChildren; 143 | } 144 | 145 | const truncatedRoot = truncateNode(<>{tree}, 0); 146 | // $FlowFixMe 147 | return truncatedRoot && truncatedRoot.props 148 | ? truncatedRoot.props.children 149 | : null; 150 | } 151 | 152 | export function cacheToTree( 153 | currentTree: React.Node, 154 | currentCache: Array, 155 | ) { 156 | return currentCache.reduce((previousTree, offset, level) => { 157 | return truncateLevel(previousTree, level, offset); 158 | }, currentTree); 159 | } 160 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from "react"; 3 | 4 | export type onTruncateEvent = (isTruncated: boolean) => void; 5 | 6 | export type TruncatedComponentState = {| 7 | previousTree: React.Node, 8 | currentTree: React.Node, 9 | isTruncationCompleted: boolean, 10 | isTruncated: boolean, 11 | level: number, 12 | // for binary-search 13 | startOffset: number, 14 | truncatedAt: ?number, 15 | |}; 16 | 17 | export type TruncatedComponentProps = {| 18 | numberOfLines?: ?number, 19 | children: React.Node, 20 | ellipsis: React.Node, 21 | onTruncate: onTruncateEvent, 22 | cacheKey?: any, 23 | lineHeight: number, 24 | |}; 25 | 26 | export type LevelInfoType = {| length: number |}; 27 | -------------------------------------------------------------------------------- /src/updater.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from "react"; 3 | import type { LevelInfoType, TruncatedComponentState } from "./types"; 4 | 5 | import { truncateLevel, cacheToTree } from "./treeUtils"; 6 | 7 | const cache: Map> = new Map(); 8 | 9 | function getCachedTree(cacheKey, tree) { 10 | if (cacheKey) { 11 | const isCached = cache.has(cacheKey); 12 | if (isCached) { 13 | const cacheItem = cache.get(cacheKey); 14 | if (cacheItem && cacheItem.length) { 15 | const nextTree = cacheToTree(tree, cache[cacheKey]); 16 | return nextTree; 17 | } 18 | // no need to truncate. Return tree not truncated 19 | return tree; 20 | } else { 21 | cache.set(cacheKey, []); 22 | } 23 | } 24 | return null; 25 | } 26 | 27 | const updater = { 28 | getInitialState( 29 | children: React.Node, 30 | cacheKey?: any, 31 | ): TruncatedComponentState { 32 | const cachedTree = getCachedTree(cacheKey, children); 33 | 34 | let currentTree = children; 35 | let isTruncationCompleted = false; 36 | let isTruncated = false; 37 | 38 | if (cachedTree) { 39 | currentTree = cachedTree; 40 | isTruncationCompleted = true; 41 | 42 | isTruncated = cachedTree !== children; 43 | } 44 | 45 | return { 46 | currentTree, 47 | previousTree: children, 48 | isTruncationCompleted, 49 | isTruncated, 50 | level: 0, 51 | // for binary-search 52 | startOffset: 0, 53 | truncatedAt: null, 54 | }; 55 | }, 56 | 57 | truncationCompleted() { 58 | return { isTruncationCompleted: true }; 59 | }, 60 | 61 | truncateTotheMidddle: (levelInfo: LevelInfoType) => 62 | function innerTruncateToTheMiddleUpdater({ 63 | startOffset, 64 | currentTree, 65 | level, 66 | }: TruncatedComponentState) { 67 | const middle = Math.floor((startOffset + levelInfo.length) / 2); 68 | return { 69 | isTruncated: true, 70 | currentTree: truncateLevel(currentTree, level, middle), 71 | previousTree: currentTree, 72 | truncatedAt: middle, 73 | }; 74 | }, 75 | 76 | goToDeeperLevel({ 77 | level: currentLevel, 78 | previousTree, 79 | }: TruncatedComponentState) { 80 | return { 81 | currentTree: previousTree, 82 | level: currentLevel + 1, 83 | startOffset: 0, 84 | truncatedAt: null, 85 | }; 86 | }, 87 | 88 | setStartOffset: ({ previousTree, truncatedAt }: TruncatedComponentState) => { 89 | return { 90 | currentTree: previousTree, 91 | startOffset: truncatedAt, 92 | }; 93 | }, 94 | 95 | setTruncatedTree: (nextTree: React.Node) => 96 | function innerSetTruncatedTreeUpdater({ 97 | currentTree, 98 | }: TruncatedComponentState) { 99 | return { 100 | currentTree: nextTree, 101 | isTruncationCompleted: true, 102 | isTruncated: true, 103 | }; 104 | }, 105 | 106 | clearCache(cacheKey?: any) { 107 | if (cache.has(cacheKey)) { 108 | cache.delete(cacheKey); 109 | } 110 | }, 111 | 112 | updateLevelCache(cacheKey?: any, level: number, truncatedAt: number) { 113 | if (cache.has(cacheKey)) { 114 | const prevCache = cache.get(cacheKey); 115 | // $FlowFixMe map.has() above should be enough. This is a flow bug I guess. 116 | prevCache[level] = truncatedAt; 117 | } 118 | }, 119 | }; 120 | 121 | export default updater; 122 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function didContentChange(currentProps, nextProps) { 2 | return ( 3 | currentProps.ellipsis !== nextProps.ellipsis || 4 | currentProps.children !== nextProps.children 5 | ); 6 | } 7 | -------------------------------------------------------------------------------- /website/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | [ 5 | "babel-plugin-styled-components", 6 | { 7 | "ssr": true, 8 | "displayName": true, 9 | "preprocess": false 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Example project 2 | -------------------------------------------------------------------------------- /website/next.config.js: -------------------------------------------------------------------------------- 1 | // next.config.js 2 | const { PHASE_PRODUCTION_SERVER } = 3 | process.env.NODE_ENV === "development" 4 | ? {} 5 | : !process.env.NOW_REGION 6 | ? require("next/constants") 7 | : require("next-server/constants"); 8 | 9 | module.exports = (phase, ...rest) => { 10 | if (phase === PHASE_PRODUCTION_SERVER) { 11 | // Config used to run in production. 12 | return {}; 13 | } 14 | 15 | const withPlugins = require("next-compose-plugins"); 16 | const css = require("@zeit/next-css"); 17 | const mdx = require("@zeit/next-mdx"); 18 | 19 | const withMDX = mdx({ 20 | extension: /\.(md|mdx)$/, 21 | options: { 22 | hastPlugins: [require("@mapbox/rehype-prism")], 23 | }, 24 | }); 25 | 26 | let config = withPlugins([[withMDX], [css]], { 27 | target: "serverless", 28 | })(phase, ...rest); 29 | 30 | return config; 31 | }; 32 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-truncated-component-example", 3 | "homepage": "https://juangl.github.io/react-truncated-component", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "@mapbox/rehype-prism": "^0.3.0", 9 | "@mdx-js/mdx": "^1.0.20", 10 | "@zeit/next-css": "^1.0.1", 11 | "@zeit/next-mdx": "^1.2.0", 12 | "next": "^9.3.2", 13 | "next-compose-plugins": "^2.2.0", 14 | "prop-types": "^15.6.2", 15 | "react": "^16.8.6", 16 | "react-dom": "^16.8.6", 17 | "react-scripts": "^3.0.1", 18 | "react-truncated-component": "^1.0.4", 19 | "react-typography": "^0.16.18", 20 | "styled-components": "^4.1.3", 21 | "typography": "^0.16.18", 22 | "typography-plugin-code": "^0.16.18", 23 | "typography-theme-stern-grove": "^0.16.18" 24 | }, 25 | "scripts": { 26 | "start": "next start", 27 | "build": "next build", 28 | "dev": "next", 29 | "test": "SKIP_PREFLIGHT_CHECK=true react-scripts test", 30 | "eject": "react-scripts eject" 31 | }, 32 | "eslintConfig": { 33 | "extends": "react-app" 34 | }, 35 | "browserslist": [ 36 | ">0.2%", 37 | "not dead", 38 | "not ie <= 11", 39 | "not op_mini all" 40 | ], 41 | "devDependencies": { 42 | "babel-plugin-styled-components": "^1.10.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /website/pages/_document.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Document, { Head, Main, NextScript } from "next/document"; 3 | import { ServerStyleSheet, createGlobalStyle } from "styled-components"; 4 | import { TypographyStyle, GoogleFont } from "react-typography"; 5 | import Typography from "typography"; 6 | import CodePlugin from "typography-plugin-code"; 7 | import SternGroveTheme from "typography-theme-stern-grove"; 8 | 9 | SternGroveTheme.plugins = [ 10 | new CodePlugin(), 11 | () => ({ "p:last-child": { marginBottom: 0 } }), 12 | ]; 13 | 14 | const typography = new Typography(SternGroveTheme); 15 | 16 | const GlobalStyle = createGlobalStyle` 17 | html { 18 | box-sizing: border-box; 19 | } 20 | *, 21 | *:before, 22 | *:after { 23 | box-sizing: inherit; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | padding: 0; 29 | } 30 | `; 31 | 32 | export default class MyDocument extends Document { 33 | static getInitialProps({ renderPage }) { 34 | const sheet = new ServerStyleSheet(); 35 | const page = renderPage(App => props => 36 | sheet.collectStyles(), 37 | ); 38 | const styleTags = sheet.getStyleElement(); 39 | return { ...page, styleTags }; 40 | } 41 | 42 | render() { 43 | return ( 44 | 45 | 46 | 50 | 51 | 52 | 53 | {this.props.styleTags} 54 | 55 | 56 | 57 |
58 | 59 | 60 | 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /website/pages/index/docs.md: -------------------------------------------------------------------------------- 1 | # How it works 2 | 3 | This library works by traversing the React tree (unlike other libraries that directly manipulate the DOM tree) to find the longest text string that fits in the number of lines specified. It uses a binary search-like algorithm to speed up the proccess. 4 | 5 | The number of lines being rendered is measured based on the line-height. 6 | 7 | # Install 8 | 9 | ```bash 10 | npm install --save react-truncated-component 11 | ``` 12 | 13 | # Usage 14 | 15 | The following code is a minimal example of how your implementation would look like. To see a more advanced example go to [this page source code](https://github.com/juangl/react-truncated-component/blob/master/website/pages/index/liveResult.js). 16 | 17 | ```javascript 18 | function TruncatedText() { 19 | return ( 20 | 21 |

{/* 22 | 23 | 24 | put your long text right here 25 | 26 | 27 | */}

28 |
29 | ); 30 | } 31 | ``` 32 | 33 | # API 34 | 35 | **numberOfLines: ?number**
36 | it can be a number of null if you want disable the truncation. 37 | 38 | **children: React.Node (Required)**
39 | This should be a React tree that reflects the document format. Direct children are considered to be paragraphs. See the craveats sections to know more about this. 40 | 41 | **lineHeight: number (Required)**
42 | `line-height` in pixels. 43 | 44 | **ellipsis: ?React.Node**
45 | It can be anything that can be render. The default value is `...`. 46 | 47 | **onTruncate: function(isTruncated: boolean)**
48 | A callback function. Receives a boolean value that is `true` when the content is truncated, `false` if the content fits without truncate it. 49 | 50 | **cacheKey: ?string**
51 | If cacheKey is passed, stores lightweight cache. Useful if you're using it in a virtualized list and you want to make sure that the truncation is calculated only once. 52 | 53 | # Caveats 54 | 55 | If you're running into some strange behavior, read the next section to make sure that you're using the library properly. 56 | 57 | - ** In order to support paragraphs, direct children are considered to be paragraphs.** If you don't need paragraphs just make sure to wrap your content in a single paragraph. 58 | - ** Use margin to separate paragraphs.** If you using padding instead, the padding will be measured as part of the content since we are measuring with `clientHeight`. 59 | - **Children should reflect the document format.** all the text that you want to truncate must be children of an element. 60 | 61 | ```javascript 62 | 63 | 64 | {/* ✅ the following children is a valid because all the text is children of an element */} 65 | 66 | the rest of the content... go to somewhere 67 | 68 | 69 | {/* ✅ you can call functions too that returns a valid children format*/} 70 | {renderParagraph()} 71 | 72 | 73 | {/* 🚫 the next children won't be truncated propertly 74 | bacause the property name is not `children` but `value`*/ } 75 | 76 | 77 | {/* 🚫 the next component won't be truncated truncate 78 | because we can't see inside its render function */} 79 | 80 | 81 | 82 | ``` 83 | -------------------------------------------------------------------------------- /website/pages/index/docsContainer.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const DocsContainer = styled.div` 4 | width: 100%; 5 | max-width: 1024px; 6 | margin: 0 auto; 7 | `; 8 | 9 | export default DocsContainer; 10 | -------------------------------------------------------------------------------- /website/pages/index/ellipsis.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Button = styled.button` 5 | background: none; 6 | color: #07e; 7 | border: none; 8 | padding: 0; 9 | font: inherit; 10 | cursor: pointer; 11 | text-decoration: none; 12 | `; 13 | 14 | function Ellipsis(props) { 15 | return ; 16 | } 17 | 18 | export default Ellipsis; 19 | -------------------------------------------------------------------------------- /website/pages/index/githubLogo.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function GitHub(props) { 4 | return ( 5 | 11 | GitHub icon 12 | 13 | 14 | ); 15 | } 16 | 17 | export default GitHub; 18 | -------------------------------------------------------------------------------- /website/pages/index/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Head from "next/head"; 3 | 4 | import "./prism.css"; 5 | 6 | import TopBar from "./topBar"; 7 | import Docs from "./docs.md"; 8 | import IntroContainer from "./introContainer"; 9 | import Logo from "./logo"; 10 | import IntroTitle from "./introTitle"; 11 | import LiveResult from "./liveResult"; 12 | import IntroContentContainer from "./introContentContainer"; 13 | import DocsContainer from "./docsContainer"; 14 | 15 | export default class App extends Component { 16 | render() { 17 | return ( 18 | <> 19 | 20 | 21 | 22 | React Truncated Component 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /website/pages/index/introContainer.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Container = styled.div` 4 | display: flex; 5 | background: linear-gradient(15deg, #ffab65, #4ecdc4); 6 | background-repeat: no-repeat; 7 | background-attachment: fixed; 8 | min-height: 700px; 9 | width: 100%; 10 | color: #fff; 11 | `; 12 | 13 | export default Container; 14 | -------------------------------------------------------------------------------- /website/pages/index/introContentContainer.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const LiveResultContainer = styled.div` 4 | width: 100%; 5 | max-width: 1024px; 6 | margin: 0 auto; 7 | `; 8 | 9 | export default LiveResultContainer; 10 | -------------------------------------------------------------------------------- /website/pages/index/introTitle.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Container = styled.div` 5 | margin-top: 40px; 6 | margin-bottom: 40px; 7 | text-align: center; 8 | text-shadow: rgba(20, 20, 20, 0.1) 0.09rem 0.09em 0.9rem; 9 | `; 10 | 11 | const Title = styled.h1` 12 | font-size: 1.5rem; 13 | margin: 0.5rem; 14 | color: #fff; 15 | `; 16 | 17 | const Subtitle = styled.h2` 18 | font-size: 1.1rem; 19 | font-weight: 400; 20 | margin: 0; 21 | `; 22 | 23 | function IntroTitle() { 24 | return ( 25 | 26 | React component to truncate text 27 | With format and paragraphs support included 28 | 29 | ); 30 | } 31 | 32 | export default IntroTitle; 33 | -------------------------------------------------------------------------------- /website/pages/index/liveResult.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import dynamic from "next/dynamic"; 3 | import styled from "styled-components"; 4 | 5 | import TruncationEnabled from "./truncationEnabled"; 6 | import LiveSettingsContainer from "./liveSettingsContainer"; 7 | import Ellipsis from "./ellipsis"; 8 | import NumberOfLinesInput from "./numberOfLinesInput"; 9 | import LiveResultBox from "./liveResultBox"; 10 | import mediaTemplates from "../../utils/mediaTemplates"; 11 | 12 | const ReactTruncateFormat = dynamic(() => import("react-truncated-component"), { 13 | ssr: false, 14 | loading: () => ( 15 |
22 | ), 23 | }); 24 | 25 | const Container = styled.div` 26 | display: flex; 27 | flex-direction: row; 28 | justify-content: flex-start; 29 | margin-bottom: 30px; 30 | 31 | ${mediaTemplates.phone` 32 | flex-direction: column; 33 | `} 34 | `; 35 | 36 | class LiveResult extends React.Component { 37 | state = { 38 | numberOfLines: 5, 39 | isTruncationEnabled: true, 40 | }; 41 | 42 | onSeeMoreClick = () => { 43 | this.setState({ 44 | isTruncationEnabled: false, 45 | }); 46 | }; 47 | 48 | render() { 49 | return ( 50 | 51 | 52 | { 56 | this.setState({ 57 | numberOfLines: e.target.value, 58 | }); 59 | }} 60 | /> 61 | 62 | { 65 | this.setState({ 66 | isTruncationEnabled: e.target.checked, 67 | }); 68 | }} 69 | /> 70 | 71 | 72 | 73 | } 75 | numberOfLines={ 76 | this.state.isTruncationEnabled ? this.state.numberOfLines : null 77 | } 78 | lineHeight={23} 79 | > 80 |

81 | 82 | Nothomyrmecia 83 | 84 | , also known as the dinosaur ant or dawn ant, is a 85 | rare{" "} 86 | 91 | genus 92 | {" "} 93 | of{" "} 94 | 99 | ants 100 | {" "} 101 | consisting‍‍ of a single{" "} 102 | 107 | species 108 | 109 | ,{" "} 110 | 111 | Nothomyrmecia macrops 112 | 113 | . These ants live in South Australia, nesting in old-growth{" "} 114 | 119 | mallee 120 | {" "} 121 | woodland and{" "} 122 | 123 | 128 | Eucalyptus 129 | 130 | {" "} 131 | woodland. The full distribution of Nothomyrmecia has never 132 | been assessed, and it is unknown how widespread the species truly 133 | is; its potential range may be wider if it does favour{" "} 134 | 139 | old-growth 140 | {" "} 141 | mallee woodland. 142 |

143 |

144 | Possible threats to its survival include habitat destruction and 145 | climate change. Nothomyrmecia is most active when it is 146 | cold because workers encounter fewer competitors and predators 147 | such as{" "} 148 | 149 | 154 | Camponotus 155 | 156 | {" "} 157 | and{" "} 158 | 159 | 164 | Iridomyrmex 165 | 166 | 167 | , and it also increases hunting success. Thus, the increase of 168 | temperature may prevent them from foraging and very few areas 169 | would be suitable for the ant to live in. As a result, the{" "} 170 | 174 | IUCN 175 | {" "} 176 | lists the ant as{" "} 177 | 182 | Critically Endangered 183 | 184 | . 185 |

186 |
187 |
188 |
189 | ); 190 | } 191 | } 192 | 193 | export default LiveResult; 194 | -------------------------------------------------------------------------------- /website/pages/index/liveResultBox.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import mediaTemplates from "../../utils/mediaTemplates"; 3 | 4 | const LiveResultBox = styled.div` 5 | width: 500px; 6 | background-color: #fff; 7 | padding: 10px 20px; 8 | color: #000; 9 | box-shadow: rgba(20, 20, 20, 0.27) 0.0555556rem 0.0555556rem 1.11111rem; 10 | margin: 0 auto; 11 | 12 | ${mediaTemplates.smallScreen` 13 | width: auto; 14 | flex: 1; 15 | margin-right: 10px; 16 | `} 17 | 18 | ${mediaTemplates.phone` 19 | margin-right: 0; 20 | `} 21 | `; 22 | 23 | export default LiveResultBox; 24 | -------------------------------------------------------------------------------- /website/pages/index/liveSettingsContainer.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import mediaTemplates from "../../utils/mediaTemplates"; 3 | 4 | const LiveSettingsContainer = styled.div` 5 | position: absolute; 6 | display: flex; 7 | align-items: flex-end; 8 | flex-direction: column; 9 | margin: 10px; 10 | 11 | ${mediaTemplates.smallScreen` 12 | position: static; 13 | `} 14 | `; 15 | 16 | export default LiveSettingsContainer; 17 | -------------------------------------------------------------------------------- /website/pages/index/logo.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const LogoImg = styled.img.attrs({ 5 | src: "/static/logo.png", 6 | alt: "React Truncate Component", 7 | })` 8 | width: 251px; 9 | height: 83px; 10 | margin-bottom: 0; 11 | `; 12 | 13 | const Container = styled.div` 14 | flex: 1; 15 | display: flex; 16 | justify-content: center; 17 | margin-top: 90px; 18 | `; 19 | 20 | function Logo() { 21 | return ( 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export default Logo; 29 | -------------------------------------------------------------------------------- /website/pages/index/numberOfLinesInput.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Container = styled.div` 5 | margin-bottom: 5px; 6 | display: flex; 7 | flex-direction: column; 8 | `; 9 | 10 | const Input = styled.input.attrs({ 11 | name: "number-of-lines", 12 | type: "range", 13 | min: "0", 14 | max: "13", 15 | })``; 16 | 17 | function NumberOfLinesInput(props) { 18 | return ( 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | export default NumberOfLinesInput; 27 | -------------------------------------------------------------------------------- /website/pages/index/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.15.0 2 | https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript */ 3 | /** 4 | * prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML 5 | * Based on https://github.com/chriskempson/tomorrow-theme 6 | * @author Rose Pritchard 7 | */ 8 | 9 | code[class*="language-"], 10 | pre[class*="language-"] { 11 | color: #ccc; 12 | background: none; 13 | font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; 14 | text-align: left; 15 | white-space: pre; 16 | word-spacing: normal; 17 | word-break: normal; 18 | word-wrap: normal; 19 | line-height: 1.5; 20 | 21 | -moz-tab-size: 4; 22 | -o-tab-size: 4; 23 | tab-size: 4; 24 | 25 | -webkit-hyphens: none; 26 | -moz-hyphens: none; 27 | -ms-hyphens: none; 28 | hyphens: none; 29 | } 30 | 31 | /* Code blocks */ 32 | pre[class*="language-"] { 33 | padding: 1em; 34 | margin: 0.5em 0; 35 | overflow: auto; 36 | } 37 | 38 | :not(pre) > code[class*="language-"], 39 | pre[class*="language-"] { 40 | background: #2d2d2d; 41 | } 42 | 43 | /* Inline code */ 44 | :not(pre) > code[class*="language-"] { 45 | padding: 0.1em; 46 | border-radius: 0.3em; 47 | white-space: normal; 48 | } 49 | 50 | .token.comment, 51 | .token.block-comment, 52 | .token.prolog, 53 | .token.doctype, 54 | .token.cdata { 55 | color: #999; 56 | } 57 | 58 | .token.punctuation { 59 | color: #ccc; 60 | } 61 | 62 | .token.tag, 63 | .token.attr-name, 64 | .token.namespace, 65 | .token.deleted { 66 | color: #e2777a; 67 | } 68 | 69 | .token.function-name { 70 | color: #6196cc; 71 | } 72 | 73 | .token.boolean, 74 | .token.number, 75 | .token.function { 76 | color: #f08d49; 77 | } 78 | 79 | .token.property, 80 | .token.class-name, 81 | .token.constant, 82 | .token.symbol { 83 | color: #f8c555; 84 | } 85 | 86 | .token.selector, 87 | .token.important, 88 | .token.atrule, 89 | .token.keyword, 90 | .token.builtin { 91 | color: #cc99cd; 92 | } 93 | 94 | .token.string, 95 | .token.char, 96 | .token.attr-value, 97 | .token.regex, 98 | .token.variable { 99 | color: #7ec699; 100 | } 101 | 102 | .token.operator, 103 | .token.entity, 104 | .token.url { 105 | color: #67cdcc; 106 | } 107 | 108 | .token.important, 109 | .token.bold { 110 | font-weight: bold; 111 | } 112 | .token.italic { 113 | font-style: italic; 114 | } 115 | 116 | .token.entity { 117 | cursor: help; 118 | } 119 | 120 | .token.inserted { 121 | color: green; 122 | } 123 | -------------------------------------------------------------------------------- /website/pages/index/topBar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import GithubLogo from "./githubLogo"; 5 | 6 | const Container = styled.div` 7 | position: fixed; 8 | z-index: 9999; 9 | width: 100%; 10 | background-color: rgba(0, 0, 0, 0.1); 11 | `; 12 | 13 | const Nav = styled.nav` 14 | padding: 15px 30px; 15 | display: flex; 16 | max-width: 1024px; 17 | margin: 0 auto; 18 | `; 19 | 20 | const StyledLink = styled.a` 21 | color: #fff; 22 | text-decoration: none; 23 | `; 24 | 25 | const RightBox = styled.div` 26 | margin-left: auto; 27 | `; 28 | 29 | const StyledGithubLogo = styled(GithubLogo)` 30 | vertical-align: middle; 31 | width: 25px; 32 | height: 25px; 33 | fill: #fff; 34 | `; 35 | 36 | const TopBar = () => ( 37 | 38 | 45 | 46 | ); 47 | 48 | export default TopBar; 49 | -------------------------------------------------------------------------------- /website/pages/index/truncationEnabled.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Input = styled.input.attrs({ 5 | type: "checkbox", 6 | name: "truncation-enabled", 7 | })``; 8 | 9 | function TruncationEnabled(props) { 10 | return ( 11 | 14 | ); 15 | } 16 | 17 | export default TruncationEnabled; 18 | -------------------------------------------------------------------------------- /website/static/7xT19rLg5W.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juangl/react-truncated-component/13cffcec1001bb4f60961459dcb517f814fba547/website/static/7xT19rLg5W.gif -------------------------------------------------------------------------------- /website/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juangl/react-truncated-component/13cffcec1001bb4f60961459dcb517f814fba547/website/static/favicon.ico -------------------------------------------------------------------------------- /website/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | React Truncated Component 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /website/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juangl/react-truncated-component/13cffcec1001bb4f60961459dcb517f814fba547/website/static/logo.png -------------------------------------------------------------------------------- /website/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-truncated-component", 3 | "name": "react-truncated-component", 4 | "start_url": "./index.html", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /website/utils/mediaTemplates.js: -------------------------------------------------------------------------------- 1 | import { css } from "styled-components"; 2 | 3 | const sizes = { 4 | desktop: 1000, 5 | smallScreen: 900, 6 | tablet: 768, 7 | phone: 576, 8 | }; 9 | 10 | // Iterate through the sizes and create a media template 11 | const mediaTemplates = Object.keys(sizes).reduce((acc, label) => { 12 | acc[label] = (...args) => css` 13 | @media (max-width: ${sizes[label] / 16}em) { 14 | ${css(...args)} 15 | } 16 | `; 17 | 18 | return acc; 19 | }, {}); 20 | 21 | export default mediaTemplates; 22 | --------------------------------------------------------------------------------