From f499076a5a25845224c7a1ce51060fd6c5be4b35 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 3 Oct 2023 14:48:22 -0700 Subject: [PATCH 1/3] Add new `EuiTextBlockTruncate` component --- .../text_block_truncate.stories.tsx | 45 ++++++++++++ .../text_block_truncate.test.tsx | 45 ++++++++++++ .../text_truncate/text_block_truncate.tsx | 73 +++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/components/text_truncate/text_block_truncate.stories.tsx create mode 100644 src/components/text_truncate/text_block_truncate.test.tsx create mode 100644 src/components/text_truncate/text_block_truncate.tsx diff --git a/src/components/text_truncate/text_block_truncate.stories.tsx b/src/components/text_truncate/text_block_truncate.stories.tsx new file mode 100644 index 00000000000..fae93b3b2ca --- /dev/null +++ b/src/components/text_truncate/text_block_truncate.stories.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { EuiPanel } from '../panel'; + +import { + EuiTextBlockTruncate, + EuiTextBlockTruncateProps, +} from './text_block_truncate'; + +const meta: Meta = { + title: 'EuiTextBlockTruncate', + component: EuiTextBlockTruncate, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = { + render: ({ children, ...args }) => ( + +

{children}

+
+ ), + args: { + lines: 3, + children: + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.", + }, +}; diff --git a/src/components/text_truncate/text_block_truncate.test.tsx b/src/components/text_truncate/text_block_truncate.test.tsx new file mode 100644 index 00000000000..d954c93d6bf --- /dev/null +++ b/src/components/text_truncate/text_block_truncate.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render } from '../../test/rtl'; +import { shouldRenderCustomStyles } from '../../test/internal'; + +import { EuiTextBlockTruncate } from './text_block_truncate'; + +describe('EuiTextBlockTruncate', () => { + shouldRenderCustomStyles(); + + it('renders', () => { + const { container } = render( + Hello world + ); + expect(container.firstChild).toMatchInlineSnapshot(` +
+ Hello world +
+ `); + }); + + it('allows cloning styles onto the child element instead of rendering an extra div wrapper', () => { + const { container } = render( + +

Hello world

+
+ ); + expect(container.firstChild).toMatchInlineSnapshot(` +

+ Hello world +

+ `); + }); +}); diff --git a/src/components/text_truncate/text_block_truncate.tsx b/src/components/text_truncate/text_block_truncate.tsx new file mode 100644 index 00000000000..8e91d026e54 --- /dev/null +++ b/src/components/text_truncate/text_block_truncate.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { + isValidElement, + FunctionComponent, + HTMLAttributes, + PropsWithChildren, + useMemo, +} from 'react'; +import { css } from '@emotion/react'; +import classNames from 'classnames'; + +import { CommonProps } from '../common'; +import { cloneElementWithCss } from '../../services'; + +const styles = { + euiTextBlockTruncate: css` + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 0; + overflow: hidden; + `, +}; + +export type EuiTextBlockTruncateProps = PropsWithChildren & + CommonProps & + HTMLAttributes & { + /** + * Number of lines of text to truncate to + */ + lines: number; + /** + * Applies styling to the child element instead of rendering a parent wrapper `div`. + * Can only be used when wrapping a *single* child element/tag, and not raw text. + */ + cloneElement?: boolean; + }; + +export const EuiTextBlockTruncate: FunctionComponent< + EuiTextBlockTruncateProps +> = ({ children, className, style, lines, cloneElement, ...rest }) => { + const classes = classNames('euiTextBlockTruncate', className); + + const cssStyles = styles.euiTextBlockTruncate; + + const inlineStyles = useMemo( + () => ({ + WebkitLineClamp: lines, + ...style, + }), + [lines, style] + ); + + if (isValidElement(children) && cloneElement) { + return cloneElementWithCss(children, { + css: cssStyles, + style: { ...children.props.style, ...inlineStyles }, + className: classNames(children.props.className, classes), + }); + } else { + return ( +
+ {children} +
+ ); + } +}; From 86bbad2dd372988b11fe1d2f68c1e9193cc3eda5 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 3 Oct 2023 16:46:29 -0700 Subject: [PATCH 2/3] [docs] Create tabbed section on text truncation page + update `` to not require a `page` component, and use the `sections` array instead --- .../guide_tabbed_page/guide_tabbed_page.tsx | 18 +- src-docs/src/routes.js | 52 +- .../src/views/text_truncate/multi_line.tsx | 25 + .../text_truncate/text_truncate_example.js | 527 ++++++++++-------- src/components/text_truncate/index.ts | 3 + 5 files changed, 360 insertions(+), 265 deletions(-) create mode 100644 src-docs/src/views/text_truncate/multi_line.tsx diff --git a/src-docs/src/components/guide_tabbed_page/guide_tabbed_page.tsx b/src-docs/src/components/guide_tabbed_page/guide_tabbed_page.tsx index 78c9fce25ed..576de96f5dd 100644 --- a/src-docs/src/components/guide_tabbed_page/guide_tabbed_page.tsx +++ b/src-docs/src/components/guide_tabbed_page/guide_tabbed_page.tsx @@ -20,7 +20,10 @@ import { } from '../../../../src/components'; import { LanguageSelector, ThemeContext } from '../with_theme'; -import { GuideSection } from '../guide_section/guide_section'; +import { + GuideSection, + GuideSectionProps, +} from '../guide_section/guide_section'; export type GuideTabbedPageProps = PropsWithChildren & CommonProps & { @@ -126,11 +129,20 @@ export const GuideTabbedPage: FunctionComponent = ({ /> ); } else { - const PageComponent = page.page; + let rendered: ReactNode; + + if (page.page) { + const PageComponent = page.page; + rendered = ; + } else { + rendered = page.sections.map((sectionProps: GuideSectionProps) => ( + + )); + } return ( - + {rendered} ); } diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 6fdae119ce1..930a20ca705 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -650,31 +650,33 @@ const navigation = [ { name: 'Utilities', items: [ - AccessibilityExample, - AutoSizerExample, - BeaconExample, - ColorPaletteExample, - CopyExample, - UtilityClassesExample, - DelayRenderExample, - ErrorBoundaryExample, - FocusTrapExample, - HighlightAndMarkExample, - HtmlIdGeneratorExample, - InnerTextExample, - I18nExample, - MutationObserverExample, - OutsideClickDetectorExample, - OverlayMaskExample, - PortalExample, - PrettyDurationExample, - ProviderExample, - ResizeObserverExample, - ScrollExample, - TextDiffExample, - TextTruncateExample, - WindowEventExample, - ].map((example) => createExample(example)), + ...[ + AccessibilityExample, + AutoSizerExample, + BeaconExample, + ColorPaletteExample, + CopyExample, + UtilityClassesExample, + DelayRenderExample, + ErrorBoundaryExample, + FocusTrapExample, + HighlightAndMarkExample, + HtmlIdGeneratorExample, + InnerTextExample, + I18nExample, + MutationObserverExample, + OutsideClickDetectorExample, + OverlayMaskExample, + PortalExample, + PrettyDurationExample, + ProviderExample, + ResizeObserverExample, + ScrollExample, + TextDiffExample, + ].map((example) => createExample(example)), + createTabbedPage(TextTruncateExample), + createExample(WindowEventExample), + ], }, { name: 'Package', diff --git a/src-docs/src/views/text_truncate/multi_line.tsx b/src-docs/src/views/text_truncate/multi_line.tsx new file mode 100644 index 00000000000..f341584a839 --- /dev/null +++ b/src-docs/src/views/text_truncate/multi_line.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { css } from '@emotion/react'; +import { faker } from '@faker-js/faker'; + +import { EuiPanel, EuiText, EuiTextBlockTruncate } from '../../../../src'; + +export default () => { + return ( + + + + {faker.lorem.lines(10)} + + + + ); +}; diff --git a/src-docs/src/views/text_truncate/text_truncate_example.js b/src-docs/src/views/text_truncate/text_truncate_example.js index a38a75383b8..a463fba56b9 100644 --- a/src-docs/src/views/text_truncate/text_truncate_example.js +++ b/src-docs/src/views/text_truncate/text_truncate_example.js @@ -9,6 +9,7 @@ import { EuiCode, EuiCallOut, EuiTextTruncate, + EuiTextBlockTruncate, } from '../../../../src/components'; import Truncation from './truncation'; @@ -29,10 +30,13 @@ const renderPropSource = require('!!raw-loader!./render_prop'); import Performance from './performance'; const performanceSource = require('!!raw-loader!./performance'); +import MultiLine from './multi_line'; +const multiLineSource = require('!!raw-loader!./multi_line'); + export const TextTruncateExample = { title: 'Text truncation', isBeta: true, - intro: ( + notice: ( EuiTextTruncate is a beta component that is still undergoing performance investigation. We @@ -40,259 +44,308 @@ export const TextTruncateExample = { per page). ), - sections: [ + pages: [ { - source: [ + title: 'Single line', + sections: [ { - type: GuideSectionTypes.JS, - code: truncationSource, + source: [ + { + type: GuideSectionTypes.JS, + code: truncationSource, + }, + ], + text: ( + <> +

+ EuiTextTruncate provides customizable and + size-aware single line text truncation. +

+

+ The four truncation styles supported are{' '} + start, end,{' '} + startEnd, and middle. + Resize the below demo to see how different truncation styles + respond to dynamic width changes. +

+ + ), + demo: , + props: { EuiTextTruncate }, + snippet: ``, }, - ], - text: ( - <> -

- EuiTextTruncate provides customizable and - size-aware single line text truncation. -

-

- The four truncation styles supported are start,{' '} - end, startEnd, and{' '} - middle. Resize the below demo to see how - different truncation styles respond to dynamic width changes. -

- - ), - demo: , - props: { EuiTextTruncate }, - snippet: ``, - }, - { - text: ( - <> - -

- EuiTextTruncate attempts to mimic the behavior of{' '} - text-overflow: ellipsis as closely as possible, - although there may be edge cases and cross-browser issues, as this - is essentially a{' '} - + - browser implementation - {' '} - we are trying to polyfill. +

+ EuiTextTruncate attempts to mimic the + behavior of text-overflow: ellipsis as + closely as possible, although there may be edge cases and + cross-browser issues, as this is essentially a{' '} + + browser implementation + {' '} + we are trying to polyfill. +

+
    +
  • + Screen readers should ignore the truncated text and only + read out the full text. +
  • +
  • + Sighted mouse users will be able to briefly hover over the + truncated text and read the full text in a native browser + title tooltip. +
  • +
  • + For mouse users, double clicking to select the truncated + line should allow copying the full untruncated text. +
  • +
+
+ + ), + }, + { + title: 'Custom ellipsis', + source: [ + { + type: GuideSectionTypes.JS, + code: ellipsisSource, + }, + ], + text: ( +

+ By default, EuiTextTruncate uses the unicode + character for horizontal ellipis. It can be customized via the{' '} + ellipsis prop as necessary (e.g. for specific + languages, extra punctuation, etc).

-
    -
  • - Screen readers should ignore the truncated text and only read - out the full text. -
  • -
  • - Sighted mouse users will be able to briefly hover over the - truncated text and read the full text in a native browser title - tooltip. -
  • -
  • - For mouse users, double clicking to select the truncated line - should allow copying the full untruncated text. -
  • -
- - - ), - }, - { - title: 'Custom ellipsis', - source: [ + ), + demo: , + props: { EuiTextTruncate }, + snippet: ``, + }, + { + title: 'Truncation offset', + source: [ + { + type: GuideSectionTypes.JS, + code: truncationOffsetSource, + }, + ], + text: ( +

+ The start and end truncation + types support a truncationOffset property that + allows preserving a specified number of characters at either the + start or end of the text. Increase or decrease the number control + below to see the prop in action. +

+ ), + demo: , + props: { EuiTextTruncate }, + snippet: ``, + }, { - type: GuideSectionTypes.JS, - code: ellipsisSource, + title: 'Truncation position', + source: [ + { + type: GuideSectionTypes.JS, + code: truncationPositionSource, + }, + ], + text: ( + <> +

+ The startEnd truncation type supports a{' '} + truncationPosition property. By default,{' '} + startEnd anchors the displayed text to the + middle of the string. However, you may prefer to display a + specific subsection of the full text closer to the start or end, + which this prop allows. +

+

+ This behavior will intelligently detect when positions are near + enough to the start or end of the text to omit leading or + trailing ellipses when necessary. +

+

+ Increase or decrease the number control below to see the prop in + action. +

+ + ), + demo: , + props: { EuiTextTruncate }, + snippet: ``, }, - ], - text: ( -

- By default, EuiTextTruncate uses the unicode - character for horizontal ellipis. It can be customized via the{' '} - ellipsis prop as necessary (e.g. for specific - languages, extra punctuation, etc). -

- ), - demo: , - props: { EuiTextTruncate }, - snippet: ``, - }, - { - title: 'Truncation offset', - source: [ { - type: GuideSectionTypes.JS, - code: truncationOffsetSource, + title: 'Render prop', + source: [ + { + type: GuideSectionTypes.JS, + code: renderPropSource, + }, + ], + text: ( + <> +

+ By default, EuiTextTruncate will automatically + output the calculated truncated string. You can optionally + override this by passing a render prop function to{' '} + children, which allows for more flexible text + rendering. +

+

+ The below example demonstrates a primary use case for the render + prop and the truncationPosition prop. If a + user is searching for a specific word in truncated text, you can + use{' '} + + EuiHighlight or EuiMark + {' '} + to highlight the search term, and passing the index of the found + word to truncationPosition ensures the search + term is always visible to the user. +

+ + + ), + demo: , + props: { EuiTextTruncate }, + snippet: ` + {(text) => {text}} + `, }, - ], - text: ( -

- The start and end truncation - types support a truncationOffset property that - allows preserving a specified number of characters at either the start - or end of the text. Increase or decrease the number control below to - see the prop in action. -

- ), - demo: , - props: { EuiTextTruncate }, - snippet: ``, - }, - { - title: 'Truncation position', - source: [ { - type: GuideSectionTypes.JS, - code: truncationPositionSource, + title: 'Performance', + text: ( + <> +

+ EuiTextTruncate uses a canvas element under the + hood to manipulate text and calculate whether the text width + fits within the available width. Additionally, by default, the + component will include its own resize observer in order to react + to width changes. +

+

+ These functionalities can cause performance issues if the + component is rendered over hundreds of times per page, and we + would strongly recommend using caution when doing so. Several + escape hatches are available for performance improvements: +

+
    + css` + li:not(:last-child) { + margin-block-end: ${euiTheme.size.m}; + } + ` + } + > +
  1. + Pass a width prop to skip initializing a + resize observer for each component instance. For text within a + container of the same width, we would strongly recommend + applying a single resize observer to the parent container and + passing that width to all child{' '} + EuiTextTruncates. Additionally, you may want + to consider{' '} + + throttling + {' '} + any resize observers or width-based logic. +
  2. +
  3. + Use{' '} + + virtualization + {' '} + to reduce the number of rendered elements visible at any given + time. For over hundreds of instances, this will generally be + the most effective solution for performance or rerender + issues. +
  4. +
  5. + If necessary, consider pulling out the underlying{' '} + TruncationUtils and re-using the same + canvas context, as opposed to repeatedly creating new ones. +
  6. +
+ + ), + demo: , + source: [{ type: GuideSectionTypes.TSX, code: performanceSource }], + props: { EuiTextTruncate }, + snippet: ``, }, ], - text: ( - <> -

- The startEnd truncation type supports a{' '} - truncationPosition property. By default,{' '} - startEnd anchors the displayed text to the middle - of the string. However, you may prefer to display a specific - subsection of the full text closer to the start or end, which this - prop allows. -

-

- This behavior will intelligently detect when positions are near - enough to the start or end of the text to omit leading or trailing - ellipses when necessary. -

-

- Increase or decrease the number control below to see the prop in - action. -

- - ), - demo: , - props: { EuiTextTruncate }, - snippet: ``, }, { - title: 'Render prop', - source: [ + title: 'Multi line', + sections: [ { - type: GuideSectionTypes.JS, - code: renderPropSource, + source: [ + { + type: GuideSectionTypes.JS, + code: multiLineSource, + }, + ], + text: ( + <> +

+ EuiTextBlockTruncate allows truncating text + after a set number of wrapping lines. +

+

+ Please note: This component is currently a quick shortcut for + the{' '} + + CSS line-clamp + {' '} + property. This means that truncating at the end of the text is + the default, and there are currently no plans to add JavaScript + customization for this behavior. +

+ + ), + demo: , + props: { EuiTextBlockTruncate }, + snippet: `Hello world`, }, ], - text: ( - <> -

- By default, EuiTextTruncate will automatically - output the calculated truncated string. You can optionally override - this by passing a render prop function to{' '} - children, which allows for more flexible text - rendering. -

-

- The below example demonstrates a primary use case for the render - prop and the truncationPosition prop. If a user - is searching for a specific word in truncated text, you can use{' '} - - EuiHighlight or EuiMark - {' '} - to highlight the search term, and passing the index of the found - word to truncationPosition ensures the search - term is always visible to the user. -

- - - ), - demo: , - props: { EuiTextTruncate }, - snippet: ` - {(text) => {text}} -`, - }, - { - title: 'Performance', - text: ( - <> -

- EuiTextTruncate uses a canvas element under the - hood to manipulate text and calculate whether the text width fits - within the available width. Additionally, by default, the component - will include its own resize observer in order to react to width - changes. -

-

- These functionalities can cause performance issues if the component - is rendered over hundreds of times per page, and we would strongly - recommend using caution when doing so. Several escape hatches are - available for performance improvements: -

-
    - css` - li:not(:last-child) { - margin-block-end: ${euiTheme.size.m}; - } - ` - } - > -
  1. - Pass a width prop to skip initializing a resize - observer for each component instance. For text within a container - of the same width, we would strongly recommend applying a single - resize observer to the parent container and passing that width to - all child EuiTextTruncates. Additionally, you may - want to consider{' '} - - throttling - {' '} - any resize observers or width-based logic. -
  2. -
  3. - Use{' '} - - virtualization - {' '} - to reduce the number of rendered elements visible at any given - time. For over hundreds of instances, this will generally be the - most effective solution for performance or rerender issues. -
  4. -
  5. - If necessary, consider pulling out the underlying{' '} - TruncationUtils and re-using the same canvas - context, as opposed to repeatedly creating new ones. -
  6. -
- - ), - demo: , - source: [{ type: GuideSectionTypes.TSX, code: performanceSource }], - props: { EuiTextTruncate }, - snippet: ``, }, ], }; diff --git a/src/components/text_truncate/index.ts b/src/components/text_truncate/index.ts index a2785e5a6c7..4d5bbcb18b0 100644 --- a/src/components/text_truncate/index.ts +++ b/src/components/text_truncate/index.ts @@ -12,4 +12,7 @@ export type { } from './text_truncate'; export { EuiTextTruncate } from './text_truncate'; +export type { EuiTextBlockTruncateProps } from './text_block_truncate'; +export { EuiTextBlockTruncate } from './text_block_truncate'; + export { TruncationUtils } from './utils'; From 45471fb580c6bbb242b497245800621a426597b3 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 3 Oct 2023 19:34:00 -0700 Subject: [PATCH 3/3] changelog --- upcoming_changelogs/7250.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 upcoming_changelogs/7250.md diff --git a/upcoming_changelogs/7250.md b/upcoming_changelogs/7250.md new file mode 100644 index 00000000000..af7320f7e1c --- /dev/null +++ b/upcoming_changelogs/7250.md @@ -0,0 +1 @@ +- Added a new beta `EuiTextBlockTruncate` component for multi-line truncation