diff --git a/package.json b/package.json index 094aadfce..9b04b53ea 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "server-dev": "webpack-dev-server --config tools/webpack/webpack.config.js", "server-dev:all": "npm run server-dev -- --env.component all", "server-dev:alert": "npm run server-dev -- --env.component alert", + "server-dev:autocomplete": "npm run server-dev -- --env.component autocomplete", "server-dev:badge": "npm run server-dev -- --env.component badge", "server-dev:button": "npm run server-dev -- --env.component button", "server-dev:button-toggle": "npm run server-dev -- --env.component button-toggle", @@ -180,7 +181,7 @@ "server-dev:sidepanel": "npm run server-dev -- --env.component sidepanel", "server-dev:splitter": "npm run server-dev -- --env.component splitter", "server-dev:tabs": "npm run server-dev -- --env.component tabs", - "server-dev:tag": "npm run server-dev -- --env.component tag", + "server-dev:tags": "npm run server-dev -- --env.component tags", "server-dev:textarea": "npm run server-dev -- --env.component textarea", "server-dev:toggle": "npm run server-dev -- --env.component toggle", "server-dev:theme-picker": "npm run server-dev -- --env.component theme-picker", diff --git a/src/cdk/keycodes/keycodes.ts b/src/cdk/keycodes/keycodes.ts index 04d8f70b2..a9a413307 100644 --- a/src/cdk/keycodes/keycodes.ts +++ b/src/cdk/keycodes/keycodes.ts @@ -118,3 +118,13 @@ export const BACKSLASH = 220; export const CLOSE_SQUARE_BRACKET = 221; export const SINGLE_QUOTE = 222; export const MAC_META = 224; + +type ModifierKey = 'altKey' | 'shiftKey' | 'ctrlKey' | 'metaKey'; + +export function hasModifierKey(event: KeyboardEvent, ...modifiers: ModifierKey[]): boolean { + if (modifiers.length) { + return modifiers.some((modifier) => event[modifier]); + } + + return event.altKey || event.shiftKey || event.ctrlKey || event.metaKey; +} diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index 5143901d2..628e30c31 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -260,7 +260,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O if (to === 'to-end') { transform += ` translate${axis}(-100%)`; // The viewport should rewrite this as a `to-start` offset on the next render cycle. Otherwise - // elements will appear to expand in the wrong direction (e.g. `mat-expansion-panel` would + // elements will appear to expand in the wrong direction (e.g. `mc-expansion-panel` would // expand upward). this._renderedContentOffsetNeedsRewrite = true; } diff --git a/src/cdk/testing/dispatch-events.ts b/src/cdk/testing/dispatch-events.ts index 044eadc88..8f5b6c98c 100644 --- a/src/cdk/testing/dispatch-events.ts +++ b/src/cdk/testing/dispatch-events.ts @@ -1,3 +1,5 @@ +// tslint:disable:no-reserved-keywords + import { createFakeEvent, createKeyboardEvent, @@ -20,19 +22,15 @@ export function dispatchFakeEvent(node: Node | Window, type: string, canBubble?: } /** Shorthand to dispatch a keyboard event with a specified key code. */ -// tslint:disable-next-line:no-reserved-keywords -export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number, target?: Element, - shiftKey = false, ctrlKey = false, altKey = false): +export function dispatchKeyboardEvent(node: Node, type: string, keyCode: number, target?: Element): KeyboardEvent { - const event = createKeyboardEvent(type, keyCode, target, undefined, shiftKey, ctrlKey, altKey); - - return dispatchEvent(node, event) as KeyboardEvent; + return dispatchEvent(node, createKeyboardEvent(type, keyCode, target)) as KeyboardEvent; } /** Shorthand to dispatch a mouse event on the specified coordinates. */ -// tslint:disable-next-line:no-reserved-keywords -export function dispatchMouseEvent(node: Node, type: string, x = 0, y = 0, - event = createMouseEvent(type, x, y)): MouseEvent { +export function dispatchMouseEvent( +node: Node, type: string, x = 0, y = 0, event = createMouseEvent(type, x, y) +): MouseEvent { return dispatchEvent(node, event) as MouseEvent; } diff --git a/src/cdk/testing/event-objects.ts b/src/cdk/testing/event-objects.ts index 9d74e2be5..f1568418a 100644 --- a/src/cdk/testing/event-objects.ts +++ b/src/cdk/testing/event-objects.ts @@ -43,29 +43,28 @@ export function createTouchEvent(type: string, pageX = 0, pageY = 0) { /** Dispatches a keydown event from an element. */ // tslint:disable-next-line:no-reserved-keywords -export function createKeyboardEvent(type: string, keyCode: number, target?: Element, key?: string, - shiftKey = false, ctrlKey = false, altKey = false) { +export function createKeyboardEvent(type: string, keyCode: number, target?: Element, key?: string) { const event = document.createEvent('KeyboardEvent') as any; - // Firefox does not support `initKeyboardEvent`, but supports `initKeyEvent`. - const initEventFn = (event.initKeyEvent || event.initKeyboardEvent).bind(event); const originalPreventDefault = event.preventDefault; - initEventFn(type, true, true, window, 0, 0, 0, 0, 0, keyCode); + // Firefox does not support `initKeyboardEvent`, but supports `initKeyEvent`. + if (event.initKeyEvent) { + event.initKeyEvent(type, true, true, window, 0, 0, 0, 0, 0, keyCode); + } else { + event.initKeyboardEvent(type, true, true, window, 0, key, 0, '', false); + } // Webkit Browsers don't set the keyCode when calling the init function. // See related bug https://bugs.webkit.org/show_bug.cgi?id=16735 Object.defineProperties(event, { keyCode: { get: () => keyCode }, key: { get: () => key }, - target: { get: () => target }, - shiftKey: { get: () => shiftKey }, - ctrlKey: { get: () => ctrlKey }, - altKey: { get: () => altKey } + target: { get: () => target } }); // IE won't set `defaultPrevented` on synthetic events so we need to do it manually. event.preventDefault = function() { - Object.defineProperty(event, 'defaultPrevented', {get: () => true}); + Object.defineProperty(event, 'defaultPrevented', { get: () => true }); return originalPreventDefault.apply(this, arguments); }; diff --git a/src/cdk/testing/public-api.ts b/src/cdk/testing/public-api.ts index 85e7fcb7a..06501cc12 100644 --- a/src/cdk/testing/public-api.ts +++ b/src/cdk/testing/public-api.ts @@ -1,5 +1,6 @@ export * from './dispatch-events'; export * from './event-objects'; +export * from './type-in-element'; export * from './element-focus'; export * from './mock-ng-zone'; export * from './wrapped-error-message'; diff --git a/src/cdk/testing/type-in-element.ts b/src/cdk/testing/type-in-element.ts new file mode 100644 index 000000000..0e34e708c --- /dev/null +++ b/src/cdk/testing/type-in-element.ts @@ -0,0 +1,13 @@ +import { dispatchFakeEvent } from './dispatch-events'; + +/** + * Focuses an input, sets its value and dispatches + * the `input` event, simulating the user typing. + * @param value Value to be set on the input. + * @param element Element onto which to set the value. + */ +export function typeInElement(value: string, element: HTMLInputElement) { + element.focus(); + element.value = value; + dispatchFakeEvent(element, 'input'); +} diff --git a/src/dev-app/system-config.ts b/src/dev-app/system-config.ts index cb9429636..46fa0ec5c 100644 --- a/src/dev-app/system-config.ts +++ b/src/dev-app/system-config.ts @@ -80,7 +80,7 @@ System.config({ '@ptsecurity/mosaic/form-field': 'dist/packages/mosaic/form-field/index.js', '@ptsecurity/mosaic/tree': 'dist/packages/mosaic/tree/index.js', '@ptsecurity/mosaic/modal': 'dist/packages/mosaic/modal/index.js', - '@ptsecurity/mosaic/tag': 'dist/packages/mosaic/tag/index.js', + '@ptsecurity/mosaic/tags': 'dist/packages/mosaic/tags/index.js', '@ptsecurity/mosaic/tabs': 'dist/packages/mosaic/tabs/index.js', '@ptsecurity/mosaic/select': 'dist/packages/mosaic/select/index.js', '@ptsecurity/mosaic/sidepanel': 'dist/packages/mosaic/sidepanel/index.js', diff --git a/src/lib-dev/all/module.ts b/src/lib-dev/all/module.ts index 92cb2c90a..5192688b3 100644 --- a/src/lib-dev/all/module.ts +++ b/src/lib-dev/all/module.ts @@ -26,7 +26,7 @@ import { McProgressSpinnerModule } from '@ptsecurity/mosaic/progress-spinner'; import { McRadioModule } from '@ptsecurity/mosaic/radio'; import { McSelectModule } from '@ptsecurity/mosaic/select'; import { McSplitterModule } from '@ptsecurity/mosaic/splitter'; -import { McTagModule } from '@ptsecurity/mosaic/tag'; +import { McTagsModule } from '@ptsecurity/mosaic/tags'; import { McTextareaModule } from '@ptsecurity/mosaic/textarea'; import { McTimepickerModule } from '@ptsecurity/mosaic/timepicker'; import { McToggleModule } from '@ptsecurity/mosaic/toggle'; @@ -213,7 +213,7 @@ export class DemoComponent { McRadioModule, McSelectModule, McSplitterModule, - McTagModule, + McTagsModule, McTextareaModule, McTimepickerModule, McToggleModule, diff --git a/src/lib-dev/autocomplete/module.ts b/src/lib-dev/autocomplete/module.ts new file mode 100644 index 000000000..7c902d690 --- /dev/null +++ b/src/lib-dev/autocomplete/module.ts @@ -0,0 +1,75 @@ +import { Component, NgModule, OnInit, ViewEncapsulation } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { McAutocompleteModule, McAutocompleteSelectedEvent } from '@ptsecurity/mosaic/autocomplete'; +import { McButtonModule } from '@ptsecurity/mosaic/button'; +import { McFormFieldModule } from '@ptsecurity/mosaic/form-field'; +import { McIconModule } from '@ptsecurity/mosaic/icon'; +import { McInputModule } from '@ptsecurity/mosaic/input'; +import { Observable } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; + + +@Component({ + selector: 'app', + template: require('./template.html'), + styleUrls: ['./styles.scss'], + encapsulation: ViewEncapsulation.None +}) +export class DemoComponent implements OnInit { + options = [ + 'One', 'Two', 'Three', 'Four', 'Five', 'Longest text (0123456789 qwertyuiopasdfghjklzxcvbnm)', 'Волгоград', + 'Воронеж', 'Ейск', 'Екабпилс', 'Екатеринбург', 'Екатериновка', 'Екатеринославка', 'Екаша', 'Екибастуз', + 'Екпинди', 'Елань', 'Елец', 'Казань', 'Краснодар', 'Красноярск', 'Москва', 'Нижний Новгород', 'Новосибирск', + 'Омск', 'Пермь', 'Ростов-на-Дону', 'Самара', 'Санкт-Петербург', 'Уфа', 'Челябинск' + ]; + + filteredOptions: Observable; + + formControl = new FormControl('', Validators.required); + + onSelectionChange($event: McAutocompleteSelectedEvent) { + console.log(`onSelectionChange: ${$event}`); + } + + ngOnInit(): void { + this.filteredOptions = this.formControl.valueChanges + .pipe( + startWith(''), + map((value) => this.filter(value)) + ); + } + + private filter(value: string): string[] { + const filterValue = value.toLowerCase(); + + return this.options.filter((option) => option.toLowerCase().includes(filterValue)); + } + +} + + +@NgModule({ + declarations: [DemoComponent], + imports: [ + BrowserAnimationsModule, + BrowserModule, + FormsModule, + McAutocompleteModule, + + McInputModule, + McButtonModule, + McFormFieldModule, + McIconModule, + ReactiveFormsModule + ], + bootstrap: [DemoComponent] +}) +export class DemoModule {} + +platformBrowserDynamic() + .bootstrapModule(DemoModule) + .catch((error) => console.error(error)); + diff --git a/src/lib-dev/autocomplete/styles.scss b/src/lib-dev/autocomplete/styles.scss new file mode 100644 index 000000000..1ca253a92 --- /dev/null +++ b/src/lib-dev/autocomplete/styles.scss @@ -0,0 +1,13 @@ +@import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons'; + +@import '../../lib/core/theming/prebuilt/default-theme'; +//@import '../../lib/core/theming/prebuilt/dark-theme'; + + +.dev-container { + width: 300px; + + border: 1px solid red; + + padding: 24px; +} diff --git a/src/lib-dev/autocomplete/template.html b/src/lib-dev/autocomplete/template.html new file mode 100644 index 000000000..2ecdaa2ed --- /dev/null +++ b/src/lib-dev/autocomplete/template.html @@ -0,0 +1,12 @@ +






+ +
{{ formControl.value }}
+ +
+ + + + {{ option }} + + +
diff --git a/src/lib-dev/tag/module.ts b/src/lib-dev/tag/module.ts deleted file mode 100644 index c2aa10128..000000000 --- a/src/lib-dev/tag/module.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Component, NgModule, ViewEncapsulation } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { BrowserModule } from '@angular/platform-browser'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { McIconModule } from '@ptsecurity/mosaic/icon'; -import { McTagModule } from '@ptsecurity/mosaic/tag'; - - -@Component({ - selector: 'app', - template: require('./template.html'), - styleUrls: ['./styles.scss'], - encapsulation: ViewEncapsulation.None -}) -export class DemoComponent {} - - -@NgModule({ - declarations: [ - DemoComponent - ], - imports: [ - BrowserAnimationsModule, - BrowserModule, - FormsModule, - McTagModule, - McIconModule - ], - bootstrap: [ - DemoComponent - ] -}) -export class DemoModule {} - -platformBrowserDynamic() - .bootstrapModule(DemoModule) - .catch((error) => console.error(error)); - diff --git a/src/lib-dev/tag/template.html b/src/lib-dev/tag/template.html deleted file mode 100644 index 5e8f7aa20..000000000 --- a/src/lib-dev/tag/template.html +++ /dev/null @@ -1,68 +0,0 @@ -






- -Normal -Hovered -Focused -Disabled - -


- -Normal -Hovered -Focused -Disabled - -


- -Normal -Hovered -Focused -Disabled - - - -






- -mono
-Normal -Hovered -Focused -Disabled - -


- -Normal -Hovered -Focused -Disabled - -


- -Normal -Hovered -Focused -Disabled - - - -






- -CAPS
-Normal -Hovered -Focused -Disabled - -


- -Normal -Hovered -Focused -Disabled - -


- -Normal -Hovered -Focused -Disabled diff --git a/src/lib-dev/tags/module.ts b/src/lib-dev/tags/module.ts new file mode 100644 index 000000000..ccc7451b2 --- /dev/null +++ b/src/lib-dev/tags/module.ts @@ -0,0 +1,161 @@ +import { + Component, + ElementRef, + NgModule, OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { COMMA, ENTER } from '@ptsecurity/cdk/keycodes'; +import { McAutocompleteModule, McAutocompleteSelectedEvent } from '@ptsecurity/mosaic/autocomplete'; +import { McFormFieldModule } from '@ptsecurity/mosaic/form-field'; +import { McIconModule } from '@ptsecurity/mosaic/icon'; +import { McTagList, McTagsModule } from '@ptsecurity/mosaic/tags'; +import { McTagInputEvent } from '@ptsecurity/mosaic/tags/tag-input'; +import { merge } from 'rxjs'; +import { map } from 'rxjs/operators'; + + +@Component({ + selector: 'app', + template: require('./template.html'), + styleUrls: ['./styles.scss'], + encapsulation: ViewEncapsulation.None +}) +export class DemoComponent implements OnInit { + visible = true; + tagCtrl = new FormControl(); + + simpleTags = ['tag', 'tag1', 'tag2', 'tag3', 'tag4']; + + inputTags = ['tag', 'tag1', 'tag2', 'tag3', 'tag4']; + + autocompleteAllTags: string[] = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7', 'tag8', 'tag9', 'tag10']; + autocompleteSelectedTags: string[] = ['tag1']; + autocompleteFilteredTagsByInput: string[] = []; + autocompleteFilteredTags: any; + + readonly separatorKeysCodes: number[] = [ENTER, COMMA]; + + @ViewChild('inputTagInput') inputTagInput: ElementRef; + @ViewChild('inputTagList') inputTagList: McTagList; + + @ViewChild('autocompleteTagInput') autocompleteTagInput: ElementRef; + @ViewChild('autocompleteTagList') autocompleteTagList: McTagList; + + ngOnInit(): void { + this.autocompleteFilteredTags = merge( + this.autocompleteTagList.tagChanges.asObservable() + .pipe(map((selectedTags) => { + const values = selectedTags.map((tag) => tag.value); + + return this.autocompleteAllTags.filter((tag) => !values.includes(tag)); + })), + this.tagCtrl.valueChanges + .pipe(map((value) => { + const typedText = (value && value.new) ? value.value : value; + + this.autocompleteFilteredTagsByInput = typedText ? + this.filter(typedText) : this.autocompleteAllTags.slice(); + + return this.autocompleteFilteredTagsByInput.filter( + // @ts-ignore + (tag) => !this.autocompleteSelectedTags.includes(tag) + ); + })) + ); + } + + autocompleteOnCreate(event: McTagInputEvent): void { + const input = event.input; + const value = event.value; + + if ((value || '').trim()) { + this.autocompleteSelectedTags.push(value.trim()); + } + + if (input) { + input.value = ''; + } + } + + inputOnCreate(event: McTagInputEvent): void { + const input = event.input; + const value = event.value; + + if ((value || '').trim()) { + this.inputTags.push(value.trim()); + } + + if (input) { + input.value = ''; + } + } + + autocompleteOnSelect(event: McAutocompleteSelectedEvent): void { + if (event.option.value.new) { + this.autocompleteSelectedTags.push(event.option.value.value); + } else { + this.autocompleteSelectedTags.push(event.option.value); + } + this.autocompleteTagInput.nativeElement.value = ''; + this.tagCtrl.setValue(null); + } + + autocompleteOnRemove(fruit: any): void { + const index = this.autocompleteSelectedTags.indexOf(fruit); + + if (index >= 0) { + this.autocompleteSelectedTags.splice(index, 1); + } + } + + onRemoveTag(tag: string): void { + const index = this.simpleTags.indexOf(tag); + + if (index >= 0) { + this.simpleTags.splice(index, 1); + } + } + + inputOnRemoveTag(tag: string): void { + const index = this.inputTags.indexOf(tag); + + if (index >= 0) { + this.inputTags.splice(index, 1); + } + } + + private filter(value: string): string[] { + const filterValue = value.toLowerCase(); + + // todo добавить фильтрацию + return this.autocompleteAllTags.filter((tag) => tag.toLowerCase().indexOf(filterValue) === 0); + } +} + + +@NgModule({ + declarations: [DemoComponent], + imports: [ + BrowserAnimationsModule, + BrowserModule, + FormsModule, + McFormFieldModule, + ReactiveFormsModule, + + McAutocompleteModule, + McTagsModule, + McIconModule + ], + bootstrap: [DemoComponent] +}) +export class DemoModule {} + +platformBrowserDynamic() + .bootstrapModule(DemoModule) + .catch((error) => console.error(error)); + diff --git a/src/lib-dev/tag/styles.scss b/src/lib-dev/tags/styles.scss similarity index 83% rename from src/lib-dev/tag/styles.scss rename to src/lib-dev/tags/styles.scss index 9d31fe1f2..d5270ba99 100644 --- a/src/lib-dev/tag/styles.scss +++ b/src/lib-dev/tags/styles.scss @@ -7,3 +7,7 @@ .mc-tag { margin-right: 4px; } + +.example-container { + width: 50%; +} diff --git a/src/lib-dev/tags/template.html b/src/lib-dev/tags/template.html new file mode 100644 index 000000000..1901f4282 --- /dev/null +++ b/src/lib-dev/tags/template.html @@ -0,0 +1,121 @@ +
+


+ +

Tags list

+ + + {{ tag }} + + + + +


+ +

Tags with input

+ + + + {{ tag }} + + + + + + +


+ +

Tags with autocomplete

+ + + + {{ tag }} + + + + + + + Создать: {{ autocompleteTagInput.value }} + + {{ tag }} + + + +


+ +

simple Tags

+
+ + Normal + Hovered + Focused + Disabled + +
+ + Normal + Hovered + Focused + Disabled + +
+ + Normal + Hovered + Focused + Disabled + +


+ + mono
+ Normal + Hovered + Focused + Disabled + +
+ + Normal + Hovered + Focused + Disabled + +
+ + Normal + Hovered + Focused + Disabled + +


+ + CAPS
+ Normal + Hovered + Focused + Disabled + +
+ + Normal + Hovered + Focused + Disabled + +
+ + Normal + Hovered + Focused + Disabled +
diff --git a/src/lib/tag/README.md b/src/lib/autocomplete/README.md similarity index 100% rename from src/lib/tag/README.md rename to src/lib/autocomplete/README.md diff --git a/src/lib/autocomplete/_autocomplete-theme.scss b/src/lib/autocomplete/_autocomplete-theme.scss new file mode 100644 index 000000000..ada875565 --- /dev/null +++ b/src/lib/autocomplete/_autocomplete-theme.scss @@ -0,0 +1,27 @@ +@import '../core/theming/theming'; + +@mixin mc-autocomplete-theme($theme) { + $second: map-get($theme, second); + + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + $is-dark: map-get($theme, is-dark); + + .mc-autocomplete-panel { + color: mc-color($foreground, text); + + background-color: if($is-dark, map-get($second, 700), map-get($background, background)); + + border-color: mc-color($second); + + box-shadow: 0 3px 3px 0 mc-color($second, A200); + + .mc-selected { + background-color: if($is-dark, map-get($second, 700), map-get($background, background)); + } + } +} + +@mixin mc-autocomplete-typography($config) { +} diff --git a/src/lib/autocomplete/autocomplete-origin.directive.ts b/src/lib/autocomplete/autocomplete-origin.directive.ts new file mode 100644 index 000000000..0fad3478a --- /dev/null +++ b/src/lib/autocomplete/autocomplete-origin.directive.ts @@ -0,0 +1,14 @@ +import { Directive, ElementRef } from '@angular/core'; + + +/** + * Directive applied to an element to make it usable + * as a connection point for an autocomplete panel. + */ +@Directive({ + selector: '[mcAutocompleteOrigin]', + exportAs: 'mcAutocompleteOrigin' +}) +export class McAutocompleteOrigin { + constructor(public elementRef: ElementRef) {} +} diff --git a/src/lib/autocomplete/autocomplete-trigger.directive.ts b/src/lib/autocomplete/autocomplete-trigger.directive.ts new file mode 100644 index 000000000..5252c3cde --- /dev/null +++ b/src/lib/autocomplete/autocomplete-trigger.directive.ts @@ -0,0 +1,659 @@ +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectorRef, + Directive, + ElementRef, + forwardRef, + Host, + Inject, + InjectionToken, + Input, + NgZone, + OnDestroy, + Optional, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Directionality } from '@ptsecurity/cdk/bidi'; +import { coerceBooleanProperty } from '@ptsecurity/cdk/coercion'; +import { DOWN_ARROW, ENTER, ESCAPE, TAB, UP_ARROW } from '@ptsecurity/cdk/keycodes'; +import { + FlexibleConnectedPositionStrategy, + Overlay, + OverlayConfig, + OverlayRef, + IPositionStrategy, + IScrollStrategy, IConnectedPosition +} from '@ptsecurity/cdk/overlay'; +import { TemplatePortal } from '@ptsecurity/cdk/portal'; +import { ViewportRuler } from '@ptsecurity/cdk/scrolling'; +import { + countGroupLabelsBeforeOption, + getOptionScrollPosition, + McOption, + McOptionSelectionChange +} from '@ptsecurity/mosaic/core'; +import { McFormField } from '@ptsecurity/mosaic/form-field'; +import { Subscription, defer, fromEvent, merge, of as observableOf, Subject, Observable } from 'rxjs'; +import { filter, take, switchMap, delay, tap, map } from 'rxjs/operators'; + +import { McAutocompleteOrigin } from './autocomplete-origin.directive'; +import { McAutocomplete } from './autocomplete.component'; + + +/** + * The following style constants are necessary to save here in order + * to properly calculate the scrollTop of the panel. Because we are not + * actually focusing the active item, scroll must be handled manually. + */ + +/** The height of each autocomplete option. */ +export const AUTOCOMPLETE_OPTION_HEIGHT = 32; + +/** The total height of the autocomplete panel. */ +export const AUTOCOMPLETE_PANEL_HEIGHT = 256; + +export const AUTOCOMPLETE_BORDER_WIDTH: number = 2; + +/** Injection token that determines the scroll handling while the autocomplete panel is open. */ +export const MC_AUTOCOMPLETE_SCROLL_STRATEGY = + new InjectionToken<() => IScrollStrategy>('mc-autocomplete-scroll-strategy'); + +// tslint:disable-next-line naming-convention +export function MC_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY(overlay: Overlay): () => IScrollStrategy { + return () => overlay.scrollStrategies.reposition(); +} + +export const MC_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY_PROVIDER = { + provide: MC_AUTOCOMPLETE_SCROLL_STRATEGY, + deps: [Overlay], + useFactory: MC_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY +}; + +/** + * Provider that allows the autocomplete to register as a ControlValueAccessor. + * @docs-private + */ +export const MAT_AUTOCOMPLETE_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => McAutocompleteTrigger), + multi: true +}; + +/** + * Creates an error to be thrown when attempting to use an autocomplete trigger without a panel. + * @docs-private + */ +export function getMcAutocompleteMissingPanelError(): Error { + return Error('Attempting to open an undefined instance of `mc-autocomplete`. ' + + 'Make sure that the id passed to the `mcAutocomplete` is correct and that ' + + 'you\'re attempting to open it after the ngAfterContentInit hook.'); +} + + +@Directive({ + selector: `input[mcAutocomplete], textarea[mcAutocomplete]`, + host: { + class: 'mc-autocomplete-trigger', + '[attr.autocomplete]': 'autocompleteAttribute', + // Note: we use `focusin`, as opposed to `focus`, in order to open the panel + // a little earlier. This avoids issues where IE delays the focusing of the input. + '(focusin)': 'handleFocus()', + '(blur)': 'onTouched()', + '(input)': 'handleInput($event)', + '(keydown)': 'handleKeydown($event)', + '(click)': 'handleClick($event)' + }, + exportAs: 'mcAutocompleteTrigger', + providers: [MAT_AUTOCOMPLETE_VALUE_ACCESSOR] +}) +export class McAutocompleteTrigger implements ControlValueAccessor, OnDestroy { + // @ts-ignore + readonly optionSelections: Observable = defer(() => { + if (this.autocomplete && this.autocomplete.options) { + return merge(...this.autocomplete.options.map((option) => option.onSelectionChange)); + } + + // If there are any subscribers before `ngAfterViewInit`, the `autocomplete` will be undefined. + // Return a stream that we'll replace with the real one once everything is in place. + return this.zone.onStable + .asObservable() + .pipe(take(1), switchMap(() => this.optionSelections)); + }); + + /** The currently active option, coerced to MatOption type. */ + get activeOption(): McOption | null { + if (this.autocomplete && this.autocomplete.keyManager) { + return this.autocomplete.keyManager.activeItem; + } + + return null; + } + + get panelOpen(): boolean { + return this.overlayAttached && this.autocomplete.showPanel; + } + + /** The autocomplete panel to be attached to this trigger. */ + @Input('mcAutocomplete') autocomplete: McAutocomplete; + + /** + * Reference relative to which to position the autocomplete panel. + * Defaults to the autocomplete trigger element. + */ + @Input('mcAutocompleteConnectedTo') connectedTo: McAutocompleteOrigin; + + /** + * `autocomplete` attribute to be set on the input element. + * @docs-private + */ + @Input('autocomplete') autocompleteAttribute: string = 'off'; + + /** + * Whether the autocomplete is disabled. When disabled, the element will + * act as a regular input and the user won't be able to open the panel. + */ + @Input('mcAutocompleteDisabled') + get autocompleteDisabled(): boolean { + return this._autocompleteDisabled; + } + + set autocompleteDisabled(value: boolean) { + this._autocompleteDisabled = coerceBooleanProperty(value); + } + + private _autocompleteDisabled = false; + + private overlayAttached: boolean = false; + + private overlayRef: OverlayRef | null; + + private portal: TemplatePortal; + + private componentDestroyed = false; + + private scrollStrategy: () => IScrollStrategy; + + /** Old value of the native input. Used to work around issues with the `input` event on IE. */ + private previousValue: string | number | null; + + /** Strategy that is used to position the panel. */ + private positionStrategy: FlexibleConnectedPositionStrategy; + + /** The subscription for closing actions (some are bound to document). */ + private closingActionsSubscription: Subscription; + + /** Subscription to viewport size changes. */ + private viewportSubscription = Subscription.EMPTY; + + /** + * Whether the autocomplete can open the next time it is focused. Used to prevent a focused, + * closed autocomplete from being reopened if the user switches to another browser tab and then + * comes back. + */ + private canOpenOnNextFocus = true; + + /** Stream of keyboard events that can close the panel. */ + private readonly closeKeyEventStream = new Subject(); + + constructor( + private elementRef: ElementRef, + private viewContainerRef: ViewContainerRef, + private changeDetectorRef: ChangeDetectorRef, + private overlay: Overlay, + private zone: NgZone, + @Inject(MC_AUTOCOMPLETE_SCROLL_STRATEGY) scrollStrategy: any, + @Optional() private dir: Directionality, + @Optional() @Host() private formField: McFormField, + @Optional() @Inject(DOCUMENT) private document: any, + // @breaking-change 8.0.0 Make `_viewportRuler` required. + private viewportRuler?: ViewportRuler + ) { + // tslint:disable-next-line no-typeof-undefined + if (typeof window !== 'undefined') { + zone.runOutsideAngular(() => { + window.addEventListener('blur', this.windowBlurHandler); + }); + } + + this.scrollStrategy = scrollStrategy; + } + + ngOnDestroy() { + // tslint:disable-next-line no-typeof-undefined + if (typeof window !== 'undefined') { + window.removeEventListener('blur', this.windowBlurHandler); + } + + this.viewportSubscription.unsubscribe(); + this.componentDestroyed = true; + this.destroyPanel(); + this.closeKeyEventStream.complete(); + } + + /** `View -> model callback called when value changes` */ + // tslint:disable-next-line no-empty + onChange: (value: any) => void = () => {}; + + /** `View -> model callback called when autocomplete has been touched` */ + // tslint:disable-next-line no-empty + onTouched: () => void = () => {}; + + /** Opens the autocomplete suggestion panel. */ + openPanel(): void { + this.attachOverlay(); + } + + closePanel(): void { + if (!this.overlayAttached) { return; } + + if (this.panelOpen) { + this.autocomplete.closed.emit(); + } + + this.autocomplete.isOpen = this.overlayAttached = false; + + if (this.overlayRef && this.overlayRef.hasAttached()) { + this.overlayRef.detach(); + this.closingActionsSubscription.unsubscribe(); + } + + // Note that in some cases this can end up being called after the component is destroyed. + // Add a check to ensure that we don't try to run change detection on a destroyed view. + if (!this.componentDestroyed) { + // We need to trigger change detection manually, because + // `fromEvent` doesn't seem to do it at the proper time. + // This ensures that the label is reset when the + // user clicks outside. + this.changeDetectorRef.detectChanges(); + } + } + + /** + * Updates the position of the autocomplete suggestion panel to ensure that it fits all options + * within the viewport. + */ + updatePosition(): void { + if (this.overlayAttached) { + this.overlayRef!.updatePosition(); + } + } + + /** + * A stream of actions that should close the autocomplete panel, including + * when an option is selected, on blur, and when TAB is pressed. + */ + get panelClosingActions(): Observable { + return merge( + this.optionSelections, + this.autocomplete.keyManager.tabOut.pipe(filter(() => this.overlayAttached)), + this.closeKeyEventStream, + this.getOutsideClickStream(), + this.overlayRef ? + this.overlayRef.detachments().pipe(filter(() => this.overlayAttached)) : + observableOf() + ).pipe( + // Normalize the output so we return a consistent type. + map((event) => event instanceof McOptionSelectionChange ? event : null) + ); + } + + // Implemented as part of ControlValueAccessor. + writeValue(value: any): void { + Promise.resolve(null).then(() => this.setTriggerValue(value)); + } + + // Implemented as part of ControlValueAccessor. + registerOnChange(fn: (value: any) => {}): void { + this.onChange = fn; + } + + // Implemented as part of ControlValueAccessor. + registerOnTouched(fn: () => {}) { + this.onTouched = fn; + } + + // Implemented as part of ControlValueAccessor. + setDisabledState(isDisabled: boolean) { + this.elementRef.nativeElement.disabled = isDisabled; + } + + handleKeydown(event: KeyboardEvent): void { + // tslint:disable-next-line deprecation + const keyCode = event.keyCode; + + // Prevent the default action on all escape key presses. This is here primarily to bring IE + // in line with other browsers. By default, pressing escape on IE will cause it to revert + // the input value to the one that it had on focus, however it won't dispatch any events + // which means that the model value will be out of sync with the view. + if (keyCode === ESCAPE) { + event.preventDefault(); + } + + if (this.activeOption && keyCode === ENTER && this.panelOpen) { + this.activeOption.selectViaInteraction(); + this.resetActiveItem(); + event.preventDefault(); + } else if (this.autocomplete) { + const prevActiveItem = this.autocomplete.keyManager.activeItem; + + if (this.panelOpen || keyCode === TAB) { + this.autocomplete.onKeydown(event); + } else if (keyCode === DOWN_ARROW && this.canOpen()) { + this.openPanel(); + } + + const isArrowKey = keyCode === UP_ARROW || keyCode === DOWN_ARROW; + + if (isArrowKey || this.autocomplete.keyManager.activeItem !== prevActiveItem) { + this.scrollToOption(); + } + } + } + + handleInput(event: KeyboardEvent): void { + const target = event.target as HTMLInputElement; + let value: number | string | null = target.value; + + // Based on `NumberValueAccessor` from forms. + if (target.type === 'number') { + value = value === '' ? null : parseFloat(value); + } + + // If the input has a placeholder, IE will fire the `input` event on page load, + // focus and blur, in addition to when the user actually changed the value. To + // filter out all of the extra events, we save the value on focus and between + // `input` events, and we check whether it changed. + // See: https://connect.microsoft.com/IE/feedback/details/885747/ + if (this.previousValue !== value) { + this.previousValue = value; + this.onChange(value); + + if (this.canOpen() && this.document.activeElement === event.target) { + this.openPanel(); + } + } + } + + handleFocus(): void { + if (!this.canOpenOnNextFocus) { + this.canOpenOnNextFocus = true; + } else if (this.canOpen()) { + this.previousValue = this.elementRef.nativeElement.value; + this.attachOverlay(); + } + } + + handleClick($event: MouseEvent) { + if (this.canOpen() && this.document.activeElement === $event.target) { + this.openPanel(); + } + } + + /** Stream of clicks outside of the autocomplete panel. */ + private getOutsideClickStream(): Observable { + if (!this.document) { return observableOf(null); } + + return fromEvent(this.document, 'click') + .pipe(filter((event) => { + const clickTarget = event.target as HTMLElement; + const formField = this.formField ? + this.formField._elementRef.nativeElement : null; + + return this.overlayAttached && + clickTarget !== this.elementRef.nativeElement && + (!formField || !formField.contains(clickTarget)) && + (!!this.overlayRef && !this.overlayRef.overlayElement.contains(clickTarget)); + })); + } + + /** + * Event handler for when the window is blurred. Needs to be an + * arrow function in order to preserve the context. + */ + private windowBlurHandler = () => { + // If the user blurred the window while the autocomplete is focused, it means that it'll be + // refocused when they come back. In this case we want to skip the first focus event, if the + // pane was closed, in order to avoid reopening it unintentionally. + this.canOpenOnNextFocus = this.document.activeElement !== this.elementRef.nativeElement || this.panelOpen; + } + + /** + * Given that we are not actually focusing active options, we must manually adjust scroll + * to reveal options below the fold. First, we find the offset of the option from the top + * of the panel. If that offset is below the fold, the new scrollTop will be the offset - + * the panel height + the option height, so the active option will be just visible at the + * bottom of the panel. If that offset is above the top of the visible panel, the new scrollTop + * will become the offset. If that offset is visible within the panel already, the scrollTop is + * not adjusted. + */ + private scrollToOption(): void { + const index = this.autocomplete.keyManager.activeItemIndex || 0; + const labelCount = countGroupLabelsBeforeOption(index, + this.autocomplete.options, this.autocomplete.optionGroups); + + const newScrollPosition = getOptionScrollPosition( + index + labelCount, + AUTOCOMPLETE_OPTION_HEIGHT, + this.autocomplete.getScrollTop(), + AUTOCOMPLETE_PANEL_HEIGHT + ); + + this.autocomplete.setScrollTop(newScrollPosition); + } + + /** + * This method listens to a stream of panel closing actions and resets the + * stream every time the option list changes. + */ + private subscribeToClosingActions(): Subscription { + const firstStable = this.zone.onStable.asObservable() + .pipe(take(1)); + const optionChanges = this.autocomplete.options.changes + .pipe( + tap(() => this.positionStrategy.reapplyLastPosition()), + // Defer emitting to the stream until the next tick, because changing + // bindings in here will cause "changed after checked" errors. + delay(0) + ); + + // When the zone is stable initially, and when the option list changes... + return merge(firstStable, optionChanges) + .pipe( + // create a new stream of panelClosingActions, replacing any previous streams + // that were created, and flatten it so our stream only emits closing events... + switchMap(() => { + this.resetActiveItem(); + this.autocomplete.setVisibility(); + + if (this.panelOpen) { + this.overlayRef!.updatePosition(); + } + + return this.panelClosingActions; + }), + // when the first closing event occurs... + take(1) + ) + // set the value, close the panel, and complete. + .subscribe((event) => this.setValueAndClose(event)); + } + + /** Destroys the autocomplete suggestion panel. */ + private destroyPanel(): void { + if (this.overlayRef) { + this.closePanel(); + this.overlayRef.dispose(); + this.overlayRef = null; + } + } + + private setTriggerValue(value: any): void { + const toDisplay = this.autocomplete && this.autocomplete.displayWith ? + this.autocomplete.displayWith(value) : + value; + + // Simply falling back to an empty string if the display value is falsy does not work properly. + // The display value can also be the number zero and shouldn't fall back to an empty string. + const inputValue = toDisplay != null ? toDisplay : ''; + + // If it's used within a `MatFormField`, we should set it through the property so it can go + // through change detection. + if (this.formField) { + this.formField._control.value = inputValue; + } else { + this.elementRef.nativeElement.value = inputValue; + } + + this.previousValue = inputValue; + } + + /** This method closes the panel, and if a value is specified, also sets the associated + * control to that value. It will also mark the control as dirty if this interaction + * stemmed from the user. + */ + private setValueAndClose(event: McOptionSelectionChange | null): void { + if (event && event.source) { + this.clearPreviousSelectedOption(event.source); + this.setTriggerValue(event.source.value); + this.onChange(event.source.value); + this.elementRef.nativeElement.focus(); + + this.autocomplete.emitSelectEvent(event.source); + } + + this.closePanel(); + } + + /** Clear any previous selected option and emit a selection change event for this option */ + private clearPreviousSelectedOption(skip: McOption) { + this.autocomplete.options.forEach((option) => { + if (option !== skip && option.selected) { + option.deselect(); + } + }); + } + + private attachOverlay(): void { + if (!this.autocomplete) { + throw getMcAutocompleteMissingPanelError(); + } + + let overlayRef = this.overlayRef; + + if (!overlayRef) { + this.portal = new TemplatePortal(this.autocomplete.template, this.viewContainerRef); + overlayRef = this.overlay.create(this.getOverlayConfig()); + this.overlayRef = overlayRef; + + // Use the `keydownEvents` in order to take advantage of + // the overlay event targeting provided by the CDK overlay. + overlayRef.keydownEvents().subscribe((event) => { + // Close when pressing ESCAPE or ALT + UP_ARROW, based on the a11y guidelines. + // See: https://www.w3.org/TR/wai-aria-practices-1.1/#textbox-keyboard-interaction + // tslint:disable-next-line deprecation + if (event.keyCode === ESCAPE || (event.keyCode === UP_ARROW && event.altKey)) { + this.resetActiveItem(); + this.closeKeyEventStream.next(); + } + }); + + if (this.viewportRuler) { + this.viewportSubscription = this.viewportRuler.change().subscribe(() => { + if (this.panelOpen && overlayRef) { + overlayRef.updateSize({ width: this.getPanelWidth() }); + } + }); + } + } else { + const position = overlayRef.getConfig().positionStrategy as FlexibleConnectedPositionStrategy; + + // Update the trigger, panel width and direction, in case anything has changed. + position.setOrigin(this.getConnectedElement()); + overlayRef.updateSize({ width: this.getPanelWidth() }); + } + + if (overlayRef && !overlayRef.hasAttached()) { + overlayRef.attach(this.portal); + this.closingActionsSubscription = this.subscribeToClosingActions(); + } + + const wasOpen = this.panelOpen; + + this.autocomplete.setVisibility(); + this.autocomplete.isOpen = this.overlayAttached = true; + + // We need to do an extra `panelOpen` check in here, because the + // autocomplete won't be shown if there are no options. + if (this.panelOpen && wasOpen !== this.panelOpen) { + this.autocomplete.opened.emit(); + } + } + + private getOverlayConfig(): OverlayConfig { + return new OverlayConfig({ + positionStrategy: this.getOverlayPosition(), + scrollStrategy: this.scrollStrategy(), + width: this.getPanelWidth(), + direction: this.dir + }); + } + + private getOverlayPosition(): IPositionStrategy { + this.positionStrategy = this.overlay.position() + .flexibleConnectedTo(this.getConnectedElement()) + .withFlexibleDimensions(false) + .withPush(false) + .withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top' + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + + // The overlay edge connected to the trigger should have squared corners, while + // the opposite end has rounded corners. We apply a CSS class to swap the + // border-radius based on the overlay position. + panelClass: 'mc-autocomplete-panel-above' + } + ] as IConnectedPosition[]); + + return this.positionStrategy; + } + + private getConnectedElement(): ElementRef { + if (this.connectedTo) { + return this.connectedTo.elementRef; + } + + return this.formField ? this.formField.getConnectedOverlayOrigin() : this.elementRef; + } + + private getPanelWidth(): number | string { + return this.autocomplete.panelWidth || this.getHostWidth() - AUTOCOMPLETE_BORDER_WIDTH; + } + + private getHostWidth(): number { + return this.getConnectedElement().nativeElement.getBoundingClientRect().width; + } + + /** + * Resets the active item to -1 so arrow events will activate the + * correct options, or to 0 if the consumer opted into it. + */ + private resetActiveItem(): void { + this.autocomplete.keyManager.setActiveItem(this.autocomplete.autoActiveFirstOption ? 0 : -1); + } + + private canOpen(): boolean { + const element = this.elementRef.nativeElement; + + return !element.readOnly && !element.disabled && !this._autocompleteDisabled; + } +} diff --git a/src/lib/autocomplete/autocomplete.component.ts b/src/lib/autocomplete/autocomplete.component.ts new file mode 100644 index 000000000..dd4ba316e --- /dev/null +++ b/src/lib/autocomplete/autocomplete.component.ts @@ -0,0 +1,187 @@ +import { + AfterContentInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + ElementRef, + EventEmitter, + Inject, + InjectionToken, + Input, + Output, + QueryList, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ActiveDescendantKeyManager } from '@ptsecurity/cdk/a11y'; +import { coerceBooleanProperty } from '@ptsecurity/cdk/coercion'; +import { MC_OPTION_PARENT_COMPONENT, McOptgroup, McOption } from '@ptsecurity/mosaic/core'; + + +/** + * Autocomplete IDs need to be unique across components, so this counter exists outside of + * the component definition. + */ +let uniqueAutocompleteIdCounter = 0; + +export class McAutocompleteSelectedEvent { + constructor(public source: McAutocomplete, public option: McOption) {} +} + +/** Default `mc-autocomplete` options that can be overridden. */ +// tslint:disable-next-line naming-convention +export interface McAutocompleteDefaultOptions { + /** Whether the first option should be highlighted when an autocomplete panel is opened. */ + autoActiveFirstOption?: boolean; +} + +/** Injection token to be used to override the default options for `mc-autocomplete`. */ +export const MC_AUTOCOMPLETE_DEFAULT_OPTIONS = + new InjectionToken('mc-autocomplete-default-options', { + providedIn: 'root', + factory: MC_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY + }); + +// tslint:disable-next-line naming-convention +export function MC_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY(): McAutocompleteDefaultOptions { + return { autoActiveFirstOption: true }; +} + +@Component({ + selector: 'mc-autocomplete', + exportAs: 'mcAutocomplete', + templateUrl: 'autocomplete.html', + styleUrls: ['autocomplete.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'mc-autocomplete' }, + providers: [ + { provide: MC_OPTION_PARENT_COMPONENT, useExisting: McAutocomplete } + ] +}) +export class McAutocomplete implements AfterContentInit { + /** Unique ID to be used by autocomplete trigger's "aria-owns" property. */ + id: string = `mc-autocomplete-${uniqueAutocompleteIdCounter++}`; + + /** Manages active item in option list based on key events. */ + keyManager: ActiveDescendantKeyManager; + + /** Whether the autocomplete panel should be visible, depending on option length. */ + showPanel: boolean = false; + + @ViewChild(TemplateRef) template: TemplateRef; + + @ViewChild('panel') panel: ElementRef; + + @ContentChildren(McOption, { descendants: true }) options: QueryList; + + @ContentChildren(McOptgroup) optionGroups: QueryList; + + /** Function that maps an option's control value to its display value in the trigger. */ + @Input() displayWith: ((value: any) => string) | null = null; + + /** + * Specify the width of the autocomplete panel. Can be any CSS sizing value, otherwise it will + * match the width of its host. + */ + @Input() panelWidth: string | number; + + /** Event that is emitted whenever an option from the list is selected. */ + @Output() readonly optionSelected: EventEmitter = + new EventEmitter(); + + /** Event that is emitted when the autocomplete panel is opened. */ + @Output() readonly opened: EventEmitter = new EventEmitter(); + + /** Event that is emitted when the autocomplete panel is closed. */ + @Output() readonly closed: EventEmitter = new EventEmitter(); + + /** + * Takes classes set on the host mc-autocomplete element and applies them to the panel + * inside the overlay container to allow for easy styling. + */ + get classList() { + return this._classList; + } + + @Input('class') + set classList(value: string) { + if (value && value.length) { + value.split(' ') + .forEach((className) => this._classList[className.trim()] = true); + + this.elementRef.nativeElement.className = ''; + } + } + + private _classList: any = {}; + + /** + * Whether the first option should be highlighted when the autocomplete panel is opened. + * Can be configured globally through the `MC_AUTOCOMPLETE_DEFAULT_OPTIONS` token. + */ + @Input() + get autoActiveFirstOption(): boolean { + return this._autoActiveFirstOption; + } + + set autoActiveFirstOption(value: boolean) { + this._autoActiveFirstOption = coerceBooleanProperty(value); + } + + private _autoActiveFirstOption: boolean; + + get isOpen(): boolean { + return this._isOpen && this.showPanel; + } + + set isOpen(value: boolean) { + this._isOpen = value; + } + + private _isOpen: boolean = false; + + constructor( + private changeDetectorRef: ChangeDetectorRef, + private elementRef: ElementRef, + @Inject(MC_AUTOCOMPLETE_DEFAULT_OPTIONS) defaults: McAutocompleteDefaultOptions + ) { + this._autoActiveFirstOption = !!defaults.autoActiveFirstOption; + } + + ngAfterContentInit() { + this.keyManager = new ActiveDescendantKeyManager(this.options); + this.setVisibility(); + } + + setScrollTop(scrollTop: number): void { + if (this.panel) { + this.panel.nativeElement.scrollTop = scrollTop; + } + } + + getScrollTop(): number { + return this.panel ? this.panel.nativeElement.scrollTop : 0; + } + + setVisibility() { + this.showPanel = !!this.options.length; + this._classList['mc-autocomplete_visible'] = this.showPanel; + this._classList['mc-autocomplete_hidden'] = !this.showPanel; + + this.changeDetectorRef.markForCheck(); + } + + emitSelectEvent(option: McOption): void { + const event = new McAutocompleteSelectedEvent(this, option); + + this.optionSelected.emit(event); + } + + onKeydown(event: KeyboardEvent): any { + this.keyManager.onKeydown(event); + } +} + diff --git a/src/lib/autocomplete/autocomplete.html b/src/lib/autocomplete/autocomplete.html new file mode 100644 index 000000000..ad742bf0c --- /dev/null +++ b/src/lib/autocomplete/autocomplete.html @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/src/lib/tag/tag.component.spec.ts b/src/lib/autocomplete/autocomplete.md similarity index 100% rename from src/lib/tag/tag.component.spec.ts rename to src/lib/autocomplete/autocomplete.md diff --git a/src/lib/autocomplete/autocomplete.module.ts b/src/lib/autocomplete/autocomplete.module.ts new file mode 100644 index 000000000..c4eeacb8c --- /dev/null +++ b/src/lib/autocomplete/autocomplete.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { OverlayModule } from '@ptsecurity/cdk/overlay'; +import { McOptionModule, McCommonModule } from '@ptsecurity/mosaic/core'; + +import { McAutocompleteOrigin } from './autocomplete-origin.directive'; +import { + McAutocompleteTrigger, + MC_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY_PROVIDER +} from './autocomplete-trigger.directive'; +import { McAutocomplete } from './autocomplete.component'; + + +@NgModule({ + imports: [McOptionModule, OverlayModule, McCommonModule, CommonModule], + exports: [ + McAutocomplete, + McOptionModule, + McAutocompleteTrigger, + McAutocompleteOrigin, + McCommonModule + ], + declarations: [McAutocomplete, McAutocompleteTrigger, McAutocompleteOrigin], + providers: [MC_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY_PROVIDER] +}) +export class McAutocompleteModule {} diff --git a/src/lib/autocomplete/autocomplete.scss b/src/lib/autocomplete/autocomplete.scss new file mode 100644 index 000000000..5feb2d2f0 --- /dev/null +++ b/src/lib/autocomplete/autocomplete.scss @@ -0,0 +1,63 @@ +@import '../../cdk/a11y/a11y'; + +/** + * The max-height of the panel, currently matching mc-select value. + */ +$mc-autocomplete-panel-max-height: 256px !default; +$mc-autocomplete-panel-border-radius: 3px !default; + +.mc-autocomplete-trigger { + text-overflow: ellipsis; +} + +.mc-autocomplete-panel { + visibility: hidden; + + position: relative; + + overflow: auto; + -webkit-overflow-scrolling: touch; // for momentum scroll on mobile + + margin-top: -1px; + + min-width: 100%; + width: 100%; + max-width: none; + + max-height: $mc-autocomplete-panel-max-height; + + border: { + width: 1px; + style: solid; + } + + border-bottom-left-radius: $mc-autocomplete-panel-border-radius; + border-bottom-right-radius: $mc-autocomplete-panel-border-radius; + + padding: 4px 0; + + &.mc-autocomplete_visible { + visibility: visible; + } + + &.mc-autocomplete_hidden { + visibility: hidden; + } + + .mc-autocomplete-panel-above & { + border-radius: 0; + border-top-left-radius: $mc-autocomplete-panel-border-radius; + border-top-right-radius: $mc-autocomplete-panel-border-radius; + } + + // We need to offset horizontal dividers by their height, because + // they throw off the keyboard navigation inside the panel. + .mc-divider-horizontal { + margin-top: -1px; + } + + @include cdk-high-contrast { + outline: solid 1px; + } +} + diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts new file mode 100644 index 000000000..ec6202227 --- /dev/null +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -0,0 +1,2583 @@ +// tslint:disable:no-magic-numbers +// tslint:disable:no-unbound-method +// tslint:disable:mocha-no-side-effect-code +// tslint:disable:max-func-body-length +// tslint:disable:no-inferred-empty-object-type +// tslint:disable:chai-vague-errors +import { + ChangeDetectionStrategy, + Component, + NgZone, + OnDestroy, + OnInit, + Provider, + QueryList, + ViewChild, + ViewChildren, + Type +} from '@angular/core'; +import { + async, + ComponentFixture, + fakeAsync, + flush, + inject, + TestBed, + tick +} from '@angular/core/testing'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Directionality } from '@ptsecurity/cdk/bidi'; +import { DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW } from '@ptsecurity/cdk/keycodes'; +import { Overlay, OverlayContainer } from '@ptsecurity/cdk/overlay'; +import { ScrollDispatcher } from '@ptsecurity/cdk/scrolling'; +import { + createKeyboardEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, + MockNgZone, + typeInElement, + dispatchEvent +} from '@ptsecurity/cdk/testing'; +import { McOption, McOptionSelectionChange } from '@ptsecurity/mosaic/core'; +import { McFormField, McFormFieldModule } from '@ptsecurity/mosaic/form-field'; +import { Observable, Subject, Subscription, EMPTY } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; + +import { McInputModule } from '../input/index'; + +import { + getMcAutocompleteMissingPanelError, + MC_AUTOCOMPLETE_DEFAULT_OPTIONS, + MC_AUTOCOMPLETE_SCROLL_STRATEGY, + McAutocomplete, + McAutocompleteModule, + McAutocompleteSelectedEvent, + McAutocompleteTrigger, + McAutocompleteOrigin +} from './index'; + + +describe('McAutocomplete', () => { + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + let zone: MockNgZone; + + // Creates a test component fixture. + function createComponent(component: Type, providers: Provider[] = []) { + TestBed.configureTestingModule({ + imports: [ + McAutocompleteModule, + McFormFieldModule, + McInputModule, + FormsModule, + ReactiveFormsModule, + NoopAnimationsModule + ], + declarations: [component], + providers: [ + { provide: NgZone, useFactory: () => zone = new MockNgZone() }, + { provide: MC_AUTOCOMPLETE_DEFAULT_OPTIONS, useFactory: () => ({ autoActiveFirstOption: false }) }, + ...providers + ] + }); + + TestBed.compileComponents(); + + inject([OverlayContainer], (oc: OverlayContainer) => { + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + })(); + + return TestBed.createComponent(component); + } + + afterEach(inject([OverlayContainer], (currentOverlayContainer: OverlayContainer) => { + // Since we're resetting the testing module in some of the tests, + // we can potentially have multiple overlay containers. + currentOverlayContainer.ngOnDestroy(); + overlayContainer.ngOnDestroy(); + })); + + describe('panel toggling', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(SimpleAutocomplete); + fixture.detectChanges(); + input = fixture.debugElement.query(By.css('input')).nativeElement; + }); + + it('should open the panel when the input is focused', () => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to start out closed.`); + + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to read open when input is focused.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to display when input is focused.`); + expect(overlayContainerElement.textContent) + .toContain('California', `Expected panel to display when input is focused.`); + }); + + it('should not open the panel on focus if the input is readonly', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + input.readOnly = true; + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false, 'Expected panel state to start out closed.'); + dispatchFakeEvent(input, 'focusin'); + flush(); + + fixture.detectChanges(); + expect(trigger.panelOpen).toBe(false, 'Expected panel to stay closed.'); + })); + + it('should not open using the arrow keys when the input is readonly', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + input.readOnly = true; + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false, 'Expected panel state to start out closed.'); + dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); + flush(); + + fixture.detectChanges(); + expect(trigger.panelOpen).toBe(false, 'Expected panel to stay closed.'); + })); + + it('should open the panel programmatically', () => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to start out closed.`); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to read open when opened programmatically.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to display when opened programmatically.`); + expect(overlayContainerElement.textContent) + .toContain('California', `Expected panel to display when opened programmatically.`); + }); + + it('should show the panel when the first open is after the initial zone stabilization', + async(() => { + // Note that we're running outside the Angular zone, in order to be able + // to test properly without the subscription from `_subscribeToClosingActions` + // giving us a false positive. + fixture.ngZone!.runOutsideAngular(() => { + fixture.componentInstance.trigger.openPanel(); + + Promise.resolve().then(() => { + expect(fixture.componentInstance.panel.showPanel) + .toBe(true, `Expected panel to be visible.`); + }); + }); + })); + + it('should close the panel when the user clicks away', fakeAsync(() => { + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + zone.simulateZoneExit(); + dispatchFakeEvent(document, 'click'); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected clicking outside the panel to set its state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected clicking outside the panel to close the panel.`); + })); + + it('should close the panel when the user taps away on a touch device', fakeAsync(() => { + dispatchFakeEvent(input, 'focus'); + fixture.detectChanges(); + flush(); + dispatchFakeEvent(document, 'touchend'); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected tapping outside the panel to set its state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected tapping outside the panel to close the panel.`); + })); + + it('should close the panel when an option is clicked', fakeAsync(() => { + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const option = overlayContainerElement.querySelector('mc-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected clicking an option to set the panel state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected clicking an option to close the panel.`); + })); + + it('should close the panel when a newly created option is clicked', fakeAsync(() => { + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + zone.simulateZoneExit(); + + // Filter down the option list to a subset of original options ('Alabama', 'California') + typeInElement('al', input); + fixture.detectChanges(); + tick(); + + let options: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + options[0].click(); + + // Changing value from 'Alabama' to 'al' to re-populate the option list, + // ensuring that 'California' is created new. + dispatchFakeEvent(input, 'focusin'); + typeInElement('al', input); + fixture.detectChanges(); + tick(); + + options = overlayContainerElement.querySelectorAll('mc-option'); + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected clicking a new option to set the panel state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected clicking a new option to close the panel.`); + })); + + it('should close the panel programmatically', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected closing programmatically to set the panel state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected closing programmatically to close the panel.`); + }); + + it('should not throw when attempting to close the panel of a destroyed autocomplete', () => { + const trigger = fixture.componentInstance.trigger; + + trigger.openPanel(); + fixture.detectChanges(); + fixture.destroy(); + + expect(() => trigger.closePanel()).not.toThrow(); + }); + + it('should hide the panel when the options list is empty', fakeAsync(() => { + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + + const panel = overlayContainerElement.querySelector('.mc-autocomplete-panel') as HTMLElement; + + expect(panel.classList) + .toContain('mc-autocomplete_visible', `Expected panel to start out visible.`); + + // Filter down the option list such that no options match the value + typeInElement('af', input); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(panel.classList) + .toContain('mc-autocomplete_hidden', `Expected panel to hide itself when empty.`); + })); + + it('should not open the panel when the `input` event is invoked on a non-focused input', () => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to start out closed.`); + + input.value = 'Alabama'; + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to stay closed.`); + }); + + it('should toggle the visibility when typing and closing the panel', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.mc-autocomplete-panel')!.classList) + .toContain('mc-autocomplete_visible', 'Expected panel to be visible.'); + + typeInElement('x', input); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.mc-autocomplete-panel')!.classList) + .toContain('mc-autocomplete_hidden', 'Expected panel to be hidden.'); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + typeInElement('al', input); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.mc-autocomplete-panel')!.classList) + .toContain('mc-autocomplete_visible', 'Expected panel to be visible.'); + })); + + it('should provide the open state of the panel', fakeAsync(() => { + expect(fixture.componentInstance.panel.isOpen).toBeFalsy( + `Expected the panel to be unopened initially.`); + + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.panel.isOpen).toBeTruthy( + `Expected the panel to be opened on focus.`); + })); + + it('should emit an event when the panel is opened', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).toHaveBeenCalled(); + }); + + it('should not emit the `opened` event when no options are being shown', () => { + fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).not.toHaveBeenCalled(); + }); + + it('should not emit the opened event multiple times while typing', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).toHaveBeenCalledTimes(1); + + typeInElement('Alabam', input); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).toHaveBeenCalledTimes(1); + })); + + it('should emit an event when the panel is closed', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.closedSpy).toHaveBeenCalled(); + }); + + it('should not emit the `closed` event when no options were shown', () => { + fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.closedSpy).not.toHaveBeenCalled(); + }); + + it('should not be able to open the panel if the autocomplete is disabled', () => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to start out closed.`); + + fixture.componentInstance.autocompleteDisabled = true; + fixture.detectChanges(); + + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel to remain closed.`); + }); + + it('should continue to update the model if the autocomplete is disabled', () => { + fixture.componentInstance.autocompleteDisabled = true; + fixture.detectChanges(); + + typeInElement('hello', input); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value).toBe('hello'); + }); + + xit('should set aria-haspopup depending on whether the autocomplete is disabled', () => { + expect(input.getAttribute('aria-haspopup')).toBe('true'); + + fixture.componentInstance.autocompleteDisabled = true; + fixture.detectChanges(); + + expect(input.getAttribute('aria-haspopup')).toBe('false'); + }); + + }); + + it('should have the correct text direction in RTL', () => { + const rtlFixture = createComponent(SimpleAutocomplete, [ + { provide: Directionality, useFactory: () => ({ value: 'rtl', change: EMPTY }) } + ]); + + rtlFixture.detectChanges(); + rtlFixture.componentInstance.trigger.openPanel(); + rtlFixture.detectChanges(); + + const boundingBox = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + expect(boundingBox.getAttribute('dir')).toEqual('rtl'); + }); + + it('should update the panel direction if it changes for the trigger', () => { + const dirProvider = { value: 'rtl', change: EMPTY }; + const rtlFixture = createComponent(SimpleAutocomplete, [ + { provide: Directionality, useFactory: () => dirProvider } + ]); + + rtlFixture.detectChanges(); + rtlFixture.componentInstance.trigger.openPanel(); + rtlFixture.detectChanges(); + + let boundingBox = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + expect(boundingBox.getAttribute('dir')).toEqual('rtl'); + + rtlFixture.componentInstance.trigger.closePanel(); + rtlFixture.detectChanges(); + + dirProvider.value = 'ltr'; + rtlFixture.componentInstance.trigger.openPanel(); + rtlFixture.detectChanges(); + + boundingBox = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + expect(boundingBox.getAttribute('dir')).toEqual('ltr'); + }); + + it('should be able to set a custom value for the `autocomplete` attribute', () => { + const fixture = createComponent(AutocompleteWithNativeAutocompleteAttribute); + const input = fixture.nativeElement.querySelector('input'); + + fixture.detectChanges(); + + expect(input.getAttribute('autocomplete')).toBe('changed'); + }); + + it('should not throw when typing in an element with a null and disabled autocomplete', () => { + const fixture = createComponent(InputWithoutAutocompleteAndDisabled); + fixture.detectChanges(); + + expect(() => { + dispatchKeyboardEvent(fixture.nativeElement.querySelector('input'), 'keydown', SPACE); + fixture.detectChanges(); + }).not.toThrow(); + }); + + describe('forms integration', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(SimpleAutocomplete); + fixture.detectChanges(); + + input = fixture.debugElement.query(By.css('input')).nativeElement; + }); + + it('should update control value as user types with input value', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + typeInElement('a', input); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value) + .toEqual('a', 'Expected control value to be updated as user types.'); + + typeInElement('al', input); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value) + .toEqual('al', 'Expected control value to be updated as user types.'); + }); + + it('should update control value when autofilling', () => { + // Simulate the browser autofilling the input by setting a value and + // dispatching an `input` event while the input is out of focus. + expect(document.activeElement).not.toBe(input, 'Expected input not to have focus.'); + input.value = 'Alabama'; + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value) + .toBe('Alabama', 'Expected value to be propagated to the form control.'); + }); + + it('should update control value when option is selected with option value', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const options: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value) + .toEqual({ code: 'CA', name: 'California' }, + 'Expected control value to equal the selected option value.'); + })); + + it('should update the control back to a string if user types after an option is selected', + fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const options: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + options[1].click(); + fixture.detectChanges(); + + typeInElement('Californi', input); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.stateCtrl.value) + .toEqual('Californi', 'Expected control value to revert back to string.'); + })); + + it('should fill the text field with display value when an option is selected', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const options: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + options[1].click(); + fixture.detectChanges(); + + expect(input.value) + .toContain('California', `Expected text field to fill with selected value.`); + })); + + it('should fill the text field with value if displayWith is not set', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + fixture.componentInstance.panel.displayWith = null; + fixture.componentInstance.options.toArray()[1].value = 'test value'; + fixture.detectChanges(); + + const options: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + options[1].click(); + + fixture.detectChanges(); + expect(input.value) + .toContain('test value', `Expected input to fall back to selected option's value.`); + })); + + it('should fill the text field correctly if value is set to obj programmatically', + fakeAsync(() => { + fixture.componentInstance.stateCtrl.setValue({ code: 'AL', name: 'Alabama' }); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(input.value) + .toContain('Alabama', `Expected input to fill with matching option's viewValue.`); + })); + + it('should clear the text field if value is reset programmatically', fakeAsync(() => { + typeInElement('Alabama', input); + fixture.detectChanges(); + tick(); + + fixture.componentInstance.stateCtrl.reset(); + tick(); + + fixture.detectChanges(); + tick(); + + expect(input.value).toEqual('', `Expected input value to be empty after reset.`); + })); + + it('should disable input in view when disabled programmatically', () => { + const formFieldElement = fixture.debugElement.query(By.css('.mc-form-field')).nativeElement; + + expect(input.disabled) + .toBe(false, `Expected input to start out enabled in view.`); + expect(formFieldElement.classList.contains('mc-disabled')) + .toBe(false, `Expected input underline to start out with normal styles.`); + + fixture.componentInstance.stateCtrl.disable(); + fixture.detectChanges(); + + expect(input.disabled) + .toBe(true, `Expected input to be disabled in view when disabled programmatically.`); + expect(formFieldElement.classList.contains('mc-disabled')) + .toBe(true, `Expected input underline to display disabled styles.`); + }); + + it('should mark the autocomplete control as dirty as user types', () => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + typeInElement('a', input); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(true, `Expected control to become dirty when the user types into the input.`); + }); + + it('should mark the autocomplete control as dirty when an option is selected', fakeAsync(() => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const options: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(true, `Expected control to become dirty when an option was selected.`); + })); + + it('should not mark the control dirty when the value is set programmatically', () => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + fixture.componentInstance.stateCtrl.setValue('AL'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to stay pristine if value is set programmatically.`); + }); + + it('should mark the autocomplete control as touched on blur', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + expect(fixture.componentInstance.stateCtrl.touched) + .toBe(false, `Expected control to start out untouched.`); + + dispatchFakeEvent(input, 'blur'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.touched) + .toBe(true, `Expected control to become touched on blur.`); + }); + + it('should disable the input when used with a value accessor and without `mcInput`', () => { + overlayContainer.ngOnDestroy(); + fixture.destroy(); + TestBed.resetTestingModule(); + + const plainFixture = createComponent(PlainAutocompleteInputWithFormControl); + plainFixture.detectChanges(); + input = plainFixture.nativeElement.querySelector('input'); + + expect(input.disabled).toBe(false); + + plainFixture.componentInstance.formControl.disable(); + plainFixture.detectChanges(); + + expect(input.disabled).toBe(true); + }); + + }); + + describe('keyboard events', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + let DOWN_ARROW_EVENT: KeyboardEvent; + let UP_ARROW_EVENT: KeyboardEvent; + let ENTER_EVENT: KeyboardEvent; + + beforeEach(fakeAsync(() => { + fixture = createComponent(SimpleAutocomplete); + fixture.detectChanges(); + + input = fixture.debugElement.query(By.css('input')).nativeElement; + DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); + UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW); + ENTER_EVENT = createKeyboardEvent('keydown', ENTER); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + })); + + it('should not focus the option when DOWN key is pressed', () => { + spyOn(fixture.componentInstance.options.first, 'focus'); + + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + expect(fixture.componentInstance.options.first.focus).not.toHaveBeenCalled(); + }); + + it('should not close the panel when DOWN key is pressed', () => { + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to stay open when DOWN key is pressed.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to keep displaying when DOWN key is pressed.`); + expect(overlayContainerElement.textContent) + .toContain('California', `Expected panel to keep displaying when DOWN key is pressed.`); + }); + + it('should set the active item to the first option when DOWN key is pressed', () => { + const componentInstance = fixture.componentInstance; + const optionEls: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + + expect(componentInstance.trigger.panelOpen) + .toBe(true, 'Expected first down press to open the pane.'); + + componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(componentInstance.trigger.activeOption === componentInstance.options.first) + .toBe(true, 'Expected first option to be active.'); + expect(optionEls[0].classList).toContain('mc-active'); + expect(optionEls[1].classList).not.toContain('mc-active'); + + componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(componentInstance.trigger.activeOption === componentInstance.options.toArray()[1]) + .toBe(true, 'Expected second option to be active.'); + expect(optionEls[0].classList).not.toContain('mc-active'); + expect(optionEls[1].classList).toContain('mc-active'); + }); + + it('should not set the active item to the last option when UP key is pressed', () => { + const componentInstance = fixture.componentInstance; + const optionEls: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + + expect(componentInstance.trigger.panelOpen) + .toBe(true, 'Expected first up press to open the pane.'); + + componentInstance.trigger.handleKeydown(UP_ARROW_EVENT); + fixture.detectChanges(); + + expect(componentInstance.trigger.activeOption !== componentInstance.options.first) + .toBe(true, 'Expected last option to be active.'); + expect(optionEls[0].classList).not.toContain('mc-active'); + + componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(componentInstance.trigger.activeOption === componentInstance.options.first) + .toBe(true, 'Expected first option to be active.'); + expect(optionEls[0].classList).toContain('mc-active'); + }); + + it('should set the active item properly after filtering', fakeAsync(() => { + const componentInstance = fixture.componentInstance; + + componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + tick(); + fixture.detectChanges(); + })); + + it('should set the active item properly after filtering', () => { + const componentInstance = fixture.componentInstance; + + typeInElement('o', input); + fixture.detectChanges(); + + componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + const optionEls: NodeListOf = overlayContainerElement.querySelectorAll('mc-option'); + + expect(componentInstance.trigger.activeOption === componentInstance.options.first) + .toBe(true, 'Expected first option to be active.'); + expect(optionEls[0].classList).toContain('mc-active'); + expect(optionEls[1].classList).not.toContain('mc-active'); + }); + + it('should fill the text field when an option is selected with ENTER', fakeAsync(() => { + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + flush(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + expect(input.value) + .toContain('Alabama', `Expected text field to fill with selected value on ENTER.`); + })); + + it('should prevent the default enter key action', fakeAsync(() => { + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + flush(); + + fixture.componentInstance.trigger.handleKeydown(ENTER_EVENT); + + expect(ENTER_EVENT.defaultPrevented) + .toBe(true, 'Expected the default action to have been prevented.'); + })); + + it('should not prevent the default enter action for a closed panel after a user action', () => { + fixture.componentInstance.trigger.handleKeydown(UP_ARROW_EVENT); + fixture.detectChanges(); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + fixture.componentInstance.trigger.handleKeydown(ENTER_EVENT); + + expect(ENTER_EVENT.defaultPrevented).toBe(false, 'Default action should not be prevented.'); + }); + + it('should fill the text field, not select an option, when SPACE is entered', () => { + typeInElement('New', input); + fixture.detectChanges(); + + const SPACE_EVENT = createKeyboardEvent('keydown', SPACE); + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + fixture.componentInstance.trigger.handleKeydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(input.value).not.toContain('New York', `Expected option not to be selected on SPACE.`); + }); + + it('should mark the control dirty when selecting an option from the keyboard', fakeAsync(() => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + flush(); + fixture.componentInstance.trigger.handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(true, `Expected control to become dirty when option was selected by ENTER.`); + })); + + it('should open the panel again when typing after making a selection', fakeAsync(() => { + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + flush(); + fixture.componentInstance.trigger.handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to read closed after ENTER key.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected panel to close after ENTER key.`); + + dispatchFakeEvent(input, 'focusin'); + typeInElement('Alabama', input); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to read open when typing in input.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to display when typing in input.`); + })); + + it('should not open the panel if the `input` event was dispatched with changing the value', + fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + + dispatchFakeEvent(input, 'focusin'); + typeInElement('A', input); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + + trigger.closePanel(); + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false, 'Expected panel to be closed.'); + + // Dispatch the event without actually changing the value + // to simulate what happen in some cases on IE. + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(false, 'Expected panel to stay closed.'); + })); + + it('should scroll to active options below the fold', () => { + const trigger = fixture.componentInstance.trigger; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mc-autocomplete-panel')!; + + trigger.handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`); + + // These down arrows will set the 6th option active, below the fold. + [1, 2, 3, 4, 5, 6, 7, 8].forEach(() => trigger.handleKeydown(DOWN_ARROW_EVENT)); + + // Expect option bottom minus the panel height (288 - 256 = 32) + expect(scrollContainer.scrollTop) + .toEqual(32, `Expected panel to reveal the sixth option.`); + }); + + it('should not scroll to active options on UP arrow', () => { + const scrollContainer = document.querySelector('.cdk-overlay-pane .mc-autocomplete-panel')!; + + fixture.componentInstance.trigger.handleKeydown(UP_ARROW_EVENT); + fixture.detectChanges(); + + expect(scrollContainer.scrollTop).toEqual(0, `Expected panel to reveal last option.`); + }); + + it('should not scroll to active options that are fully in the panel', () => { + const trigger = fixture.componentInstance.trigger; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mc-autocomplete-panel')!; + + trigger.handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`); + + // These down arrows will set the 6th option active, below the fold. + [1, 2, 3, 4, 5, 6, 7, 8].forEach(() => trigger.handleKeydown(DOWN_ARROW_EVENT)); + + // Expect option bottom minus the panel height (288 - 256 = 32) + expect(scrollContainer.scrollTop) + .toEqual(32, `Expected panel to reveal the sixth option.`); + + // These up arrows will set the 2nd option active + [4, 3, 2, 1].forEach(() => trigger.handleKeydown(UP_ARROW_EVENT)); + + // Expect no scrolling to have occurred. Still showing bottom of 6th option. + expect(scrollContainer.scrollTop) + .toEqual(32, `Expected panel not to scroll up since sixth option still fully visible.`); + }); + + it('should scroll to active options that are above the panel', () => { + const trigger = fixture.componentInstance.trigger; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mc-autocomplete-panel')!; + + trigger.handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`); + + // These down arrows will set the 7th option active, below the fold. + [1, 2, 3, 4, 5, 6, 7, 8].forEach(() => trigger.handleKeydown(DOWN_ARROW_EVENT)); + + // These up arrows will set the 2nd option active + [7, 6, 5, 4, 3, 2, 1].forEach(() => trigger.handleKeydown(UP_ARROW_EVENT)); + + // Expect to show the top of the 2nd option at the top of the panel + expect(scrollContainer.scrollTop) + .toEqual(32, `Expected panel to scroll up when option is above panel.`); + }); + + it('should close the panel when pressing escape', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + + input.focus(); + flush(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(input, 'Expected input to be focused.'); + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + fixture.detectChanges(); + + expect(document.activeElement).toBe(input, 'Expected input to continue to be focused.'); + expect(trigger.panelOpen).toBe(false, 'Expected panel to be closed.'); + })); + + it('should prevent the default action when pressing escape', fakeAsync(() => { + const escapeEvent = dispatchKeyboardEvent(input, 'keydown', ESCAPE); + fixture.detectChanges(); + + expect(escapeEvent.defaultPrevented).toBe(true); + })); + + it('should close the panel when pressing ALT + UP_ARROW', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + const upArrowEvent = createKeyboardEvent('keydown', UP_ARROW); + Object.defineProperty(upArrowEvent, 'altKey', { get: () => true }); + + input.focus(); + flush(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(input, 'Expected input to be focused.'); + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + + dispatchEvent(document.body, upArrowEvent); + fixture.detectChanges(); + + expect(document.activeElement).toBe(input, 'Expected input to continue to be focused.'); + expect(trigger.panelOpen).toBe(false, 'Expected panel to be closed.'); + })); + + it('should close the panel when tabbing away from a trigger without results', fakeAsync(() => { + fixture.componentInstance.states = []; + fixture.componentInstance.filteredStates = []; + fixture.detectChanges(); + input.focus(); + flush(); + + expect(overlayContainerElement.querySelector('.mc-autocomplete-panel')) + .toBeTruthy('Expected panel to be rendered.'); + + dispatchKeyboardEvent(input, 'keydown', TAB); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.mc-autocomplete-panel')) + .toBeFalsy('Expected panel to be removed.'); + })); + + it('should reset the active option when closing with the escape key', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + + trigger.openPanel(); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + expect(!!trigger.activeOption).toBe(false, 'Expected no active option.'); + + // Press the down arrow a few times. + [1, 2, 3].forEach(() => { + trigger.handleKeydown(DOWN_ARROW_EVENT); + tick(); + fixture.detectChanges(); + }); + + // Note that this casts to a boolean, in order to prevent Jasmine + // from crashing when trying to stringify the option if the test fails. + expect(!!trigger.activeOption).toBe(true, 'Expected to find an active option.'); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + tick(); + + expect(!!trigger.activeOption).toBe(false, 'Expected no active options.'); + })); + + it('should reset the active option when closing by selecting with enter', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + + trigger.openPanel(); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + expect(!!trigger.activeOption).toBe(false, 'Expected no active option.'); + + // Press the down arrow a few times. + [1, 2, 3].forEach(() => { + trigger.handleKeydown(DOWN_ARROW_EVENT); + tick(); + fixture.detectChanges(); + }); + + // Note that this casts to a boolean, in order to prevent Jasmine + // from crashing when trying to stringify the option if the test fails. + expect(!!trigger.activeOption).toBe(true, 'Expected to find an active option.'); + + trigger.handleKeydown(ENTER_EVENT); + tick(); + + expect(!!trigger.activeOption).toBe(false, 'Expected no active options.'); + })); + + }); + + xdescribe('option groups', () => { + let fixture: ComponentFixture; + let DOWN_ARROW_EVENT: KeyboardEvent; + let UP_ARROW_EVENT: KeyboardEvent; + let container: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(AutocompleteWithGroups); + fixture.detectChanges(); + + DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); + UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + container = document.querySelector('.mc-autocomplete-panel') as HTMLElement; + })); + + it('should scroll to active options below the fold', fakeAsync(() => { + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + tick(); + fixture.detectChanges(); + expect(container.scrollTop).toBe(0, 'Expected the panel not to scroll.'); + + // Press the down arrow five times. + [1, 2, 3, 4, 5].forEach(() => { + fixture.componentInstance.trigger.handleKeydown(DOWN_ARROW_EVENT); + tick(); + }); + + //