Skip to content

Commit

Permalink
Components: Add truncate (#28176)
Browse files Browse the repository at this point in the history
* Add truncate

* Fix truncate stories

* Update tests and dependency versions

* Update truncate and view READMEs

* Fix package-lock.json

Co-authored-by: Jon Q <[email protected]>
  • Loading branch information
sarayourfriend and Jon Q authored Jan 26, 2021
1 parent 946d54b commit 5ced236
Show file tree
Hide file tree
Showing 19 changed files with 786 additions and 0 deletions.
12 changes: 12 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1277,12 +1277,24 @@
"markdown_source": "../packages/components/src/tree-select/README.md",
"parent": "components"
},
{
"title": "Truncate",
"slug": "truncate",
"markdown_source": "../packages/components/src/truncate/README.md",
"parent": "components"
},
{
"title": "UnitControl",
"slug": "unit-control",
"markdown_source": "../packages/components/src/unit-control/README.md",
"parent": "components"
},
{
"title": "View",
"slug": "view",
"markdown_source": "../packages/components/src/view/README.md",
"parent": "components"
},
{
"title": "VisuallyHidden",
"slug": "visually-hidden",
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export {
TreeGridItem as __experimentalTreeGridItem,
} from './tree-grid';
export { default as TreeSelect } from './tree-select';
export { default as __experimentalTruncate } from './truncate';
export { default as __experimentalUnitControl } from './unit-control';
export { default as VisuallyHidden } from './visually-hidden';
export { default as IsolatedEventContainer } from './isolated-event-container';
Expand Down
66 changes: 66 additions & 0 deletions packages/components/src/truncate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Truncate

`Truncate` is a typography primitive that trims text content. For almost all cases, it is recommended that `Text`, `Heading`, or `Subheading` is used to render text content. However, `Truncate` is available for custom implementations.

## Usage

```jsx live
import { Truncate } from '@wp-g2/components';

function Example() {
return (
<Truncate>
Where the north wind meets the sea, there's a river full of memory.
Sleep, my darling, safe and sound, for in this river all is found.
In her waters, deep and true, lay the answers and a path for you.
Dive down deep into her sound, but not too far or you'll be drowned
</Truncate>
);
}
```

## Props

##### ellipsis

**Type**: `string`

The ellipsis string when `truncate` is set.

##### ellipsizeMode

**Type**: `"auto"`,`"head"`,`"tail"`,`"middle"`

Determines where to truncate. For example, we can truncate text right in the middle. To do this, we need to set `ellipsizeMode` to `middle` and a text `limit`.

- `auto`: Trims content at the end automatically without a `limit`.
- `head`: Trims content at the beginning. Requires a `limit`.
- `middle`: Trims content in the middle. Requires a `limit`.
- `tail`: Trims content at the end. Requires a `limit`.

##### limit

**Type**: `number`

Determines the max characters when `truncate` is set.

##### numberOfLines

**Type**: `number`

Clamps the text content to the specifiec `numberOfLines`, adding the `ellipsis` at the end.

```jsx live
import { Truncate } from '@wp-g2/components';

function Example() {
return (
<Truncate numberOfLines={2}>
Where the north wind meets the sea, there's a river full of memory.
Sleep, my darling, safe and sound, for in this river all is found.
In her waters, deep and true, lay the answers and a path for you.
Dive down deep into her sound, but not too far or you'll be drowned
</Truncate>
);
}
```
3 changes: 3 additions & 0 deletions packages/components/src/truncate/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as Truncate } from './truncate';

export * from './use-truncate';
29 changes: 29 additions & 0 deletions packages/components/src/truncate/stories/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Internal dependencies
*/
import { Truncate } from '../index';

export default {
component: Truncate,
title: 'Components/Truncate',
};

export const _default = () => {
return (
<Truncate numberOfLines={ 2 }>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut
facilisis dictum tortor, eu tincidunt justo scelerisque tincidunt.
Duis semper dui id augue malesuada, ut feugiat nisi aliquam.
Vestibulum venenatis diam sem, finibus dictum massa semper in. Nulla
facilisi. Nunc vulputate faucibus diam, in lobortis arcu ornare vel.
In dignissim nunc sed facilisis finibus. Etiam imperdiet mattis
arcu, sed rutrum sapien blandit gravida. Aenean sollicitudin neque
eget enim blandit, sit amet rutrum leo vehicula. Nunc malesuada
ultricies eros ut faucibus. Aliquam erat volutpat. Nulla nec feugiat
risus. Vivamus iaculis dui aliquet ante ultricies feugiat.
Vestibulum ante ipsum primis in faucibus orci luctus et ultrices
posuere cubilia curae; Vivamus nec pretium velit, sit amet
consectetur ante. Praesent porttitor ex eget fermentum mattis.
</Truncate>
);
};
47 changes: 47 additions & 0 deletions packages/components/src/truncate/test/truncate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';

/**
* Internal dependencies
*/
import { Truncate } from '../index';

describe( 'props', () => {
test( 'should render correctly', () => {
const { container } = render(
<Truncate>Some people are worth melting for.</Truncate>
);
expect( container.firstChild.textContent ).toEqual(
'Some people are worth melting for.'
);
} );

test( 'should render limit', () => {
const { container } = render(
<Truncate limit={ 1 } ellipsizeMode="tail">
Some
</Truncate>
);
expect( container.firstChild.textContent ).toEqual( 'S…' );
} );

test( 'should render custom ellipsis', () => {
const { container } = render(
<Truncate ellipsis="!!!" limit={ 5 } ellipsizeMode="tail">
Some people are worth melting for.
</Truncate>
);
expect( container.firstChild.textContent ).toEqual( 'Some !!!' );
} );

test( 'should render custom ellipsizeMode', () => {
const { container } = render(
<Truncate ellipsis="!!!" ellipsizeMode="middle" limit={ 5 }>
Some people are worth melting for.
</Truncate>
);
expect( container.firstChild.textContent ).toEqual( 'So!!!r.' );
} );
} );
11 changes: 11 additions & 0 deletions packages/components/src/truncate/truncate-styles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* External dependencies
*/
import { css } from '@wp-g2/styles';

export const Truncate = css`
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
96 changes: 96 additions & 0 deletions packages/components/src/truncate/truncate-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { isNil } from 'lodash';

export const TRUNCATE_ELLIPSIS = '…';
export const TRUNCATE_TYPE = {
auto: 'auto',
head: 'head',
middle: 'middle',
tail: 'tail',
none: 'none',
};

export const TRUNCATE_DEFAULT_PROPS = {
ellipsis: TRUNCATE_ELLIPSIS,
ellipsizeMode: TRUNCATE_TYPE.auto,
limit: 0,
numberOfLines: 0,
};

// Source
// https://github.com/kahwee/truncate-middle
/**
* @param {string} word
* @param {number} headLength
* @param {number} tailLength
* @param {string} ellipsis
*/
export function truncateMiddle( word, headLength, tailLength, ellipsis ) {
if ( typeof word !== 'string' ) {
return '';
}
const wordLength = word.length;
// Setting default values
// eslint-disable-next-line no-bitwise
const frontLength = ~~headLength; // will cast to integer
// eslint-disable-next-line no-bitwise
const backLength = ~~tailLength;
/* istanbul ignore next */
const truncateStr = ! isNil( ellipsis ) ? ellipsis : TRUNCATE_ELLIPSIS;

if (
( frontLength === 0 && backLength === 0 ) ||
frontLength >= wordLength ||
backLength >= wordLength ||
frontLength + backLength >= wordLength
) {
return word;
} else if ( backLength === 0 ) {
return word.slice( 0, frontLength ) + truncateStr;
}
return (
word.slice( 0, frontLength ) +
truncateStr +
word.slice( wordLength - backLength )
);
}

/**
*
* @param {string} words
* @param {typeof TRUNCATE_DEFAULT_PROPS} props
*/
export function truncateContent( words = '', props ) {
const mergedProps = { ...TRUNCATE_DEFAULT_PROPS, ...props };
const { ellipsis, ellipsizeMode, limit } = mergedProps;

if ( ellipsizeMode === TRUNCATE_TYPE.none ) {
return words;
}

let truncateHead;
let truncateTail;

switch ( ellipsizeMode ) {
case TRUNCATE_TYPE.head:
truncateHead = 0;
truncateTail = limit;
break;
case TRUNCATE_TYPE.middle:
truncateHead = Math.floor( limit / 2 );
truncateTail = Math.floor( limit / 2 );
break;
default:
truncateHead = limit;
truncateTail = 0;
}

const truncatedContent =
ellipsizeMode !== TRUNCATE_TYPE.auto
? truncateMiddle( words, truncateHead, truncateTail, ellipsis )
: words;

return truncatedContent;
}
11 changes: 11 additions & 0 deletions packages/components/src/truncate/truncate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Internal dependencies
*/
import { createComponent } from '../utils';
import { useTruncate } from './use-truncate';

export default createComponent( {
as: 'span',
useHook: useTruncate,
name: 'Truncate',
} );
74 changes: 74 additions & 0 deletions packages/components/src/truncate/use-truncate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { useContextSystem } from '@wp-g2/context';
import { css, cx } from '@wp-g2/styles';

/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';

/**
* Internal dependencies
*/
import * as styles from './truncate-styles';
import {
TRUNCATE_ELLIPSIS,
TRUNCATE_TYPE,
truncateContent,
} from './truncate-utils';

/**
* @typedef Props
* @property {string} [ellipsis='...'] String to use to truncate the string with.
* @property {'auto' | 'head' | 'tail' | 'middle' | 'none'} [ellipsizeMode='auto'] Mode to follow.
* @property {number} [limit=0] Limit.
* @property {number} [numberOfLines=0] Number of lines.
*/

/**
* @param {import('@wp-g2/create-styles').ViewOwnProps<Props, 'span'>} props
*/
export function useTruncate( props ) {
const {
className,
children,
ellipsis = TRUNCATE_ELLIPSIS,
ellipsizeMode = TRUNCATE_TYPE.auto,
limit = 0,
numberOfLines = 0,
...otherProps
} = useContextSystem( props, 'Truncate' );

const truncatedContent = truncateContent(
typeof children === 'string' ? /** @type {string} */ ( children ) : '',
{
ellipsis,
ellipsizeMode,
limit,
numberOfLines,
}
);

const shouldTruncate = ellipsizeMode === TRUNCATE_TYPE.auto;

const classes = useMemo( () => {
const sx = {};

sx.numberOfLines = css`
-webkit-box-orient: vertical;
-webkit-line-clamp: ${ numberOfLines };
display: -webkit-box;
overflow: hidden;
`;

return cx(
shouldTruncate && ! numberOfLines && styles.Truncate,
shouldTruncate && !! numberOfLines && sx.numberOfLines,
className
);
}, [ className, numberOfLines, shouldTruncate ] );

return { ...otherProps, className: classes, children: truncatedContent };
}
Loading

0 comments on commit 5ced236

Please sign in to comment.