From 80c8ee8cc3cea4eac0836dfd364f4ede49dc10c1 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 1 Feb 2021 12:32:10 +0100 Subject: [PATCH] MOBILE-3648 core: Implement tabs component without router --- src/core/classes/tabs.ts | 623 ++++++++++++++++ src/core/components/components.module.ts | 6 + .../tabs-outlet/core-tabs-outlet.html | 31 + .../components/tabs-outlet/tabs-outlet.ts | 176 +++++ src/core/components/tabs/core-tabs.html | 59 +- src/core/components/tabs/tab.ts | 147 ++++ src/core/components/tabs/tabs.scss | 22 + src/core/components/tabs/tabs.ts | 671 +++--------------- .../features/course/pages/index/index.html | 2 +- src/core/features/course/pages/index/index.ts | 8 +- .../features/mainmenu/pages/home/home.html | 3 +- src/core/features/mainmenu/pages/home/home.ts | 6 +- src/theme/theme.light.scss | 2 +- 13 files changed, 1132 insertions(+), 624 deletions(-) create mode 100644 src/core/classes/tabs.ts create mode 100644 src/core/components/tabs-outlet/core-tabs-outlet.html create mode 100644 src/core/components/tabs-outlet/tabs-outlet.ts create mode 100644 src/core/components/tabs/tab.ts diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts new file mode 100644 index 000000000..e9a3ee271 --- /dev/null +++ b/src/core/classes/tabs.ts @@ -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 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(); // 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 = {}; // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + 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 { + 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 { + // 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 { + 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. +}; diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 0155f8833..b467f6126 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -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, diff --git a/src/core/components/tabs-outlet/core-tabs-outlet.html b/src/core/components/tabs-outlet/core-tabs-outlet.html new file mode 100644 index 000000000..58c5efbeb --- /dev/null +++ b/src/core/components/tabs-outlet/core-tabs-outlet.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + {{ tab.title | translate}} + {{ tab.badge }} + + + + + + + + + + + diff --git a/src/core/components/tabs-outlet/tabs-outlet.ts b/src/core/components/tabs-outlet/tabs-outlet.ts new file mode 100644 index 000000000..d4df2c488 --- /dev/null +++ b/src/core/components/tabs-outlet/tabs-outlet.ts @@ -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: + * + * + * + * 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 + 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 { + 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 { + 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 { + 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. +}; diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index 815137ca6..947eff9db 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -1,31 +1,28 @@ - - - - - - - - - - - - - - - {{ tab.title | translate}} - {{ tab.badge }} - - - - - - - - - - - + + + + + + + + + + + + {{ tab.title | translate}} + {{ tab.badge }} + + + + + + + + + +
+ +
diff --git a/src/core/components/tabs/tab.ts b/src/core/components/tabs/tab.ts new file mode 100644 index 000000000..17876ff91 --- /dev/null +++ b/src/core/components/tabs/tab.ts @@ -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: + * + * + * + * + * + * + * + * + */ +@Component({ + selector: 'core-tab', + template: '', +}) +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 = new EventEmitter(); + + @ContentChild(TemplateRef) template?: TemplateRef; // 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 { + 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); + } + }); + } + +} diff --git a/src/core/components/tabs/tabs.scss b/src/core/components/tabs/tabs.scss index 3c926bc70..52e8a9074 100644 --- a/src/core/components/tabs/tabs.scss +++ b/src/core/components/tabs/tabs.scss @@ -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; + } + } + } } diff --git a/src/core/components/tabs/tabs.ts b/src/core/components/tabs/tabs.ts index a8eb31188..a06e28af0 100644 --- a/src/core/components/tabs/tabs.ts +++ b/src/core/components/tabs/tabs.ts @@ -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: * - * - * - * 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', templateUrl: 'core-tabs.html', styleUrls: ['tabs.scss'], }) -export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { +export class CoreTabsComponent extends CoreTabsBaseComponent 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 = new EventEmitter(); // 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 { - 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 { + 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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 { - // 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 { + 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 { - 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 { - 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. -}; diff --git a/src/core/features/course/pages/index/index.html b/src/core/features/course/pages/index/index.html index 221b8b63d..a974e5f75 100644 --- a/src/core/features/course/pages/index/index.html +++ b/src/core/features/course/pages/index/index.html @@ -11,5 +11,5 @@ - + \ No newline at end of file diff --git a/src/core/features/course/pages/index/index.ts b/src/core/features/course/pages/index/index.ts index 27f0a6b42..9c5b834e4 100644 --- a/src/core/features/course/pages/index/index.ts +++ b/src/core/features/course/pages/index/index.ts @@ -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; }; diff --git a/src/core/features/mainmenu/pages/home/home.html b/src/core/features/mainmenu/pages/home/home.html index ad107680e..fee27cc92 100644 --- a/src/core/features/mainmenu/pages/home/home.html +++ b/src/core/features/mainmenu/pages/home/home.html @@ -15,7 +15,8 @@ - + + diff --git a/src/core/features/mainmenu/pages/home/home.ts b/src/core/features/mainmenu/pages/home/home.ts index 005951458..1acc0bc13 100644 --- a/src/core/features/mainmenu/pages/home/home.ts +++ b/src/core/features/mainmenu/pages/home/home.ts @@ -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; diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index eb28548b9..40feb3112 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -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);