// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; import { ScrollDetail } from '@ionic/core'; import { IonContent } from '@ionic/angular'; import { CoreUtils } from '@services/utils/utils'; import { CoreMath } from '@singletons/math'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreFormatTextDirective } from './format-text'; import { CoreEventObserver } from '@singletons/events'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreCancellablePromise } from '@classes/cancellable-promise'; import { CoreDom } from '@singletons/dom'; /** * Directive to make an element fixed at the bottom collapsible when scrolling. * * Example usage: * *
*/ @Directive({ selector: '[collapsible-footer]', }) export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { @Input() appearOnBottom = false; protected id = '0'; protected element: HTMLElement; protected initialHeight = 48; protected finalHeight = 0; protected initialPaddingBottom = '0px'; protected previousTop = 0; protected previousHeight = 0; protected content?: HTMLIonContentElement | null; protected loadingChangedListener?: CoreEventObserver; protected contentScrollListener?: EventListener; protected endContentScrollListener?: EventListener; protected resizeListener?: CoreEventObserver; protected slotPromise?: CoreCancellablePromise; protected viewportPromise?: CoreCancellablePromise; protected loadingHeight = false; protected pageDidEnterListener?: EventListener; protected keyUpListener?: EventListener; protected page?: HTMLElement; constructor(el: ElementRef, protected ionContent: IonContent) { this.element = el.nativeElement; } /** * @inheritdoc */ async ngOnInit(): Promise { this.id = String(CoreUtils.getUniqueId('CoreCollapsibleFooterDirective')); // Only if not present or explicitly falsy it will be false. this.appearOnBottom = !CoreUtils.isFalseOrZero(this.appearOnBottom); this.slotPromise = CoreDom.slotOnContent(this.element); await this.slotPromise; await this.waitLoadingsDone(); await this.waitFormatTextsRendered(); this.content = this.element.closest('ion-content'); this.content?.setAttribute('data-collapsible-footer-id', this.id); await this.calculateHeight(); CoreDom.onElementSlot(this.element, () => { this.calculateHeight(); }); this.listenEvents(); } /** * Calculate the height of the footer. */ protected async calculateHeight(): Promise { if (this.loadingHeight) { // Already calculating, return. return; } this.loadingHeight = true; this.viewportPromise = CoreDom.waitToBeInViewport(this.element); await this.viewportPromise; this.element.classList.remove('is-active'); await CoreUtils.nextTick(); // Set a minimum height value. this.initialHeight = this.element.getBoundingClientRect().height || this.initialHeight; const moduleNav = this.element.querySelector('core-course-module-navigation'); if (moduleNav) { this.element.classList.add('has-module-nav'); this.finalHeight = this.initialHeight - (moduleNav.getBoundingClientRect().height); } this.previousHeight = this.initialHeight; this.content?.style.setProperty('--core-collapsible-footer-max-height', this.initialHeight + 'px'); this.element.classList.add('is-active'); this.setBarHeight(this.initialHeight); this.loadingHeight = false; } /** * Setup event listeners. */ protected async listenEvents(): Promise { if (!this.content || this.content?.classList.contains('has-collapsible-footer')) { return; } this.content.classList.add('has-collapsible-footer'); // Move element to the nearest ion-content if it's not the parent. if (this.element.parentElement?.nodeName != 'ION-CONTENT') { this.content.appendChild(this.element); } // Set a padding to not overlap elements. this.initialPaddingBottom = this.content.style.getPropertyValue('--padding-bottom') || this.initialPaddingBottom; this.content.style.setProperty( '--padding-bottom', `calc(${this.initialPaddingBottom} + var(--core-collapsible-footer-max-height, 0px))`, ); const scroll = await this.content.getScrollElement(); this.content.scrollEvents = true; // Init shadow. this.content.classList.toggle('core-footer-shadow', !CoreDom.scrollIsBottom(scroll)); this.content.addEventListener('ionScroll', this.contentScrollListener = (e: CustomEvent): void => { if (!this.content) { return; } this.onScroll(e.detail, scroll); }); this.content.addEventListener('ionScrollEnd', this.endContentScrollListener = (): void => { if (!this.content) { return; } const height = this.previousHeight; const collapsed = height <= this.finalHeight; const expanded = height >= this.initialHeight; if (!collapsed && !expanded) { // Finish opening or closing the bar. const newHeight = (height - this.finalHeight) < (this.initialHeight - this.finalHeight) / 2 ? this.finalHeight : this.initialHeight; this.setBarHeight(newHeight); } }); this.resizeListener = CoreDom.onWindowResize(() => { this.calculateHeight(); }, 50); this.page = this.content.closest('.ion-page') || undefined; this.page?.addEventListener( 'ionViewDidEnter', this.pageDidEnterListener = () => { this.calculateHeight(); }, ); // Handle scroll when navigating using tab key and the selected element is behind the footer. this.content.addEventListener('keyup', this.keyUpListener = (ev: KeyboardEvent) => { if (ev.key !== 'Tab' || !document.activeElement) { return; } if (document.activeElement.getBoundingClientRect().bottom > this.element.getBoundingClientRect().top) { document.activeElement.scrollIntoView({ block: 'center' }); } }); } /** * Wait until all children inside the element are done rendering. */ protected async waitFormatTextsRendered(): Promise { await CoreDirectivesRegistry.waitDirectivesReady(this.element, 'core-format-text', CoreFormatTextDirective); } /** * On scroll function. * * @param scrollDetail Scroll detail object. * @param scrollElement Scroll element to calculate maxScroll. */ protected onScroll(scrollDetail: ScrollDetail, scrollElement: HTMLElement): void { const maxScroll = scrollElement.scrollHeight - scrollElement.offsetHeight; if (scrollDetail.scrollTop <= 0 || (this.appearOnBottom && scrollDetail.scrollTop >= maxScroll)) { // Reset. this.setBarHeight(this.initialHeight); } else { let newHeight = this.previousHeight - (scrollDetail.scrollTop - this.previousTop); newHeight = CoreMath.clamp(newHeight, this.finalHeight, this.initialHeight); this.setBarHeight(newHeight); } this.previousTop = scrollDetail.scrollTop; } /** * Sets the bar height. * * @param height The new bar height. */ protected setBarHeight(height: number): void { const collapsed = height <= this.finalHeight; const expanded = height >= this.initialHeight; this.element.classList.toggle('footer-collapsed', collapsed); this.element.classList.toggle('footer-expanded', expanded); this.content?.style.setProperty('--core-collapsible-footer-height', height + 'px'); this.previousHeight = height; } /** * Wait until all children inside the page. * * @returns Promise resolved when loadings are done. */ protected async waitLoadingsDone(): Promise { const scrollElement = await this.ionContent.getScrollElement(); await Promise.all([ await CoreDirectivesRegistry.waitDirectivesReady(scrollElement, 'core-loading', CoreLoadingComponent), await CoreDirectivesRegistry.waitDirectivesReady(this.element, 'core-loading', CoreLoadingComponent), ]); } /** * @inheritdoc */ async ngOnDestroy(): Promise { if (this.content) { // Only reset the variables and classes if the collapsible footer hasn't changed (avoid race conditions). if (this.content.getAttribute('data-collapsible-footer-id') === this.id) { this.content.style.setProperty('--padding-bottom', this.initialPaddingBottom); this.content.classList.remove('has-collapsible-footer'); } if (this.contentScrollListener) { this.content.removeEventListener('ionScroll', this.contentScrollListener); } if (this.endContentScrollListener) { this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener); } if (this.keyUpListener) { this.content.removeEventListener('keyup', this.keyUpListener); } } if (this.page && this.pageDidEnterListener) { this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener); } this.element?.remove(); this.resizeListener?.off(); this.slotPromise?.cancel(); this.viewportPromise?.cancel(); } }