diff --git a/.gitignore b/.gitignore index 3999067c5c..d80f742175 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,6 @@ Thumbs.db .now - # bazel bazel-* .bazelrc.user diff --git a/angular.json b/angular.json index 9c472a6996..98a8522fb9 100644 --- a/angular.json +++ b/angular.json @@ -1525,6 +1525,46 @@ }, "schematics": {} }, + "combobox": { + "projectType": "library", + "root": "libs/barista-components/experimental/combobox", + "sourceRoot": "libs/barista-components/experimental/combobox/src", + "prefix": "dt", + "architect": { + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "libs/barista-components/experimental/combobox/tsconfig.lib.json", + "libs/barista-components/experimental/combobox/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**", + "!libs/barista-components/experimental/combobox/**" + ] + } + }, + "lint-styles": { + "builder": "./dist/libs/workspace:stylelint", + "options": { + "stylelintConfig": ".stylelintrc", + "reportFile": "dist/stylelint/report.xml", + "exclude": ["**/node_modules/**"], + "files": ["libs/barista-components/experimental/combobox/**/*.scss"] + } + }, + "test": { + "builder": "@nrwl/jest:jest", + "options": { + "jestConfig": "libs/barista-components/experimental/combobox/jest.config.js", + "tsConfig": "libs/barista-components/experimental/combobox/tsconfig.spec.json", + "setupFile": "libs/barista-components/experimental/combobox/src/test-setup.ts", + "passWithNoTests": true + } + } + }, + "schematics": {} + }, "confirmation-dialog": { "projectType": "library", "root": "libs/barista-components/confirmation-dialog", diff --git a/apps/components-e2e/src/app/app.routing.module.ts b/apps/components-e2e/src/app/app.routing.module.ts index 321a2cd56b..026b1d70fd 100644 --- a/apps/components-e2e/src/app/app.routing.module.ts +++ b/apps/components-e2e/src/app/app.routing.module.ts @@ -54,6 +54,13 @@ export const routes: Routes = [ (module) => module.DtE2ECheckboxModule, ), }, + { + path: 'combobox', + loadChildren: () => + import('../components/combobox/combobox.module').then( + (module) => module.DtE2EComboboxModule, + ), + }, { path: 'consumption', loadChildren: () => diff --git a/apps/components-e2e/src/components/combobox/combobox.e2e.ts b/apps/components-e2e/src/components/combobox/combobox.e2e.ts new file mode 100644 index 0000000000..eea87e2227 --- /dev/null +++ b/apps/components-e2e/src/components/combobox/combobox.e2e.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +fixture('Combobox').page('http://localhost:4200/combobox'); + +// test('should execute click handlers when not disabled', async (testController: TestController) => { +// await testController.click(button); +// +// const count = await clickCounter.textContent; +// await testController.expect(count).eql('1'); +// }); +// +// test('should not execute click handlers when disabled', async (testController: TestController) => { +// await testController.click(disableButton); +// +// await testController.expect(button.hasAttribute('disabled')).ok(); +// +// await testController.click(button); +// await testController.expect(await clickCounter.textContent).eql('0'); +// }); diff --git a/apps/components-e2e/src/components/combobox/combobox.html b/apps/components-e2e/src/components/combobox/combobox.html new file mode 100644 index 0000000000..7df6303ba7 --- /dev/null +++ b/apps/components-e2e/src/components/combobox/combobox.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/apps/components-e2e/src/components/combobox/combobox.module.ts b/apps/components-e2e/src/components/combobox/combobox.module.ts new file mode 100644 index 0000000000..81278706fa --- /dev/null +++ b/apps/components-e2e/src/components/combobox/combobox.module.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { Route, RouterModule } from '@angular/router'; +import { DtE2ECombobox } from './combobox'; +import { DtComboboxModule } from '@dynatrace/barista-components/experimental/combobox'; + +const routes: Route[] = [{ path: '', component: DtE2ECombobox }]; + +@NgModule({ + declarations: [DtE2ECombobox], + imports: [CommonModule, RouterModule.forChild(routes), DtComboboxModule], + exports: [], + providers: [], +}) +export class DtE2EComboboxModule {} diff --git a/apps/components-e2e/src/components/combobox/combobox.po.ts b/apps/components-e2e/src/components/combobox/combobox.po.ts new file mode 100644 index 0000000000..5d82cf8d51 --- /dev/null +++ b/apps/components-e2e/src/components/combobox/combobox.po.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Selector } from 'testcafe'; + +export const combobox = Selector('#test-combobox'); diff --git a/apps/components-e2e/src/components/combobox/combobox.ts b/apps/components-e2e/src/components/combobox/combobox.ts new file mode 100644 index 0000000000..047c85515d --- /dev/null +++ b/apps/components-e2e/src/components/combobox/combobox.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'dt-e2e-combobox', + templateUrl: 'combobox.html', +}) +export class DtE2ECombobox {} diff --git a/apps/dev/src/app.module.ts b/apps/dev/src/app.module.ts index f75fde9a87..94b28eb1b0 100644 --- a/apps/dev/src/app.module.ts +++ b/apps/dev/src/app.module.ts @@ -91,9 +91,10 @@ import { TopBarNavigationDemo } from './top-bar-navigation/top-bar-navigation-de import { TreeTableDemo } from './tree-table/tree-table-demo.component'; import { DtIconModule } from '@dynatrace/barista-components/icon'; import { - DT_UI_TEST_CONFIG, DT_DEFAULT_UI_TEST_CONFIG, + DT_UI_TEST_CONFIG, } from '@dynatrace/barista-components/core'; +import { ComboboxDemo } from './combobox/combobox-demo.component'; // tslint:disable-next-line: use-component-selector @Component({ template: '' }) @@ -121,6 +122,7 @@ export class NoopRouteComponent {} CardDemo, ChartDemo, CheckboxDemo, + ComboboxDemo, ConfirmationDialogDemo, ContextDialogDemo, CopyToClipboardDemo, diff --git a/apps/dev/src/combobox/combobox-demo.component.html b/apps/dev/src/combobox/combobox-demo.component.html new file mode 100644 index 0000000000..de2500585f --- /dev/null +++ b/apps/dev/src/combobox/combobox-demo.component.html @@ -0,0 +1,37 @@ + + + {{ option.name }} + + + + + Value 1 + + + Value 2 + + + +

+ Selected Value: + {{ selectedValue || 'No value selected' }} +

+ + + {{ coffee.viewValue }} + + diff --git a/apps/dev/src/combobox/combobox-demo.component.ts b/apps/dev/src/combobox/combobox-demo.component.ts new file mode 100644 index 0000000000..bacb14bdf7 --- /dev/null +++ b/apps/dev/src/combobox/combobox-demo.component.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ViewChild, + AfterViewInit, +} from '@angular/core'; +import { take } from 'rxjs/operators'; +import { timer } from 'rxjs'; +import { DtCombobox } from '@dynatrace/barista-components/experimental/combobox'; + +const allOptions: { name: string; value: string }[] = [ + { name: 'Value 1', value: '[value: Value 1]' }, + { name: 'Value 2', value: '[value: Value 2]' }, + { name: 'Value 3', value: '[value: Value 3]' }, + { name: 'Value 4', value: '[value: Value 4]' }, +]; + +@Component({ + selector: 'combobox-dev-app-demo', + templateUrl: 'combobox-demo.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComboboxDemo implements AfterViewInit { + @ViewChild(DtCombobox) combobox: DtCombobox; + + _initialValue = allOptions[0]; + _options = [...allOptions]; + _loading = false; + _displayWith = (option: { name: string; value: string }) => option.name; + + constructor(private _changeDetectorRef: ChangeDetectorRef) {} + + ngAfterViewInit(): void { + this.combobox.selectionChange.subscribe((val) => { + console.log(val); + }); + } + + openedChanged(event: boolean): void { + console.log(`openedChanged: '${event}'`); + } + + valueChanged(event: string): void { + console.log(`valueChanged: '${event}'`); + } + + filterChanged(event: string): void { + console.log(`filterChanged: '${event}'`); + + this._loading = true; + this._changeDetectorRef.markForCheck(); + + timer(1500) + .pipe(take(1)) + .subscribe(() => { + this._options = allOptions.filter( + (option) => + option.value.toLowerCase().indexOf(event.toLowerCase()) >= 0, + ); + this._loading = false; + this._changeDetectorRef.markForCheck(); + }); + } + + selectedValue: string; + coffees = [ + { value: 'ThePerfectPour', viewValue: 'ThePerfectPour' }, + { value: 'Affogato', viewValue: 'Affogato' }, + { value: 'Americano', viewValue: 'Americano' }, + ]; +} diff --git a/apps/dev/src/devapp-routing.module.ts b/apps/dev/src/devapp-routing.module.ts index 6c91d8cc8a..42b965ee7d 100644 --- a/apps/dev/src/devapp-routing.module.ts +++ b/apps/dev/src/devapp-routing.module.ts @@ -77,6 +77,7 @@ import { ToastDemo } from './toast/toast-demo.component'; import { ToggleButtonGroupDemo } from './toggle-button-group/toggle-button-group-demo.component'; import { TopBarNavigationDemo } from './top-bar-navigation/top-bar-navigation-demo.component'; import { TreeTableDemo } from './tree-table/tree-table-demo.component'; +import { ComboboxDemo } from './combobox/combobox-demo.component'; const routes: Routes = [ { path: 'alert', component: AlertDemo }, @@ -88,6 +89,7 @@ const routes: Routes = [ { path: 'card', component: CardDemo }, { path: 'chart', component: ChartDemo }, { path: 'checkbox', component: CheckboxDemo }, + { path: 'combobox', component: ComboboxDemo }, { path: 'consumption', component: ConsumptionDemo }, { path: 'context-dialog', component: ContextDialogDemo }, { path: 'confirmation-dialog', component: ConfirmationDialogDemo }, diff --git a/apps/dev/src/devapp.component.ts b/apps/dev/src/devapp.component.ts index 060b8f70b0..620ff6da17 100644 --- a/apps/dev/src/devapp.component.ts +++ b/apps/dev/src/devapp.component.ts @@ -49,6 +49,7 @@ export class DevApp implements AfterContentInit, OnDestroy { { name: 'Card', route: '/card' }, { name: 'Chart', route: '/chart' }, { name: 'Checkbox', route: '/checkbox' }, + { name: 'Combobox', route: '/combobox' }, { name: 'Confirmation-dialog', route: '/confirmation-dialog' }, { name: 'Consumption', route: '/consumption' }, { diff --git a/apps/dev/src/dt-components.module.ts b/apps/dev/src/dt-components.module.ts index 75c7a3715c..1a510c2645 100644 --- a/apps/dev/src/dt-components.module.ts +++ b/apps/dev/src/dt-components.module.ts @@ -74,6 +74,7 @@ import { DtToastModule } from '@dynatrace/barista-components/toast'; import { DtToggleButtonGroupModule } from '@dynatrace/barista-components/toggle-button-group'; import { DtTopBarNavigationModule } from '@dynatrace/barista-components/top-bar-navigation'; import { DtTreeTableModule } from '@dynatrace/barista-components/tree-table'; +import { DtComboboxModule } from '@dynatrace/barista-components/experimental/combobox'; /** * NgModule that includes all Dynatrace angular components modules that are required to serve the examples. @@ -89,6 +90,7 @@ import { DtTreeTableModule } from '@dynatrace/barista-components/tree-table'; DtCardModule, DtChartModule, DtCheckboxModule, + DtComboboxModule, DtConfirmationDialogModule, DtContextDialogModule, DtCopyToClipboardModule, diff --git a/apps/universal/src/app/barista.module.ts b/apps/universal/src/app/barista.module.ts index a31ef04a6f..f177dfbf68 100644 --- a/apps/universal/src/app/barista.module.ts +++ b/apps/universal/src/app/barista.module.ts @@ -58,6 +58,7 @@ import { DtTimelineChartModule } from '@dynatrace/barista-components/timeline-ch import { DtToggleButtonGroupModule } from '@dynatrace/barista-components/toggle-button-group'; import { DtTopBarNavigationModule } from '@dynatrace/barista-components/top-bar-navigation'; import { DtTreeTableModule } from '@dynatrace/barista-components/tree-table'; +import { DtComboboxModule } from '@dynatrace/barista-components/experimental/combobox'; @NgModule({ imports: [ @@ -71,6 +72,7 @@ import { DtTreeTableModule } from '@dynatrace/barista-components/tree-table'; DtButtonModule, DtCardModule, DtCheckboxModule, + DtComboboxModule, DtConsumptionModule, DtContainerBreakpointObserverModule, DtContainerBreakpointObserverModule, diff --git a/apps/universal/src/app/kitchen-sink/kitchen-sink.html b/apps/universal/src/app/kitchen-sink/kitchen-sink.html index b3d3444a64..484773429c 100644 --- a/apps/universal/src/app/kitchen-sink/kitchen-sink.html +++ b/apps/universal/src/app/kitchen-sink/kitchen-sink.html @@ -370,6 +370,24 @@

Packets

my content + + option 1 + option 2 + option 3 + + + + + Value 1 + Value 2 + Value 3 + + + Value 1 + Value 2 + Value 3 + + {{ tooltip.label }}: {{ tooltip.value }} diff --git a/libs/barista-components/autocomplete/src/autocomplete-module.ts b/libs/barista-components/autocomplete/src/autocomplete-module.ts index 30246faf8c..2ca5cf6b2a 100644 --- a/libs/barista-components/autocomplete/src/autocomplete-module.ts +++ b/libs/barista-components/autocomplete/src/autocomplete-module.ts @@ -23,9 +23,10 @@ import { DtOptionModule } from '@dynatrace/barista-components/core'; import { DtAutocomplete } from './autocomplete'; import { DtAutocompleteOrigin } from './autocomplete-origin'; import { DtAutocompleteTrigger } from './autocomplete-trigger'; +import { PortalModule } from '@angular/cdk/portal'; @NgModule({ - imports: [CommonModule, OverlayModule, DtOptionModule], + imports: [CommonModule, OverlayModule, DtOptionModule, PortalModule], exports: [ DtAutocompleteTrigger, DtAutocomplete, diff --git a/libs/barista-components/autocomplete/src/autocomplete-trigger.ts b/libs/barista-components/autocomplete/src/autocomplete-trigger.ts index 25928a0f28..48c92adbd4 100644 --- a/libs/barista-components/autocomplete/src/autocomplete-trigger.ts +++ b/libs/barista-components/autocomplete/src/autocomplete-trigger.ts @@ -25,16 +25,17 @@ import { import { Overlay, OverlayConfig, + OverlayContainer, OverlayRef, PositionStrategy, ViewportRuler, - OverlayContainer, } from '@angular/cdk/overlay'; import { DOCUMENT } from '@angular/common'; import { ChangeDetectorRef, Directive, ElementRef, + forwardRef, Host, Inject, Input, @@ -42,43 +43,42 @@ import { OnDestroy, Optional, Provider, - forwardRef, } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { - EMPTY, - Observable, - Subject, - Subscription, defer, + EMPTY, fromEvent, merge, + Observable, of as observableOf, + Subject, + Subscription, } from 'rxjs'; import { delay, filter, map, - startWith, switchMap, take, takeUntil, tap, + startWith, } from 'rxjs/operators'; import { - DtOption, - DtOptionSelectionChange, - DtViewportResizer, _countGroupLabelsBeforeOption, _getOptionScrollPosition, - isDefined, _readKeyCode, - stringify, + DT_UI_TEST_CONFIG, DtFlexibleConnectedPositionStrategy, + DtOption, + DtOptionSelectionChange, dtSetUiTestAttribute, - DT_UI_TEST_CONFIG, DtUiTestConfiguration, + DtViewportResizer, + isDefined, + stringify, } from '@dynatrace/barista-components/core'; import { DtFormField } from '@dynatrace/barista-components/form-field'; @@ -96,7 +96,7 @@ export const DT_AUTOCOMPLETE_VALUE_ACCESSOR: Provider = { }; /** The height of the select items. */ -export const AUTOCOMPLETE_OPTION_HEIGHT = 32; +export const AUTOCOMPLETE_OPTION_HEIGHT = 28; /** The max height of the select's overlay panel */ export const AUTOCOMPLETE_PANEL_MAX_HEIGHT = 256; @@ -205,16 +205,18 @@ export class DtAutocompleteTrigger /** Stream of changes to the selection state of the autocomplete options. */ readonly optionSelections: Observable> = defer( () => { - const options = this.autocomplete ? this.autocomplete._options : null; - - if (options) { - return options.changes.pipe( - startWith(options), - switchMap(() => - merge>( - ...options.map((option) => option.selectionChange), - ), - ), + const optionsChanged = this.autocomplete + ? this.autocomplete._options + : null; + + if (optionsChanged) { + return optionsChanged.changes.pipe( + startWith(optionsChanged), + switchMap(() => { + return merge>( + ...optionsChanged.map((option) => option.selectionChange), + ); + }), ); } @@ -690,8 +692,7 @@ export class DtAutocompleteTrigger const index = this.autocomplete._keyManager.activeItemIndex || 0; const labelCount = _countGroupLabelsBeforeOption( index, - this.autocomplete._options, - this.autocomplete._optionGroups, + this.autocomplete._options.toArray(), ); const newScrollPosition = _getOptionScrollPosition( diff --git a/libs/barista-components/autocomplete/src/autocomplete.html b/libs/barista-components/autocomplete/src/autocomplete.html index ba9d97016f..2a86fe6cd7 100644 --- a/libs/barista-components/autocomplete/src/autocomplete.html +++ b/libs/barista-components/autocomplete/src/autocomplete.html @@ -7,5 +7,6 @@ #panel > + diff --git a/libs/barista-components/autocomplete/src/autocomplete.spec.ts b/libs/barista-components/autocomplete/src/autocomplete.spec.ts index dc0070742f..471e453283 100644 --- a/libs/barista-components/autocomplete/src/autocomplete.spec.ts +++ b/libs/barista-components/autocomplete/src/autocomplete.spec.ts @@ -35,6 +35,9 @@ import { Type, ViewChild, ViewChildren, + TemplateRef, + ViewContainerRef, + AfterViewInit, } from '@angular/core'; import { ComponentFixture, @@ -78,6 +81,7 @@ import { MockNgZone, typeInElement, } from '@dynatrace/testing/browser'; +import { TemplatePortal } from '@angular/cdk/portal'; describe('DtAutocomplete', () => { let overlayContainer: OverlayContainer; @@ -727,7 +731,7 @@ describe('DtAutocomplete', () => { let UP_ARROW_EVENT: KeyboardEvent; let ENTER_EVENT: KeyboardEvent; - beforeEach(fakeAsync(() => { + beforeEach(() => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); @@ -739,18 +743,19 @@ describe('DtAutocomplete', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); zone.simulateZoneExit(); - })); + }); - it('should not focus the option when DOWN key is pressed', () => { + it('should not focus the option when DOWN key is pressed', fakeAsync(() => { jest .spyOn(fixture.componentInstance.options.first, 'focus') .mockImplementation(() => {}); 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); @@ -1594,6 +1599,19 @@ describe('DtAutocomplete', () => { ); }); }); + describe('additional programmatic options', () => { + it('should add the additional option at the end of the normal content projected options', () => { + const fixture: ComponentFixture = createComponent( + ProgrammaticOptions, + ); + fixture.detectChanges(); + const trigger = fixture.componentInstance.trigger; + trigger.openPanel(); + fixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain('First'); + expect(overlayContainerElement.textContent).toContain('Second'); + }); + }); }); @Component({ @@ -1934,3 +1952,45 @@ class PropagateAttribute { @ViewChild(DtAutocompleteTrigger, { static: false }) trigger: DtAutocompleteTrigger; } + +@Component({ + template: ` + + + First + + + + Second + + `, +}) +class ProgrammaticOptions implements AfterViewInit { + @ViewChild(DtAutocompleteTrigger, { static: false }) + trigger: DtAutocompleteTrigger; + + @ViewChild(TemplateRef) templateRef: TemplateRef; + + @ViewChild(DtAutocomplete, { static: true }) autocomplete: DtAutocomplete< + number + >; + + @ViewChildren(DtOption) options: QueryList>; + + constructor(private _viewContainerRef: ViewContainerRef) {} + + ngAfterViewInit(): void { + this.autocomplete._additionalPortal = new TemplatePortal( + this.templateRef, + this._viewContainerRef, + ); + this.autocomplete._additionalOptions = [this.options.last]; + } +} diff --git a/libs/barista-components/autocomplete/src/autocomplete.ts b/libs/barista-components/autocomplete/src/autocomplete.ts index 4c0e2afabe..6c59defc72 100644 --- a/libs/barista-components/autocomplete/src/autocomplete.ts +++ b/libs/barista-components/autocomplete/src/autocomplete.ts @@ -35,9 +35,11 @@ import { ViewChild, ViewContainerRef, ViewEncapsulation, + OnDestroy, } from '@angular/core'; -import { DtOptgroup, DtOption } from '@dynatrace/barista-components/core'; +import { DtOption } from '@dynatrace/barista-components/core'; +import { Subscription } from 'rxjs'; let _uniqueIdCounter = 0; @@ -82,7 +84,8 @@ export function DT_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY(): DtAutocompleteDefault encapsulation: ViewEncapsulation.Emulated, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DtAutocomplete implements AfterContentInit, AfterViewInit { +export class DtAutocomplete + implements AfterContentInit, AfterViewInit, OnDestroy { /** * Whether the first option should be highlighted when the autocomplete panel is opened. * Can be configured globally through the `DT_AUTOCOMPLETE_DEFAULT_OPTIONS` token. @@ -159,6 +162,9 @@ export class DtAutocomplete implements AfterContentInit, AfterViewInit { /** @internal */ _portal: TemplatePortal; + /** @internal Additional portal used when additional programmatic options need to be added */ + _additionalPortal: TemplatePortal; + /** Unique ID to be used by autocomplete trigger's "aria-owns" property. */ id = `dt-autocomplete-${_uniqueIdCounter++}`; @@ -176,12 +182,31 @@ export class DtAutocomplete implements AfterContentInit, AfterViewInit { * @internal References to all the options that are currently applied. */ @ContentChildren(DtOption, { descendants: true }) - _options: QueryList>; + private _projectedOptions: QueryList>; - /** - * @interal References to all the option groups that are currently applied. - */ - @ContentChildren(DtOptgroup) _optionGroups: QueryList; + /** @internal The options that are added programmatically and not part of the content children */ + get _additionalOptions(): DtOption[] { + return this._additionalOptionsInternal; + } + set _additionalOptions(val: DtOption[]) { + this._additionalOptionsInternal = val; + this._combineOptions(); + // only emit a changes event when content init is already done + // similar to the querylist changes event + if (this._initialized) { + this._options.notifyOnChanges(); + } + } + private _additionalOptionsInternal: DtOption[] = []; + + /** @internal Querylist that combines projected & programmatic changes */ + _options = new QueryList>(); + + /** Property to trace whether initialization is already done */ + private _initialized = false; + + private _projectedOptionsChangeSubscription: Subscription = + Subscription.EMPTY; constructor( private _changeDetectorRef: ChangeDetectorRef, @@ -198,11 +223,27 @@ export class DtAutocomplete implements AfterContentInit, AfterViewInit { } ngAfterContentInit(): void { + // Combine the changes first + this._combineOptions(); + // init keymanager with custom querylist that combines projected and programmatic options this._keyManager = new ActiveDescendantKeyManager>( this._options, ).withWrap(); // Set the initial visibility state. this._setVisibility(); + this._projectedOptionsChangeSubscription = this._projectedOptions.changes.subscribe( + () => { + this._combineOptions(); + this._options.notifyOnChanges(); + }, + ); + // We need this property here so we don't emit a change event on the first changes for programmatic actions + // similar to what the querylist does + this._initialized = true; + } + + ngOnDestroy(): void { + this._projectedOptionsChangeSubscription.unsubscribe(); } /** @@ -243,6 +284,14 @@ export class DtAutocomplete implements AfterContentInit, AfterViewInit { this.optionSelected.emit(event); } + /** Combines the projected and the programmatic options and updates the querylist */ + private _combineOptions(): void { + const options = this._projectedOptions + .toArray() + .concat(this._additionalOptionsInternal); + this._options.reset(options); + } + /** Sets the autocomplete visibility classes on a classlist based on the panel is visible. */ private _setVisibilityClasses(classList: { [key: string]: boolean }): void { classList['dt-autocomplete-visible'] = this.showPanel; diff --git a/libs/barista-components/core/src/error/errors.ts b/libs/barista-components/core/src/error/errors.ts new file mode 100644 index 0000000000..c95bec082d --- /dev/null +++ b/libs/barista-components/core/src/error/errors.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const DT_COMPARE_WITH_NON_FUNCTION_VALUE_ERROR_MSG = + '`compareWith` must be a function.'; diff --git a/libs/barista-components/core/src/error/index.ts b/libs/barista-components/core/src/error/index.ts index b954b1c85c..008195f954 100644 --- a/libs/barista-components/core/src/error/index.ts +++ b/libs/barista-components/core/src/error/index.ts @@ -15,3 +15,4 @@ */ export * from './error-matcher'; +export * from './errors'; diff --git a/libs/barista-components/core/src/option/option.ts b/libs/barista-components/core/src/option/option.ts index 6ac0b58909..bd2b6a6eda 100644 --- a/libs/barista-components/core/src/option/option.ts +++ b/libs/barista-components/core/src/option/option.ts @@ -27,13 +27,13 @@ import { OnDestroy, Optional, Output, - QueryList, ViewEncapsulation, } from '@angular/core'; import { Subject } from 'rxjs'; import { _readKeyCode } from '../util/index'; import { DtOptgroup } from './optgroup'; +import { Highlightable } from '@angular/cdk/a11y'; let _uniqueId = 0; @@ -69,7 +69,7 @@ export class DtOptionSelectionChange { encapsulation: ViewEncapsulation.Emulated, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DtOption implements AfterViewChecked, OnDestroy { +export class DtOption implements Highlightable, AfterViewChecked, OnDestroy { private _selected = false; private _active = false; private _disabled = false; @@ -257,24 +257,18 @@ export class DtOption implements AfterViewChecked, OnDestroy { /** Counts the amount of option group labels that precede the specified option. */ export function _countGroupLabelsBeforeOption( optionIndex: number, - options: QueryList>, - optionGroups: QueryList, + options: DtOption[], ): number { - if (optionGroups.length) { - const optionsArray = options.toArray(); - const groups = optionGroups.toArray(); - let groupCounter = 0; + if (options.some((option) => !!option.group)) { + const optionsArray = options; + const groups = new Set(); for (let i = 0; i < optionIndex + 1; i++) { - if ( - optionsArray[i].group && - optionsArray[i].group === groups[groupCounter] - ) { - groupCounter++; + if (optionsArray[i].group && !groups.has(optionsArray[i].group!)) { + groups.add(optionsArray[i].group!); } } - - return groupCounter; + return groups.size; } return 0; diff --git a/libs/barista-components/experimental/combobox/README.md b/libs/barista-components/experimental/combobox/README.md new file mode 100644 index 0000000000..f94bb41597 --- /dev/null +++ b/libs/barista-components/experimental/combobox/README.md @@ -0,0 +1,68 @@ +# Combobox (experimental) + +The `` is similar to the `` component in the sense that +it is a form control for selecting a value from a list of options. The major +differences between the two components is that the `` allows the +user to freely filter for options before selecting one. This makes it much more +suitable for large amounts of data that couldn't be handled well by the +`` component. It is also designed to work with Angular forms. By +using the `` element, which is also provided in the select module, +you can add values to the select. Also, the use of `` is supported +for grouping options. + + + +## Imports + +You have to import the `DtComboboxModule` when you want to use the +``. The `` component also requires Angular's +`BrowserAnimationsModule` for animations. For more details on this see +[_Step 2: Animations_](/components/get-started/#step-2-animations) in the +getting started guide. + +```typescript +@NgModule({ + imports: [DtComboboxModule], +}) +class MyModule {} +``` + +## Initialization + +The API of the `` is very similar to the native ` + + + + + + + + + diff --git a/libs/barista-components/experimental/combobox/src/combobox.scss b/libs/barista-components/experimental/combobox/src/combobox.scss new file mode 100644 index 0000000000..3164303e81 --- /dev/null +++ b/libs/barista-components/experimental/combobox/src/combobox.scss @@ -0,0 +1,96 @@ +@import '../../../style/font-mixins'; +@import '../../../core/src/style/variables'; +@import '../../../core/src/style/form-control'; +@import '../../../core/src/style/interactive-common'; + +:host { + display: inline-block; + box-sizing: border-box; + outline: none; + @include dt-main-font(); + @include dt-form-control(); + @include dt-cdkmonitor-focus-style(); + + // Do not allow it to grow outside its wrapper + max-width: 100%; + + .dt-combobox-spinner { + display: inline-block; + width: 16px; + height: 16px; + fill: $gray-500; + } + + &.dt-combobox-open .dt-combobox-arrow { + transform: rotate(180deg); + } + + &:hover:not(.dt-select-disabled) { + border-color: $gray-500; + cursor: pointer; + } +} + +:host.dt-combobox-disabled { + background-color: $gray-100; + color: $disabledcolor; + + .dt-combobox-trigger { + pointer-events: none; + } + + .dt-combobox-input, + .dt-combobox-input::placeholder { + color: $disabledcolor; + } +} + +:host.dt-combobox-disabled .dt-combobox-arrow ::ng-deep svg { + fill: $disabledcolor; +} + +.dt-combobox-trigger { + display: grid; + grid-template-columns: 1fr 30px; + align-items: center; + border-radius: 3px; +} + +.dt-combobox-input { + @include dt-main-font(); + appearance: none; + position: relative; + display: inline-block; + box-sizing: border-box; + text-decoration: none; + padding: 0 0 0 12px; + line-height: -moz-block-height; + vertical-align: middle; + white-space: nowrap; + text-align: left; + border: none; + width: 100%; + outline: none; + min-height: 30px; + border-radius: 3px; +} + +.dt-combobox-input::placeholder { + @include dt-main-font(); +} + +.dt-combobox-postfix { + justify-self: center; +} + +.dt-combobox-arrow { + width: 16px; + transform: rotate(0); + transition: transform 150ms ease-out; + fill: $turquoise-600; + + ::ng-deep svg { + width: 100%; + height: 100%; + } +} diff --git a/libs/barista-components/experimental/combobox/src/combobox.ts b/libs/barista-components/experimental/combobox/src/combobox.ts new file mode 100644 index 0000000000..9624f87eb0 --- /dev/null +++ b/libs/barista-components/experimental/combobox/src/combobox.ts @@ -0,0 +1,541 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Optional, + Output, + QueryList, + TemplateRef, + ViewChild, + ViewContainerRef, + ViewEncapsulation, + Self, + Attribute, + AfterContentInit, + NgZone, +} from '@angular/core'; +import { + DtAutocomplete, + DtAutocompleteSelectedEvent, + DtAutocompleteTrigger, +} from '@dynatrace/barista-components/autocomplete'; +import { fromEvent, Subject, Observable, defer, merge } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + map, + takeUntil, + take, + switchMap, +} from 'rxjs/operators'; +import { + CanDisable, + DtOption, + ErrorStateMatcher, + HasTabIndex, + mixinDisabled, + mixinErrorState, + mixinTabIndex, + DtOptionSelectionChange, + isDefined, + DtLogger, + DtLoggerFactory, + DT_COMPARE_WITH_NON_FUNCTION_VALUE_ERROR_MSG, +} from '@dynatrace/barista-components/core'; +import { DtFormFieldControl } from '@dynatrace/barista-components/form-field'; +import { + FormGroupDirective, + NgControl, + NgForm, + ControlValueAccessor, +} from '@angular/forms'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { SelectionModel } from '@angular/cdk/collections'; + +const LOG: DtLogger = DtLoggerFactory.create('DtCombobox'); + +/** Change event object that is emitted when the combobox value has changed. */ +export class DtComboboxChange { + constructor( + /** Reference to the combobox that emitted the change event. */ + public source: DtCombobox, + /** Current value of the combobox that emitted the event. */ + public value: T, + ) {} +} + +export class DtComboboxBase { + constructor( + public _elementRef: ElementRef, + public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl, + ) {} +} +export const _DtComboboxMixinBase = mixinTabIndex( + mixinDisabled(mixinErrorState(DtComboboxBase)), +); + +@Component({ + selector: 'dt-combobox', + exportAs: 'dtCombobox', + templateUrl: 'combobox.html', + styleUrls: ['combobox.scss'], + host: { + class: 'dt-combobox', + // role: 'listbox', // TODO ChMa: a11y build still fails with "Certain ARIA roles must contain particular children" + '[class.dt-combobox-loading]': '_loading', + '[class.dt-combobox-disabled]': 'disabled', + '[class.dt-combobox-invalid]': 'errorState', + '[class.dt-combobox-required]': 'required', + '[class.dt-combobox-open]': '_panelOpen', + '[attr.id]': 'id', + '[attr.tabindex]': 'tabIndex', + '[attr.aria-required]': 'required.toString()', + '[attr.aria-disabled]': 'disabled.toString()', + '[attr.aria-invalid]': 'errorState', + }, + inputs: ['disabled', 'tabIndex'], + providers: [{ provide: DtFormFieldControl, useExisting: DtCombobox }], + encapsulation: ViewEncapsulation.Emulated, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DtCombobox extends _DtComboboxMixinBase + implements + OnInit, + AfterContentInit, + AfterViewInit, + OnDestroy, + CanDisable, + HasTabIndex, + ControlValueAccessor, + DtFormFieldControl { + /** The ID for the combobox. */ + @Input() id: string; + /** The currently selected value in the combobox. */ + @Input() + get value(): T { + return this._value; + } + set value(newValue: T) { + if (newValue !== this._value) { + this.writeValue(newValue); + this._value = newValue; + } + } + private _value: T; + + /** When set to true, a loading indicator is shown to show to the user that data is currently being loaded/filtered. */ + @Input() + get loading(): boolean { + return this._loading; + } + set loading(value: boolean) { + const coercedValue = coerceBooleanProperty(value); + + if (coercedValue !== this._loading) { + this._loading = coercedValue; + + if (this._loading) { + this._reopenAutocomplete = true; + this._autocompleteTrigger.closePanel(); + } else if (this._reopenAutocomplete) { + this._reopenAutocomplete = false; + this._autocompleteTrigger.openPanel(); + } + this._changeDetectorRef.markForCheck(); + } + } + _loading = false; + + /** Whether the control is required. */ + @Input() required: boolean = false; + /** An arbitrary class name that is added to the combobox dropdown. */ + @Input() panelClass: string = ''; + /** A placeholder text for the input field. */ + @Input() placeholder: string | undefined; + /** A function returning a display name for a given object that represents an option from the combobox. */ + @Input() displayWith: (value: T) => string = (value: T) => `${value}`; + /** Aria label of the combobox. */ + @Input('aria-label') ariaLabel: string; + /** Input that can be used to specify the `aria-labelledby` attribute. */ + @Input('aria-labelledby') ariaLabelledBy: string; + /** Whether the control is focused. (TODO ChMa: implement!) */ + @Input() focused: boolean; + + /** + * Function to compare the option values with the selected values. The first argument + * is a value from an option. The second is a value from the selection. A boolean + * should be returned. + * Defaults to value equality. + */ + @Input() + get compareWith(): (v1: T, v2: T) => boolean { + return this._compareWith; + } + set compareWith(fn: (v1: T, v2: T) => boolean) { + // tslint:disable-next-line:strict-type-predicates + if (typeof fn !== 'function') { + LOG.error(DT_COMPARE_WITH_NON_FUNCTION_VALUE_ERROR_MSG); + } + this._compareWith = fn; + if (this._selectionModel) { + // A different comparator means the selection could change. + this._initializeSelection(); + } + } + private _compareWith = (v1: T, v2: T) => v1 === v2; + + /** Event emitted when a new value has been selected. */ + @Output() valueChange = new EventEmitter(); + /** Event emitted when the selected value has been changed by the user. */ + @Output() readonly selectionChange = new EventEmitter>(); + /** Event emitted when the filter changes. */ + @Output() filterChange = new EventEmitter(); + /** Event emitted when the combobox panel has been toggled. */ + @Output() openedChange = new EventEmitter(); + + /** Combined stream of all of the child options' change events. */ + readonly optionSelectionChanges: Observable< + DtOptionSelectionChange + > = defer(() => { + if (this._options) { + return merge>( + ...this._options.map((option) => option.selectionChange), + ); + } + + return this._ngZone.onStable.asObservable().pipe( + take(1), + switchMap(() => this.optionSelectionChanges), + ); + }); + + /** @internal The trigger of the internal autocomplete trigger */ + @ViewChild('autocompleteTrigger', { static: true }) + _autocompleteTrigger: DtAutocompleteTrigger; + /** @internal The elementRef of the input used internally */ + @ViewChild('searchInput', { static: true }) _searchInput: ElementRef; + /** + * @internal The templateRef used to capture the options passed via ng-content + * to pass through to the autocomplete + */ + @ViewChild('autocompleteContent') _templatePortalContent: TemplateRef; + /** @internal The autocomplete instance that holds all options */ + @ViewChild(DtAutocomplete) _autocomplete: DtAutocomplete; + + /** @internal The options received via ng-content */ + @ContentChildren(DtOption, { descendants: true }) + _options: QueryList>; + + /** @return false if no value is currently selected. */ + get empty(): boolean { + return !this._selectionModel || this._selectionModel.isEmpty(); + } + + /** The currently selected option. */ + get selected(): DtOption { + return this._selectionModel.selected[0]; + } + + /** The value displayed in the trigger. */ + get triggerValue(): string { + return !this.empty ? this._selectionModel.selected[0].viewValue : ''; + } + + /** @internal is false if the autocomplete panel is not shown */ + _panelOpen = false; + + /** @internal `View -> model callback called when value changes` */ + _onChange: (value: any) => void = () => {}; + + /** @internal `View -> model callback called when combobox has been touched` */ + _onTouched = () => {}; + + /** @internal Deals with the selection logic. */ + _selectionModel: SelectionModel>; + + private _reopenAutocomplete = false; + /** Emits whenever the component is destroyed. */ + private readonly _destroy = new Subject(); + + constructor( + public _elementRef: ElementRef, + @Optional() public _defaultErrorStateMatcher: ErrorStateMatcher, + @Optional() public _parentForm: NgForm, + @Optional() public _parentFormGroup: FormGroupDirective, + @Self() @Optional() public ngControl: NgControl, + @Attribute('tabindex') tabIndex: string, + private _viewContainerRef: ViewContainerRef, + private _changeDetectorRef: ChangeDetectorRef, + private _ngZone: NgZone, + ) { + super( + _elementRef, + _defaultErrorStateMatcher, + _parentForm, + _parentFormGroup, + ngControl, + ); + + if (this.ngControl) { + // Note: we provide the value accessor through here, instead of + // the `providers` to avoid running into a circular import. + this.ngControl.valueAccessor = this; + } + + this.tabIndex = parseInt(tabIndex, 10) || 0; + + // Force setter to be called in case id was not specified. + this.id = this.id; + } + + ngOnInit(): void { + this._selectionModel = new SelectionModel>(); + this.stateChanges.next(); + + fromEvent(this._searchInput.nativeElement, 'input') + .pipe( + map((event: KeyboardEvent): string => { + event.stopPropagation(); + return this._searchInput.nativeElement.value; + }), + distinctUntilChanged(), + debounceTime(150), + takeUntil(this._destroy), + ) + .subscribe((query) => this.filterChange.emit(query)); + } + + ngAfterContentInit(): void { + this._selectionModel.changed + .pipe(takeUntil(this._destroy)) + .subscribe((event) => { + event.added.forEach((option) => { + option.select(); + }); + event.removed.forEach((option) => { + option.deselect(); + }); + }); + } + + ngAfterViewInit(): void { + this._autocomplete._additionalPortal = new TemplatePortal( + this._templatePortalContent, + this._viewContainerRef, + ); + this._autocomplete._additionalOptions = this._options.toArray(); + this._autocompleteTrigger.registerOnChange(() => this._onChange); + this._autocompleteTrigger.registerOnTouched(() => this._onTouched); + } + + ngOnDestroy(): void { + this._destroy.next(); + this._destroy.complete(); + } + + /** Toggles the panel containing the options of the combobox */ + toggle(): void { + // TODO: note that currently if the toggle method is called inside an event handler + // e.g. onclick of a button and the event is not stopped from bubbling to the document + // the click outside event stream in the autocomplete fires and immediately closes the overlay after opening + if (this._panelOpen) { + this.close(); + } else { + this.open(); + } + } + + /** Opens the panel containing the options of the combobox */ + open(): void { + this._panelOpen = true; + this._autocompleteTrigger.openPanel(); // implicitly triggers _opened() + this._changeDetectorRef.markForCheck(); + } + + /** Closes the panel containing the options of the combobox */ + close(): void { + this._panelOpen = false; + this._autocompleteTrigger.closePanel(); // implicitly triggers _closed() + this._changeDetectorRef.markForCheck(); + } + + /** Sets the list of element IDs that currently describe this control. */ + setDescribedByIds(_: string[]): void { + // TODO ChMa: implement (what does this even do?) + } + + /** Handles a click on the control's container. */ + onContainerClick(_: MouseEvent): void { + // TODO ChMa: implement (do we even need this handler?) + } + + /** Sets the combobox's value. Part of the ControlValueAccessor. */ + writeValue(value: T): void { + if (this._options) { + this._setSelectionByValue(value); + if (this._autocompleteTrigger) { + this._autocompleteTrigger.writeValue(value); + } + } + } + + /** + * Saves a callback function to be invoked when the select's value + * changes from user input. Part of the ControlValueAccessor. + */ + registerOnChange(fn: (value: any) => {}): void { + this._onChange = fn; + this._autocompleteTrigger.registerOnChange(fn); + } + + /** + * Saves a callback function to be invoked when the combobox is blurred + * by the user. Part of the ControlValueAccessor. + */ + registerOnTouched(fn: () => {}): void { + this._onTouched = fn; + this._autocompleteTrigger.registerOnTouched(fn); + } + + /** Disables the combobox. Part of the ControlValueAccessor. */ + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this._changeDetectorRef.markForCheck(); + this.stateChanges.next(); + } + + /** @internal called when the user selects a different option */ + _optionSelected(event: DtAutocompleteSelectedEvent): void { + const option = event.option; + const wasSelected = this._selectionModel.isSelected(option); + + if (isDefined(option.value)) { + if (option.selected) { + this._selectionModel.select(option); + } else { + this._selectionModel.deselect(option); + } + } else { + option.deselect(); + this._selectionModel.clear(); + this._propagateChanges(option.value); + } + + if (wasSelected !== this._selectionModel.isSelected(option)) { + this._propagateChanges(); + } + + this.stateChanges.next(); + + this._changeDetectorRef.markForCheck(); + } + + /** + * @internal called when user clicks on trigger, + * stops event propagation to handle toggling correctly on the trigger + */ + _toggle(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + + this.toggle(); + } + + /** @internal called when dt-autocomplete emits an open event */ + _opened(): void { + this._panelOpen = true; + this.openedChange.emit(true); + this._changeDetectorRef.markForCheck(); + } + + /** @internal called when dt-autocomplete emits a close event */ + _closed(): void { + this._panelOpen = false; + this.openedChange.emit(false); + this._changeDetectorRef.markForCheck(); + } + + /** Emits change event to set the model value. */ + private _propagateChanges(fallbackValue?: T): void { + const valueToEmit = this.selected ? this.selected.value : fallbackValue; + this._value = valueToEmit!; + this.valueChange.emit(valueToEmit); + this._onChange(valueToEmit!); + this.selectionChange.emit(new DtComboboxChange(this, valueToEmit!)); + this._changeDetectorRef.markForCheck(); + } + + /** Handles the initial value selection */ + private _initializeSelection(): void { + // Defer setting the value in order to avoid the "Expression + // has changed after it was checked" errors from Angular. + Promise.resolve().then(() => { + this._setSelectionByValue( + this.ngControl ? this.ngControl.value : this._value, + ); + }); + } + + /** Updates the selection by value using selection model and keymanager to handle the active item */ + private _setSelectionByValue(value: T): void { + this._selectionModel.clear(); + const correspondingOption = this._selectValue(value); + + // Shift focus to the active item. Note that we shouldn't do this in multiple + // mode, because we don't know what option the user interacted with last. + if (correspondingOption && this._autocomplete?._keyManager) { + this._autocomplete._keyManager.setActiveItem(correspondingOption); + } + + this._changeDetectorRef.markForCheck(); + } + + /** Searches for an option matching the value and selects it if found */ + private _selectValue(value: T): DtOption | undefined { + const correspondingOption = this._options.find((option: DtOption) => { + try { + // Treat null as a special reset value. + return ( + isDefined(option.value) && this._compareWith(option.value, value) + ); + } catch (error) { + // Notify developers of errors in their comparator. + LOG.warn(error); + return false; + } + }); + + if (correspondingOption) { + this._selectionModel.select(correspondingOption); + } + + return correspondingOption; + } +} diff --git a/libs/barista-components/experimental/combobox/src/test-setup.ts b/libs/barista-components/experimental/combobox/src/test-setup.ts new file mode 100644 index 0000000000..3c66e43d72 --- /dev/null +++ b/libs/barista-components/experimental/combobox/src/test-setup.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'jest-preset-angular'; diff --git a/libs/barista-components/experimental/combobox/tsconfig.json b/libs/barista-components/experimental/combobox/tsconfig.json new file mode 100644 index 0000000000..a6233e13a0 --- /dev/null +++ b/libs/barista-components/experimental/combobox/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*.ts"] +} diff --git a/libs/barista-components/experimental/combobox/tsconfig.lib.json b/libs/barista-components/experimental/combobox/tsconfig.lib.json new file mode 100644 index 0000000000..b639cbf40a --- /dev/null +++ b/libs/barista-components/experimental/combobox/tsconfig.lib.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "target": "es2015", + "declaration": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "enableResourceInlining": true + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts"] +} diff --git a/libs/barista-components/experimental/combobox/tsconfig.spec.json b/libs/barista-components/experimental/combobox/tsconfig.spec.json new file mode 100644 index 0000000000..aed68bc6bf --- /dev/null +++ b/libs/barista-components/experimental/combobox/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/barista-components/experimental/combobox/tslint.json b/libs/barista-components/experimental/combobox/tslint.json new file mode 100644 index 0000000000..b20a356378 --- /dev/null +++ b/libs/barista-components/experimental/combobox/tslint.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../../tslint.json", + "rules": {} +} diff --git a/libs/barista-components/select/src/select.ts b/libs/barista-components/select/src/select.ts index 13be13ba2d..e61f07a95a 100644 --- a/libs/barista-components/select/src/select.ts +++ b/libs/barista-components/select/src/select.ts @@ -97,6 +97,7 @@ import { DT_UI_TEST_CONFIG, DtUiTestConfiguration, dtSetUiTestAttribute, + DT_COMPARE_WITH_NON_FUNCTION_VALUE_ERROR_MSG, } from '@dynatrace/barista-components/core'; import { DtFormField, @@ -108,7 +109,7 @@ let uniqueId = 0; const LOG: DtLogger = DtLoggerFactory.create('DtSelect'); /** The height of the select items. */ -export const SELECT_ITEM_HEIGHT = 32; +export const SELECT_ITEM_HEIGHT = 28; /** The max height of the select's overlay panel */ export const SELECT_PANEL_MAX_HEIGHT = 256; @@ -137,9 +138,12 @@ export const _DtSelectMixinBase = mixinTabIndex( mixinDisabled(mixinErrorState(DtSelectBase)), ); -export function getDtSelectNonFunctionValueError(): Error { - return Error('`compareWith` must be a function.'); -} +/** + * @deprecated Will be removed with v8.0.0 - will not throw an Error after 8.0.0 but instead use the Logger + * see combobox compareWith + */ +export const getDtSelectNonFunctionValueError = () => + new Error(DT_COMPARE_WITH_NON_FUNCTION_VALUE_ERROR_MSG); @Component({ selector: 'dt-select', @@ -882,8 +886,7 @@ export class DtSelect extends _DtSelectMixinBase : this._getOptionIndex(this._selectionModel.selected[0])!; selectedOptionOffset += _countGroupLabelsBeforeOption( selectedOptionOffset, - this.options, - this.optionGroups, + this.options.toArray(), ); // We must maintain a scroll buffer so the selected option will be scrolled to the @@ -920,8 +923,7 @@ export class DtSelect extends _DtSelectMixinBase const activeOptionIndex = this._keyManager.activeItemIndex || 0; const labelCount = _countGroupLabelsBeforeOption( activeOptionIndex, - this.options, - this.optionGroups, + this.options.toArray(), ); this.panel.nativeElement.scrollTop = _getOptionScrollPosition( diff --git a/libs/examples/src/combobox/combobox-examples.module.ts b/libs/examples/src/combobox/combobox-examples.module.ts new file mode 100644 index 0000000000..71f9f6c30a --- /dev/null +++ b/libs/examples/src/combobox/combobox-examples.module.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NgModule } from '@angular/core'; +import { DtComboboxModule } from '@dynatrace/barista-components/experimental/combobox'; +import { DtExampleComboboxSimple } from './combobox-simple-example/combobox-simple-example'; +import { DtOptionModule } from '@dynatrace/barista-components/core'; +import { CommonModule } from '@angular/common'; + +export const DT_COMBOBOX_EXAMPLES = [DtExampleComboboxSimple]; + +@NgModule({ + imports: [DtComboboxModule, DtOptionModule, CommonModule], + declarations: [...DT_COMBOBOX_EXAMPLES], + entryComponents: [...DT_COMBOBOX_EXAMPLES], +}) +export class DtComboboxExamplesModule {} diff --git a/libs/examples/src/combobox/combobox-simple-example/combobox-simple-example.html b/libs/examples/src/combobox/combobox-simple-example/combobox-simple-example.html new file mode 100644 index 0000000000..173752ed4f --- /dev/null +++ b/libs/examples/src/combobox/combobox-simple-example/combobox-simple-example.html @@ -0,0 +1,15 @@ + + + {{ option.name }} + + diff --git a/libs/examples/src/combobox/combobox-simple-example/combobox-simple-example.ts b/libs/examples/src/combobox/combobox-simple-example/combobox-simple-example.ts new file mode 100644 index 0000000000..ee2b49f4a8 --- /dev/null +++ b/libs/examples/src/combobox/combobox-simple-example/combobox-simple-example.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChangeDetectorRef, Component } from '@angular/core'; +import { take } from 'rxjs/operators'; +import { timer } from 'rxjs'; + +const allOptions: { name: string; value: string }[] = [ + { name: 'Value 1', value: '[value: Value 1]' }, + { name: 'Value 2', value: '[value: Value 2]' }, + { name: 'Value 3', value: '[value: Value 3]' }, + { name: 'Value 4', value: '[value: Value 4]' }, +]; + +@Component({ + selector: 'dt-example-simple-combobox', + templateUrl: './combobox-simple-example.html', +}) +export class DtExampleComboboxSimple { + _initialValue = allOptions[0]; + _options = [...allOptions]; + _loading = false; + _displayWith = (option: { name: string; value: string }) => option.name; + + constructor(private _changeDetectorRef: ChangeDetectorRef) {} + + openedChanged(event: boolean): void { + console.log(`openedChanged: '${event}'`); + } + + valueChanged(event: string): void { + console.log(`valueChanged: '${event}'`); + } + + filterChanged(event: string): void { + console.log(`filterChanged: '${event}'`); + + this._loading = true; + this._changeDetectorRef.markForCheck(); + + timer(1500) + .pipe(take(1)) + .subscribe(() => { + this._options = allOptions.filter( + (option) => + option.value.toLowerCase().indexOf(event.toLowerCase()) >= 0, + ); + this._loading = false; + this._changeDetectorRef.markForCheck(); + }); + } +} diff --git a/libs/examples/src/combobox/index.ts b/libs/examples/src/combobox/index.ts new file mode 100644 index 0000000000..ee7cdf7c8e --- /dev/null +++ b/libs/examples/src/combobox/index.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './combobox-simple-example/combobox-simple-example'; +export * from './combobox-examples.module'; diff --git a/libs/examples/src/examples.module.ts b/libs/examples/src/examples.module.ts index 8109555aa7..96585bff5f 100644 --- a/libs/examples/src/examples.module.ts +++ b/libs/examples/src/examples.module.ts @@ -30,6 +30,7 @@ import { DtButtonGroupExamplesModule } from './button-group/button-group-example import { DtCardExamplesModule } from './card/card-examples.module'; import { DtChartExamplesModule } from './chart/chart-examples.module'; import { DtCheckboxExamplesModule } from './checkbox/checkbox-examples.module'; +import { DtComboboxExamplesModule } from './combobox/combobox-examples.module'; import { DtConfirmationDialogExamplesModule } from './confirmation-dialog/confirmation-dialog-examples.module'; import { DtConsumptionExamplesModule } from './consumption/consumption-examples.module'; import { DtContainerBreakpointObserverExamplesModule } from './container-breakpoint-observer/container-breakpoint-observer-examples.module'; @@ -96,6 +97,7 @@ import { DtExamplesTreeTableModule } from './tree-table/tree-table-examples.modu DtCardExamplesModule, DtChartExamplesModule, DtCheckboxExamplesModule, + DtComboboxExamplesModule, DtConfirmationDialogExamplesModule, DtConsumptionExamplesModule, DtContainerBreakpointObserverExamplesModule, diff --git a/libs/examples/src/index.ts b/libs/examples/src/index.ts index 2342e31e6c..3e0decc7bc 100644 --- a/libs/examples/src/index.ts +++ b/libs/examples/src/index.ts @@ -84,6 +84,7 @@ import { DtExampleCheckboxDark } from './checkbox/checkbox-dark-example/checkbox import { DtExampleCheckboxDefault } from './checkbox/checkbox-default-example/checkbox-default-example'; import { DtExampleCheckboxIndeterminate } from './checkbox/checkbox-indeterminate-example/checkbox-indeterminate-example'; import { DtExampleCheckboxResponsive } from './checkbox/checkbox-responsive-example/checkbox-responsive-example'; +import { DtExampleComboboxSimple } from './combobox/combobox-simple-example/combobox-simple-example'; import { DtExampleConfirmationDialogDefault } from './confirmation-dialog/confirmation-dialog-default-example/confirmation-dialog-default-example'; import { DtExampleConfirmationDialogShowBackdrop } from './confirmation-dialog/confirmation-dialog-show-backdrop-example/confirmation-dialog-show-backdrop-example'; import { DtExampleConsumptionDefault } from './consumption/consumption-default-example/consumption-default-example'; @@ -331,6 +332,7 @@ export { DtButtonGroupExamplesModule } from './button-group/button-group-example export { DtCardExamplesModule } from './card/card-examples.module'; export { DtChartExamplesModule } from './chart/chart-examples.module'; export { DtCheckboxExamplesModule } from './checkbox/checkbox-examples.module'; +export { DtComboboxExamplesModule } from './combobox/combobox-examples.module'; export { DtConfirmationDialogExamplesModule } from './confirmation-dialog/confirmation-dialog-examples.module'; export { DtConsumptionExamplesModule } from './consumption/consumption-examples.module'; export { DtContainerBreakpointObserverExamplesModule } from './container-breakpoint-observer/container-breakpoint-observer-examples.module'; @@ -445,6 +447,7 @@ export { DtExampleCheckboxDefault, DtExampleCheckboxIndeterminate, DtExampleCheckboxResponsive, + DtExampleComboboxSimple, DtExampleConfirmationDialogDefault, DtExampleConfirmationDialogShowBackdrop, DtExampleConsumptionDefault, @@ -750,6 +753,7 @@ export const EXAMPLES_MAP = new Map>([ ['DtExampleCheckboxDefault', DtExampleCheckboxDefault], ['DtExampleCheckboxIndeterminate', DtExampleCheckboxIndeterminate], ['DtExampleCheckboxResponsive', DtExampleCheckboxResponsive], + ['DtExampleComboboxSimple', DtExampleComboboxSimple], ['DtExampleConfirmationDialogDefault', DtExampleConfirmationDialogDefault], [ 'DtExampleConfirmationDialogShowBackdrop', diff --git a/nx.json b/nx.json index d8d2f4f9f9..339704ce71 100644 --- a/nx.json +++ b/nx.json @@ -75,6 +75,7 @@ "card", "chart", "checkbox", + "combobox", "confirmation-dialog", "consumption", "container-breakpoint-observer", @@ -158,6 +159,9 @@ "checkbox": { "tags": ["scope:components", "type:library"] }, + "combobox": { + "tags": ["scope:components", "type:library"] + }, "confirmation-dialog": { "tags": ["scope:components", "type:library"] }, diff --git a/tsconfig.json b/tsconfig.json index ba5463e847..8ffdccdf49 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,6 +47,9 @@ "@dynatrace/barista-components/checkbox": [ "libs/barista-components/checkbox/index.ts" ], + "@dynatrace/barista-components/experimental/combobox": [ + "libs/barista-components/experimental/combobox/index.ts" + ], "@dynatrace/barista-components/confirmation-dialog": [ "libs/barista-components/confirmation-dialog/index.ts" ],