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 { 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 {}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue