From 85a2f6b50f3e1804f3b3d01bfe000fd3fcc8feb9 Mon Sep 17 00:00:00 2001 From: Denis Severin Date: Thu, 23 Mar 2023 11:34:24 +0200 Subject: [PATCH 1/4] fix(core,platform): multi input and combobox tokenizer behaviour --- .../core/src/lib/list/list-component.token.ts | 6 - .../lib/list/list-item/list-item.component.ts | 3 +- .../list-navigation-item.component.ts | 2 +- libs/core/src/lib/list/list.component.ts | 2 +- libs/core/src/lib/list/tokens.ts | 2 +- .../base-multi-combobox.class.ts | 5 +- .../multi-combobox.component.html | 7 +- .../multi-combobox.component.ts | 9 +- .../multi-input/multi-input.component.html | 3 + .../multi-input/multi-input.component.scss | 1 + .../lib/multi-input/multi-input.component.ts | 2 +- libs/core/src/lib/token/token.component.ts | 32 ++- .../src/lib/token/tokenizer.component.html | 13 +- .../core/src/lib/token/tokenizer.component.ts | 247 +++++++++++++----- ...esponsive-tokenizer-example.component.html | 12 + ...-responsive-tokenizer-example.component.ts | 119 +++++++++ libs/docs/core/multi-combobox/index.ts | 2 + .../multi-combobox-docs.component.html | 24 ++ .../multi-combobox-docs.component.ts | 16 ++ .../multi-combobox-docs.module.ts | 6 +- .../multi-combobox.component.html | 7 +- .../multi-input/multi-input.component.html | 7 +- 22 files changed, 425 insertions(+), 102 deletions(-) delete mode 100644 libs/core/src/lib/list/list-component.token.ts create mode 100644 libs/docs/core/multi-combobox/examples/tokenizer/multi-combobox-responsive-tokenizer-example.component.html create mode 100644 libs/docs/core/multi-combobox/examples/tokenizer/multi-combobox-responsive-tokenizer-example.component.ts diff --git a/libs/core/src/lib/list/list-component.token.ts b/libs/core/src/lib/list/list-component.token.ts deleted file mode 100644 index 3f61723e079..00000000000 --- a/libs/core/src/lib/list/list-component.token.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { InjectionToken } from '@angular/core'; -import { ListComponentInterface } from './list-component.interface'; - -export const FD_LIST_COMPONENT = new InjectionToken('ListComponent'); - -export const FD_LIST_UNREAD_INDICATOR = new InjectionToken('ListUnreadIndicator'); diff --git a/libs/core/src/lib/list/list-item/list-item.component.ts b/libs/core/src/lib/list/list-item/list-item.component.ts index d946856ed4f..74dbc1c31ae 100644 --- a/libs/core/src/lib/list/list-item/list-item.component.ts +++ b/libs/core/src/lib/list/list-item/list-item.component.ts @@ -30,12 +30,11 @@ import { startWith, takeUntil } from 'rxjs/operators'; import { KeyUtil } from '@fundamental-ngx/cdk/utils'; import { LIST_ITEM_COMPONENT, ListItemInterface } from '@fundamental-ngx/cdk/utils'; import { ENTER, SPACE } from '@angular/cdk/keycodes'; -import { FD_LIST_UNREAD_INDICATOR } from '../list-component.token'; import { ListFocusItem } from '../list-focus-item.model'; import { ButtonComponent, FD_BUTTON_COMPONENT } from '@fundamental-ngx/core/button'; import { Nullable } from '@fundamental-ngx/cdk/utils'; import { ListUnreadIndicator } from '../list-unread-indicator.interface'; -import { FD_LIST_LINK_DIRECTIVE } from '../tokens'; +import { FD_LIST_LINK_DIRECTIVE, FD_LIST_UNREAD_INDICATOR } from '../tokens'; let listItemUniqueId = 0; diff --git a/libs/core/src/lib/list/list-navigation-item/list-navigation-item.component.ts b/libs/core/src/lib/list/list-navigation-item/list-navigation-item.component.ts index 5479ff117a6..03801fb9a2b 100644 --- a/libs/core/src/lib/list/list-navigation-item/list-navigation-item.component.ts +++ b/libs/core/src/lib/list/list-navigation-item/list-navigation-item.component.ts @@ -16,7 +16,7 @@ import { FocusableOption } from '@angular/cdk/a11y'; import { Subject } from 'rxjs'; import { ListNavigationItemArrowDirective } from '../directives/list-navigation-item-arrow.directive'; import { ListNavigationItemTextDirective } from '../directives/list-navigation-item-text.directive'; -import { FD_LIST_COMPONENT } from '../list-component.token'; +import { FD_LIST_COMPONENT } from '../tokens'; import { ListComponentInterface } from '../list-component.interface'; @Component({ diff --git a/libs/core/src/lib/list/list.component.ts b/libs/core/src/lib/list/list.component.ts index 52d03d24040..c15365ef487 100644 --- a/libs/core/src/lib/list/list.component.ts +++ b/libs/core/src/lib/list/list.component.ts @@ -32,7 +32,7 @@ import { ContentDensityMode } from '@fundamental-ngx/core/content-density'; import { ListComponentInterface } from './list-component.interface'; -import { FD_LIST_COMPONENT, FD_LIST_UNREAD_INDICATOR } from './list-component.token'; +import { FD_LIST_COMPONENT, FD_LIST_UNREAD_INDICATOR } from './tokens'; import { ListUnreadIndicator } from './list-unread-indicator.interface'; /** diff --git a/libs/core/src/lib/list/tokens.ts b/libs/core/src/lib/list/tokens.ts index b892701fa89..3fc58b7c898 100644 --- a/libs/core/src/lib/list/tokens.ts +++ b/libs/core/src/lib/list/tokens.ts @@ -5,4 +5,4 @@ export const FD_LIST_MESSAGE_DIRECTIVE = new InjectionToken('FdListMessageDirect export const FD_LIST_LINK_DIRECTIVE = new InjectionToken('FdListLinkDirective'); export const FD_LIST_COMPONENT = new InjectionToken('ListComponent'); -export const FD_LIST_UNREAD_INDICATOR = new InjectionToken('ListUnreadIndicator'); +export const FD_LIST_UNREAD_INDICATOR = new InjectionToken('ListUnreadIndicator'); diff --git a/libs/core/src/lib/multi-combobox/base-multi-combobox.class.ts b/libs/core/src/lib/multi-combobox/base-multi-combobox.class.ts index d5b23cff859..7a410fcd0a7 100644 --- a/libs/core/src/lib/multi-combobox/base-multi-combobox.class.ts +++ b/libs/core/src/lib/multi-combobox/base-multi-combobox.class.ts @@ -30,7 +30,6 @@ import { RangeSelector } from '@fundamental-ngx/cdk/utils'; import { ContentDensityObserver } from '@fundamental-ngx/core/content-density'; -import { FormItemControl } from '@fundamental-ngx/core/form'; import equal from 'fast-deep-equal'; import { BehaviorSubject, skip, startWith, Subscription, takeUntil, timer } from 'rxjs'; import { @@ -112,7 +111,7 @@ export abstract class BaseMultiCombobox { abstract isGroup: boolean; abstract inputText: string; - abstract searchInputElement: Nullable; + abstract searchInputElement: Nullable>; abstract selectionChange: EventEmitter; abstract dataReceived: EventEmitter; @@ -408,7 +407,7 @@ export abstract class BaseMultiCombobox { /** @hidden */ protected _focusToSearchField(): void { - this.searchInputElement?.elmRef?.nativeElement.focus(); + this.searchInputElement?.nativeElement.focus(); } /** @hidden */ diff --git a/libs/core/src/lib/multi-combobox/multi-combobox.component.html b/libs/core/src/lib/multi-combobox/multi-combobox.component.html index 1e1da9ac2f5..131f99c512c 100644 --- a/libs/core/src/lib/multi-combobox/multi-combobox.component.html +++ b/libs/core/src/lib/multi-combobox/multi-combobox.component.html @@ -45,6 +45,8 @@ diff --git a/libs/core/src/lib/multi-combobox/multi-combobox.component.ts b/libs/core/src/lib/multi-combobox/multi-combobox.component.ts index 2e4adc55de8..8bd6a2b5a16 100644 --- a/libs/core/src/lib/multi-combobox/multi-combobox.component.ts +++ b/libs/core/src/lib/multi-combobox/multi-combobox.component.ts @@ -39,7 +39,6 @@ import equal from 'fast-deep-equal'; import { Subject } from 'rxjs'; import { debounceTime, takeUntil } from 'rxjs/operators'; import { contentDensityObserverProviders } from '@fundamental-ngx/core/content-density'; -import { FormItemControl } from '@fundamental-ngx/core/form'; import { TokenizerComponent } from '@fundamental-ngx/core/token'; import { SelectableOptionItem, OptionItem } from '@fundamental-ngx/cdk/forms'; @@ -245,8 +244,8 @@ export class MultiComboboxComponent extends BaseMultiCombobox implem private readonly listComponent: ListComponentInterface; /** @hidden */ - @ViewChild('searchInputElement') - readonly searchInputElement: Nullable; + @ViewChild('searchInputElement', { read: ElementRef }) + readonly searchInputElement: Nullable>; /** @hidden */ @ContentChildren(TemplateDirective) @@ -613,6 +612,8 @@ export class MultiComboboxComponent extends BaseMultiCombobox implem if (this.isOpen && this.listComponent) { this.listComponent.setItemActive(0); } + + this.searchInputElement?.nativeElement.focus(); } /** @@ -661,7 +662,7 @@ export class MultiComboboxComponent extends BaseMultiCombobox implem */ _handleListFocusEscape(direction: FocusEscapeDirection): void { if (direction === 'up') { - this.searchInputElement?.elmRef?.nativeElement.focus(); + this._focusToSearchField(); } } diff --git a/libs/core/src/lib/multi-input/multi-input.component.html b/libs/core/src/lib/multi-input/multi-input.component.html index 10f392806ce..a5f816749f8 100644 --- a/libs/core/src/lib/multi-input/multi-input.component.html +++ b/libs/core/src/lib/multi-input/multi-input.component.html @@ -47,6 +47,7 @@ (addOnButtonClicked)="_addOnButtonClicked($event)" > diff --git a/libs/core/src/lib/multi-input/multi-input.component.scss b/libs/core/src/lib/multi-input/multi-input.component.scss index 92233404c9d..a1dcf730bd7 100644 --- a/libs/core/src/lib/multi-input/multi-input.component.scss +++ b/libs/core/src/lib/multi-input/multi-input.component.scss @@ -36,6 +36,7 @@ .fd-input { &.fd-multi-input-tokenizer-input { + min-width: 4rem; margin-top: 0; margin-bottom: 0; padding-left: 0; diff --git a/libs/core/src/lib/multi-input/multi-input.component.ts b/libs/core/src/lib/multi-input/multi-input.component.ts index 787acfb4949..5ebdcc6c6a7 100644 --- a/libs/core/src/lib/multi-input/multi-input.component.ts +++ b/libs/core/src/lib/multi-input/multi-input.component.ts @@ -100,7 +100,7 @@ export class MultiInputComponent /** Whether to use cozy visuals but compact collapsing behavior. */ @Input() - compactCollapse = false; + compactCollapse = true; /** Max height of the popover. Any overflowing elements will be accessible through scrolling. */ @Input() diff --git a/libs/core/src/lib/token/token.component.ts b/libs/core/src/lib/token/token.component.ts index 27d364fc21a..8a07b739fc3 100644 --- a/libs/core/src/lib/token/token.component.ts +++ b/libs/core/src/lib/token/token.component.ts @@ -5,6 +5,7 @@ import { Component, ElementRef, EventEmitter, + inject, Input, OnDestroy, Output, @@ -13,10 +14,11 @@ import { ViewContainerRef, ViewEncapsulation } from '@angular/core'; -import { Subscription } from 'rxjs'; -import { KeyUtil } from '@fundamental-ngx/cdk/utils'; +import { fromEvent, Subscription } from 'rxjs'; +import { DestroyedService, KeyUtil } from '@fundamental-ngx/cdk/utils'; import { ENTER, SPACE } from '@angular/cdk/keycodes'; import { ContentDensityObserver, contentDensityObserverProviders } from '@fundamental-ngx/core/content-density'; +import { takeUntil } from 'rxjs/operators'; /** * A token is used to represent contextualizing information. @@ -28,7 +30,10 @@ import { ContentDensityObserver, contentDensityObserverProviders } from '@fundam styleUrls: ['./token.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - providers: [contentDensityObserverProviders()] + providers: [contentDensityObserverProviders(), DestroyedService], + host: { + '[style.max-width.%]': '100' + } }) export class TokenComponent implements AfterViewInit, OnDestroy { /** Whether the token is disabled. */ @@ -103,9 +108,18 @@ export class TokenComponent implements AfterViewInit, OnDestroy { // eslint-disable-next-line @angular-eslint/no-output-on-prefix onTokenKeydown = new EventEmitter(); + /** + * Emitted when token element received or lost focus. + */ + @Output() + elementFocused = new EventEmitter(); + /** @hidden */ totalCount: number; + /** @hidden */ + private readonly _destroy$ = inject(DestroyedService); + /** @hidden */ constructor( public elementRef: ElementRef, @@ -116,6 +130,18 @@ export class TokenComponent implements AfterViewInit, OnDestroy { /** @hidden */ ngAfterViewInit(): void { this._viewContainer.createEmbeddedView(this._content); + + fromEvent(this.tokenWrapperElement.nativeElement, 'focus') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + this.elementFocused.emit(true); + }); + + fromEvent(this.tokenWrapperElement.nativeElement, 'blur') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + this.elementFocused.emit(false); + }); } /** @hidden */ diff --git a/libs/core/src/lib/token/tokenizer.component.html b/libs/core/src/lib/token/tokenizer.component.html index 105366511bd..3102b96ca23 100644 --- a/libs/core/src/lib/token/tokenizer.component.html +++ b/libs/core/src/lib/token/tokenizer.component.html @@ -4,7 +4,7 @@ [class.fd-tokenizer--compact]="compact" >
-
+
@@ -52,20 +52,17 @@ - {{ 'coreTokenizer.moreLabel' | fdTranslate: { count: moreTokensLeft.length + moreTokensRight.length } }} + {{ 'coreTokenizer.moreLabel' | fdTranslate : { count: moreTokensLeft.length + moreTokensRight.length } }} - {{ 'coreTokenizer.moreLabel' | fdTranslate: { count: hiddenCozyTokenCount } }} + {{ 'coreTokenizer.moreLabel' | fdTranslate : { count: hiddenCozyTokenCount } }} diff --git a/libs/core/src/lib/token/tokenizer.component.ts b/libs/core/src/lib/token/tokenizer.component.ts index 38671decaa5..66767092687 100644 --- a/libs/core/src/lib/token/tokenizer.component.ts +++ b/libs/core/src/lib/token/tokenizer.component.ts @@ -1,3 +1,4 @@ +import { DOCUMENT } from '@angular/common'; import { AfterContentInit, AfterViewInit, @@ -10,6 +11,7 @@ import { EventEmitter, forwardRef, HostListener, + Inject, Input, OnChanges, OnDestroy, @@ -24,10 +26,17 @@ import { ViewEncapsulation } from '@angular/core'; import { A, BACKSPACE, DELETE, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE } from '@angular/cdk/keycodes'; -import { fromEvent, merge, Subject, Subscription } from 'rxjs'; -import { filter, mapTo, takeUntil, debounceTime } from 'rxjs/operators'; +import { BehaviorSubject, fromEvent, merge, startWith, Subject, Subscription } from 'rxjs'; +import { filter, takeUntil, debounceTime, map } from 'rxjs/operators'; import { FormControlComponent } from '@fundamental-ngx/core/form'; -import { applyCssClass, CssClassBuilder, KeyUtil, resizeObservable, RtlService } from '@fundamental-ngx/cdk/utils'; +import { + applyCssClass, + CssClassBuilder, + KeyUtil, + Nullable, + resizeObservable, + RtlService +} from '@fundamental-ngx/cdk/utils'; import { TokenComponent } from './token.component'; import { ContentDensityObserver, contentDensityObserverProviders } from '@fundamental-ngx/core/content-density'; @@ -56,14 +65,14 @@ export class TokenizerComponent /** @hidden */ @ContentChild(forwardRef(() => FormControlComponent), { read: ElementRef }) - input: ElementRef; + input: ElementRef; /** @hidden */ @ViewChild('tokenizerInner') tokenizerInnerEl: ElementRef; /** @hidden */ - @ViewChild('moreElement') + @ViewChild('moreElementSpan') moreElement: ElementRef; /** @hidden */ @@ -145,6 +154,12 @@ export class TokenizerComponent /** @hidden */ _showOverflowPopover = true; + /** @hidden */ + _showMoreElement = false; + + /** @hidden */ + _tokensContainerWidth = 'auto'; + /** @hidden * Variable which will keep the index of the first token pressed in the tokenizer */ @@ -171,13 +186,26 @@ export class TokenizerComponent /** @hidden */ private readonly _eventListeners: (() => void)[] = []; + /** @hidden */ + private _forceAllTokensToDisplay = false; + + /** @hidden */ + private _tokenElementFocused = new BehaviorSubject(false); + + /** @hidden */ + private _inputElementFocused = false; + + /** @hidden */ + private _tokenElementFocusedSub: Nullable; + /** @hidden */ constructor( readonly _contentDensityObserver: ContentDensityObserver, private _elementRef: ElementRef, private _cdRef: ChangeDetectorRef, @Optional() private _rtlService: RtlService, - private _renderer: Renderer2 + private _renderer: Renderer2, + @Inject(DOCUMENT) private readonly _document: Document ) { this._eventListeners.push( this._renderer.listen('window', 'click', (e: Event) => { @@ -195,20 +223,26 @@ export class TokenizerComponent if (this.input?.nativeElement) { this._inputKeydownEvent(); } + // watch for changes to the tokenList and attempt to expand/collapse tokens as needed - if (this.tokenListChangesSubscription) { - this.tokenListChangesSubscription.unsubscribe(); - } - this.tokenListChangesSubscription = this.tokenList.changes.subscribe(() => { + this.tokenList.changes.pipe(startWith(null)).subscribe(() => { + this.tokenListChangesSubscription?.unsubscribe(); + this.tokenListChangesSubscription = new Subscription(); this._resetTokens(); + this.tokenList.forEach((token) => { + this.tokenListChangesSubscription.add( + token.onCloseClick.subscribe(() => { + this._resetTokens(); + }) + ); + this.tokenListChangesSubscription.add( + token.elementFocused.subscribe((isFocused) => { + this._tokenElementFocused.next(isFocused); + }) + ); + }); }); - this.tokenList.forEach((token) => { - this.tokenListChangesSubscription.add( - token.onCloseClick.subscribe(() => { - this._resetTokens(); - }) - ); - }); + if (!this.compact && !this.compactCollapse) { this._handleCozyTokenCount(); } @@ -381,11 +415,18 @@ export class TokenizerComponent /** @hidden */ getCombinedTokenWidth(): number { - let totalTokenWidth = 0; + let totalTokenWidth = this._getTokensAreaWidthWithoutTokens(); // get the width of each token this.tokenList.forEach((token) => { totalTokenWidth = totalTokenWidth + token.elementRef.nativeElement.getBoundingClientRect().width; }); + + return totalTokenWidth; + } + + /** @hidden */ + private _getTokensAreaWidthWithoutTokens(): number { + let totalTokenWidth = 0; // add input width if (this.input?.nativeElement) { totalTokenWidth = totalTokenWidth + this.input.nativeElement.getBoundingClientRect().width; @@ -428,6 +469,51 @@ export class TokenizerComponent } } + /** @hidden */ + _showAllTokens(): void { + this._forceAllTokensToDisplay = true; + this._inputElementFocused = true; + this.tokenList.forEach((token) => { + this._makeElementVisible(token.elementRef); + token._viewContainer.createEmbeddedView(token._content); + }); + this._tokensContainerWidth = 'auto'; + this._showMoreElement = false; + this._cdRef.detectChanges(); + this.tokenizerInnerEl.nativeElement.scrollLeft = this.tokenizerInnerEl.nativeElement.scrollWidth; + } + + /** @hidden */ + _hideTokens(): void { + setTimeout(() => { + this._inputElementFocused = false; + const tokenFocused = this._getFocusedTokenIndex() > -1; + + if (tokenFocused) { + this._waitForFocusToDisappear(); + return; + } + this._forceAllTokensToDisplay = false; + this._resetTokens(); + this._cdRef.detectChanges(); + this.tokenizerInnerEl.nativeElement.scrollLeft = this.tokenizerInnerEl.nativeElement.scrollWidth; + }); + } + + /** @hidden */ + private _waitForFocusToDisappear(): void { + this._tokenElementFocusedSub?.unsubscribe(); + // 5 ms delay for other token to receive focus, check if _showAllTokens was called again + this._tokenElementFocusedSub = this._tokenElementFocused + .pipe( + debounceTime(5), + filter((isFocused) => isFocused && !this._inputElementFocused) + ) + .subscribe(() => { + this._hideTokens(); + }); + } + /** @hidden */ private _handleArrowRight(fromIndex: number): void { if (fromIndex === this.tokenList.length - this.moreTokensRight.length - 1 && this.moreTokensRight.length) { @@ -444,55 +530,72 @@ export class TokenizerComponent /** @hidden */ private _collapseTokens(side?: string): void { - if (this.compact || this.compactCollapse) { - this._cdRef.detectChanges(); - this._viewContainer.forEach((viewContainer) => viewContainer.clear()); - this.tokenList.forEach((token) => token._viewContainer.createEmbeddedView(token._content)); - - let elementWidth = this._elementRef.nativeElement.getBoundingClientRect().width; - let combinedTokenWidth = this.getCombinedTokenWidth(); // the combined width of all tokens, the "____ more" text, and the input - let i = 0; - /* - When resizing, we want to collapse the tokens on the left first. However when the user is navigating through - a group of overflowing tokens using the arrow left key, we may need to hide tokens on the right. So if this - function has been called with the param 'right' it will collapse tokens from the right side of the list rather - than the (default) left side. - */ - if (side === 'right') { - i = this.tokenList.length - 1; - } - while (combinedTokenWidth > elementWidth && (side === 'right' ? i >= 0 : i < this.tokenList.length)) { - // loop through the tokens and hide them until the combinedTokenWidth fits in the elementWidth - const token = this.tokenList.find((item, index) => index === i); - const moreTokens = side === 'right' ? this.moreTokensRight : this.moreTokensLeft; - - if (token) { - if (moreTokens.indexOf(token) === -1) { - moreTokens.push(token); - } - token.elementRef.nativeElement.style.display = 'none'; - } - // get the new elementWidth and combinedTokenWidth as these will have changed after setting a token display to 'none' - elementWidth = this._elementRef.nativeElement.getBoundingClientRect().width; - combinedTokenWidth = this.getCombinedTokenWidth(); - side === 'right' ? i-- : i++; - } + if (this._forceAllTokensToDisplay) { + return; + } + if (!this.compact && !this.compactCollapse) { + this._getHiddenCozyTokenCount(); + return; + } + this._cdRef.detectChanges(); + this._viewContainer.forEach((viewContainer) => viewContainer.clear()); - this._cdRef.detectChanges(); - this._hiddenTokens.forEach((hiddenToken, index) => { - hiddenToken._viewContainer.clear(); - this._viewContainer.get(index)?.createEmbeddedView(hiddenToken._content); - }); + const availableWidth = this._getTokensAreaWidthWithoutTokens(); + + if (this.tokenList.length === 1) { + const tokenWidth = this.tokenList.get(0)?.elementRef.nativeElement.getBoundingClientRect().width; + this._tokensContainerWidth = availableWidth < tokenWidth ? `calc(100% - ${availableWidth}px)` : 'auto'; + return; } else { - this._getHiddenCozyTokenCount(); + this._tokensContainerWidth = 'auto'; + } + + this._checkMoreElementVisibility(); + + this.tokenList.forEach((token) => token._viewContainer.createEmbeddedView(token._content)); + + let elementWidth = this._elementRef.nativeElement.getBoundingClientRect().width; + let combinedTokenWidth = this.getCombinedTokenWidth(); // the combined width of all tokens, the "____ more" text, and the input + let i = 0; + /* + When resizing, we want to collapse the tokens on the left first. However, when the user is navigating through + a group of overflowing tokens using the arrow left key, we may need to hide tokens on the right. So if this + function has been called with the param 'right' it will collapse tokens from the right side of the list rather + than the (default) left side. + */ + if (side === 'right') { + i = this.tokenList.length - 1; } + while (combinedTokenWidth > elementWidth && (side === 'right' ? i >= 0 : i < this.tokenList.length)) { + // loop through the tokens and hide them until the combinedTokenWidth fits in the elementWidth + const token = this.tokenList.get(i); + const moreTokens = side === 'right' ? this.moreTokensRight : this.moreTokensLeft; + + if (token) { + if (moreTokens.indexOf(token) === -1) { + moreTokens.push(token); + } + token.elementRef.nativeElement.style.display = 'none'; + } + // get the new elementWidth and combinedTokenWidth as these will have changed after setting a token display to 'none' + elementWidth = this._elementRef.nativeElement.getBoundingClientRect().width; + this._checkMoreElementVisibility(); + combinedTokenWidth = this.getCombinedTokenWidth(); + side === 'right' ? i-- : i++; + } + + this._cdRef.detectChanges(); + this._hiddenTokens.forEach((hiddenToken, index) => { + hiddenToken._viewContainer.clear(); + this._viewContainer.get(index)?.createEmbeddedView(hiddenToken._content); + }); } /** @hidden */ private _resetTokens(): void { this.moreTokensLeft = []; this.moreTokensRight = []; - if (this.compact || this.compactCollapse) { + if (this.compact || this.compactCollapse || this._forceAllTokensToDisplay) { this.tokenList.forEach((token) => { this._makeElementVisible(token.elementRef); }); @@ -520,7 +623,7 @@ export class TokenizerComponent } }); - this._cdRef.detectChanges(); + this._checkMoreElementVisibility(); } /** @hidden */ @@ -657,12 +760,12 @@ export class TokenizerComponent /** @hidden */ private _isTokenFocused(token: TokenComponent): boolean { - return token.tokenWrapperElement.nativeElement === document.activeElement; + return token.tokenWrapperElement.nativeElement === this._document.activeElement; } /** @hidden */ private _isInputFocused(): boolean { - return document.activeElement === this.input.nativeElement; + return this._document.activeElement === this.input.nativeElement; } /** @hidden */ @@ -690,9 +793,9 @@ export class TokenizerComponent merge( fromEvent(this._elementRef.nativeElement, 'focus', { capture: true }).pipe( filter((event) => (event['target'] as any)?.tagName === 'INPUT' && this.tokenizerFocusable), - mapTo(true) + map(() => true) ), - fromEvent(this._elementRef.nativeElement, 'blur', { capture: true }).pipe(mapTo(false)) + fromEvent(this._elementRef.nativeElement, 'blur', { capture: true }).pipe(map(() => false)) ) .pipe( // debounceTime is needed in order to filter subsequent focus-blur events, that happen simultaneously @@ -707,8 +810,24 @@ export class TokenizerComponent /** @hidden Listen window resize and distribute cards on column change */ private _listenOnResize(): void { - resizeObservable(this.elementRef().nativeElement) + resizeObservable(this._elementRef.nativeElement) .pipe(debounceTime(60), takeUntil(this._onDestroy$)) .subscribe(() => this.onResize()); } + + /** @hidden */ + private _checkMoreElementVisibility(): void { + const showMoreElement = + (this.moreTokensLeft.length > 0 || this.moreTokensRight.length > 0 || this.hiddenCozyTokenCount > 0) && + !this.open && + !this._tokenizerHasFocus; + + if (showMoreElement === this._showMoreElement) { + return; + } + + this._showMoreElement = showMoreElement; + + this._cdRef.detectChanges(); + } } diff --git a/libs/docs/core/multi-combobox/examples/tokenizer/multi-combobox-responsive-tokenizer-example.component.html b/libs/docs/core/multi-combobox/examples/tokenizer/multi-combobox-responsive-tokenizer-example.component.html new file mode 100644 index 00000000000..7e3df0dbfe5 --- /dev/null +++ b/libs/docs/core/multi-combobox/examples/tokenizer/multi-combobox-responsive-tokenizer-example.component.html @@ -0,0 +1,12 @@ +
+ + +
diff --git a/libs/docs/core/multi-combobox/examples/tokenizer/multi-combobox-responsive-tokenizer-example.component.ts b/libs/docs/core/multi-combobox/examples/tokenizer/multi-combobox-responsive-tokenizer-example.component.ts new file mode 100644 index 00000000000..6c0a3772d62 --- /dev/null +++ b/libs/docs/core/multi-combobox/examples/tokenizer/multi-combobox-responsive-tokenizer-example.component.ts @@ -0,0 +1,119 @@ +import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; +import { MultiComboboxSelectionChangeEvent } from '@fundamental-ngx/core/multi-combobox'; + +@Component({ + selector: 'fd-multi-combobox-responsive-tokenizer-example', + templateUrl: './multi-combobox-responsive-tokenizer-example.component.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MultiComboboxResponsiveTokenizerExampleComponent { + dataSource = [ + '10" Portable DVD player', + '7" Widescreen Portable DVD Player w MP3', + 'Astro Laptop 1516', + 'Astro Phone 6', + 'Audio/Video Cable Kit - 4m', + 'Beam Breaker B-1', + 'Beam Breaker B-2', + 'Beam Breaker B-3', + 'Benda Laptop 1408', + 'Bending Screen 21HD', + 'Blaster Extreme', + 'Broad Screen 22HD', + 'Camcorder View', + 'CD/DVD case: 264 sleeves', + 'Cepat Tablet 10.5', + 'Cepat Tablet 8', + 'Cerdik Phone 7', + 'Comfort Easy', + 'Comfort Senior', + 'Copperberry', + 'Copymaster', + 'Cordless Bluetooth Keyboard, english international', + 'Cordless Mouse', + 'Designer Mousepad', + 'e-Book Reader ReadMe', + 'Ergo Mousepad', + 'Ergo Screen E-I', + 'Ergo Screen E-II', + 'Ergo Screen E-III', + 'Ergonomic Keyboard', + 'Fabric bag professional', + 'Family PC Basic', + 'Family PC Pro', + 'Flat Basic', + 'Flat Future', + 'Flat Watch HD32', + 'Flat Watch HD37', + 'Flat Watch HD41', + 'Flat XL', + 'Flat XXL', + 'Flyer', + 'Gaming Monster', + 'Gaming Monster Pro', + 'Gladiator MX', + 'Goldberry', + 'Hurricane GX', + 'Hurricane GX/LN', + 'Internet Keyboard', + 'ITelO FlexTop I4000', + 'ITelO FlexTop I6300c', + 'ITelO FlexTop I9100', + 'ITelO FlexTop I9800', + 'ITelo Jog-Mate', + 'ITelo MusicStick', + 'ITelO Vault', + 'ITelO Vault Net', + 'ITelO Vault SAT', + 'Jet Scan Professional', + 'Jet Scan Professional', + 'Laser Allround', + 'Laser Basic', + 'Laser Professional Eco', + 'Lovely Sound 5.1', + 'Lovely Sound 5.1 Wireless', + 'Lovely Sound Stereo', + 'Maxi Tablet', + 'Media Keyboard', + 'Mini Tablet', + 'Mousepad', + 'Multi Color', + 'Multi Print', + 'Notebook Basic 15', + 'Notebook Basic 17', + 'Notebook Basic 18', + 'Notebook Basic 19', + 'Notebook Lock', + 'Notebook Professional 15', + 'Notebook Professional 17', + 'PC Lock', + 'PC Power Station', + 'Photo Scan', + 'Platinberry', + 'Play Movie', + 'Pocket Mouse', + 'Portable DVD Player with 9" LCD Monitor', + 'Power Pro Player 40', + 'Power Pro Player 80', + 'Power Scan', + 'Proctra X', + 'Record Movie', + 'Removable CD/DVD Laser Labels', + 'Screen clean', + 'Server Basic', + 'Server Power Pro', + 'Server Professional', + 'Silverberry', + 'Smart Design', + 'Smart Firewall', + 'Smart Games', + 'Smart Internet Antivirus' + ]; + + selectedItems1 = [this.dataSource[1]]; + + onSelect1(item: MultiComboboxSelectionChangeEvent): void { + this.selectedItems1 = item.selectedItems; + } +} diff --git a/libs/docs/core/multi-combobox/index.ts b/libs/docs/core/multi-combobox/index.ts index 934dde8ffef..3e175814d38 100644 --- a/libs/docs/core/multi-combobox/index.ts +++ b/libs/docs/core/multi-combobox/index.ts @@ -1 +1,3 @@ export * from './multi-combobox-docs.module'; + +export * from './examples/tokenizer/multi-combobox-responsive-tokenizer-example.component'; diff --git a/libs/docs/core/multi-combobox/multi-combobox-docs.component.html b/libs/docs/core/multi-combobox/multi-combobox-docs.component.html index 462ffc87410..f84f1f94c10 100644 --- a/libs/docs/core/multi-combobox/multi-combobox-docs.component.html +++ b/libs/docs/core/multi-combobox/multi-combobox-docs.component.html @@ -94,3 +94,27 @@ + +Reviewing tokens + +

+ If tokens have been selected, and the multi-combo box is not in focus, the input field displays as many tokens + as possible in the available space. If more tokens have been selected, an [n] More label indicates the number of + hidden tokens. The tokens in the input field appear in the order in which they were selected. +

+

+ Clicking the [n] More label opens a popover below the input field, in which all selected items are shown. The + user can deselect an item by clicking its checkbox or label. +

+

+ If there is only one token in the input field and its length exceeds the width of the input field, the text is + truncated. Clicking the token opens a popover below the input field, in which the full text of the token is + shown. +

+
+ + + + + + diff --git a/libs/docs/core/multi-combobox/multi-combobox-docs.component.ts b/libs/docs/core/multi-combobox/multi-combobox-docs.component.ts index a3ae0a7e0a1..b8271b66cf9 100644 --- a/libs/docs/core/multi-combobox/multi-combobox-docs.component.ts +++ b/libs/docs/core/multi-combobox/multi-combobox-docs.component.ts @@ -15,6 +15,8 @@ const multiComboboxStatesHtml = 'multi-combobox-states/multi-combobox-states-exa const multiComboboxStatesTs = 'multi-combobox-states/multi-combobox-states-example.component.ts'; const multiComboboxLoadingHtml = 'multi-combobox-loading/multi-combobox-loading-example.component.html'; const multiComboboxLoadingTs = 'multi-combobox-loading/multi-combobox-loading-example.component.ts'; +const multiComboboxTokenizerHtml = 'tokenizer/multi-combobox-responsive-tokenizer-example.component.html'; +const multiComboboxTokenizerTs = 'tokenizer/multi-combobox-responsive-tokenizer-example.component.ts'; @Component({ selector: 'app-multi-combobox', @@ -118,4 +120,18 @@ export class MultiComboboxDocsComponent { component: 'MultiComboboxLoadingExampleComponent' } ]; + + multiComboboxTokenizerExample: ExampleFile[] = [ + { + language: 'html', + fileName: 'multi-combobox-responsive-tokenizer-example', + code: getAssetFromModuleAssets(multiComboboxTokenizerHtml) + }, + { + language: 'typescript', + fileName: 'multi-combobox-responsive-tokenizer-example', + code: getAssetFromModuleAssets(multiComboboxTokenizerTs), + component: 'MultiComboboxResponsiveTokenizerExampleComponent' + } + ]; } diff --git a/libs/docs/core/multi-combobox/multi-combobox-docs.module.ts b/libs/docs/core/multi-combobox/multi-combobox-docs.module.ts index 864706c401f..6391b5b1658 100644 --- a/libs/docs/core/multi-combobox/multi-combobox-docs.module.ts +++ b/libs/docs/core/multi-combobox/multi-combobox-docs.module.ts @@ -15,6 +15,7 @@ import { MultiComboboxFormsExampleComponent } from './examples/multi-combobox-fo import { MultiComboboxStatesExampleComponent } from './examples/multi-combobox-states/multi-combobox-states-example.component'; import { MultiComboboxLoadingExampleComponent } from './examples/multi-combobox-loading/multi-combobox-loading-example.component'; import { FormModule } from '@fundamental-ngx/core/form'; +import { MultiComboboxResponsiveTokenizerExampleComponent } from './examples/tokenizer/multi-combobox-responsive-tokenizer-example.component'; const routes: Routes = [ { @@ -37,7 +38,7 @@ const routes: Routes = [ BusyIndicatorModule, FormModule ], - exports: [RouterModule], + exports: [RouterModule, MultiComboboxResponsiveTokenizerExampleComponent], declarations: [ MultiComboboxDocsComponent, MultiComboboxHeaderComponent, @@ -47,7 +48,8 @@ const routes: Routes = [ MultiComboboxColumnsExampleComponent, MultiComboboxFormsExampleComponent, MultiComboboxStatesExampleComponent, - MultiComboboxLoadingExampleComponent + MultiComboboxLoadingExampleComponent, + MultiComboboxResponsiveTokenizerExampleComponent ], providers: [currentComponentProvider('multi-combobox')] }) diff --git a/libs/platform/src/lib/form/multi-combobox/multi-combobox/multi-combobox.component.html b/libs/platform/src/lib/form/multi-combobox/multi-combobox/multi-combobox.component.html index 408c2435956..6f4da810f32 100644 --- a/libs/platform/src/lib/form/multi-combobox/multi-combobox/multi-combobox.component.html +++ b/libs/platform/src/lib/form/multi-combobox/multi-combobox/multi-combobox.component.html @@ -43,6 +43,8 @@ (keydown)="navigateByTokens($event)" > diff --git a/libs/platform/src/lib/form/multi-input/multi-input.component.html b/libs/platform/src/lib/form/multi-input/multi-input.component.html index 3f666c9d0bf..3e3c539c6cc 100644 --- a/libs/platform/src/lib/form/multi-input/multi-input.component.html +++ b/libs/platform/src/lib/form/multi-input/multi-input.component.html @@ -45,6 +45,8 @@ Date: Thu, 23 Mar 2023 16:42:31 +0200 Subject: [PATCH 2/4] fix(core): fix failing unit test --- libs/core/src/lib/token/tokenizer.component.spec.ts | 9 ++++----- libs/core/src/lib/token/tokenizer.component.ts | 12 ++---------- .../platform-table-semantic-example.component.html | 8 +++++++- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/libs/core/src/lib/token/tokenizer.component.spec.ts b/libs/core/src/lib/token/tokenizer.component.spec.ts index 740c91e2e2f..897d5ca0c6c 100644 --- a/libs/core/src/lib/token/tokenizer.component.spec.ts +++ b/libs/core/src/lib/token/tokenizer.component.spec.ts @@ -62,6 +62,7 @@ describe('TokenizerComponent', () => { it('should addEventListener to input during ngAfterViewInit and handle keydown', async () => { spyOn(component, 'handleKeyDown'); + await whenStable(fixture); component.ngAfterViewInit(); await whenStable(fixture); @@ -232,17 +233,15 @@ describe('TokenizerComponent', () => { component.tokenList.forEach((token) => { spyOn(token.tokenWrapperElement.nativeElement, 'getBoundingClientRect').and.returnValue({ width: 1 }); }); - spyOn(component.input.nativeElement, 'getBoundingClientRect').and.returnValue({ width: 1 }); + spyOn(component.input.nativeElement, 'getBoundingClientRect').and.returnValue({ width: 1 } as DOMRect); }); - it('should handle ngAfterContentInit', () => { + it('should handle resize', () => { spyOn(component.elementRef().nativeElement, 'getBoundingClientRect').and.returnValue({ width: 1 }); - spyOn(component, 'onResize'); - component.ngAfterContentInit(); + component.onResize(); expect(component.previousElementWidth).toBe(1); - expect(component.onResize).toHaveBeenCalled(); }); it('should get the hidden cozy token count AfterViewChecked', async () => { diff --git a/libs/core/src/lib/token/tokenizer.component.ts b/libs/core/src/lib/token/tokenizer.component.ts index 66767092687..a65f5728305 100644 --- a/libs/core/src/lib/token/tokenizer.component.ts +++ b/libs/core/src/lib/token/tokenizer.component.ts @@ -1,6 +1,5 @@ import { DOCUMENT } from '@angular/common'; import { - AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, @@ -48,9 +47,7 @@ import { ContentDensityObserver, contentDensityObserverProviders } from '@fundam changeDetection: ChangeDetectionStrategy.OnPush, providers: [contentDensityObserverProviders()] }) -export class TokenizerComponent - implements AfterViewInit, AfterContentInit, OnDestroy, CssClassBuilder, OnInit, OnChanges -{ +export class TokenizerComponent implements AfterViewInit, OnDestroy, CssClassBuilder, OnInit, OnChanges { /** user's custom classes */ @Input() class: string; @@ -246,14 +243,9 @@ export class TokenizerComponent if (!this.compact && !this.compactCollapse) { this._handleCozyTokenCount(); } - } - - /** @hidden */ - ngAfterContentInit(): void { this._listenElementEvents(); this.previousElementWidth = this._elementRef.nativeElement.getBoundingClientRect().width; this._listenOnResize(); - this.onResize(); } /** @hidden */ @@ -811,7 +803,7 @@ export class TokenizerComponent /** @hidden Listen window resize and distribute cards on column change */ private _listenOnResize(): void { resizeObservable(this._elementRef.nativeElement) - .pipe(debounceTime(60), takeUntil(this._onDestroy$)) + .pipe(startWith(null), debounceTime(60), takeUntil(this._onDestroy$)) .subscribe(() => this.onResize()); } diff --git a/libs/docs/platform/table/examples/platform-table-semantic-example.component.html b/libs/docs/platform/table/examples/platform-table-semantic-example.component.html index 767f4c32553..891053af461 100644 --- a/libs/docs/platform/table/examples/platform-table-semantic-example.component.html +++ b/libs/docs/platform/table/examples/platform-table-semantic-example.component.html @@ -1,4 +1,10 @@ - + From ee3c116cd35488450e96411d824647114d648d9e Mon Sep 17 00:00:00 2001 From: Denis Severin Date: Thu, 23 Mar 2023 17:47:10 +0200 Subject: [PATCH 3/4] fix(core): fix failing unit test --- libs/core/src/lib/token/tokenizer.component.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/core/src/lib/token/tokenizer.component.ts b/libs/core/src/lib/token/tokenizer.component.ts index a65f5728305..092a15112f9 100644 --- a/libs/core/src/lib/token/tokenizer.component.ts +++ b/libs/core/src/lib/token/tokenizer.component.ts @@ -250,9 +250,8 @@ export class TokenizerComponent implements AfterViewInit, OnDestroy, CssClassBui /** @hidden */ ngOnDestroy(): void { - if (this.tokenListChangesSubscription) { - this.tokenListChangesSubscription.unsubscribe(); - } + this.tokenListChangesSubscription?.unsubscribe(); + this._tokenElementFocusedSub?.unsubscribe(); this._onDestroy$.next(); this._onDestroy$.complete(); this._eventListeners.forEach((e) => e()); @@ -802,8 +801,9 @@ export class TokenizerComponent implements AfterViewInit, OnDestroy, CssClassBui /** @hidden Listen window resize and distribute cards on column change */ private _listenOnResize(): void { + this.onResize(); resizeObservable(this._elementRef.nativeElement) - .pipe(startWith(null), debounceTime(60), takeUntil(this._onDestroy$)) + .pipe(debounceTime(30), takeUntil(this._onDestroy$)) .subscribe(() => this.onResize()); } From 8def190715aecfeb50121b8884ccf599995b5d89 Mon Sep 17 00:00:00 2001 From: Denis Severin Date: Fri, 24 Mar 2023 10:27:42 +0200 Subject: [PATCH 4/4] fix(core): fix failing e2e test --- .../multi-combobox/multi-combobox.component.ts | 2 -- .../e2e/multi-combobox.e2e-spec.ts | 17 +++++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/libs/core/src/lib/multi-combobox/multi-combobox.component.ts b/libs/core/src/lib/multi-combobox/multi-combobox.component.ts index 8bd6a2b5a16..c86a5d5b9e3 100644 --- a/libs/core/src/lib/multi-combobox/multi-combobox.component.ts +++ b/libs/core/src/lib/multi-combobox/multi-combobox.component.ts @@ -612,8 +612,6 @@ export class MultiComboboxComponent extends BaseMultiCombobox implem if (this.isOpen && this.listComponent) { this.listComponent.setItemActive(0); } - - this.searchInputElement?.nativeElement.focus(); } /** diff --git a/libs/docs/core/multi-combobox/e2e/multi-combobox.e2e-spec.ts b/libs/docs/core/multi-combobox/e2e/multi-combobox.e2e-spec.ts index 24029b17256..17ffdabddac 100644 --- a/libs/docs/core/multi-combobox/e2e/multi-combobox.e2e-spec.ts +++ b/libs/docs/core/multi-combobox/e2e/multi-combobox.e2e-spec.ts @@ -38,6 +38,7 @@ describe('multi-combobox test suite', () => { const mobileExample = 6; const nMoreExample = 5; const twoColumnExample = 9; + const tokenizerExample = 16; const searchValue = 'app'; beforeAll(async () => { @@ -73,7 +74,7 @@ describe('multi-combobox test suite', () => { const exampleCount = await getElementArrayLength(expandButton); for (let i = 0; i < exampleCount; i++) { - if (i === mobileExample) { + if (i === mobileExample || i === tokenizerExample) { continue; } @@ -119,12 +120,12 @@ describe('multi-combobox test suite', () => { const inputCount = await getElementArrayLength(inputField); for (let i = 0; i < inputCount; i++) { - if (i !== mobileExample) { + if (i !== mobileExample && i !== tokenizerExample) { await scrollIntoView(inputField, i); await click(inputField, i); await sendKeys(searchValue); - if (i == inputCount - 1) { + if (i == inputCount - 2) { await browser.pause(500); } @@ -132,7 +133,10 @@ describe('multi-combobox test suite', () => { const listItemText = await getTextArr(listItem); for (const element of listItemText) { - await expect(element.toString().toLowerCase()).toContain(searchValue); + await expect(element.toString().toLowerCase()).toContain( + searchValue, + `Values do not match for index ${i}` + ); } } } @@ -224,9 +228,10 @@ describe('multi-combobox test suite', () => { async function checkInputExpansion(index: number = 0): Promise { await scrollIntoView(expandButton, index); await click(expandButton, index); - await expect(await waitForElDisplayed(list)).toBe(true); + await expect(await waitForElDisplayed(list)).toBe(true, `List is not displayed for index ${index}`); await click(expandButton, index); - await expect(await doesItExist(list)).toBe(false); + + await expect(await doesItExist(list)).toBe(false, `List is still displayed for index ${index}`); } async function checkListClosedAfterSelection(index: number = 0): Promise {