Skip to content

Commit 6e7002d

Browse files
authored
feat(ExpandableText): introduce component (#5258)
Closes #5062
1 parent cb55a07 commit 6e7002d

File tree

8 files changed

+291
-2
lines changed

8 files changed

+291
-2
lines changed

packages/main/src/components/ActionSheet/ActionSheet.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import ResponsivePopoverDomRef from '../../webComponents/ResponsivePopover/Respo
1818

1919
<ControlsWithNote of={ComponentStories.Default} />
2020

21-
<DomRefTable rows={ResponsivePopoverDomRef} /> |
21+
<DomRefTable rows={ResponsivePopoverDomRef} />
2222

2323
## Opening ActionSheets
2424

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { cypressPassThroughTestsFactory } from '@/cypress/support/utils';
2+
import { ExpandableText } from './index.js';
3+
4+
const longText = ` If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sentence. Lorem ipsum dolor st amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat`;
5+
6+
function getText(text) {
7+
cy.findByTestId('et').should('be.visible').and('have.text', text);
8+
}
9+
10+
describe('ExpandableText', () => {
11+
it('maxCharacters', () => {
12+
[false, true].forEach((renderWhitespace) => {
13+
cy.mount(
14+
<ExpandableText data-testid="et" renderWhitespace={renderWhitespace}>
15+
{longText}
16+
</ExpandableText>
17+
);
18+
getText(
19+
renderWhitespace
20+
? ' If renderWhitespace is set to true, there will be thirteen white spaces in front and af... Show more'
21+
: 'If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sent... Show more'
22+
);
23+
cy.mount(
24+
<ExpandableText
25+
data-testid="et"
26+
maxCharacters={200}
27+
style={{ width: '300px' }}
28+
renderWhitespace={renderWhitespace}
29+
>
30+
{longText}
31+
</ExpandableText>
32+
);
33+
getText(
34+
renderWhitespace
35+
? ' If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sentence. Lorem ipsum dolor st amet, consetetur sadipscing elitr, sed diam nonu... Show more'
36+
: 'If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sentence. Lorem ipsum dolor st amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt... Show more'
37+
);
38+
39+
cy.mount(
40+
<ExpandableText data-testid="et" maxCharacters={2000} renderWhitespace={renderWhitespace}>
41+
{longText}
42+
</ExpandableText>
43+
);
44+
getText(
45+
' If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sentence. Lorem ipsum dolor st amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat'
46+
);
47+
});
48+
});
49+
50+
it('expand/collapse', () => {
51+
cy.mount(<ExpandableText data-testid="et">{longText}</ExpandableText>);
52+
cy.findByText('Show more').click();
53+
getText(
54+
' If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sentence. Lorem ipsum dolor st amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat Show less'
55+
);
56+
cy.findByText('Show less').click();
57+
getText(
58+
'If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sent... Show more'
59+
);
60+
});
61+
62+
it('showOverflowInPopover', () => {
63+
cy.mount(
64+
<ExpandableText data-testid="et" showOverflowInPopover>
65+
{longText}
66+
</ExpandableText>
67+
);
68+
cy.findByText('Show more').click();
69+
getText(
70+
'If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sent... Show less'
71+
);
72+
cy.get('[ui5-responsive-popover]').should('have.attr', 'open');
73+
cy.realPress('Escape');
74+
getText(
75+
'If renderWhitespace is set to true, there will be thirteen white spaces in front and after this sent... Show more'
76+
);
77+
});
78+
79+
cypressPassThroughTestsFactory(ExpandableText);
80+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ControlsWithNote, DocsHeader, Footer } from '@sb/components';
2+
import { Canvas, Meta } from '@storybook/blocks';
3+
import * as ComponentStories from './ExpandableText.stories';
4+
5+
<Meta of={ComponentStories} />
6+
7+
<DocsHeader since="1.23.0" />
8+
9+
<br />
10+
11+
## Example
12+
13+
<Canvas of={ComponentStories.Default} />
14+
15+
## Properties
16+
17+
<ControlsWithNote of={ComponentStories.Default} />
18+
19+
<Footer />
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { ExpandableText } from './index';
3+
4+
const meta = {
5+
title: 'Data Display / ExpandableText',
6+
component: ExpandableText,
7+
argTypes: {
8+
children: {
9+
control: 'text'
10+
}
11+
},
12+
args: {
13+
children: ` If "renderWhitespace" is set to true, there will be thirteen white spaces in front and after this sentence. Lorem ipsum dolor st amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat`,
14+
maxCharacters: 100
15+
}
16+
} satisfies Meta<typeof ExpandableText>;
17+
18+
export default meta;
19+
type Story = StoryObj<typeof meta>;
20+
21+
export const Default: Story = {};
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
'use client';
2+
3+
import { useI18nBundle, useIsomorphicId } from '@ui5/webcomponents-react-base';
4+
import { clsx } from 'clsx';
5+
import React, { forwardRef, useEffect, useRef, useState } from 'react';
6+
import { createPortal } from 'react-dom';
7+
import { createUseStyles } from 'react-jss';
8+
import { CLOSE_POPOVER, SHOW_FULL_TEXT, SHOW_LESS, SHOW_MORE } from '../../i18n/i18n-defaults.js';
9+
import type { CommonProps } from '../../interfaces/index.js';
10+
import { useCanRenderPortal } from '../../internal/ssr.js';
11+
import { getUi5TagWithSuffix } from '../../internal/utils.js';
12+
import type { LinkDomRef } from '../../webComponents/index.js';
13+
import { Link } from '../../webComponents/index.js';
14+
import { ResponsivePopover } from '../../webComponents/ResponsivePopover/index.js';
15+
import type { TextPropTypes } from '../Text/index.js';
16+
import { Text } from '../Text/index.js';
17+
import { TextStyles } from '../Text/Text.jss.js';
18+
19+
export interface ExpandableTextPropTypes
20+
extends Omit<TextPropTypes, 'maxLines' | 'wrapping' | 'children'>,
21+
CommonProps {
22+
/**
23+
* Determines the text to be displayed.
24+
*/
25+
children?: string;
26+
/**
27+
* Specifies the maximum number of characters from the beginning of the text field that are shown initially.
28+
*
29+
* @default 100
30+
*/
31+
maxCharacters?: number;
32+
/**
33+
* Determines if the full text should be displayed inside a `ResponsivePopover` or in-place.
34+
*/
35+
showOverflowInPopover?: boolean;
36+
/**
37+
* Defines where modals are rendered into via `React.createPortal`.
38+
*
39+
* You can find out more about this [here](https://sap.github.io/ui5-webcomponents-react/?path=/docs/knowledge-base-working-with-portals--page).
40+
*
41+
* @default document.body
42+
*/
43+
portalContainer?: Element;
44+
}
45+
46+
const useStyles = createUseStyles(
47+
{
48+
expandableText: { ...TextStyles.text },
49+
text: { display: 'inline' },
50+
ellipsis: { wordSpacing: '0.125rem' },
51+
popover: { maxWidth: '30rem', '&::part(content)': { padding: '1rem' } }
52+
},
53+
{ name: 'ExpandableText' }
54+
);
55+
/**
56+
* The `ExpandableText` component can be used to display long texts inside a table, list or form.
57+
*
58+
* Initially, only the first characters from the text are shown with a "Show More" link which allows the full text to be displayed. The `showOverflowInPopover` property determines if the full text will be displayed expanded in place (default) or in a popover (`showOverflowInPopover: true`). If the text is expanded a "Show Less" link is displayed, which allows collapsing the text field.
59+
*
60+
* @since 1.23.0
61+
*/
62+
const ExpandableText = forwardRef<HTMLSpanElement, ExpandableTextPropTypes>((props, ref) => {
63+
const {
64+
children,
65+
emptyIndicator,
66+
renderWhitespace,
67+
hyphenated,
68+
showOverflowInPopover,
69+
maxCharacters = 100,
70+
portalContainer,
71+
className,
72+
...rest
73+
} = props;
74+
const [collapsed, setCollapsed] = useState(true);
75+
const [popoverOpen, setPopoverOpen] = useState(false);
76+
const linkRef = useRef<LinkDomRef>(null);
77+
const classes = useStyles();
78+
const uniqueId = useIsomorphicId();
79+
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
80+
const trimmedChildren = renderWhitespace ? children : children?.replace(/\s+/g, ' ').trim();
81+
const isOverflow = trimmedChildren?.length >= maxCharacters;
82+
const strippedChildren =
83+
isOverflow && (collapsed || showOverflowInPopover) ? trimmedChildren?.slice(0, maxCharacters) : children;
84+
85+
const handleClick = () => {
86+
if (showOverflowInPopover) {
87+
setPopoverOpen((prev) => !prev);
88+
}
89+
setCollapsed((prev) => !prev);
90+
};
91+
92+
const closePopover = () => {
93+
setCollapsed(true);
94+
setPopoverOpen(false);
95+
};
96+
97+
useEffect(() => {
98+
const tagName = getUi5TagWithSuffix('ui5-link');
99+
void customElements.whenDefined(tagName).then(() => {
100+
if (linkRef.current) {
101+
if (showOverflowInPopover) {
102+
linkRef.current.accessibilityAttributes = { hasPopup: 'Dialog' };
103+
} else {
104+
linkRef.current.accessibilityAttributes = { expanded: !collapsed };
105+
}
106+
}
107+
});
108+
}, [collapsed, showOverflowInPopover]);
109+
110+
const canRenderPortal = useCanRenderPortal();
111+
if (showOverflowInPopover && !canRenderPortal) {
112+
return null;
113+
}
114+
return (
115+
<span className={clsx(classes.expandableText, className)} {...rest} ref={ref}>
116+
<Text
117+
emptyIndicator={emptyIndicator}
118+
renderWhitespace={renderWhitespace}
119+
hyphenated={hyphenated}
120+
className={classes.text}
121+
>
122+
{strippedChildren}
123+
</Text>
124+
{isOverflow && (
125+
<>
126+
<span className={classes.ellipsis}>{showOverflowInPopover || collapsed ? '... ' : ' '}</span>
127+
<Link
128+
accessibleName={
129+
showOverflowInPopover
130+
? collapsed
131+
? i18nBundle.getText(CLOSE_POPOVER)
132+
: i18nBundle.getText(SHOW_FULL_TEXT)
133+
: undefined
134+
}
135+
accessibleRole="button"
136+
onClick={handleClick}
137+
ref={linkRef}
138+
id={`${uniqueId}-link`}
139+
>
140+
{collapsed ? i18nBundle.getText(SHOW_MORE) : i18nBundle.getText(SHOW_LESS)}
141+
</Link>
142+
</>
143+
)}
144+
{showOverflowInPopover &&
145+
popoverOpen &&
146+
createPortal(
147+
<ResponsivePopover opener={`${uniqueId}-link`} open onAfterClose={closePopover} className={classes.popover}>
148+
<Text renderWhitespace={renderWhitespace} hyphenated={hyphenated} className={classes.text}>
149+
{children}
150+
</Text>
151+
</ResponsivePopover>,
152+
portalContainer ?? document.body
153+
)}
154+
</span>
155+
);
156+
});
157+
158+
ExpandableText.displayName = 'ExpandableText';
159+
160+
export { ExpandableText };

packages/main/src/components/Text/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { TextStyles } from './Text.jss.js';
1111

1212
export interface TextPropTypes extends CommonProps {
1313
/**
14-
* Pass the text as direct child of Text
14+
* Pass the text as direct child of Text.
15+
*
16+
* __Note:__ Although this prop accepts all HTML Elements, it is strongly recommended that you only use text in order to preserve the intended design.
1517
*/
1618
children?: ReactNode;
1719
/**

packages/main/src/i18n/messagebundle.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,9 @@ SELECT_ALL=Select All
290290

291291
#XACT: Tooltip for selected "Select All" checkbox of table
292292
DESELECT_ALL=Deselect All
293+
294+
#XACT: Invisible text for ExpandableText Show More button
295+
SHOW_FULL_TEXT=Show the full text
296+
297+
#XACT: Invisible text for ExpandableText Show Less button
298+
CLOSE_POPOVER=Close the popover

packages/main/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './components/AnalyticalTable/defaults/LoadingComponent/TablePlace
88
export * from './components/DynamicPage/index.js';
99
export * from './components/DynamicPageHeader/index.js';
1010
export * from './components/DynamicPageTitle/index.js';
11+
export * from './components/ExpandableText/index.js';
1112
export * from './components/FilterBar/index.js';
1213
export * from './components/FilterGroupItem/index.js';
1314
export * from './components/FlexBox/index.js';

0 commit comments

Comments
 (0)