MOBILE-3648 core: Implement tabs component without router
parent
f1fbb75889
commit
80c8ee8cc3
|
@ -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 { CoreSplitViewComponent } from './split-view/split-view';
|
||||||
import { CoreEmptyBoxComponent } from './empty-box/empty-box';
|
import { CoreEmptyBoxComponent } from './empty-box/empty-box';
|
||||||
import { CoreTabsComponent } from './tabs/tabs';
|
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 { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading';
|
||||||
import { CoreProgressBarComponent } from './progress-bar/progress-bar';
|
import { CoreProgressBarComponent } from './progress-bar/progress-bar';
|
||||||
import { CoreContextMenuComponent } from './context-menu/context-menu';
|
import { CoreContextMenuComponent } from './context-menu/context-menu';
|
||||||
|
@ -61,6 +63,8 @@ import { CorePipesModule } from '@pipes/pipes.module';
|
||||||
CoreSplitViewComponent,
|
CoreSplitViewComponent,
|
||||||
CoreEmptyBoxComponent,
|
CoreEmptyBoxComponent,
|
||||||
CoreTabsComponent,
|
CoreTabsComponent,
|
||||||
|
CoreTabComponent,
|
||||||
|
CoreTabsOutletComponent,
|
||||||
CoreInfiniteLoadingComponent,
|
CoreInfiniteLoadingComponent,
|
||||||
CoreProgressBarComponent,
|
CoreProgressBarComponent,
|
||||||
CoreContextMenuComponent,
|
CoreContextMenuComponent,
|
||||||
|
@ -94,6 +98,8 @@ import { CorePipesModule } from '@pipes/pipes.module';
|
||||||
CoreSplitViewComponent,
|
CoreSplitViewComponent,
|
||||||
CoreEmptyBoxComponent,
|
CoreEmptyBoxComponent,
|
||||||
CoreTabsComponent,
|
CoreTabsComponent,
|
||||||
|
CoreTabComponent,
|
||||||
|
CoreTabsOutletComponent,
|
||||||
CoreInfiniteLoadingComponent,
|
CoreInfiniteLoadingComponent,
|
||||||
CoreProgressBarComponent,
|
CoreProgressBarComponent,
|
||||||
CoreContextMenuComponent,
|
CoreContextMenuComponent,
|
||||||
|
|
|
@ -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>
|
|
@ -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,5 +1,4 @@
|
||||||
<ion-tabs class="hide-header">
|
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1" #tabBar>
|
||||||
<ion-tab-bar slot="top" class="core-tabs-bar" [hidden]="!tabs || numTabsShown <= 1">
|
|
||||||
<ion-spinner *ngIf="!hideUntil"></ion-spinner>
|
<ion-spinner *ngIf="!hideUntil"></ion-spinner>
|
||||||
<ion-row *ngIf="hideUntil">
|
<ion-row *ngIf="hideUntil">
|
||||||
<ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1">
|
<ion-col class="col-with-arrow ion-no-padding" (click)="slidePrev()" size="1">
|
||||||
|
@ -9,16 +8,12 @@
|
||||||
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
|
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
|
||||||
[attr.aria-label]="description" aria-hidden="false">
|
[attr.aria-label]="description" aria-hidden="false">
|
||||||
<ng-container *ngFor="let tab of tabs">
|
<ng-container *ngFor="let tab of tabs">
|
||||||
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide"
|
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide {{tab.class}}"
|
||||||
[attr.aria-label]="tab.title | translate" role="tab" [attr.aria-controls]="tab.id" [id]="tab.id + '-tab'"
|
role="tab" [attr.aria-label]="tab.title | translate" [attr.aria-controls]="tab.id" [id]="tab.id! + '-tab'"
|
||||||
[tabindex]="selected == tab.id ? null : -1">
|
[tabindex]="selected == tab.id ? null : -1" (click)="selectTab(tab.id, $event)">
|
||||||
|
|
||||||
<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-icon *ngIf="tab.icon" [name]="tab.icon"></ion-icon>
|
||||||
<ion-label>{{ tab.title | translate}}</ion-label>
|
<ion-label>{{ tab.title | translate}}</ion-label>
|
||||||
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
|
<ion-badge *ngIf="tab.badge">{{ tab.badge }}</ion-badge>
|
||||||
</ion-tab-button>
|
|
||||||
</ion-slide>
|
</ion-slide>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ion-slides>
|
</ion-slides>
|
||||||
|
@ -28,4 +23,6 @@
|
||||||
</ion-col>
|
</ion-col>
|
||||||
</ion-row>
|
</ion-row>
|
||||||
</ion-tab-bar>
|
</ion-tab-bar>
|
||||||
</ion-tabs>
|
<div class="core-tabs-content-container" #originalTabs>
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
|
|
|
@ -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;
|
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 {
|
import {
|
||||||
Component,
|
Component,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
|
||||||
EventEmitter,
|
|
||||||
OnInit,
|
|
||||||
OnChanges,
|
|
||||||
OnDestroy,
|
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Platform, IonSlides, IonTabs } from '@ionic/angular';
|
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { CoreTabsBaseComponent } from '@classes/tabs';
|
||||||
import { Subscription } from 'rxjs';
|
import { CoreTabComponent } from './tab';
|
||||||
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';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component displays some top scrollable tabs that will autohide on vertical scroll.
|
* 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:
|
* Example usage:
|
||||||
*
|
*
|
||||||
* <core-tabs selectedIndex="1" [tabs]="tabs"></core-tabs>
|
* <core-tabs selectedIndex="1">
|
||||||
*
|
* <core-tab [title]="'core.courses.timeline' | translate" (ionSelect)="switchTab('timeline')">
|
||||||
* Tab contents will only be shown if that tab is selected.
|
* <ng-template> <!-- This ng-template is required, @see CoreTabComponent. -->
|
||||||
*
|
* <!-- Tab contents. -->
|
||||||
* @todo: Test behaviour when tabs are added late.
|
* </ng-template>
|
||||||
* @todo: Test RTL and tab history.
|
* </core-tab>
|
||||||
|
* </core-tabs>
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'core-tabs',
|
selector: 'core-tabs',
|
||||||
templateUrl: 'core-tabs.html',
|
templateUrl: 'core-tabs.html',
|
||||||
styleUrls: ['tabs.scss'],
|
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.
|
@ViewChild('originalTabs') originalTabsRef?: ElementRef;
|
||||||
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.
|
protected originalTabsContainer?: HTMLElement; // The container of the original tabs. It will include each tab's content.
|
||||||
@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;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected element: ElementRef,
|
element: ElementRef,
|
||||||
platform: Platform,
|
|
||||||
translate: TranslateService,
|
|
||||||
) {
|
) {
|
||||||
this.direction = platform.isRTL ? 'rtl' : 'ltr';
|
super(element);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View has been initialized.
|
* View has been initialized.
|
||||||
*/
|
*/
|
||||||
async ngAfterViewInit(): Promise<void> {
|
async ngAfterViewInit(): Promise<void> {
|
||||||
|
super.ngAfterViewInit();
|
||||||
|
|
||||||
if (this.isDestroyed) {
|
if (this.isDestroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stackEventsSubscription = this.ionTabs!.outlet.stackEvents.subscribe(async (stackEvent: StackEvent) => {
|
this.tabsElement = this.element.nativeElement;
|
||||||
if (this.isCurrentView) {
|
this.originalTabsContainer = this.originalTabsRef?.nativeElement;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the tabs, determining the first tab to be shown.
|
* Initialize the tabs, determining the first tab to be shown.
|
||||||
*/
|
*/
|
||||||
protected async initializeTabs(): Promise<void> {
|
protected async initializeTabs(): Promise<void> {
|
||||||
let selectedTab: CoreTab | undefined = this.tabs[this.selectedIndex || 0] || undefined;
|
await super.initializeTabs();
|
||||||
|
|
||||||
if (!selectedTab || !selectedTab.enabled) {
|
// @todo: Is this still needed?
|
||||||
// The tab is not enabled or not shown. Get the first tab that is enabled.
|
// if (this.content) {
|
||||||
selectedTab = this.tabs.find((tab) => tab.enabled) || undefined;
|
// 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');
|
||||||
|
// }
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedTab) {
|
/**
|
||||||
return;
|
* 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();
|
||||||
|
|
||||||
this.firstSelectedTab = selectedTab.id!;
|
setTimeout(() => {
|
||||||
this.selectTab(this.firstSelectedTab);
|
this.calculateSlides();
|
||||||
|
});
|
||||||
|
|
||||||
// Setup tab scrolling.
|
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();
|
this.calculateTabBarHeight();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.initialized = true;
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
// Check which arrows should be shown.
|
|
||||||
this.calculateSlides();
|
this.calculateSlides();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method executed when the slides are changed.
|
* Load the tab.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
*
|
||||||
* @param scrollEvent Scroll event to check scroll position.
|
* @param tabToSelect Tab to load.
|
||||||
* @param content Content element to check measures.
|
* @return Promise resolved with true if tab is successfully loaded.
|
||||||
*/
|
*/
|
||||||
protected showHideTabs(scrollEvent: CustomEvent, content: HTMLElement): void {
|
protected async loadTab(tabToSelect: CoreTabComponent): Promise<boolean> {
|
||||||
if (!this.tabBarElement || !this.tabsElement || !content) {
|
const currentTab = this.getSelected();
|
||||||
return;
|
currentTab?.unselectTab();
|
||||||
}
|
tabToSelect.selectTab();
|
||||||
|
|
||||||
// Always show on very tall screens.
|
return true;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a tab by ID.
|
* Sort the tabs, keeping the same order as in the original list.
|
||||||
*
|
|
||||||
* @param tabId Tab ID.
|
|
||||||
* @param e Event.
|
|
||||||
* @return Promise resolved when done.
|
|
||||||
*/
|
*/
|
||||||
async selectTab(tabId: string, e?: Event): Promise<void> {
|
protected sortTabs(): void {
|
||||||
const index = this.tabs.findIndex((tab) => tabId == tab.id);
|
if (!this.originalTabsContainer) {
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index isn't valid, select the first one.
|
const newTabs: CoreTabComponent[] = [];
|
||||||
index = 0;
|
|
||||||
|
this.tabs.forEach((tab) => {
|
||||||
|
const originalIndex = Array.prototype.indexOf.call(this.originalTabsContainer?.children, tab.element);
|
||||||
|
if (originalIndex != -1) {
|
||||||
|
newTabs[originalIndex] = tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 CoreNavigator.instance.navigate(tabToSelect.page, {
|
|
||||||
params: tabToSelect.pageParams,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ok !== false) {
|
this.tabs = newTabs;
|
||||||
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.
|
* Function to call when the visibility of a tab has changed.
|
||||||
* 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 {
|
tabVisibilityChanged(): 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapt tabs to a window resize.
|
|
||||||
*/
|
|
||||||
protected windowResized(): void {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.calculateSlides();
|
this.calculateSlides();
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component destroyed.
|
|
||||||
*/
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.isDestroyed = true;
|
|
||||||
|
|
||||||
if (this.resizeFunction) {
|
|
||||||
window.removeEventListener('resize', this.resizeFunction);
|
|
||||||
}
|
|
||||||
this.stackEventsSubscription?.unsubscribe();
|
|
||||||
this.languageChangedSubscription.unsubscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<core-tabs [tabs]="tabs" [hideUntil]="loaded"></core-tabs>
|
<core-tabs-outlet [tabs]="tabs" [hideUntil]="loaded"></core-tabs-outlet>
|
||||||
</ion-content>
|
</ion-content>
|
|
@ -15,7 +15,7 @@
|
||||||
import { Component, ViewChild, OnDestroy, OnInit } from '@angular/core';
|
import { Component, ViewChild, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { Params } from '@angular/router';
|
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 { CoreCourseFormatDelegate } from '../../services/format-delegate';
|
||||||
import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate';
|
import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate';
|
||||||
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
|
import { CoreCourseAnyCourseData } from '@features/courses/services/courses';
|
||||||
|
@ -35,7 +35,7 @@ import { CoreNavigator } from '@services/navigator';
|
||||||
})
|
})
|
||||||
export class CoreCourseIndexPage implements OnInit, OnDestroy {
|
export class CoreCourseIndexPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
@ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent;
|
@ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent;
|
||||||
|
|
||||||
title?: string;
|
title?: string;
|
||||||
course?: CoreCourseAnyCourseData;
|
course?: CoreCourseAnyCourseData;
|
||||||
|
@ -45,7 +45,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
|
||||||
protected currentPagePath = '';
|
protected currentPagePath = '';
|
||||||
protected selectTabObserver: CoreEventObserver;
|
protected selectTabObserver: CoreEventObserver;
|
||||||
protected firstTabName?: string;
|
protected firstTabName?: string;
|
||||||
protected contentsTab: CoreTab = {
|
protected contentsTab: CoreTabsOutletTab = {
|
||||||
page: 'contents',
|
page: 'contents',
|
||||||
title: 'core.course.contents',
|
title: 'core.course.contents',
|
||||||
pageParams: {},
|
pageParams: {},
|
||||||
|
@ -183,6 +183,6 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CourseTab = CoreTab & {
|
type CourseTab = CoreTabsOutletTab & {
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,7 +15,8 @@
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<!-- @todo -->
|
<!-- @todo -->
|
||||||
<core-loading [hideUntil]="loaded">
|
<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">
|
<ng-container *ngIf="tabs.length == 0">
|
||||||
<core-empty-box icon="fas-home" [message]="'core.courses.nocourses' | translate"></core-empty-box>
|
<core-empty-box icon="fas-home" [message]="'core.courses.nocourses' | translate"></core-empty-box>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
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';
|
import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../../services/home-delegate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -30,10 +30,10 @@ import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../.
|
||||||
})
|
})
|
||||||
export class CoreMainMenuHomePage implements OnInit {
|
export class CoreMainMenuHomePage implements OnInit {
|
||||||
|
|
||||||
@ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent;
|
@ViewChild(CoreTabsOutletComponent) tabsComponent?: CoreTabsOutletComponent;
|
||||||
|
|
||||||
siteName!: string;
|
siteName!: string;
|
||||||
tabs: CoreTab[] = [];
|
tabs: CoreTabsOutletTab[] = [];
|
||||||
loaded = false;
|
loaded = false;
|
||||||
selectedTab?: number;
|
selectedTab?: number;
|
||||||
|
|
||||||
|
|
|
@ -156,7 +156,7 @@
|
||||||
--core-tab-color-active: var(--custom-tab-color-active, var(--core-color));
|
--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-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);
|
--background: var(--core-tabs-background);
|
||||||
ion-slide {
|
ion-slide {
|
||||||
--background: var(--core-tab-background);
|
--background: var(--core-tab-background);
|
||||||
|
|
Loading…
Reference in New Issue