From 4977b2b5949f54b4c7e3e64a344e55d6bd6730da Mon Sep 17 00:00:00 2001 From: Blackbaud-AdamHickey Date: Thu, 29 Jun 2017 14:49:36 -0400 Subject: [PATCH] Text Highlight directive (#839) * Adding highlight directive and demo page. * Adding and replacing highlight style. * Use mark element. * Update demo. * Add test for directive. * Update test. * Update highlighter to be case insensitive. * Can't add mark text to innerhtml. * Update tests. * Use mutation observer for dom events. * Reformat test * Update async test. * Mock mutation observer service. * Add extra test. * Adding visual tests. * Add text highlight visual test. * Move mutation service class. * Update event creation in test. * Adding test for mutation observer class. Removing undefined check for IE10. * Disconnect mutation observer on destroy. Add exports. Fix name. --- .../src/app/home.component.ts | 1 + .../src/app/text-highlight/index.html | 1 + .../text-highlight-visual.component.html | 9 + .../text-highlight-visual.component.ts | 12 + .../text-highlight.visual-spec.ts | 35 +++ src/app/components/demo-components.service.ts | 22 ++ src/app/components/text-highlight/index.html | 18 ++ .../text-highlight-demo.component.html | 23 ++ .../text-highlight-demo.component.ts | 10 + src/core.ts | 6 + .../mutation-observer-service.spec.ts | 13 ++ .../mutation/mutation-observer-service.ts | 8 + .../text-highlight.component.fixture.html | 8 + .../text-highlight.component.fixture.ts | 11 + src/modules/text-highlight/index.ts | 2 + .../text-highlight.directive.spec.ts | 217 ++++++++++++++++++ .../text-highlight.directive.ts | 138 +++++++++++ .../text-highlight/text-highlight.module.ts | 16 ++ 18 files changed, 550 insertions(+) create mode 100644 skyux-spa-visual-tests/src/app/text-highlight/index.html create mode 100644 skyux-spa-visual-tests/src/app/text-highlight/text-highlight-visual.component.html create mode 100644 skyux-spa-visual-tests/src/app/text-highlight/text-highlight-visual.component.ts create mode 100644 skyux-spa-visual-tests/src/app/text-highlight/text-highlight.visual-spec.ts create mode 100644 src/app/components/text-highlight/index.html create mode 100644 src/app/components/text-highlight/text-highlight-demo.component.html create mode 100644 src/app/components/text-highlight/text-highlight-demo.component.ts create mode 100644 src/modules/mutation/mutation-observer-service.spec.ts create mode 100644 src/modules/mutation/mutation-observer-service.ts create mode 100644 src/modules/text-highlight/fixtures/text-highlight.component.fixture.html create mode 100644 src/modules/text-highlight/fixtures/text-highlight.component.fixture.ts create mode 100644 src/modules/text-highlight/index.ts create mode 100644 src/modules/text-highlight/text-highlight.directive.spec.ts create mode 100644 src/modules/text-highlight/text-highlight.directive.ts create mode 100644 src/modules/text-highlight/text-highlight.module.ts diff --git a/skyux-spa-visual-tests/src/app/home.component.ts b/skyux-spa-visual-tests/src/app/home.component.ts index 03a77297c..7d94c9f89 100644 --- a/skyux-spa-visual-tests/src/app/home.component.ts +++ b/skyux-spa-visual-tests/src/app/home.component.ts @@ -36,6 +36,7 @@ export class HomeComponent { 'tabs', 'text-expand', 'text-expand-repeater', + 'text-highlight', 'tiles', 'toolbar', 'wait' diff --git a/skyux-spa-visual-tests/src/app/text-highlight/index.html b/skyux-spa-visual-tests/src/app/text-highlight/index.html new file mode 100644 index 000000000..8ae1903fe --- /dev/null +++ b/skyux-spa-visual-tests/src/app/text-highlight/index.html @@ -0,0 +1 @@ + diff --git a/skyux-spa-visual-tests/src/app/text-highlight/text-highlight-visual.component.html b/skyux-spa-visual-tests/src/app/text-highlight/text-highlight-visual.component.html new file mode 100644 index 000000000..4a71f76cd --- /dev/null +++ b/skyux-spa-visual-tests/src/app/text-highlight/text-highlight-visual.component.html @@ -0,0 +1,9 @@ +
+
The text that you enter is highlighted here.
+
+
+
The text that you enter is highlighted here.
+
+
+
The text that you enter is highlighted here.
+
\ No newline at end of file diff --git a/skyux-spa-visual-tests/src/app/text-highlight/text-highlight-visual.component.ts b/skyux-spa-visual-tests/src/app/text-highlight/text-highlight-visual.component.ts new file mode 100644 index 000000000..4bb8f109a --- /dev/null +++ b/skyux-spa-visual-tests/src/app/text-highlight/text-highlight-visual.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component} from '@angular/core'; + +@Component({ + selector: 'text-highlight-visual', + templateUrl: './text-highlight-visual.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TextHighlightVisualComponent { + public normalSearchTerm: string = 'enter'; + public blankSearchTerm: string = ''; + public notMatchedSearchTerm: string = 'xnotmatched'; +} diff --git a/skyux-spa-visual-tests/src/app/text-highlight/text-highlight.visual-spec.ts b/skyux-spa-visual-tests/src/app/text-highlight/text-highlight.visual-spec.ts new file mode 100644 index 000000000..66115073b --- /dev/null +++ b/skyux-spa-visual-tests/src/app/text-highlight/text-highlight.visual-spec.ts @@ -0,0 +1,35 @@ +import { SkyVisualTest } from '../../../config/utils/visual-test-commands'; +import { element, by } from 'protractor'; + +describe('TextHighlight', () => { + + it('should match previous text highlight screenshot', () => { + return SkyVisualTest.setupTest('text-highlight') + .then(() => { + return SkyVisualTest.compareScreenshot({ + screenshotName: 'text-highlight-normal-screenshot', + selector: '#text-highlight-normal' + }); + }); + }); + + it('should match previous text highlight screenshot when term is blank', () => { + return SkyVisualTest.setupTest('text-highlight') + .then(() => { + return SkyVisualTest.compareScreenshot({ + screenshotName: 'text-highlight-blank-screenshot', + selector: '#text-highlight-blank' + }); + }); + }); + + it('should match previous text highlight screenshot when term is not matched', () => { + return SkyVisualTest.setupTest('text-highlight') + .then(() => { + return SkyVisualTest.compareScreenshot({ + screenshotName: 'text-highlight-no-match-screenshot', + selector: '#text-highlight-no-match' + }); + }); + }); +}); diff --git a/src/app/components/demo-components.service.ts b/src/app/components/demo-components.service.ts index 6e7201bba..b05d5f32d 100644 --- a/src/app/components/demo-components.service.ts +++ b/src/app/components/demo-components.service.ts @@ -316,6 +316,28 @@ export class SkyDemoComponentsService { ]; } }, + { + name: 'Highlight', + icon: 'paint-brush', + summary: `The highlight component highlights text within DOM elements.`, + url: '/components/text-highlight', + getCodeFiles: function () { + return [ + { + name: 'text-highlight-demo.component.html', + // tslint:disable-next-line + fileContents: require('!!raw-loader!./text-highlight/text-highlight-demo.component.html') + }, + { + name: 'text-highlight-demo.component.ts', + // tslint:disable-next-line + fileContents: require('!!raw-loader!./text-highlight/text-highlight-demo.component.ts'), + componentName: 'SkyTextHighlightDemoComponent', + bootstrapSelector: 'sky-text-highlight-demo' + } + ]; + } + }, { name: 'Key info', icon: 'key', diff --git a/src/app/components/text-highlight/index.html b/src/app/components/text-highlight/index.html new file mode 100644 index 000000000..8b1de8cb8 --- /dev/null +++ b/src/app/components/text-highlight/index.html @@ -0,0 +1,18 @@ + + + The highlight directive allows you to highlight text within DOM elements. When you set the skyHighlight attribute to the text you want to highlight, it highlights all matching text within the element. + + + + + Specifies the text to highlight. + + + + + + + + diff --git a/src/app/components/text-highlight/text-highlight-demo.component.html b/src/app/components/text-highlight/text-highlight-demo.component.html new file mode 100644 index 000000000..ecf16102c --- /dev/null +++ b/src/app/components/text-highlight/text-highlight-demo.component.html @@ -0,0 +1,23 @@ +
+ +
+ +
+ +
+ + Display additional content + +
+ +
The text that you enter is highlighted here. +
+ This additional content is highlighted too! +
+
diff --git a/src/app/components/text-highlight/text-highlight-demo.component.ts b/src/app/components/text-highlight/text-highlight-demo.component.ts new file mode 100644 index 000000000..eb318d3ed --- /dev/null +++ b/src/app/components/text-highlight/text-highlight-demo.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'sky-text-highlight-demo', + templateUrl: './text-highlight-demo.component.html' +}) +export class SkyTextHighlightDemoComponent { + public searchTerm: string; + public showAdditionalContent: boolean = false; +} diff --git a/src/core.ts b/src/core.ts index 3009110d7..f6ba7f8af 100644 --- a/src/core.ts +++ b/src/core.ts @@ -40,6 +40,7 @@ import { SkySortModule } from './modules/sort'; import { SkyTabsModule } from './modules/tabs'; import { SkyTextExpandModule } from './modules/text-expand'; import { SkyTextExpandRepeaterModule } from './modules/text-expand-repeater'; +import { SkyTextHighlightModule } from './modules/text-highlight'; import { SkyToolbarModule } from './modules/toolbar'; import { SkyTilesModule } from './modules/tiles'; import { SkyTimepickerModule } from './modules/timepicker'; @@ -84,6 +85,7 @@ import { SkyWaitModule } from './modules/wait'; SkyTabsModule, SkyTextExpandModule, SkyTextExpandRepeaterModule, + SkyTextHighlightModule, SkyTilesModule, SkyTimepickerModule, SkyToolbarModule, @@ -356,6 +358,10 @@ export { SkyTextExpandRepeaterComponent, SkyTextExpandRepeaterModule } from './modules/text-expand-repeater'; +export { + SkyTextHighlightDirective, + SkyTextHighlightModule +} from './modules/text-highlight'; export { SkyTileDashboardService, SkyTileContentModule, diff --git a/src/modules/mutation/mutation-observer-service.spec.ts b/src/modules/mutation/mutation-observer-service.spec.ts new file mode 100644 index 000000000..05ded2dfb --- /dev/null +++ b/src/modules/mutation/mutation-observer-service.spec.ts @@ -0,0 +1,13 @@ +import { TestBed } from '@angular/core/testing'; +import { MutationObserverService } from './mutation-observer-service'; + +describe('Mutation observer service', () => { + + it('should return a new instance of a mutation observer', () => { + let service = new MutationObserverService(); + let observer = service.create((mutations: MutationRecord[]) => 0); + + expect(observer).not.toBe(undefined); + expect(observer).toEqual(jasmine.any(MutationObserver)); + }); +}); diff --git a/src/modules/mutation/mutation-observer-service.ts b/src/modules/mutation/mutation-observer-service.ts new file mode 100644 index 000000000..a715af833 --- /dev/null +++ b/src/modules/mutation/mutation-observer-service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class MutationObserverService { + public create(callback: any): MutationObserver { + return new MutationObserver(callback); + } +} diff --git a/src/modules/text-highlight/fixtures/text-highlight.component.fixture.html b/src/modules/text-highlight/fixtures/text-highlight.component.fixture.html new file mode 100644 index 000000000..29eb67e27 --- /dev/null +++ b/src/modules/text-highlight/fixtures/text-highlight.component.fixture.html @@ -0,0 +1,8 @@ + + + +
Here is some test text. +
+ Here is additional text that was previously hidden. +
+
diff --git a/src/modules/text-highlight/fixtures/text-highlight.component.fixture.ts b/src/modules/text-highlight/fixtures/text-highlight.component.fixture.ts new file mode 100644 index 000000000..e102ef3de --- /dev/null +++ b/src/modules/text-highlight/fixtures/text-highlight.component.fixture.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'sky-text-highlight-component', + templateUrl: 'text-highlight.component.fixture.html' +}) + +export class SkyTextHighlightTestComponent { + public searchTerm: string; + public showAdditionalContent: boolean = false; +} diff --git a/src/modules/text-highlight/index.ts b/src/modules/text-highlight/index.ts new file mode 100644 index 000000000..c0c842957 --- /dev/null +++ b/src/modules/text-highlight/index.ts @@ -0,0 +1,2 @@ +export { SkyTextHighlightModule } from './text-highlight.module'; +export { SkyTextHighlightDirective } from './text-highlight.directive'; diff --git a/src/modules/text-highlight/text-highlight.directive.spec.ts b/src/modules/text-highlight/text-highlight.directive.spec.ts new file mode 100644 index 000000000..b5409ce05 --- /dev/null +++ b/src/modules/text-highlight/text-highlight.directive.spec.ts @@ -0,0 +1,217 @@ +import { + TestBed, + ComponentFixture +} from '@angular/core/testing'; + +import { FormsModule } from '@angular/forms'; + +import { SkyTextHighlightTestComponent } from './fixtures/text-highlight.component.fixture'; +import { SkyCheckboxModule } from '../checkbox/checkbox.module'; +import { SkyTextHighlightModule } from './text-highlight.module'; +import { MutationObserverService } from '../mutation/mutation-observer-service'; + +function updateInputText(fixture: ComponentFixture, text: string) { + let params = { + bubbles: false, + cancelable: false + }; + + let inputEvent = document.createEvent('Event'); + inputEvent.initEvent('input', params.bubbles, params.cancelable); + + const inputEl = fixture.nativeElement.querySelector('.sky-input-search-term') as HTMLInputElement; + inputEl.value = text; + inputEl.dispatchEvent(inputEvent); + fixture.detectChanges(); +} + +const additionalTextHidden = ` + `; + +const additionalTextVisible = ` + `; + +function getHtmlOutput(text: string) { + return `${text}${additionalTextHidden}`; +} + +function getHtmlOutputAdditionalText(text: string, additionalText: string) { + return `${text}${additionalTextVisible}
+ ${additionalText} +
`; +} + +describe('Text Highlight', () => { + + let fixture: ComponentFixture; + let component: SkyTextHighlightTestComponent; + let nativeElement: HTMLElement; + let callbacks: any[]; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + SkyTextHighlightTestComponent + ], + imports: [ + SkyCheckboxModule, + SkyTextHighlightModule, + FormsModule + ], + providers: [{ + provide: MutationObserverService, + useValue: { + create: function(callback: Function) { + callbacks.push(callback); + + return { + observe: () => {}, + disconnect: () => {} + }; + } + } + }] + }); + + callbacks = []; + fixture = TestBed.createComponent(SkyTextHighlightTestComponent); + nativeElement = fixture.nativeElement as HTMLElement; + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should not highlight any text when search term is blank', () => { + const containerEl = nativeElement.querySelector('.sky-test-div-container'); + const expectedHtml = getHtmlOutput('Here is some test text.'); + + expect(containerEl.innerHTML.trim()).toBe(expectedHtml); + }); + + it('should highlight search term', () => { + updateInputText(fixture, 'text'); + + const containerEl = nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + const expectedHtml = + getHtmlOutput('Here is some test text.'); + + expect(containerEl.innerHTML.trim()).toBe(expectedHtml); + }); + + it('should highlight case insensitive search term', () => { + updateInputText(fixture, 'here'); + + const containerEl = nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + const expectedHtml = + getHtmlOutput('Here is some test text.'); + + expect(containerEl.innerHTML.trim()).toBe(expectedHtml); + }); + + it('should highlight search term in nested component', () => { + component.showAdditionalContent = true; + fixture.detectChanges(); + + updateInputText(fixture, 'here'); + + const containerEl = nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + const text = 'Here is some test text.'; + // tslint:disable-next-line:max-line-length + const additional = 'Here is additional text that was previously hidden.'; + const expectedHtml = getHtmlOutputAdditionalText(text, additional); + + expect(containerEl.innerHTML.trim()).toBe(expectedHtml); + }); + + it('changed search term should highlight new term and old term should not highlight', () => { + updateInputText(fixture, 'some'); + + const containerEl = nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + const expectedHtml = + getHtmlOutput('Here is some test text.'); + + expect(containerEl.innerHTML.trim()).toBe(expectedHtml); + + updateInputText(fixture, 'Here'); + + const containerElChanged = + nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + const expectedHtmlChanged = + getHtmlOutput('Here is some test text.'); + + expect(containerElChanged.innerHTML.trim()).toBe(expectedHtmlChanged); + }); + + it('highlight search term of html that was previously hidden', () => { + component.showAdditionalContent = false; + fixture.detectChanges(); + + updateInputText(fixture, 'is'); + + const containerEl = nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + const expectedHtml = + getHtmlOutput('Here is some test text.'); + + expect(containerEl.innerHTML.trim()).toBe(expectedHtml); + + // check box to show extra content + const checkboxEl = + fixture.nativeElement.querySelector('.sky-test-checkbox') as HTMLInputElement; + + checkboxEl.click(); + fixture.detectChanges(); + + // mock the mutation observer callback on DOM change + callbacks[0](undefined); + + fixture.detectChanges(); + + const containerElUpdated = + nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + + const text = 'Here is some test text.'; + // tslint:disable-next-line:max-line-length + const additional = 'Here is additional text that was previously hidden.'; + const expectedHtmlChanged = getHtmlOutputAdditionalText(text, additional); + + expect(containerElUpdated.innerHTML.trim()).toBe(expectedHtmlChanged); + }); + + it('highlight hidden search term where only highlighted term was hidden', () => { + component.showAdditionalContent = false; + fixture.detectChanges(); + + updateInputText(fixture, 'additional'); + + const containerEl = nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + const expectedHtml = + getHtmlOutput('Here is some test text.'); + + expect(containerEl.innerHTML.trim()).toBe(expectedHtml); + + // check box to show extra content + const checkboxEl = + fixture.nativeElement.querySelector('.sky-test-checkbox') as HTMLInputElement; + + checkboxEl.click(); + fixture.detectChanges(); + + // mock the mutation observer callback on DOM change + callbacks[0](undefined); + + fixture.detectChanges(); + + const containerElUpdated = + nativeElement.querySelector('.sky-test-div-container') as HTMLElement; + + const text = 'Here is some test text.'; + // tslint:disable-next-line:max-line-length + const additional = 'Here is additional text that was previously hidden.'; + const expectedHtmlChanged = getHtmlOutputAdditionalText(text, additional); + + expect(containerElUpdated.innerHTML.trim()).toBe(expectedHtmlChanged); + }); +}); diff --git a/src/modules/text-highlight/text-highlight.directive.ts b/src/modules/text-highlight/text-highlight.directive.ts new file mode 100644 index 000000000..04aaab4f1 --- /dev/null +++ b/src/modules/text-highlight/text-highlight.directive.ts @@ -0,0 +1,138 @@ +import { + Directive, + SimpleChanges, + Input, + AfterViewInit, + OnChanges, + ElementRef, + OnDestroy +} from '@angular/core'; + +import { MutationObserverService } from '../mutation/mutation-observer-service'; + +const className = 'sky-highlight-mark'; + +@Directive({ + selector: '[skyHighlight]' +}) +export class SkyTextHighlightDirective implements OnChanges, AfterViewInit, OnDestroy { + + @Input() + public skyHighlight: string = undefined; + + private existingHighlight = false; + private observer: MutationObserver; + + private static getRegexMatch(node: HTMLElement, searchText: string): RegExpExecArray { + const text = node.nodeValue; + const searchRegex = new RegExp(searchText, 'gi'); + + return searchRegex.exec(text); + } + + private static markNode(node: any, searchText: string) { + const regexMatch = SkyTextHighlightDirective.getRegexMatch(node, searchText); + + // found match + if (regexMatch && regexMatch.length > 0) { + + // split apart text node with mark tags in the middle on the search term + const matchIndex = regexMatch.index; + + const middle = node.splitText(matchIndex); + middle.splitText(searchText.length); + const middleClone = middle.cloneNode(true); + + const markNode = document.createElement('mark'); + markNode.className = className; + markNode.appendChild(middleClone); + middle.parentNode.replaceChild(markNode, middle); + + return 1; + } else { + return 0; + } + } + + private static markTextNodes(node: HTMLElement, searchText: string) { + if (node.nodeType === 3) { + return SkyTextHighlightDirective.markNode(node, searchText); + + } else if (node.nodeType === 1 && node.childNodes) { + for (let i = 0; i < node.childNodes.length; i++) { + let childNode = node.childNodes[i] as HTMLElement; + i += SkyTextHighlightDirective.markTextNodes(childNode, searchText); + } + } + + return 0; + } + + private static removeHighlight(el: ElementRef) { + const matchedElements = + el.nativeElement.querySelectorAll(`mark.${className}`) as NodeList; + + if (matchedElements) { + for (let i = 0; i < matchedElements.length; i++) { + const node = matchedElements[i]; + const parentNode = node.parentNode; + + parentNode.replaceChild(node.firstChild, node); + parentNode.normalize(); + } + } + } + + constructor(private el: ElementRef, private observerService: MutationObserverService) { } + + public ngOnChanges(changes: SimpleChanges): void { + this.highlight(); + } + + public ngAfterViewInit(): void { + let me = this; + + this.observer = this.observerService.create((mutations: MutationRecord[]) => { + me.highlight(); + }); + + this.observeDom(); + } + + public ngOnDestroy(): void { + this.observer.disconnect(); + } + + private readyForHighlight(searchText: string): boolean { + return searchText && this.el.nativeElement; + } + + private highlight(): void { + if (this.observer) { + this.observer.disconnect(); + } + + const searchText = this.skyHighlight; + + if (this.existingHighlight) { + SkyTextHighlightDirective.removeHighlight(this.el); + } + + if (this.readyForHighlight(searchText)) { + const node: HTMLElement = this.el.nativeElement; + + // mark all matched text in the DOM + SkyTextHighlightDirective.markTextNodes(node, searchText); + this.existingHighlight = true; + } + + this.observeDom(); + } + + private observeDom() { + if (this.observer) { + const config = { attributes: true, childList: true, characterData: true }; + this.observer.observe(this.el.nativeElement, config); + } + } +} diff --git a/src/modules/text-highlight/text-highlight.module.ts b/src/modules/text-highlight/text-highlight.module.ts new file mode 100644 index 000000000..dd011696d --- /dev/null +++ b/src/modules/text-highlight/text-highlight.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { SkyTextHighlightDirective } from './text-highlight.directive'; +import { MutationObserverService } from '../mutation/mutation-observer-service'; + +@NgModule({ + declarations: [ + SkyTextHighlightDirective + ], + exports: [ + SkyTextHighlightDirective + ], + providers: [ + MutationObserverService + ] +}) +export class SkyTextHighlightModule { }