Skip to content

Commit 2033919

Browse files
chore(clerk-js): Add truncation option to line items description text (#5560)
1 parent 1b34bcb commit 2033919

File tree

8 files changed

+187
-12
lines changed

8 files changed

+187
-12
lines changed

.changeset/light-roses-design.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/types': patch
4+
---
5+
6+
Add copy and truncation options to `<LineItems.Description />` component.

packages/clerk-js/bundlewatch.config.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{ "path": "./dist/clerk.js", "maxSize": "590kB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "73.21KB" },
55
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
6-
{ "path": "./dist/ui-common*.js", "maxSize": "98.2KB" },
6+
{ "path": "./dist/ui-common*.js", "maxSize": "99KB" },
77
{ "path": "./dist/vendors*.js", "maxSize": "36KB" },
88
{ "path": "./dist/coinbase*.js", "maxSize": "35.5KB" },
99
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },

packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,12 @@ export const CheckoutComplete = ({ checkout }: { checkout: __experimental_Commer
124124
<LineItems.Group variant='tertiary'>
125125
{/* TODO(@COMMERCE): needs localization */}
126126
<LineItems.Title title='Invoice ID' />
127-
<LineItems.Description text={checkout.invoice ? checkout.invoice.id : '–'} />
127+
<LineItems.Description
128+
text={checkout.invoice ? checkout.invoice.id : '–'}
129+
truncateText
130+
copyText
131+
copyLabel='Copy invoice ID'
132+
/>
128133
</LineItems.Group>
129134
</LineItems.Root>
130135
<Button

packages/clerk-js/src/ui/customizables/elementDescriptors.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
4242
'lineItemsDescriptionSuffix',
4343
'lineItemsDescriptionPrefix',
4444
'lineItemsDescriptionText',
45+
'lineItemsCopyButton',
4546

4647
'actionCard',
4748

packages/clerk-js/src/ui/elements/LineItems.tsx

+93-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import * as React from 'react';
22

33
import type { LocalizationKey } from '../customizables';
4-
import { Box, Dd, descriptors, Dl, Dt, Span } from '../customizables';
4+
import { Box, Button, Dd, descriptors, Dl, Dt, Icon, Span } from '../customizables';
5+
import { useClipboard } from '../hooks';
6+
import { Check, Copy } from '../icons';
57
import { common } from '../styledSystem';
8+
import { truncateWithEndVisible } from '../utils/truncateTextWithEndVisible';
69

710
/* -------------------------------------------------------------------------------------------------
811
* LineItems.Root
@@ -95,7 +98,6 @@ function Title({ title, description }: TitleProps) {
9598
sx={t => ({
9699
display: 'grid',
97100
color: variant === 'primary' ? t.colors.$colorText : t.colors.$colorTextSecondary,
98-
marginTop: variant !== 'primary' ? t.space.$0x25 : undefined,
99101
...common.textVariants(t)[textVariant],
100102
})}
101103
>
@@ -120,11 +122,26 @@ function Title({ title, description }: TitleProps) {
120122

121123
interface DescriptionProps {
122124
text: string | LocalizationKey;
125+
/**
126+
* When true, the text will be truncated with an ellipsis in the middle and the last 5 characters will be visible.
127+
* @default `false`
128+
*/
129+
truncateText?: boolean;
130+
/**
131+
* When true, there will be a button to copy the providedtext.
132+
* @default `false`
133+
*/
134+
copyText?: boolean;
135+
/**
136+
* The visually hidden label for the copy button.
137+
* @default `Copy`
138+
*/
139+
copyLabel?: string;
123140
prefix?: string | LocalizationKey;
124141
suffix?: string | LocalizationKey;
125142
}
126143

127-
function Description({ text, prefix, suffix }: DescriptionProps) {
144+
function Description({ text, prefix, suffix, truncateText = false, copyText = false, copyLabel }: DescriptionProps) {
128145
const context = React.useContext(GroupContext);
129146
if (!context) {
130147
throw new Error('LineItems.Description must be used within LineItems.Group');
@@ -147,6 +164,7 @@ function Description({ text, prefix, suffix }: DescriptionProps) {
147164
justifyContent: 'flex-end',
148165
alignItems: 'center',
149166
gap: t.space.$1,
167+
minWidth: '0',
150168
})}
151169
>
152170
{prefix ? (
@@ -159,13 +177,27 @@ function Description({ text, prefix, suffix }: DescriptionProps) {
159177
})}
160178
/>
161179
) : null}
162-
<Span
163-
localizationKey={text}
164-
elementDescriptor={descriptors.lineItemsDescriptionText}
165-
sx={t => ({
166-
...common.textVariants(t).body,
167-
})}
168-
/>
180+
{typeof text === 'string' && truncateText ? (
181+
<TruncatedText text={text} />
182+
) : (
183+
<Span
184+
localizationKey={text}
185+
elementDescriptor={descriptors.lineItemsDescriptionText}
186+
sx={t => ({
187+
...common.textVariants(t).body,
188+
minWidth: '0',
189+
overflow: 'hidden',
190+
textOverflow: 'ellipsis',
191+
whiteSpace: 'nowrap',
192+
})}
193+
/>
194+
)}
195+
{typeof text === 'string' && copyText ? (
196+
<CopyButton
197+
text={text}
198+
copyLabel={copyLabel}
199+
/>
200+
) : null}
169201
</Span>
170202
{suffix ? (
171203
<Span
@@ -174,13 +206,64 @@ function Description({ text, prefix, suffix }: DescriptionProps) {
174206
sx={t => ({
175207
color: t.colors.$colorTextSecondary,
176208
...common.textVariants(t).caption,
209+
justifySelf: 'flex-end',
177210
})}
178211
/>
179212
) : null}
180213
</Dd>
181214
);
182215
}
183216

217+
function TruncatedText({ text }: { text: string }) {
218+
const { onCopy } = useClipboard(text);
219+
return (
220+
<Span
221+
elementDescriptor={descriptors.lineItemsDescriptionText}
222+
sx={t => ({
223+
...common.textVariants(t).body,
224+
display: 'flex',
225+
minWidth: '0',
226+
})}
227+
onCopy={async e => {
228+
e.preventDefault();
229+
await onCopy();
230+
}}
231+
>
232+
{truncateWithEndVisible(text)}
233+
</Span>
234+
);
235+
}
236+
237+
function CopyButton({ text, copyLabel = 'Copy' }: { text: string; copyLabel?: string }) {
238+
const { onCopy, hasCopied } = useClipboard(text);
239+
240+
return (
241+
<Button
242+
variant='unstyled'
243+
onClick={onCopy}
244+
sx={t => ({
245+
color: 'inherit',
246+
width: t.sizes.$4,
247+
height: t.sizes.$4,
248+
padding: 0,
249+
borderRadius: t.radii.$sm,
250+
'&:focus-visible': {
251+
outline: '2px solid',
252+
outlineColor: t.colors.$neutralAlpha200,
253+
},
254+
})}
255+
focusRing={false}
256+
aria-label={hasCopied ? 'Copied' : copyLabel}
257+
>
258+
<Icon
259+
size='sm'
260+
icon={hasCopied ? Check : Copy}
261+
aria-hidden
262+
/>
263+
</Button>
264+
);
265+
}
266+
184267
export const LineItems = {
185268
Root,
186269
Group,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { truncateWithEndVisible } from '../truncateTextWithEndVisible';
2+
3+
describe('truncateWithEndVisible', () => {
4+
test('should return empty string when input is empty', () => {
5+
expect(truncateWithEndVisible('')).toBe('');
6+
});
7+
8+
test('should return original string when length is less than maxLength', () => {
9+
expect(truncateWithEndVisible('short')).toBe('short');
10+
expect(truncateWithEndVisible('123456789', 10)).toBe('123456789');
11+
});
12+
13+
test('should truncate string with default parameters', () => {
14+
expect(truncateWithEndVisible('this is a very long string')).toBe('this is a ve...tring');
15+
});
16+
17+
test('should truncate string with custom maxLength', () => {
18+
expect(truncateWithEndVisible('this is a very long string', 15)).toBe('this is...tring');
19+
});
20+
21+
test('should truncate string with custom endChars', () => {
22+
expect(truncateWithEndVisible('this is a very long string', 20, 3)).toBe('this is a very...ing');
23+
});
24+
25+
test('should handle edge case where maxLength is too small', () => {
26+
expect(truncateWithEndVisible('1234567890', 5, 3)).toBe('...890');
27+
});
28+
29+
test('should handle email addresses', () => {
30+
expect(truncateWithEndVisible('[email protected]', 10)).toBe('te...e.com');
31+
});
32+
33+
test('should handle very long strings', () => {
34+
const longString = 'a'.repeat(1000);
35+
expect(truncateWithEndVisible(longString, 20)).toBe('aaaaaaaaaaaa...aaaaa');
36+
});
37+
38+
test('should handle strings with spaces', () => {
39+
expect(truncateWithEndVisible('hello world this is a test', 15)).toBe('hello w... test');
40+
});
41+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Truncates a string to show the beginning and the last N characters,
3+
* with an ellipsis in the middle.
4+
*
5+
* @param {string} str - The string to truncate
6+
* @param {number} maxLength - Maximum total length of the truncated string (including ellipsis)
7+
* @param {number} endChars - Number of characters to preserve at the end
8+
* @return {string} - Truncated string with ellipsis in the middle
9+
*
10+
* @example
11+
* truncateWithEndVisible('this is a very long string') // returns 'this is a ve...tring'
12+
* truncateWithEndVisible('[email protected]', 10) // returns 'te...e.com'
13+
*/
14+
export function truncateWithEndVisible(str: string, maxLength = 20, endChars = 5): string {
15+
const ELLIPSIS = '...';
16+
const ELLIPSIS_LENGTH = ELLIPSIS.length;
17+
18+
if (!str || str.length <= maxLength) {
19+
return str;
20+
}
21+
22+
if (maxLength <= endChars + ELLIPSIS_LENGTH) {
23+
return ELLIPSIS + str.slice(-endChars);
24+
}
25+
26+
const chars = Array.from(str);
27+
const totalChars = chars.length;
28+
29+
if (totalChars <= maxLength) {
30+
return str;
31+
}
32+
33+
const beginLength = maxLength - endChars - ELLIPSIS_LENGTH;
34+
const beginPortion = chars.slice(0, beginLength).join('');
35+
const endPortion = chars.slice(-endChars).join('');
36+
37+
return beginPortion + ELLIPSIS + endPortion;
38+
}

packages/types/src/appearance.ts

+1
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export type ElementsConfig = {
162162
lineItemsDescriptionText: WithOptions;
163163
lineItemsDescriptionSuffix: WithOptions;
164164
lineItemsDescriptionPrefix: WithOptions;
165+
lineItemsCopyButton: WithOptions;
165166

166167
logoBox: WithOptions;
167168
logoImage: WithOptions;

0 commit comments

Comments
 (0)