From cb20b232babf64d46392de7b25d329630ec10595 Mon Sep 17 00:00:00 2001 From: joydaven Date: Sun, 18 Oct 2026 01:44:27 +0800 Subject: [PATCH] fix(Chip): render substring highlight as React nodes instead of raw HTML --- src/components/Chip/Chip.stories.tsx | 13 ++++ src/components/Chip/Chip.test.tsx | 28 ++++++++ src/components/Chip/Chip.tsx | 17 +++-- .../FilterPanelSection.test.tsx | 33 +++++++++- .../FilterPanelSection/FilterPanelSection.tsx | 7 +- src/utils.test.ts | 31 --------- src/utils.test.tsx | 66 +++++++++++++++++++ src/utils.ts | 36 ++++++---- 8 files changed, 177 insertions(+), 54 deletions(-) delete mode 100644 src/utils.test.ts create mode 100644 src/utils.test.tsx diff --git a/src/components/Chip/Chip.stories.tsx b/src/components/Chip/Chip.stories.tsx index b7ce870bf..da3f1344f 100644 --- a/src/components/Chip/Chip.stories.tsx +++ b/src/components/Chip/Chip.stories.tsx @@ -83,3 +83,16 @@ export const WithBadge: Story = { ), name: "With Badge", }; + +export const SubstringHighlight: Story = { + render: () => , + name: "Substring highlight", + parameters: { + docs: { + description: { + story: + "Use `subString` to emphasise part of the value, e.g. the current search term.", + }, + }, + }, +}; diff --git a/src/components/Chip/Chip.test.tsx b/src/components/Chip/Chip.test.tsx index bdec3450e..cf059a0c1 100644 --- a/src/components/Chip/Chip.test.tsx +++ b/src/components/Chip/Chip.test.tsx @@ -265,4 +265,32 @@ describe("Chip ", () => { await userEvent.tab(); expect(chip).not.toHaveFocus(); }); + + it("emphasises the substring within the value", () => { + render(); + const chip = screen.getByTestId("chip"); + expect(chip).toHaveTextContent("positive"); + expect( + within(chip).getByText("sit", { selector: "strong" }), + ).toBeInTheDocument(); + }); + + it("wraps the value in quotes when quoteValue is true", () => { + render( + , + ); + const chip = screen.getByTestId("chip"); + expect(chip).toHaveTextContent("'positive'"); + expect( + within(chip).getByText("sit", { selector: "strong" }), + ).toBeInTheDocument(); + }); + + it("renders markup in the value as plain text", () => { + const value = ""; + render(); + const chip = screen.getByTestId("chip"); + expect(within(chip).queryByRole("img")).not.toBeInTheDocument(); + expect(chip).toHaveTextContent(value); + }); }); diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index 6e167c3df..afe82dc2a 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -119,12 +119,17 @@ const Chip = ({ <> {iconName && } {lead && {lead.toUpperCase()}} - + + {quoteValue ? ( + <> + {"'"} + {chipValue} + {"'"} + + ) : ( + chipValue + )} + {badge && badge} ); diff --git a/src/components/SearchAndFilter/FilterPanelSection/FilterPanelSection.test.tsx b/src/components/SearchAndFilter/FilterPanelSection/FilterPanelSection.test.tsx index e67d05da5..a5c73c923 100644 --- a/src/components/SearchAndFilter/FilterPanelSection/FilterPanelSection.test.tsx +++ b/src/components/SearchAndFilter/FilterPanelSection/FilterPanelSection.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import React from "react"; import FilterPanelSection from "./FilterPanelSection"; @@ -137,4 +137,35 @@ describe("Filter panel section", () => { document.querySelector(".p-filter-panel-section__counter"), ).not.toBeInTheDocument(); }); + + it("emphasises the search term in the heading", () => { + render( + , + ); + const heading = screen.getByRole("heading"); + expect(heading).toHaveTextContent("Regions"); + expect( + within(heading).getByText("Reg", { selector: "strong" }), + ).toBeInTheDocument(); + }); + + it("renders markup in the heading as plain text", () => { + render( + Regions" }} + />, + ); + expect( + screen.getByRole("heading", { name: "Regions" }), + ).toBeInTheDocument(); + expect(screen.queryByText("Regions")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/SearchAndFilter/FilterPanelSection/FilterPanelSection.tsx b/src/components/SearchAndFilter/FilterPanelSection/FilterPanelSection.tsx index 662a07370..c71999f82 100644 --- a/src/components/SearchAndFilter/FilterPanelSection/FilterPanelSection.tsx +++ b/src/components/SearchAndFilter/FilterPanelSection/FilterPanelSection.tsx @@ -113,10 +113,9 @@ const FilterPanelSection = ({ + > + {highlightSubString(heading, searchTerm).text} + )}
{ - it("returns a substring match", () => { - const string = "react"; - const subString = "act"; - const testString = highlightSubString(string, subString); - expect(testString).toEqual({ match: true, text: "react" }); - }); - - it("gracefully fails when invalid string is provided", () => { - expect(highlightSubString(undefined, "somesub")).toEqual({ - text: "", - match: false, - }); - }); - - it("gracefully fails when invalid substring is provided", () => { - expect(highlightSubString("somestring", undefined)).toEqual({ - text: "somestring", - match: false, - }); - }); - - it("handles special substrings for regex matching", () => { - expect(highlightSubString("somestring\\", "\\")).toEqual({ - text: "somestring\\", - match: true, - }); - }); -}); diff --git a/src/utils.test.tsx b/src/utils.test.tsx new file mode 100644 index 000000000..f307e5594 --- /dev/null +++ b/src/utils.test.tsx @@ -0,0 +1,66 @@ +import { render } from "@testing-library/react"; +import React from "react"; + +import { highlightSubString } from "./utils"; + +describe("highlightSubString function ", () => { + it("returns a substring match", () => { + const { text, match } = highlightSubString("react", "act"); + expect(match).toBe(true); + expect(render(<>{text}).container.innerHTML).toBe( + "react", + ); + }); + + it("matches case insensitively and emphasises every occurrence", () => { + const { text, match } = highlightSubString("Banana", "AN"); + expect(match).toBe(true); + expect(render(<>{text}).container.innerHTML).toBe( + "Banana", + ); + }); + + it("does not parse the string as HTML", () => { + const { text, match } = highlightSubString("bold", "bold"); + expect(match).toBe(true); + expect(render(<>{text}).container.innerHTML).toBe( + "<b>bold</b>", + ); + }); + + it("returns the string unchanged when there is no match", () => { + expect(highlightSubString("react", "xyz")).toEqual({ + text: "react", + match: false, + }); + }); + + it("gracefully fails when invalid string is provided", () => { + expect(highlightSubString(undefined, "somesub")).toEqual({ + text: "", + match: false, + }); + }); + + it("gracefully fails when invalid substring is provided", () => { + expect(highlightSubString("somestring", undefined)).toEqual({ + text: "somestring", + match: false, + }); + }); + + it("does not match an empty substring", () => { + expect(highlightSubString("somestring", "")).toEqual({ + text: "somestring", + match: false, + }); + }); + + it("handles special substrings for regex matching", () => { + const { text, match } = highlightSubString("somestring\\", "\\"); + expect(match).toBe(true); + expect(render(<>{text}).container.innerHTML).toBe( + "somestring\\", + ); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 1c5f8962e..e695253ec 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,19 +1,24 @@ import { NavLink, NavLinkAnchor, NavLinkButton } from "components/Navigation"; -import { isValidElement, ReactNode } from "react"; +import { createElement, isValidElement, ReactNode } from "react"; export const IS_DEV = process.env.NODE_ENV === "development"; /** - * Find substring and wrap in tag + * Find substring and emphasise every match by wrapping it in a element. + * The result is a React node, so the input is never parsed as HTML. * @param {string} str - The string to search * @param {string} subString - The substring to find - * @return {Obj} newStr - Object with text and match bool + * @return {Obj} Object with the text (as React nodes) and match bool */ export const highlightSubString = ( str?: string, subString?: string, -): { text: string; match: boolean } => { - if (typeof str !== "string" || typeof subString !== "string") { +): { text: ReactNode; match: boolean } => { + if ( + typeof str !== "string" || + typeof subString !== "string" || + subString === "" + ) { return { text: str || "", match: false, @@ -21,15 +26,22 @@ export const highlightSubString = ( } const escapedSubstring = subString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const caseInsensitiveRegex = new RegExp(escapedSubstring, "gi"); - const newStr = str.replace( - caseInsensitiveRegex, - (match) => `${match}`, - ); + // The capture group makes split() keep the matched text, at every odd index. + const caseInsensitiveRegex = new RegExp(`(${escapedSubstring})`, "gi"); + const parts = str.split(caseInsensitiveRegex); + + if (parts.length === 1) { + return { + text: str, + match: false, + }; + } return { - text: subString === "" ? str : newStr, - match: newStr !== str, + text: parts.map((part, index) => + index % 2 === 1 ? createElement("strong", { key: index }, part) : part, + ), + match: true, }; };