MOBILE-3814 module: Implement a new collapsible item directive

main
Pau Ferrer Ocaña 2022-02-09 17:49:24 +01:00
parent 61a7d5d59f
commit e403d2c3ba
6 changed files with 280 additions and 12 deletions

View File

@ -0,0 +1,187 @@
// (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, Input, OnInit } from '@angular/core';
import { CoreDomUtils } from '@services/utils/dom';
import { Translate } from '@singletons';
import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents } from '@singletons/events';
const defaultMaxHeight = 56;
const buttonHeight = 44;
/**
* Directive to make an element collapsible.
*
* Example usage:
*
* <div collapsible-item>
*/
@Directive({
selector: '[collapsible-item]',
})
export class CoreCollapsibleItemDirective implements OnInit {
/**
* Max height in pixels to render the content box. It should be 56 at least to make sense.
* Using this parameter will force display: block to calculate height better.
* If you want to avoid this use class="inline" at the same time to use display: inline-block.
*/
@Input('collapsible-item') height: number | string = defaultMaxHeight;
protected element: HTMLElement;
protected toggleExpandEnabled = false;
protected expanded = false;
protected maxHeight = defaultMaxHeight;
protected loadingChangedListener?: CoreEventObserver;
constructor(el: ElementRef<HTMLElement>) {
this.element = el.nativeElement;
this.element.addEventListener('click', this.elementClicked.bind(this));
}
/**
* @inheritdoc
*/
ngOnInit(): void {
if (typeof this.height === 'string') {
this.maxHeight = this.height === ''
? defaultMaxHeight
: parseInt(this.height, 10);
} else {
this.maxHeight = this.height;
}
this.maxHeight = this.maxHeight < defaultMaxHeight ? defaultMaxHeight : this.maxHeight;
if (!this.maxHeight || (window.innerWidth > 576 && window.innerHeight > 576)) {
// Do not collapse on big screens.
return;
}
// Calculate the height now.
this.calculateHeight();
setTimeout(() => this.calculateHeight(), 200); // Try again, sometimes the first calculation is wrong.
this.setExpandButtonEnabled(false);
// Recalculate the height if a parent core-loading displays the content.
this.loadingChangedListener =
CoreEvents.on(CoreEvents.CORE_LOADING_CHANGED, (data: CoreEventLoadingChangedData) => {
if (data.loaded && CoreDomUtils.closest(this.element.parentElement, '#' + data.uniqueId)) {
// The format-text is inside the loading, re-calculate the height.
this.calculateHeight();
setTimeout(() => this.calculateHeight(), 200);
}
});
}
/**
* Calculate the height and check if we need to display show more or not.
*/
protected calculateHeight(): void {
// @todo: Work on calculate this height better.
if (!this.maxHeight) {
return;
}
// Remove max-height (if any) to calculate the real height.
const initialMaxHeight = this.element.style.maxHeight;
this.element.style.maxHeight = '';
const height = CoreDomUtils.getElementHeight(this.element) || 0;
// Restore the max height now.
this.element.style.maxHeight = initialMaxHeight;
// If cannot calculate height, shorten always.
this.setExpandButtonEnabled(!height || height > this.maxHeight);
}
/**
* Sets if expand button is enabled or not.
*
* @param enable Wether enable or disable.
*/
protected setExpandButtonEnabled(enable: boolean): void {
this.toggleExpandEnabled = enable;
this.element.classList.toggle('collapsible-enabled', enable);
if (!enable || this.element.querySelector('ion-button.collapsible-toggle')) {
return;
}
// Add expand/collapse buttons
const toggleButton = document.createElement('ion-button');
toggleButton.classList.add('collapsible-toggle');
toggleButton.setAttribute('fill', 'clear');
const toggleText = document.createElement('span');
toggleText.classList.add('collapsible-toggle-text');
toggleButton.appendChild(toggleText);
const expandArrow = document.createElement('span');
expandArrow.classList.add('collapsible-toggle-arrow');
toggleButton.appendChild(expandArrow);
this.element.appendChild(toggleButton);
this.toggleExpand(this.expanded);
}
/**
* Expand or collapse text.
*
* @param expand Wether expand or collapse text. If undefined, will toggle.
*/
protected toggleExpand(expand?: boolean): void {
if (expand === undefined) {
expand = !this.expanded;
}
this.expanded = expand;
this.element.classList.toggle('collapsible-expanded', expand);
this.element.classList.toggle('collapsible-collapsed', !expand);
this.element.style.maxHeight = expand ? '' : (this.maxHeight + buttonHeight) + 'px';
const toggleButton = this.element.querySelector('ion-button.collapsible-toggle');
const toggleText = toggleButton?.querySelector('.collapsible-toggle-text');
if (!toggleButton || !toggleText) {
return;
}
toggleText.innerHTML = expand ? Translate.instant('core.showless') : Translate.instant('core.showmore');
toggleButton.setAttribute('aria-expanded', expand ? 'true' : 'false');
}
/**
* Listener to call when the element is clicked.
*
* @param e Click event.
*/
protected elementClicked(e: MouseEvent): void {
if (e.defaultPrevented) {
// Ignore it if the event was prevented by some other listener.
return;
}
if (!this.toggleExpandEnabled) {
// Nothing to do on click, just stop.
return;
}
e.preventDefault();
e.stopPropagation();
this.toggleExpand();
}
}

View File

@ -29,6 +29,7 @@ import { CoreOnResizeDirective } from './on-resize';
import { CoreDownloadFileDirective } from './download-file';
import { CoreCollapsibleHeaderDirective } from './collapsible-header';
import { CoreSwipeNavigationDirective } from './swipe-navigation';
import { CoreCollapsibleItemDirective } from './collapsible-item';
@NgModule({
declarations: [
@ -47,6 +48,7 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
CoreDownloadFileDirective,
CoreCollapsibleHeaderDirective,
CoreSwipeNavigationDirective,
CoreCollapsibleItemDirective,
],
exports: [
CoreAutoFocusDirective,
@ -64,6 +66,7 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
CoreDownloadFileDirective,
CoreCollapsibleHeaderDirective,
CoreSwipeNavigationDirective,
CoreCollapsibleItemDirective,
],
})
export class CoreDirectivesModule {}

View File

@ -16,10 +16,10 @@
<ion-item *ngIf="allSectionId == section.id" class="ion-text-wrap divider core-course-index-all"
(click)="selectSectionOrModule($event, section.id)" button [class.item-current]="selectedId === section.id" detail="false">
<ion-label>
<p class="item-heading">
<h2>
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id">
</core-format-text>
</p>
</h2>
</ion-label>
</ion-item>
<ng-container *ngIf="allSectionId != section.id">
@ -34,10 +34,10 @@
<ion-icon *ngIf="!section.hasVisibleModules" name="" slot="start" aria-hidden="true" class="expandable-status-icon">
</ion-icon>
<ion-label>
<p class="item-heading">
<h2>
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id">
</core-format-text>
</p>
</h2>
</ion-label>
<ion-badge *ngIf="section.highlighted && highlighted">{{highlighted}}</ion-badge>
<ion-icon name="fas-lock" *ngIf="section.availabilityinfo" slot="end" class="restricted"

View File

@ -62,8 +62,8 @@
'item-media': module.handlerData.icon,
'item-dimmed': module.visible === 0 || module.uservisible === false
}">
<ion-label>
<core-format-text class="core-module-description" *ngIf="module.description" [maxHeight]="80" [text]="module.description"
<ion-label collapsible-item>
<core-format-text class="core-module-description" *ngIf="module.description" [text]="module.description"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="module.course">
</core-format-text>

View File

@ -918,8 +918,6 @@ ion-chip {
color: var(--ion-color-base);
}
}
}
ion-searchbar {
@ -1243,13 +1241,19 @@ ion-item.item-input ion-input.has-focus {
}
}
ion-item-divider.item, ion-item.item.divider {
ion-item-divider.item,
ion-item.item.divider {
--inner-padding-end: 8px;
background: var(--background);
min-height: var(--min-height);
border-width: var(--item-divider-border-width);
border-bottom-width: var(--item-divider-border-width);
--border-width: var(--item-divider-border-width);
--inner-border-width: 0 0 var(--item-divider-border-width) 0;
font-size: var(--item-divider-font-size);
h2, ion-label h2 {
font-weight: medium;
h2, ion-label h2,
p.item-heading, ion-label p.item-heading {
font-size: var(--item-divider-font-size);
}
}
@ -1314,6 +1318,80 @@ ion-grid.core-no-grid > ion-row {
right: 0;
}
[collapsible-item] {
--collapsible-display-toggle: none;
--collapsible-toggle-background: var(--ion-item-background);
--collapsible-min-button-height: 44px;
.collapsible-toggle {
display: var(--collapsible-display-toggle);
}
&.collapsible-enabled {
--collapsible-display-toggle: block;
.collapsible-toggle {
display: var(--collapsible-display-toggle);
position: absolute;
bottom: 0;
left: 0;
right: 0;
text-align: center;
z-index: 7;
text-transform: none;
text-align: end;
font-size: 14px;
background-color: var(--collapsible-toggle-background);
color: var(--text-color);
margin: 0;
.collapsible-toggle-arrow {
width: var(--a11y-min-target-size);
height: var(--a11y-min-target-size);
background-position: center;
background-repeat: no-repeat;
background-size: 14px 14px;
@include core-transition(transform, 500ms);
@include push-arrow-color(626262, true);
@include darkmode() {
@include push-arrow-color(ffffff, true);
}
}
}
&.collapsible-collapsed {
overflow: hidden;
min-height: calc(var(--collapsible-min-button-height) + 12);
.collapsible-toggle-arrow {
transform: rotate(90deg);
}
&:before {
content: '';
height: 100%;
position: absolute;
@include position(null, 0, 0, 0);
background: -webkit-linear-gradient(top, rgba(var(--core-format-text-background-gradient-rgb), 0) calc(100% - 60px), rgba(var(--core-format-text-background-gradient-rgb), 1) calc(100% - 40px));
background: linear-gradient(to bottom, rgba(var(--core-format-text-background-gradient-rgb), 0) calc(100% - 60px), rgba(var(--core-format-text-background-gradient-rgb), 1) calc(100% - 40px));
z-index: 6;
}
}
&.collapsible-expanded {
max-height: none !important;
padding-bottom: var(--collapsible-min-button-height); // So the Show less button can fit.
.collapsible-toggle-arrow {
transform: rotate(-90deg);
}
}
}
}
ion-header[collapsible] {
@include core-transition(all, 500ms);

View File

@ -222,7 +222,7 @@
}
--item-divider-min-height: calc(var(--a11y-min-target-size) + 8px);
--item-divider-background: transparent;
--item-divider-background: var(--ion-item-background);
--item-divider-color: var(--text-color);
--item-divider-border-width: 0px;
--item-divider-font-size: 20px;