Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions src/components/common/definitions/defineAllComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import IgcDropdownGroupComponent from '../../dropdown/dropdown-group.js';
import IgcDropdownHeaderComponent from '../../dropdown/dropdown-header.js';
import IgcDropdownItemComponent from '../../dropdown/dropdown-item.js';
import IgcExpansionPanelComponent from '../../expansion-panel/expansion-panel.js';
import IgcHighlightComponent from '../../highlight/highlight.js';
import IgcIconComponent from '../../icon/icon.js';
import IgcInputComponent from '../../input/input.js';
import IgcListComponent from '../../list/list.js';
Expand Down Expand Up @@ -102,6 +103,7 @@ const allComponents: IgniteComponent[] = [
IgcDividerComponent,
IgcSwitchComponent,
IgcExpansionPanelComponent,
IgcHighlightComponent,
IgcIconComponent,
IgcInputComponent,
IgcListHeaderComponent,
Expand Down
15 changes: 14 additions & 1 deletion src/components/common/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ export function roundByDPR(value: number): number {
}

export function scrollIntoView(
element?: HTMLElement,
element?: HTMLElement | null,
config?: ScrollIntoViewOptions
): void {
if (!element) {
Expand Down Expand Up @@ -498,6 +498,19 @@ export function equal<T>(a: unknown, b: T, visited = new WeakSet()): boolean {
return false;
}

/**
* Escapes any potential regex syntax characters in a string, and returns a new string
* that can be safely used as a literal pattern for the `RegExp()` constructor.
*
* @remarks
* Substitute with `RegExp.escape` once it has enough support:
*
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape#browser_compatibility
*/
export function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** Required utility type for specific props */
export type RequiredProps<T, K extends keyof T> = T & {
[P in K]-?: T[P];
Expand Down
8 changes: 2 additions & 6 deletions src/components/date-time-input/date-util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { parseISODate } from '../calendar/helpers.js';
import { clamp } from '../common/util.js';
import { clamp, escapeRegex } from '../common/util.js';

export enum FormatDesc {
Numeric = 'numeric',
Expand Down Expand Up @@ -847,13 +847,9 @@ export abstract class DateTimeUtil {
);
}

private static escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

private static trimEmptyPlaceholders(value: string, prompt?: string): string {
const result = value.replace(
new RegExp(DateTimeUtil.escapeRegExp(prompt ?? '_'), 'g'),
new RegExp(escapeRegex(prompt ?? '_'), 'g'),
''
);
return result;
Expand Down
137 changes: 137 additions & 0 deletions src/components/highlight/highlight.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';

import { defineComponents } from '../common/definitions/defineComponents.js';
import IgcHighlightComponent from './highlight.js';

describe('Highlight', () => {
before(() => defineComponents(IgcHighlightComponent));

let highlight: IgcHighlightComponent;

function createHighlightWithInitialMatch() {
return html`<igc-highlight search-text="lorem">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Sapiente in
recusandae aliquam placeat! Saepe hic reiciendis quae, dolorum totam ab
mollitia, tempora excepturi blanditiis repellat dolore nemo cumque illum
quas.
</igc-highlight>`;
}

function createHighlight() {
return html`<igc-highlight>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Sapiente in
recusandae aliquam placeat! Saepe hic reiciendis quae, dolorum totam ab
mollitia, tempora excepturi blanditiis repellat dolore nemo cumque illum
quas.
</igc-highlight>`;
}

describe('Initial render', () => {
beforeEach(async () => {
highlight = await fixture(createHighlightWithInitialMatch());
});

it('is correctly matched', async () => {
expect(highlight.size).to.equal(1);
});
});

describe('DOM', () => {
beforeEach(async () => {
highlight = await fixture(createHighlight());
});

it('is defined', async () => {
expect(highlight).to.not.be.undefined;
});

it('is accessible', async () => {
await expect(highlight).shadowDom.to.be.accessible();
await expect(highlight).lightDom.to.be.accessible();
});
});

describe('API', () => {
beforeEach(async () => {
highlight = await fixture(createHighlight());
});

it('matches on changing `search` value', async () => {
expect(highlight.size).to.equal(0);

highlight.searchText = 'lorem';
await elementUpdated(highlight);

expect(highlight.size).to.equal(1);

highlight.searchText = '';
await elementUpdated(highlight);

expect(highlight.size).to.equal(0);
});

it('matches with case sensitivity', async () => {
highlight.caseSensitive = true;
highlight.searchText = 'lorem';
await elementUpdated(highlight);

expect(highlight.size).to.equal(0);

highlight.searchText = 'Lorem';
await elementUpdated(highlight);

expect(highlight.size).to.equal(1);
});

it('moves to the next match when `next()` is invoked', async () => {
highlight.searchText = 'e';
await elementUpdated(highlight);

expect(highlight.size).greaterThan(0);
expect(highlight.current).to.equal(0);

highlight.next();
expect(highlight.current).to.equal(1);
});

it('moves to the previous when `previous()` is invoked', async () => {
highlight.searchText = 'e';
await elementUpdated(highlight);

expect(highlight.size).greaterThan(0);
expect(highlight.current).to.equal(0);

// Wrap around to the last one
highlight.previous();
expect(highlight.current).to.equal(highlight.size - 1);
});

it('setActive called', async () => {
highlight.searchText = 'e';
await elementUpdated(highlight);

highlight.setActive(15);
expect(highlight.current).to.equal(15);
});

it('refresh called', async () => {
highlight.searchText = 'lorem';
await elementUpdated(highlight);

expect(highlight.size).to.equal(1);

const node = document.createElement('div');
node.textContent = 'Lorem '.repeat(9);

highlight.append(node);
highlight.search();

expect(highlight.size).to.equal(10);

node.remove();
highlight.search();

expect(highlight.size).to.equal(1);
});
});
});
116 changes: 116 additions & 0 deletions src/components/highlight/highlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { registerComponent } from '../common/definitions/register.js';
import {
createHighlightController,
type HighlightNavigation,
} from './service.js';

/**
* The highlight component provides a way for efficient searching and highlighting of
* text projected into it.
*
* @element igc-highlight
*
* @slot - The default slot of the component.
*
* @cssproperty --resting-color - The text color for a highlighted text node.
* @cssproperty --resting-background - The background color for a highlighted text node.
* @cssproperty --active-color - The text color for the active highlighted text node.
* @cssproperty --active-background - The background color for the active highlighted text node.
*/
export default class IgcHighlightComponent extends LitElement {
public static readonly tagName = 'igc-highlight';

public static override styles = css`
:host {
display: contents;
}
`;

/* blazorSuppress */
public static register(): void {
registerComponent(IgcHighlightComponent);
}

private readonly _service = createHighlightController(this);

private _caseSensitive = false;
private _searchText = '';

/**
* Whether to match the searched text with case sensitivity in mind.
* @attr case-sensitive
*/
@property({ type: Boolean, reflect: true, attribute: 'case-sensitive' })
public set caseSensitive(value: boolean) {
this._caseSensitive = value;
this.search();
}

public get caseSensitive(): boolean {
return this._caseSensitive;
}

/**
* The string to search and highlight in the DOM content of the component.
* @attr search-text
*/
@property({ attribute: 'search-text' })
public set searchText(value: string) {
this._searchText = value;
this.search();
}

public get searchText(): string {
return this._searchText;
}

/** The number of matches. */
public get size(): number {
return this._service.size;
}

/** The index of the currently active match. */
public get current(): number {
return this._service.current;
}

/** Moves the active state to the next match. */
public next(options?: HighlightNavigation): void {
this._service.next(options);
}

/** Moves the active state to the previous match. */
public previous(options?: HighlightNavigation): void {
this._service.previous(options);
}

/** Moves the active state to the given index. */
public setActive(index: number, options?: HighlightNavigation): void {
this._service.setActive(index, options);
}

/**
* Executes the highlight logic again based on the current `searchText` and
* `caseSensitive` values.
*
* Useful when the slotted content is dynamic.
*/
public search(): void {
if (this.hasUpdated) {
this._service.clear();
this._service.find(this.searchText);
}
}

protected override render() {
return html`<slot></slot>`;
}
}

declare global {
interface HTMLElementTagNameMap {
'igc-highlight': IgcHighlightComponent;
}
}
Loading