Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions example/.ondevice/storybook.requires.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const getStories = () => {
"./src/stories/Card.stories.tsx": require("../src/stories/Card.stories.tsx"),
"./src/stories/Checkbox.stories.tsx": require("../src/stories/Checkbox.stories.tsx"),
"./src/stories/CodeInput.stories.tsx": require("../src/stories/CodeInput.stories.tsx"),
"./src/stories/HighlightText.stories.tsx": require("../src/stories/HighlightText.stories.tsx"),
"./src/stories/Progress.stories.tsx": require("../src/stories/Progress.stories.tsx"),
"./src/stories/RadioButton.stories.tsx": require("../src/stories/RadioButton.stories.tsx"),
"./src/stories/Slider.stories.tsx": require("../src/stories/Slider.stories.tsx"),
Expand Down
17 changes: 17 additions & 0 deletions example/src/stories/HighlightText.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type {ComponentMeta, ComponentStory} from '@storybook/react'
import React from 'react'

import {HighlightText} from 'rn-base-component'

export default {
title: 'components/HighlightText',
component: HighlightText,
} as ComponentMeta<typeof HighlightText>

export const Basic: ComponentStory<typeof HighlightText> = args => <HighlightText {...args} />

Basic.args = {
textToHighlight: 'Hello STS Tea123123m!',
searchWords: ['Hello', 'Tea'],
highlightTextStyle: {backgroundColor: 'yellow'},
}
59 changes: 59 additions & 0 deletions src/__tests__/HighlightText.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {render} from '@testing-library/react-native'
import React from 'react'
import {StyleSheet} from 'react-native'
import type {ReactTestInstance} from 'react-test-renderer'
import HighlightText from '../components/HighlightText/HighlightText'

describe('HighlightText', () => {
const textToHighlight = 'Lorem ipsum dolor sit amet consectetur adipiscing elit'
const searchWords = ['ipsum', 'adipiscing']
it('should render correctly', () => {
const {getByTestId} = render(
<HighlightText textToHighlight={textToHighlight} searchWords={searchWords} />,
)
expect(getByTestId('container')).toBeDefined()
})

it('renders highlighted and non-highlighted text correctly', () => {
const {getByTestId, getAllByTestId} = render(
<HighlightText textToHighlight={textToHighlight} searchWords={searchWords} />,
)

const container = getByTestId('container')
expect(container).toBeTruthy()
const renderedTexts = getAllByTestId('text')
expect(renderedTexts).toHaveLength(5)

const firstText = container.children[0] as ReactTestInstance
const highlightedText = container.children[1] as ReactTestInstance
const lastText = container.children[2] as ReactTestInstance

expect(firstText?.props.children).toBe('Lorem ')
expect(highlightedText?.props.children).toBe('ipsum')
expect(lastText?.props?.children).toBe(' dolor sit amet consectetur ')
})

it('applies custom styles correctly', () => {
const highlightTextStyle = {backgroundColor: 'yellow'}
const normalTextStyle = {backgroundColor: 'red'}

const {getAllByTestId} = render(
<HighlightText
textToHighlight={textToHighlight}
searchWords={searchWords}
highlightTextStyle={highlightTextStyle}
normalTextStyle={normalTextStyle}
/>,
)

const renderedTexts = getAllByTestId('text')

const firstText = renderedTexts[0] as ReactTestInstance
const highlightedText = renderedTexts[1] as ReactTestInstance
const lastText = renderedTexts[2] as ReactTestInstance

expect(StyleSheet.flatten(firstText.props.style)).toEqual(normalTextStyle)
expect(StyleSheet.flatten(highlightedText.props.style)).toEqual(highlightTextStyle)
expect(StyleSheet.flatten(lastText.props.style)).toEqual(normalTextStyle)
})
})
87 changes: 87 additions & 0 deletions src/components/HighlightText/HighlightText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from 'react'
import {StyleProp, Text, TextStyle} from 'react-native'
import styled from 'styled-components/native'
import type {ITheme} from '../../theme'
import {findAll} from './utils'

type CustomTextStyleProp = StyleProp<TextStyle> | Array<StyleProp<TextStyle>>

export type Theme = {
theme?: ITheme
}

export type HighlightTextProps = {
/**
* Text to highlight
*/
textToHighlight: string
/**
* Array of search words
*/
searchWords: Array<string>
/**
* custom function to process each word and text to highlight
* default: undefined
*/
sanitize?: (string: string) => string
/**
* Escape special characters
* default: false
*/
autoEscape?: boolean
/**
* Styles applied to sentence
* default: undefined
*/
textWrapperStyle?: CustomTextStyleProp
/**
* Styles applied to normal text
* default: undefined
*/
normalTextStyle?: CustomTextStyleProp
/**
* Styles applied to highlight text
* default: undefined
*/
highlightTextStyle?: CustomTextStyleProp
}

const HighlightText: React.FC<HighlightTextProps> = ({
textToHighlight,
searchWords,
sanitize,
autoEscape = false,
textWrapperStyle,
normalTextStyle,
highlightTextStyle,
...props
}) => {
const chunks = findAll({textToHighlight, searchWords, sanitize, autoEscape})
return (
<TextWrapper style={textWrapperStyle} testID="container" {...props}>
{chunks.map((chunk, index) => {
const text = textToHighlight.substring(chunk.start, chunk.end)

return !chunk.highlight ? (
<Text style={normalTextStyle} key={index} testID="text">
{text}
</Text>
) : (
<Highlight testID="text" key={index} style={chunk.highlight && highlightTextStyle}>
{text}
</Highlight>
)
})}
</TextWrapper>
)
}

const TextWrapper = styled(Text)({})
const Highlight = styled(Text)(({theme}: Theme) => ({
color: theme?.colors?.darkText,
fontWeight: theme?.fontWeights?.extrabold,
backgroundColor: theme?.colors?.white,
}))

HighlightText.displayName = 'HighlightText'
export default HighlightText
169 changes: 169 additions & 0 deletions src/components/HighlightText/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
export type Chunk = {
highlight: boolean
start: number
end: number
}

/**
* Creates an array of chunk objects representing both higlightable and non highlightable pieces of text that match each search word.
* @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean })
*/
export const findAll = ({
autoEscape,
caseSensitive = false,
findChunks = defaultFindChunks,
sanitize,
searchWords,
textToHighlight,
}: {
autoEscape?: boolean
caseSensitive?: boolean
findChunks?: typeof defaultFindChunks
sanitize?: typeof defaultSanitize
searchWords: Array<string>
textToHighlight: string
}): Array<Chunk> =>
fillInChunks({
chunksToHighlight: combineChunks({
chunks: findChunks({
autoEscape,
caseSensitive,
sanitize,
searchWords,
textToHighlight,
}),
}),
totalLength: textToHighlight ? textToHighlight.length : 0,
})

/**
* Takes an array of {start:number, end:number} objects and combines chunks that overlap into single chunks.
* @return {start:number, end:number}[]
*/
export const combineChunks = ({chunks}: {chunks: Array<Chunk>}): Array<Chunk> => {
const res: Array<Chunk> = chunks
.sort((first, second) => first.start - second.start)
.reduce((processedChunks: Chunk[], nextChunk) => {
// First chunk just goes straight in the array...
if (processedChunks.length === 0) {
return [nextChunk]
} else {
// ... subsequent chunks get checked to see if they overlap...
const prevChunk = processedChunks.pop()
if (prevChunk && nextChunk.start <= prevChunk.end) {
// It may be the case that prevChunk completely surrounds nextChunk, so take the
// largest of the end indeces.
const endIndex = Math.max(prevChunk.end, nextChunk.end)
processedChunks.push({highlight: false, start: prevChunk.start, end: endIndex})
} else {
if (prevChunk) {
processedChunks.push(prevChunk, nextChunk)
}
}
return processedChunks
}
}, [])

return res
}

/**
* Examine text for any matches.
* If we find matches, add them to the returned array as a "chunk" object ({start:number, end:number}).
* @return {start:number, end:number}[]
*/
const defaultFindChunks = ({
autoEscape,
caseSensitive,
sanitize = defaultSanitize,
searchWords,
textToHighlight,
}: {
autoEscape?: boolean
caseSensitive?: boolean
sanitize?: typeof defaultSanitize
searchWords: Array<string>
textToHighlight: string
}): Array<Chunk> => {
textToHighlight = sanitize(textToHighlight)

return searchWords
.filter(searchWord => searchWord) // Remove empty words
.reduce((chunks: Array<Chunk>, searchWord) => {
searchWord = sanitize(searchWord)

if (autoEscape) {
searchWord = escapeRegExpFn(searchWord)
}

const regex = new RegExp(searchWord, caseSensitive ? 'g' : 'gi')

let match
while ((match = regex.exec(textToHighlight))) {
const start = match.index
const end = regex.lastIndex
// We do not return zero-length matches
if (end > start) {
chunks.push({highlight: false, start, end})
}

// Prevent browsers like Firefox from getting stuck in an infinite loop
// See http://www.regexguru.com/2008/04/watch-out-for-zero-length-matches/
if (match.index === regex.lastIndex) {
regex.lastIndex++
}
}

return chunks
}, [])
}
// Allow the findChunks to be overridden in findAll,
// but for backwards compatibility we export as the old name
export {defaultFindChunks as findChunks}

/**
* Given a set of chunks to highlight, create an additional set of chunks
* to represent the bits of text between the highlighted text.
* @param chunksToHighlight {start:number, end:number}[]
* @param totalLength number
* @return {start:number, end:number, highlight:boolean}[]
*/
export const fillInChunks = ({
chunksToHighlight,
totalLength,
}: {
chunksToHighlight: Array<Chunk>
totalLength: number
}): Array<Chunk> => {
const allChunks: Array<Chunk> = []
const append = (start: number, end: number, highlight: boolean) => {
if (end - start > 0) {
allChunks.push({
start,
end,
highlight,
})
}
}

if (chunksToHighlight.length === 0) {
append(0, totalLength, false)
} else {
let lastIndex = 0
chunksToHighlight.forEach(chunk => {
append(lastIndex, chunk.start, false)
append(chunk.start, chunk.end, true)
lastIndex = chunk.end
})
append(lastIndex, totalLength, false)
}
return allChunks
}

function defaultSanitize(string: string): string {
return string
}

function escapeRegExpFn(string: string): string {
return string.replace(/[-[\]{}()*+?.,\\^$|]/g, '\\$&')
}
3 changes: 2 additions & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import CodeInput from './CodeInput/CodeInput'
import Slider from './Slider/Slider'
import Card from './Card/Card'
import TextInput from './TextInput/TextInput'
import HighlightText from './HighlightText/HighlightText'

export {Button, CodeInput, Checkbox, Progress, Slider, RadioButton, Card, TextInput}
export {Button, CodeInput, Checkbox, Progress, Slider, RadioButton, Card, TextInput, HighlightText}
export * from './Text/Text'