diff --git a/package-lock.json b/package-lock.json index 9dc24bbf0..18d46b811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4224,6 +4224,12 @@ "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==", "dev": true }, + "@types/resize-observer-browser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz", + "integrity": "sha512-8k/67Z95Goa6Lznuykxkfhq9YU3l1Qe6LNZmwde1u7802a3x8v44oq0j91DICclxatTr0rNnhXx7+VTIetSrSQ==", + "dev": true + }, "@types/source-list-map": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", diff --git a/package.json b/package.json index 73602ebcc..69d03672b 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@ionic/v4-migration-tslint": "^1.7.1", "@types/faker": "^5.1.3", "@types/node": "^12.12.64", + "@types/resize-observer-browser": "^0.1.5", "@types/webpack-env": "^1.16.0", "@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/parser": "^4.22.0", diff --git a/scripts/langindex.json b/scripts/langindex.json index df29938fb..af613b569 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2038,6 +2038,8 @@ "core.save": "moodle", "core.savechanges": "assign", "core.scanqr": "local_moodlemobileapp", + "core.scrollbackward": "local_moodlemobileapp", + "core.scrollforward": "local_moodlemobileapp", "core.search": "moodle", "core.searching": "local_moodlemobileapp", "core.searchresults": "moodle", diff --git a/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/addon-block-recentlyaccessedcourses.html b/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/addon-block-recentlyaccessedcourses.html index 0f445b464..56d9ffb91 100644 --- a/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/addon-block-recentlyaccessedcourses.html +++ b/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/addon-block-recentlyaccessedcourses.html @@ -2,27 +2,38 @@

{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}

-
- - - - - {{prefetchCoursesData.badge}} - - +
+
+ + + + + {{prefetchCoursesData.badge}} + + +
+ + +
-
- - - +
+
+ + + +
diff --git a/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/recentlyaccessedcourses.ts b/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/recentlyaccessedcourses.ts index 3b6d98336..bea1cc104 100644 --- a/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/recentlyaccessedcourses.ts +++ b/src/addons/block/recentlyaccessedcourses/components/recentlyaccessedcourses/recentlyaccessedcourses.ts @@ -46,6 +46,7 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom downloadCourseEnabled = false; downloadCoursesEnabled = false; + scrollElementId!: string; protected prefetchIconsInitialized = false; protected isDestroyed = false; @@ -62,6 +63,10 @@ export class AddonBlockRecentlyAccessedCoursesComponent extends CoreBlockBaseCom * Component being initialized. */ async ngOnInit(): Promise { + // Generate unique id for scroll element. + const scrollId = CoreUtils.getUniqueId('AddonBlockRecentlyAccessedCoursesComponent-Scroll'); + + this.scrollElementId = `addon-block-recentlyaccessedcourses-scroll-${scrollId}`; // Refresh the enabled flags if enabled. this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); diff --git a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/addon-block-recentlyaccesseditems.html b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/addon-block-recentlyaccesseditems.html index bc9ffadb7..62267a363 100644 --- a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/addon-block-recentlyaccesseditems.html +++ b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/addon-block-recentlyaccesseditems.html @@ -1,27 +1,38 @@

{{ 'addon.block_recentlyaccesseditems.pluginname' | translate }}

+
+ + +
-
-
- - - - - - {{ item.iconTitle }} -

- -

-

- - -

-
-
-
+
+
+
+ + + + + + {{ item.iconTitle }} +

+ +

+

+ + +

+
+
+
+
diff --git a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.scss b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.scss index 14a35cad6..ce246a96f 100644 --- a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.scss +++ b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.scss @@ -1,7 +1,7 @@ @import "~theme/globals"; :host { - .core-horizontal-scroll > div { + .core-horizontal-scroll > div > div { @include horizontal_scroll_item(80%, 250px, 300px); } diff --git a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.ts b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.ts index 1712ece96..135e00a91 100644 --- a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.ts +++ b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.ts @@ -22,6 +22,7 @@ import { import { CoreTextUtils } from '@services/utils/text'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; +import { CoreUtils } from '@services/utils/utils'; /** * Component to render a recently accessed items block. @@ -34,6 +35,7 @@ import { CoreContentLinksHelper } from '@features/contentlinks/services/contentl export class AddonBlockRecentlyAccessedItemsComponent extends CoreBlockBaseComponent implements OnInit { items: AddonBlockRecentlyAccessedItemsItem[] = []; + scrollElementId!: string; protected fetchContentDefaultError = 'Error getting recently accessed items data.'; @@ -41,6 +43,18 @@ export class AddonBlockRecentlyAccessedItemsComponent extends CoreBlockBaseCompo super('AddonBlockRecentlyAccessedItemsComponent'); } + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + // Generate unique id for scroll element. + const scrollId = CoreUtils.getUniqueId('AddonBlockRecentlyAccessedItemsComponent-Scroll'); + + this.scrollElementId = `addon-block-recentlyaccesseditems-scroll-${scrollId}`; + + super.ngOnInit(); + } + /** * Perform the invalidate content function. * diff --git a/src/addons/block/starredcourses/components/starredcourses/addon-block-starredcourses.html b/src/addons/block/starredcourses/components/starredcourses/addon-block-starredcourses.html index dec6f7412..9b3cbaa37 100644 --- a/src/addons/block/starredcourses/components/starredcourses/addon-block-starredcourses.html +++ b/src/addons/block/starredcourses/components/starredcourses/addon-block-starredcourses.html @@ -2,27 +2,39 @@

{{ 'addon.block_starredcourses.pluginname' | translate }}

-
- - - - - {{prefetchCoursesData.badge}} - - +
+
+ + + + + {{prefetchCoursesData.badge}} + + +
+ + +
-
- - - +
+
+ + + +
diff --git a/src/addons/block/starredcourses/components/starredcourses/starredcourses.ts b/src/addons/block/starredcourses/components/starredcourses/starredcourses.ts index edafb09e6..b95d8084d 100644 --- a/src/addons/block/starredcourses/components/starredcourses/starredcourses.ts +++ b/src/addons/block/starredcourses/components/starredcourses/starredcourses.ts @@ -46,6 +46,7 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im downloadCourseEnabled = false; downloadCoursesEnabled = false; + scrollElementId!: string; protected prefetchIconsInitialized = false; protected isDestroyed = false; @@ -62,6 +63,11 @@ export class AddonBlockStarredCoursesComponent extends CoreBlockBaseComponent im * Component being initialized. */ async ngOnInit(): Promise { + // Generate unique id for scroll element. + const scrollId = CoreUtils.getUniqueId('AddonBlockStarredCoursesComponent-Scroll'); + + this.scrollElementId = `addon-block-starredcourses-scroll-${scrollId}`; + // Refresh the enabled flags if enabled. this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite(); diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 0ca148450..f1a876dc6 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -57,6 +57,7 @@ import { CoreTimerComponent } from './timer/timer'; import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; import { CoreComboboxComponent } from './combobox/combobox'; import { CoreSpacerComponent } from './spacer/spacer'; +import { CoreHorizontalScrollControlsComponent } from './horizontal-scroll-controls/horizontal-scroll-controls'; @NgModule({ declarations: [ @@ -96,6 +97,7 @@ import { CoreSpacerComponent } from './spacer/spacer'; CoreUserAvatarComponent, CoreComboboxComponent, CoreSpacerComponent, + CoreHorizontalScrollControlsComponent, ], imports: [ CommonModule, @@ -142,6 +144,7 @@ import { CoreSpacerComponent } from './spacer/spacer'; CoreUserAvatarComponent, CoreComboboxComponent, CoreSpacerComponent, + CoreHorizontalScrollControlsComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/horizontal-scroll-controls/core-horizontal-scroll-controls.html b/src/core/components/horizontal-scroll-controls/core-horizontal-scroll-controls.html new file mode 100644 index 000000000..7f3091787 --- /dev/null +++ b/src/core/components/horizontal-scroll-controls/core-horizontal-scroll-controls.html @@ -0,0 +1,23 @@ + + + + + + + diff --git a/src/core/components/horizontal-scroll-controls/horizontal-scroll-controls.scss b/src/core/components/horizontal-scroll-controls/horizontal-scroll-controls.scss new file mode 100644 index 000000000..e4c64b946 --- /dev/null +++ b/src/core/components/horizontal-scroll-controls/horizontal-scroll-controls.scss @@ -0,0 +1,10 @@ +:host { + --flex-row-direction: row; + + display: flex; + flex-direction: var(--flex-row-direction); +} + +:host-context([dir="rtl"]) { + --flex-row-direction: row-reverse; +} diff --git a/src/core/components/horizontal-scroll-controls/horizontal-scroll-controls.ts b/src/core/components/horizontal-scroll-controls/horizontal-scroll-controls.ts new file mode 100644 index 000000000..61964d49b --- /dev/null +++ b/src/core/components/horizontal-scroll-controls/horizontal-scroll-controls.ts @@ -0,0 +1,106 @@ +// (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 { Component, Input } from '@angular/core'; +import { Platform } from '@singletons'; + +const enum ScrollPosition { + Start = 'start', + End = 'end', + Middle = 'middle', + Hidden = 'hidden', +} + +@Component({ + selector: 'core-horizontal-scroll-controls', + templateUrl: 'core-horizontal-scroll-controls.html', + styleUrls: ['./horizontal-scroll-controls.scss'], +}) +export class CoreHorizontalScrollControlsComponent { + + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('aria-controls') targetId?: string; + + scrollPosition: ScrollPosition = ScrollPosition.Hidden; + + /** + * Get target element. + */ + private get target(): HTMLElement | null { + return this.targetId && document.getElementById(this.targetId) || null; + } + + /** + * Scroll the target in the given direction. + * + * @param direction Scroll direction. + */ + scroll(direction: 'forward' | 'backward'): void { + if (!this.target) { + return; + } + + const leftDelta = direction === 'forward' ? this.target.clientWidth : -this.target.clientWidth; + const newScrollLeft = Math.max( + Math.min( + this.target.scrollLeft + leftDelta, + this.target.scrollWidth - this.target.clientWidth, + ), + 0, + ); + + this.target.scrollBy({ + left: leftDelta, + behavior: 'smooth', + }); + + this.updateScrollPosition(newScrollLeft); + } + + /** + * Update the current scroll position. + */ + updateScrollPosition(scrollLeft?: number): void { + this.scrollPosition = this.getScrollPosition(scrollLeft); + } + + /** + * Get the current scroll position. + * + * @param scrollLeft Scroll left to use for reference in the calculations. + * @returns Scroll position. + */ + private getScrollPosition(scrollLeft?: number): ScrollPosition { + scrollLeft = scrollLeft ?? this.target?.scrollLeft ?? 0; + + if (!this.target || this.target.scrollWidth <= this.target.clientWidth) { + return ScrollPosition.Hidden; + } + + if (scrollLeft === 0) { + return Platform.isRTL ? ScrollPosition.End : ScrollPosition.Start; + } + + if (!Platform.isRTL && this.target.scrollWidth - scrollLeft === this.target.clientWidth) { + return ScrollPosition.End; + } + + if (Platform.isRTL && this.target.scrollWidth + scrollLeft === this.target.clientWidth) { + return ScrollPosition.Start; + } + + return ScrollPosition.Middle; + } + +} diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index b83ee4534..f60c2d19d 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -25,6 +25,7 @@ import { CoreLongPressDirective } from './long-press'; import { CoreSupressEventsDirective } from './supress-events'; import { CoreUserLinkDirective } from './user-link'; import { CoreAriaButtonClickDirective } from './aria-button'; +import { CoreOnResizeDirective } from './on-resize'; @NgModule({ declarations: [ @@ -39,6 +40,7 @@ import { CoreAriaButtonClickDirective } from './aria-button'; CoreSupressEventsDirective, CoreUserLinkDirective, CoreAriaButtonClickDirective, + CoreOnResizeDirective, ], exports: [ CoreAutoFocusDirective, @@ -52,6 +54,7 @@ import { CoreAriaButtonClickDirective } from './aria-button'; CoreSupressEventsDirective, CoreUserLinkDirective, CoreAriaButtonClickDirective, + CoreOnResizeDirective, ], }) export class CoreDirectivesModule {} diff --git a/src/core/directives/on-resize.ts b/src/core/directives/on-resize.ts new file mode 100644 index 000000000..8660008f0 --- /dev/null +++ b/src/core/directives/on-resize.ts @@ -0,0 +1,99 @@ +// (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, OnInit, Output, EventEmitter, OnDestroy } from '@angular/core'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Directive to listen for element resize events. + */ +@Directive({ + selector: '[onResize]', +}) +export class CoreOnResizeDirective implements OnInit, OnDestroy { + + @Output() onResize = new EventEmitter(); + + private element: HTMLElement; + private resizeObserver?: ResizeObserver; + private mutationObserver?: MutationObserver; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * @inheritdoc + */ + ngOnInit(): void { + 'ResizeObserver' in window + ? this.watchResize() + : this.watchMutations(); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + this.mutationObserver?.disconnect(); + } + + /** + * Watch resize events. + */ + private watchResize(): void { + this.resizeObserver = new ResizeObserver(() => this.onResize.emit()); + + this.resizeObserver.observe(this.element); + } + + /** + * Watch mutation events to detect resizing. + */ + private watchMutations(): void { + let size = this.getElementSize(); + const onMutation = () => { + const newSize = this.getElementSize(); + + if (newSize.width !== size.width || newSize.height !== size.height) { + size = newSize; + + this.onResize.emit(); + } + }; + + // Debounce 20ms to let mutations resolve before checking the new size. + this.mutationObserver = new MutationObserver(CoreUtils.debounce(onMutation, 20)); + + this.mutationObserver.observe(this.element, { + subtree: true, + childList: true, + characterData: true, + }); + } + + /** + * Get element size. + * + * @returns Element size. + */ + private getElementSize(): { width: number; height: number } { + return { + width: this.element.clientWidth, + height: this.element.clientHeight, + }; + } + +} diff --git a/src/core/lang.json b/src/core/lang.json index 14f113a28..1d13f4d39 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -270,6 +270,8 @@ "sorry": "Sorry...", "sort": "Sort", "sortby": "Sort by", + "scrollbackward": "Scroll backward", + "scrollforward": "Scroll forward", "start": "Start", "storingfiles": "Storing files", "strftimedate": "%d %B %Y", diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index b712d1574..6d1a7d2b9 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -32,6 +32,10 @@ text-transform: none; } +.flex { display: flex; } +.inline-block { display: inline-block; } +.block { display: block; } + .flex-row { display: flex; flex-direction: row; diff --git a/tsconfig.app.json b/tsconfig.app.json index feedfbd74..530f84a16 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -8,6 +8,7 @@ "cordova", "dom-mediacapture-record", "node", + "resize-observer-browser", "webpack-env" ], "paths": { diff --git a/tsconfig.json b/tsconfig.json index 0e32c7acc..700301d46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "faker", "jest", "node", + "resize-observer-browser", "webpack-env" ], "paths": { diff --git a/tsconfig.test.json b/tsconfig.test.json index 34d2a3698..58fd560d7 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -12,7 +12,8 @@ "dom-mediacapture-record", "faker", "jest", - "node" + "node", + "resize-observer-browser" ], "paths": { "@addons/*": ["addons/*"],