Skip to content

Commit 5efa498

Browse files
feat: Added highlight container (#1880)
--------- Co-authored-by: Dilyana Yarabanova <45598235+didimmova@users.noreply.github.com> Co-authored-by: didimmova <d.dimova11@gmail.com>
1 parent 6624b44 commit 5efa498

21 files changed

Lines changed: 1149 additions & 1 deletion

src/components/common/definitions/defineAllComponents.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import IgcDropdownGroupComponent from '../../dropdown/dropdown-group.js';
3030
import IgcDropdownHeaderComponent from '../../dropdown/dropdown-header.js';
3131
import IgcDropdownItemComponent from '../../dropdown/dropdown-item.js';
3232
import IgcExpansionPanelComponent from '../../expansion-panel/expansion-panel.js';
33+
import IgcHighlightComponent from '../../highlight/highlight.js';
3334
import IgcIconComponent from '../../icon/icon.js';
3435
import IgcInputComponent from '../../input/input.js';
3536
import IgcListComponent from '../../list/list.js';
@@ -105,6 +106,7 @@ const allComponents: IgniteComponent[] = [
105106
IgcDividerComponent,
106107
IgcSwitchComponent,
107108
IgcExpansionPanelComponent,
109+
IgcHighlightComponent,
108110
IgcIconComponent,
109111
IgcInputComponent,
110112
IgcListHeaderComponent,

src/components/common/util.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ export function roundByDPR(value: number): number {
368368
}
369369

370370
export function scrollIntoView(
371-
element?: HTMLElement,
371+
element?: HTMLElement | null,
372372
config?: ScrollIntoViewOptions
373373
): void {
374374
if (!element) {
@@ -505,6 +505,19 @@ export function equal<T>(a: unknown, b: T, visited = new WeakSet()): boolean {
505505
return false;
506506
}
507507

508+
/**
509+
* Escapes any potential regex syntax characters in a string, and returns a new string
510+
* that can be safely used as a literal pattern for the `RegExp()` constructor.
511+
*
512+
* @remarks
513+
* Substitute with `RegExp.escape` once it has enough support:
514+
*
515+
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape#browser_compatibility
516+
*/
517+
export function escapeRegex(value: string): string {
518+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
519+
}
520+
508521
/** Required utility type for specific props */
509522
export type RequiredProps<T, K extends keyof T> = T & {
510523
[P in K]-?: T[P];
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
2+
3+
import { defineComponents } from '../common/definitions/defineComponents.js';
4+
import IgcHighlightComponent from './highlight.js';
5+
6+
describe('Highlight', () => {
7+
before(() => defineComponents(IgcHighlightComponent));
8+
9+
let highlight: IgcHighlightComponent;
10+
11+
function createHighlightWithInitialMatch() {
12+
return html`<igc-highlight search-text="lorem">
13+
Lorem ipsum dolor sit amet consectetur adipisicing elit. Sapiente in
14+
recusandae aliquam placeat! Saepe hic reiciendis quae, dolorum totam ab
15+
mollitia, tempora excepturi blanditiis repellat dolore nemo cumque illum
16+
quas.
17+
</igc-highlight>`;
18+
}
19+
20+
function createHighlight() {
21+
return html`<igc-highlight>
22+
Lorem ipsum dolor sit amet consectetur adipisicing elit. Sapiente in
23+
recusandae aliquam placeat! Saepe hic reiciendis quae, dolorum totam ab
24+
mollitia, tempora excepturi blanditiis repellat dolore nemo cumque illum
25+
quas.
26+
</igc-highlight>`;
27+
}
28+
29+
describe('Initial render', () => {
30+
beforeEach(async () => {
31+
highlight = await fixture(createHighlightWithInitialMatch());
32+
});
33+
34+
it('is correctly matched', async () => {
35+
expect(highlight.size).to.equal(1);
36+
});
37+
});
38+
39+
describe('DOM', () => {
40+
beforeEach(async () => {
41+
highlight = await fixture(createHighlight());
42+
});
43+
44+
it('is defined', async () => {
45+
expect(highlight).to.not.be.undefined;
46+
});
47+
48+
it('is accessible', async () => {
49+
await expect(highlight).shadowDom.to.be.accessible();
50+
await expect(highlight).lightDom.to.be.accessible();
51+
});
52+
});
53+
54+
describe('API', () => {
55+
beforeEach(async () => {
56+
highlight = await fixture(createHighlight());
57+
});
58+
59+
it('matches on changing `search` value', async () => {
60+
expect(highlight.size).to.equal(0);
61+
62+
highlight.searchText = 'lorem';
63+
await elementUpdated(highlight);
64+
65+
expect(highlight.size).to.equal(1);
66+
67+
highlight.searchText = '';
68+
await elementUpdated(highlight);
69+
70+
expect(highlight.size).to.equal(0);
71+
});
72+
73+
it('matches with case sensitivity', async () => {
74+
highlight.caseSensitive = true;
75+
highlight.searchText = 'lorem';
76+
await elementUpdated(highlight);
77+
78+
expect(highlight.size).to.equal(0);
79+
80+
highlight.searchText = 'Lorem';
81+
await elementUpdated(highlight);
82+
83+
expect(highlight.size).to.equal(1);
84+
});
85+
86+
it('moves to the next match when `next()` is invoked', async () => {
87+
highlight.searchText = 'e';
88+
await elementUpdated(highlight);
89+
90+
expect(highlight.size).greaterThan(0);
91+
expect(highlight.current).to.equal(0);
92+
93+
highlight.next();
94+
expect(highlight.current).to.equal(1);
95+
});
96+
97+
it('moves to the previous when `previous()` is invoked', async () => {
98+
highlight.searchText = 'e';
99+
await elementUpdated(highlight);
100+
101+
expect(highlight.size).greaterThan(0);
102+
expect(highlight.current).to.equal(0);
103+
104+
// Wrap around to the last one
105+
highlight.previous();
106+
expect(highlight.current).to.equal(highlight.size - 1);
107+
});
108+
109+
it('setActive called', async () => {
110+
highlight.searchText = 'e';
111+
await elementUpdated(highlight);
112+
113+
highlight.setActive(15);
114+
expect(highlight.current).to.equal(15);
115+
});
116+
117+
it('refresh called', async () => {
118+
highlight.searchText = 'lorem';
119+
await elementUpdated(highlight);
120+
121+
expect(highlight.size).to.equal(1);
122+
123+
const node = document.createElement('div');
124+
node.textContent = 'Lorem '.repeat(9);
125+
126+
highlight.append(node);
127+
highlight.search();
128+
129+
expect(highlight.size).to.equal(10);
130+
131+
node.remove();
132+
highlight.search();
133+
134+
expect(highlight.size).to.equal(1);
135+
});
136+
});
137+
});
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { html, LitElement } from 'lit';
2+
import { property } from 'lit/decorators.js';
3+
import { addThemingController } from '../../theming/theming-controller.js';
4+
import { registerComponent } from '../common/definitions/register.js';
5+
import {
6+
createHighlightController,
7+
type HighlightNavigation,
8+
} from './service.js';
9+
import { styles as shared } from './themes/shared/highlight.common.css.js';
10+
import { all } from './themes/themes.js';
11+
12+
/**
13+
* The highlight component provides efficient searching and highlighting of text
14+
* projected into it via its default slot. It uses the native CSS Custom Highlight API
15+
* to apply highlight styles to matched text nodes without modifying the DOM.
16+
*
17+
* The component supports case-sensitive matching, programmatic navigation between
18+
* matches, and automatic scroll-into-view of the active match.
19+
*
20+
* @element igc-highlight
21+
*
22+
* @slot - The default slot. Place the text content you want to search and highlight here.
23+
*
24+
* @cssproperty --foreground - The text color for a highlighted text node.
25+
* @cssproperty --background - The background color for a highlighted text node.
26+
* @cssproperty --foreground-active - The text color for the active highlighted text node.
27+
* @cssproperty --background-active - The background color for the active highlighted text node.
28+
*
29+
* @example
30+
* Basic usage — wrap your text and set the `search-text` attribute:
31+
* ```html
32+
* <igc-highlight search-text="world">
33+
* <p>Hello, world! The world is a wonderful place.</p>
34+
* </igc-highlight>
35+
* ```
36+
*
37+
* @example
38+
* Case-sensitive search:
39+
* ```html
40+
* <igc-highlight search-text="Hello" case-sensitive>
41+
* <p>Hello hello HELLO — only the first one matches.</p>
42+
* </igc-highlight>
43+
* ```
44+
*
45+
* @example
46+
* Navigating between matches programmatically:
47+
* ```typescript
48+
* const highlight = document.querySelector('igc-highlight');
49+
*
50+
* highlight.searchText = 'world';
51+
* console.log(highlight.size); // total number of matches
52+
* console.log(highlight.current); // index of the active match (0-based)
53+
*
54+
* highlight.next(); // move to the next match
55+
* highlight.previous(); // move to the previous match
56+
* highlight.setActive(2); // jump to a specific match by index
57+
* ```
58+
*
59+
* @example
60+
* Prevent scroll-into-view when navigating:
61+
* ```typescript
62+
* const highlight = document.querySelector('igc-highlight');
63+
* highlight.next({ preventScroll: true });
64+
* ```
65+
*
66+
* @example
67+
* Re-run search after dynamic content changes (e.g. lazy-loaded text):
68+
* ```typescript
69+
* const highlight = document.querySelector('igc-highlight');
70+
* // After slotted content has been updated:
71+
* highlight.search();
72+
* ```
73+
*/
74+
export default class IgcHighlightComponent extends LitElement {
75+
public static readonly tagName = 'igc-highlight';
76+
public static override styles = [shared];
77+
78+
/* blazorSuppress */
79+
public static register(): void {
80+
registerComponent(IgcHighlightComponent);
81+
}
82+
83+
//#region Internal properties and state
84+
85+
private readonly _service = createHighlightController(this);
86+
87+
private _caseSensitive = false;
88+
private _searchText = '';
89+
90+
//#endregion
91+
92+
//#region Public properties and attributes
93+
94+
/**
95+
* Whether to match the searched text with case sensitivity in mind.
96+
* When `true`, only exact-case occurrences of `searchText` are highlighted.
97+
*
98+
* @attr case-sensitive
99+
* @default false
100+
*/
101+
@property({ type: Boolean, reflect: true, attribute: 'case-sensitive' })
102+
public set caseSensitive(value: boolean) {
103+
this._caseSensitive = value;
104+
this.search();
105+
}
106+
107+
public get caseSensitive(): boolean {
108+
return this._caseSensitive;
109+
}
110+
111+
/**
112+
* The string to search and highlight in the DOM content of the component.
113+
* Setting this property triggers a new search automatically.
114+
* An empty string clears all highlights.
115+
*
116+
* @attr search-text
117+
*/
118+
@property({ attribute: 'search-text' })
119+
public set searchText(value: string) {
120+
this._searchText = value;
121+
this.search();
122+
}
123+
124+
public get searchText(): string {
125+
return this._searchText;
126+
}
127+
128+
/** The total number of matches found for the current `searchText`. Returns `0` when there are no matches or `searchText` is empty. */
129+
public get size(): number {
130+
return this._service.size;
131+
}
132+
133+
/** The zero-based index of the currently active (focused) match. Returns `0` when there are no matches. */
134+
public get current(): number {
135+
return this._service.current;
136+
}
137+
138+
//#endregion
139+
140+
constructor() {
141+
super();
142+
143+
addThemingController(this, all, {
144+
themeChange: this._addStylesheet,
145+
});
146+
}
147+
148+
//#region Internal methods
149+
150+
private _addStylesheet(): void {
151+
this._service.attachStylesheet();
152+
}
153+
154+
//#endregion
155+
156+
//#region Public methods
157+
158+
/**
159+
* Moves the active highlight to the next match.
160+
* Wraps around to the first match after the last one.
161+
*
162+
* @param options - Optional navigation options (e.g. `preventScroll`).
163+
*/
164+
public next(options?: HighlightNavigation): void {
165+
this._service.next(options);
166+
}
167+
168+
/**
169+
* Moves the active highlight to the previous match.
170+
* Wraps around to the last match when going back from the first one.
171+
*
172+
* @param options - Optional navigation options (e.g. `preventScroll`).
173+
*/
174+
public previous(options?: HighlightNavigation): void {
175+
this._service.previous(options);
176+
}
177+
178+
/**
179+
* Moves the active highlight to the match at the specified zero-based index.
180+
*
181+
* @param index - The zero-based index of the match to activate.
182+
* @param options - Optional navigation options (e.g. `preventScroll`).
183+
*/
184+
public setActive(index: number, options?: HighlightNavigation): void {
185+
this._service.setActive(index, options);
186+
}
187+
188+
/**
189+
* Re-runs the highlight search based on the current `searchText` and `caseSensitive` values.
190+
*
191+
* Call this method after the slotted content changes dynamically (e.g. after lazy loading
192+
* or programmatic DOM mutations) to ensure all matches are up to date.
193+
*/
194+
public search(): void {
195+
if (this.hasUpdated) {
196+
this._service.clear();
197+
this._service.find(this.searchText);
198+
}
199+
}
200+
201+
//#endregion
202+
203+
protected override render() {
204+
return html`<slot></slot>`;
205+
}
206+
}
207+
208+
declare global {
209+
interface HTMLElementTagNameMap {
210+
'igc-highlight': IgcHighlightComponent;
211+
}
212+
}

0 commit comments

Comments
 (0)