From defbc03a623feeaddd664d30b54a9b7f9f3dcdba Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 20 Oct 2023 15:34:35 +0200 Subject: [PATCH 1/7] feat(wc): implement a general cdk overlay for all web components This will work with any webcomponent by creating an overlay at the root of each webcomponent Changed the name of the custom OverlayContainer for clarity --- .../src/app/AppOverlayContainer.ts | 41 ------------------- .../src/app/components/base.component.ts | 17 +++++++- .../gn-search-input.component.html | 4 +- .../src/app/webcomponent-overlay-container.ts | 32 +++++++++++++++ .../src/app/webcomponents.module.ts | 14 ++----- apps/webcomponents/src/styles.css | 4 +- 6 files changed, 55 insertions(+), 57 deletions(-) delete mode 100644 apps/webcomponents/src/app/AppOverlayContainer.ts create mode 100644 apps/webcomponents/src/app/webcomponent-overlay-container.ts diff --git a/apps/webcomponents/src/app/AppOverlayContainer.ts b/apps/webcomponents/src/app/AppOverlayContainer.ts deleted file mode 100644 index 73c6f3249f..0000000000 --- a/apps/webcomponents/src/app/AppOverlayContainer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { OverlayContainer } from '@angular/cdk/overlay' -import { Platform } from '@angular/cdk/platform' -import { DOCUMENT } from '@angular/common' -import { Inject, Injectable, OnDestroy } from '@angular/core' - -@Injectable() -export class AppOverlayContainer extends OverlayContainer implements OnDestroy { - private selector: string - - constructor( - @Inject(DOCUMENT) private document: Document, - platform: Platform - ) { - super(document, platform) - } - - setSelector(selector: string) { - this.selector = selector - } - ngOnDestroy() { - super.ngOnDestroy() - } - - protected _createContainer(): void { - const container: HTMLDivElement = this.document.createElement('div') - container.classList.add('app-overlay-container') - const element: Element | null = this.document - .querySelector(this.selector) - .shadowRoot.querySelector('#angular-app-root') - if (element !== null) { - element.appendChild(container) - this._containerElement = container - } else { - console.error( - 'Material CDK Overlay creation failed ! ' + - 'It can work only with gn-search-input webcomponent. ' + - 'You have to add an element with id="angular-app-root" (in the shadowDOM) to which the overlay will be appended.' - ) - } - } -} diff --git a/apps/webcomponents/src/app/components/base.component.ts b/apps/webcomponents/src/app/components/base.component.ts index 0c3a3b63e7..f31461387a 100644 --- a/apps/webcomponents/src/app/components/base.component.ts +++ b/apps/webcomponents/src/app/components/base.component.ts @@ -1,4 +1,11 @@ -import { Component, Injector, Input, OnChanges, OnInit } from '@angular/core' +import { + Component, + ElementRef, + Injector, + Input, + OnChanges, + OnInit, +} from '@angular/core' import { LinkClassifierService, LinkUsage, @@ -10,6 +17,8 @@ import { TranslateService } from '@ngx-translate/core' import { firstValueFrom } from 'rxjs' import { DatasetDistribution } from '@geonetwork-ui/common/domain/record' import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/records-repository.interface' +import { OverlayContainer } from '@angular/cdk/overlay' +import { WebcomponentOverlayContainer } from '../webcomponent-overlay-container' export const apiConfiguration = new Configuration() @@ -40,6 +49,12 @@ export class BaseComponent implements OnChanges, OnInit { this.searchService = injector.get(SearchApiService) this.recordsRepository = injector.get(RecordsRepositoryInterface) this.linkClassifier = injector.get(LinkClassifierService) + + const elementRef = injector.get(ElementRef) + const overlayContainer = injector.get( + OverlayContainer + ) as WebcomponentOverlayContainer + overlayContainer.setRoot(elementRef.nativeElement.shadowRoot) } ngOnInit() { diff --git a/apps/webcomponents/src/app/components/gn-search-input/gn-search-input.component.html b/apps/webcomponents/src/app/components/gn-search-input/gn-search-input.component.html index 4abf44b912..1b159ba380 100644 --- a/apps/webcomponents/src/app/components/gn-search-input/gn-search-input.component.html +++ b/apps/webcomponents/src/app/components/gn-search-input/gn-search-input.component.html @@ -1,3 +1 @@ -
- -
+ diff --git a/apps/webcomponents/src/app/webcomponent-overlay-container.ts b/apps/webcomponents/src/app/webcomponent-overlay-container.ts new file mode 100644 index 0000000000..6a2415c7ac --- /dev/null +++ b/apps/webcomponents/src/app/webcomponent-overlay-container.ts @@ -0,0 +1,32 @@ +import { OverlayContainer } from '@angular/cdk/overlay' +import { Platform } from '@angular/cdk/platform' +import { DOCUMENT } from '@angular/common' +import { Inject, Injectable } from '@angular/core' + +@Injectable() +export class WebcomponentOverlayContainer extends OverlayContainer { + private componentRoot: HTMLElement + + constructor( + @Inject(DOCUMENT) private document: Document, + platform: Platform + ) { + super(document, platform) + } + + setRoot(componentRoot: HTMLElement) { + this.componentRoot = componentRoot + } + + protected _createContainer(): void { + const container: HTMLDivElement = this.document.createElement('div') + container.classList.add('gn-ui-overlay-container') + if (!this.componentRoot) { + throw new Error( + 'Angular CDK OverlayContainer was used without proper initialization.' + ) + } + this.componentRoot.appendChild(container) + this._containerElement = container + } +} diff --git a/apps/webcomponents/src/app/webcomponents.module.ts b/apps/webcomponents/src/app/webcomponents.module.ts index 452151d851..fa0f1cf47e 100644 --- a/apps/webcomponents/src/app/webcomponents.module.ts +++ b/apps/webcomponents/src/app/webcomponents.module.ts @@ -1,6 +1,5 @@ import { OverlayContainer } from '@angular/cdk/overlay' -import { Platform } from '@angular/cdk/platform' -import { CommonModule, DOCUMENT } from '@angular/common' +import { CommonModule } from '@angular/common' import { CUSTOM_ELEMENTS_SCHEMA, Injector, NgModule } from '@angular/core' import { createCustomElement } from '@angular/elements' import { MatIconModule } from '@angular/material/icon' @@ -12,6 +11,7 @@ import { UiElementsModule } from '@geonetwork-ui/ui/elements' import { UiInputsModule } from '@geonetwork-ui/ui/inputs' import { UiSearchModule } from '@geonetwork-ui/ui/search' import { + EmbeddedTranslateLoader, TRANSLATE_DEFAULT_CONFIG, UtilI18nModule, } from '@geonetwork-ui/util/i18n' @@ -20,7 +20,7 @@ import { StoreModule } from '@ngrx/store' import { StoreDevtoolsModule } from '@ngrx/store-devtools' import { TranslateLoader, TranslateModule } from '@ngx-translate/core' import { AppComponent } from './app.component' -import { AppOverlayContainer } from './AppOverlayContainer' +import { WebcomponentOverlayContainer } from './webcomponent-overlay-container' import { apiConfiguration, BaseComponent } from './components/base.component' import { GnAggregatedRecordsComponent } from './components/gn-aggregated-records/gn-aggregated-records.component' import { GnFacetsComponent } from './components/gn-facets/gn-facets.component' @@ -31,7 +31,6 @@ import { GnMapViewerComponent } from './components/gn-map-viewer/gn-map-viewer.c import { FeatureMapModule } from '@geonetwork-ui/feature/map' import { GnDatasetViewChartComponent } from './components/gn-dataset-view-chart/gn-dataset-view-chart.component' import { FeatureDatavizModule } from '@geonetwork-ui/feature/dataviz' -import { EmbeddedTranslateLoader } from '@geonetwork-ui/util/i18n' import { FeatureAuthModule } from '@geonetwork-ui/feature/auth' import { BrowserAnimationsModule } from '@angular/platform-browser/animations' @@ -90,12 +89,7 @@ const CUSTOM_ELEMENTS: [new (...args) => BaseComponent, string][] = [ }, { provide: OverlayContainer, - useFactory: (document: Document, platform: Platform) => { - const container = new AppOverlayContainer(document, platform) - container.setSelector('gn-search-input') - return container - }, - deps: [DOCUMENT, Platform], + useClass: WebcomponentOverlayContainer, }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], diff --git a/apps/webcomponents/src/styles.css b/apps/webcomponents/src/styles.css index 4814c8c96c..e614fc4a34 100644 --- a/apps/webcomponents/src/styles.css +++ b/apps/webcomponents/src/styles.css @@ -51,7 +51,7 @@ min-width: 1px; min-height: 1px; } -.app-overlay-container { +.gn-ui-overlay-container { position: absolute; z-index: 1000; pointer-events: none; @@ -60,7 +60,7 @@ height: 100%; width: 100%; } -.app-overlay-container:empty { +.gn-ui-overlay-container:empty { display: none; } From 449147263cb6cef119300987d382f7758c19b646 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 20 Oct 2023 15:35:37 +0200 Subject: [PATCH 2/7] chore(wc): fix font url for material icons --- apps/webcomponents/src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webcomponents/src/styles.css b/apps/webcomponents/src/styles.css index e614fc4a34..2e69e7cea4 100644 --- a/apps/webcomponents/src/styles.css +++ b/apps/webcomponents/src/styles.css @@ -13,7 +13,7 @@ font-family: 'Material Symbols Outlined'; font-style: normal; font-weight: 400; - src: url(https://fonts.gstatic.com/s/materialiconsoutlined/v108/gok-H7zzDkdnRel8-DQ6KAXJ69wP1tGnf4ZGhUce.woff2) + src: url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v138/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsLjBuVY.woff2) format('woff2'); } .material-symbols-outlined { From 697802f9c176408efd43300f098a075c3f2b4fcb Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 20 Oct 2023 16:02:43 +0200 Subject: [PATCH 3/7] feat(inputs): use outsideClick instead of backdropClick to close overlays --- .../dropdown-multiselect/dropdown-multiselect.component.html | 2 +- .../src/lib/dropdown-selector/dropdown-selector.component.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.html b/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.html index c938fe3cc1..439d197378 100644 --- a/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.html +++ b/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.html @@ -43,7 +43,7 @@ [cdkConnectedOverlayPositions]="overlayPositions" [cdkConnectedOverlayScrollStrategy]="scrollStrategy" [cdkConnectedOverlayFlexibleDimensions]="true" - (backdropClick)="closeOverlay()" + (overlayOutsideClick)="closeOverlay()" (detach)="closeOverlay()" >
Date: Fri, 20 Oct 2023 20:02:01 +0200 Subject: [PATCH 4/7] feat(search): do not emit inputSubmitted if only clearing the search field --- .../fuzzy-search/fuzzy-search.component.html | 1 + .../fuzzy-search.component.spec.ts | 23 ++++++++++++++++--- .../fuzzy-search/fuzzy-search.component.ts | 4 ++++ .../autocomplete.component.spec.ts | 20 +++++++++------- .../autocomplete/autocomplete.component.ts | 3 ++- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.html b/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.html index ea7fcafe9e..0ce3113a7f 100644 --- a/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.html +++ b/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.html @@ -4,6 +4,7 @@ [action]="autoCompleteAction" (itemSelected)="handleItemSelection($event)" (inputSubmitted)="handleInputSubmission($event)" + (inputCleared)="handleInputCleared()" [value]="searchInputValue$ | async" [clearOnSelection]="true" > diff --git a/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.spec.ts b/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.spec.ts index 39328db8d9..b4f31ca72a 100644 --- a/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.spec.ts +++ b/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.spec.ts @@ -109,7 +109,6 @@ describe('FuzzySearchComponent', () => { }) describe('search enter key press', () => { - let outputValue describe('when no output defined', () => { beforeEach(() => { component.handleInputSubmission('blarg') @@ -123,8 +122,7 @@ describe('FuzzySearchComponent', () => { describe('when output is defined', () => { beforeEach(() => { jest.resetAllMocks() - outputValue = null - component.inputSubmitted.subscribe((event) => (outputValue = event)) + component.inputSubmitted.subscribe() jest.spyOn(component.inputSubmitted, 'emit') component.handleInputSubmission('blarg') }) @@ -139,6 +137,25 @@ describe('FuzzySearchComponent', () => { }) }) + describe('search input clear', () => { + describe('when output is defined', () => { + beforeEach(() => { + jest.resetAllMocks() + component.inputSubmitted.subscribe() + jest.spyOn(component.inputSubmitted, 'emit') + component.handleInputCleared() + }) + it('clears the search filters', () => { + expect(searchService.updateFilters).toHaveBeenCalledWith({ + any: '', + }) + }) + it('does not emit inputSubmitted', () => { + expect(component.inputSubmitted.emit).not.toHaveBeenCalled() + }) + }) + }) + describe('search suggestion selection', () => { describe('when no output defined', () => { beforeEach(() => { diff --git a/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.ts b/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.ts index b13e99b905..2e80a32aec 100644 --- a/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.ts +++ b/libs/feature/search/src/lib/fuzzy-search/fuzzy-search.component.ts @@ -72,4 +72,8 @@ export class FuzzySearchComponent implements OnInit { this.searchService.updateFilters({ any }) } } + + handleInputCleared() { + this.searchService.updateFilters({ any: '' }) + } } diff --git a/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.spec.ts b/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.spec.ts index be2eb5d4b1..7c11ac9daa 100644 --- a/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.spec.ts +++ b/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.spec.ts @@ -111,34 +111,38 @@ describe('AutocompleteComponent', () => { }) }) describe('when input is not empty', () => { - let anyEmitted + let anyEmitted: boolean + let clearEmitted: boolean let button beforeEach(() => { + anyEmitted = false + clearEmitted = false component.inputRef.nativeElement.value = 'blar' component.inputRef.nativeElement.dispatchEvent(new InputEvent('input')) component.triggerRef.closePanel = jest.fn() - component.inputSubmitted.subscribe((event) => (anyEmitted = event)) + component.inputSubmitted.subscribe(() => (anyEmitted = true)) + component.inputCleared.subscribe(() => (clearEmitted = true)) fixture.detectChanges() button = fixture.debugElement.query(By.css('.clear-btn')) + button.nativeElement.click() }) it('is visible', () => { expect(button).not.toBeNull() }) it('resets the text input', () => { - button.nativeElement.click() expect(component.inputRef.nativeElement.value).toBe('') }) it('sets the text input of the focus', () => { - button.nativeElement.click() expect(document.activeElement).toBe(component.inputRef.nativeElement) }) it('closes the autocomplete panel', () => { - button.nativeElement.click() expect(component.triggerRef.closePanel).toHaveBeenCalled() }) - it('clears search result by emitting empty string', () => { - button.nativeElement.click() - expect(anyEmitted).toEqual('') + it('does not emit an inputSubmitted event', () => { + expect(anyEmitted).toEqual(false) + }) + it('emits an inputCleared event', () => { + expect(clearEmitted).toEqual(true) }) }) }) diff --git a/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts b/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts index d8c9c25441..2442fe4c83 100644 --- a/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts +++ b/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts @@ -49,6 +49,7 @@ export class AutocompleteComponent @Input() clearOnSelection = false @Output() itemSelected = new EventEmitter() @Output() inputSubmitted = new EventEmitter() + @Output() inputCleared = new EventEmitter() @ViewChild(MatAutocompleteTrigger) triggerRef: MatAutocompleteTrigger @ViewChild(MatAutocomplete) autocomplete: MatAutocomplete @ViewChild('searchInput') inputRef: ElementRef @@ -126,7 +127,7 @@ export class AutocompleteComponent clear(): void { this.inputRef.nativeElement.value = '' - this.inputSubmitted.emit('') + this.inputCleared.emit() this.selectionSubject .pipe(take(1)) .subscribe((selection) => selection && selection.option.deselect()) From c3d7ceccac87ef3005e11d34516b1f71efe26796 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Fri, 20 Oct 2023 20:23:37 +0200 Subject: [PATCH 5/7] fix(wc): do not include icons font in samples; this shouldn't be needed --- .../gn-dataset-view-chart/gn-dataset-view-chart.sample.html | 4 ---- .../gn-dataset-view-table/gn-dataset-view-table.sample.html | 4 ---- .../src/app/components/gn-facets/gn-facets.sample.html | 4 ---- .../app/components/gn-map-viewer/gn-map-viewer.sample.html | 4 ---- .../gn-results-list/gn-results-list-multiple.sample.html | 4 ---- .../components/gn-results-list/gn-results-list.sample.html | 4 ---- .../gn-search-input/gn-search-input-and-results.sample.html | 4 ---- .../components/gn-search-input/gn-search-input.sample.html | 4 ---- 8 files changed, 32 deletions(-) diff --git a/apps/webcomponents/src/app/components/gn-dataset-view-chart/gn-dataset-view-chart.sample.html b/apps/webcomponents/src/app/components/gn-dataset-view-chart/gn-dataset-view-chart.sample.html index d875ef9b79..aa88dcf175 100644 --- a/apps/webcomponents/src/app/components/gn-dataset-view-chart/gn-dataset-view-chart.sample.html +++ b/apps/webcomponents/src/app/components/gn-dataset-view-chart/gn-dataset-view-chart.sample.html @@ -7,10 +7,6 @@ - - - - - - - - Date: Sat, 21 Oct 2023 15:24:18 +0200 Subject: [PATCH 6/7] feat(wc): copy fontface definitions to document on init this will make icons and fonts appear in web components --- .../src/app/components/base.component.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/webcomponents/src/app/components/base.component.ts b/apps/webcomponents/src/app/components/base.component.ts index f31461387a..59394945c8 100644 --- a/apps/webcomponents/src/app/components/base.component.ts +++ b/apps/webcomponents/src/app/components/base.component.ts @@ -83,6 +83,7 @@ export class BaseComponent implements OnChanges, OnInit { this.titleFont ) this.facade.init(this.searchId) + this.copyFontFacesToDocument() this.isInitialized = true } @@ -90,6 +91,27 @@ export class BaseComponent implements OnChanges, OnInit { // to override } + private copyFontFacesToDocument() { + // get the list of font face definitions in the Shadow DOM + const root = this.injector.get(ElementRef).nativeElement as HTMLElement + const styles = root.shadowRoot.styleSheets + const fontFaces = Array.from(styles).reduce( + (prev, curr) => [ + ...prev, + ...Array.from(curr.cssRules) + .filter((rule) => rule.cssText.startsWith('@font-face')) + .map((rule) => rule.cssText), + ], + [] + ) + + // all font faces are then copied to the document + const style = document.createElement('style') + const cssText = fontFaces.join('\n') + style.appendChild(document.createTextNode(cssText)) + document.head.appendChild(style) + } + async getRecordLink( uuid: string, usages: LinkUsage[] From 6291baa357503a0bb5335e1495931b3b08a25361 Mon Sep 17 00:00:00 2001 From: Olivia Guyot Date: Mon, 23 Oct 2023 18:50:35 +0200 Subject: [PATCH 7/7] feat(wc): extract css classes from the metariel theme for overlay backdrop This makes the overlay correctly close with WC as well --- apps/webcomponents/src/styles.css | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/webcomponents/src/styles.css b/apps/webcomponents/src/styles.css index 2e69e7cea4..799b8b6c3e 100644 --- a/apps/webcomponents/src/styles.css +++ b/apps/webcomponents/src/styles.css @@ -32,7 +32,7 @@ -webkit-font-smoothing: antialiased; } -/* Material Theme */ +/* These classes were extracted from the full Material theme to save size */ .cdk-overlay-pane { position: absolute; pointer-events: auto; @@ -42,7 +42,6 @@ max-width: 100%; max-height: 100%; } - .cdk-overlay-connected-position-bounding-box { position: absolute; z-index: 1000; @@ -51,6 +50,30 @@ min-width: 1px; min-height: 1px; } +.cdk-overlay-backdrop { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + pointer-events: auto; + transition: opacity 400ms cubic-bezier(0.25, 0.8, 0.25, 1); + opacity: 0; +} +.cdk-overlay-backdrop.cdk-overlay-backdrop-showing { + opacity: 1; +} +.cdk-overlay-transparent-backdrop { + transition: visibility 1ms linear, opacity 1ms linear; + visibility: hidden; + opacity: 1; +} +.cdk-overlay-transparent-backdrop.cdk-overlay-backdrop-showing { + opacity: 0; + visibility: visible; +} + .gn-ui-overlay-container { position: absolute; z-index: 1000;