-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Federico Zivolo
committed
Dec 6, 2018
1 parent
8f16cc6
commit 5b54763
Showing
6 changed files
with
304 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
react-ellipsis is a React component designed to trim multi-line text | ||
to limit it to a given maximum height, adding the ellipsis (`…`) symbol | ||
at the end to denote some text has been cut. | ||
|
||
It provides the same functionality of the CSS [`text-overflow: ellipsis`][mdn-text-overflow] | ||
but with support for multi-line texts. | ||
|
||
[mdn-text-overflow]: https://developer.mozilla.org/en-US/docs/Web/CSS/text-overflow |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
{ | ||
"name": "@quid/react-ellipsis", | ||
"version": "1.0.0", | ||
"description": "Trim multi-line text and add ellipsis to it", | ||
"main": "dist/index.js", | ||
"main:umd": "dist/index.umd.js", | ||
"module": "dist/index.es.js", | ||
"license": "MIT", | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/quid/ui-framework.git" | ||
}, | ||
"scripts": { | ||
"start": "microbundle watch", | ||
"prepare": "microbundle build && flow-copy-source --ignore '{__mocks__/*,*.test}.js' src dist", | ||
"test": "cd ../.. && yarn test --testPathPattern packages/react-ellipsis" | ||
}, | ||
"devDependencies": { | ||
"flow-copy-source": "^2.0.2", | ||
"microbundle": "^0.8.3" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
// Jest Snapshot v1, https://goo.gl/fbAQLP | ||
|
||
exports[`renders the title tag if \`addTitle\` is true 1`] = ` | ||
<div | ||
title="some text here" | ||
> | ||
some text here | ||
</div> | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,179 @@ | ||
// @flow | ||
|
||
/** | ||
* React implementation of https://github.com/dollarshaveclub/shave | ||
* It allows to truncate multiple lines of text adding ellipsis at the end | ||
* | ||
* TODO: Features and enhancements | ||
* - Add `maxLines` as alternative to `maxHeight` | ||
* we may create an hidden `span` with a single letter inside to compute a line height | ||
*/ | ||
|
||
import React, { Component, type Node } from 'react'; | ||
|
||
export function nlToBr(text: string): Array<string | Node> { | ||
return text.split(/\n()/g).map((str, i) => (str ? str : <br key={i} />)); | ||
} | ||
|
||
export function isWidthDifferentFn(element: ?HTMLElement, width: number) { | ||
return !!element && width !== element.clientWidth; | ||
} | ||
|
||
type Props = { | ||
/** The text will be trimmed after exceeding this value */ | ||
maxHeight: number, | ||
/** The character to add at the end of the trimmed text */ | ||
character: string, | ||
/** The text to display, use `/n` to render newlines */ | ||
children: string, | ||
/** Wheter show or not an HTML title/tooltip on hover */ | ||
addTitle: boolean, | ||
/** The tag this component will render to wrap the text */ | ||
tag: string, | ||
}; | ||
|
||
type State = { | ||
previousChildren?: string, | ||
good: number, | ||
current: number, | ||
bad: ?number, | ||
}; | ||
|
||
export default class Ellipsis extends Component<Props, State> { | ||
static defaultProps = { | ||
tag: 'div', | ||
character: '…', | ||
children: '', | ||
addTitle: false, | ||
}; | ||
|
||
isWidthDifferentFn = isWidthDifferentFn; | ||
|
||
state = { | ||
previousChildren: undefined, | ||
good: 0, | ||
current: this.props.children.split(' ').length, | ||
bad: null, | ||
}; | ||
|
||
element: ?HTMLElement = undefined; | ||
width: number = 0; | ||
|
||
componentDidMount() { | ||
// setState in `componentDidMount` is usually bad practice... | ||
// In this case this is exactly what we want, we cache original text | ||
// as `trimmedText` and setting the state we trigger a new render | ||
// making the `componentDidUpdate` loop we want begin. | ||
this.setState({ | ||
previousChildren: this.props.children, | ||
}); | ||
} | ||
|
||
componentDidUpdate() { | ||
const { | ||
element, | ||
width, | ||
props: { children }, | ||
state: { previousChildren }, | ||
} = this; | ||
|
||
// If the component size changes, we must force a restart of the component | ||
// to make sure we show all the possible text in the new available area | ||
const isWidthDifferent = this.isWidthDifferentFn(element, width); | ||
|
||
if (!!element && isWidthDifferent) { | ||
this.width = element.clientWidth; | ||
} | ||
|
||
const isChildrenDifferent = previousChildren !== children; | ||
|
||
// If `children` has been updated, we need to update `trimmedText` in | ||
// the internal component state | ||
// In both cases, we run the updateText loop to properly trim the text | ||
if (isChildrenDifferent || isWidthDifferent) { | ||
this.setState( | ||
{ | ||
previousChildren: this.props.children, | ||
current: this.props.children.split(' ').length, | ||
good: 0, | ||
bad: null, | ||
}, | ||
this.updateText | ||
); | ||
} else { | ||
requestAnimationFrame(this.updateText); | ||
} | ||
} | ||
|
||
updateText = () => { | ||
const { | ||
element, | ||
state: { bad, good, current }, | ||
props: { maxHeight }, | ||
} = this; | ||
|
||
if (!element) { | ||
return; | ||
} | ||
|
||
const scrollHeight = element.scrollHeight; | ||
|
||
if (scrollHeight > maxHeight) { | ||
if (bad != null && bad - good === 1) { | ||
// We have found the good/bad boundary. | ||
this.setState({ current: good }); | ||
} else { | ||
// Too big, so update current to half the distance to the known good. | ||
this.setState({ | ||
current: current - ((current - good) >> 1), | ||
bad: current, | ||
}); | ||
} | ||
} else { | ||
if (bad == null || current === good) { | ||
// We did it! | ||
return; | ||
} | ||
// Too small, so update current to half the distance to the known bad. | ||
this.setState({ | ||
current: current + ((bad - current) >> 1), | ||
good: current, | ||
}); | ||
} | ||
}; | ||
|
||
render() { | ||
const { | ||
props: { children, character, tag: Tag, addTitle, ...other }, | ||
state: { current }, | ||
} = this; | ||
|
||
const { | ||
maxHeight, // eslint-disable-line no-unused-vars | ||
...tagProps | ||
} = other; | ||
|
||
const trimmedText = children | ||
.split(' ') | ||
.slice(0, current) | ||
.join(' '); | ||
|
||
// Since we can't support `<br />` inside `children`, we support `\n` and we | ||
// take care to convert them to `<br />` when we render the text. | ||
const newlinedText = nlToBr(trimmedText); | ||
|
||
// We don't want to show ellipsis if no text has been trimmed | ||
const ellipsis = children.length !== trimmedText.length && character; | ||
|
||
return ( | ||
<Tag | ||
ref={element => (this.element = element)} | ||
title={addTitle ? children : null} | ||
{...tagProps} | ||
> | ||
{newlinedText} | ||
{ellipsis} | ||
</Tag> | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
```jsx | ||
<Ellipsis maxHeight={50} style={{ width: 200, outline: '1px solid lightgray' }}> | ||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis leo augue, | ||
bibendum vel dolor ac bibendum mattis lacus. Ut a magna semper, laoreet tellus | ||
quis, rutrum libero. | ||
</Ellipsis> | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
// @flow | ||
import React from 'react'; | ||
import { mount } from 'enzyme'; | ||
import Ellipsis, { nlToBr, isWidthDifferentFn } from './'; | ||
|
||
it('converts \\n to <br />', () => { | ||
expect(nlToBr('foo\nbar')).toEqual(['foo', <br key={1} />, 'bar']); | ||
}); | ||
|
||
it('checks if element width is different', () => { | ||
const element = document.createElement('div'); | ||
expect(isWidthDifferentFn(element, 1)).toBe(true); | ||
// 0 is the default value used by JSDOM for any DOM Node size | ||
expect(isWidthDifferentFn(element, 0)).toBe(false); | ||
}); | ||
|
||
it('trims the last word if scrollHeight is higher than maxHeight', () => { | ||
const wrapper = mount(<Ellipsis maxHeight={20}>some text here</Ellipsis>); | ||
const instance: any = wrapper.instance(); | ||
instance.element = { scrollHeight: 21 }; | ||
instance.updateText(); | ||
|
||
expect(wrapper.state().current).toBe(2); | ||
}); | ||
|
||
it('does not trim the last word if scrollHeight is equal or lower than maxHeight', () => { | ||
const wrapper = mount(<Ellipsis maxHeight={20}>some text here</Ellipsis>); | ||
const instance: any = wrapper.instance(); | ||
instance.element = { scrollHeight: 20 }; | ||
instance.updateText(); | ||
|
||
expect(wrapper.state().current).toBe(3); | ||
}); | ||
|
||
it('does not trim if only one word is left', () => { | ||
const wrapper = mount(<Ellipsis maxHeight={20}>wow</Ellipsis>); | ||
const instance: any = wrapper.instance(); | ||
instance.element = { scrollHeight: 21 }; | ||
instance.updateText(); | ||
|
||
expect(wrapper.state().current).toBe(1); | ||
}); | ||
|
||
it('calls componentDidUpdate on update', () => { | ||
const wrapper = mount(<Ellipsis maxHeight={20}>some text here</Ellipsis>); | ||
expect(wrapper.state().previousChildren).toBe('some text here'); | ||
wrapper.setProps({ children: 'something else here' }); | ||
expect(wrapper.state().previousChildren).toBe('something else here'); | ||
}); | ||
|
||
it('calls componentDidUpdate on update', () => { | ||
let toDo = 2; | ||
// replace the method with one that returns true only the first two times | ||
// to properly test the update cycle | ||
class MockEllipsis extends Ellipsis { | ||
isWidthDifferentFn = () => { | ||
if (toDo > 0) { | ||
toDo--; | ||
return true; | ||
} else { | ||
return false; | ||
} | ||
}; | ||
} | ||
const wrapper = mount( | ||
<MockEllipsis maxHeight={20}>some text here</MockEllipsis> | ||
); | ||
wrapper.setProps({ children: 'something else here' }); | ||
expect(wrapper.state().previousChildren).toBe('something else here'); | ||
}); | ||
|
||
it('renders the title tag if `addTitle` is true', () => { | ||
const wrapper = mount( | ||
<Ellipsis addTitle maxHeight={20}> | ||
some text here | ||
</Ellipsis> | ||
); | ||
expect(wrapper).toMatchSnapshot(); | ||
}); |