MOBILE-3814 module: Implement a new collapsible item directive
parent
61a7d5d59f
commit
e403d2c3ba
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -29,6 +29,7 @@ import { CoreOnResizeDirective } from './on-resize';
|
||||||
import { CoreDownloadFileDirective } from './download-file';
|
import { CoreDownloadFileDirective } from './download-file';
|
||||||
import { CoreCollapsibleHeaderDirective } from './collapsible-header';
|
import { CoreCollapsibleHeaderDirective } from './collapsible-header';
|
||||||
import { CoreSwipeNavigationDirective } from './swipe-navigation';
|
import { CoreSwipeNavigationDirective } from './swipe-navigation';
|
||||||
|
import { CoreCollapsibleItemDirective } from './collapsible-item';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
@ -47,6 +48,7 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
|
||||||
CoreDownloadFileDirective,
|
CoreDownloadFileDirective,
|
||||||
CoreCollapsibleHeaderDirective,
|
CoreCollapsibleHeaderDirective,
|
||||||
CoreSwipeNavigationDirective,
|
CoreSwipeNavigationDirective,
|
||||||
|
CoreCollapsibleItemDirective,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CoreAutoFocusDirective,
|
CoreAutoFocusDirective,
|
||||||
|
@ -64,6 +66,7 @@ import { CoreSwipeNavigationDirective } from './swipe-navigation';
|
||||||
CoreDownloadFileDirective,
|
CoreDownloadFileDirective,
|
||||||
CoreCollapsibleHeaderDirective,
|
CoreCollapsibleHeaderDirective,
|
||||||
CoreSwipeNavigationDirective,
|
CoreSwipeNavigationDirective,
|
||||||
|
CoreCollapsibleItemDirective,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreDirectivesModule {}
|
export class CoreDirectivesModule {}
|
||||||
|
|
|
@ -16,10 +16,10 @@
|
||||||
<ion-item *ngIf="allSectionId == section.id" class="ion-text-wrap divider core-course-index-all"
|
<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">
|
(click)="selectSectionOrModule($event, section.id)" button [class.item-current]="selectedId === section.id" detail="false">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p class="item-heading">
|
<h2>
|
||||||
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id">
|
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id">
|
||||||
</core-format-text>
|
</core-format-text>
|
||||||
</p>
|
</h2>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ng-container *ngIf="allSectionId != section.id">
|
<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 *ngIf="!section.hasVisibleModules" name="" slot="start" aria-hidden="true" class="expandable-status-icon">
|
||||||
</ion-icon>
|
</ion-icon>
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p class="item-heading">
|
<h2>
|
||||||
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id">
|
<core-format-text [text]="section.name" contextLevel="course" [contextInstanceId]="course?.id">
|
||||||
</core-format-text>
|
</core-format-text>
|
||||||
</p>
|
</h2>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-badge *ngIf="section.highlighted && highlighted">{{highlighted}}</ion-badge>
|
<ion-badge *ngIf="section.highlighted && highlighted">{{highlighted}}</ion-badge>
|
||||||
<ion-icon name="fas-lock" *ngIf="section.availabilityinfo" slot="end" class="restricted"
|
<ion-icon name="fas-lock" *ngIf="section.availabilityinfo" slot="end" class="restricted"
|
||||||
|
|
|
@ -62,8 +62,8 @@
|
||||||
'item-media': module.handlerData.icon,
|
'item-media': module.handlerData.icon,
|
||||||
'item-dimmed': module.visible === 0 || module.uservisible === false
|
'item-dimmed': module.visible === 0 || module.uservisible === false
|
||||||
}">
|
}">
|
||||||
<ion-label>
|
<ion-label collapsible-item>
|
||||||
<core-format-text class="core-module-description" *ngIf="module.description" [maxHeight]="80" [text]="module.description"
|
<core-format-text class="core-module-description" *ngIf="module.description" [text]="module.description"
|
||||||
contextLevel="module" [contextInstanceId]="module.id" [courseId]="module.course">
|
contextLevel="module" [contextInstanceId]="module.id" [courseId]="module.course">
|
||||||
</core-format-text>
|
</core-format-text>
|
||||||
|
|
||||||
|
|
|
@ -918,8 +918,6 @@ ion-chip {
|
||||||
color: var(--ion-color-base);
|
color: var(--ion-color-base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-searchbar {
|
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;
|
--inner-padding-end: 8px;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
min-height: var(--min-height);
|
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);
|
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);
|
font-size: var(--item-divider-font-size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1314,6 +1318,80 @@ ion-grid.core-no-grid > ion-row {
|
||||||
right: 0;
|
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] {
|
ion-header[collapsible] {
|
||||||
@include core-transition(all, 500ms);
|
@include core-transition(all, 500ms);
|
||||||
|
|
||||||
|
|
|
@ -222,7 +222,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
--item-divider-min-height: calc(var(--a11y-min-target-size) + 8px);
|
--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-color: var(--text-color);
|
||||||
--item-divider-border-width: 0px;
|
--item-divider-border-width: 0px;
|
||||||
--item-divider-font-size: 20px;
|
--item-divider-font-size: 20px;
|
||||||
|
|
Loading…
Reference in New Issue