From 0448fc61ee6e4a3e5c47dfb63a177ef9acbbdf5e Mon Sep 17 00:00:00 2001 From: N1XUS Date: Mon, 25 Jul 2022 15:48:16 +0300 Subject: [PATCH] feat(core): migrate breadcrumbs component (#8402) * feat(core): migrate breadcrumbs component * fix(core): fix flickering of the overflow layout items * fix(docs): remove deprecated example * fix(core): dispose subscription on destroy * fix(core): add complete subscriptions on destroy * fix(fn): unsubscribe from observable * fix(core): fix unit tests * fix(core): add deprecation message --- .../breadcrumb/breadcrumb-docs.component.html | 12 - .../breadcrumb/breadcrumb-docs.component.ts | 9 - .../breadcrumb/breadcrumb-docs.module.ts | 4 +- .../examples/breadcrumb-examples.component.ts | 6 - ...eadcrumb-responsive-example.component.html | 16 - e2e/wdio/core/pages/dynamic-page.po.ts | 2 +- .../breadcrumb/breadcrumb-item.component.ts | 82 +++-- .../lib/breadcrumb/breadcrumb.component.html | 114 ++++--- .../lib/breadcrumb/breadcrumb.component.scss | 10 +- .../breadcrumb/breadcrumb.component.spec.ts | 42 ++- .../lib/breadcrumb/breadcrumb.component.ts | 196 +++++------ .../src/lib/breadcrumb/breadcrumb.module.ts | 6 +- libs/core/src/lib/breadcrumb/test.ts | 1 + .../dynamic-page-header.component.spec.ts | 2 +- libs/core/src/lib/dynamic-page/test.ts | 2 +- .../directives/overflow-expand.directive.ts | 9 +- .../overflow-item-container-ref.directive.ts | 2 +- .../directives/overflow-item-ref.directive.ts | 29 +- ...verflow-layout-item-container.directive.ts | 23 +- .../overflow-layout-item.directive.ts | 13 + libs/core/src/lib/overflow-layout/index.ts | 2 + .../interfaces/overflow-expand.interface.ts | 6 +- .../interfaces/overflow-item-ref.interface.ts | 24 +- .../interfaces/overflow-item.interface.ts | 4 +- .../overflow-layout.component.html | 96 +++--- .../overflow-layout.component.scss | 6 + .../overflow-layout.component.spec.ts | 24 +- .../overflow-layout.component.ts | 269 +++++---------- .../overflow-layout.service.ts | 319 ++++++++++++++++++ libs/core/src/lib/overflow-layout/test.ts | 2 +- .../time/time-column/time-column.component.ts | 16 +- .../lib/utils/functions/resize-observable.ts | 9 +- libs/fn/src/lib/select/select.component.ts | 8 +- 33 files changed, 856 insertions(+), 509 deletions(-) delete mode 100644 apps/docs/src/app/core/component-docs/breadcrumb/examples/breadcrumb-responsive-example.component.html create mode 100644 libs/core/src/lib/overflow-layout/overflow-layout.service.ts diff --git a/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.component.html b/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.component.html index 70248d6b88f..60b0ebcac6c 100644 --- a/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.component.html +++ b/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.component.html @@ -20,15 +20,3 @@ - - Responsive Breadcrumbs - - The breadcrumb will automatically refer to its parent element to know whether to show breadcrumbs or to collapse - them into the overflow menu. You can provide an HTMLElement to the [containerElement] input to set your own - container element. Note that the responsiveness feature will not function properly if the containerElement or parent - element have a set fixed width. - - - - - diff --git a/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.component.ts b/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.component.ts index 2da69aadf2c..0f008b40ab9 100644 --- a/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.component.ts +++ b/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.component.ts @@ -1,7 +1,6 @@ import { Component } from '@angular/core'; import breadcrumbHrefExample from '!./examples/breadcrumb-href-example.component.html?raw'; -import breadcrumbResponsiveExample from '!./examples/breadcrumb-responsive-example.component.html?raw'; import breadcrumbRouterLinkExample from '!./examples/breadcrumb-routerLink-example.component.html?raw'; import { ExampleFile } from '../../../documentation/core-helpers/code-example/example-file'; @@ -25,12 +24,4 @@ export class BreadcrumbDocsComponent { fileName: 'fd-breadcrumb-href-example' } ]; - - breadcrumbResponsiveHtml: ExampleFile[] = [ - { - language: 'html', - code: breadcrumbResponsiveExample, - fileName: 'fd-breadcrumb-responsive-example' - } - ]; } diff --git a/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.module.ts b/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.module.ts index 278ba2d7d89..109e365840d 100644 --- a/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.module.ts +++ b/apps/docs/src/app/core/component-docs/breadcrumb/breadcrumb-docs.module.ts @@ -4,7 +4,6 @@ import { ApiComponent } from '../../../documentation/core-helpers/api/api.compon import { API_FILES } from '../../api-files'; import { BreadcrumbHrefExampleComponent, - BreadcrumbResponsiveExampleComponent, BreadcrumbRouterLinkExampleComponent } from './examples/breadcrumb-examples.component'; import { BreadcrumbHeaderComponent } from './breadcrumb-header/breadcrumb-header.component'; @@ -31,8 +30,7 @@ const routes: Routes = [ BreadcrumbDocsComponent, BreadcrumbHeaderComponent, BreadcrumbHrefExampleComponent, - BreadcrumbRouterLinkExampleComponent, - BreadcrumbResponsiveExampleComponent + BreadcrumbRouterLinkExampleComponent ], providers: [moduleDeprecationsProvider(DeprecatedBreadcrumbsCompactDirective)] }) diff --git a/apps/docs/src/app/core/component-docs/breadcrumb/examples/breadcrumb-examples.component.ts b/apps/docs/src/app/core/component-docs/breadcrumb/examples/breadcrumb-examples.component.ts index a6bab6bdd92..49982156729 100644 --- a/apps/docs/src/app/core/component-docs/breadcrumb/examples/breadcrumb-examples.component.ts +++ b/apps/docs/src/app/core/component-docs/breadcrumb/examples/breadcrumb-examples.component.ts @@ -37,9 +37,3 @@ export class BreadcrumbRouterLinkExampleComponent { ] }) export class BreadcrumbHrefExampleComponent {} - -@Component({ - selector: 'fd-breadcrumb-responsive-example', - templateUrl: './breadcrumb-responsive-example.component.html' -}) -export class BreadcrumbResponsiveExampleComponent {} diff --git a/apps/docs/src/app/core/component-docs/breadcrumb/examples/breadcrumb-responsive-example.component.html b/apps/docs/src/app/core/component-docs/breadcrumb/examples/breadcrumb-responsive-example.component.html deleted file mode 100644 index a07511971f2..00000000000 --- a/apps/docs/src/app/core/component-docs/breadcrumb/examples/breadcrumb-responsive-example.component.html +++ /dev/null @@ -1,16 +0,0 @@ -
- - - Breadcrumb Level 1 - - - Breadcrumb Level 2 - - - Breadcrumb Level 3 - - - Breadcrumb Level 4 - - -
diff --git a/e2e/wdio/core/pages/dynamic-page.po.ts b/e2e/wdio/core/pages/dynamic-page.po.ts index 466211241e5..94b07de9cc6 100644 --- a/e2e/wdio/core/pages/dynamic-page.po.ts +++ b/e2e/wdio/core/pages/dynamic-page.po.ts @@ -30,7 +30,7 @@ export class DynamicPagePo extends CoreBaseComponentPo { flexibleColumn = '.fd-flexible-column-layout__column '; article = '.fd-dynamic-page-section-example'; breadcrumbLink = '.fd-dynamic-page__breadcrumb-wrapper a'; - currentBreadcrumbLink = '.fd-dynamic-page__breadcrumb-wrapper fd-breadcrumb-item:last-child span'; + currentBreadcrumbLink = '.fd-dynamic-page__breadcrumb-wrapper .fd-overflow-layout__item--last span'; open(): void { super.open(this.url); diff --git a/libs/core/src/lib/breadcrumb/breadcrumb-item.component.ts b/libs/core/src/lib/breadcrumb/breadcrumb-item.component.ts index 60a978eb0a0..15c96a9ff32 100644 --- a/libs/core/src/lib/breadcrumb/breadcrumb-item.component.ts +++ b/libs/core/src/lib/breadcrumb/breadcrumb-item.component.ts @@ -1,14 +1,14 @@ +import { DomPortal } from '@angular/cdk/portal'; import { AfterViewInit, - ChangeDetectorRef, + ChangeDetectionStrategy, Component, ContentChild, ElementRef, forwardRef, - Renderer2 + ViewEncapsulation } from '@angular/core'; import { LinkComponent } from '@fundamental-ngx/core/link'; -import { DomPortal } from '@angular/cdk/portal'; /** * Breadcrumb item directive. Must have child breadcrumb link directives. @@ -21,54 +21,84 @@ import { DomPortal } from '@angular/cdk/portal'; */ @Component({ selector: 'fd-breadcrumb-item', - template: '
', + template: '', host: { class: 'fd-breadcrumb__item' - } + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush }) export class BreadcrumbItemComponent implements AfterViewInit { - /** @hidden */ - get elementRef(): ElementRef { - return this._elementRef; - } - /** @hidden */ @ContentChild(forwardRef(() => LinkComponent)) breadcrumbLink: LinkComponent; - /** @hidden */ - get width(): number { - return this._elementRef.nativeElement.getBoundingClientRect().width; - } - /** In case there is no link in Item and breadcrumb item is non-interactive, we move whole item content to menu item title */ breadcrumbItemPortal: DomPortal; /** When breadcrumb item has link in it, we are moving link content to menu item title */ linkContentPortal: DomPortal; - constructor( - private _elementRef: ElementRef, - private renderer2: Renderer2, - private _cdR: ChangeDetectorRef - ) {} + /** + * Breadcrumb item dom portal. + */ + portal: DomPortal; + + /** @hidden */ + private _attached = false; + + /** @hidden */ + constructor(public readonly elementRef: ElementRef) {} /** @hidden */ - get needsClickProxy(): boolean { + get _needsClickProxy(): boolean { return ( !!this.breadcrumbLink?.elementRef().nativeElement.getAttribute('href') || !!this.breadcrumbLink.routerLink ); } - show = (): void => this.renderer2.setStyle(this._elementRef.nativeElement, 'display', 'inline-block'); - hide = (): void => this.renderer2.setStyle(this._elementRef.nativeElement, 'display', 'none'); - /** @hidden */ ngAfterViewInit(): void { - if (this.breadcrumbLink) { + this._attach(); + } + + /** + * Sets breadcrumb item dom portal. + */ + setPortal(): void { + if (!this.portal) { + this.portal = new DomPortal(this.elementRef); + } + } + + /** @hidden */ + _detach(): void { + if (!this._attached) { + return; + } + + if (this.linkContentPortal?.isAttached) { + this.linkContentPortal?.detach(); + } + + if (this.breadcrumbItemPortal?.isAttached) { + this.breadcrumbItemPortal?.detach(); + } + + this._attached = false; + } + + /** @hidden */ + _attach(): void { + if (this._attached) { + return; + } + + if (this.breadcrumbLink && this.breadcrumbLink.contentSpan) { this.linkContentPortal = new DomPortal(this.breadcrumbLink.contentSpan.nativeElement); } + this.breadcrumbItemPortal = new DomPortal(this.elementRef.nativeElement.firstElementChild as Element); - this._cdR.detectChanges(); + this._attached = true; } } diff --git a/libs/core/src/lib/breadcrumb/breadcrumb.component.html b/libs/core/src/lib/breadcrumb/breadcrumb.component.html index 91181efaf02..b0ccf163ad3 100644 --- a/libs/core/src/lib/breadcrumb/breadcrumb.component.html +++ b/libs/core/src/lib/breadcrumb/breadcrumb.component.html @@ -1,52 +1,74 @@ - - -
  • + + +
    + +
    +
    + + + +
  • + + + + + + + + + + + + + + + + + + +
  • +
    +
    + - - - - - - - - - - - - - - - - + … + - + - - - - - ... - - - + diff --git a/libs/core/src/lib/breadcrumb/breadcrumb.component.scss b/libs/core/src/lib/breadcrumb/breadcrumb.component.scss index 0bfc1cf97ef..8a159551678 100644 --- a/libs/core/src/lib/breadcrumb/breadcrumb.component.scss +++ b/libs/core/src/lib/breadcrumb/breadcrumb.component.scss @@ -1,10 +1,18 @@ @import '~fundamental-styles/dist/breadcrumb'; .fd-breadcrumb { - display: inline-block; + display: flex; white-space: nowrap; .fd-breadcrumb__collapsed { cursor: pointer; } } + +.fd-breadcrumb__item:last-child::after { + content: '/'; +} + +.fd-overflow-layout__item--last .fd-breadcrumb__item::after { + content: none; +} diff --git a/libs/core/src/lib/breadcrumb/breadcrumb.component.spec.ts b/libs/core/src/lib/breadcrumb/breadcrumb.component.spec.ts index 2fcc4ecb10c..0e5b38c8865 100644 --- a/libs/core/src/lib/breadcrumb/breadcrumb.component.spec.ts +++ b/libs/core/src/lib/breadcrumb/breadcrumb.component.spec.ts @@ -1,8 +1,10 @@ +import { PortalModule } from '@angular/cdk/portal'; import { Component, NO_ERRORS_SCHEMA, ViewChild } from '@angular/core'; import { RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { OverflowLayoutModule } from '@fundamental-ngx/core/overflow-layout'; import { PopoverModule } from '@fundamental-ngx/core/popover'; import { MenuModule } from '@fundamental-ngx/core/menu'; import { IconModule } from '@fundamental-ngx/core/icon'; @@ -40,7 +42,16 @@ describe('BreadcrumbComponent', () => { waitForAsync(() => { TestBed.configureTestingModule({ declarations: [BreadcrumbComponent, BreadcrumbItemComponent, BreadcrumbWrapperComponent], - imports: [PopoverModule, MenuModule, IconModule, LinkModule, RouterModule, RouterTestingModule], + imports: [ + PopoverModule, + MenuModule, + IconModule, + LinkModule, + RouterModule, + RouterTestingModule, + OverflowLayoutModule, + PortalModule + ], providers: [RtlService], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -59,25 +70,26 @@ describe('BreadcrumbComponent', () => { expect(component).toBeTruthy(); }); - it('should handle onResize - enlarging the screen', async () => { - spyOn(component.elementRef.nativeElement.parentElement as Element, 'getBoundingClientRect').and.returnValue({ - width: component.elementRef.nativeElement.getBoundingClientRect().width + 100 - } as any); + it('should handle onResize - enlarging the screen', fakeAsync(() => { + const hiddenItemsCountSpy = spyOn(component, '_onHiddenItemsCountChange').and.callThrough(); + component.elementRef.nativeElement.parentElement!.style.width = '500px'; component.onResize(); - await whenStable(fixture); + tick(1000); - expect(component._collapsedBreadcrumbItems.length).toBe(0); - }); + expect(hiddenItemsCountSpy).toHaveBeenCalledWith(0); + })); - it('should handle onResize - shrinking the screen', () => { - spyOn(component.elementRef.nativeElement.parentElement as Element, 'getBoundingClientRect').and.returnValue({ - width: component.elementRef.nativeElement.getBoundingClientRect().width / 2 - } as any); + it('should handle onResize - shrinking the screen', fakeAsync(() => { + const hiddenItemsCountSpy = spyOn(component, '_onHiddenItemsCountChange').and.callThrough(); + component.elementRef.nativeElement.parentElement!.style.width = '200px'; component.onResize(); + fixture.detectChanges(); - expect(component._collapsedBreadcrumbItems.length).toBeGreaterThan(1); - }); + tick(1000); + + expect(hiddenItemsCountSpy).toHaveBeenCalledWith(2); + })); }); diff --git a/libs/core/src/lib/breadcrumb/breadcrumb.component.ts b/libs/core/src/lib/breadcrumb/breadcrumb.component.ts index 5d1dff90dac..f409136ed4b 100644 --- a/libs/core/src/lib/breadcrumb/breadcrumb.component.ts +++ b/libs/core/src/lib/breadcrumb/breadcrumb.component.ts @@ -5,19 +5,20 @@ import { Component, ContentChildren, ElementRef, - forwardRef, + EventEmitter, Input, - NgZone, - OnDestroy, + isDevMode, OnInit, Optional, + Output, QueryList, ViewChild, ViewEncapsulation } from '@angular/core'; +import { OverflowLayoutComponent } from '@fundamental-ngx/core/overflow-layout'; import { BreadcrumbItemComponent } from './breadcrumb-item.component'; -import { ResizeObserverService, RtlService } from '@fundamental-ngx/core/utils'; -import { BehaviorSubject, debounceTime, firstValueFrom, map, startWith, Subscription, tap } from 'rxjs'; +import { DestroyedService, RtlService } from '@fundamental-ngx/core/utils'; +import { BehaviorSubject, takeUntil } from 'rxjs'; import { MenuComponent } from '@fundamental-ngx/core/menu'; import { Placement } from '@fundamental-ngx/core/shared'; @@ -33,8 +34,6 @@ import { Placement } from '@fundamental-ngx/core/shared'; * ``` */ @Component({ - // TODO to be discussed - // eslint-disable-next-line selector: 'fd-breadcrumb', host: { class: 'fd-breadcrumb' @@ -42,127 +41,99 @@ import { Placement } from '@fundamental-ngx/core/shared'; templateUrl: './breadcrumb.component.html', styleUrls: ['./breadcrumb.component.scss'], encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DestroyedService] }) -export class BreadcrumbComponent implements AfterViewInit, OnInit, OnDestroy { - /** @hidden */ - @ContentChildren(forwardRef(() => BreadcrumbItemComponent)) - _breadcrumbItems: QueryList; - - /** @hidden */ - @ViewChild(MenuComponent) - _menuComponent: MenuComponent; - - @ViewChild('overflowBreadcrumbsContainer') - private readonly _overflowContainer: ElementRef; - - /** @hidden */ - _collapsedBreadcrumbItems: Array = []; - - /** @hidden */ - _placement$ = new BehaviorSubject('bottom-start'); - +export class BreadcrumbComponent implements OnInit, AfterViewInit { /** + * @deprecated + * Breadcrumbs component now uses more advanced calculation mechanism without the need of specifying the container element. + * * The element to act as the breadcrumb container. When provided, the breadcrumb's responsive collapsing behavior * performs better. When not provided, the immediate parent element's width will be used. */ @Input() - containerElement: HTMLElement; + set containerElement(_: HTMLElement) { + if (isDevMode()) { + console.warn( + 'Breadcrumbs component now uses more advanced calculation mechanism without the need of specifying the container element.' + ); + } + } /** Whether to append items to the overflow dropdown in reverse order. Default is true. */ @Input() reverse = false; + /** + * Event emitted when visible items count is changed. + */ + @Output() + visibleItemsCount = new EventEmitter(); + + /** + * Event emitted when hidden items count is changed. + */ + @Output() + hiddenItemsCount = new EventEmitter(); + /** @hidden */ - _containerBoundary: number; + @ContentChildren(BreadcrumbItemComponent) + private readonly _contentItems: QueryList; /** @hidden */ - private _subscriptions = new Subscription(); + @ViewChild(MenuComponent) + private readonly _menuComponent: MenuComponent; /** @hidden */ - private _itemToSize = new Map(); + @ViewChild(OverflowLayoutComponent) + private readonly _overflowLayout: OverflowLayoutComponent; + /** + * @hidden + * Array of breadcrumb items. + */ + _items: BreadcrumbItemComponent[] = []; + + /** @hidden */ + _placement$ = new BehaviorSubject('bottom-start'); + + /** @hidden */ constructor( - public readonly elementRef: ElementRef, - @Optional() private readonly _rtlService: RtlService | null, - private readonly _cdRef: ChangeDetectorRef, - private readonly _resizeObserver: ResizeObserverService, - private readonly _ngZone: NgZone + public elementRef: ElementRef, + private _onDestroy$: DestroyedService, + @Optional() private _rtlService: RtlService | null, + private _cdr: ChangeDetectorRef ) {} /** @hidden */ ngOnInit(): void { - if (this._rtlService) { - this._subscriptions.add( - this._rtlService.rtl.subscribe((value) => this._placement$.next(value ? 'bottom-end' : 'bottom-start')) - ); - } - } - - /** @hidden */ - ngAfterViewInit(): void { - this._subscriptions.add( - this._breadcrumbItems.changes - .pipe( - startWith(this._breadcrumbItems), - map((items) => items.toArray() as BreadcrumbItemComponent[]), - map((items) => items.map((item) => [item, item.width]) as [BreadcrumbItemComponent, number][]), - tap((itemToSize: [BreadcrumbItemComponent, number][]) => (this._itemToSize = new Map(itemToSize))) - ) - .subscribe() - ); - firstValueFrom(this._ngZone.onStable).then(() => { - this._subscriptions.add( - this._resizeObserver - .observe(this.containerElement || (this.elementRef.nativeElement.parentElement as Element)) - // Add small delay for performance reasons. - .pipe(debounceTime(30)) - .subscribe(() => this.onResize()) - ); - }); + this._rtlService?.rtl + .pipe(takeUntil(this._onDestroy$)) + .subscribe((value) => this._placement$.next(value ? 'bottom-end' : 'bottom-start')); } /** @hidden */ - ngOnDestroy(): void { - this._subscriptions.unsubscribe(); + onResize(): void { + this._overflowLayout.triggerRecalculation(); } /** - * Callback function when breadcrumbs container has been resized. - */ - onResize(): void { - if (!this.elementRef.nativeElement.parentElement) { - return; - } - this._containerBoundary = this.elementRef.nativeElement.parentElement.getBoundingClientRect().width; - - if (this.containerElement) { - this._containerBoundary = this.containerElement.getBoundingClientRect().width; + * We catch interactions with item, Enter, Space, Mouse click and Touch click, + * if original element had router link we are proxying click to that element + * */ + itemClicked(breadcrumbItem: any, $event: any): void { + if (breadcrumbItem.needsClickProxy) { + $event.preventDefault(); + breadcrumbItem.breadcrumbLink.elementRef().nativeElement.click(); } + } - if (this._overflowContainer) { - this._containerBoundary -= this._overflowContainer.nativeElement.getBoundingClientRect().width; - } + /** @hidden */ + ngAfterViewInit(): void { + this._setItems(); - let visibleSum = 0; - const breadcrumbItemComponents = this._breadcrumbItems.toArray(); - let i; - for (i = breadcrumbItemComponents.length - 1; i >= 0; i--) { - const breadcrumbItem = breadcrumbItemComponents[i]; - const itemSize = this._itemToSize.has(breadcrumbItem) - ? (this._itemToSize.get(breadcrumbItem) as number) - : breadcrumbItem.width; - if (visibleSum + itemSize <= this._containerBoundary) { - visibleSum += itemSize; - breadcrumbItem.show(); - } else { - break; - } - } - const collapsedBreadcrumbItems = breadcrumbItemComponents.slice(0, ++i); - this._collapsedBreadcrumbItems = this.reverse ? collapsedBreadcrumbItems : collapsedBreadcrumbItems.reverse(); - this._collapsedBreadcrumbItems.forEach((item) => item.hide()); - this._cdRef.detectChanges(); + this._contentItems.changes.subscribe(() => this._setItems()); } /** @hidden */ @@ -171,14 +142,29 @@ export class BreadcrumbComponent implements AfterViewInit, OnInit, OnDestroy { event.preventDefault(); } - /** - * We catch interactions with item, Enter, Space, Mouse click and Touch click, - * if original element had router link we are proxying click to that element - * */ - itemClicked(breadcrumbItem: BreadcrumbItemComponent, $event: any): void { - if (breadcrumbItem.needsClickProxy) { - $event.preventDefault(); - breadcrumbItem.breadcrumbLink.elementRef().nativeElement.click(); + /** @hidden */ + _onHiddenChange(isHidden: boolean, breadcrumb: BreadcrumbItemComponent): void { + if (!isHidden) { + breadcrumb._detach(); + } else { + breadcrumb._attach(); } } + + /** @hidden */ + _onVisibleItemsCountChange(visibleItemsCount: number): void { + this.visibleItemsCount.emit(visibleItemsCount); + } + + /** @hidden */ + _onHiddenItemsCountChange(hiddenItemsCount: number): void { + this.hiddenItemsCount.emit(hiddenItemsCount); + } + + /** @hidden */ + private _setItems(): void { + this._contentItems.forEach((item) => item.setPortal()); + this._items = this._contentItems.toArray(); + this._cdr.detectChanges(); + } } diff --git a/libs/core/src/lib/breadcrumb/breadcrumb.module.ts b/libs/core/src/lib/breadcrumb/breadcrumb.module.ts index 471957adbc4..9fa87f34725 100644 --- a/libs/core/src/lib/breadcrumb/breadcrumb.module.ts +++ b/libs/core/src/lib/breadcrumb/breadcrumb.module.ts @@ -1,5 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { ButtonModule } from '@fundamental-ngx/core/button'; +import { OverflowLayoutModule } from '@fundamental-ngx/core/overflow-layout'; import { BreadcrumbComponent } from './breadcrumb.component'; import { BreadcrumbItemComponent } from './breadcrumb-item.component'; @@ -21,7 +23,9 @@ import { ContentDensityModule } from '@fundamental-ngx/core/content-density'; LinkModule, PortalModule, PipeModule, - ContentDensityModule + ContentDensityModule, + OverflowLayoutModule, + ButtonModule ], exports: [ BreadcrumbComponent, diff --git a/libs/core/src/lib/breadcrumb/test.ts b/libs/core/src/lib/breadcrumb/test.ts index f7496773ee9..949a08108b3 100644 --- a/libs/core/src/lib/breadcrumb/test.ts +++ b/libs/core/src/lib/breadcrumb/test.ts @@ -4,6 +4,7 @@ import 'core-js/es/reflect'; import 'zone.js'; import 'zone.js/testing'; +import '@angular/localize/init'; import { getTestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; diff --git a/libs/core/src/lib/dynamic-page/dynamic-page-header/header/dynamic-page-header.component.spec.ts b/libs/core/src/lib/dynamic-page/dynamic-page-header/header/dynamic-page-header.component.spec.ts index 3e3d4f7a476..0b264a4a2fd 100644 --- a/libs/core/src/lib/dynamic-page/dynamic-page-header/header/dynamic-page-header.component.spec.ts +++ b/libs/core/src/lib/dynamic-page/dynamic-page-header/header/dynamic-page-header.component.spec.ts @@ -70,7 +70,7 @@ describe('DynamicPageTitleComponent', () => { header.size = 'small'; - tick(5); + tick(50); expect(breadcrumbSpy).toHaveBeenCalled(); expect(contentToolbarSpy).toHaveBeenCalledWith('small'); diff --git a/libs/core/src/lib/dynamic-page/test.ts b/libs/core/src/lib/dynamic-page/test.ts index f7496773ee9..54954bff26d 100644 --- a/libs/core/src/lib/dynamic-page/test.ts +++ b/libs/core/src/lib/dynamic-page/test.ts @@ -2,7 +2,7 @@ import 'core-js/es/reflect'; import 'zone.js'; - +import '@angular/localize/init'; import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; diff --git a/libs/core/src/lib/overflow-layout/directives/overflow-expand.directive.ts b/libs/core/src/lib/overflow-layout/directives/overflow-expand.directive.ts index 42481ba8c27..fc27b89c269 100644 --- a/libs/core/src/lib/overflow-layout/directives/overflow-expand.directive.ts +++ b/libs/core/src/lib/overflow-layout/directives/overflow-expand.directive.ts @@ -1,4 +1,4 @@ -import { Directive, TemplateRef } from '@angular/core'; +import { Directive, Input, TemplateRef } from '@angular/core'; import { OverflowExpand, OverflowExpandDirectiveContext } from '../interfaces/overflow-expand.interface'; import { FD_OVERFLOW_EXPAND } from '../tokens/overflow-expand.token'; @@ -14,7 +14,10 @@ import { FD_OVERFLOW_EXPAND } from '../tokens/overflow-expand.token'; } ] }) -export class OverflowExpandDirective implements OverflowExpand { +export class OverflowExpandDirective implements OverflowExpand { + @Input() + fdOverflowExpandItems: T; + /** @hidden */ static ngTemplateContextGuard( dir: OverflowExpandDirective, @@ -24,5 +27,5 @@ export class OverflowExpandDirective implements OverflowExpand { } /** @hidden */ - constructor(public templateRef: TemplateRef) {} + constructor(public templateRef: TemplateRef>) {} } diff --git a/libs/core/src/lib/overflow-layout/directives/overflow-item-container-ref.directive.ts b/libs/core/src/lib/overflow-layout/directives/overflow-item-container-ref.directive.ts index 8065c0466e8..d29b23d69ac 100644 --- a/libs/core/src/lib/overflow-layout/directives/overflow-item-container-ref.directive.ts +++ b/libs/core/src/lib/overflow-layout/directives/overflow-item-container-ref.directive.ts @@ -28,7 +28,7 @@ export class OverflowItemContainerRefDirective { if (value && !this._detached) { this._viewRef = this._viewContainerRef.detach()!; this._detached = true; - } else if (!value && this._viewRef && this._detached) { + } else if (!value && this._viewRef && !this._viewRef.destroyed && this._detached) { this._viewRef = this._viewContainerRef.insert(this._viewRef); this._detached = false; } diff --git a/libs/core/src/lib/overflow-layout/directives/overflow-item-ref.directive.ts b/libs/core/src/lib/overflow-layout/directives/overflow-item-ref.directive.ts index 0c82539a409..4080e1ff054 100644 --- a/libs/core/src/lib/overflow-layout/directives/overflow-item-ref.directive.ts +++ b/libs/core/src/lib/overflow-layout/directives/overflow-item-ref.directive.ts @@ -1,4 +1,4 @@ -import { Directive, ElementRef, TemplateRef } from '@angular/core'; +import { Directive, ElementRef, Input, TemplateRef } from '@angular/core'; import { OverflowItemDirectiveContext, OverflowItemRef } from '../interfaces/overflow-item-ref.interface'; import { OverflowItem } from '../interfaces/overflow-item.interface'; import { FD_OVERFLOW_ITEM_REF } from '../tokens/overflow-item-ref.token'; @@ -15,7 +15,7 @@ import { FD_OVERFLOW_ITEM_REF } from '../tokens/overflow-item-ref.token'; } ] }) -export class OverflowItemRefDirective implements OverflowItemRef { +export class OverflowItemRefDirective implements OverflowItemRef { /** * Element ref of the `fdOverflowLayoutItem` directive. */ @@ -27,12 +27,33 @@ export class OverflowItemRefDirective implements OverflowItemRef { /** * Whether the item is hidden. */ - hidden = false; + get hidden(): boolean { + return this._hidden; + } + + set hidden(value: boolean) { + this._hidden = value; + this.overflowItem.hiddenChange.emit(value); + } + _hidden = false; /** * Index of the item in the array of Overflow Layout Component's items. */ index: number; + /** Whether this item is last in the array. */ + first: boolean; + + /** Whether this item is first in the array. */ + last: boolean; + + /** Whether the item is softly hidden. */ + softHidden = true; + + /** Item instance. Used for correct autocomplete. */ + @Input('fdOverflowItemRef') + item: T; + /** @hidden */ static ngTemplateContextGuard( dir: OverflowItemRefDirective, @@ -42,7 +63,7 @@ export class OverflowItemRefDirective implements OverflowItemRef { } /** @hidden */ - constructor(public templateRef: TemplateRef) {} + constructor(public templateRef: TemplateRef>) {} /** * Sets the element reference of the `fdOverflowLayoutItem` directive.` diff --git a/libs/core/src/lib/overflow-layout/directives/overflow-layout-item-container.directive.ts b/libs/core/src/lib/overflow-layout/directives/overflow-layout-item-container.directive.ts index 022a28792bc..3401048dea1 100644 --- a/libs/core/src/lib/overflow-layout/directives/overflow-layout-item-container.directive.ts +++ b/libs/core/src/lib/overflow-layout/directives/overflow-layout-item-container.directive.ts @@ -1,4 +1,4 @@ -import { ContentChild, Directive, ElementRef, HostBinding } from '@angular/core'; +import { ContentChild, Directive, ElementRef, HostBinding, Input } from '@angular/core'; import { OverflowItemContainerRefDirective } from './overflow-item-container-ref.directive'; @Directive({ @@ -9,6 +9,27 @@ export class OverflowLayoutItemContainerDirective { @HostBinding('class') private readonly _initialClass = 'fd-overflow-layout__item'; + /** + * Whether this item is the first one in the array. + */ + @Input() + @HostBinding('class.fd-overflow-layout__item--last') + last = false; + + /** + * Whether this item is the last one in the array. + */ + @Input() + @HostBinding('class.fd-overflow-layout__item--first') + first = false; + + /** + * Whether this item is softly hidden. Used during free space calculation without flickering of the items. + */ + @Input() + @HostBinding('class.fd-overflow-layout__item--soft-hidden') + softHidden = true; + /** * Container reference. */ diff --git a/libs/core/src/lib/overflow-layout/directives/overflow-layout-item.directive.ts b/libs/core/src/lib/overflow-layout/directives/overflow-layout-item.directive.ts index b8cd83a427b..eeff0209521 100644 --- a/libs/core/src/lib/overflow-layout/directives/overflow-layout-item.directive.ts +++ b/libs/core/src/lib/overflow-layout/directives/overflow-layout-item.directive.ts @@ -1,12 +1,14 @@ import { Directive, ElementRef, + EventEmitter, HostBinding, HostListener, Inject, Input, OnInit, Optional, + Output, SkipSelf } from '@angular/core'; import { OverflowItemRef } from '../interfaces/overflow-item-ref.interface'; @@ -46,6 +48,13 @@ export class OverflowLayoutItemDirective implements OverflowItem, OnInit { get forceVisibility(): boolean { return this._forceVisibility; } + + /** + * Event emitted when `hidden` property has been changed. + */ + @Output() + hiddenChange = new EventEmitter(); + /** * Whether the item is hidden. */ @@ -53,6 +62,10 @@ export class OverflowLayoutItemDirective implements OverflowItem, OnInit { return this._overflowItemRef?.hidden === true; } + set hidden(value: boolean) { + this.hiddenChange.emit(value); + } + /** @hidden */ @HostBinding('attr.tabindex') private get _tabindex(): number { diff --git a/libs/core/src/lib/overflow-layout/index.ts b/libs/core/src/lib/overflow-layout/index.ts index 9bebed127fa..da06a562fb6 100644 --- a/libs/core/src/lib/overflow-layout/index.ts +++ b/libs/core/src/lib/overflow-layout/index.ts @@ -12,3 +12,5 @@ export * from './interfaces/overflow-popover-content.interface'; export * from './tokens/overflow-expand.token'; export * from './tokens/overflow-item-ref.token'; export * from './tokens/overflow-item.token'; +export * from './tokens/overflow-container.token'; +export * from './overflow-layout.service'; diff --git a/libs/core/src/lib/overflow-layout/interfaces/overflow-expand.interface.ts b/libs/core/src/lib/overflow-layout/interfaces/overflow-expand.interface.ts index 2ff236cb89b..8bb18d18e87 100644 --- a/libs/core/src/lib/overflow-layout/interfaces/overflow-expand.interface.ts +++ b/libs/core/src/lib/overflow-layout/interfaces/overflow-expand.interface.ts @@ -1,11 +1,11 @@ import { TemplateRef } from '@angular/core'; import { OverflowItemRef } from './overflow-item-ref.interface'; -export type OverflowExpandDirectiveContext = { $implicit: OverflowItemRef[] }; +export type OverflowExpandDirectiveContext = { $implicit: OverflowItemRef[] }; -export interface OverflowExpand { +export interface OverflowExpand { /** * Template reference of the directive. */ - templateRef: TemplateRef; + templateRef: TemplateRef>; } diff --git a/libs/core/src/lib/overflow-layout/interfaces/overflow-item-ref.interface.ts b/libs/core/src/lib/overflow-layout/interfaces/overflow-item-ref.interface.ts index 8a7d16ee338..c313f703af8 100644 --- a/libs/core/src/lib/overflow-layout/interfaces/overflow-item-ref.interface.ts +++ b/libs/core/src/lib/overflow-layout/interfaces/overflow-item-ref.interface.ts @@ -1,9 +1,15 @@ import { ElementRef, TemplateRef } from '@angular/core'; import { OverflowItem } from './overflow-item.interface'; -export type OverflowItemDirectiveContext = { $implicit: boolean; index: number }; +export type OverflowItemDirectiveContext = { + $implicit: boolean; + index: number; + first: boolean; + last: boolean; + item: T; +}; -export interface OverflowItemRef { +export interface OverflowItemRef { /** * Element reference. */ @@ -20,10 +26,22 @@ export interface OverflowItemRef { * The index of the item in the array of items. */ index: number; + + /** Whether this item is last in the array. */ + first: boolean; + + /** Whether this item is first in the array. */ + last: boolean; + + /** Whether the item is softly hidden. */ + softHidden: boolean; /** * Template reference of the directive. */ - templateRef: TemplateRef; + templateRef: TemplateRef>; + + /** Item instance. Used for correct autocomplete. */ + item: T; /** * Sets the element reference of the directive. diff --git a/libs/core/src/lib/overflow-layout/interfaces/overflow-item.interface.ts b/libs/core/src/lib/overflow-layout/interfaces/overflow-item.interface.ts index 13717402ce1..b33c6234ba0 100644 --- a/libs/core/src/lib/overflow-layout/interfaces/overflow-item.interface.ts +++ b/libs/core/src/lib/overflow-layout/interfaces/overflow-item.interface.ts @@ -1,5 +1,5 @@ import { FocusableOption } from '@angular/cdk/a11y'; -import { ElementRef } from '@angular/core'; +import { ElementRef, EventEmitter } from '@angular/core'; export interface OverflowItem extends FocusableOption { /** @@ -18,4 +18,6 @@ export interface OverflowItem extends FocusableOption { * Whether the item is hidden. */ hidden: boolean; + + hiddenChange: EventEmitter; } diff --git a/libs/core/src/lib/overflow-layout/overflow-layout.component.html b/libs/core/src/lib/overflow-layout/overflow-layout.component.html index 933196642f3..9bc1546f269 100644 --- a/libs/core/src/lib/overflow-layout/overflow-layout.component.html +++ b/libs/core/src/lib/overflow-layout/overflow-layout.component.html @@ -1,11 +1,20 @@ + + + +
    -
    +
    @@ -13,39 +22,50 @@
    -
    - - - - - - -
    - - - -
    -
    -
    -
    - -
    + + + + + +
    + + + + + + +
    + + + +
    +
    +
    +
    + +
    +
    diff --git a/libs/core/src/lib/overflow-layout/overflow-layout.component.scss b/libs/core/src/lib/overflow-layout/overflow-layout.component.scss index 89d68e4e37c..c2e769d070c 100644 --- a/libs/core/src/lib/overflow-layout/overflow-layout.component.scss +++ b/libs/core/src/lib/overflow-layout/overflow-layout.component.scss @@ -52,4 +52,10 @@ $fd-block: 'fd-overflow-layout'; display: block; white-space: normal; } + + &__item { + &--soft-hidden { + opacity: 0 !important; + } + } } diff --git a/libs/core/src/lib/overflow-layout/overflow-layout.component.spec.ts b/libs/core/src/lib/overflow-layout/overflow-layout.component.spec.ts index 281b4465e1e..e70f540e170 100644 --- a/libs/core/src/lib/overflow-layout/overflow-layout.component.spec.ts +++ b/libs/core/src/lib/overflow-layout/overflow-layout.component.spec.ts @@ -1,5 +1,5 @@ import { Component, ViewChild } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { OverflowLayoutItemDirective } from './directives/overflow-layout-item.directive'; import { OverflowLayoutComponent } from './overflow-layout.component'; @@ -12,12 +12,14 @@ import { OverflowLayoutModule } from './overflow-layout.module';
    + > + {{ i }} +
    @@ -69,20 +71,18 @@ describe('OverflowLayoutComponent', () => { ); }); - it('should render automatic amount of items', async (done) => { + it('should render automatic amount of items', fakeAsync(() => { + tick(1000); const expectedAmount = Math.floor(component.containerWidth / component.elementsWidth); - - component.overflowLayout.visibleItemsCount.subscribe((value) => { - expect(value).toEqual(expectedAmount); - done(); - }); - + const visibleItemsCountSpy = spyOn(component.overflowLayout.visibleItemsCount, 'emit').and.callThrough(); component.maxItems = Infinity; fixture.detectChanges(); - await fixture.whenStable(); + tick(1000); + + expect(visibleItemsCountSpy).toHaveBeenCalledWith(expectedAmount); expect(fixture.debugElement.queryAll(By.directive(OverflowLayoutItemDirective)).length).toEqual(expectedAmount); - }); + })); it('should react on items resize', async () => { component.elementsWidth = 300; diff --git a/libs/core/src/lib/overflow-layout/overflow-layout.component.ts b/libs/core/src/lib/overflow-layout/overflow-layout.component.ts index 36818513520..c93738fb333 100644 --- a/libs/core/src/lib/overflow-layout/overflow-layout.component.ts +++ b/libs/core/src/lib/overflow-layout/overflow-layout.component.ts @@ -1,4 +1,3 @@ -import { FocusKeyManager } from '@angular/cdk/a11y'; import { DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, TAB, UP_ARROW } from '@angular/cdk/keycodes'; import { Component, @@ -22,14 +21,15 @@ import { OnDestroy, Input } from '@angular/core'; -import { KeyUtil, resizeObservable, RtlService } from '@fundamental-ngx/core/utils'; -import { debounceTime, distinctUntilChanged, filter, skip, Subject, Subscription } from 'rxjs'; +import { KeyUtil, RtlService } from '@fundamental-ngx/core/utils'; +import { debounceTime, Subject, Subscription } from 'rxjs'; import { OverflowLayoutItemContainerDirective } from './directives/overflow-layout-item-container.directive'; import { OverflowContainer } from './interfaces/overflow-container.interface'; import { OverflowExpand } from './interfaces/overflow-expand.interface'; import { OverflowItemRef } from './interfaces/overflow-item-ref.interface'; import { OverflowItem } from './interfaces/overflow-item.interface'; import { OverflowPopoverContent } from './interfaces/overflow-popover-content.interface'; +import { OverflowLayoutConfig, OverflowLayoutService } from './overflow-layout.service'; import { FD_OVERFLOW_CONTAINER } from './tokens/overflow-container.token'; import { FD_OVERFLOW_EXPAND } from './tokens/overflow-expand.token'; import { FD_OVERFLOW_ITEM_REF } from './tokens/overflow-item-ref.token'; @@ -45,7 +45,8 @@ import { FD_OVERFLOW_ITEM } from './tokens/overflow-item.token'; { provide: FD_OVERFLOW_CONTAINER, useExisting: OverflowLayoutComponent - } + }, + OverflowLayoutService ] }) export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, OverflowContainer { @@ -65,6 +66,18 @@ export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, Overfl return this._maxVisibleItems; } + /** Direction of the fitting items calculation. */ + @Input() + showMorePosition: 'left' | 'right' = 'right'; + + /** Whether to render hidden items in reverse order. */ + @Input() + reverseHiddenItems = false; + + /** Whether to enable keyboard navigation. */ + @Input() + enableKeyboardNavigation = true; + /** * Event, triggered when amount of visible items has been changed. */ @@ -81,7 +94,7 @@ export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, Overfl * @hidden * List of items to display. */ - @ContentChildren(FD_OVERFLOW_ITEM_REF) + @ContentChildren(FD_OVERFLOW_ITEM_REF, { descendants: true }) _items: QueryList; /** @@ -95,7 +108,7 @@ export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, Overfl * @hidden * List of items that can be focused. */ - @ContentChildren(FD_OVERFLOW_ITEM) + @ContentChildren(FD_OVERFLOW_ITEM, { descendants: true }) _overflowItems: QueryList; /** @@ -139,12 +152,6 @@ export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, Overfl @HostBinding('class') private readonly _initialClass = 'fd-overflow-layout'; - /** @hidden */ - private _keyboardEventsManager: FocusKeyManager; - - /** @hidden */ - private _listenToItemResize = true; - /** @hidden */ private readonly _subscription = new Subscription(); @@ -155,25 +162,32 @@ export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, Overfl private _fillTrigger$ = new Subject(); /** @hidden */ - private _dir: 'rtl' | 'ltr' = 'ltr'; + private _maxVisibleItems = Infinity; /** @hidden */ - private _maxVisibleItems = Infinity; + private _canListenToResize = false; /** @hidden */ constructor( - private _cdr: ChangeDetectorRef, - private _zone: NgZone, - private _elRef: ElementRef, - @Optional() private _rtlService: RtlService + protected _cdr: ChangeDetectorRef, + protected _zone: NgZone, + protected _elRef: ElementRef, + protected _overflowLayoutService: OverflowLayoutService, + @Optional() protected _rtlService: RtlService ) { - this._subscription.add(this._fillTrigger$.pipe(debounceTime(30)).subscribe(() => this._fitVisibleItems())); + this._subscription.add( + this._fillTrigger$.pipe(debounceTime(30)).subscribe(() => this._overflowLayoutService.fitVisibleItems()) + ); } /** * Triggers layout recalculation of the items. */ triggerRecalculation(): void { + if (!this._canListenToResize) { + return; + } + this._overflowLayoutService.setConfig(this._getConfig()); this._fillTrigger$.next(); } @@ -182,26 +196,6 @@ export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, Overfl this._subscription.unsubscribe(); } - /** @hidden */ - @HostListener('keyup', ['$event']) - private _keyUpHandler(event: KeyboardEvent): void { - if (KeyUtil.isKeyCode(event, TAB)) { - const index = this._allItems.findIndex( - (item) => item.overflowItem?.focusable && item.elementRef.nativeElement === event.target - ); - if (index !== -1) { - this._keyboardEventsManager.setActiveItem(index); - } - } - - if (KeyUtil.isKeyCode(event, [DOWN_ARROW, UP_ARROW, LEFT_ARROW, RIGHT_ARROW])) { - event.preventDefault(); - - // passing the event to key manager so, we get a change fired - this._keyboardEventsManager.onKeydown(event); - } - } - /** * Sets current focused element. * @param element Element that needs to be focused. @@ -210,7 +204,7 @@ export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, Overfl const index = this._overflowItems.toArray().findIndex((item) => item === element); if (index !== -1) { - this._keyboardEventsManager.setActiveItem(index); + this._overflowLayoutService._keyboardEventsManager.setActiveItem(index); } } @@ -236,173 +230,72 @@ export class OverflowLayoutComponent implements AfterViewInit, OnDestroy, Overfl /** @hidden */ ngAfterViewInit(): void { - this._fitVisibleItems(); - this._setFocusKeyManager(); - this._listenToChanges(); - this._subscribeToRtl(); - } - - /** @hidden */ - private _listenToChanges(): void { this._subscription.add( - this._items.changes.subscribe(() => { - setTimeout(() => { - this._fitVisibleItems(); - }); + this._overflowLayoutService.detectChanges.subscribe(() => { + this._cdr.detectChanges(); }) ); - this._listenToSizeChanges(this._elRef.nativeElement, this._itemsWrapper.nativeElement); - } + this._subscription.add( + this._overflowLayoutService.onResult.subscribe((result) => { + this._hiddenItems = result.hiddenItems; + this._showMore = result.showMore; + this.hiddenItemsCount.emit(result.hiddenItems.length); + this.visibleItemsCount.emit(this._allItems.filter((i) => !i.hidden).length); + this._cdr.detectChanges(); + }) + ); - /** @hidden */ - private _listenToSizeChanges(...elements: HTMLElement[]): void { - elements.forEach((element) => - this._subscription.add( - resizeObservable(element) - .pipe( - skip(1), - filter(() => this._listenToItemResize), - distinctUntilChanged(), - debounceTime(30) - ) - .subscribe(() => { - setTimeout(() => { - this._fitVisibleItems(); - }); - }) - ) + this._subscription.add( + this._items.changes.subscribe(() => { + this._allItems = this._items.toArray(); + this._cdr.detectChanges(); + }) ); - } - /** @hidden */ - private _fitVisibleItems(): void { - this._listenToItemResize = false; this._allItems = this._items.toArray(); - this._visibleItems.forEach((i) => (i.containerRef.hidden = false)); - this._allItems.forEach((item, index) => { - item.hidden = false; - item.index = index; - }); - this._cdr.detectChanges(); - const containerWidth = this._elRef.nativeElement.getBoundingClientRect().width; - const itemsContainerWidth = this._itemsWrapper.nativeElement.getBoundingClientRect().width; - - if ( - containerWidth >= itemsContainerWidth && - this._visibleItems.length <= this.maxVisibleItems && - this._hiddenItems.length === 0 - ) { - this._showMore = false; - this._cdr.detectChanges(); - this._listenToItemResize = true; - return; - } - this._showMore = true; - let fittingElmCount = 0; - let fittingElmsWidth = 0; - let shouldHideItems = false; this._cdr.detectChanges(); - const showMoreContainerWidth = this._showMoreContainer.nativeElement.getBoundingClientRect().width; - let layoutWidth = this._layoutContainer.nativeElement.getBoundingClientRect().width; + this._overflowLayoutService.startListening(this._getConfig()); - // Try to find all forced visible items - const forcedItemsIndexes = this._getForcedItemsIndexes(); - - forcedItemsIndexes.forEach((itemIndex) => { - const container = this._visibleItems.get(itemIndex); - if (!container) { - return; - } - const elementSize = this._getElementWidth(container.elementRef.nativeElement); - - layoutWidth -= elementSize; - }); - - if (layoutWidth < 0 && forcedItemsIndexes.length > 0) { - console.warn( - 'There is no enough space to fit all forced visible items into the container. Please adjust their visibility accordingly.' - ); - } - - this._visibleItems.forEach((item, index) => { - const itemRef = this._allItems[index]; - if (shouldHideItems && !itemRef.overflowItem.forceVisibility) { - item.containerRef.hidden = true; - itemRef.hidden = true; - return; - } - - const elementSize = this._getElementWidth(item.elementRef.nativeElement); - const combinedWidth = fittingElmsWidth + elementSize; - - if ( - (combinedWidth <= layoutWidth || - (item === this._visibleItems.last && combinedWidth <= layoutWidth + showMoreContainerWidth)) && - fittingElmCount < this.maxVisibleItems - ) { - fittingElmsWidth += elementSize; - fittingElmCount++; - } else if (!itemRef.overflowItem.forceVisibility) { - shouldHideItems = true; - item.containerRef.hidden = true; - itemRef.hidden = true; - } - }); - - this._hiddenItems = this._allItems.filter((i) => i.hidden); - this.visibleItemsCount.emit(this._allItems.filter((i) => !i.hidden).length); - this.hiddenItemsCount.emit(this._hiddenItems.length); - - this._showMore = this._hiddenItems.length > 0; - - this._cdr.detectChanges(); - - this._listenToItemResize = true; + this._canListenToResize = true; } /** @hidden */ - private _setFocusKeyManager(): void { - this._dir = this._rtlService?.rtl.value ? 'rtl' : 'ltr'; - this._keyboardEventsManager = new FocusKeyManager(this._overflowItems) - .withWrap() - .withHorizontalOrientation(this._dir) - .skipPredicate((item) => !item.focusable || item.hidden); + private _getConfig(): OverflowLayoutConfig { + return { + visibleItems: this._visibleItems, + items: this._items, + itemsWrapper: this._itemsWrapper.nativeElement, + showMoreContainer: this._showMoreContainer.nativeElement, + layoutContainerElement: this._layoutContainer.nativeElement, + maxVisibleItems: this.maxVisibleItems, + direction: this.showMorePosition, + enableKeyboardNavigation: this.enableKeyboardNavigation, + reverseHiddenItems: this.reverseHiddenItems + }; } - /** @hidden Rtl change subscription */ - private _subscribeToRtl(): void { - if (!this._rtlService) { + /** @hidden */ + @HostListener('keyup', ['$event']) + private _keyUpHandler(event: KeyboardEvent): void { + if (!this.enableKeyboardNavigation) { return; } + if (KeyUtil.isKeyCode(event, TAB)) { + const index = this._allItems.findIndex( + (item) => item.overflowItem?.focusable && item.elementRef.nativeElement === event.target + ); + if (index !== -1) { + this._overflowLayoutService._keyboardEventsManager.setActiveItem(index); + } + } - const rtlSub = this._rtlService.rtl.subscribe((isRtl) => { - this._dir = isRtl ? 'rtl' : 'ltr'; - - this._keyboardEventsManager = this._keyboardEventsManager.withHorizontalOrientation(isRtl ? 'rtl' : 'ltr'); - }); - - this._subscription.add(rtlSub); - } - - /** @hidden */ - private _getForcedItemsIndexes(): number[] { - return this._allItems - .map((item, index) => (item.overflowItem.forceVisibility ? index : -1)) - .filter((i) => i > -1); - } - - /** - * @hidden - * Returns combined width of the element including margins. - * @param element Element to calculate width of. - */ - private _getElementWidth(element: HTMLElement): number { - const elementStyle = getComputedStyle(element); - const elementWidth = element.getBoundingClientRect().width; - const elementSize = elementWidth + parseFloat(elementStyle.marginLeft) + parseFloat(elementStyle.marginRight); + if (KeyUtil.isKeyCode(event, [DOWN_ARROW, UP_ARROW, LEFT_ARROW, RIGHT_ARROW])) { + event.preventDefault(); - return elementSize; + // passing the event to key manager so, we get a change fired + this._overflowLayoutService._keyboardEventsManager.onKeydown(event); + } } } diff --git a/libs/core/src/lib/overflow-layout/overflow-layout.service.ts b/libs/core/src/lib/overflow-layout/overflow-layout.service.ts new file mode 100644 index 00000000000..18f057eb6c8 --- /dev/null +++ b/libs/core/src/lib/overflow-layout/overflow-layout.service.ts @@ -0,0 +1,319 @@ +import { FocusKeyManager } from '@angular/cdk/a11y'; +import { ElementRef, Injectable, OnDestroy, Optional, QueryList } from '@angular/core'; +import { resizeObservable, RtlService } from '@fundamental-ngx/core/utils'; +import { debounceTime, distinctUntilChanged, filter, Observable, skip, Subject, Subscription } from 'rxjs'; +import { OverflowLayoutItemContainerDirective } from './directives/overflow-layout-item-container.directive'; +import { OverflowItemRef } from './interfaces/overflow-item-ref.interface'; +import { OverflowItem } from './interfaces/overflow-item.interface'; + +export interface OverflowLayoutConfig { + items: QueryList; + visibleItems: QueryList; + itemsWrapper: HTMLElement; + showMoreContainer: HTMLElement; + layoutContainerElement: HTMLElement; + maxVisibleItems: number; + direction: 'left' | 'right'; + enableKeyboardNavigation: boolean; + reverseHiddenItems: boolean; +} + +export class OverflowLayoutListeningResult { + showMore = false; + items: OverflowItemRef[] = []; + hiddenItems: OverflowItemRef[] = []; + visibleItems: OverflowItemRef[] = []; +} + +@Injectable() +export class OverflowLayoutService implements OnDestroy { + /** + * Overflow Layout config. + */ + config: OverflowLayoutConfig; + + /** + * Overflow Layout calculation result. + */ + result = new OverflowLayoutListeningResult(); + + /** @hidden */ + _keyboardEventsManager: FocusKeyManager; + + /** @hidden */ + private _listenToItemResize = true; + + /** @hidden */ + private readonly _subscription = new Subscription(); + + /** @hidden */ + private _allItems: OverflowItemRef[] = []; + + /** @hidden */ + private _hiddenItems: OverflowItemRef[] = []; + + /** @hidden */ + private _overflowItems: OverflowItem[] = []; + + /** @hidden */ + private _detectChanges$ = new Subject(); + + /** @hidden */ + private _result$ = new Subject(); + + /** @hidden */ + private _dir: 'rtl' | 'ltr' = 'ltr'; + + /** + * Observable which emits when changes detection is required. + */ + get detectChanges(): Observable { + return this._detectChanges$.asObservable(); + } + + /** + * Observable which emits when new calculation result is available. + */ + get onResult(): Observable { + return this._result$.asObservable(); + } + + /** @hidden */ + constructor(private _elRef: ElementRef, @Optional() private _rtlService: RtlService | null) {} + + /** @hidden */ + ngOnDestroy(): void { + this._subscription.unsubscribe(); + } + + startListening(config: OverflowLayoutConfig): void { + this.setConfig(config); + this.fitVisibleItems(); + this._setFocusKeyManager(); + this._listenToChanges(); + this._subscribeToRtl(); + } + + setConfig(config: OverflowLayoutConfig): void { + this.config = config; + } + + private _emitResult(): void { + this._result$.next(this.result); + } + + /** @hidden */ + private _listenToChanges(): void { + this._subscription.add( + this.config.items.changes.subscribe(() => { + setTimeout(() => { + this.fitVisibleItems(); + }); + }) + ); + + this._listenToSizeChanges(this._elRef.nativeElement, this.config.itemsWrapper); + } + + /** @hidden */ + private _listenToSizeChanges(...elements: HTMLElement[]): void { + elements.forEach((element) => + this._subscription.add( + resizeObservable(element) + .pipe( + skip(1), + filter(() => this._listenToItemResize), + distinctUntilChanged(), + debounceTime(30) + ) + .subscribe(() => { + setTimeout(() => { + this.fitVisibleItems(); + }); + }) + ) + ); + } + + /** @hidden */ + fitVisibleItems(): void { + this._listenToItemResize = false; + this._allItems = this.config.items.toArray(); + + let allItems = this.config.items.toArray(); + + allItems.forEach((item, index) => { + // Softly hide previously completely hidden item in order to correctly calculate it's size. + item.softHidden = true; + item.hidden = false; + item.index = index; + item.first = index === 0; + item.last = index === allItems.length - 1; + }); + + this._detectChanges$.next(); + + allItems = this.config.direction === 'right' ? allItems : allItems.reverse(); + const visibleContainerItems = + this.config.direction === 'right' + ? this.config.visibleItems.toArray() + : this.config.visibleItems.toArray().reverse(); + visibleContainerItems.forEach((i) => (i.containerRef.hidden = false)); + + this.result.showMore = false; + this._emitResult(); + const containerWidth = this._elRef.nativeElement.getBoundingClientRect().width; + const itemsContainerWidth = this.config.itemsWrapper.getBoundingClientRect().width; + + if ( + containerWidth >= itemsContainerWidth && + this.config.visibleItems.length <= this.config.maxVisibleItems && + this._hiddenItems.length === 0 + ) { + // Make all items fully visible. + allItems.forEach((item) => { + item.softHidden = false; + }); + this.result.showMore = false; + this.result.hiddenItems = this._hiddenItems; + this._emitResult(); + this._listenToItemResize = true; + return; + } + this.result.showMore = true; + this._emitResult(); + let fittingElmCount = 0; + let fittingElmsWidth = 0; + let shouldHideItems = false; + + const showMoreContainerWidth = Math.ceil(this.config.showMoreContainer.getBoundingClientRect().width); + let layoutWidth = Math.ceil(this.config.layoutContainerElement.getBoundingClientRect().width); + + // Try to find all forced visible items + const forcedItemsIndexes = this._getForcedItemsIndexes(); + + forcedItemsIndexes.forEach((itemIndex) => { + const container = this.config.visibleItems.get(itemIndex); + if (!container) { + return; + } + const elementSize = this._getElementWidth(container.elementRef.nativeElement); + + layoutWidth -= elementSize; + }); + + if (layoutWidth < 0 && forcedItemsIndexes.length > 0) { + console.warn( + 'There is no enough space to fit all forced visible items into the container. Please adjust their visibility accordingly.' + ); + } + + this._detectChanges$.next(); + + visibleContainerItems.forEach((item, index) => { + const itemRef = allItems[index]; + if (shouldHideItems && !itemRef.overflowItem.forceVisibility) { + item.containerRef.hidden = true; + item.softHidden = false; + itemRef.hidden = true; + return; + } + + const elementSize = this._getElementWidth(item.elementRef.nativeElement); + const combinedWidth = fittingElmsWidth + elementSize; + + const condition = + (combinedWidth <= layoutWidth || + (item === this.config.visibleItems.last && + combinedWidth <= layoutWidth + showMoreContainerWidth)) && + fittingElmCount < this.config.maxVisibleItems; + + if (condition) { + fittingElmsWidth += elementSize; + fittingElmCount++; + } else if (!itemRef.overflowItem.forceVisibility) { + shouldHideItems = true; + item.softHidden = false; + item.containerRef.hidden = true; + itemRef.hidden = true; + } + }); + + // Reverse original order back. + allItems = this.config.direction === 'right' ? allItems : allItems.reverse(); + + allItems.forEach((item) => { + item.softHidden = false; + }); + + let hiddenItems = allItems.filter((i) => i.hidden); + hiddenItems = !this.config.reverseHiddenItems ? hiddenItems.reverse() : hiddenItems; + const visibleItems = allItems.filter((i) => !i.hidden); + + visibleItems.forEach((item, index) => { + item.index = index; + item.first = index === 0; + item.last = index === visibleItems.length - 1; + }); + + this._hiddenItems = hiddenItems.map((item, index) => { + item.first = index === 0; + item.last = index === hiddenItems.length - 1; + item.index = index; + return item; + }); + + this.result.showMore = this._hiddenItems.length > 0; + this.result.hiddenItems = this._hiddenItems; + this._emitResult(); + + this._listenToItemResize = true; + } + + /** @hidden */ + private _setFocusKeyManager(): void { + if (!this.config.enableKeyboardNavigation) { + return; + } + this._dir = this._rtlService?.rtl.value ? 'rtl' : 'ltr'; + this._keyboardEventsManager = new FocusKeyManager(this._overflowItems) + .withWrap() + .withHorizontalOrientation(this._dir) + .skipPredicate((item) => !item.focusable || item.hidden); + } + + /** @hidden Rtl change subscription */ + private _subscribeToRtl(): void { + if (!this._rtlService || !this.config.enableKeyboardNavigation) { + return; + } + + this._subscription.add( + this._rtlService.rtl.subscribe((isRtl) => { + this._dir = isRtl ? 'rtl' : 'ltr'; + + this._keyboardEventsManager = this._keyboardEventsManager.withHorizontalOrientation(this._dir); + }) + ); + } + + /** @hidden */ + private _getForcedItemsIndexes(): number[] { + return this._allItems + .map((item, index) => (item.overflowItem.forceVisibility ? index : -1)) + .filter((i) => i > -1); + } + + /** + * @hidden + * Returns combined width of the element including margins. + * @param element Element to calculate width of. + */ + private _getElementWidth(element: HTMLElement): number { + const elementStyle = getComputedStyle(element); + const elementWidth = element.getBoundingClientRect().width; + const elementSize = elementWidth + parseFloat(elementStyle.marginLeft) + parseFloat(elementStyle.marginRight); + + return Math.ceil(elementSize); + } +} diff --git a/libs/core/src/lib/overflow-layout/test.ts b/libs/core/src/lib/overflow-layout/test.ts index f7496773ee9..54954bff26d 100644 --- a/libs/core/src/lib/overflow-layout/test.ts +++ b/libs/core/src/lib/overflow-layout/test.ts @@ -2,7 +2,7 @@ import 'core-js/es/reflect'; import 'zone.js'; - +import '@angular/localize/init'; import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; diff --git a/libs/core/src/lib/time/time-column/time-column.component.ts b/libs/core/src/lib/time/time-column/time-column.component.ts index ba9e2f7576e..18231c81568 100644 --- a/libs/core/src/lib/time/time-column/time-column.component.ts +++ b/libs/core/src/lib/time/time-column/time-column.component.ts @@ -189,21 +189,18 @@ export class TimeColumnComponent = Selectable /** @hidden */ private _viewInit$ = new BehaviorSubject(false); + private _resize$ = new BehaviorSubject(false); + /** @hidden */ private _subscriptions: Subscription = new Subscription(); /** @hidden */ constructor(private _changeDetRef: ChangeDetectorRef, private _elmRef: ElementRef) { this._subscriptions.add( - combineLatest([ - this._viewInit$, - this._elementsAtOnce$, - this._offset$, - resizeObservable(this._elmRef.nativeElement) - ]) + combineLatest([this._viewInit$, this._elementsAtOnce$, this._offset$, this._resize$]) .pipe( filter(([viewInit]) => viewInit), - tap(([, elementsAtOnce, offset, size]) => { + tap(([, elementsAtOnce, offset]) => { const averageHeight = this.items.toArray().reduce((acc, next) => acc + next.getHeight(), 0) / this.items.length; this.wrapperHeight = averageHeight * elementsAtOnce; @@ -236,6 +233,11 @@ export class TimeColumnComponent = Selectable /** @hidden */ ngAfterViewInit(): void { + this._subscriptions.add( + resizeObservable(this._elmRef.nativeElement).subscribe(() => { + this._resize$.next(true); + }) + ); this._viewInit$.next(true); } diff --git a/libs/core/src/lib/utils/functions/resize-observable.ts b/libs/core/src/lib/utils/functions/resize-observable.ts index 4a25adc4731..58bb0453aea 100644 --- a/libs/core/src/lib/utils/functions/resize-observable.ts +++ b/libs/core/src/lib/utils/functions/resize-observable.ts @@ -10,13 +10,20 @@ import { map } from 'rxjs/operators'; export function resizeObservable(target: Element, options?: ResizeObserverOptions): Observable { if ('ResizeObserver' in window) { return new Observable((subscriber) => { + let animationFrame: number; const ro = new ResizeObserver((entries) => { - subscriber.next(entries); + animationFrame = window.requestAnimationFrame(() => { + subscriber.next(entries); + }); }); ro.observe(target, options); return function unsubscribe(): void { + if (animationFrame) { + window.cancelAnimationFrame(animationFrame); + } + ro.unobserve(target); ro.disconnect(); }; }); diff --git a/libs/fn/src/lib/select/select.component.ts b/libs/fn/src/lib/select/select.component.ts index 9310cd79fbe..a75e9e47ca1 100644 --- a/libs/fn/src/lib/select/select.component.ts +++ b/libs/fn/src/lib/select/select.component.ts @@ -108,9 +108,11 @@ export class SelectComponent implements AfterContentInit, OnDestroy, ControlValu ngAfterContentInit(): void { this._selectWidth = this._elRef.nativeElement.getBoundingClientRect().width; - resizeObservable(this._elRef.nativeElement).subscribe(() => { - this._selectWidth = this._elRef.nativeElement.getBoundingClientRect().width; - }); + this._subscriptions.add( + resizeObservable(this._elRef.nativeElement).subscribe(() => { + this._selectWidth = this._elRef.nativeElement.getBoundingClientRect().width; + }) + ); if (!this._internalValue) { return;