= {
diff --git a/src/core/directives/collapsible-footer.ts b/src/core/directives/collapsible-footer.ts
new file mode 100644
index 000000000..71fd0b7fd
--- /dev/null
+++ b/src/core/directives/collapsible-footer.ts
@@ -0,0 +1,158 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Directive, ElementRef, OnDestroy, OnInit } from '@angular/core';
+import { ScrollDetail } from '@ionic/core';
+import { IonContent } from '@ionic/angular';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreMath } from '@singletons/math';
+
+/**
+ * Directive to make an element fixed at the bottom collapsible when scrolling.
+ *
+ * Example usage:
+ *
+ *
+ */
+@Directive({
+ selector: '[collapsible-footer]',
+})
+export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
+
+ protected element: HTMLElement;
+ protected initialHeight = 0;
+ protected initialPaddingBottom = 0;
+ protected previousTop = 0;
+ protected previousHeight = 0;
+ protected stickTimeout?: number;
+ protected content?: HTMLIonContentElement | null;
+
+ constructor(el: ElementRef, protected ionContent: IonContent) {
+ this.element = el.nativeElement;
+ this.element.setAttribute('slot', 'fixed'); // Just in case somebody forgets to add it.
+ }
+
+ /**
+ * Setup scroll event listener.
+ *
+ * @param retries Number of retries left.
+ */
+ protected async listenScrollEvents(retries = 5): Promise {
+ // Already initialized.
+ if (this.initialHeight > 0) {
+ return;
+ }
+
+ this.initialHeight = this.element.getBoundingClientRect().height;
+
+ if (this.initialHeight == 0 && retries > 0) {
+ await CoreUtils.nextTicks(50);
+
+ this.listenScrollEvents(retries - 1);
+
+ return;
+ }
+
+ // Set a minimum height value.
+ this.initialHeight = this.initialHeight || 48;
+ this.previousHeight = this.initialHeight;
+
+ this.content = this.element.closest('ion-content');
+
+ if (!this.content) {
+ return;
+ }
+
+ this.content.classList.add('has-collapsible-footer');
+
+ // Move element to the nearest ion-content if it's not the parent.
+ if (this.element.parentElement?.nodeName != 'ION-CONTENT') {
+ this.content.appendChild(this.element);
+ }
+
+ // Set a padding to not overlap elements.
+ this.initialPaddingBottom = parseFloat(this.content.style.getPropertyValue('--padding-bottom') || '0');
+ this.content.style.setProperty('--padding-bottom', this.initialPaddingBottom + this.initialHeight + 'px');
+ const scroll = await this.content.getScrollElement();
+ this.content.scrollEvents = true;
+
+ this.setBarHeight(this.initialHeight);
+ this.content.addEventListener('ionScroll', (e: CustomEvent): void => {
+ if (!this.content) {
+ return;
+ }
+
+ this.onScroll(e.detail, scroll);
+ });
+
+ }
+
+ /**
+ * On scroll function.
+ *
+ * @param scrollDetail Scroll detail object.
+ * @param scrollElement Scroll element to calculate maxScroll.
+ */
+ protected onScroll(scrollDetail: ScrollDetail, scrollElement: HTMLElement): void {
+ const maxScroll = scrollElement.scrollHeight - scrollElement.offsetHeight;
+ if (scrollDetail.scrollTop <= 0 || scrollDetail.scrollTop >= maxScroll) {
+ // Reset.
+ this.setBarHeight(this.initialHeight);
+ } else {
+ let newHeight = this.previousHeight - (scrollDetail.scrollTop - this.previousTop);
+ newHeight = CoreMath.clamp(newHeight, 0, this.initialHeight);
+
+ this.setBarHeight(newHeight);
+ }
+ this.previousTop = scrollDetail.scrollTop;
+ }
+
+ /**
+ * Sets the bar height.
+ *
+ * @param height The new bar height.
+ */
+ protected setBarHeight(height: number): void {
+ if (this.stickTimeout) {
+ clearTimeout(this.stickTimeout);
+ }
+
+ this.element.classList.toggle('footer-collapsed', height <= 0);
+ this.element.classList.toggle('footer-expanded', height >= this.initialHeight);
+ this.content?.style.setProperty('--core-collapsible-footer-height', height + 'px');
+ this.previousHeight = height;
+
+ if (height > 0 && height < this.initialHeight) {
+ // Finish opening or closing the bar.
+ const newHeight = height < this.initialHeight / 2 ? 0 : this.initialHeight;
+
+ this.stickTimeout = window.setTimeout(() => this.setBarHeight(newHeight), 500);
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ ngOnInit(): void {
+ this.listenScrollEvents();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ async ngOnDestroy(): Promise {
+ this.content?.style.setProperty('--padding-bottom', this.initialPaddingBottom + 'px');
+ }
+
+}
diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts
index 42a2b3c79..9f518532f 100644
--- a/src/core/directives/directives.module.ts
+++ b/src/core/directives/directives.module.ts
@@ -30,6 +30,7 @@ import { CoreDownloadFileDirective } from './download-file';
import { CoreCollapsibleHeaderDirective } from './collapsible-header';
import { CoreSwipeNavigationDirective } from './swipe-navigation';
import { CoreCollapsibleItemDirective } from './collapsible-item';
+import { CoreCollapsibleFooterDirective } from './collapsible-footer';
@NgModule({
declarations: [
@@ -49,6 +50,7 @@ import { CoreCollapsibleItemDirective } from './collapsible-item';
CoreCollapsibleHeaderDirective,
CoreSwipeNavigationDirective,
CoreCollapsibleItemDirective,
+ CoreCollapsibleFooterDirective,
],
exports: [
CoreAutoFocusDirective,
@@ -67,6 +69,7 @@ import { CoreCollapsibleItemDirective } from './collapsible-item';
CoreCollapsibleHeaderDirective,
CoreSwipeNavigationDirective,
CoreCollapsibleItemDirective,
+ CoreCollapsibleFooterDirective,
],
})
export class CoreDirectivesModule {}
diff --git a/src/core/features/course/components/module-navigation/module-navigation.scss b/src/core/features/course/components/module-navigation/module-navigation.scss
index 48713703f..7ae45b850 100644
--- a/src/core/features/course/components/module-navigation/module-navigation.scss
+++ b/src/core/features/course/components/module-navigation/module-navigation.scss
@@ -1,7 +1,7 @@
@import "~theme/globals";
:host {
- --height: var(--core-navigation-height, var(--core-navigation-max-height));
+ --height: var(--core-navigation-max-height);
--background: var(--core-navigation-background);
--button-vertical-margin: 2px;
@@ -9,12 +9,8 @@
width: 100%;
background-color: var(--background);
display: block;
- bottom: 0;
- z-index: 3;
border-top: 1px solid var(--stroke);
- @include core-transition(all, 200ms);
-
core-loading {
text-align: center;
--loading-inline-min-height: var(--height);
@@ -27,11 +23,6 @@
}
}
-:host-context(.core-iframe-fullscreen) {
- opacity: 0 !important;
- height: 0 !important;
-}
-
:host-context(core-course-format.core-course-format-singleactivity) {
opacity: 0 !important;
height: 0 !important;
diff --git a/src/core/features/course/components/module-navigation/module-navigation.ts b/src/core/features/course/components/module-navigation/module-navigation.ts
index 5b6a4062f..af20ac47e 100644
--- a/src/core/features/course/components/module-navigation/module-navigation.ts
+++ b/src/core/features/course/components/module-navigation/module-navigation.ts
@@ -17,13 +17,11 @@ import { CoreCourse, CoreCourseProvider, CoreCourseWSSection } from '@features/c
import { CoreCourseModuleCompletionData, CoreCourseModuleData } from '@features/course/services/course-helper';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { IonContent } from '@ionic/angular';
-import { ScrollDetail } from '@ionic/core';
import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
-import { CoreMath } from '@singletons/math';
/**
* Component to show a button to go to the next resource/activity.
@@ -51,21 +49,11 @@ export class CoreCourseModuleNavigationComponent implements OnInit, OnDestroy {
loaded = false;
showCompletion = false; // Whether to show completion.
- protected element: HTMLElement;
- protected initialHeight = 0;
- protected initialPaddingBottom = 0;
- protected previousTop = 0;
- protected previousHeight = 0;
- protected stickTimeout?: number;
- protected content?: HTMLIonContentElement | null;
protected completionObserver: CoreEventObserver;
constructor(el: ElementRef, protected ionContent: IonContent) {
const siteId = CoreSites.getCurrentSiteId();
- this.element = el.nativeElement;
- this.element.setAttribute('slot', 'fixed');
-
this.completionObserver = CoreEvents.on(CoreEvents.COMPLETION_MODULE_VIEWED, async (data) => {
if (data && data.courseId == this.courseId) {
// Check if now there's a next module.
@@ -88,76 +76,14 @@ export class CoreCourseModuleNavigationComponent implements OnInit, OnDestroy {
await this.setNextAndPreviousModules(CoreSitesReadingStrategy.PREFER_CACHE);
} finally {
this.loaded = true;
-
- await CoreUtils.nextTicks(50);
- this.listenScrollEvents();
}
}
- /**
- * Setup scroll event listener.
- *
- * @param retries Number of retries left.
- */
- protected async listenScrollEvents(retries = 3): Promise {
- this.initialHeight = this.element.getBoundingClientRect().height;
-
- if (this.initialHeight == 0 && retries > 0) {
- await CoreUtils.nextTicks(50);
-
- this.listenScrollEvents(retries - 1);
-
- return;
- }
- // Set a minimum height value.
- this.initialHeight = this.initialHeight || 48;
- this.previousHeight = this.initialHeight;
-
- this.content = this.element.closest('ion-content');
-
- if (!this.content) {
- return;
- }
-
- // Special case where there's no navigation.
- const courseFormat = this.element.closest('core-course-format.core-course-format-singleactivity');
- if (courseFormat) {
- this.element.remove();
- this.ngOnDestroy();
-
- return;
- }
-
- this.content.classList.add('has-core-navigation');
-
- // Move element to the nearest ion-content if it's not the parent.
- if (this.element.parentElement?.nodeName != 'ION-CONTENT') {
- this.content.appendChild(this.element);
- }
-
- // Set a padding to not overlap elements.
- this.initialPaddingBottom = parseFloat(this.content.style.getPropertyValue('--padding-bottom') || '0');
- this.content.style.setProperty('--padding-bottom', this.initialPaddingBottom + this.initialHeight + 'px');
- const scroll = await this.content.getScrollElement();
- this.content.scrollEvents = true;
-
- this.setBarHeight(this.initialHeight);
- this.content.addEventListener('ionScroll', (e: CustomEvent): void => {
- if (!this.content) {
- return;
- }
-
- this.onScroll(e.detail, scroll);
- });
-
- }
-
/**
* @inheritdoc
*/
async ngOnDestroy(): Promise {
this.completionObserver.off();
- this.content?.style.setProperty('--padding-bottom', this.initialPaddingBottom + 'px');
}
/**
@@ -316,46 +242,4 @@ export class CoreCourseModuleNavigationComponent implements OnInit, OnDestroy {
}
}
- /**
- * On scroll function.
- *
- * @param scrollDetail Scroll detail object.
- * @param scrollElement Scroll element to calculate maxScroll.
- */
- protected onScroll(scrollDetail: ScrollDetail, scrollElement: HTMLElement): void {
- const maxScroll = scrollElement.scrollHeight - scrollElement.offsetHeight;
- if (scrollDetail.scrollTop <= 0 || scrollDetail.scrollTop >= maxScroll) {
- // Reset.
- this.setBarHeight(this.initialHeight);
- } else {
- let newHeight = this.previousHeight - (scrollDetail.scrollTop - this.previousTop);
- newHeight = CoreMath.clamp(newHeight, 0, this.initialHeight);
-
- this.setBarHeight(newHeight);
- }
- this.previousTop = scrollDetail.scrollTop;
- }
-
- /**
- * Sets the bar height.
- *
- * @param height The new bar height.
- */
- protected setBarHeight(height: number): void {
- if (this.stickTimeout) {
- clearTimeout(this.stickTimeout);
- }
-
- this.element.style.opacity = height <= 0 ? '0' : '1';
- this.content?.style.setProperty('--core-navigation-height', height + 'px');
- this.previousHeight = height;
-
- if (height > 0 && height < this.initialHeight) {
- // Finish opening or closing the bar.
- const newHeight = height < this.initialHeight / 2 ? 0 : this.initialHeight;
-
- this.stickTimeout = window.setTimeout(() => this.setBarHeight(newHeight), 500);
- }
- }
-
}
diff --git a/src/core/features/course/pages/module-preview/module-preview.html b/src/core/features/course/pages/module-preview/module-preview.html
index 010351d87..60efcbee6 100644
--- a/src/core/features/course/pages/module-preview/module-preview.html
+++ b/src/core/features/course/pages/module-preview/module-preview.html
@@ -44,6 +44,6 @@
-
diff --git a/src/core/features/siteplugins/components/module-index/core-siteplugins-module-index.html b/src/core/features/siteplugins/components/module-index/core-siteplugins-module-index.html
index 94574fcbe..b47edea6c 100644
--- a/src/core/features/siteplugins/components/module-index/core-siteplugins-module-index.html
+++ b/src/core/features/siteplugins/components/module-index/core-siteplugins-module-index.html
@@ -14,5 +14,5 @@
(onLoadingContent)="contentLoading()">
-
+
diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss
index 822b5b991..8261707ae 100644
--- a/src/theme/theme.base.scss
+++ b/src/theme/theme.base.scss
@@ -1124,7 +1124,7 @@ ion-fab[core-fab] {
}
}
-ion-content.has-core-navigation ion-fab {
+ion-content.has-collapsible-footer ion-fab {
bottom: calc(var(--core-navigation-height, 0px) + 10px);
@include core-transition(all, 200ms);
}
@@ -1442,6 +1442,30 @@ ion-grid.core-no-grid > ion-row {
@include collapsible-item();
}
+[collapsible-footer] {
+ &.footer-collapsed {
+ --core-collapsible-footer-height: 0;
+ opacity: 0;
+ }
+ &.footer-expanded {
+ --core-collapsible-footer-height: auto;
+ }
+
+ width: 100%;
+ bottom: 0;
+ z-index: 3;
+ height: var(--core-collapsible-footer-height, auto);
+ background-color: var(--core-collapsible-footer-background);
+ display: block;
+ border-top: 1px solid var(--stroke);
+ @include core-transition(all, 200ms);
+}
+
+.core-iframe-fullscreen [collapsible-footer] {
+ opacity: 0 !important;
+ height: 0 !important;
+}
+
ion-header.no-title {
--core-header-toolbar-border-width: 0;
--core-header-toolbar-background: transparent;
diff --git a/src/theme/theme.dark.scss b/src/theme/theme.dark.scss
index b5be2494e..358d341f1 100644
--- a/src/theme/theme.dark.scss
+++ b/src/theme/theme.dark.scss
@@ -131,6 +131,8 @@
--core-navigation-background: var(--contrast-background);
+ --core-collapsible-footer-background: var(--contrast-background);
+
--addon-messages-message-bg: var(--gray-800);
--addon-messages-message-activated-bg: var(--gray-700);
--addon-messages-message-note-text: var(--subdued-text-color);
diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss
index 58a0cc3a5..34fa46c45 100644
--- a/src/theme/theme.light.scss
+++ b/src/theme/theme.light.scss
@@ -310,9 +310,11 @@
--core-courseimage-on-course-size: 72px;
--core-courseimage-radius: var(--medium-radius);
- --core-navigation-max-height: 48px;
+ --core-navigation-height: 48px;
--core-navigation-background: var(--contrast-background);
+ --core-collapsible-footer-background: var(--contrast-background);
+
--core-user-menu-site-logo-max-height: 32px;
--addon-calendar-today-border-color: var(--primary);