From 27f1f27c152b24380eab0549a38b3db7a41b7648 Mon Sep 17 00:00:00 2001 From: MoritzRS Date: Thu, 11 Aug 2022 17:41:45 +0200 Subject: [PATCH] feat: add Back To Top button (#1180) * changed button styling to an icon button * fix e2e test concerning scroll behavior smooth Co-authored-by: Stefan Hauke Co-authored-by: Silke --- e2e/cypress/e2e/framework/index.ts | 4 +- e2e/cypress/support/commands.js | 6 ++ .../back-to-top/back-to-top.component.html | 9 +++ .../back-to-top/back-to-top.component.spec.ts | 31 ++++++++++ .../back-to-top/back-to-top.component.ts | 59 +++++++++++++++++++ .../shell/header/header/header.component.html | 2 + .../header/header/header.component.spec.ts | 10 +++- src/app/shell/shell.module.ts | 2 + src/assets/i18n/de_DE.json | 1 + src/assets/i18n/en_US.json | 1 + src/assets/i18n/fr_FR.json | 1 + src/styles/components/header/back-to-top.scss | 26 ++++++++ src/styles/main.scss | 1 + 13 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/app/shell/header/back-to-top/back-to-top.component.html create mode 100644 src/app/shell/header/back-to-top/back-to-top.component.spec.ts create mode 100644 src/app/shell/header/back-to-top/back-to-top.component.ts create mode 100644 src/styles/components/header/back-to-top.scss diff --git a/e2e/cypress/e2e/framework/index.ts b/e2e/cypress/e2e/framework/index.ts index 503903d197..bb7661c855 100644 --- a/e2e/cypress/e2e/framework/index.ts +++ b/e2e/cypress/e2e/framework/index.ts @@ -36,9 +36,9 @@ export function fillFormField(parent: string, key: string, value: number | strin cy.get(parent).within(() => { if (/^(INPUT|TEXTAREA)$/.test(tagName)) { const inputField = cy.get(`[data-testing-id="${key}"]`); - inputField.clear(); + inputField.focus().clear(); if (value) { - inputField.focus().type(value.toString()); + inputField.type(value.toString()); } } else if (tagName === 'SELECT') { if (typeof value === 'number') { diff --git a/e2e/cypress/support/commands.js b/e2e/cypress/support/commands.js index e8b197ac35..817aa199be 100644 --- a/e2e/cypress/support/commands.js +++ b/e2e/cypress/support/commands.js @@ -74,3 +74,9 @@ Cypress.on('uncaught:exception', (err, runnable) => { // failing the test return false; }); + +// mark the html tag with a Cypress specific CSS class for Cypress context specific styling +Cypress.on('window:before:load', win => { + const htmlNode = win.document.querySelector('html'); + htmlNode.classList.add('cypress-tests'); +}); diff --git a/src/app/shell/header/back-to-top/back-to-top.component.html b/src/app/shell/header/back-to-top/back-to-top.component.html new file mode 100644 index 0000000000..7a186c561d --- /dev/null +++ b/src/app/shell/header/back-to-top/back-to-top.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shell/header/back-to-top/back-to-top.component.spec.ts b/src/app/shell/header/back-to-top/back-to-top.component.spec.ts new file mode 100644 index 0000000000..cde737d35f --- /dev/null +++ b/src/app/shell/header/back-to-top/back-to-top.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; + +import { BackToTopComponent } from './back-to-top.component'; + +describe('Back To Top Component', () => { + let component: BackToTopComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [BackToTopComponent, MockComponent(FaIconComponent)], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BackToTopComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/shell/header/back-to-top/back-to-top.component.ts b/src/app/shell/header/back-to-top/back-to-top.component.ts new file mode 100644 index 0000000000..475f796274 --- /dev/null +++ b/src/app/shell/header/back-to-top/back-to-top.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, HostListener } from '@angular/core'; + +@Component({ + selector: 'ish-back-to-top', + templateUrl: './back-to-top.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BackToTopComponent { + /** + * @description + * Button will not show if window.scrollY is less than MARGIN_TOP. + * Should be any desired value > 0 + */ + private readonly MARGIN_TOP = 50; + + /** + * @description + * Button will show if user has scrolled at least SCROLL_MIN upwards. + * If set to 0 the button will show as soon as user scrolls upwards + */ + private readonly SCROLL_MIN = 50; + + isVisible = false; + private previousOffset = 0; + + jump() { + window.scrollTo(0, 0); + } + + private hide() { + this.isVisible = false; + } + + private show() { + this.isVisible = true; + } + + private updateOffset() { + this.previousOffset = window.scrollY; + } + + @HostListener('window:scroll') onWindowScroll() { + const diff = this.previousOffset - window.scrollY; + + const scrollsDown = diff < 0; + const isAtTop = window.scrollY < this.MARGIN_TOP; + const hasEnoughIntention = diff > this.SCROLL_MIN; + + if (scrollsDown || isAtTop) { + this.updateOffset(); + this.hide(); + } else if (!this.isVisible && !hasEnoughIntention) { + this.hide(); + } else { + this.updateOffset(); + this.show(); + } + } +} diff --git a/src/app/shell/header/header/header.component.html b/src/app/shell/header/header/header.component.html index 1311d82da3..ae9b18a0c6 100644 --- a/src/app/shell/header/header/header.component.html +++ b/src/app/shell/header/header/header.component.html @@ -10,3 +10,5 @@ > + + diff --git a/src/app/shell/header/header/header.component.spec.ts b/src/app/shell/header/header/header.component.spec.ts index 831f820b63..90d72f805f 100644 --- a/src/app/shell/header/header/header.component.spec.ts +++ b/src/app/shell/header/header/header.component.spec.ts @@ -6,6 +6,7 @@ import { instance, mock, when } from 'ts-mockito'; import { AppFacade } from 'ish-core/facades/app.facade'; import { findAllCustomElements } from 'ish-core/utils/dev/html-query-utils'; +import { BackToTopComponent } from 'ish-shell/header/back-to-top/back-to-top.component'; import { HeaderDefaultComponent } from 'ish-shell/header/header-default/header-default.component'; import { HeaderSimpleComponent } from 'ish-shell/header/header-simple/header-simple.component'; @@ -23,7 +24,12 @@ describe('Header Component', () => { await TestBed.configureTestingModule({ imports: [RouterTestingModule], - declarations: [HeaderComponent, MockComponent(HeaderDefaultComponent), MockComponent(HeaderSimpleComponent)], + declarations: [ + HeaderComponent, + MockComponent(BackToTopComponent), + MockComponent(HeaderDefaultComponent), + MockComponent(HeaderSimpleComponent), + ], providers: [{ provide: AppFacade, useFactory: () => instance(appFacade) }], }).compileComponents(); }); @@ -45,6 +51,7 @@ describe('Header Component', () => { expect(findAllCustomElements(element)).toMatchInlineSnapshot(` Array [ "ish-header-default", + "ish-back-to-top", ] `); }); @@ -55,6 +62,7 @@ describe('Header Component', () => { expect(findAllCustomElements(element)).toMatchInlineSnapshot(` Array [ "ish-header-simple", + "ish-back-to-top", ] `); }); diff --git a/src/app/shell/shell.module.ts b/src/app/shell/shell.module.ts index 322b4cf66b..d97bdfb6c7 100644 --- a/src/app/shell/shell.module.ts +++ b/src/app/shell/shell.module.ts @@ -22,6 +22,7 @@ import { WishlistsExportsModule } from '../extensions/wishlists/exports/wishlist import { CookiesBannerComponent } from './application/cookies-banner/cookies-banner.component'; import { FooterComponent } from './footer/footer/footer.component'; +import { BackToTopComponent } from './header/back-to-top/back-to-top.component'; import { HeaderCheckoutComponent } from './header/header-checkout/header-checkout.component'; import { HeaderDefaultComponent } from './header/header-default/header-default.component'; import { HeaderNavigationComponent } from './header/header-navigation/header-navigation.component'; @@ -61,6 +62,7 @@ const exportedComponents = [CookiesBannerComponent, FooterComponent, HeaderCompo ], declarations: [ ...exportedComponents, + BackToTopComponent, CookiesBannerComponent, HeaderCheckoutComponent, HeaderDefaultComponent, diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index b4f19a5e76..a860736014 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -564,6 +564,7 @@ "approval.rejectform.button.reject.label": "Ablehnen", "approval.rejectform.invalid_comment.error": "Ein Kommentar muss angegeben werden.", "approval.rejectform.reject_order.heading": "Bestellanfrage ablehnen", + "back_to_top.title": "Nach oben scrollen", "basket.add_quote.error": "Das Preisangebot konnte nicht in den Warenkorb gelegt werden.", "basket.add_quotelineitem.error": "Das Preisangebot kann nicht in den Warenkorb gelegt werden. Es enthält ungültige Produkte.", "basket.not_found.error": "Der Warenkorb konnte nicht gefunden werden.", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 2e3b2b2c92..681e393b08 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -564,6 +564,7 @@ "approval.rejectform.button.reject.label": "Reject", "approval.rejectform.invalid_comment.error": "Comment is required and cannot be empty", "approval.rejectform.reject_order.heading": "Reject Requisition", + "back_to_top.title": "Scroll back to the top", "basket.add_quote.error": "The quote could not be added to the cart.", "basket.add_quotelineitem.error": "The quote cannot be added to the shopping cart. It contains invalid products.", "basket.not_found.error": "The shopping cart could not be found.", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index ac2e9ae229..2e3550ff8a 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -564,6 +564,7 @@ "approval.rejectform.button.reject.label": "Rejeter", "approval.rejectform.invalid_comment.error": "Le commentaire est obligatoire et ne peut pas être vide", "approval.rejectform.reject_order.heading": "Rejeter la demande d’achat", + "back_to_top.title": "Revenir en haut", "basket.add_quote.error": "Le devis n’a pas pu être ajouté au panier.", "basket.add_quotelineitem.error": "Le devis ne peut pas être ajouté au panier. Il contient des produits non valides.", "basket.not_found.error": "Le panier n’a pas pu être trouvé.", diff --git a/src/styles/components/header/back-to-top.scss b/src/styles/components/header/back-to-top.scss new file mode 100644 index 0000000000..9b43eab6b4 --- /dev/null +++ b/src/styles/components/header/back-to-top.scss @@ -0,0 +1,26 @@ +// apply scroll-behavior except when running in Cypress context (it breaks a lot of tests) +html:not(.cypress-tests) { + scroll-behavior: smooth; +} + +@keyframes back-to-top-btn-animation { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.back-to-top-btn { + position: fixed; + right: $space-default; + bottom: $space-default; + z-index: $zindex-fixed; + animation: back-to-top-btn-animation 0.15s ease-in-out; + + @media (max-width: $screen-xs-max) { + width: auto; + } +} diff --git a/src/styles/main.scss b/src/styles/main.scss index 80db0af746..b3a375d19e 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -55,6 +55,7 @@ @import 'components/header/main-navigation'; @import 'components/header/header-sticky'; @import 'components/header/user-information-mobile'; +@import 'components/header/back-to-top'; @import 'components/footer/footer'; @import 'components/common/enhanced-image'; @import 'components/common/video';