Skip to content

Commit fbdb2ba

Browse files
committed
feat: highlight text
1 parent 9c8453a commit fbdb2ba

6 files changed

Lines changed: 344 additions & 10 deletions

File tree

example/.ondevice/storybook.requires.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/* do not change this file, it is auto generated by storybook. */
22

33
import {
4-
configure,
4+
addArgsEnhancer,
55
addDecorator,
66
addParameters,
7-
addArgsEnhancer,
87
clearDecorators,
8+
configure,
99
} from '@storybook/react-native'
1010

1111
global.STORIES = [
@@ -18,14 +18,14 @@ global.STORIES = [
1818
},
1919
]
2020

21-
import '@storybook/addon-ondevice-notes/register'
22-
import '@storybook/addon-ondevice-controls/register'
23-
import '@storybook/addon-ondevice-backgrounds/register'
2421
import '@storybook/addon-ondevice-actions/register'
22+
import '@storybook/addon-ondevice-backgrounds/register'
23+
import '@storybook/addon-ondevice-controls/register'
24+
import '@storybook/addon-ondevice-notes/register'
2525

26-
import {argsEnhancers} from '@storybook/addon-actions/dist/modern/preset/addArgs'
26+
import { argsEnhancers } from '@storybook/addon-actions/dist/modern/preset/addArgs'
2727

28-
import {decorators, parameters} from './preview'
28+
import { decorators, parameters } from './preview'
2929

3030
if (decorators) {
3131
if (__DEV__) {
@@ -55,6 +55,7 @@ const getStories = () => {
5555
"./src/stories/Text.stories.tsx": require("../src/stories/Text.stories.tsx"),
5656
"./src/stories/Checkbox.stories.tsx": require("../src/stories/Checkbox.stories.tsx"),
5757
'./src/stories/RadioButton.stories.tsx': require('../src/stories/RadioButton.stories.tsx'),
58+
'./src/stories/HighlightText.stories.tsx': require('../src/stories/HighlightText.stories.tsx'),
5859
};
5960
};
6061

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type {ComponentMeta, ComponentStory} from '@storybook/react'
2+
import React from 'react'
3+
4+
import {HighlightText} from 'rn-base-component'
5+
6+
export default {
7+
title: 'components/HighlightText',
8+
component: HighlightText,
9+
} as ComponentMeta<typeof HighlightText>
10+
11+
export const Basic: ComponentStory<typeof HighlightText> = args => <HighlightText {...args} />
12+
13+
Basic.args = {
14+
textToHighlight: 'Hello STS Tea123123m!',
15+
searchWords: ['Hello', 'Tea'],
16+
highlightTextStyle: {backgroundColor: 'yellow'},
17+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react'
2+
import {render} from '@testing-library/react-native'
3+
import HighlightText from '../components/HighlightText/HighlightText'
4+
import type {ReactTestInstance} from 'react-test-renderer'
5+
import {StyleSheet} from 'react-native'
6+
7+
describe('HighlightText', () => {
8+
const textToHighlight = 'Lorem ipsum dolor sit amet consectetur adipiscing elit'
9+
const searchWords = ['ipsum', 'adipiscing']
10+
it('should render correctly', () => {
11+
const {getByTestId} = render(
12+
<HighlightText textToHighlight={textToHighlight} searchWords={searchWords} />,
13+
)
14+
expect(getByTestId('container')).toBeDefined()
15+
})
16+
17+
it('renders highlighted and non-highlighted text correctly', () => {
18+
const {getByTestId, getAllByTestId} = render(
19+
<HighlightText textToHighlight={textToHighlight} searchWords={searchWords} />,
20+
)
21+
22+
const container = getByTestId('container')
23+
expect(container).toBeTruthy()
24+
const renderedTexts = getAllByTestId('text')
25+
expect(renderedTexts).toHaveLength(5)
26+
27+
const firstText = container.children[0] as ReactTestInstance
28+
const highlightedText = container.children[1] as ReactTestInstance
29+
const lastText = container.children[2] as ReactTestInstance
30+
31+
expect(firstText?.props.children).toBe('Lorem ')
32+
expect(highlightedText?.props.children).toBe('ipsum')
33+
expect(lastText?.props?.children).toBe(' dolor sit amet consectetur ')
34+
})
35+
36+
it('applies custom styles correctly', () => {
37+
const highlightTextStyle = {backgroundColor: 'yellow'}
38+
const normalTextStyle = {backgroundColor: 'red'}
39+
40+
const {getAllByTestId} = render(
41+
<HighlightText
42+
textToHighlight={textToHighlight}
43+
searchWords={searchWords}
44+
highlightTextStyle={highlightTextStyle}
45+
normalTextStyle={normalTextStyle}
46+
/>,
47+
)
48+
49+
const renderedTexts = getAllByTestId('text')
50+
51+
const firstText = renderedTexts[0] as ReactTestInstance
52+
const highlightedText = renderedTexts[1] as ReactTestInstance
53+
const lastText = renderedTexts[2] as ReactTestInstance
54+
55+
expect(StyleSheet.flatten(firstText.props.style)).toEqual(normalTextStyle)
56+
expect(StyleSheet.flatten(highlightedText.props.style)).toEqual(highlightTextStyle)
57+
expect(StyleSheet.flatten(lastText.props.style)).toEqual(normalTextStyle)
58+
})
59+
})
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React from 'react'
2+
import {StyleProp, Text, TextStyle} from 'react-native'
3+
import styled from 'styled-components/native'
4+
import type {ITheme} from '../../theme'
5+
import {findAll} from './utils'
6+
7+
type CustomTextStyleProp = StyleProp<TextStyle> | Array<StyleProp<TextStyle>>
8+
9+
export type Theme = {
10+
theme?: ITheme
11+
}
12+
13+
export type HighlightTextProps = {
14+
/**
15+
* Text to highlight
16+
*/
17+
textToHighlight: string
18+
/**
19+
* Array of search words
20+
*/
21+
searchWords: Array<string>
22+
/**
23+
* custom function to process each word and text to highlight
24+
* default: undefined
25+
*/
26+
sanitize?: (string: string) => string
27+
/**
28+
* Escape special characters
29+
* default: false
30+
*/
31+
autoEscape?: boolean
32+
/**
33+
* Styles applied to sentence
34+
* default: undefined
35+
*/
36+
textWrapperStyle?: CustomTextStyleProp
37+
/**
38+
* Styles applied to normal text
39+
* default: undefined
40+
*/
41+
normalTextStyle?: CustomTextStyleProp
42+
/**
43+
* Styles applied to highlight text
44+
* default: undefined
45+
*/
46+
highlightTextStyle?: CustomTextStyleProp
47+
}
48+
49+
const HighlightText: React.FC<HighlightTextProps> = ({
50+
textToHighlight,
51+
searchWords,
52+
sanitize,
53+
autoEscape = false,
54+
textWrapperStyle,
55+
normalTextStyle,
56+
highlightTextStyle,
57+
...props
58+
}) => {
59+
const chunks = findAll({textToHighlight, searchWords, sanitize, autoEscape})
60+
return (
61+
<TextWrapper style={textWrapperStyle} testID="container" {...props}>
62+
{chunks.map((chunk, index) => {
63+
const text = textToHighlight.substring(chunk.start, chunk.end)
64+
65+
return !chunk.highlight ? (
66+
<Text style={normalTextStyle} key={index} testID="text">
67+
{text}
68+
</Text>
69+
) : (
70+
<Highlight testID="text" key={index} style={chunk.highlight && highlightTextStyle}>
71+
{text}
72+
</Highlight>
73+
)
74+
})}
75+
</TextWrapper>
76+
)
77+
}
78+
79+
const TextWrapper = styled(Text)({})
80+
const Highlight = styled(Text)(({theme}: Theme) => ({
81+
color: theme?.colors?.darkText,
82+
fontWeight: theme?.fontWeights?.extrabold,
83+
backgroundColor: theme?.colors?.white,
84+
}))
85+
86+
HighlightText.displayName = 'HighlightText'
87+
export default HighlightText
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
export type Chunk = {
2+
highlight: boolean
3+
start: number
4+
end: number
5+
}
6+
7+
/**
8+
* Creates an array of chunk objects representing both higlightable and non highlightable pieces of text that match each search word.
9+
* @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean })
10+
*/
11+
export const findAll = ({
12+
autoEscape,
13+
caseSensitive = false,
14+
findChunks = defaultFindChunks,
15+
sanitize,
16+
searchWords,
17+
textToHighlight,
18+
}: {
19+
autoEscape?: boolean
20+
caseSensitive?: boolean
21+
findChunks?: typeof defaultFindChunks
22+
sanitize?: typeof defaultSanitize
23+
searchWords: Array<string>
24+
textToHighlight: string
25+
}): Array<Chunk> =>
26+
fillInChunks({
27+
chunksToHighlight: combineChunks({
28+
chunks: findChunks({
29+
autoEscape,
30+
caseSensitive,
31+
sanitize,
32+
searchWords,
33+
textToHighlight,
34+
}),
35+
}),
36+
totalLength: textToHighlight ? textToHighlight.length : 0,
37+
})
38+
39+
/**
40+
* Takes an array of {start:number, end:number} objects and combines chunks that overlap into single chunks.
41+
* @return {start:number, end:number}[]
42+
*/
43+
export const combineChunks = ({chunks}: {chunks: Array<Chunk>}): Array<Chunk> => {
44+
const res: Array<Chunk> = chunks
45+
.sort((first, second) => first.start - second.start)
46+
.reduce((processedChunks: Chunk[], nextChunk) => {
47+
// First chunk just goes straight in the array...
48+
if (processedChunks.length === 0) {
49+
return [nextChunk]
50+
} else {
51+
// ... subsequent chunks get checked to see if they overlap...
52+
const prevChunk = processedChunks.pop()
53+
if (prevChunk && nextChunk.start <= prevChunk.end) {
54+
// It may be the case that prevChunk completely surrounds nextChunk, so take the
55+
// largest of the end indeces.
56+
const endIndex = Math.max(prevChunk.end, nextChunk.end)
57+
processedChunks.push({highlight: false, start: prevChunk.start, end: endIndex})
58+
} else {
59+
if (prevChunk) {
60+
processedChunks.push(prevChunk, nextChunk)
61+
}
62+
}
63+
return processedChunks
64+
}
65+
}, [])
66+
67+
return res
68+
}
69+
70+
/**
71+
* Examine text for any matches.
72+
* If we find matches, add them to the returned array as a "chunk" object ({start:number, end:number}).
73+
* @return {start:number, end:number}[]
74+
*/
75+
const defaultFindChunks = ({
76+
autoEscape,
77+
caseSensitive,
78+
sanitize = defaultSanitize,
79+
searchWords,
80+
textToHighlight,
81+
}: {
82+
autoEscape?: boolean
83+
caseSensitive?: boolean
84+
sanitize?: typeof defaultSanitize
85+
searchWords: Array<string>
86+
textToHighlight: string
87+
}): Array<Chunk> => {
88+
textToHighlight = sanitize(textToHighlight)
89+
90+
return searchWords
91+
.filter(searchWord => searchWord) // Remove empty words
92+
.reduce((chunks: Array<Chunk>, searchWord) => {
93+
searchWord = sanitize(searchWord)
94+
95+
if (autoEscape) {
96+
searchWord = escapeRegExpFn(searchWord)
97+
}
98+
99+
const regex = new RegExp(searchWord, caseSensitive ? 'g' : 'gi')
100+
101+
let match
102+
while ((match = regex.exec(textToHighlight))) {
103+
const start = match.index
104+
const end = regex.lastIndex
105+
// We do not return zero-length matches
106+
if (end > start) {
107+
chunks.push({highlight: false, start, end})
108+
}
109+
110+
// Prevent browsers like Firefox from getting stuck in an infinite loop
111+
// See http://www.regexguru.com/2008/04/watch-out-for-zero-length-matches/
112+
if (match.index === regex.lastIndex) {
113+
regex.lastIndex++
114+
}
115+
}
116+
117+
return chunks
118+
}, [])
119+
}
120+
// Allow the findChunks to be overridden in findAll,
121+
// but for backwards compatibility we export as the old name
122+
export {defaultFindChunks as findChunks}
123+
124+
/**
125+
* Given a set of chunks to highlight, create an additional set of chunks
126+
* to represent the bits of text between the highlighted text.
127+
* @param chunksToHighlight {start:number, end:number}[]
128+
* @param totalLength number
129+
* @return {start:number, end:number, highlight:boolean}[]
130+
*/
131+
export const fillInChunks = ({
132+
chunksToHighlight,
133+
totalLength,
134+
}: {
135+
chunksToHighlight: Array<Chunk>
136+
totalLength: number
137+
}): Array<Chunk> => {
138+
const allChunks: Array<Chunk> = []
139+
const append = (start: number, end: number, highlight: boolean) => {
140+
if (end - start > 0) {
141+
allChunks.push({
142+
start,
143+
end,
144+
highlight,
145+
})
146+
}
147+
}
148+
149+
if (chunksToHighlight.length === 0) {
150+
append(0, totalLength, false)
151+
} else {
152+
let lastIndex = 0
153+
chunksToHighlight.forEach(chunk => {
154+
append(lastIndex, chunk.start, false)
155+
append(chunk.start, chunk.end, true)
156+
lastIndex = chunk.end
157+
})
158+
append(lastIndex, totalLength, false)
159+
}
160+
return allChunks
161+
}
162+
163+
function defaultSanitize(string: string): string {
164+
return string
165+
}
166+
167+
function escapeRegExpFn(string: string): string {
168+
return string.replace(/[-[\]{}()*+?.,\\^$|]/g, '\\$&')
169+
}

src/components/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import RadioButton from './RadioButton/RadioButton'
21
import Button from './Button'
2+
import RadioButton from './RadioButton/RadioButton'
33

4-
import Progress from './Progress/Progress'
54
import Checkbox from './Checkbox/Checkbox'
65
import CodeInput from './CodeInput/CodeInput'
6+
import HighlightText from './HighlightText/HighlightText'
7+
import Progress from './Progress/Progress'
78

8-
export {Button, CodeInput, Checkbox, Progress, RadioButton}
99
export * from './Text/Text'
10+
export {Button, Checkbox, CodeInput, HighlightText, Progress, RadioButton}

0 commit comments

Comments
 (0)