MOBILE-3648 core: Implement tabs component without router
This commit is contained in:
		
							parent
							
								
									f1fbb75889
								
							
						
					
					
						commit
						80c8ee8cc3
					
				
							
								
								
									
										623
									
								
								src/core/classes/tabs.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										623
									
								
								src/core/classes/tabs.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,623 @@ | ||||
| // (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 { | ||||
|     Component, | ||||
|     Input, | ||||
|     Output, | ||||
|     EventEmitter, | ||||
|     OnInit, | ||||
|     OnChanges, | ||||
|     OnDestroy, | ||||
|     AfterViewInit, | ||||
|     ViewChild, | ||||
|     ElementRef, | ||||
| } from '@angular/core'; | ||||
| import { IonSlides } from '@ionic/angular'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| 
 | ||||
| import { CoreApp } from '@services/app'; | ||||
| import { CoreConfig } from '@services/config'; | ||||
| import { CoreConstants } from '@/core/constants'; | ||||
| import { Platform, Translate } from '@singletons'; | ||||
| 
 | ||||
| /** | ||||
|  * Class to abstract some common code for tabs. | ||||
|  */ | ||||
| @Component({ | ||||
|     template: '', | ||||
| }) | ||||
| export class CoreTabsBaseComponent<T extends CoreTabBase> implements OnInit, AfterViewInit, OnChanges, OnDestroy { | ||||
| 
 | ||||
|     // Minimum tab's width.
 | ||||
|     protected static readonly MIN_TAB_WIDTH = 107; | ||||
|     // Max height that allows tab hiding.
 | ||||
|     protected static readonly MAX_HEIGHT_TO_HIDE_TABS = 768; | ||||
| 
 | ||||
|     @Input() protected selectedIndex = 0; // Index of the tab to select.
 | ||||
|     @Input() hideUntil = false; // Determine when should the contents be shown.
 | ||||
|     @Output() protected ionChange = new EventEmitter<T>(); // Emitted when the tab changes.
 | ||||
| 
 | ||||
|     @ViewChild(IonSlides) protected slides?: IonSlides; | ||||
| 
 | ||||
|     tabs: T[] = []; // List of tabs.
 | ||||
| 
 | ||||
|     selected?: string; // Selected tab id.
 | ||||
|     showPrevButton = false; | ||||
|     showNextButton = false; | ||||
|     maxSlides = 3; | ||||
|     numTabsShown = 0; | ||||
|     direction = 'ltr'; | ||||
|     description = ''; | ||||
|     lastScroll = 0; | ||||
|     slidesOpts = { | ||||
|         initialSlide: 0, | ||||
|         slidesPerView: 3, | ||||
|         centerInsufficientSlides: true, | ||||
|     }; | ||||
| 
 | ||||
|     protected initialized = false; | ||||
|     protected afterViewInitTriggered = false; | ||||
| 
 | ||||
|     protected tabBarHeight = 0; | ||||
|     protected tabsElement?: HTMLElement; // The tabs parent element. It's the element that will be "scrolled" to hide tabs.
 | ||||
|     protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element.
 | ||||
|     protected tabsShown = true; | ||||
|     protected resizeFunction?: EventListenerOrEventListenerObject; | ||||
|     protected isDestroyed = false; | ||||
|     protected isCurrentView = true; | ||||
|     protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view.
 | ||||
|     protected hasSliddenToInitial = false; // Whether we've already slidden to the initial slide or there was no need.
 | ||||
|     protected selectHistory: string[] = []; | ||||
| 
 | ||||
|     protected firstSelectedTab?: string; // ID of the first selected tab to control history.
 | ||||
|     protected unregisterBackButtonAction: any; | ||||
|     protected languageChangedSubscription?: Subscription; | ||||
|     protected isInTransition = false; // Weather Slides is in transition.
 | ||||
|     protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any
 | ||||
|     protected slidesSwiperLoaded = false; | ||||
|     protected scrollListenersSet: Record<string | number, boolean> = {}; // Prevent setting listeners twice.
 | ||||
| 
 | ||||
|     constructor( | ||||
|         protected element: ElementRef, | ||||
|     ) { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; | ||||
| 
 | ||||
|         // Change the side when the language changes.
 | ||||
|         this.languageChangedSubscription = Translate.instance.onLangChange.subscribe(() => { | ||||
|             setTimeout(() => { | ||||
|                 this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * View has been initialized. | ||||
|      */ | ||||
|     async ngAfterViewInit(): Promise<void> { | ||||
|         if (this.isDestroyed) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.afterViewInitTriggered = true; | ||||
|         this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar'); | ||||
| 
 | ||||
|         this.slidesSwiper = await this.slides?.getSwiper(); | ||||
|         this.slidesSwiper.once('progress', () => { | ||||
|             this.slidesSwiperLoaded = true; | ||||
|             this.calculateSlides(); | ||||
|         }); | ||||
| 
 | ||||
| 
 | ||||
|         if (!this.initialized && this.hideUntil) { | ||||
|             // Tabs should be shown, initialize them.
 | ||||
|             await this.initializeTabs(); | ||||
|         } | ||||
| 
 | ||||
|         this.resizeFunction = this.windowResized.bind(this); | ||||
| 
 | ||||
|         window.addEventListener('resize', this.resizeFunction!); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate the tab bar height. | ||||
|      */ | ||||
|     protected calculateTabBarHeight(): void { | ||||
|         if (!this.tabBarElement) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.tabBarHeight = this.tabBarElement.offsetHeight; | ||||
| 
 | ||||
|         if (this.tabsShown) { | ||||
|             // Smooth translation.
 | ||||
|             this.tabBarElement.style.top = - this.lastScroll + 'px'; | ||||
|             this.tabBarElement.style.height = 'calc(100% + ' + scroll + 'px'; | ||||
|         } else { | ||||
|             this.tabBarElement.classList.add('tabs-hidden'); | ||||
|             this.tabBarElement.style.top = '0'; | ||||
|             this.tabBarElement.style.height = ''; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Detect changes on input properties. | ||||
|      */ | ||||
|     ngOnChanges(): void { | ||||
|         // Wait for ngAfterViewInit so it works in the case that each tab has its own component.
 | ||||
|         if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) { | ||||
|             // Tabs should be shown, initialize them.
 | ||||
|             // Use a setTimeout so child components update their inputs before initializing the tabs.
 | ||||
|             setTimeout(() => { | ||||
|                 this.initializeTabs(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * User entered the page that contains the component. | ||||
|      */ | ||||
|     ionViewDidEnter(): void { | ||||
|         this.isCurrentView = true; | ||||
| 
 | ||||
|         this.calculateSlides(); | ||||
| 
 | ||||
|         this.registerBackButtonAction(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Register back button action. | ||||
|      */ | ||||
|     protected registerBackButtonAction(): void { | ||||
|         this.unregisterBackButtonAction = CoreApp.instance.registerBackButtonAction(() => { | ||||
|             // The previous page in history is not the last one, we need the previous one.
 | ||||
|             if (this.selectHistory.length > 1) { | ||||
|                 const tabIndex = this.selectHistory[this.selectHistory.length - 2]; | ||||
| 
 | ||||
|                 // Remove curent and previous tabs from history.
 | ||||
|                 this.selectHistory = this.selectHistory.filter((tabId) => this.selected != tabId && tabIndex != tabId); | ||||
| 
 | ||||
|                 this.selectTab(tabIndex); | ||||
| 
 | ||||
|                 return true; | ||||
|             } else if (this.selected != this.firstSelectedTab) { | ||||
|                 // All history is gone but we are not in the first selected tab.
 | ||||
|                 this.selectHistory = []; | ||||
| 
 | ||||
|                 this.selectTab(this.firstSelectedTab!); | ||||
| 
 | ||||
|                 return true; | ||||
|             } | ||||
| 
 | ||||
|             return false; | ||||
|         }, 750); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * User left the page that contains the component. | ||||
|      */ | ||||
|     ionViewDidLeave(): void { | ||||
|         // Unregister the custom back button action for this page
 | ||||
|         this.unregisterBackButtonAction && this.unregisterBackButtonAction(); | ||||
| 
 | ||||
|         this.isCurrentView = false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate slides. | ||||
|      */ | ||||
|     protected async calculateSlides(): Promise<void> { | ||||
|         if (!this.isCurrentView || !this.initialized) { | ||||
|             // Don't calculate if component isn't in current view, the calculations are wrong.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.tabsShown) { | ||||
|             if (window.innerHeight >= CoreTabsBaseComponent.MAX_HEIGHT_TO_HIDE_TABS) { | ||||
|                 // Ensure tabbar is shown.
 | ||||
|                 this.tabsShown = true; | ||||
|                 this.tabBarElement?.classList.remove('tabs-hidden'); | ||||
|                 this.lastScroll = 0; | ||||
|                 this.calculateTabBarHeight(); | ||||
|             } else { | ||||
|                 // Don't recalculate.
 | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         await this.calculateMaxSlides(); | ||||
| 
 | ||||
|         this.updateSlides(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the tab on a index. | ||||
|      * | ||||
|      * @param tabId Tab ID. | ||||
|      * @return Selected tab. | ||||
|      */ | ||||
|     protected getTabIndex(tabId: string): number { | ||||
|         return this.tabs.findIndex((tab) => tabId == tab.id); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the current selected tab. | ||||
|      * | ||||
|      * @return Selected tab. | ||||
|      */ | ||||
|     getSelected(): T | undefined { | ||||
|         const index = this.selected && this.getTabIndex(this.selected); | ||||
| 
 | ||||
|         return index !== undefined && index >= 0 ? this.tabs[index] : undefined; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize the tabs, determining the first tab to be shown. | ||||
|      */ | ||||
|     protected async initializeTabs(): Promise<void> { | ||||
|         let selectedTab: T | undefined = this.tabs[this.selectedIndex || 0] || undefined; | ||||
| 
 | ||||
|         if (!selectedTab || !selectedTab.enabled) { | ||||
|             // The tab is not enabled or not shown. Get the first tab that is enabled.
 | ||||
|             selectedTab = this.tabs.find((tab) => tab.enabled) || undefined; | ||||
|         } | ||||
| 
 | ||||
|         if (!selectedTab) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.firstSelectedTab = selectedTab.id!; | ||||
|         this.selectTab(this.firstSelectedTab); | ||||
| 
 | ||||
|         // Setup tab scrolling.
 | ||||
|         this.calculateTabBarHeight(); | ||||
| 
 | ||||
|         this.initialized = true; | ||||
| 
 | ||||
|         // Check which arrows should be shown.
 | ||||
|         this.calculateSlides(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Method executed when the slides are changed. | ||||
|      */ | ||||
|     async slideChanged(): Promise<void> { | ||||
|         if (!this.slidesSwiperLoaded) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.isInTransition = false; | ||||
|         const slidesCount = await this.slides?.length() || 0; | ||||
|         if (slidesCount > 0) { | ||||
|             this.showPrevButton = !await this.slides?.isBeginning(); | ||||
|             this.showNextButton = !await this.slides?.isEnd(); | ||||
|         } else { | ||||
|             this.showPrevButton = false; | ||||
|             this.showNextButton = false; | ||||
|         } | ||||
| 
 | ||||
|         const currentIndex = await this.slides!.getActiveIndex(); | ||||
|         if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) { | ||||
|             // Current tab has changed, don't slide to initial anymore.
 | ||||
|             this.shouldSlideToInitial = false; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Updates the number of slides to show. | ||||
|      */ | ||||
|     protected async updateSlides(): Promise<void> { | ||||
|         this.numTabsShown = this.tabs.reduce((prev: number, current) => current.enabled ? prev + 1 : prev, 0); | ||||
| 
 | ||||
|         this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; | ||||
| 
 | ||||
|         this.slideChanged(); | ||||
| 
 | ||||
|         this.calculateTabBarHeight(); | ||||
|         await this.slides!.update(); | ||||
| 
 | ||||
|         if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { | ||||
|             this.hasSliddenToInitial = true; | ||||
|             this.shouldSlideToInitial = true; | ||||
| 
 | ||||
|             setTimeout(() => { | ||||
|                 if (this.shouldSlideToInitial) { | ||||
|                     this.slides!.slideTo(this.selectedIndex, 0); | ||||
|                     this.shouldSlideToInitial = false; | ||||
|                 } | ||||
|             }, 400); | ||||
| 
 | ||||
|             return; | ||||
|         } else if (this.selectedIndex) { | ||||
|             this.hasSliddenToInitial = true; | ||||
|         } | ||||
| 
 | ||||
|         setTimeout(() => { | ||||
|             this.slideChanged(); // Call slide changed again, sometimes the slide active index takes a while to be updated.
 | ||||
|         }, 400); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate the number of slides that can fit on the screen. | ||||
|      */ | ||||
|     protected async calculateMaxSlides(): Promise<void> { | ||||
|         if (!this.slidesSwiperLoaded) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.maxSlides = 3; | ||||
|         const width = this.slidesSwiper.width; | ||||
|         if (!width) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const fontSize = await CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]); | ||||
| 
 | ||||
|         this.maxSlides = Math.floor(width / (fontSize / CoreConstants.CONFIG.font_sizes[0] * CoreTabsBaseComponent.MIN_TAB_WIDTH)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Method that shows the next tab. | ||||
|      */ | ||||
|     async slideNext(): Promise<void> { | ||||
|         // Stop if slides are in transition.
 | ||||
|         if (!this.showNextButton || this.isInTransition) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (await this.slides!.isBeginning()) { | ||||
|             // Slide to the second page.
 | ||||
|             this.slides!.slideTo(this.maxSlides); | ||||
|         } else { | ||||
|             const currentIndex = await this.slides!.getActiveIndex(); | ||||
|             if (typeof currentIndex !== 'undefined') { | ||||
|                 const nextSlideIndex = currentIndex + this.maxSlides; | ||||
|                 this.isInTransition = true; | ||||
|                 if (nextSlideIndex < this.numTabsShown) { | ||||
|                     // Slide to the next page.
 | ||||
|                     await this.slides!.slideTo(nextSlideIndex); | ||||
|                 } else { | ||||
|                     // Slide to the latest slide.
 | ||||
|                     await this.slides!.slideTo(this.numTabsShown - 1); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Method that shows the previous tab. | ||||
|      */ | ||||
|     async slidePrev(): Promise<void> { | ||||
|         // Stop if slides are in transition.
 | ||||
|         if (!this.showPrevButton || this.isInTransition) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (await this.slides!.isEnd()) { | ||||
|             this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2); | ||||
|             // Slide to the previous of the latest page.
 | ||||
|         } else { | ||||
|             const currentIndex = await this.slides!.getActiveIndex(); | ||||
|             if (typeof currentIndex !== 'undefined') { | ||||
|                 const prevSlideIndex = currentIndex - this.maxSlides; | ||||
|                 this.isInTransition = true; | ||||
|                 if (prevSlideIndex >= 0) { | ||||
|                     // Slide to the previous page.
 | ||||
|                     await this.slides!.slideTo(prevSlideIndex); | ||||
|                 } else { | ||||
|                     // Slide to the first page.
 | ||||
|                     await this.slides!.slideTo(0); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show or hide the tabs. This is used when the user is scrolling inside a tab. | ||||
|      * | ||||
|      * @param scrollEvent Scroll event to check scroll position. | ||||
|      * @param content Content element to check measures. | ||||
|      */ | ||||
|     showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void { | ||||
|         if (!this.tabBarElement || !this.tabsElement || !content) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Always show on very tall screens.
 | ||||
|         if (window.innerHeight >= CoreTabsBaseComponent.MAX_HEIGHT_TO_HIDE_TABS) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.tabBarHeight && this.tabBarElement.offsetHeight != this.tabBarHeight) { | ||||
|             // Wrong tab height, recalculate it.
 | ||||
|             this.calculateTabBarHeight(); | ||||
|         } | ||||
| 
 | ||||
|         if (!this.tabBarHeight) { | ||||
|             // We don't have the tab bar height, this means the tab bar isn't shown.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const scroll = parseInt(scrollEvent.detail.scrollTop, 10); | ||||
|         if (scroll <= 0) { | ||||
|             // Ensure tabbar is shown.
 | ||||
|             this.tabsElement.style.top = '0'; | ||||
|             this.tabsElement.style.height = ''; | ||||
|             this.tabBarElement.classList.remove('tabs-hidden'); | ||||
|             this.tabsShown = true; | ||||
|             this.lastScroll = 0; | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (scroll == this.lastScroll) { | ||||
|             // Ensure scroll has been modified to avoid flicks.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (this.tabsShown && scroll > this.tabBarHeight) { | ||||
|             this.tabsShown = false; | ||||
| 
 | ||||
|             // Hide tabs.
 | ||||
|             this.tabBarElement.classList.add('tabs-hidden'); | ||||
|             this.tabsElement.style.top = '0'; | ||||
|             this.tabsElement.style.height = ''; | ||||
|         } else if (!this.tabsShown && scroll <= this.tabBarHeight) { | ||||
|             this.tabsShown = true; | ||||
|             this.tabBarElement.classList.remove('tabs-hidden'); | ||||
|         } | ||||
| 
 | ||||
|         if (this.tabsShown && content.scrollHeight > content.clientHeight + (this.tabBarHeight - scroll)) { | ||||
|             // Smooth translation.
 | ||||
|             this.tabsElement.style.top = - scroll + 'px'; | ||||
|             this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px'; | ||||
|         } | ||||
|         // Use lastScroll after moving the tabs to avoid flickering.
 | ||||
|         this.lastScroll = parseInt(scrollEvent.detail.scrollTop, 10); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Select a tab by ID. | ||||
|      * | ||||
|      * @param tabId Tab ID. | ||||
|      * @param e Event. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async selectTab(tabId: string, e?: Event): Promise<void> { | ||||
|         const index = this.tabs.findIndex((tab) => tabId == tab.id); | ||||
| 
 | ||||
|         return this.selectByIndex(index, e); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Select a tab by index. | ||||
|      * | ||||
|      * @param index Index to select. | ||||
|      * @param e Event. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async selectByIndex(index: number, e?: Event): Promise<void> { | ||||
|         if (index < 0 || index >= this.tabs.length) { | ||||
|             if (this.selected) { | ||||
|                 // Invalid index do not change tab.
 | ||||
|                 e?.preventDefault(); | ||||
|                 e?.stopPropagation(); | ||||
| 
 | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // Index isn't valid, select the first one.
 | ||||
|             index = 0; | ||||
|         } | ||||
| 
 | ||||
|         const tabToSelect = this.tabs[index]; | ||||
|         if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) { | ||||
|             // Already selected or not enabled.
 | ||||
|             e?.preventDefault(); | ||||
|             e?.stopPropagation(); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (this.selected) { | ||||
|             await this.slides!.slideTo(index); | ||||
|         } | ||||
| 
 | ||||
|         const ok = await this.loadTab(tabToSelect); | ||||
| 
 | ||||
|         if (ok !== false) { | ||||
|             this.selectHistory.push(tabToSelect.id!); | ||||
|             this.selected = tabToSelect.id; | ||||
|             this.selectedIndex = index; | ||||
| 
 | ||||
|             this.ionChange.emit(tabToSelect); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load the tab. | ||||
|      * | ||||
|      * @param tabToSelect Tab to load. | ||||
|      * @return Promise resolved with true if tab is successfully loaded. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     protected async loadTab(tabToSelect: T): Promise<boolean> { | ||||
|         // Each implementation should override this function.
 | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Listen scroll events in an element's inner ion-content (if any). | ||||
|      * | ||||
|      * @param element Element to search ion-content in. | ||||
|      * @param id ID of the tab/page. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async listenContentScroll(element: HTMLElement, id: number | string): Promise<void> { | ||||
|         const content = element.querySelector('ion-content'); | ||||
| 
 | ||||
|         if (!content || this.scrollListenersSet[id]) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const scroll = await content.getScrollElement(); | ||||
|         content.scrollEvents = true; | ||||
|         this.scrollListenersSet[id] = true; | ||||
|         content.addEventListener('ionScroll', (e: CustomEvent): void => { | ||||
|             this.showHideTabs(e, scroll); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Adapt tabs to a window resize. | ||||
|      */ | ||||
|     protected windowResized(): void { | ||||
|         setTimeout(() => { | ||||
|             this.calculateSlides(); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component destroyed. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.isDestroyed = true; | ||||
| 
 | ||||
|         if (this.resizeFunction) { | ||||
|             window.removeEventListener('resize', this.resizeFunction); | ||||
|         } | ||||
|         this.languageChangedSubscription?.unsubscribe(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Data for each tab. | ||||
|  */ | ||||
| export type CoreTabBase = { | ||||
|     title: string; // The translatable tab title.
 | ||||
|     id?: string; // Unique tab id.
 | ||||
|     class?: string; // Class, if needed.
 | ||||
|     icon?: string; // The tab icon.
 | ||||
|     badge?: string; // A badge to add in the tab.
 | ||||
|     badgeStyle?: string; // The badge color.
 | ||||
|     enabled?: boolean; // Whether the tab is enabled.
 | ||||
| }; | ||||
| @ -32,6 +32,8 @@ import { CoreShowPasswordComponent } from './show-password/show-password'; | ||||
| import { CoreSplitViewComponent } from './split-view/split-view'; | ||||
| import { CoreEmptyBoxComponent } from './empty-box/empty-box'; | ||||
| import { CoreTabsComponent } from './tabs/tabs'; | ||||
| import { CoreTabComponent } from './tabs/tab'; | ||||
| import { CoreTabsOutletComponent } from './tabs-outlet/tabs-outlet'; | ||||
| import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading'; | ||||
| import { CoreProgressBarComponent } from './progress-bar/progress-bar'; | ||||
| import { CoreContextMenuComponent } from './context-menu/context-menu'; | ||||
| @ -61,6 +63,8 @@ import { CorePipesModule } from '@pipes/pipes.module'; | ||||
|         CoreSplitViewComponent, | ||||
|         CoreEmptyBoxComponent, | ||||
|         CoreTabsComponent, | ||||
|         CoreTabComponent, | ||||
|         CoreTabsOutletComponent, | ||||
|         CoreInfiniteLoadingComponent, | ||||
|         CoreProgressBarComponent, | ||||
|         CoreContextMenuComponent, | ||||
| @ -94,6 +98,8 @@ import { CorePipesModule } from '@pipes/pipes.module'; | ||||
|         CoreSplitViewComponent, | ||||
|         CoreEmptyBoxComponent, | ||||
|         CoreTabsComponent, | ||||
|         CoreTabComponent, | ||||
|         CoreTabsOutletComponent, | ||||
|         CoreInfiniteLoadingComponent, | ||||
|         CoreProgressBarComponent, | ||||
|         CoreContextMenuComponent, | ||||
|  | ||||
							
								
								
									
										31
									
								
								src/core/components/tabs-outlet/core-tabs-outlet.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/core/components/tabs-outlet/core-tabs-outlet.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| <ion-tabs class="hide-header"> | ||||
|     <ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1" #tabBar> | ||||
|         <ion-spinner *ngIf="!hideUntil"></ion-spinner> | ||||
|         <ion-row *ngIf="hideUntil"> | ||||
|             <ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1"> | ||||
|                 <ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon> | ||||
|             </ion-col> | ||||
|             <ion-col class="ion-no-padding" size="10"> | ||||
|                 <ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist" | ||||
|                     [attr.aria-label]="description" aria-hidden="false"> | ||||
|                     <ng-container *ngFor="let tab of tabs"> | ||||
|                         <ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide" role="tab" | ||||
|                             [attr.aria-label]="tab.title | translate" [attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'" | ||||
|                             [tabindex]="selected == tab.id ? null : -1"> | ||||
| 
 | ||||
|                             <ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" [tab]="tab.page" [layout]="layout" | ||||
|                                 class="{{tab.class}}"> | ||||
|                                 <ion-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon> | ||||
|                                 <ion-label>{{ tab.title | translate}}</ion-label> | ||||
|                                 <ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge> | ||||
|                             </ion-tab-button> | ||||
|                         </ion-slide> | ||||
|                     </ng-container> | ||||
|                 </ion-slides> | ||||
|             </ion-col> | ||||
|             <ion-col class="col-with-arrow ion-no-padding" (click)="slideNext()" size="1"> | ||||
|                 <ion-icon *ngIf="showNextButton" name="fas-chevron-right"></ion-icon> | ||||
|             </ion-col> | ||||
|         </ion-row> | ||||
|     </ion-tab-bar> | ||||
| </ion-tabs> | ||||
							
								
								
									
										176
									
								
								src/core/components/tabs-outlet/tabs-outlet.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/core/components/tabs-outlet/tabs-outlet.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,176 @@ | ||||
| // (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 { | ||||
|     Component, | ||||
|     Input, | ||||
|     OnInit, | ||||
|     OnChanges, | ||||
|     OnDestroy, | ||||
|     AfterViewInit, | ||||
|     ViewChild, | ||||
|     ElementRef, | ||||
| } from '@angular/core'; | ||||
| import { IonTabs } from '@ionic/angular'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| 
 | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreTabBase, CoreTabsBaseComponent } from '@classes/tabs'; | ||||
| 
 | ||||
| /** | ||||
|  * This component displays some top scrollable tabs that will autohide on vertical scroll. | ||||
|  * Each tab will load a page using Angular router. | ||||
|  * | ||||
|  * Example usage: | ||||
|  * | ||||
|  * <core-tabs-outlet selectedIndex="1" [tabs]="tabs"></core-tabs-outlet> | ||||
|  * | ||||
|  * Tab contents will only be shown if that tab is selected. | ||||
|  * | ||||
|  * @todo: Test behaviour when tabs are added late. | ||||
|  * @todo: Test RTL and tab history. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'core-tabs-outlet', | ||||
|     templateUrl: 'core-tabs-outlet.html', | ||||
|     styleUrls: ['../tabs/tabs.scss'], | ||||
| }) | ||||
| export class CoreTabsOutletComponent extends CoreTabsBaseComponent<CoreTabsOutletTab> | ||||
|     implements OnInit, AfterViewInit, OnChanges, OnDestroy { | ||||
| 
 | ||||
|     /** | ||||
|      * Determine tabs layout. | ||||
|      */ | ||||
|     @Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide'; | ||||
|     @Input() tabs: CoreTabsOutletTab[] = []; | ||||
| 
 | ||||
|     @ViewChild(IonTabs) protected ionTabs?: IonTabs; | ||||
| 
 | ||||
|     protected stackEventsSubscription?: Subscription; | ||||
| 
 | ||||
|     constructor( | ||||
|         element: ElementRef, | ||||
|     ) { | ||||
|         super(element); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         super.ngOnInit(); | ||||
| 
 | ||||
|         this.tabs.forEach((tab) => { | ||||
|             this.initTab(tab); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Init tab info. | ||||
|      * | ||||
|      * @param tab Tab. | ||||
|      */ | ||||
|     protected initTab(tab: CoreTabsOutletTab): void { | ||||
|         tab.id = tab.id || 'core-tab-outlet-' + CoreUtils.instance.getUniqueId('CoreTabsOutletComponent'); | ||||
|         if (typeof tab.enabled == 'undefined') { | ||||
|             tab.enabled = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * View has been initialized. | ||||
|      */ | ||||
|     async ngAfterViewInit(): Promise<void> { | ||||
|         super.ngAfterViewInit(); | ||||
| 
 | ||||
|         if (this.isDestroyed) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.tabsElement = this.element.nativeElement.querySelector('ion-tabs'); | ||||
|         this.stackEventsSubscription = this.ionTabs?.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => { | ||||
|             if (!this.isCurrentView) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             this.listenContentScroll(stackEvent.enteringView.element, stackEvent.enteringView.id); | ||||
|             this.showHideNavBarButtons(stackEvent.enteringView.element.tagName); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Detect changes on input properties. | ||||
|      */ | ||||
|     ngOnChanges(): void { | ||||
|         this.tabs.forEach((tab) => { | ||||
|             this.initTab(tab); | ||||
|         }); | ||||
| 
 | ||||
|         super.ngOnChanges(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load the tab. | ||||
|      * | ||||
|      * @param tabToSelect Tab to load. | ||||
|      * @return Promise resolved with true if tab is successfully loaded. | ||||
|      */ | ||||
|     protected async loadTab(tabToSelect: CoreTabsOutletTab): Promise<boolean> { | ||||
|         return CoreNavigator.instance.navigate(tabToSelect.page, { | ||||
|             params: tabToSelect.pageParams, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get all child core-navbar-buttons and show or hide depending on the page state. | ||||
|      * We need to use querySelectorAll because ContentChildren doesn't work with ng-template. | ||||
|      * https://github.com/angular/angular/issues/14842
 | ||||
|      * | ||||
|      * @param activatedPageName Activated page name. | ||||
|      */ | ||||
|     protected showHideNavBarButtons(activatedPageName: string): void { | ||||
|         const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons'); | ||||
|         const domUtils = CoreDomUtils.instance; | ||||
|         elements.forEach((element) => { | ||||
|             const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element); | ||||
| 
 | ||||
|             if (instance) { | ||||
|                 const pagetagName = element.closest('.ion-page')?.tagName; | ||||
|                 instance.forceHide(activatedPageName != pagetagName); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component destroyed. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         super.ngOnDestroy(); | ||||
|         this.stackEventsSubscription?.unsubscribe(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Tab to be displayed in CoreTabsOutlet. | ||||
|  */ | ||||
| export type CoreTabsOutletTab = CoreTabBase & { | ||||
|     page: string; // Page to navigate to.
 | ||||
|     pageParams?: Params; // Page params.
 | ||||
| }; | ||||
| @ -1,31 +1,28 @@ | ||||
| <ion-tabs class="hide-header"> | ||||
|     <ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1"> | ||||
|         <ion-spinner *ngIf="!hideUntil"></ion-spinner> | ||||
|         <ion-row *ngIf="hideUntil"> | ||||
|             <ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1"> | ||||
|                 <ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon> | ||||
|             </ion-col> | ||||
|             <ion-col class="ion-no-padding" size="10"> | ||||
|                 <ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist" | ||||
|                     [attr.aria-label]="description" aria-hidden="false"> | ||||
|                     <ng-container *ngFor="let tab of tabs"> | ||||
|                         <ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide" | ||||
|                             [attr.aria-label]="tab.title | translate" role="tab" [attr.aria-controls]="tab.id" [id]="tab.id + '-tab'" | ||||
|                             [tabindex]="selected == tab.id ? null : -1"> | ||||
| 
 | ||||
|                             <ion-tab-button (ionTabButtonClick)="selectTab(tab.id, $event)" [tab]="tab.page" [layout]="layout" | ||||
|                                 class="{{tab.class}}"> | ||||
|                                 <ion-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon> | ||||
|                                 <ion-label>{{ tab.title | translate}}</ion-label> | ||||
|                                 <ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge> | ||||
|                             </ion-tab-button> | ||||
|                         </ion-slide> | ||||
|                     </ng-container> | ||||
|                 </ion-slides> | ||||
|             </ion-col> | ||||
|             <ion-col class="col-with-arrow ion-no-padding" (click)="slideNext()" size="1"> | ||||
|                 <ion-icon *ngIf="showNextButton" name="fas-chevron-right"></ion-icon> | ||||
|             </ion-col> | ||||
|         </ion-row> | ||||
|     </ion-tab-bar> | ||||
| </ion-tabs> | ||||
| <ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1" #tabBar> | ||||
|     <ion-spinner *ngIf="!hideUntil"></ion-spinner> | ||||
|     <ion-row *ngIf="hideUntil"> | ||||
|         <ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1"> | ||||
|             <ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon> | ||||
|         </ion-col> | ||||
|         <ion-col class="ion-no-padding" size="10"> | ||||
|             <ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist" | ||||
|                 [attr.aria-label]="description" aria-hidden="false"> | ||||
|                 <ng-container *ngFor="let tab of tabs"> | ||||
|                     <ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide {{tab.class}}" | ||||
|                         role="tab" [attr.aria-label]="tab.title | translate" [attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'" | ||||
|                         [tabindex]="selected == tab.id ? null : -1" (click)="selectTab(tab.id, $event)"> | ||||
|                         <ion-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon> | ||||
|                         <ion-label>{{ tab.title | translate}}</ion-label> | ||||
|                         <ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge> | ||||
|                     </ion-slide> | ||||
|                 </ng-container> | ||||
|             </ion-slides> | ||||
|         </ion-col> | ||||
|         <ion-col class="col-with-arrow ion-no-padding" (click)="slideNext()" size="1"> | ||||
|             <ion-icon *ngIf="showNextButton" name="fas-chevron-right"></ion-icon> | ||||
|         </ion-col> | ||||
|     </ion-row> | ||||
| </ion-tab-bar> | ||||
| <div class="core-tabs-content-container" #originalTabs> | ||||
|     <ng-content></ng-content> | ||||
| </div> | ||||
|  | ||||
							
								
								
									
										147
									
								
								src/core/components/tabs/tab.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								src/core/components/tabs/tab.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,147 @@ | ||||
| // (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 { Component, Input, Output, OnInit, OnDestroy, ElementRef, EventEmitter, ContentChild, TemplateRef } from '@angular/core'; | ||||
| import { CoreTabBase } from '@classes/tabs'; | ||||
| 
 | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; | ||||
| import { CoreTabsComponent } from './tabs'; | ||||
| 
 | ||||
| /** | ||||
|  * A tab to use inside core-tabs. The content of this tab will be displayed when the tab is selected. | ||||
|  * | ||||
|  * You must provide either a title or an icon for the tab. | ||||
|  * | ||||
|  * The tab content MUST be surrounded by ng-template. This component uses ngTemplateOutlet instead of ng-content because the | ||||
|  * latter executes all the code immediately. This means that all the tabs would be initialized as soon as your view is | ||||
|  * loaded, leading to performance issues. | ||||
|  * | ||||
|  * Example usage: | ||||
|  * | ||||
|  * <core-tabs selectedIndex="1"> | ||||
|  *     <core-tab [title]="'core.courses.timeline' | translate" (ionSelect)="switchTab('timeline')"> | ||||
|  *         <ng-template> <!-- This ng-template is required. --> | ||||
|  *             <!-- Tab contents. --> | ||||
|  *         </ng-template> | ||||
|  *     </core-tab> | ||||
|  * </core-tabs> | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'core-tab', | ||||
|     template: '<ng-container *ngIf="loaded" [ngTemplateOutlet]="template"></ng-container>', | ||||
| }) | ||||
| export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase { | ||||
| 
 | ||||
|     @Input() title!: string; // The tab title.
 | ||||
|     @Input() icon?: string; // The tab icon.
 | ||||
|     @Input() badge?: string; // A badge to add in the tab.
 | ||||
|     @Input() badgeStyle?: string; // The badge color.
 | ||||
|     @Input() enabled = true; // Whether the tab is enabled.
 | ||||
|     @Input() class?: string; // Class, if needed.
 | ||||
|     @Input() set show(val: boolean) { // Whether the tab should be shown. Use a setter to detect changes on the value.
 | ||||
|         if (typeof val != 'undefined') { | ||||
|             const hasChanged = this.isShown != val; | ||||
|             this.isShown = val; | ||||
| 
 | ||||
|             if (this.initialized && hasChanged) { | ||||
|                 this.tabs.tabVisibilityChanged(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Input() id?: string; // An ID to identify the tab.
 | ||||
|     @Output() ionSelect: EventEmitter<CoreTabComponent> = new EventEmitter<CoreTabComponent>(); | ||||
| 
 | ||||
|     @ContentChild(TemplateRef) template?: TemplateRef<unknown>; // Template defined by the content.
 | ||||
| 
 | ||||
|     element: HTMLElement; // The core-tab element.
 | ||||
|     loaded = false; | ||||
|     initialized = false; | ||||
|     isShown = true; | ||||
|     tabElement?: HTMLElement | null; | ||||
| 
 | ||||
|     constructor( | ||||
|         protected tabs: CoreTabsComponent, | ||||
|         element: ElementRef, | ||||
|     ) { | ||||
|         this.element = element.nativeElement; | ||||
| 
 | ||||
|         this.element.setAttribute('role', 'tabpanel'); | ||||
|         this.element.setAttribute('tabindex', '0'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         this.id = this.id || 'core-tab-' + CoreUtils.instance.getUniqueId('CoreTabComponent'); | ||||
|         this.element.setAttribute('aria-labelledby', this.id + '-tab'); | ||||
|         this.element.setAttribute('id', this.id); | ||||
| 
 | ||||
|         this.tabs.addTab(this); | ||||
|         this.initialized = true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component destroyed. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.tabs.removeTab(this); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Select tab. | ||||
|      */ | ||||
|     async selectTab(): Promise<void> { | ||||
|         this.element.classList.add('selected'); | ||||
| 
 | ||||
|         this.tabElement = this.tabElement || document.getElementById(this.id + '-tab'); | ||||
|         this.tabElement?.setAttribute('aria-selected', 'true'); | ||||
| 
 | ||||
|         this.loaded = true; | ||||
|         this.ionSelect.emit(this); | ||||
|         this.showHideNavBarButtons(true); | ||||
| 
 | ||||
|         // Setup tab scrolling.
 | ||||
|         this.tabs.listenContentScroll(this.element, this.id!); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Unselect tab. | ||||
|      */ | ||||
|     unselectTab(): void { | ||||
|         this.tabElement?.setAttribute('aria-selected', 'false'); | ||||
|         this.element.classList.remove('selected'); | ||||
|         this.showHideNavBarButtons(false); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show all hide all children navbar buttons. | ||||
|      * | ||||
|      * @param show Whether to show or hide the buttons. | ||||
|      */ | ||||
|     protected showHideNavBarButtons(show: boolean): void { | ||||
|         const elements = this.element.querySelectorAll('core-navbar-buttons'); | ||||
|         elements.forEach((element) => { | ||||
|             const instance: CoreNavBarButtonsComponent = CoreDomUtils.instance.getInstanceByElement(element); | ||||
| 
 | ||||
|             if (instance) { | ||||
|                 instance.forceHide(!show); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -69,4 +69,26 @@ | ||||
|             transform: translateY(0) !important; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     ::ng-deep { | ||||
|         core-tab, .core-tab { | ||||
|             display: none; | ||||
|             height: 100%; | ||||
|             position: relative; | ||||
|             z-index: 1; | ||||
| 
 | ||||
|             &.selected { | ||||
|                 display: block; | ||||
|             } | ||||
| 
 | ||||
|             ion-header { | ||||
|                 display: none; | ||||
|             } | ||||
| 
 | ||||
|             .fixed-content, .scroll-content { | ||||
|                 margin-top: 0 !important; | ||||
|                 margin-bottom: 0 !important; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -15,652 +15,157 @@ | ||||
| import { | ||||
|     Component, | ||||
|     Input, | ||||
|     Output, | ||||
|     EventEmitter, | ||||
|     OnInit, | ||||
|     OnChanges, | ||||
|     OnDestroy, | ||||
|     AfterViewInit, | ||||
|     ViewChild, | ||||
|     ElementRef, | ||||
| } from '@angular/core'; | ||||
| import { Platform, IonSlides, IonTabs } from '@ionic/angular'; | ||||
| import { TranslateService } from '@ngx-translate/core'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { CoreApp } from '@services/app'; | ||||
| import { CoreConfig } from '@services/config'; | ||||
| import { CoreConstants } from '@/core/constants'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| 
 | ||||
| import { CoreTabsBaseComponent } from '@classes/tabs'; | ||||
| import { CoreTabComponent } from './tab'; | ||||
| 
 | ||||
| /** | ||||
|  * This component displays some top scrollable tabs that will autohide on vertical scroll. | ||||
|  * Unlike core-tabs-outlet, this component does NOT use Angular router. | ||||
|  * | ||||
|  * Example usage: | ||||
|  * | ||||
|  * <core-tabs selectedIndex="1" [tabs]="tabs"></core-tabs> | ||||
|  * | ||||
|  * Tab contents will only be shown if that tab is selected. | ||||
|  * | ||||
|  * @todo: Test behaviour when tabs are added late. | ||||
|  * @todo: Test RTL and tab history. | ||||
|  * <core-tabs selectedIndex="1"> | ||||
|  *     <core-tab [title]="'core.courses.timeline' | translate" (ionSelect)="switchTab('timeline')"> | ||||
|  *         <ng-template> <!-- This ng-template is required, @see CoreTabComponent. --> | ||||
|  *             <!-- Tab contents. --> | ||||
|  *         </ng-template> | ||||
|  *     </core-tab> | ||||
|  * </core-tabs> | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'core-tabs', | ||||
|     templateUrl: 'core-tabs.html', | ||||
|     styleUrls: ['tabs.scss'], | ||||
| }) | ||||
| export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { | ||||
| export class CoreTabsComponent extends CoreTabsBaseComponent<CoreTabComponent> implements AfterViewInit { | ||||
| 
 | ||||
|     @Input() parentScrollable = false; // Determine if the scroll should be in the parent content or the tab itself.
 | ||||
| 
 | ||||
|     // Minimum tab's width to display fully the word "Competencies" which is the longest tab in the app.
 | ||||
|     protected static readonly MIN_TAB_WIDTH = 107; | ||||
|     // Max height that allows tab hiding.
 | ||||
|     protected static readonly MAX_HEIGHT_TO_HIDE_TABS = 768; | ||||
|     @ViewChild('originalTabs') originalTabsRef?: ElementRef; | ||||
| 
 | ||||
|     @Input() protected selectedIndex = 0; // Index of the tab to select.
 | ||||
|     @Input() hideUntil = false; // Determine when should the contents be shown.
 | ||||
|     /** | ||||
|      * Determine tabs layout. | ||||
|      */ | ||||
|     @Input() layout: 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide' = 'icon-hide'; | ||||
|     @Input() tabs: CoreTab[] = []; | ||||
|     @Output() protected ionChange: EventEmitter<CoreTab> = new EventEmitter<CoreTab>(); // Emitted when the tab changes.
 | ||||
| 
 | ||||
|     @ViewChild(IonSlides) protected slides?: IonSlides; | ||||
|     @ViewChild(IonTabs) protected ionTabs?: IonTabs; | ||||
| 
 | ||||
|     selected?: string; // Selected tab id.
 | ||||
|     showPrevButton = false; | ||||
|     showNextButton = false; | ||||
|     maxSlides = 3; | ||||
|     numTabsShown = 0; | ||||
|     direction = 'ltr'; | ||||
|     description = ''; | ||||
|     lastScroll = 0; | ||||
|     slidesOpts = { | ||||
|         initialSlide: 0, | ||||
|         slidesPerView: 3, | ||||
|         centerInsufficientSlides: true, | ||||
|     }; | ||||
| 
 | ||||
|     protected initialized = false; | ||||
|     protected afterViewInitTriggered = false; | ||||
| 
 | ||||
|     protected tabBarHeight = 0; | ||||
|     protected tabBarElement?: HTMLIonTabBarElement; // The top tab bar element.
 | ||||
|     protected tabsElement?: HTMLIonTabsElement; // The ionTabs native Element.
 | ||||
|     protected tabsShown = true; | ||||
|     protected resizeFunction?: EventListenerOrEventListenerObject; | ||||
|     protected isDestroyed = false; | ||||
|     protected isCurrentView = true; | ||||
|     protected shouldSlideToInitial = false; // Whether we need to slide to the initial slide because it's out of view.
 | ||||
|     protected hasSliddenToInitial = false; // Whether we've already slidden to the initial slide or there was no need.
 | ||||
|     protected selectHistory: string[] = []; | ||||
| 
 | ||||
|     protected firstSelectedTab?: string; // ID of the first selected tab to control history.
 | ||||
|     protected unregisterBackButtonAction: any; | ||||
|     protected languageChangedSubscription: Subscription; | ||||
|     protected isInTransition = false; // Weather Slides is in transition.
 | ||||
|     protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any
 | ||||
|     protected slidesSwiperLoaded = false; | ||||
|     protected stackEventsSubscription?: Subscription; | ||||
|     protected originalTabsContainer?: HTMLElement; // The container of the original tabs. It will include each tab's content.
 | ||||
| 
 | ||||
|     constructor( | ||||
|         protected element: ElementRef, | ||||
|         platform: Platform, | ||||
|         translate: TranslateService, | ||||
|         element: ElementRef, | ||||
|     ) { | ||||
|         this.direction = platform.isRTL ? 'rtl' : 'ltr'; | ||||
| 
 | ||||
|         // Change the side when the language changes.
 | ||||
|         this.languageChangedSubscription = translate.onLangChange.subscribe(() => { | ||||
|             setTimeout(() => { | ||||
|                 this.direction = platform.isRTL ? 'rtl' : 'ltr'; | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         this.tabs.forEach((tab) => { | ||||
|             this.initTab(tab); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Init tab info. | ||||
|      * | ||||
|      * @param tab Tab class. | ||||
|      */ | ||||
|     protected initTab(tab: CoreTab): void { | ||||
|         tab.id = tab.id || 'core-tab-' + CoreUtils.instance.getUniqueId('CoreTabsComponent'); | ||||
|         if (typeof tab.enabled == 'undefined') { | ||||
|             tab.enabled = true; | ||||
|         } | ||||
|         super(element); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * View has been initialized. | ||||
|      */ | ||||
|     async ngAfterViewInit(): Promise<void> { | ||||
|         super.ngAfterViewInit(); | ||||
| 
 | ||||
|         if (this.isDestroyed) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.stackEventsSubscription = this.ionTabs!.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => { | ||||
|             if (this.isCurrentView) { | ||||
|                 const content = stackEvent.enteringView.element.querySelector('ion-content'); | ||||
| 
 | ||||
|                 this.showHideNavBarButtons(stackEvent.enteringView.element.tagName); | ||||
|                 if (content) { | ||||
|                     const scroll = await content.getScrollElement(); | ||||
|                     content.scrollEvents = true; | ||||
|                     content.addEventListener('ionScroll', (e: CustomEvent): void => { | ||||
|                         this.showHideTabs(e, scroll); | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         this.tabBarElement = this.element.nativeElement.querySelector('ion-tab-bar'); | ||||
|         this.tabsElement = this.element.nativeElement.querySelector('ion-tabs'); | ||||
| 
 | ||||
|         this.slidesSwiper = await this.slides?.getSwiper(); | ||||
|         this.slidesSwiper.once('progress', () => { | ||||
|             this.slidesSwiperLoaded = true; | ||||
|             this.calculateSlides(); | ||||
|         }); | ||||
| 
 | ||||
|         this.afterViewInitTriggered = true; | ||||
| 
 | ||||
|         if (!this.initialized && this.hideUntil) { | ||||
|             // Tabs should be shown, initialize them.
 | ||||
|             await this.initializeTabs(); | ||||
|         } | ||||
| 
 | ||||
|         this.resizeFunction = this.windowResized.bind(this); | ||||
| 
 | ||||
|         window.addEventListener('resize', this.resizeFunction!); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Detect changes on input properties. | ||||
|      */ | ||||
|     ngOnChanges(): void { | ||||
|         this.tabs.forEach((tab) => { | ||||
|             this.initTab(tab); | ||||
|         }); | ||||
| 
 | ||||
|         // We need to wait for ngAfterViewInit because we need core-tab components to be executed.
 | ||||
|         if (!this.initialized && this.hideUntil && this.afterViewInitTriggered) { | ||||
|             // Tabs should be shown, initialize them.
 | ||||
|             // Use a setTimeout so child core-tab update their inputs before initializing the tabs.
 | ||||
|             setTimeout(() => { | ||||
|                 this.initializeTabs(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * User entered the page that contains the component. | ||||
|      */ | ||||
|     ionViewDidEnter(): void { | ||||
|         this.isCurrentView = true; | ||||
| 
 | ||||
|         this.calculateSlides(); | ||||
| 
 | ||||
|         this.registerBackButtonAction(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Register back button action. | ||||
|      */ | ||||
|     protected registerBackButtonAction(): void { | ||||
|         this.unregisterBackButtonAction = CoreApp.instance.registerBackButtonAction(() => { | ||||
|             // The previous page in history is not the last one, we need the previous one.
 | ||||
|             if (this.selectHistory.length > 1) { | ||||
|                 const tabIndex = this.selectHistory[this.selectHistory.length - 2]; | ||||
| 
 | ||||
|                 // Remove curent and previous tabs from history.
 | ||||
|                 this.selectHistory = this.selectHistory.filter((tabId) => this.selected != tabId && tabIndex != tabId); | ||||
| 
 | ||||
|                 this.selectTab(tabIndex); | ||||
| 
 | ||||
|                 return true; | ||||
|             } else if (this.selected != this.firstSelectedTab) { | ||||
|                 // All history is gone but we are not in the first selected tab.
 | ||||
|                 this.selectHistory = []; | ||||
| 
 | ||||
|                 this.selectTab(this.firstSelectedTab!); | ||||
| 
 | ||||
|                 return true; | ||||
|             } | ||||
| 
 | ||||
|             return false; | ||||
|         }, 750); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * User left the page that contains the component. | ||||
|      */ | ||||
|     ionViewDidLeave(): void { | ||||
|         // Unregister the custom back button action for this page
 | ||||
|         this.unregisterBackButtonAction && this.unregisterBackButtonAction(); | ||||
| 
 | ||||
|         this.isCurrentView = false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate slides. | ||||
|      */ | ||||
|     protected async calculateSlides(): Promise<void> { | ||||
|         if (!this.isCurrentView || !this.initialized) { | ||||
|             // Don't calculate if component isn't in current view, the calculations are wrong.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.tabsShown) { | ||||
|             if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) { | ||||
|                 // Ensure tabbar is shown.
 | ||||
|                 this.tabsShown = true; | ||||
|                 this.tabBarElement!.classList.remove('tabs-hidden'); | ||||
|                 this.lastScroll = 0; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         await this.calculateMaxSlides(); | ||||
| 
 | ||||
|         this.updateSlides(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate the tab bar height. | ||||
|      */ | ||||
|     protected calculateTabBarHeight(): void { | ||||
|         if (!this.tabBarElement || !this.tabsElement) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.tabBarHeight = this.tabBarElement.offsetHeight; | ||||
| 
 | ||||
|         if (this.tabsShown) { | ||||
|             // Smooth translation.
 | ||||
|             this.tabsElement.style.top = - this.lastScroll + 'px'; | ||||
|             this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px'; | ||||
|         } else { | ||||
|             this.tabBarElement.classList.add('tabs-hidden'); | ||||
|             this.tabsElement.style.top = '0'; | ||||
|             this.tabsElement.style.height = ''; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the tab on a index. | ||||
|      * | ||||
|      * @param tabId Tab ID. | ||||
|      * @return Selected tab. | ||||
|      */ | ||||
|     protected getTabIndex(tabId: string): number { | ||||
|         return this.tabs.findIndex((tab) => tabId == tab.id); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the current selected tab. | ||||
|      * | ||||
|      * @return Selected tab. | ||||
|      */ | ||||
|     getSelected(): CoreTab | undefined { | ||||
|         const index = this.selected && this.getTabIndex(this.selected); | ||||
| 
 | ||||
|         return index && index >= 0 ? this.tabs[index] : undefined; | ||||
|         this.tabsElement = this.element.nativeElement; | ||||
|         this.originalTabsContainer = this.originalTabsRef?.nativeElement; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize the tabs, determining the first tab to be shown. | ||||
|      */ | ||||
|     protected async initializeTabs(): Promise<void> { | ||||
|         let selectedTab: CoreTab | undefined = this.tabs[this.selectedIndex || 0] || undefined; | ||||
|         await super.initializeTabs(); | ||||
| 
 | ||||
|         if (!selectedTab || !selectedTab.enabled) { | ||||
|             // The tab is not enabled or not shown. Get the first tab that is enabled.
 | ||||
|             selectedTab = this.tabs.find((tab) => tab.enabled) || undefined; | ||||
|         // @todo: Is this still needed?
 | ||||
|         // if (this.content) {
 | ||||
|         //     if (!this.parentScrollable) {
 | ||||
|         //         // Parent scroll element (if core-tabs is inside a ion-content).
 | ||||
|         //         const scroll = await this.content.getScrollElement();
 | ||||
|         //         if (scroll) {
 | ||||
|         //             scroll.classList.add('no-scroll');
 | ||||
|         //         }
 | ||||
|         //     } else {
 | ||||
|         //         this.originalTabsContainer?.classList.add('no-scroll');
 | ||||
|         //     }
 | ||||
|         // }
 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Add a new tab if it isn't already in the list of tabs. | ||||
|      * | ||||
|      * @param tab The tab to add. | ||||
|      */ | ||||
|     addTab(tab: CoreTabComponent): void { | ||||
|         // Check if tab is already in the list.
 | ||||
|         if (this.getTabIndex(tab.id!) == -1) { | ||||
|             this.tabs.push(tab); | ||||
|             this.sortTabs(); | ||||
| 
 | ||||
|             setTimeout(() => { | ||||
|                 this.calculateSlides(); | ||||
|             }); | ||||
| 
 | ||||
|             if (this.initialized && this.tabs.length > 1 && this.tabBarHeight == 0) { | ||||
|                 // Calculate the tabBarHeight again now that there is more than 1 tab and the bar will be seen.
 | ||||
|                 // Use timeout to wait for the view to be rendered. 0 ms should be enough, use 50 to be sure.
 | ||||
|                 setTimeout(() => { | ||||
|                     this.calculateTabBarHeight(); | ||||
|                 }, 50); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|         if (!selectedTab) { | ||||
|             return; | ||||
|         } | ||||
|     /** | ||||
|      * Remove a tab from the list of tabs. | ||||
|      * | ||||
|      * @param tab The tab to remove. | ||||
|      */ | ||||
|     removeTab(tab: CoreTabComponent): void { | ||||
|         const index = this.getTabIndex(tab.id!); | ||||
|         this.tabs.splice(index, 1); | ||||
| 
 | ||||
|         this.firstSelectedTab = selectedTab.id!; | ||||
|         this.selectTab(this.firstSelectedTab); | ||||
| 
 | ||||
|         // Setup tab scrolling.
 | ||||
|         this.calculateTabBarHeight(); | ||||
| 
 | ||||
|         this.initialized = true; | ||||
| 
 | ||||
|         // Check which arrows should be shown.
 | ||||
|         this.calculateSlides(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Method executed when the slides are changed. | ||||
|      */ | ||||
|     async slideChanged(): Promise<void> { | ||||
|         if (!this.slidesSwiperLoaded) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.isInTransition = false; | ||||
|         const slidesCount = await this.slides?.length() || 0; | ||||
|         if (slidesCount > 0) { | ||||
|             this.showPrevButton = !await this.slides?.isBeginning(); | ||||
|             this.showNextButton = !await this.slides?.isEnd(); | ||||
|         } else { | ||||
|             this.showPrevButton = false; | ||||
|             this.showNextButton = false; | ||||
|         } | ||||
| 
 | ||||
|         const currentIndex = await this.slides!.getActiveIndex(); | ||||
|         if (this.shouldSlideToInitial && currentIndex != this.selectedIndex) { | ||||
|             // Current tab has changed, don't slide to initial anymore.
 | ||||
|             this.shouldSlideToInitial = false; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Updates the number of slides to show. | ||||
|      */ | ||||
|     protected async updateSlides(): Promise<void> { | ||||
|         this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0); | ||||
| 
 | ||||
|         this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; | ||||
| 
 | ||||
|         this.calculateTabBarHeight(); | ||||
|         await this.slides!.update(); | ||||
| 
 | ||||
|         if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { | ||||
|             this.hasSliddenToInitial = true; | ||||
|             this.shouldSlideToInitial = true; | ||||
| 
 | ||||
|             setTimeout(() => { | ||||
|                 if (this.shouldSlideToInitial) { | ||||
|                     this.slides!.slideTo(this.selectedIndex, 0); | ||||
|                     this.shouldSlideToInitial = false; | ||||
|                 } | ||||
|             }, 400); | ||||
| 
 | ||||
|             return; | ||||
|         } else if (this.selectedIndex) { | ||||
|             this.hasSliddenToInitial = true; | ||||
|         } | ||||
| 
 | ||||
|         setTimeout(() => { | ||||
|             this.slideChanged(); // Call slide changed again, sometimes the slide active index takes a while to be updated.
 | ||||
|         }, 400); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate the number of slides that can fit on the screen. | ||||
|      */ | ||||
|     protected async calculateMaxSlides(): Promise<void> { | ||||
|         if (!this.slidesSwiperLoaded) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.maxSlides = 3; | ||||
|         const width = this.slidesSwiper.width; | ||||
|         if (width) { | ||||
|             const fontSize = await | ||||
|             CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]); | ||||
| 
 | ||||
|             this.maxSlides = Math.floor(width / (fontSize / CoreConstants.CONFIG.font_sizes[0] * | ||||
|                 CoreTabsComponent.MIN_TAB_WIDTH)); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Method that shows the next tab. | ||||
|      */ | ||||
|     async slideNext(): Promise<void> { | ||||
|         // Stop if slides are in transition.
 | ||||
|         if (!this.showNextButton || this.isInTransition) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (await this.slides!.isBeginning()) { | ||||
|             // Slide to the second page.
 | ||||
|             this.slides!.slideTo(this.maxSlides); | ||||
|         } else { | ||||
|             const currentIndex = await this.slides!.getActiveIndex(); | ||||
|             if (typeof currentIndex !== 'undefined') { | ||||
|                 const nextSlideIndex = currentIndex + this.maxSlides; | ||||
|                 this.isInTransition = true; | ||||
|                 if (nextSlideIndex < this.numTabsShown) { | ||||
|                     // Slide to the next page.
 | ||||
|                     await this.slides!.slideTo(nextSlideIndex); | ||||
|                 } else { | ||||
|                     // Slide to the latest slide.
 | ||||
|                     await this.slides!.slideTo(this.numTabsShown - 1); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Method that shows the previous tab. | ||||
|      */ | ||||
|     async slidePrev(): Promise<void> { | ||||
|         // Stop if slides are in transition.
 | ||||
|         if (!this.showPrevButton || this.isInTransition) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (await this.slides!.isEnd()) { | ||||
|             this.slides!.slideTo(this.numTabsShown - this.maxSlides * 2); | ||||
|             // Slide to the previous of the latest page.
 | ||||
|         } else { | ||||
|             const currentIndex = await this.slides!.getActiveIndex(); | ||||
|             if (typeof currentIndex !== 'undefined') { | ||||
|                 const prevSlideIndex = currentIndex - this.maxSlides; | ||||
|                 this.isInTransition = true; | ||||
|                 if (prevSlideIndex >= 0) { | ||||
|                     // Slide to the previous page.
 | ||||
|                     await this.slides!.slideTo(prevSlideIndex); | ||||
|                 } else { | ||||
|                     // Slide to the first page.
 | ||||
|                     await this.slides!.slideTo(0); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show or hide the tabs. This is used when the user is scrolling inside a tab. | ||||
|      * Load the tab. | ||||
|      * | ||||
|      * @param scrollEvent Scroll event to check scroll position. | ||||
|      * @param content Content element to check measures. | ||||
|      * @param tabToSelect Tab to load. | ||||
|      * @return Promise resolved with true if tab is successfully loaded. | ||||
|      */ | ||||
|     protected showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void { | ||||
|         if (!this.tabBarElement || !this.tabsElement || !content) { | ||||
|             return; | ||||
|         } | ||||
|     protected async loadTab(tabToSelect: CoreTabComponent): Promise<boolean> { | ||||
|         const currentTab = this.getSelected(); | ||||
|         currentTab?.unselectTab(); | ||||
|         tabToSelect.selectTab(); | ||||
| 
 | ||||
|         // Always show on very tall screens.
 | ||||
|         if (window.innerHeight >= CoreTabsComponent.MAX_HEIGHT_TO_HIDE_TABS) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.tabBarHeight && this.tabBarElement.offsetHeight != this.tabBarHeight) { | ||||
|             // Wrong tab height, recalculate it.
 | ||||
|             this.calculateTabBarHeight(); | ||||
|         } | ||||
| 
 | ||||
|         if (!this.tabBarHeight) { | ||||
|             // We don't have the tab bar height, this means the tab bar isn't shown.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const scroll = parseInt(scrollEvent.detail.scrollTop, 10); | ||||
|         if (scroll <= 0) { | ||||
|             // Ensure tabbar is shown.
 | ||||
|             this.tabsElement.style.top = '0'; | ||||
|             this.tabsElement.style.height = ''; | ||||
|             this.tabBarElement!.classList.remove('tabs-hidden'); | ||||
|             this.tabsShown = true; | ||||
|             this.lastScroll = 0; | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (scroll == this.lastScroll) { | ||||
|             // Ensure scroll has been modified to avoid flicks.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (this.tabsShown && scroll > this.tabBarHeight) { | ||||
|             this.tabsShown = false; | ||||
| 
 | ||||
|             // Hide tabs.
 | ||||
|             this.tabBarElement.classList.add('tabs-hidden'); | ||||
|             this.tabsElement.style.top = '0'; | ||||
|             this.tabsElement.style.height = ''; | ||||
|         } else if (!this.tabsShown && scroll <= this.tabBarHeight) { | ||||
|             this.tabsShown = true; | ||||
|             this.tabBarElement!.classList.remove('tabs-hidden'); | ||||
|         } | ||||
| 
 | ||||
|         if (this.tabsShown && content.scrollHeight > content.clientHeight + (this.tabBarHeight - scroll)) { | ||||
|             // Smooth translation.
 | ||||
|             this.tabsElement.style.top = - scroll + 'px'; | ||||
|             this.tabsElement.style.height = 'calc(100% + ' + scroll + 'px'; | ||||
|         } | ||||
|         // Use lastScroll after moving the tabs to avoid flickering.
 | ||||
|         this.lastScroll = parseInt(scrollEvent.detail.scrollTop, 10); | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Select a tab by ID. | ||||
|      * | ||||
|      * @param tabId Tab ID. | ||||
|      * @param e Event. | ||||
|      * @return Promise resolved when done. | ||||
|      * Sort the tabs, keeping the same order as in the original list. | ||||
|      */ | ||||
|     async selectTab(tabId: string, e?: Event): Promise<void> { | ||||
|         const index = this.tabs.findIndex((tab) => tabId == tab.id); | ||||
| 
 | ||||
|         return this.selectByIndex(index, e); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Select a tab by index. | ||||
|      * | ||||
|      * @param index Index to select. | ||||
|      * @param e Event. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async selectByIndex(index: number, e?: Event): Promise<void> { | ||||
|         if (index < 0 || index >= this.tabs.length) { | ||||
|             if (this.selected) { | ||||
|                 // Invalid index do not change tab.
 | ||||
|                 e?.preventDefault(); | ||||
|                 e?.stopPropagation(); | ||||
| 
 | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // Index isn't valid, select the first one.
 | ||||
|             index = 0; | ||||
|         } | ||||
| 
 | ||||
|         const tabToSelect = this.tabs[index]; | ||||
|         if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) { | ||||
|             // Already selected or not enabled.
 | ||||
|             e?.preventDefault(); | ||||
|             e?.stopPropagation(); | ||||
| 
 | ||||
|     protected sortTabs(): void { | ||||
|         if (!this.originalTabsContainer) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (this.selected) { | ||||
|             await this.slides!.slideTo(index); | ||||
|         } | ||||
|         const newTabs: CoreTabComponent[] = []; | ||||
| 
 | ||||
|         const ok = await CoreNavigator.instance.navigate(tabToSelect.page, { | ||||
|             params: tabToSelect.pageParams, | ||||
|         }); | ||||
| 
 | ||||
|         if (ok !== false) { | ||||
|             this.selectHistory.push(tabToSelect.id!); | ||||
|             this.selected = tabToSelect.id; | ||||
|             this.selectedIndex = index; | ||||
| 
 | ||||
|             this.ionChange.emit(tabToSelect); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get all child core-navbar-buttons and show or hide depending on the page state. | ||||
|      * We need to use querySelectorAll because ContentChildren doesn't work with ng-template. | ||||
|      * https://github.com/angular/angular/issues/14842
 | ||||
|      * | ||||
|      * @param activatedPageName Activated page name. | ||||
|      */ | ||||
|     protected showHideNavBarButtons(activatedPageName: string): void { | ||||
|         const elements = this.ionTabs!.outlet.nativeEl.querySelectorAll('core-navbar-buttons'); | ||||
|         const domUtils = CoreDomUtils.instance; | ||||
|         elements.forEach((element) => { | ||||
|             const instance: CoreNavBarButtonsComponent = domUtils.getInstanceByElement(element); | ||||
| 
 | ||||
|             if (instance) { | ||||
|                 const pagetagName = element.closest('.ion-page')?.tagName; | ||||
|                 instance.forceHide(activatedPageName != pagetagName); | ||||
|         this.tabs.forEach((tab) => { | ||||
|             const originalIndex = Array.prototype.indexOf.call(this.originalTabsContainer?.children, tab.element); | ||||
|             if (originalIndex != -1) { | ||||
|                 newTabs[originalIndex] = tab; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         this.tabs = newTabs; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Adapt tabs to a window resize. | ||||
|      * Function to call when the visibility of a tab has changed. | ||||
|      */ | ||||
|     protected windowResized(): void { | ||||
|         setTimeout(() => { | ||||
|             this.calculateSlides(); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component destroyed. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.isDestroyed = true; | ||||
| 
 | ||||
|         if (this.resizeFunction) { | ||||
|             window.removeEventListener('resize', this.resizeFunction); | ||||
|         } | ||||
|         this.stackEventsSubscription?.unsubscribe(); | ||||
|         this.languageChangedSubscription.unsubscribe(); | ||||
|     tabVisibilityChanged(): void { | ||||
|         this.calculateSlides(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Core Tab class. | ||||
|  */ | ||||
| export type CoreTab = { | ||||
|     page: string; // Page to navigate to.
 | ||||
|     title: string; // The translatable tab title.
 | ||||
|     id?: string; // Unique tab id.
 | ||||
|     class?: string; // Class, if needed.
 | ||||
|     icon?: string; // The tab icon.
 | ||||
|     badge?: string; // A badge to add in the tab.
 | ||||
|     badgeStyle?: string; // The badge color.
 | ||||
|     enabled?: boolean; // Whether the tab is enabled.
 | ||||
|     pageParams?: Params; // Page params.
 | ||||
| }; | ||||
|  | ||||
| @ -11,5 +11,5 @@ | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
|     <core-tabs [tabs]="tabs" [hideUntil]="loaded"></core-tabs> | ||||
|     <core-tabs-outlet [tabs]="tabs" [hideUntil]="loaded"></core-tabs-outlet> | ||||
| </ion-content> | ||||
| @ -15,7 +15,7 @@ | ||||
| import { Component, ViewChild, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { Params } from '@angular/router'; | ||||
| 
 | ||||
| import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs'; | ||||
| import { CoreTabsOutletTab, CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet'; | ||||
| import { CoreCourseFormatDelegate } from '../../services/format-delegate'; | ||||
| import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate'; | ||||
| import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; | ||||
| @ -35,7 +35,7 @@ import { CoreNavigator } from '@services/navigator'; | ||||
| }) | ||||
| export class CoreCourseIndexPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|     @ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent; | ||||
|     @ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent; | ||||
| 
 | ||||
|     title?: string; | ||||
|     course?: CoreCourseAnyCourseData; | ||||
| @ -45,7 +45,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { | ||||
|     protected currentPagePath = ''; | ||||
|     protected selectTabObserver: CoreEventObserver; | ||||
|     protected firstTabName?: string; | ||||
|     protected contentsTab: CoreTab = { | ||||
|     protected contentsTab: CoreTabsOutletTab = { | ||||
|         page: 'contents', | ||||
|         title: 'core.course.contents', | ||||
|         pageParams: {}, | ||||
| @ -183,6 +183,6 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| type CourseTab = CoreTab & { | ||||
| type CourseTab = CoreTabsOutletTab & { | ||||
|     name?: string; | ||||
| }; | ||||
|  | ||||
| @ -15,7 +15,8 @@ | ||||
| <ion-content> | ||||
|     <!-- @todo --> | ||||
|     <core-loading [hideUntil]="loaded"> | ||||
|         <core-tabs *ngIf="tabs.length > 0" [selectedIndex]="selectedTab" [hideUntil]="loaded" [tabs]="tabs"></core-tabs> | ||||
|         <core-tabs-outlet *ngIf="tabs.length > 0" [selectedIndex]="selectedTab" [hideUntil]="loaded" [tabs]="tabs"> | ||||
|         </core-tabs-outlet> | ||||
|         <ng-container *ngIf="tabs.length == 0"> | ||||
|             <core-empty-box icon="fas-home" [message]="'core.courses.nocourses' | translate"></core-empty-box> | ||||
|         </ng-container> | ||||
|  | ||||
| @ -17,7 +17,7 @@ import { Subscription } from 'rxjs'; | ||||
| 
 | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs'; | ||||
| import { CoreTabsOutletComponent, CoreTabsOutletTab } from '@components/tabs-outlet/tabs-outlet'; | ||||
| import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../../services/home-delegate'; | ||||
| 
 | ||||
| /** | ||||
| @ -30,10 +30,10 @@ import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../. | ||||
| }) | ||||
| export class CoreMainMenuHomePage implements OnInit { | ||||
| 
 | ||||
|     @ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent; | ||||
|     @ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent; | ||||
| 
 | ||||
|     siteName!: string; | ||||
|     tabs: CoreTab[] = []; | ||||
|     tabs: CoreTabsOutletTab[] = []; | ||||
|     loaded = false; | ||||
|     selectedTab?: number; | ||||
| 
 | ||||
|  | ||||
| @ -156,7 +156,7 @@ | ||||
|     --core-tab-color-active: var(--custom-tab-color-active, var(--core-color)); | ||||
|     --core-tab-border-color-active: var(--custom-tab-border-color-active, var(--core-color)); | ||||
| 
 | ||||
|     core-tabs { | ||||
|     core-tabs, core-tabs-outlet { | ||||
|         --background: var(--core-tabs-background); | ||||
|         ion-slide { | ||||
|             --background: var(--core-tab-background); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user