Skip to content

Commit 998f45d

Browse files
committed
feat(UI): add matchtext component
1 parent 426adec commit 998f45d

File tree

8 files changed

+266
-0
lines changed

8 files changed

+266
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React from 'react';
2+
3+
import type { Meta, StoryObj } from '@storybook/react';
4+
import { BADGE } from '@geometricpanda/storybook-addon-badges';
5+
6+
import MatchText from './MatchText';
7+
import { matchTextDefaults } from './defaults';
8+
9+
// Auto Docs
10+
const meta = {
11+
title: 'Typography / MatchText',
12+
component: MatchText,
13+
14+
// Display Properties
15+
parameters: {
16+
layout: 'centered',
17+
badges: [BADGE.STABLE, 'readyForDesignReview'],
18+
docs: {
19+
subtitle: 'Used to highlight text parts dynamically',
20+
},
21+
},
22+
23+
// Component-level argTypes
24+
argTypes: {
25+
text: {
26+
description: 'The initial text to matching',
27+
table: {
28+
type: {
29+
summary: 'string',
30+
},
31+
},
32+
},
33+
highlight: {
34+
description: 'The text to highlight',
35+
table: {
36+
type: {
37+
summary: 'string',
38+
},
39+
},
40+
},
41+
type: {
42+
description: 'The type of text to display.',
43+
table: {
44+
defaultValue: { summary: matchTextDefaults.type },
45+
},
46+
},
47+
size: {
48+
description: 'Override the size of the text.',
49+
table: {
50+
defaultValue: { summary: `${matchTextDefaults.size}` },
51+
},
52+
},
53+
color: {
54+
description: 'Override the color of the text.',
55+
table: {
56+
defaultValue: { summary: matchTextDefaults.color },
57+
},
58+
},
59+
weight: {
60+
description: 'Override the weight of the text.',
61+
table: {
62+
defaultValue: { summary: matchTextDefaults.weight },
63+
},
64+
},
65+
highlightedTextProps: {
66+
description: 'Overide text props for highlighted parts',
67+
table: {
68+
defaultValue: { summary: JSON.stringify(matchTextDefaults.highlightedTextProps) },
69+
},
70+
},
71+
},
72+
73+
// Define default args
74+
args: {
75+
text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit.
76+
Maecenas aliquet nulla id felis vehicula, et posuere dui dapibus.
77+
Nullam rhoncus massa non tortor convallis, in blandit turpis rutrum.
78+
Morbi tempus velit mauris, at mattis metus mattis sed. Nunc molestie efficitur lectus, vel mollis eros.`,
79+
highlight: 'ipsum',
80+
type: matchTextDefaults.type,
81+
size: matchTextDefaults.size,
82+
color: matchTextDefaults.color,
83+
weight: matchTextDefaults.weight,
84+
highlightedTextProps: matchTextDefaults.highlightedTextProps,
85+
},
86+
} satisfies Meta<typeof MatchText>;
87+
88+
export default meta;
89+
90+
// Stories
91+
92+
type Story = StoryObj<typeof meta>;
93+
94+
// Basic story is what is displayed 1st in storybook
95+
// Pass props to this so that it can be customized via the UI props panel
96+
export const sandbox: Story = {
97+
tags: ['dev'],
98+
render: (props) => <MatchText {...props} />,
99+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from 'react';
2+
import { Text } from '../Text';
3+
import { matchTextDefaults } from './defaults';
4+
import { MatchTextProps } from './types';
5+
import { annotateHighlightedText } from './utils';
6+
7+
export default function MatchText({
8+
text,
9+
highlight,
10+
highlightedTextProps = matchTextDefaults.highlightedTextProps,
11+
...props
12+
}: MatchTextProps) {
13+
const markedTextParts = annotateHighlightedText(text, highlight);
14+
15+
const textPartsWithKeys = markedTextParts.map((part, index) => ({
16+
...part,
17+
key: `${index}-${part.text}${part.highlighted && '-highlighted'}`,
18+
}));
19+
20+
return (
21+
<Text {...props}>
22+
{textPartsWithKeys.map((part) => {
23+
if (part.highlighted)
24+
return (
25+
<Text {...{ ...props, ...highlightedTextProps }} type="span" key={part.key}>
26+
{part.text}
27+
</Text>
28+
);
29+
return <React.Fragment key={part.key}>{part.text}</React.Fragment>;
30+
})}
31+
</Text>
32+
);
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { annotateHighlightedText } from '../utils';
2+
3+
const SAMPLE_INPUT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit';
4+
5+
describe('annotateHighlightedText', () => {
6+
it('should highlight matching substrings', () => {
7+
const highlight = 'ipsum';
8+
9+
const result = annotateHighlightedText(SAMPLE_INPUT, highlight);
10+
11+
expect(result).toEqual([
12+
{ text: 'Lorem ', highlighted: false },
13+
{ text: 'ipsum', highlighted: true },
14+
{ text: ' dolor sit amet, consectetur adipiscing elit', highlighted: false },
15+
]);
16+
});
17+
18+
it('should handle multiple matches', () => {
19+
const highlight = 'ip';
20+
21+
const result = annotateHighlightedText(SAMPLE_INPUT, highlight);
22+
23+
expect(result).toEqual([
24+
{ text: 'Lorem ', highlighted: false },
25+
{ text: 'ip', highlighted: true },
26+
{ text: 'sum dolor sit amet, consectetur ad', highlighted: false },
27+
{ text: 'ip', highlighted: true },
28+
{ text: 'iscing elit', highlighted: false },
29+
]);
30+
});
31+
32+
it('should handle no matches', () => {
33+
const highlight = 'xyz';
34+
35+
const result = annotateHighlightedText(SAMPLE_INPUT, highlight);
36+
37+
expect(result).toEqual([{ text: SAMPLE_INPUT, highlighted: false }]);
38+
});
39+
40+
it('should handle case-insensitive matching', () => {
41+
const highlight = 'DOLOR';
42+
43+
const result = annotateHighlightedText(SAMPLE_INPUT, highlight);
44+
45+
expect(result).toEqual([
46+
{ text: 'Lorem ipsum ', highlighted: false },
47+
{ text: 'dolor', highlighted: true },
48+
{ text: ' sit amet, consectetur adipiscing elit', highlighted: false },
49+
]);
50+
});
51+
52+
it('should handle empty highlight string', () => {
53+
const highlight = '';
54+
55+
const result = annotateHighlightedText(SAMPLE_INPUT, highlight);
56+
57+
expect(result).toEqual([{ text: SAMPLE_INPUT, highlighted: false }]);
58+
});
59+
60+
it('should handle empty input string', () => {
61+
const input = '';
62+
const highlight = 'test';
63+
64+
const result = annotateHighlightedText(input, highlight);
65+
66+
expect(result).toEqual([]);
67+
});
68+
69+
it('should escape special characters in the highlight string', () => {
70+
const highlight = '[(){}?*^$|\\.]';
71+
72+
const result = annotateHighlightedText('test[(){}?*^$|\\.]test', highlight);
73+
74+
expect(result).toEqual([
75+
{ text: 'test', highlighted: false },
76+
{ text: '[(){}?*^$|\\.]', highlighted: true },
77+
{ text: 'test', highlighted: false },
78+
]);
79+
});
80+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { textDefaults } from '../Text';
2+
import { MatchTextProps } from './types';
3+
4+
export const matchTextDefaults: Partial<MatchTextProps> = {
5+
...textDefaults,
6+
highlightedTextProps: {
7+
weight: 'bold',
8+
},
9+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import MatchText from './MatchText';
2+
3+
export { MatchText };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { TextProps } from '../Text';
2+
3+
export type TextPropsWithoutChildren = Omit<TextProps, 'children'>;
4+
5+
export interface MatchTextProps extends TextPropsWithoutChildren {
6+
text: string;
7+
highlight: string;
8+
highlightedTextProps?: Partial<TextPropsWithoutChildren>;
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export function annotateHighlightedText(input: string, highlight: string): { text: string; highlighted: boolean }[] {
2+
const escapedHighlight = highlight.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3+
4+
if (escapedHighlight === '')
5+
return [
6+
{
7+
text: input,
8+
highlighted: false,
9+
},
10+
];
11+
12+
const result: { text: string; highlighted: boolean }[] = [];
13+
// Case-insensitive regex for matching
14+
const regex = new RegExp(`(${escapedHighlight})`, 'gi');
15+
let lastIndex = 0;
16+
17+
input.replace(regex, (match, p1, offset) => {
18+
if (offset > lastIndex) {
19+
result.push({ text: input.slice(lastIndex, offset), highlighted: false });
20+
}
21+
result.push({ text: p1, highlighted: true });
22+
lastIndex = offset + match.length;
23+
24+
return match;
25+
});
26+
27+
if (lastIndex < input.length) {
28+
result.push({ text: input.slice(lastIndex), highlighted: false });
29+
}
30+
31+
return result;
32+
}

datahub-web-react/src/alchemy-components/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from './components/Icon';
1919
export * from './components/Input';
2020
export * from './components/LineChart';
2121
export * from './components/Loader';
22+
export * from './components/MatchText';
2223
export * from './components/Modal';
2324
export * from './components/PageTitle';
2425
export * from './components/Pills';

0 commit comments

Comments
 (0)