Skip to content
Merged
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 packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
- Replace remaining Emotion fixtures in polymorphic tests and ToolsPanel stories with SCSS Modules ([#84926](https://github.com/WordPress/gutenberg/pull/84926)).
- Generate connected component class names locally and remove unused case-conversion dependencies ([#85098](https://github.com/WordPress/gutenberg/pull/85098)).
- Remove unused Emotion-based `boxSizingReset` and `rtl` utilities ([#84929](https://github.com/WordPress/gutenberg/pull/84929)).
- Simplify the internal Text highlighter ([#85101](https://github.com/WordPress/gutenberg/pull/85101)).
- Update Ariakit to 0.4.41 ([#84539](https://github.com/WordPress/gutenberg/pull/84539)).
- Refactor to TypeScript ([#84611](https://github.com/WordPress/gutenberg/pull/84611)).

Expand Down
48 changes: 48 additions & 0 deletions packages/components/src/text/test/index.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,56 @@ describe( 'Text', () => {
expect(
screen.queryByText( 'ipsum', { selector: 'mark' } )
).not.toBeInTheDocument();
expect( screen.getByRole( 'heading' ) ).toHaveTextContent(
'Lorem ipsum.'
);
} );

test.each( [
{
description: 'regular-expression matches',
text: 'cat c.t',
words: [ 'c.t' ],
highlights: [ 'cat', 'c.t' ],
},
{
description: 'literal matches when escaping is enabled',
text: 'cat c.t',
words: [ 'c.t' ],
escape: true,
highlights: [ 'c.t' ],
},
{
description: 'original text when matching sanitized words',
text: 'café cafe',
words: [ 'cafe' ],
sanitize: ( text: string ) =>
text.normalize( 'NFD' ).replace( /[\u0300-\u036f]/g, '' ),
highlights: [ 'café', 'cafe' ],
},
] )(
'preserves $description',
async ( { text, words, escape, sanitize, highlights } ) => {
await render(
<Text
as="p"
highlightWords={ words }
highlightEscape={ escape }
highlightSanitize={ sanitize }
>
{ text }
</Text>
);

expect( screen.getByRole( 'paragraph' ) ).toHaveTextContent( text );
expect(
screen
.queryAllByText( /.+/, { selector: 'mark' } )
.map( ( mark ) => mark.textContent )
).toEqual( highlights );
}
);

test( 'should render isBlock', async () => {
await render(
<Text role="heading" isBlock>
Expand Down
146 changes: 13 additions & 133 deletions packages/components/src/text/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import memoize from 'memize';
import type { FindAllArgs } from 'highlight-words-core';
import { findAll } from 'highlight-words-core';
import { createElement } from '@wordpress/element';
Expand All @@ -8,166 +7,47 @@ import { createElement } from '@wordpress/element';
* https://github.com/bvaughn/react-highlight-words/blob/HEAD/src/Highlighter.js
*/

/**
* @typedef Options
* @property {string} [activeClassName=''] Classname for active highlighted areas.
* @property {number} [activeIndex=-1] The index of the active highlighted area.
* @property {React.CSSProperties} [activeStyle] Styles to apply to the active highlighted area.
* @property {boolean} [autoEscape] Whether to automatically escape text.
* @property {boolean} [caseSensitive=false] Whether to highlight in a case-sensitive manner.
* @property {string} children Children to highlight.
* @property {FindAllArgs['findChunks']} [findChunks] Custom `findChunks` function to pass to `highlight-words-core`.
* @property {string | Record<string, unknown>} [highlightClassName=''] Classname to apply to highlighted text or a Record of classnames to apply to given text (which should be the key).
* @property {React.CSSProperties} [highlightStyle={}] Styles to apply to highlighted text.
* @property {keyof React.JSX.IntrinsicElements} [highlightTag='mark'] Tag to use for the highlighted text.
* @property {FindAllArgs['sanitize']} [sanitize] Custom `sanitize` function to pass to `highlight-words-core`.
* @property {string[]} [searchWords=[]] Words to search for and highlight.
* @property {string} [unhighlightClassName=''] Classname to apply to unhighlighted text.
* @property {React.CSSProperties} [unhighlightStyle] Style to apply to unhighlighted text.
*/

interface Options {
activeClassName?: string;
activeIndex?: number;
activeStyle?: React.CSSProperties;
autoEscape?: boolean;
caseSensitive?: boolean;
children: string;
findChunks?: FindAllArgs[ 'findChunks' ];
highlightClassName?: string | Record< string, unknown >;
highlightStyle?: React.CSSProperties;
highlightTag?: keyof React.JSX.IntrinsicElements;
sanitize?: FindAllArgs[ 'sanitize' ];
searchWords?: string[];
unhighlightClassName?: string;
unhighlightStyle?: React.CSSProperties;
}

/**
* Maps props to lowercase names.
* Creates the highlighted text rendered by Text.
*
* @param object Props to map.
* @return The mapped props.
*/
const lowercaseProps = < T extends Record< string, unknown > >( object: T ) => {
const mapped: Record< string, unknown > = {};
for ( const key in object ) {
mapped[ key.toLowerCase() ] = object[ key ];
}
return mapped as { [ K in keyof T as Lowercase< string & K > ]: T[ K ] };
};

const memoizedLowercaseProps = memoize( lowercaseProps );

/**
* @param options
* @param options.activeClassName
* @param options.activeIndex
* @param options.activeStyle
* @param options.autoEscape
* @param options.caseSensitive
* @param options.children
* @param options.findChunks
* @param options.highlightClassName
* @param options.highlightStyle
* @param options.highlightTag
* @param options.sanitize
* @param options.searchWords
* @param options.unhighlightClassName
* @param options.unhighlightStyle
* @param options Highlighter options.
* @param options.autoEscape Whether to escape regular-expression characters.
* @param options.caseSensitive Whether matching is case-sensitive.
* @param options.children Text to highlight.
* @param options.sanitize Text normalization function used for matching.
* @param options.searchWords Search terms to highlight.
*/
export function createHighlighterText( {
activeClassName = '',
activeIndex = -1,
activeStyle,
autoEscape,
caseSensitive = false,
children,
findChunks,
highlightClassName = '',
highlightStyle = {},
highlightTag = 'mark',
sanitize,
searchWords = [],
unhighlightClassName = '',
unhighlightStyle,
}: Options ) {
if ( ! children ) {
return null;
}
if ( typeof children !== 'string' ) {
return children;
}

const textToHighlight = children;

const chunks = findAll( {
autoEscape,
caseSensitive,
findChunks,
sanitize,
searchWords,
textToHighlight,
textToHighlight: children,
} );
const HighlightTag = highlightTag;
let highlightIndex = -1;
let highlightClassNames = '';
let highlightStyles;

const textContent = chunks.map( ( chunk, index ) => {
const text = textToHighlight.substr(
chunk.start,
chunk.end - chunk.start
);

if ( chunk.highlight ) {
highlightIndex++;

let highlightClass;
if ( typeof highlightClassName === 'object' ) {
if ( ! caseSensitive ) {
highlightClassName =
memoizedLowercaseProps( highlightClassName );
highlightClass = highlightClassName[ text.toLowerCase() ];
} else {
highlightClass = highlightClassName[ text ];
}
} else {
highlightClass = highlightClassName;
}

const isActive = highlightIndex === +activeIndex;

highlightClassNames = `${ highlightClass } ${
isActive ? activeClassName : ''
}`;
highlightStyles =
isActive === true && activeStyle !== null
? Object.assign( {}, highlightStyle, activeStyle )
: highlightStyle;

const props: Record< string, unknown > = {
children: text,
className: highlightClassNames,
key: index,
style: highlightStyles,
};

// Don't attach arbitrary props to DOM elements; this triggers React DEV warnings (https://fb.me/react-unknown-prop)
// Only pass through the highlightIndex attribute for custom components.
if ( typeof HighlightTag !== 'string' ) {
props.highlightIndex = highlightIndex;
}

return createElement( HighlightTag, props );
}
return createElement( 'span', {
children: text,
className: unhighlightClassName,
return chunks.map( ( chunk, index ) =>
createElement( chunk.highlight ? 'mark' : 'span', {
children: children.slice( chunk.start, chunk.end ),
key: index,
style: unhighlightStyle,
} );
} );

return textContent;
} )
);
}
Loading