diff --git a/projects/demo/src/modules/directives/highlight/examples/1/index.html b/projects/demo/src/modules/directives/highlight/examples/1/index.html index b87ce09c9912c..7a6249d1bb1f6 100644 --- a/projects/demo/src/modules/directives/highlight/examples/1/index.html +++ b/projects/demo/src/modules/directives/highlight/examples/1/index.html @@ -4,7 +4,11 @@ > Search - +
@@ -14,11 +18,7 @@ - diff --git a/projects/demo/src/modules/directives/highlight/examples/2/index.html b/projects/demo/src/modules/directives/highlight/examples/2/index.html new file mode 100644 index 0000000000000..b4c8a665c9366 --- /dev/null +++ b/projects/demo/src/modules/directives/highlight/examples/2/index.html @@ -0,0 +1,27 @@ + + Search + +
Member
+ {{ cell }}
+ + + + + + + + + + + + +
MemberNicknameFate
+ {{ cell }} +
diff --git a/projects/demo/src/modules/directives/highlight/examples/2/index.less b/projects/demo/src/modules/directives/highlight/examples/2/index.less new file mode 100644 index 0000000000000..d8b678d81babb --- /dev/null +++ b/projects/demo/src/modules/directives/highlight/examples/2/index.less @@ -0,0 +1,17 @@ +:host { + display: block; +} + +table { + width: 100%; + border-spacing: 0; +} + +th, +td { + text-align: left; + border: 1px solid var(--tui-base-03); + height: 3.375rem; + padding: 0 1rem; + vertical-align: middle; +} diff --git a/projects/demo/src/modules/directives/highlight/examples/2/index.ts b/projects/demo/src/modules/directives/highlight/examples/2/index.ts new file mode 100644 index 0000000000000..c603eea08238d --- /dev/null +++ b/projects/demo/src/modules/directives/highlight/examples/2/index.ts @@ -0,0 +1,22 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; + +@Component({ + selector: 'tui-highlight-example-2', + templateUrl: './index.html', + styleUrls: ['./index.less'], + changeDetection, + encapsulation, +}) +export class TuiHighlightExample2 { + search = ''; + + readonly rows = [ + ['King Arthur', '-', 'Arrested'], + ['Sir Bedevere', 'The Wise', 'Arrested'], + ['Sir Lancelot', 'The Brave', 'Arrested'], + ['Sir Galahad', 'The Chaste', 'Killed'], + ['Sir Robin', 'The Not-Quite-So-Brave-As-Sir-Lancelot', 'Killed'], + ]; +} diff --git a/projects/demo/src/modules/directives/highlight/examples/3/index.html b/projects/demo/src/modules/directives/highlight/examples/3/index.html new file mode 100644 index 0000000000000..c61cd34b68eb4 --- /dev/null +++ b/projects/demo/src/modules/directives/highlight/examples/3/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + +
MemberNicknameFate
+ {{ cell }} +
diff --git a/projects/demo/src/modules/directives/highlight/examples/3/index.less b/projects/demo/src/modules/directives/highlight/examples/3/index.less new file mode 100644 index 0000000000000..d8b678d81babb --- /dev/null +++ b/projects/demo/src/modules/directives/highlight/examples/3/index.less @@ -0,0 +1,17 @@ +:host { + display: block; +} + +table { + width: 100%; + border-spacing: 0; +} + +th, +td { + text-align: left; + border: 1px solid var(--tui-base-03); + height: 3.375rem; + padding: 0 1rem; + vertical-align: middle; +} diff --git a/projects/demo/src/modules/directives/highlight/examples/3/index.ts b/projects/demo/src/modules/directives/highlight/examples/3/index.ts new file mode 100644 index 0000000000000..0904eb934d11e --- /dev/null +++ b/projects/demo/src/modules/directives/highlight/examples/3/index.ts @@ -0,0 +1,22 @@ +import {Component} from '@angular/core'; +import {changeDetection} from '@demo/emulate/change-detection'; +import {encapsulation} from '@demo/emulate/encapsulation'; + +@Component({ + selector: 'tui-highlight-example-3', + templateUrl: './index.html', + styleUrls: ['./index.less'], + changeDetection, + encapsulation, +}) +export class TuiHighlightExample3 { + readonly rows = [ + ['King Arthur', '-', 'Arrested'], + ['Sir Bedevere', 'The Wise', 'Arrested'], + ['Sir Lancelot', 'The Brave', 'Arrested'], + ['Sir Galahad', 'The Chaste', 'Killed'], + ['Sir Robin', 'The Not-Quite-So-Brave-As-Sir-Lancelot', 'Killed'], + ]; + + readonly regexp = [/S[a-z]+/g, /A[a-z]+/g]; +} diff --git a/projects/demo/src/modules/directives/highlight/highlight.component.ts b/projects/demo/src/modules/directives/highlight/highlight.component.ts index b9d3862048034..1ab906fbebb51 100644 --- a/projects/demo/src/modules/directives/highlight/highlight.component.ts +++ b/projects/demo/src/modules/directives/highlight/highlight.component.ts @@ -15,4 +15,14 @@ export class ExampleTuiHighlightComponent { TypeScript: import('./examples/1/index.ts?raw'), HTML: import('./examples/1/index.html?raw'), }; + + readonly example2: TuiDocExample = { + TypeScript: import('./examples/2/index.ts?raw'), + HTML: import('./examples/2/index.html?raw'), + }; + + readonly example3: TuiDocExample = { + TypeScript: import('./examples/3/index.ts?raw'), + HTML: import('./examples/3/index.html?raw'), + }; } diff --git a/projects/demo/src/modules/directives/highlight/highlight.module.ts b/projects/demo/src/modules/directives/highlight/highlight.module.ts index 2eb7d6c0b168b..ee0156c1c8307 100644 --- a/projects/demo/src/modules/directives/highlight/highlight.module.ts +++ b/projects/demo/src/modules/directives/highlight/highlight.module.ts @@ -7,6 +7,8 @@ import {TuiTextfieldControllerModule} from '@taiga-ui/core'; import {TuiHighlightModule, TuiInputModule} from '@taiga-ui/kit'; import {TuiHighlightExample1} from './examples/1'; +import {TuiHighlightExample2} from './examples/2'; +import {TuiHighlightExample3} from './examples/3'; import {ExampleTuiHighlightComponent} from './highlight.component'; @NgModule({ @@ -19,7 +21,12 @@ import {ExampleTuiHighlightComponent} from './highlight.component'; RouterModule.forChild(tuiGenerateRoutes(ExampleTuiHighlightComponent)), TuiTextfieldControllerModule, ], - declarations: [ExampleTuiHighlightComponent, TuiHighlightExample1], + declarations: [ + ExampleTuiHighlightComponent, + TuiHighlightExample1, + TuiHighlightExample2, + TuiHighlightExample3, + ], exports: [ExampleTuiHighlightComponent], }) export class ExampleTuiHighlightModule {} diff --git a/projects/demo/src/modules/directives/highlight/highlight.template.html b/projects/demo/src/modules/directives/highlight/highlight.template.html index fb46f8ce9d2c1..7718c558417a6 100644 --- a/projects/demo/src/modules/directives/highlight/highlight.template.html +++ b/projects/demo/src/modules/directives/highlight/highlight.template.html @@ -7,12 +7,28 @@

Directive is used to highlight text in element

+ + + + + + + + diff --git a/projects/kit/directives/highlight/highlight.component.ts b/projects/kit/directives/highlight/highlight.component.ts new file mode 100644 index 0000000000000..253ac82df7386 --- /dev/null +++ b/projects/kit/directives/highlight/highlight.component.ts @@ -0,0 +1,21 @@ +import {ChangeDetectionStrategy, Component, HostBinding} from '@angular/core'; + +@Component({ + selector: 'tui-highlight', + template: '', + styleUrls: ['./highlight.style.less'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TuiHighlightComponent { + @HostBinding('style.left.px') + left = NaN; + + @HostBinding('style.top.px') + top = NaN; + + @HostBinding('style.width.px') + width = NaN; + + @HostBinding('style.height.px') + height = NaN; +} diff --git a/projects/kit/directives/highlight/highlight.directive.ts b/projects/kit/directives/highlight/highlight.directive.ts index 06c5f76e31cd3..c042775ce8c8c 100644 --- a/projects/kit/directives/highlight/highlight.directive.ts +++ b/projects/kit/directives/highlight/highlight.directive.ts @@ -1,7 +1,38 @@ import {DOCUMENT} from '@angular/common'; -import {Directive, ElementRef, Inject, Input, OnChanges, Renderer2} from '@angular/core'; -import {svgNodeFilter, TuiDestroyService, tuiPx, TuiResizeService} from '@taiga-ui/cdk'; -import {Observable} from 'rxjs'; +import { + ComponentFactory, + ComponentFactoryResolver, + Directive, + ElementRef, + HostBinding, + Inject, + Input, + OnChanges, + Self, + ViewContainerRef, +} from '@angular/core'; +import { + svgNodeFilter, + TuiDestroyService, + tuiIsNumber, + tuiIsString, + TuiResizeService, +} from '@taiga-ui/cdk'; +import {Observable, Subject} from 'rxjs'; +import {mergeAll, switchMap, takeUntil} from 'rxjs/operators'; + +import {TuiHighlightComponent} from './highlight.component'; + +type TuiArrayOrValue = T | readonly T[]; + +interface TuiHighlightOccurrence { + index: number; + length: number; +} + +function tuiToArray(value: T | readonly T[]): readonly T[] { + return Array.isArray(value) ? value : [value]; +} @Directive({ selector: '[tuiHighlight]', @@ -12,90 +43,162 @@ import {Observable} from 'rxjs'; providers: [TuiDestroyService, TuiResizeService], }) export class TuiHighlightDirective implements OnChanges { - private readonly highlight: HTMLElement = this.setUpHighlight(); - private readonly treeWalker = this.doc.createTreeWalker( this.el.nativeElement, NodeFilter.SHOW_TEXT, svgNodeFilter, ); + private readonly clearHighlights$ = new Subject(); + + private readonly addHighlight$ = new Subject>(); + + private readonly cf: ComponentFactory; + + @Input() + tuiHighlight: TuiArrayOrValue = ''; + + /** + * @deprecated Use --tui-highlight-color instead. Remove in 4.0. + */ @Input() - tuiHighlight = ''; + @HostBinding('style.--tui-highlight-color') + tuiHighlightColor?: string; @Input() - tuiHighlightColor = 'var(--tui-selection)'; + tuiHighlightMultiOccurrences = false; + + @Input() + tuiHighlightCaseSensitive = false; constructor( @Inject(DOCUMENT) private readonly doc: Document, @Inject(ElementRef) private readonly el: ElementRef, - @Inject(Renderer2) private readonly renderer: Renderer2, @Inject(TuiResizeService) resize$: Observable, + @Self() @Inject(TuiDestroyService) destroy$: Observable, + @Inject(ViewContainerRef) private readonly vcr: ViewContainerRef, + @Inject(ComponentFactoryResolver) cfr: ComponentFactoryResolver, ) { resize$.subscribe(() => { - this.updateStyles(); + this.updateHighlights(); }); + + this.clearHighlights$ + .pipe( + switchMap(() => this.addHighlight$.pipe(mergeAll())), + takeUntil(destroy$), + ) + .subscribe(); + + this.cf = cfr.resolveComponentFactory(TuiHighlightComponent); } get match(): boolean { - return this.indexOf(this.el.nativeElement.textContent) !== -1; + const [occurrence] = this.getOccurrences(this.el.nativeElement.textContent); + + return Boolean(occurrence); } ngOnChanges(): void { - this.updateStyles(); + this.updateHighlights(); } - private updateStyles(): void { - this.highlight.style.display = 'none'; + private updateHighlights(): void { + this.clearHighlights$.next(); if (!this.match) { return; } - this.treeWalker.currentNode = this.el.nativeElement; + const hostRect = this.el.nativeElement.getBoundingClientRect(); - do { - const index = this.indexOf(this.treeWalker.currentNode.nodeValue); + for (const range of this.getRanges()) { + this.addHighlight$.next( + this.createHighlight(hostRect, range.getBoundingClientRect()), + ); - if (index === -1) { - continue; + if (!this.tuiHighlightMultiOccurrences) { + return; } + } + } - const range = this.doc.createRange(); + private createHighlight( + hostRect: DOMRect, + {left, top, width, height}: DOMRect, + ): Observable { + return new Observable(() => { + const ref = this.vcr.createComponent(this.cf); + const {instance} = ref; - range.setStart(this.treeWalker.currentNode, index); - range.setEnd(this.treeWalker.currentNode, index + this.tuiHighlight.length); + instance.left = left - hostRect.left; + instance.top = top - hostRect.top; + instance.width = width; + instance.height = height; - const hostRect = this.el.nativeElement.getBoundingClientRect(); - const {left, top, width, height} = range.getBoundingClientRect(); - const {style} = this.highlight; + this.el.nativeElement.appendChild(ref.location.nativeElement); - style.background = this.tuiHighlightColor; - style.left = tuiPx(left - hostRect.left); - style.top = tuiPx(top - hostRect.top); - style.width = tuiPx(width); - style.height = tuiPx(height); - style.display = 'block'; + ref.changeDetectorRef.detectChanges(); - return; - } while (this.treeWalker.nextNode()); + return () => ref.destroy(); + }); } - private indexOf(source: string | null): number { - return !source || !this.tuiHighlight - ? -1 - : source.toLowerCase().indexOf(this.tuiHighlight.toLowerCase()); + private *getOccurrences(source: string | null): Generator { + if (!source || !this.tuiHighlight) { + return; + } + + for (const item of tuiToArray(this.tuiHighlight)) { + if (tuiIsString(item)) { + const itemValue = this.tuiHighlightCaseSensitive + ? item + : item.toLowerCase(); + const sourceValue = this.tuiHighlightCaseSensitive + ? source + : source.toLowerCase(); + + for ( + let index = sourceValue.indexOf(itemValue); + index >= 0; + index = sourceValue.indexOf(itemValue, index + 1) + ) { + yield { + index, + length: itemValue.length, + }; + } + } else { + for (const match of source.matchAll(item)) { + if (tuiIsNumber(match.index) && match.length) { + yield { + index: match.index, + length: match[0].length, + }; + } + } + } + } } - private setUpHighlight(): HTMLElement { - const highlight = this.renderer.createElement('div'); - const {style} = highlight; + private *getRanges(): Generator { + for (const node of this.getNodes()) { + for (const {index, length} of this.getOccurrences(node.nodeValue)) { + const range = this.doc.createRange(); - style.background = this.tuiHighlightColor; - style.zIndex = '-1'; - style.position = 'absolute'; - this.renderer.appendChild(this.el.nativeElement, highlight); + range.setStart(node, index); + range.setEnd(node, index + length); - return highlight; + yield range; + } + } + } + + private *getNodes(): Generator { + this.treeWalker.currentNode = this.el.nativeElement; + + do { + yield this.treeWalker.currentNode; + } while (this.treeWalker.nextNode()); } } diff --git a/projects/kit/directives/highlight/highlight.module.ts b/projects/kit/directives/highlight/highlight.module.ts index ed92aba0ef2e2..f4fccbde50599 100644 --- a/projects/kit/directives/highlight/highlight.module.ts +++ b/projects/kit/directives/highlight/highlight.module.ts @@ -1,9 +1,10 @@ import {NgModule} from '@angular/core'; +import {TuiHighlightComponent} from './highlight.component'; import {TuiHighlightDirective} from './highlight.directive'; @NgModule({ - declarations: [TuiHighlightDirective], + declarations: [TuiHighlightDirective, TuiHighlightComponent], exports: [TuiHighlightDirective], }) export class TuiHighlightModule {} diff --git a/projects/kit/directives/highlight/highlight.style.less b/projects/kit/directives/highlight/highlight.style.less new file mode 100644 index 0000000000000..e82df974b8515 --- /dev/null +++ b/projects/kit/directives/highlight/highlight.style.less @@ -0,0 +1,7 @@ +:host { + position: absolute; + z-index: -1; + display: block; + background: var(--tui-highlight-color, var(--tui-selection)); + border-radius: var(--tui-highlight-radius, 0); +} diff --git a/projects/kit/directives/highlight/test/highlight.directive.spec.ts b/projects/kit/directives/highlight/test/highlight.directive.spec.ts index 04469535f3731..eb65816195816 100644 --- a/projects/kit/directives/highlight/test/highlight.directive.spec.ts +++ b/projects/kit/directives/highlight/test/highlight.directive.spec.ts @@ -3,7 +3,7 @@ import {TestBed} from '@angular/core/testing'; import {TuiHighlightModule} from '@taiga-ui/kit'; import {configureTestSuite} from '@taiga-ui/testing'; -describe(`TuiHighlight directive`, () => { +describe(`TuiHighlight directive in single occurrence mode`, () => { @Component({ template: `
{ }); it(`Highlight is shown`, () => { - const element = document.querySelector(`#ica`)?.firstElementChild as HTMLElement; + const element = document + .querySelector(`#ica`) + ?.querySelector(`tui-highlight`) as HTMLElement | null; - expect(element.style.display).toBe(`block`); + expect(element).toBeTruthy(); }); it(`Highlight is not shown`, () => { - const element = document.querySelector(`#dong`)?.firstElementChild as HTMLElement; + const element = document + .querySelector(`#dong`) + ?.querySelector(`tui-highlight`) as HTMLElement | null; - expect(element.style.display).toBe(`none`); + expect(element).toBeFalsy(); }); it(`Highlight color is yellow`, () => { - const element = document.querySelector(`#aaa`)?.firstElementChild as HTMLElement; + const element = document.querySelector(`#aaa`) as HTMLElement; - expect(element.style.background).toBe(`yellow`); + expect(element.style.getPropertyValue(`--tui-highlight-color`)).toBe(`yellow`); + }); +}); + +describe(`TuiHighlight directive in multi occurrence mode`, () => { + @Component({ + template: ` +
+ HAPICA HAPICA HAPICA +
+ `, + }) + class TestComponent {} + + configureTestSuite(() => { + TestBed.configureTestingModule({ + imports: [TuiHighlightModule], + declarations: [TestComponent], + }); + }); + + beforeEach(() => { + const fixture = TestBed.createComponent(TestComponent); + + fixture.detectChanges(); + }); + + it(`Highlights is shown`, () => { + const elements = Array.from( + document + .querySelector(`#ica`) + ?.querySelectorAll(`tui-highlight`) as ArrayLike, + ); + + expect(elements.length).toBe(3); + }); +}); + +describe(`TuiHighlight directive in case sensitive mode`, () => { + @Component({ + template: ` +
+ HAPICA +
+
+ HAPICA +
+ `, + }) + class TestComponent {} + + configureTestSuite(() => { + TestBed.configureTestingModule({ + imports: [TuiHighlightModule], + declarations: [TestComponent], + }); + }); + + beforeEach(() => { + const fixture = TestBed.createComponent(TestComponent); + + fixture.detectChanges(); + }); + + it(`Highlights is not shown`, () => { + const elements = Array.from( + document + .querySelector(`#f`) + ?.querySelectorAll(`tui-highlight`) as ArrayLike, + ); + + expect(elements.length).toBe(0); + }); + + it(`Highlights is shown`, () => { + const elements = Array.from( + document + .querySelector(`#s`) + ?.querySelectorAll(`tui-highlight`) as ArrayLike, + ); + + expect(elements.length).toBe(1); }); });