Skip to content

chore(clerk-js): Add truncation option to line items description text #5560

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 9, 2025
6 changes: 6 additions & 0 deletions .changeset/light-roses-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Add copy and truncation options to `<LineItems.Description />` component.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{ "path": "./dist/clerk.js", "maxSize": "590kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "73.21KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "98.2KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "99KB" },
{ "path": "./dist/vendors*.js", "maxSize": "36KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "35.5KB" },
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,12 @@ export const CheckoutComplete = ({ checkout }: { checkout: __experimental_Commer
<LineItems.Group variant='tertiary'>
{/* TODO(@COMMERCE): needs localization */}
<LineItems.Title title='Invoice ID' />
<LineItems.Description text={checkout.invoice ? checkout.invoice.id : '–'} />
<LineItems.Description
text={checkout.invoice ? checkout.invoice.id : '–'}
truncateText
copyText
copyLabel='Copy invoice ID'
/>
</LineItems.Group>
</LineItems.Root>
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'lineItemsDescriptionSuffix',
'lineItemsDescriptionPrefix',
'lineItemsDescriptionText',
'lineItemsCopyButton',

'actionCard',

Expand Down
103 changes: 93 additions & 10 deletions packages/clerk-js/src/ui/elements/LineItems.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as React from 'react';

import type { LocalizationKey } from '../customizables';
import { Box, Dd, descriptors, Dl, Dt, Span } from '../customizables';
import { Box, Button, Dd, descriptors, Dl, Dt, Icon, Span } from '../customizables';
import { useClipboard } from '../hooks';
import { Check, Copy } from '../icons';
import { common } from '../styledSystem';
import { truncateWithEndVisible } from '../utils/truncateTextWithEndVisible';

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

interface DescriptionProps {
text: string | LocalizationKey;
/**
* When true, the text will be truncated with an ellipsis in the middle and the last 5 characters will be visible.
* @default `false`
*/
truncateText?: boolean;
/**
* When true, there will be a button to copy the providedtext.
* @default `false`
*/
copyText?: boolean;
/**
* The visually hidden label for the copy button.
* @default `Copy`
*/
copyLabel?: string;
prefix?: string | LocalizationKey;
suffix?: string | LocalizationKey;
}

function Description({ text, prefix, suffix }: DescriptionProps) {
function Description({ text, prefix, suffix, truncateText = false, copyText = false, copyLabel }: DescriptionProps) {
const context = React.useContext(GroupContext);
if (!context) {
throw new Error('LineItems.Description must be used within LineItems.Group');
Expand All @@ -147,6 +164,7 @@ function Description({ text, prefix, suffix }: DescriptionProps) {
justifyContent: 'flex-end',
alignItems: 'center',
gap: t.space.$1,
minWidth: '0',
})}
>
{prefix ? (
Expand All @@ -159,13 +177,27 @@ function Description({ text, prefix, suffix }: DescriptionProps) {
})}
/>
) : null}
<Span
localizationKey={text}
elementDescriptor={descriptors.lineItemsDescriptionText}
sx={t => ({
...common.textVariants(t).body,
})}
/>
{typeof text === 'string' && truncateText ? (
<TruncatedText text={text} />
) : (
<Span
localizationKey={text}
elementDescriptor={descriptors.lineItemsDescriptionText}
sx={t => ({
...common.textVariants(t).body,
minWidth: '0',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
/>
)}
{typeof text === 'string' && copyText ? (
<CopyButton
text={text}
copyLabel={copyLabel}
/>
) : null}
</Span>
{suffix ? (
<Span
Expand All @@ -174,13 +206,64 @@ function Description({ text, prefix, suffix }: DescriptionProps) {
sx={t => ({
color: t.colors.$colorTextSecondary,
...common.textVariants(t).caption,
justifySelf: 'flex-end',
})}
/>
) : null}
</Dd>
);
}

function TruncatedText({ text }: { text: string }) {
const { onCopy } = useClipboard(text);
return (
<Span
elementDescriptor={descriptors.lineItemsDescriptionText}
sx={t => ({
...common.textVariants(t).body,
display: 'flex',
minWidth: '0',
})}
onCopy={async e => {
e.preventDefault();
await onCopy();
}}
>
{truncateWithEndVisible(text)}
</Span>
);
}

function CopyButton({ text, copyLabel = 'Copy' }: { text: string; copyLabel?: string }) {
const { onCopy, hasCopied } = useClipboard(text);

return (
<Button
variant='unstyled'
onClick={onCopy}
sx={t => ({
color: 'inherit',
width: t.sizes.$4,
height: t.sizes.$4,
padding: 0,
borderRadius: t.radii.$sm,
'&:focus-visible': {
outline: '2px solid',
outlineColor: t.colors.$neutralAlpha200,
},
})}
focusRing={false}
aria-label={hasCopied ? 'Copied' : copyLabel}
>
<Icon
size='sm'
icon={hasCopied ? Check : Copy}
aria-hidden
/>
</Button>
);
}

export const LineItems = {
Root,
Group,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { truncateWithEndVisible } from '../truncateTextWithEndVisible';

describe('truncateWithEndVisible', () => {
test('should return empty string when input is empty', () => {
expect(truncateWithEndVisible('')).toBe('');
});

test('should return original string when length is less than maxLength', () => {
expect(truncateWithEndVisible('short')).toBe('short');
expect(truncateWithEndVisible('123456789', 10)).toBe('123456789');
});

test('should truncate string with default parameters', () => {
expect(truncateWithEndVisible('this is a very long string')).toBe('this is a ve...tring');
});

test('should truncate string with custom maxLength', () => {
expect(truncateWithEndVisible('this is a very long string', 15)).toBe('this is...tring');
});

test('should truncate string with custom endChars', () => {
expect(truncateWithEndVisible('this is a very long string', 20, 3)).toBe('this is a very...ing');
});

test('should handle edge case where maxLength is too small', () => {
expect(truncateWithEndVisible('1234567890', 5, 3)).toBe('...890');
});

test('should handle email addresses', () => {
expect(truncateWithEndVisible('[email protected]', 10)).toBe('te...e.com');
});

test('should handle very long strings', () => {
const longString = 'a'.repeat(1000);
expect(truncateWithEndVisible(longString, 20)).toBe('aaaaaaaaaaaa...aaaaa');
});

test('should handle strings with spaces', () => {
expect(truncateWithEndVisible('hello world this is a test', 15)).toBe('hello w... test');
});
});
38 changes: 38 additions & 0 deletions packages/clerk-js/src/ui/utils/truncateTextWithEndVisible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Truncates a string to show the beginning and the last N characters,
* with an ellipsis in the middle.
*
* @param {string} str - The string to truncate
* @param {number} maxLength - Maximum total length of the truncated string (including ellipsis)
* @param {number} endChars - Number of characters to preserve at the end
* @return {string} - Truncated string with ellipsis in the middle
*
* @example
* truncateWithEndVisible('this is a very long string') // returns 'this is a ve...tring'
* truncateWithEndVisible('[email protected]', 10) // returns 'te...e.com'
*/
export function truncateWithEndVisible(str: string, maxLength = 20, endChars = 5): string {
const ELLIPSIS = '...';
const ELLIPSIS_LENGTH = ELLIPSIS.length;

if (!str || str.length <= maxLength) {
return str;
}

if (maxLength <= endChars + ELLIPSIS_LENGTH) {
return ELLIPSIS + str.slice(-endChars);
}

const chars = Array.from(str);
const totalChars = chars.length;

if (totalChars <= maxLength) {
return str;
}

const beginLength = maxLength - endChars - ELLIPSIS_LENGTH;
const beginPortion = chars.slice(0, beginLength).join('');
const endPortion = chars.slice(-endChars).join('');

return beginPortion + ELLIPSIS + endPortion;
}
1 change: 1 addition & 0 deletions packages/types/src/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export type ElementsConfig = {
lineItemsDescriptionText: WithOptions;
lineItemsDescriptionSuffix: WithOptions;
lineItemsDescriptionPrefix: WithOptions;
lineItemsCopyButton: WithOptions;

logoBox: WithOptions;
logoImage: WithOptions;
Expand Down