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
13 changes: 13 additions & 0 deletions src/components/Chip/Chip.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,16 @@ export const WithBadge: Story = {
),
name: "With Badge",
};

export const SubstringHighlight: Story = {
render: () => <Chip lead="Status" value="Positive" subString="sit" />,
name: "Substring highlight",
parameters: {
docs: {
description: {
story:
"Use `subString` to emphasise part of the value, e.g. the current search term.",
},
},
},
};
28 changes: 28 additions & 0 deletions src/components/Chip/Chip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,32 @@ describe("Chip ", () => {
await userEvent.tab();
expect(chip).not.toHaveFocus();
});

it("emphasises the substring within the value", () => {
render(<Chip data-testid="chip" value="positive" subString="sit" />);
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(
<Chip data-testid="chip" value="positive" subString="sit" quoteValue />,
);
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 = "<img src=x onerror=alert(1)>";
render(<Chip data-testid="chip" value={value} subString="img" />);
const chip = screen.getByTestId("chip");
expect(within(chip).queryByRole("img")).not.toBeInTheDocument();
expect(chip).toHaveTextContent(value);
});
});
17 changes: 11 additions & 6 deletions src/components/Chip/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,17 @@ const Chip = ({
<>
{iconName && <i className={`p-icon--${iconName}`} />}
{lead && <span className="p-chip__lead">{lead.toUpperCase()}</span>}
<span
className="p-chip__value"
dangerouslySetInnerHTML={{
__html: quoteValue ? `'${chipValue}'` : chipValue,
}}
/>
<span className="p-chip__value">
{quoteValue ? (
<>
{"'"}
{chipValue}
{"'"}
</>
) : (
chipValue
)}
</span>
{badge && badge}
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
<FilterPanelSection
searchData={[]}
searchTerm="reg"
toggleSelected={jest.fn()}
data={sampleData}
/>,
);
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(
<FilterPanelSection
searchData={[]}
searchTerm=""
toggleSelected={jest.fn()}
data={{ ...sampleData, heading: "<b>Regions</b>" }}
/>,
);
expect(
screen.getByRole("heading", { name: "<b>Regions</b>" }),
).toBeInTheDocument();
expect(screen.queryByText("Regions")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,9 @@ const FilterPanelSection = ({
<HeadingTag
className="p-filter-panel-section__heading"
id={`filter-panel-section-heading-${headingId}`}
dangerouslySetInnerHTML={{
__html: highlightSubString(heading, searchTerm).text,
}}
/>
>
{highlightSubString(heading, searchTerm).text}
</HeadingTag>
)}
<div
className="p-filter-panel-section__chips"
Expand Down
31 changes: 0 additions & 31 deletions src/utils.test.ts

This file was deleted.

66 changes: 66 additions & 0 deletions src/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
"re<strong>act</strong>",
);
});

it("matches case insensitively and emphasises every occurrence", () => {
const { text, match } = highlightSubString("Banana", "AN");
expect(match).toBe(true);
expect(render(<>{text}</>).container.innerHTML).toBe(
"B<strong>an</strong><strong>an</strong>a",
);
});

it("does not parse the string as HTML", () => {
const { text, match } = highlightSubString("<b>bold</b>", "bold");
expect(match).toBe(true);
expect(render(<>{text}</>).container.innerHTML).toBe(
"&lt;b&gt;<strong>bold</strong>&lt;/b&gt;",
);
});

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<strong>\\</strong>",
);
});
});
36 changes: 24 additions & 12 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
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 <strong /> tag
* Find substring and emphasise every match by wrapping it in a <strong /> 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,
};
}

const escapedSubstring = subString.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const caseInsensitiveRegex = new RegExp(escapedSubstring, "gi");
const newStr = str.replace(
caseInsensitiveRegex,
(match) => `<strong>${match}</strong>`,
);
// 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,
};
};

Expand Down
Loading