import { ComponentDefaultTestId, getTestId } from "../../tests/test-ids-utils";
import cx from "classnames";
import React, { forwardRef, useMemo, useRef } from "react";
import Tooltip from "../../components/Tooltip/Tooltip";
import useIsOverflowing from "../../hooks/useIsOverflowing/useIsOverflowing";
import useIsomorphicLayoutEffect from "../../hooks/ssr/useIsomorphicLayoutEffect";
import useMergeRef from "../../hooks/useMergeRef";
import VibeComponentProps from "../../types/VibeComponentProps";
import { DialogPosition } from "../../constants";
import styles from "./TextWithHighlight.module.scss";
const getTextPart = (
text: string,
key: number,
shouldHighlight: boolean,
wrappingTextTag: keyof JSX.IntrinsicElements = "em",
wrappingElementClassName: string
) => {
const WrappingElement = wrappingTextTag;
if (shouldHighlight) {
return (
{text}
);
}
return {text};
};
export interface TextWithHighlightProps extends VibeComponentProps {
/** Text to wrap */
text?: string;
highlightTerm?: string;
/** Number of highlighted parts */
limit?: number;
ignoreCase?: boolean;
/** Should use ellipsis */
useEllipsis?: boolean;
/** Allow highlight every word as a separate term */
allowTermSplit?: boolean;
linesToClamp?: number;
/** Tooltip to show when there is no overflow */
nonEllipsisTooltip?: string;
/** HTML tag to wrap the selected text */
wrappingTextTag?: keyof JSX.IntrinsicElements;
wrappingElementClassName?: string;
tooltipPosition?: DialogPosition;
}
const TextWithHighlight: React.FC = forwardRef(
(
{
className,
id,
text = "",
highlightTerm,
limit,
useEllipsis = true,
linesToClamp = 3,
ignoreCase = true,
allowTermSplit = true,
nonEllipsisTooltip,
tooltipPosition,
wrappingTextTag = "em",
wrappingElementClassName,
"data-testid": dataTestId
},
ref
) => {
const componentRef = useRef(null);
const mergedRef = useMergeRef(ref, componentRef);
const textWithHighlights = useMemo(() => {
if (!text || !highlightTerm || limit === 0) return text;
let finalTerm = escapeRegExp(highlightTerm);
if (allowTermSplit) {
finalTerm = finalTerm.split(" ").join("|");
}
const regex = new RegExp(`(${finalTerm})`, ignoreCase ? "i" : "");
const tokens = text.split(regex);
const parts = [];
// Tokens include the term search (in odd indices)
let highlightTermsCount = 0;
let key = 0;
for (let i = 0; i < tokens.length; i++) {
// skip empty tokens
if (tokens[i]) {
// adding highlight part
const isTermPart = i % 2 === 1;
const shouldHighlight = isTermPart && (!limit || limit < 0 || highlightTermsCount < limit);
parts.push(getTextPart(tokens[i], key++, shouldHighlight, wrappingTextTag, wrappingElementClassName));
if (isTermPart) highlightTermsCount++;
}
}
return parts;
}, [text, highlightTerm, limit, ignoreCase, allowTermSplit, wrappingTextTag, wrappingElementClassName]);
const isOverflowing = useIsOverflowing({ ref: useEllipsis && componentRef });
useIsomorphicLayoutEffect(() => {
if (componentRef.current) {
componentRef.current.style.setProperty("--heading-clamp-lines", linesToClamp.toString());
}
}, [componentRef, linesToClamp, isOverflowing]);
const Element = (
{textWithHighlights}
);
if (isOverflowing || nonEllipsisTooltip) {
const tooltipContent = isOverflowing ? text : nonEllipsisTooltip;
return (
{Element}
);
}
return Element;
}
);
export default TextWithHighlight;
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}