Skip to content

Commit

Permalink
feat: new react-ellipsis component
Browse files Browse the repository at this point in the history
  • Loading branch information
Federico Zivolo committed Dec 6, 2018
1 parent 8f16cc6 commit 5b54763
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 0 deletions.
8 changes: 8 additions & 0 deletions packages/react-ellipsis/README.md
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
22 changes: 22 additions & 0 deletions packages/react-ellipsis/package.json
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"
}
}
9 changes: 9 additions & 0 deletions packages/react-ellipsis/src/__snapshots__/index.test.js.snap
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>
`;
179 changes: 179 additions & 0 deletions packages/react-ellipsis/src/index.js
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>
);
}
}
7 changes: 7 additions & 0 deletions packages/react-ellipsis/src/index.md
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>
```
79 changes: 79 additions & 0 deletions packages/react-ellipsis/src/index.test.js
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();
});

0 comments on commit 5b54763

Please sign in to comment.