diff --git a/src/cdk-experimental/combobox/BUILD.bazel b/src/cdk-experimental/combobox/BUILD.bazel index a7fd868955e5..f6de49a05921 100644 --- a/src/cdk-experimental/combobox/BUILD.bazel +++ b/src/cdk-experimental/combobox/BUILD.bazel @@ -25,6 +25,7 @@ ng_test_library( ), deps = [ ":combobox", + "//src/cdk/keycodes", "//src/cdk/testing/private", "@npm//@angular/platform-browser", ], diff --git a/src/cdk-experimental/combobox/combobox-module.ts b/src/cdk-experimental/combobox/combobox-module.ts index 58e1cf7a911a..b8ac8c7782bc 100644 --- a/src/cdk-experimental/combobox/combobox-module.ts +++ b/src/cdk-experimental/combobox/combobox-module.ts @@ -10,8 +10,9 @@ import {NgModule} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; import {CdkCombobox} from './combobox'; import {CdkComboboxPanel} from './combobox-panel'; +import {CdkComboboxPopup} from './combobox-popup'; -const EXPORTED_DECLARATIONS = [CdkCombobox, CdkComboboxPanel]; +const EXPORTED_DECLARATIONS = [CdkCombobox, CdkComboboxPanel, CdkComboboxPopup]; @NgModule({ imports: [OverlayModule], exports: EXPORTED_DECLARATIONS, diff --git a/src/cdk-experimental/combobox/combobox-panel.ts b/src/cdk-experimental/combobox/combobox-panel.ts index 51e6aa55cd7b..cf2dedef638c 100644 --- a/src/cdk-experimental/combobox/combobox-panel.ts +++ b/src/cdk-experimental/combobox/combobox-panel.ts @@ -12,6 +12,9 @@ import {Directive, TemplateRef} from '@angular/core'; import {Subject} from 'rxjs'; @Directive({ + host: { + 'class': 'cdk-combobox-panel' + }, selector: 'ng-template[cdkComboboxPanel]', exportAs: 'cdkComboboxPanel', }) @@ -24,13 +27,21 @@ export class CdkComboboxPanel { contentId: string = ''; contentType: AriaHasPopupValue; - constructor(readonly _templateRef: TemplateRef) {} + constructor( + readonly _templateRef: TemplateRef + ) {} - /** Tells the parent combobox to closet he panel and sends back the content value. */ + /** Tells the parent combobox to close the panel and sends back the content value. */ closePanel(data?: T) { this.valueUpdated.next(data); } + // TODO: instead of using a focus function, potentially use cdk/a11y focus trapping + focusContent() { + // TODO: Use an injected document here + document.getElementById(this.contentId)?.focus(); + } + /** Registers the content's id and the content type with the panel. */ _registerContent(contentId: string, contentType: AriaHasPopupValue) { this.contentId = contentId; diff --git a/src/cdk-experimental/combobox/combobox-popup.ts b/src/cdk-experimental/combobox/combobox-popup.ts new file mode 100644 index 000000000000..f00b14d7fcec --- /dev/null +++ b/src/cdk-experimental/combobox/combobox-popup.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, Inject, InjectionToken, Input, OnInit, Optional} from '@angular/core'; +import {AriaHasPopupValue, CdkComboboxPanel} from './combobox-panel'; + +export const PANEL = new InjectionToken('CdkComboboxPanel'); + +let nextId = 0; + +@Directive({ + selector: '[cdkComboboxPopup]', + exportAs: 'cdkComboboxPopup', + host: { + 'class': 'cdk-combobox-popup', + '[attr.role]': 'role', + '[id]': 'id', + 'tabindex': '-1' + } +}) +export class CdkComboboxPopup implements OnInit { + @Input() + get role(): AriaHasPopupValue { + return this._role; + } + set role(value: AriaHasPopupValue) { + this._role = value; + } + private _role: AriaHasPopupValue = 'dialog'; + + @Input() id = `cdk-combobox-popup-${nextId++}`; + + @Input('parentPanel') private readonly _explicitPanel: CdkComboboxPanel; + + constructor( + @Optional() @Inject(PANEL) readonly _parentPanel?: CdkComboboxPanel, + ) { } + + ngOnInit() { + this.registerWithPanel(); + } + + registerWithPanel(): void { + if (this._parentPanel === null || this._parentPanel === undefined) { + this._explicitPanel._registerContent(this.id, this._role); + } else { + this._parentPanel._registerContent(this.id, this._role); + } + } +} diff --git a/src/cdk-experimental/combobox/combobox.spec.ts b/src/cdk-experimental/combobox/combobox.spec.ts index 5be8db219563..cba1ab7c1562 100644 --- a/src/cdk-experimental/combobox/combobox.spec.ts +++ b/src/cdk-experimental/combobox/combobox.spec.ts @@ -1,22 +1,44 @@ -import {Component, DebugElement} from '@angular/core'; +import { + Component, + DebugElement, + Directive, ElementRef, + Inject, + InjectionToken, + Input, + OnInit, + Optional, + ViewChild +} from '@angular/core'; import {ComponentFixture, TestBed, async} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {CdkComboboxModule} from './combobox-module'; import {CdkCombobox} from './combobox'; -import {dispatchMouseEvent} from '@angular/cdk/testing/private'; +import {dispatchKeyboardEvent, dispatchMouseEvent} from '@angular/cdk/testing/private'; +import { + AriaHasPopupValue, + CdkComboboxPanel} from '@angular/cdk-experimental/combobox/combobox-panel'; +import {DOWN_ARROW, ESCAPE} from '@angular/cdk/keycodes'; describe('Combobox', () => { describe('with a basic toggle trigger', () => { let fixture: ComponentFixture; + let testComponent: ComboboxToggle; let combobox: DebugElement; let comboboxInstance: CdkCombobox; let comboboxElement: HTMLElement; + let dialog: DebugElement; + let dialogInstance: FakeDialogContent; + let dialogElement: HTMLElement; + + let applyButton: DebugElement; + let applyButtonElement: HTMLElement; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [CdkComboboxModule], - declarations: [ComboboxToggle], + declarations: [ComboboxToggle, FakeDialogContent], }).compileComponents(); })); @@ -24,6 +46,8 @@ describe('Combobox', () => { fixture = TestBed.createComponent(ComboboxToggle); fixture.detectChanges(); + testComponent = fixture.debugElement.componentInstance; + combobox = fixture.debugElement.query(By.directive(CdkCombobox)); comboboxInstance = combobox.injector.get>(CdkCombobox); comboboxElement = combobox.nativeElement; @@ -45,6 +69,54 @@ describe('Combobox', () => { expect(comboboxElement.getAttribute('aria-disabled')).toBe('false'); }); + it('should have aria-owns and aria-haspopup attributes', () => { + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + dialog = fixture.debugElement.query(By.directive(FakeDialogContent)); + dialogInstance = dialog.injector.get>(FakeDialogContent); + + expect(comboboxElement.getAttribute('aria-owns')).toBe(dialogInstance.dialogId); + expect(comboboxElement.getAttribute('aria-haspopup')).toBe('dialog'); + }); + + it('should update aria-expanded attribute upon toggle of panel', () => { + expect(comboboxElement.getAttribute('aria-expanded')).toBe('false'); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxElement.getAttribute('aria-expanded')).toBe('true'); + + comboboxInstance.close(); + fixture.detectChanges(); + + expect(comboboxElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should toggle focus upon toggling the panel', () => { + comboboxElement.focus(); + testComponent.actions = 'toggle'; + fixture.detectChanges(); + + expect(document.activeElement).toEqual(comboboxElement); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + + dialog = fixture.debugElement.query(By.directive(FakeDialogContent)); + dialogElement = dialog.nativeElement; + + expect(document.activeElement).toBe(dialogElement); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(document.activeElement).not.toEqual(dialogElement); + }); + it('should have a panel that is closed by default', () => { expect(comboboxInstance.hasPanel()).toBeTrue(); expect(comboboxInstance.isOpen()).toBeFalse(); @@ -69,21 +141,290 @@ describe('Combobox', () => { expect(comboboxInstance.isOpen()).toBeFalse(); }); + + it('should update textContent on close of panel', () => { + expect(comboboxInstance.isOpen()).toBeFalse(); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + + testComponent.inputElement.nativeElement.value = 'testing input'; + fixture.detectChanges(); + + applyButton = fixture.debugElement.query(By.css('#applyButton')); + applyButtonElement = applyButton.nativeElement; + + dispatchMouseEvent(applyButtonElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + expect(comboboxElement.textContent).toEqual('testing input'); + }); + + it('should close panel on outside click', () => { + expect(comboboxInstance.isOpen()).toBeFalse(); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + + const otherDiv = fixture.debugElement.query(By.css('#other-content')); + const otherDivElement = otherDiv.nativeElement; + + dispatchMouseEvent(otherDivElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + }); + }); + + describe('with a coerce open action property function', () => { + let fixture: ComponentFixture; + let testComponent: ComboboxToggle; + + let combobox: DebugElement; + let comboboxInstance: CdkCombobox; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkComboboxModule], + declarations: [ComboboxToggle, FakeDialogContent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ComboboxToggle); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + combobox = fixture.debugElement.query(By.directive(CdkCombobox)); + comboboxInstance = combobox.injector.get>(CdkCombobox); + }); + + it('should coerce single string into open action', () => { + const openActions = comboboxInstance.openActions; + expect(openActions.length).toBe(1); + expect(openActions[0]).toBe('click'); + }); + + it('should coerce actions separated by space', () => { + testComponent.actions = 'focus click'; + fixture.detectChanges(); + + const openActions = comboboxInstance.openActions; + expect(openActions.length).toBe(2); + expect(openActions[0]).toBe('focus'); + expect(openActions[1]).toBe('click'); + }); + + it('should coerce actions separated by comma', () => { + testComponent.actions = 'focus,click,downKey'; + fixture.detectChanges(); + + const openActions = comboboxInstance.openActions; + expect(openActions.length).toBe(3); + expect(openActions[0]).toBe('focus'); + expect(openActions[1]).toBe('click'); + expect(openActions[2]).toBe('downKey'); + }); + + it('should coerce actions separated by commas and spaces', () => { + testComponent.actions = 'focus click,downKey'; + fixture.detectChanges(); + + const openActions = comboboxInstance.openActions; + expect(openActions.length).toBe(3); + expect(openActions[0]).toBe('focus'); + expect(openActions[1]).toBe('click'); + expect(openActions[2]).toBe('downKey'); + }); + + it('should throw error when given invalid open action', () => { + expect(() => { + testComponent.actions = 'invalidAction'; + fixture.detectChanges(); + }).toThrow(); + }); }); + describe('with various open actions', () => { + let fixture: ComponentFixture; + let testComponent: ComboboxToggle; + + let combobox: DebugElement; + let comboboxInstance: CdkCombobox; + let comboboxElement: HTMLElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkComboboxModule], + declarations: [ComboboxToggle, FakeDialogContent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ComboboxToggle); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + combobox = fixture.debugElement.query(By.directive(CdkCombobox)); + comboboxInstance = combobox.injector.get>(CdkCombobox); + comboboxElement = combobox.nativeElement; + }); + + it('should open panel with focus open action', () => { + testComponent.actions = 'focus'; + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + + comboboxElement.focus(); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + }); + + it('should open panel with click open action', () => { + testComponent.actions = 'click'; + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + }); + + it('should open panel with downKey open action', () => { + testComponent.actions = 'downKey'; + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + + dispatchKeyboardEvent(comboboxElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + }); + + it('should toggle panel with toggle open action', () => { + testComponent.actions = 'toggle'; + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + }); + + it('should close panel on escape key', () => { + testComponent.actions = 'click'; + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + + dispatchKeyboardEvent(comboboxElement, 'keydown', ESCAPE); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + }); + + it('should handle multiple open actions', () => { + testComponent.actions = 'click downKey'; + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + + dispatchMouseEvent(comboboxElement, 'click'); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + + dispatchKeyboardEvent(comboboxElement, 'keydown', ESCAPE); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeFalse(); + + dispatchKeyboardEvent(comboboxElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(comboboxInstance.isOpen()).toBeTrue(); + }); + }); }); @Component({ template: ` - +
- Panel Content +
+ + +
`, }) class ComboboxToggle { + @ViewChild('input') inputElement: ElementRef; + + actions: string = 'click'; +} + +export const PANEL = new InjectionToken('CdkComboboxPanel'); + +let id = 0; + +@Directive({ + selector: '[dialogContent]', + exportAs: 'dialogContent', + host: { + '[attr.role]': 'role', + '[id]': 'dialogId', + 'tabIndex': '-1' + } +}) +export class FakeDialogContent implements OnInit { + + dialogId = `dialog-${id++}`; + role = 'dialog'; + + @Input('parentPanel') private readonly _explicitPanel: CdkComboboxPanel; + + constructor( + @Optional() @Inject(PANEL) readonly _parentPanel?: CdkComboboxPanel, + ) { } + + ngOnInit() { + this.registerWithPanel(); + } + + registerWithPanel(): void { + if (this._parentPanel === null || this._parentPanel === undefined) { + this._explicitPanel._registerContent(this.dialogId, this.role as AriaHasPopupValue); + } else { + this._parentPanel._registerContent(this.dialogId, this.role as AriaHasPopupValue); + } + } } diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index 107440634f9f..11c06a663ce6 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ + export type OpenAction = 'focus' | 'click' | 'downKey' | 'toggle'; export type OpenActionInput = OpenAction | OpenAction[] | string | null | undefined; @@ -14,7 +15,7 @@ import { Directive, ElementRef, EventEmitter, - Input, + Input, isDevMode, OnDestroy, Optional, Output, ViewContainerRef @@ -29,18 +30,26 @@ import { OverlayRef } from '@angular/cdk/overlay'; import {Directionality} from '@angular/cdk/bidi'; -import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {BooleanInput, coerceBooleanProperty, coerceArray} from '@angular/cdk/coercion'; +import {DOWN_ARROW, ESCAPE} from '@angular/cdk/keycodes'; +const allowedOpenActions = ['focus', 'click', 'downKey', 'toggle']; @Directive({ selector: '[cdkCombobox]', exportAs: 'cdkCombobox', host: { 'role': 'combobox', - '(click)': 'toggle()', + 'class': 'cdk-combobox', + '(click)': '_handleInteractions("click")', + '(focus)': '_handleInteractions("focus")', + '(keydown)': '_keydown($event)', + '(document:click)': '_attemptClose($event)', '[attr.aria-disabled]': 'disabled', - '[attr.aria-controls]': 'contentId', - '[attr.aria-haspopup]': 'contentType' + '[attr.aria-owns]': 'contentId', + '[attr.aria-haspopup]': 'contentType', + '[attr.aria-expanded]': 'isOpen()', + '[attr.tabindex]': '_getTabIndex()' } }) export class CdkCombobox implements OnDestroy, AfterContentInit { @@ -103,6 +112,45 @@ export class CdkCombobox implements OnDestroy, AfterContentInit { this.panelValueChanged.complete(); } + _keydown(event: KeyboardEvent) { + const {keyCode} = event; + + if (keyCode === DOWN_ARROW && this._openActions.indexOf('downKey') !== -1) { + this.open(); + } else if (keyCode === ESCAPE) { + event.preventDefault(); + this.close(); + } + } + + _handleInteractions(interaction: OpenAction) { + if (interaction === 'click') { + if (this._openActions.indexOf('toggle') !== -1) { + this.toggle(); + } else if (this._openActions.indexOf('click') !== -1) { + this.open(); + } + } else if (interaction === 'focus') { + if (this._openActions.indexOf('focus') !== -1) { + this.open(); + } + } + } + + _attemptClose(event: MouseEvent) { + if (this.isOpen()) { + let target = event.composedPath ? event.composedPath()[0] : event.target; + while (target instanceof Element) { + if (target.className.indexOf('cdk-combobox') !== -1) { + return; + } + target = target.parentElement; + } + } + + this.close(); + } + /** Toggles the open state of the panel. */ toggle() { if (this.hasPanel()) { @@ -116,6 +164,7 @@ export class CdkCombobox implements OnDestroy, AfterContentInit { this.opened.next(); this._overlayRef = this._overlayRef || this._overlay.create(this._getOverlayConfig()); this._overlayRef.attach(this._getPanelContent()); + this._panel?.focusContent(); } } @@ -137,7 +186,12 @@ export class CdkCombobox implements OnDestroy, AfterContentInit { return !!this.panel; } + _getTabIndex(): string | null { + return this.disabled ? null : '0'; + } + private _setComboboxValue(value: T) { + const valueChanged = (this.value !== value); this.value = value; @@ -148,9 +202,8 @@ export class CdkCombobox implements OnDestroy, AfterContentInit { } private _setTextContent(content: T) { - if (typeof content === 'string') { - this._elementRef.nativeElement.textContent = `${content}`; - } + const contentArray = coerceArray(content); + this._elementRef.nativeElement.textContent = contentArray.join(' '); } private _getOverlayConfig() { @@ -187,13 +240,11 @@ export class CdkCombobox implements OnDestroy, AfterContentInit { } private _coerceOpenActionProperty(input: string | OpenAction[]): OpenAction[] { - let actions: OpenAction[] = []; - if (typeof input === 'string') { - actions.push(input as OpenAction); - } else { - actions = input; + let actions = typeof input === 'string' ? input.trim().split(/[ ,]+/) : input; + if (isDevMode() && actions.some(a => allowedOpenActions.indexOf(a) === -1)) { + throw Error(`${input} is not a support open action for CdkCombobox`); } - return actions; + return actions as OpenAction[]; } static ngAcceptInputType_openActions: OpenActionInput; diff --git a/src/cdk-experimental/combobox/public-api.ts b/src/cdk-experimental/combobox/public-api.ts index ddc82a9468b3..07eadcfa2ec1 100644 --- a/src/cdk-experimental/combobox/public-api.ts +++ b/src/cdk-experimental/combobox/public-api.ts @@ -9,3 +9,4 @@ export * from './combobox-module'; export * from './combobox'; export * from './combobox-panel'; +export * from './combobox-popup';