Merge pull request #3183 from crazyserver/MOBILE-3814

Mobile 3814
main
Noel De Martin 2022-03-16 14:04:07 +01:00 committed by GitHub
commit 52f0399b99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 431 additions and 281 deletions

View File

@ -271,7 +271,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
async ngOnInit(): Promise<void> {
this.route.queryParams.subscribe(async (params) => {
// When a child page loads this callback is triggered too.
const conversationId =CoreNavigator.getRouteNumberParam('conversationId', { params });
const conversationId = CoreNavigator.getRouteNumberParam('conversationId', { params });
const userId = CoreNavigator.getRouteNumberParam('userId', { params });
if (conversationId || userId) {
// Update the selected ones.

View File

@ -1,29 +1,39 @@
@import "~theme/globals";
:host {
::ng-deep {
core-user-avatar .core-avatar-extra {
--extra-icon-size: 16px;
::ng-deep core-user-avatar {
.core-avatar-extra-img,
core-mod-icon {
margin: 0 !important;
position: absolute;
right: -4px;
bottom: -4px;
}
core-user-avatar img.core-avatar-extra {
background: none;
width: 24px;
height: 24px;
border-radius: 0 !important;
}
core-user-avatar core-mod-icon.core-avatar-extra {
--size: 16px;
padding: 0.2rem;
}
.core-avatar-extra-img {
background: var(--background-color);
border-radius: var(--medium-radius);
img {
max-width: var(--extra-icon-size);
max-height: var(--extra-icon-size);
display: block;
}
}
core-mod-icon {
--size: var(--extra-icon-size);
}
}
.core-notification-icon {
width: 34px;
height: 34px;
margin: 10px !important;
width: var(--core-avatar-size);
height: var(--core-avatar-size);
@include margin(6px, 8px, 6px, 0px);
background: var(--background-color);
border-radius: var(--small-radius);
}
.item core-format-text ::ng-deep {

View File

@ -36,10 +36,11 @@
<core-user-avatar *ngIf="notification.useridfrom > 0" [user]="notification" slot="start"
[profileUrl]="notification.profileimageurlfrom" [fullname]="notification.userfromfullname"
[userId]="notification.useridfrom">
<img *ngIf="notification.iconurl && !notification.modname" [src]="notification.iconurl" alt="" role="presentation"
class="core-avatar-extra">
<div class="core-avatar-extra-img" *ngIf="notification.iconurl && !notification.modname">
<img [src]="notification.iconurl" alt="" role="presentation">
</div>
<core-mod-icon *ngIf="notification.modname" [modicon]="notification.iconurl" [modname]="notification.modname"
[showAlt]="false" class="core-avatar-extra">
[showAlt]="false">
</core-mod-icon>
</core-user-avatar>

View File

@ -13,8 +13,10 @@
<ion-item class="ion-text-wrap" [attr.aria-label]="subject">
<core-user-avatar *ngIf="userIdFrom > 0" slot="start" [userId]="userIdFrom" [profileUrl]="profileImageUrlFrom"
[fullname]="userFromFullName">
<img *ngIf="iconUrl && !modname" [src]="iconUrl" alt="" role="presentation" class="core-avatar-extra">
<core-mod-icon *ngIf="modname" [modicon]="iconUrl" [modname]="modname" [showAlt]="false" class="core-avatar-extra">
<div class="core-avatar-extra-img" *ngIf="iconUrl && !modname">
<img [src]="iconUrl" alt="" role="presentation">
</div>
<core-mod-icon *ngIf="modname" [modicon]="iconUrl" [modname]="modname" [showAlt]="false">
</core-mod-icon>
</core-user-avatar>

View File

@ -0,0 +1,24 @@
// (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.
/**
* Component that is not rendered immediately after being mounted.
*/
export interface AsyncComponent {
/**
* Wait until the component is fully rendered and ready.
*/
ready(): Promise<void>;
}

View File

@ -20,6 +20,7 @@ import { CoreAnimations } from '@components/animations';
import { Translate } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CorePromisedValue } from '@classes/promised-value';
import { AsyncComponent } from '@classes/async-component';
/**
* Component to show a loading spinner and message while data is being loaded.
@ -47,7 +48,7 @@ import { CorePromisedValue } from '@classes/promised-value';
styleUrls: ['loading.scss'],
animations: [CoreAnimations.SHOW_HIDE],
})
export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, AsyncComponent {
@Input() hideUntil: unknown; // Determine when should the contents be shown.
@Input() message?: string; // Message to show while loading.
@ -58,7 +59,7 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
uniqueId: string;
protected element: HTMLElement; // Current element.
loaded = false; // Only comes true once.
protected firstLoadedPromise = new CorePromisedValue<string>();
protected onReadyPromise = new CorePromisedValue<void>();
constructor(element: ElementRef) {
this.element = element.nativeElement;
@ -108,7 +109,7 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
if (!this.loaded && loaded) {
this.loaded = true; // Only comes true once.
this.firstLoadedPromise.resolve(this.uniqueId);
this.onReadyPromise.resolve();
}
// Event has been deprecated since app 4.0.
@ -119,12 +120,10 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit {
}
/**
* Wait the loading to finish.
*
* @return Promise resolved with the uniqueId when done.
* @inheritdoc
*/
async whenLoaded(): Promise<string> {
return await this.firstLoadedPromise;
async ready(): Promise<void> {
return await this.onReadyPromise;
}
}

View File

@ -9,7 +9,7 @@
</core-progress-bar>
</ion-col>
<ion-col class="ion-text-end ion-no-padding core-navigation-arrow" size="auto">
<ion-button [disabled]="nextIndex < 0" fill="clear" [attr.aria-label]="nextTitle" (click)="navigate(nextIndex)">
<ion-button [disabled]="nextIndex >= items.length" fill="clear" [attr.aria-label]="nextTitle" (click)="navigate(nextIndex)">
<ion-icon name="fas-chevron-right" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-col>

View File

@ -67,15 +67,21 @@ export class CoreNavigationBarComponent implements OnChanges {
this.progress = ((this.currentIndex + 1) / this.items.length) * 100;
this.progressText = `${this.currentIndex + 1} / ${this.items.length}`;
this.nextIndex = this.items[this.currentIndex + 1]?.enabled ? this.currentIndex + 1 : -1;
if (this.nextIndex >= 0) {
this.nextTitle = Translate.instant(this.nextTranslate, { $a: this.items[this.nextIndex].title || '' });
this.nextIndex = this.currentIndex + 1;
while (this.items[this.nextIndex] && !this.items[this.nextIndex].enabled) {
this.nextIndex++;
}
this.nextTitle = this.items[this.nextIndex]
? Translate.instant(this.nextTranslate, { $a: this.items[this.nextIndex].title || '' })
: '';
this.previousIndex = this.items[this.currentIndex - 1]?.enabled ? this.currentIndex - 1 : -1;
if (this.previousIndex >= 0) {
this.previousTitle = Translate.instant(this.previousTranslate, { $a: this.items[this.previousIndex].title || '' });
this.previousIndex = this.currentIndex - 1;
while (this.items[this.previousIndex] && !this.items[this.previousIndex].enabled) {
this.previousIndex--;
}
this.previousTitle = this.items[this.previousIndex]
? Translate.instant(this.previousTranslate, { $a: this.items[this.previousIndex].title || '' })
: '';
}
/**

View File

@ -78,8 +78,6 @@
}
:host-context(ion-item) {
margin-top: 6px;
margin-bottom: 6px;
@include margin(6px, 8px, 6px, 0px);
img {
min-width: var(--core-avatar-size);

View File

@ -66,7 +66,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
await this.domPromise;
await this.waitLoadingsDone();
await this.waitFormatTextsRendered(this.element);
await this.waitFormatTextsRendered();
this.content = this.element.closest('ion-content');
@ -156,15 +156,9 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
/**
* Wait until all <core-format-text> children inside the element are done rendering.
*
* @param element Element.
*/
protected async waitFormatTextsRendered(element: Element): Promise<void> {
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreFormatTextDirective>(
element,
'core-format-text',
'rendered',
);
protected async waitFormatTextsRendered(): Promise<void> {
await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-format-text', CoreFormatTextDirective);
}
/**
@ -210,13 +204,8 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy {
const scrollElement = await this.ionContent.getScrollElement();
await Promise.all([
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>
(scrollElement, 'core-loading', 'whenLoaded'),
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>(
this.element,
'core-loading',
'whenLoaded',
),
await CoreComponentsRegistry.waitComponentsReady(scrollElement, 'core-loading', CoreLoadingComponent),
await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-loading', CoreLoadingComponent),
]);
}

View File

@ -76,6 +76,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
protected enabled = true;
protected isWithinContent = false;
protected enteredPromise = new CorePromisedValue<void>();
protected mutationObserver?: MutationObserver;
constructor(el: ElementRef) {
this.collapsedHeader = el.nativeElement;
@ -126,6 +127,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
}
this.resizeListener?.off();
this.mutationObserver?.disconnect();
}
/**
@ -161,6 +163,31 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
this.subscriptions.push(CoreSettingsHelper.onDarkModeChange().subscribe(() => {
this.initializeFloatingTitle();
}));
this.mutationObserver = new MutationObserver(() => {
if (!this.expandedHeader) {
return;
}
const originalTitle = this.expandedHeader.querySelector('h1.collapsible-header-original-title') ||
this.expandedHeader.querySelector('h1') as HTMLHeadingElement;
const floatingTitleWrapper = originalTitle.parentElement as HTMLElement;
const floatingTitle = floatingTitleWrapper.querySelector('.collapsible-header-floating-title') as HTMLHeadingElement;
if (!floatingTitle || !originalTitle) {
return;
}
// Original title changed, change the contents.
const newFloatingTitle = originalTitle.cloneNode(true) as HTMLHeadingElement;
newFloatingTitle.classList.add('collapsible-header-floating-title');
newFloatingTitle.classList.remove('collapsible-header-original-title');
floatingTitleWrapper.replaceChild(newFloatingTitle, floatingTitle);
this.initializeFloatingTitle();
});
}
/**
@ -247,6 +274,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
floatingTitleWrapper.insertBefore(floatingTitle, originalTitle);
originalTitle.classList.add('collapsible-header-original-title');
this.mutationObserver?.observe(originalTitle, { childList: true, subtree: true });
}
const floatingTitleBoundingBox = floatingTitle.getBoundingClientRect();
@ -304,15 +332,13 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
/**
* Wait until all <core-loading> children inside the page.
*
* @return Promise resolved when loadings are done.
*/
protected async waitLoadingsDone(): Promise<void> {
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>(
this.page,
'core-loading',
'whenLoaded',
);
if (!this.page) {
return;
}
await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-loading', CoreLoadingComponent);
}
/**
@ -322,11 +348,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
* @return Promise resolved when texts are rendered.
*/
protected async waitFormatTextsRendered(element: Element): Promise<void> {
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreFormatTextDirective>(
element,
'core-format-text',
'rendered',
);
await CoreComponentsRegistry.waitComponentsReady(element, 'core-format-text', CoreFormatTextDirective);
}
/**

View File

@ -16,7 +16,6 @@ import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CoreLoadingComponent } from '@components/loading/loading';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreEventObserver } from '@singletons/events';
@ -103,28 +102,18 @@ export class CoreCollapsibleItemDirective implements OnInit, OnDestroy {
const page = this.element.closest('.ion-page');
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>(page, 'core-loading', 'whenLoaded');
if (!page) {
return;
}
await CoreComponentsRegistry.waitComponentsReady(page, 'core-loading', CoreLoadingComponent);
}
/**
* Wait until all <core-format-text> children inside the element are done rendering.
*
* @param element Element.
*/
protected async waitFormatTextsRendered(element: Element): Promise<void> {
let formatTextElements: HTMLElement[] = [];
if (this.element.tagName == 'CORE-FORMAT-TEXT') {
formatTextElements = [this.element];
} else {
formatTextElements = Array.from(element.querySelectorAll('core-format-text'));
}
const formatTexts = formatTextElements.map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective));
await Promise.all(formatTexts.map(formatText => formatText?.rendered()));
await CoreUtils.nextTick();
protected async waitFormatTextsRendered(): Promise<void> {
await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-format-text', CoreFormatTextDirective);
}
/**
@ -134,7 +123,7 @@ export class CoreCollapsibleItemDirective implements OnInit, OnDestroy {
// Remove max-height (if any) to calculate the real height.
this.element.classList.add('collapsible-loading-height');
await this.waitFormatTextsRendered(this.element);
await this.waitFormatTextsRendered();
this.expandedHeight = this.element.getBoundingClientRect().height;

View File

@ -33,6 +33,7 @@ import { CoreCollapsibleItemDirective } from './collapsible-item';
import { CoreCollapsibleFooterDirective } from './collapsible-footer';
import { CoreContentDirective } from './content';
import { CoreOnAppearDirective } from './on-appear';
import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes';
@NgModule({
declarations: [
@ -55,6 +56,7 @@ import { CoreOnAppearDirective } from './on-appear';
CoreCollapsibleItemDirective,
CoreCollapsibleFooterDirective,
CoreContentDirective,
CoreUpdateNonReactiveAttributesDirective,
],
exports: [
CoreAutoFocusDirective,
@ -76,6 +78,7 @@ import { CoreOnAppearDirective } from './on-appear';
CoreCollapsibleItemDirective,
CoreCollapsibleFooterDirective,
CoreContentDirective,
CoreUpdateNonReactiveAttributesDirective,
],
})
export class CoreDirectivesModule {}

View File

@ -43,6 +43,7 @@ import { CoreSubscriptions } from '@singletons/subscriptions';
import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreCollapsibleItemDirective } from './collapsible-item';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { AsyncComponent } from '@classes/async-component';
/**
* Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective
@ -56,7 +57,7 @@ import { CoreCancellablePromise } from '@classes/cancellable-promise';
@Directive({
selector: 'core-format-text',
})
export class CoreFormatTextDirective implements OnChanges, OnDestroy {
export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncComponent {
@ViewChild(CoreCollapsibleItemDirective) collapsible?: CoreCollapsibleItemDirective;
@ -137,9 +138,9 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy {
}
/**
* Wait until the text is fully rendered.
* @inheritdoc
*/
async rendered(): Promise<void> {
async ready(): Promise<void> {
if (!this.element.classList.contains('core-format-text-loading')) {
return;
}

View File

@ -27,7 +27,7 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
@Output() onAppear = new EventEmitter();
private element: HTMLElement;
protected domPromise?: CoreCancellablePromise<void>;
protected visiblePromise?: CoreCancellablePromise<void>;
constructor(element: ElementRef) {
this.element = element.nativeElement;
@ -37,9 +37,9 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.domPromise = CoreDomUtils.waitToBeInDOM(this.element);
this.visiblePromise = CoreDomUtils.waitToBeVisible(this.element);
await this.domPromise;
await this.visiblePromise;
this.onAppear.emit();
}
@ -48,7 +48,7 @@ export class CoreOnAppearDirective implements OnInit, OnDestroy {
* @inheritdoc
*/
ngOnDestroy(): void {
this.domPromise?.cancel();
this.visiblePromise?.cancel();
}
}

View File

@ -0,0 +1,68 @@
// (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';
/**
* Directive to observe mutations on some attributes and propagate them inside.
* Current supported attributes: ion-button.aria-label
*
* This is necessary in order to update some attributes that are not reactive, for example aria-label.
*
* @see https://github.com/ionic-team/ionic-framework/issues/21534
*/
@Directive({
selector: 'ion-button',
})
export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestroy {
protected element: HTMLIonButtonElement;
protected mutationObserver: MutationObserver;
constructor(element: ElementRef<HTMLIonButtonElement>) {
this.element = element.nativeElement;
this.mutationObserver = new MutationObserver(() => {
const ariaLabel = this.element.getAttribute('aria-label');
if (!ariaLabel) {
// Aria label unset by ionButton component (when first created).
return;
}
// Propagate label to button.
const button = this.element.shadowRoot?.querySelector('button');
button?.setAttribute('aria-label', ariaLabel);
});
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
await this.element.componentOnReady();
if (!this.element.getAttribute('aria-label')) {
return;
}
this.mutationObserver.observe(this.element, { attributes: true, attributeFilter: ['aria-label'] });
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.mutationObserver.disconnect();
}
}

View File

@ -24,7 +24,7 @@
<core-infinite-loading [enabled]="canLoadMore" (action)="showMoreActivities($event)"></core-infinite-loading>
</div>
<div collapsible-footer appearOnBottom *ngIf="displayCourseIndex && hasPreviousOrNextSections" slot="fixed">
<div collapsible-footer appearOnBottom *ngIf="displayCourseIndex && (previousSection || nextSection)" slot="fixed">
<div class="core-course-section-nav-buttons safe-area-padding-horizontal list-item-limited-width">
<ion-button *ngIf="previousSection" (click)="sectionChanged(previousSection)" expand="block"
[attr.aria-label]="('core.previous' | translate) + ': ' + previousSection.name">

View File

@ -92,7 +92,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
selectedSection?: CoreCourseSection;
previousSection?: CoreCourseSection;
nextSection?: CoreCourseSection;
hasPreviousOrNextSections = false;
allSectionsId: number = CoreCourseProvider.ALL_SECTIONS_ID;
stealthModulesSectionId: number = CoreCourseProvider.STEALTH_MODULES_SECTION_ID;
loaded = false;
@ -489,8 +488,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
this.showMoreActivities();
}
this.hasPreviousOrNextSections = !!this.previousSection || !!this.nextSection;
// Scroll to module if needed. Give more priority to the input.
const moduleIdToScroll = this.moduleId && previousValue === undefined ? this.moduleId : moduleId;
if (moduleIdToScroll) {
@ -507,8 +504,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
CoreCourse.logView(this.course.id, newSection.section, undefined, this.course.fullname),
);
}
this.invalidateSectionButtons();
}
/**
@ -563,25 +558,6 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
done?.();
}
/**
* Invalidate section buttons so that they are rendered again. This is necessary in order to update
* some attributes that are not reactive, for example aria-label.
*
* @see https://github.com/ionic-team/ionic-framework/issues/21534
*/
protected async invalidateSectionButtons(): Promise<void> {
const previousSection = this.previousSection;
const nextSection = this.nextSection;
this.previousSection = undefined;
this.nextSection = undefined;
await CoreUtils.nextTick();
this.previousSection = previousSection;
this.nextSection = nextSection;
}
/**
* Show more activities (only used when showing all the sections at the same time).
*

View File

@ -18,6 +18,7 @@ ion-item.item {
&.item-current {
--background: var(--primary-tint);
--color: var(--gray-900);
border: 0;
}
@ -34,6 +35,7 @@ ion-item.item {
&:hover {
background: var(--primary-shade);
color: var(--gray-100);
}
}
}
@ -43,7 +45,7 @@ ion-item.item {
margin: 3px;
border-radius: 50%;
&:hover {
background: var(--gray-300);
background: var(--secondary);
}
}

View File

@ -23,134 +23,139 @@
</ion-icon>
</div>
<div *ngIf="course" class="course-container">
<ion-item class="ion-text-wrap course-name">
<ion-label>
<p *ngIf="course.displayname && course.shortname && course.fullname != course.displayname"
class="core-course-shortname">
<core-format-text [text]="course.shortname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</p>
<h1>
<span class="sr-only">{{ 'core.courses.aria:coursename' | translate }}</span>
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</h1>
<ion-chip color="primary" *ngIf="course.categoryname" class="core-course-category ion-text-nowrap">
<span class="sr-only">{{ 'core.courses.aria:coursecategory' | translate }}</span>
<ion-label>
<core-format-text [text]="course.categoryname" contextLevel="coursecat" [contextInstanceId]="course.categoryid">
</core-format-text>
</ion-label>
</ion-chip>
</ion-label>
<ion-button fill="clear" [href]="courseUrl" core-link [showBrowserWarning]="false"
[attr.aria-label]="'core.openinbrowser' | translate" slot="end">
<ion-icon name="fas-external-link-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="progress !== undefined || course.startdate || course.enddate">
<ion-label>
<div class="core-course-progress" *ngIf="progress !== undefined">
<core-progress-bar [progress]="progress" a11yText="core.course.aria:sectionprogress">
</core-progress-bar>
</div>
<div *ngIf="course.startdate || course.enddate" class="core-course-dates">
<p *ngIf="course.startdate">
<ion-icon name="fas-calendar" aria-hidden="true"></ion-icon>
<strong>{{ 'core.course.startdate' | translate }}</strong><br>
{{ course.startdate * 1000 | coreFormatDate:'strftimedaydatetime' }}
</p>
<p *ngIf="course.enddate">
<ion-icon name="fas-calendar" aria-hidden="true"></ion-icon>
<strong>{{ 'core.course.enddate' | translate }}</strong><br>
{{ course.enddate * 1000 | coreFormatDate:'strftimedaydatetime' }}
</p>
</div>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="course.summary" detail="false">
<ion-label>
<p class="item-heading">
{{'core.course.coursesummary' | translate}}
</p>
<core-format-text [text]="course.summary" collapsible-item contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</ion-label>
</ion-item>
<ion-list *ngIf="course.contacts && course.contacts.length">
<ion-item [button]="course.contacts.length >= 5" class="ion-text-wrap" (click)="toggleContacts()"
[attr.aria-label]="(contactsExpanded ? 'core.collapse' : 'core.expand') | translate" detail="false">
<ion-icon *ngIf="course.contacts.length >= 5" name="fas-chevron-right" flip-rtl slot="start" aria-hidden="true"
class="expandable-status-icon" [class.expandable-status-icon-expanded]="contactsExpanded">
</ion-icon>
<div class="list-item-limited-width">
<ion-item class="ion-text-wrap course-name">
<ion-label>
<p class="item-heading">
{{ 'core.teachers' | translate }}
<p *ngIf="course.displayname && course.shortname && course.fullname != course.displayname"
class="core-course-shortname">
<core-format-text [text]="course.shortname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</p>
<h1>
<span class="sr-only">{{ 'core.courses.aria:coursename' | translate }}</span>
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</h1>
<ion-chip color="primary" *ngIf="course.categoryname" class="core-course-category ion-text-nowrap">
<span class="sr-only">{{ 'core.courses.aria:coursecategory' | translate }}</span>
<ion-label>
<core-format-text [text]="course.categoryname" contextLevel="coursecat"
[contextInstanceId]="course.categoryid">
</core-format-text>
</ion-label>
</ion-chip>
</ion-label>
<ion-button fill="clear" [href]="courseUrl" core-link [showBrowserWarning]="false"
[attr.aria-label]="'core.openinbrowser' | translate" slot="end">
<ion-icon name="fas-external-link-alt" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="progress !== undefined || course.startdate || course.enddate">
<ion-label>
<div class="core-course-progress" *ngIf="progress !== undefined">
<core-progress-bar [progress]="progress" a11yText="core.course.aria:sectionprogress">
</core-progress-bar>
</div>
<div *ngIf="course.startdate || course.enddate" class="core-course-dates">
<p *ngIf="course.startdate">
<ion-icon name="fas-calendar" aria-hidden="true"></ion-icon>
<strong>{{ 'core.course.startdate' | translate }}</strong><br>
{{ course.startdate * 1000 | coreFormatDate:'strftimedaydatetime' }}
</p>
<p *ngIf="course.enddate">
<ion-icon name="fas-calendar" aria-hidden="true"></ion-icon>
<strong>{{ 'core.course.enddate' | translate }}</strong><br>
{{ course.enddate * 1000 | coreFormatDate:'strftimedaydatetime' }}
</p>
</div>
</ion-label>
</ion-item>
<ng-container *ngIf="contactsExpanded || course.contacts.length < 5">
<ion-item button class="ion-text-wrap" *ngFor="let contact of course.contacts" core-user-link [userId]="contact.id"
[courseId]="isEnrolled ? course.id : null" [attr.aria-label]="'core.viewprofile' | translate" detail="true">
<core-user-avatar [user]="contact" slot="start" [userId]="contact.id" [courseId]="isEnrolled ? course.id : null"
[linkProfile]="false">
</core-user-avatar>
<ion-item class="ion-text-wrap" *ngIf="course.summary" detail="false">
<ion-label>
<p class="item-heading">
{{'core.course.coursesummary' | translate}}
</p>
<core-format-text [text]="course.summary" collapsible-item contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</ion-label>
</ion-item>
<ion-list *ngIf="course.contacts && course.contacts.length">
<ion-item [button]="course.contacts.length >= 5" class="ion-text-wrap" (click)="toggleContacts()"
[attr.aria-label]="(contactsExpanded ? 'core.collapse' : 'core.expand') | translate" detail="false">
<ion-icon *ngIf="course.contacts.length >= 5" name="fas-chevron-right" flip-rtl slot="start" aria-hidden="true"
class="expandable-status-icon" [class.expandable-status-icon-expanded]="contactsExpanded">
</ion-icon>
<ion-label>
<p class="item-heading">{{contact.fullname}}</p>
<p class="item-heading">
{{ 'core.teachers' | translate }}
</p>
</ion-label>
</ion-item>
</ng-container>
<core-spacer></core-spacer>
</ion-list>
<ion-item class="ion-text-wrap" *ngIf="course.customfields">
<ion-label>
<ng-container *ngFor="let field of course.customfields">
<div *ngIf="field.value"
class="core-customfield core-customfield_{{field.type}} core-customfield_{{field.shortname}}">
<span class="core-customfieldname">
<core-format-text [text]="field.name" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
<span class="core-customfieldseparator">: </span>
</span>
<span class="core-customfieldvalue">
<core-format-text [text]="field.value" [collapsible-item]="field.type == 'textarea' ? '' : null"
contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</span>
</div>
<ng-container *ngIf="contactsExpanded || course.contacts.length < 5">
<ion-item button class="ion-text-wrap" *ngFor="let contact of course.contacts" core-user-link [userId]="contact.id"
[courseId]="isEnrolled ? course.id : null" [attr.aria-label]="'core.viewprofile' | translate" detail="true">
<core-user-avatar [user]="contact" slot="start" [userId]="contact.id" [courseId]="isEnrolled ? course.id : null"
[linkProfile]="false">
</core-user-avatar>
<ion-label>
<p class="item-heading">{{contact.fullname}}</p>
</ion-label>
</ion-item>
</ng-container>
</ion-label>
</ion-item>
<core-spacer></core-spacer>
</ion-list>
<ion-item class="ion-text-wrap" *ngIf="course.customfields">
<ion-label>
<ng-container *ngFor="let field of course.customfields">
<div *ngIf="field.value"
class="core-customfield core-customfield_{{field.type}} core-customfield_{{field.shortname}}">
<span class="core-customfieldname">
<core-format-text [text]="field.name" contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
<span class="core-customfieldseparator">: </span>
</span>
<span class="core-customfieldvalue">
<core-format-text [text]="field.value" [collapsible-item]="field.type == 'textarea' ? '' : null"
contextLevel="course" [contextInstanceId]="course.id">
</core-format-text>
</span>
</div>
</ng-container>
</ion-label>
</ion-item>
</div>
</div>
</core-loading>
</ion-content>
<ion-footer *ngIf="course && dataLoaded">
<ng-container *ngIf="canAccessCourse">
<ion-button *ngFor="let item of courseMenuHandlers" (click)="openMenuItem(item)" [class]="'ion-text-wrap '+ item.data.class"
expand="block">
<ion-icon *ngIf="item.data.icon" [name]="item.data.icon" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{item.data.title | translate }}</ion-label>
<div class="list-item-limited-width">
<ng-container *ngIf="canAccessCourse">
<ion-button *ngFor="let item of courseMenuHandlers" (click)="openMenuItem(item)" [class]="'ion-text-wrap '+ item.data.class"
expand="block">
<ion-icon *ngIf="item.data.icon" [name]="item.data.icon" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{item.data.title | translate }}</ion-label>
</ion-button>
</ng-container>
<ion-button expand="block" (click)="enrolMe()" *ngIf="!isEnrolled && (selfEnrolInstances.length > 0 || otherEnrolments)"
class="ion-text-wrap">
{{ 'core.courses.enrolme' | translate }}
</ion-button>
</ng-container>
<ion-button expand="block" (click)="enrolMe()" *ngIf="!isEnrolled && (selfEnrolInstances.length > 0 || otherEnrolments)"
class="ion-text-wrap">
{{ 'core.courses.enrolme' | translate }}
</ion-button>
<ion-card class="core-info-card ion-text-wrap" *ngIf="!isEnrolled && !selfEnrolInstances.length && !otherEnrolments">
<ion-item>
<ion-icon name="fas-info-circle" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'core.courses.notenrollable' | translate }}</ion-label>
</ion-item>
</ion-card>
<ion-card class="core-info-card ion-text-wrap" *ngIf="!isEnrolled && !selfEnrolInstances.length && !otherEnrolments">
<ion-item>
<ion-icon name="fas-info-circle" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'core.courses.notenrollable' | translate }}</ion-label>
</ion-item>
</ion-card>
<ion-button (click)="openCourse()" *ngIf="!isModal && canAccessCourse" expand="block" fill="outline" class="ion-text-wrap">
<ion-icon name="fas-eye" slot="start" aria-hidden="true"></ion-icon>
{{ 'core.course.viewcourse' | translate }}
</ion-button>
<ion-button (click)="openCourse()" *ngIf="!isModal && canAccessCourse" expand="block" fill="outline" class="ion-text-wrap">
<ion-icon name="fas-eye" slot="start" aria-hidden="true"></ion-icon>
{{ 'core.course.viewcourse' | translate }}
</ion-button>
</div>
</ion-footer>

View File

@ -39,11 +39,14 @@
.course-container {
position: relative;
top: calc(var(--thumb-height) - var(--big-radius));
border-radius: var(--big-radius) var(--big-radius) 0 0;
background-color: var(--ion-background-color);
box-shadow: var(--drop-shadow-top);
clip-path: inset(-5px 0px 0px 0px);
}
.course-name {
border-radius: var(--big-radius) var(--big-radius) 0 0;
box-shadow: var(--drop-shadow-top);
--background: transparent;
ion-label {
margin-bottom: 0px;
}

View File

@ -16,24 +16,25 @@
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-item class="core-format-progress-list ion-text-wrap list-item-limited-width" collapsible>
<ng-container *ngIf="course">
<div *ngIf="!course.courseImage" #courseThumb slot="start" class="core-course-thumb">
<ion-icon *ngIf="!course.courseImage" name="fas-graduation-cap" class="course-icon" aria-hidden="true">
</ion-icon>
</div>
<ion-avatar *ngIf="course.courseImage" slot="start" class="core-course-thumb">
<img [src]="course.courseImage" core-external-content alt="" />
</ion-avatar>
</ng-container>
<div class="core-course-header">
<ion-item class="core-format-progress-list ion-text-wrap list-item-limited-width" collapsible>
<ng-container *ngIf="course">
<div *ngIf="!course.courseImage" #courseThumb slot="start" class="core-course-thumb">
<ion-icon *ngIf="!course.courseImage" name="fas-graduation-cap" class="course-icon" aria-hidden="true">
</ion-icon>
</div>
<ion-avatar *ngIf="course.courseImage" slot="start" class="core-course-thumb">
<img [src]="course.courseImage" core-external-content alt="" />
</ion-avatar>
</ng-container>
<ion-label>
<h1>{{ title }}</h1>
<div class="core-course-progress" *ngIf="progress !== undefined">
<core-progress-bar [progress]="progress" a11yText="core.course.aria:sectionprogress">
</core-progress-bar>
</div>
</ion-label>
</ion-item>
<ion-label>
<h1>{{ title }}</h1>
<div class="core-course-progress" *ngIf="progress !== undefined">
<core-progress-bar [progress]="progress" a11yText="core.course.aria:sectionprogress">
</core-progress-bar>
</div>
</ion-label>
</ion-item>
</div>
<core-tabs-outlet [tabs]="tabs" [hideUntil]="loaded" (ionChange)="tabSelected()"></core-tabs-outlet>

View File

@ -44,4 +44,8 @@
h1 {
font-size: 20px;
}
.core-course-header {
background: var(--ion-item-background);
}
}

View File

@ -1064,15 +1064,13 @@ export class CoreCourseHelperProvider {
*
* @param courses Courses array to get info from.
* @param prefetch Prefetch information.
* @param minCourses Min course to show icon.
* @return Resolved with the prefetch information updated when done.
*/
async initPrefetchCoursesIcons(
courses: CoreCourseBasicData[],
prefetch: CorePrefetchStatusInfo,
minCourses: number = 2,
): Promise<CorePrefetchStatusInfo> {
if (!courses || courses.length < minCourses) {
if (!courses || courses.length <= 0) {
// Not enough courses.
prefetch.icon = '';

View File

@ -294,8 +294,11 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentIn
await this.domPromise;
const page = this.element.closest('.ion-page');
if (!page) {
return;
}
await CoreComponentsRegistry.finishRenderingAllElementsInside<CoreLoadingComponent>(page, 'core-loading', 'whenLoaded');
await CoreComponentsRegistry.waitComponentsReady(page, 'core-loading', CoreLoadingComponent);
}
/**

View File

@ -100,7 +100,7 @@ export class CoreDomUtilsProvider {
* @param timeout If defined, timeout to wait before rejecting the promise.
* @return Cancellable promise.
*/
waitToBeInDOM(element: Element, timeout?: number): CoreCancellablePromise<void> {
waitToBeInDOM(element: HTMLElement, timeout?: number): CoreCancellablePromise<void> {
const root = element.getRootNode({ composed: true });
if (root === document) {
@ -140,6 +140,41 @@ export class CoreDomUtilsProvider {
);
}
/**
* Wait an element to be in dom and visible.
*
* @param element Element to wait.
* @return Cancellable promise.
*/
waitToBeVisible(element: HTMLElement): CoreCancellablePromise<void> {
const domPromise = CoreDomUtils.waitToBeInDOM(element);
let interval: number | undefined;
return new CoreCancellablePromise<void>(
async (resolve) => {
await domPromise;
if (CoreDomUtils.isElementVisible(element)) {
return resolve();
}
interval = window.setInterval(() => {
if (!CoreDomUtils.isElementVisible(element)) {
return;
}
resolve();
window.clearInterval(interval);
}, 50);
},
() => {
domPromise.cancel();
window.clearInterval(interval);
},
);
}
/**
* Window resize is widely checked and may have many performance issues, debouce usage is needed to avoid calling it too much.
* This function helps setting up the debounce feature and remove listener easily.

View File

@ -13,6 +13,7 @@
// limitations under the License.
import { Component } from '@angular/core';
import { AsyncComponent } from '@classes/async-component';
import { CoreUtils } from '@services/utils/utils';
/**
@ -39,7 +40,7 @@ export class CoreComponentsRegistry {
* @param componentClass Component class.
* @returns Component instance.
*/
static resolve<T = Component>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null {
static resolve<T>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null {
const instance = (element && this.instances.get(element) as T) ?? null;
return instance && (!componentClass || instance instanceof componentClass)
@ -65,35 +66,45 @@ export class CoreComponentsRegistry {
}
/**
* Waits all elements to be rendered.
* Get a component instances and wait to be ready.
*
* @param element Parent element where to search.
* @param selector Selector to search on parent.
* @param fnName Component function that have to be resolved when rendered.
* @param params Params of function that have to be resolved when rendered.
* @param element Root element.
* @param componentClass Component class.
* @return Promise resolved when done.
*/
static async finishRenderingAllElementsInside<T = Component>(
element: Element | undefined | null,
selector: string,
fnName: string,
params?: unknown[],
static async waitComponentReady<T extends AsyncComponent>(
element: Element | null,
componentClass?: ComponentConstructor<T>,
): Promise<void> {
if (!element) {
const instance = this.resolve(element, componentClass);
if (!instance) {
return;
}
const components = Array
.from(element.querySelectorAll(selector))
.map(element => CoreComponentsRegistry.resolve<T>(element));
await instance.ready();
}
await Promise.all(components.map(component => {
if (!component) {
return;
}
return component[fnName].apply(component, params);
}));
/**
* Waits all elements matching to be ready.
*
* @param element Element where to search.
* @param selector Selector to search on parent.
* @param componentClass Component class.
* @return Promise resolved when done.
*/
static async waitComponentsReady<T extends AsyncComponent>(
element: Element,
selector: string,
componentClass?: ComponentConstructor<T>,
): Promise<void> {
if (element.matches(selector)) {
// Element to wait is myself.
await CoreComponentsRegistry.waitComponentReady<T>(element, componentClass);
} else {
await Promise.all(Array
.from(element.querySelectorAll(selector))
.map(element => CoreComponentsRegistry.waitComponentReady<T>(element, componentClass)));
}
// Wait for next tick to ensure components are completely rendered.
await CoreUtils.nextTick();
@ -105,4 +116,4 @@ export class CoreComponentsRegistry {
* Component constructor.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ComponentConstructor<T> = { new(...args: any[]): T };
export type ComponentConstructor<T = Component> = { new(...args: any[]): T };

View File

@ -80,7 +80,7 @@
body.dark {
$dark: map-get($base, 'dark');
$dark: mix($light, white, 40%) !default;
$dark: mix($light, white, 80%) !default;
@include generate-color-variants($color-name, $dark);
}
}