Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 )
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure this kind of truncating using substrings is language specific. How does this component behave in other languages? cc @swissspidy

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some tests with strings in other languages would be good.

So if I understand this right, truncateMiddle( 'Hello', 1, 1, '...') will return H...o.

Sounds OK when using Latin alphabet, but with strings like こんにちは ("Kon'nichiwa", "Hello" in Japanese) this will result in こ...は, which probably is less understandable.

Not sure how it would work with Arabic, but you'd know that best :-)

Worst case we'd need some words/characters logic like the word count package has

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting 🤔 ! if there's a library folks are aware of that could elegantly handle this, I think it would be a worth while addition :D.


For context, So far, we've only used it for Truncating... handling.

I based this component on something I've built in the past. Also heavily drawing inspiration from how Text and truncation is handled in React Native:

https://reactnative.dev/docs/text#ellipsizemode

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding more thoughts!

Taking a step back, for the use-cases of user interfaces, realistically the only situations were we'd need to truncateMiddle would be for long file names.

For example:

thequickbrownfoxjumpsoverthelazydog.jpg

maybe becomes

theq...ydog.jpg

This is common for attachment related UIs or slug/tags.

In that case, it doesn't necessarily matter if the word/letters is linguistically understandable.
As long as there's enough to identify either the extension (e.g. .jpg) or the file / keyword / slug :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excuse my idiot questions:

  • if middle is not the important use-case for this component, should we include it?
  • It seems this code has some logic to split words... I believe we may already have some similar logic in @wordpress/wordcount that is very similar and that works across languages, If we keep this behavior, do you think there's a way to kind of consolidate that logic somehow?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should include middle as long as the use case that @ItsJonQ brought up is one that we'll need available at any time (or if block developers would find it useful for file names).

We don't do any word counting in this component, just line counting. Was there a specific place @youknowriad that you would see us using @wordpress/wordcount for this component?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I misunderstood the code but I thought there was some smart behavior to try to split between "words" or something like that in which case, it would be a similar algorithm to word count to figure out where are the words.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no such logic, as far as I can see. @ItsJonQ maybe you can chime in here to verify.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe there's logic to split words - just splitting characters.

);
}

/**
*
* @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