Skip to content

Combobox base #20211

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Aug 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
06737c4
build: Added required files to listbox directory.
nielsr98 Jun 11, 2020
24c1ca6
build: added listbox option directive and renamed listbox directive f…
nielsr98 Jun 11, 2020
86d7b58
build: Added required files to listbox directory.
nielsr98 Jun 11, 2020
2e8ae02
build: added listbox option directive and renamed listbox directive f…
nielsr98 Jun 11, 2020
4c290e3
build: Added required files to listbox directory.
nielsr98 Jun 11, 2020
43a3d93
build: added listbox option directive and renamed listbox directive f…
nielsr98 Jun 11, 2020
906dea3
build: Added required files to listbox directory.
nielsr98 Jun 11, 2020
c289a7d
build: added listbox option directive and renamed listbox directive f…
nielsr98 Jun 11, 2020
4ac224b
feat(dev-app/listbox): added cdk listbox example to the dev-app.
nielsr98 Jul 15, 2020
df2e62f
fix(listbox): removed duplicate dep in dev-app build file.
nielsr98 Jul 22, 2020
f72129c
fix(listbox): deleted unused files.
nielsr98 Aug 4, 2020
a739263
feat(combobox): added all basic features to combobox and combobox pan…
nielsr98 Aug 6, 2020
0d0dea8
feat(combobox): finished basic tests and clean up.
nielsr98 Aug 6, 2020
a2a9c9b
fix(combobox): fixed the import path for coercion.
nielsr98 Aug 6, 2020
f5314db
fix(combobox): fixed import path for combobox panel.
nielsr98 Aug 6, 2020
cc2cb62
fix(combobox): fixed lint errors throughout.
nielsr98 Aug 6, 2020
5922cc9
refactor(combobox): removed unused panel test file.
nielsr98 Aug 6, 2020
a768b2c
refactor(combobox): changed import path for panel in combobox.
nielsr98 Aug 6, 2020
10d038c
refactor(combobox): cleaned up Inputs in combobox and moved aria-hasp…
nielsr98 Aug 7, 2020
a0e5d78
refactor(combobox): added jsdoc to the public functions.
nielsr98 Aug 7, 2020
a6f8750
fix(combobox): removed duplicate ContentType type and getPanelContent…
nielsr98 Aug 7, 2020
856b5c3
refactor(combobox): made contentId and contentType of combobox public.
nielsr98 Aug 7, 2020
f79781c
refactor(combobox): changed names and made coerceOpenActionProperty s…
nielsr98 Aug 10, 2020
681c9d7
fix(combobox): updated syntax for casting.
nielsr98 Aug 10, 2020
36b297a
refactor(combobox): changed casting syntax back.
nielsr98 Aug 10, 2020
9673d33
fix(combobox): fixed trailing whitespace.
nielsr98 Aug 10, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/cdk-experimental/combobox/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("//tools:defaults.bzl", "ng_module")
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")

package(default_visibility = ["//visibility:public"])

Expand All @@ -9,4 +9,28 @@ ng_module(
exclude = ["**/*.spec.ts"],
),
module_name = "@angular/cdk-experimental/combobox",
deps = [
"//src/cdk/a11y",
"//src/cdk/bidi",
"//src/cdk/collections",
"//src/cdk/overlay",
],
)

ng_test_library(
name = "unit_test_sources",
srcs = glob(
["**/*.spec.ts"],
exclude = ["**/*.e2e.spec.ts"],
),
deps = [
":combobox",
"//src/cdk/testing/private",
"@npm//@angular/platform-browser",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_test_sources"],
)
2 changes: 2 additions & 0 deletions src/cdk-experimental/combobox/combobox-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
*/

import {NgModule} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
import {CdkCombobox} from './combobox';
import {CdkComboboxPanel} from './combobox-panel';

const EXPORTED_DECLARATIONS = [CdkCombobox, CdkComboboxPanel];
@NgModule({
imports: [OverlayModule],
exports: EXPORTED_DECLARATIONS,
declarations: EXPORTED_DECLARATIONS,
})
Expand Down
30 changes: 29 additions & 1 deletion src/cdk-experimental/combobox/combobox-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,40 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive} from '@angular/core';
export type AriaHasPopupValue = 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog';

import {Directive, TemplateRef} from '@angular/core';
import {Subject} from 'rxjs';

@Directive({
selector: 'ng-template[cdkComboboxPanel]',
exportAs: 'cdkComboboxPanel',
})
export class CdkComboboxPanel<T = unknown> {

valueUpdated: Subject<T> = new Subject<T>();
contentIdUpdated: Subject<string> = new Subject<string>();
contentTypeUpdated: Subject<AriaHasPopupValue> = new Subject<AriaHasPopupValue>();

contentId: string = '';
contentType: AriaHasPopupValue;

constructor(readonly _templateRef: TemplateRef<unknown>) {}

/** Tells the parent combobox to closet he panel and sends back the content value. */
closePanel(data?: T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add JsDoc for public methods
(here and below)

this.valueUpdated.next(data);
}

/** Registers the content's id and the content type with the panel. */
_registerContent(contentId: string, contentType: AriaHasPopupValue) {
this.contentId = contentId;
if (contentType !== 'listbox' && contentType !== 'dialog') {
throw Error('CdkComboboxPanel currently only supports listbox or dialog content.');
}
this.contentType = contentType;

this.contentIdUpdated.next(this.contentId);
this.contentTypeUpdated.next(this.contentType);
}
}
89 changes: 89 additions & 0 deletions src/cdk-experimental/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {Component, DebugElement} 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';

describe('Combobox', () => {
describe('with a basic toggle trigger', () => {
let fixture: ComponentFixture<ComboboxToggle>;

let combobox: DebugElement;
let comboboxInstance: CdkCombobox<unknown>;
let comboboxElement: HTMLElement;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkComboboxModule],
declarations: [ComboboxToggle],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(ComboboxToggle);
fixture.detectChanges();

combobox = fixture.debugElement.query(By.directive(CdkCombobox));
comboboxInstance = combobox.injector.get<CdkCombobox<unknown>>(CdkCombobox);
comboboxElement = combobox.nativeElement;
});

it('should have the combobox role', () => {
expect(comboboxElement.getAttribute('role')).toBe('combobox');
});

it('should update the aria disabled attribute', () => {
comboboxInstance.disabled = true;
fixture.detectChanges();

expect(comboboxElement.getAttribute('aria-disabled')).toBe('true');

comboboxInstance.disabled = false;
fixture.detectChanges();

expect(comboboxElement.getAttribute('aria-disabled')).toBe('false');
});

it('should have a panel that is closed by default', () => {
expect(comboboxInstance.hasPanel()).toBeTrue();
expect(comboboxInstance.isOpen()).toBeFalse();
});

it('should have an open action of click by default', () => {
expect(comboboxInstance.isOpen()).toBeFalse();

dispatchMouseEvent(comboboxElement, 'click');
fixture.detectChanges();

expect(comboboxInstance.isOpen()).toBeTrue();
});

it('should not open panel when disabled', () => {
expect(comboboxInstance.isOpen()).toBeFalse();
comboboxInstance.disabled = true;
fixture.detectChanges();

dispatchMouseEvent(comboboxElement, 'click');
fixture.detectChanges();

expect(comboboxInstance.isOpen()).toBeFalse();
});
});

});

@Component({
template: `
<button cdkCombobox #toggleCombobox class="example-combobox"
[cdkComboboxTriggerFor]="panel"
[openAction]="'focus'">
No Value
</button>

<ng-template cdkComboboxPanel #panel="cdkComboboxPanel">
Panel Content
</ng-template>`,
})
class ComboboxToggle {
}
180 changes: 177 additions & 3 deletions src/cdk-experimental/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,189 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive} from '@angular/core';
export type OpenAction = 'focus' | 'click' | 'downKey' | 'toggle';
export type OpenActionInput = OpenAction | OpenAction[] | string | null | undefined;

import {
AfterContentInit,
Directive,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Optional,
Output, ViewContainerRef
} from '@angular/core';
import {CdkComboboxPanel, AriaHasPopupValue} from './combobox-panel';
import {TemplatePortal} from '@angular/cdk/portal';
import {
ConnectedPosition,
FlexibleConnectedPositionStrategy,
Overlay,
OverlayConfig,
OverlayRef
} from '@angular/cdk/overlay';
import {Directionality} from '@angular/cdk/bidi';
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';


@Directive({
selector: '[cdkCombobox]',
exportAs: 'cdkCombobox',
host: {
'role': 'combobox'
'role': 'combobox',
'(click)': 'toggle()',
'[attr.aria-disabled]': 'disabled',
'[attr.aria-controls]': 'contentId',
'[attr.aria-haspopup]': 'contentType'
Comment on lines +42 to +43
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for these attributes?

}
})
export class CdkCombobox<T = unknown> {
export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
@Input('cdkComboboxTriggerFor')
get panel(): CdkComboboxPanel<T> | undefined { return this._panel; }
set panel(panel: CdkComboboxPanel<T> | undefined) { this._panel = panel; }
private _panel: CdkComboboxPanel<T> | undefined;

@Input()
value: T;

@Input()
get disabled(): boolean { return this._disabled; }
set disabled(value: boolean) { this._disabled = coerceBooleanProperty(value); }
private _disabled: boolean = false;

@Input()
get openAction(): OpenAction[] {
return this._openActions;
}
set openAction(action: OpenAction[]) {
this._openActions = this._coerceOpenActionProperty(action);
}
private _openActions: OpenAction[] = ['click'];

@Output('comboboxPanelOpened') readonly opened: EventEmitter<void> = new EventEmitter<void>();
@Output('comboboxPanelClosed') readonly closed: EventEmitter<void> = new EventEmitter<void>();
@Output('panelValueChanged') readonly panelValueChanged: EventEmitter<T> = new EventEmitter<T>();

private _overlayRef: OverlayRef;
private _panelContent: TemplatePortal;
contentId: string = '';
contentType: AriaHasPopupValue;

constructor(
private readonly _elementRef: ElementRef<HTMLElement>,
private readonly _overlay: Overlay,
protected readonly _viewContainerRef: ViewContainerRef,
@Optional() private readonly _directionality?: Directionality
) {}

ngAfterContentInit() {
this._panel?.valueUpdated.subscribe(data => {
this._setComboboxValue(data);
this.close();
});

this._panel?.contentIdUpdated.subscribe(id => {
this.contentId = id;
});

this._panel?.contentTypeUpdated.subscribe(type => {
this.contentType = type;
});
}

ngOnDestroy() {
this.opened.complete();
this.closed.complete();
this.panelValueChanged.complete();
}

/** Toggles the open state of the panel. */
toggle() {
if (this.hasPanel()) {
this.isOpen() ? this.close() : this.open();
}
}

/** If the combobox is closed and not disabled, opens the panel. */
open() {
if (!this.isOpen() && !this.disabled) {
this.opened.next();
this._overlayRef = this._overlayRef || this._overlay.create(this._getOverlayConfig());
this._overlayRef.attach(this._getPanelContent());
}
}

/** If the combobox is open and not disabled, closes the panel. */
close() {
if (this.isOpen() && !this.disabled) {
this.closed.next();
this._overlayRef.detach();
}
}

/** Returns true if panel is currently opened. */
isOpen(): boolean {
return this._overlayRef ? this._overlayRef.hasAttached() : false;
}

/** Returns true if combobox has a child panel. */
hasPanel(): boolean {
return !!this.panel;
}

private _setComboboxValue(value: T) {
const valueChanged = (this.value !== value);
this.value = value;

if (valueChanged) {
this.panelValueChanged.emit(value);
}
}

private _getOverlayConfig() {
return new OverlayConfig({
positionStrategy: this._getOverlayPositionStrategy(),
scrollStrategy: this._overlay.scrollStrategies.block(),
direction: this._directionality,
});
}

private _getOverlayPositionStrategy(): FlexibleConnectedPositionStrategy {
return this._overlay
.position()
.flexibleConnectedTo(this._elementRef)
.withPositions(this._getOverlayPositions());
}

private _getOverlayPositions(): ConnectedPosition[] {
return [
{originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top'},
{originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom'},
{originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top'},
{originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom'},
];
}

private _getPanelContent() {
const hasPanelChanged = this._panel?._templateRef !== this._panelContent?.templateRef;
if (this._panel && (!this._panel || hasPanelChanged)) {
this._panelContent = new TemplatePortal(this._panel._templateRef, this._viewContainerRef);
}

return this._panelContent;
}

private _coerceOpenActionProperty(input: string | OpenAction[]): OpenAction[] {
let actions: OpenAction[] = [];
if (typeof input === 'string') {
actions.push(input as OpenAction);
} else {
actions = input;
}
return actions;
}

static ngAcceptInputType_disabled: BooleanInput;
static ngAcceptInputType_openActions: OpenActionInput;
}
2 changes: 1 addition & 1 deletion src/cdk-experimental/combobox/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
* found in the LICENSE file at https://angular.io/license
*/

export * from './combobox-module';
export * from './combobox';
export * from './combobox-panel';
export * from './combobox-module';