typescript 42 lines · 8 steps

Splitting text into highlighted match segments

A search-highlighter that breaks a label into matched and unmatched pieces without breaking on special characters.

Explained by highlit
1type MatchSegment = {
2 text: string;
3 matched: boolean;
4};
5 
6function escapeRegExp(input: string): string {
7 return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8}
9 
10export function highlightMatches(label: string, query: string): MatchSegment[] {
11 const trimmed = query.trim();
12 if (trimmed.length === 0) {
13 return [{ text: label, matched: false }];
14 }
15 
16 const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "gi");
17 const segments: MatchSegment[] = [];
18 let lastIndex = 0;
19 
20 for (const match of label.matchAll(pattern)) {
21 const start = match.index ?? 0;
22 if (start > lastIndex) {
23 segments.push({ text: label.slice(lastIndex, start), matched: false });
24 }
25 segments.push({ text: match[0], matched: true });
26 lastIndex = start + match[0].length;
27 }
28 
29 if (lastIndex < label.length) {
30 segments.push({ text: label.slice(lastIndex), matched: false });
31 }
32 
33 return segments;
34}
35 
36export function renderHighlighted(label: string, query: string): string {
37 return highlightMatches(label, query)
38 .map((segment) =>
39 segment.matched ? `<mark>${segment.text}</mark>` : segment.text,
40 )
41 .join("");
42}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Escaping user input before building a RegExp prevents query characters from being interpreted as regex syntax.
  2. 2Tracking a running lastIndex lets you emit the gaps between matches as their own unmatched segments.
  3. 3Returning structured segments instead of HTML keeps matching logic reusable across render targets.

Related explainers

typescript
import { Component, input, computed } from '@angular/core';
 
function toNumber(value: number | string): number {
  return typeof value === 'number' ? value : parseFloat(value);

Signal inputs and computed in Angular

signals reactivity derived-state
Intermediate 5 steps
typescript
import sanitizeHtml from "sanitize-html";
 
interface RichTextOptions {
  allowImages?: boolean;

Building a configurable HTML sanitizer allowlist

sanitization xss-prevention allowlist
Intermediate 7 steps
typescript
import { useCallback, useEffect, useRef, useState } from "react";
 
interface Page<T> {
  items: T[];

A cursor-based infinite scroll hook in React

custom-hooks pagination intersection-observer
Intermediate 9 steps
typescript
type NestedValue = string | NestedValue[] | { [key: string]: NestedValue };
 
function parseFieldPath(name: string): string[] {
  const match = name.match(/^([^\[\]]+)((?:\[[^\[\]]*\])*)$/);

Parsing bracketed form field names into nested objects

parsing recursive-types regex
Intermediate 8 steps
typescript
import { Component } from '@angular/core';
import { NgForm } from '@angular/forms';
 
interface SignupModel {

How template-driven forms validate in Angular

forms two-way-binding validation
Intermediate 9 steps
typescript
import { Injectable, signal, computed } from '@angular/core';
 
export type ToastKind = 'success' | 'error' | 'info' | 'warning';
 

Building a signal-based toast service in Angular

signals state-management dependency-injection
Intermediate 8 steps

Share this explainer

Here's the card — post it anywhere.

Splitting text into highlighted match segments — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code