From e403d2c3ba49effbbf42f7e058b8e3731c42dbc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 9 Feb 2022 17:49:24 +0100 Subject: [PATCH] MOBILE-3814 module: Implement a new collapsible item directive --- src/core/directives/collapsible-item.ts | 187 ++++++++++++++++++ src/core/directives/directives.module.ts | 3 + .../components/course-index/course-index.html | 8 +- .../components/module/core-course-module.html | 4 +- src/theme/theme.base.scss | 88 ++++++++- src/theme/theme.light.scss | 2 +- 6 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 src/core/directives/collapsible-item.ts diff --git a/src/core/directives/collapsible-item.ts b/src/core/directives/collapsible-item.ts new file mode 100644 index 000000000..c7f069354 --- /dev/null +++ b/src/core/directives/collapsible-item.ts @@ -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: + * + *
+ */ +@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) { + 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(); + } + +} diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index 018ddafad..42a2b3c79 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -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 {} diff --git a/src/core/features/course/components/course-index/course-index.html b/src/core/features/course/components/course-index/course-index.html index b0f6d03a3..a994ca87e 100644 --- a/src/core/features/course/components/course-index/course-index.html +++ b/src/core/features/course/components/course-index/course-index.html @@ -16,10 +16,10 @@ -

+

-

+

@@ -34,10 +34,10 @@ -

+

-

+

{{highlighted}} - - + diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 0100acc6b..9c15df238 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -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); diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index 61b52c0f7..2697f08fd 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -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;