Skip to content

Commit 6184328

Browse files
authored
feat(cdk-experimental/combobox): add combobox base (#20211)
* build: Added required files to listbox directory. * build: added listbox option directive and renamed listbox directive files. * build: Added required files to listbox directory. * build: added listbox option directive and renamed listbox directive files. * build: Added required files to listbox directory. * build: added listbox option directive and renamed listbox directive files. * build: Added required files to listbox directory. * build: added listbox option directive and renamed listbox directive files. * feat(dev-app/listbox): added cdk listbox example to the dev-app. * fix(listbox): removed duplicate dep in dev-app build file. * fix(listbox): deleted unused files. * feat(combobox): added all basic features to combobox and combobox panel, and some tests. * feat(combobox): finished basic tests and clean up. * fix(combobox): fixed the import path for coercion. * fix(combobox): fixed import path for combobox panel. * fix(combobox): fixed lint errors throughout. * refactor(combobox): removed unused panel test file. * refactor(combobox): changed import path for panel in combobox. * refactor(combobox): cleaned up Inputs in combobox and moved aria-haspopup and aria-controls to the combobox not the panel. * refactor(combobox): added jsdoc to the public functions. * fix(combobox): removed duplicate ContentType type and getPanelContent function. * refactor(combobox): made contentId and contentType of combobox public. * refactor(combobox): changed names and made coerceOpenActionProperty simpler for this PR. * fix(combobox): updated syntax for casting. * refactor(combobox): changed casting syntax back. * fix(combobox): fixed trailing whitespace.
1 parent f6e82cc commit 6184328

File tree

6 files changed

+323
-6
lines changed

6 files changed

+323
-6
lines changed

src/cdk-experimental/combobox/BUILD.bazel

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("//tools:defaults.bzl", "ng_module")
1+
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")
22

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

@@ -9,4 +9,28 @@ ng_module(
99
exclude = ["**/*.spec.ts"],
1010
),
1111
module_name = "@angular/cdk-experimental/combobox",
12+
deps = [
13+
"//src/cdk/a11y",
14+
"//src/cdk/bidi",
15+
"//src/cdk/collections",
16+
"//src/cdk/overlay",
17+
],
18+
)
19+
20+
ng_test_library(
21+
name = "unit_test_sources",
22+
srcs = glob(
23+
["**/*.spec.ts"],
24+
exclude = ["**/*.e2e.spec.ts"],
25+
),
26+
deps = [
27+
":combobox",
28+
"//src/cdk/testing/private",
29+
"@npm//@angular/platform-browser",
30+
],
31+
)
32+
33+
ng_web_test_suite(
34+
name = "unit_tests",
35+
deps = [":unit_test_sources"],
1236
)

src/cdk-experimental/combobox/combobox-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
*/
88

99
import {NgModule} from '@angular/core';
10+
import {OverlayModule} from '@angular/cdk/overlay';
1011
import {CdkCombobox} from './combobox';
1112
import {CdkComboboxPanel} from './combobox-panel';
1213

1314
const EXPORTED_DECLARATIONS = [CdkCombobox, CdkComboboxPanel];
1415
@NgModule({
16+
imports: [OverlayModule],
1517
exports: EXPORTED_DECLARATIONS,
1618
declarations: EXPORTED_DECLARATIONS,
1719
})

src/cdk-experimental/combobox/combobox-panel.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,40 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive} from '@angular/core';
9+
export type AriaHasPopupValue = 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog';
10+
11+
import {Directive, TemplateRef} from '@angular/core';
12+
import {Subject} from 'rxjs';
1013

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

20+
valueUpdated: Subject<T> = new Subject<T>();
21+
contentIdUpdated: Subject<string> = new Subject<string>();
22+
contentTypeUpdated: Subject<AriaHasPopupValue> = new Subject<AriaHasPopupValue>();
23+
24+
contentId: string = '';
25+
contentType: AriaHasPopupValue;
26+
27+
constructor(readonly _templateRef: TemplateRef<unknown>) {}
28+
29+
/** Tells the parent combobox to closet he panel and sends back the content value. */
30+
closePanel(data?: T) {
31+
this.valueUpdated.next(data);
32+
}
33+
34+
/** Registers the content's id and the content type with the panel. */
35+
_registerContent(contentId: string, contentType: AriaHasPopupValue) {
36+
this.contentId = contentId;
37+
if (contentType !== 'listbox' && contentType !== 'dialog') {
38+
throw Error('CdkComboboxPanel currently only supports listbox or dialog content.');
39+
}
40+
this.contentType = contentType;
41+
42+
this.contentIdUpdated.next(this.contentId);
43+
this.contentTypeUpdated.next(this.contentType);
44+
}
1745
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {Component, DebugElement} from '@angular/core';
2+
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
3+
import {By} from '@angular/platform-browser';
4+
import {CdkComboboxModule} from './combobox-module';
5+
import {CdkCombobox} from './combobox';
6+
import {dispatchMouseEvent} from '@angular/cdk/testing/private';
7+
8+
describe('Combobox', () => {
9+
describe('with a basic toggle trigger', () => {
10+
let fixture: ComponentFixture<ComboboxToggle>;
11+
12+
let combobox: DebugElement;
13+
let comboboxInstance: CdkCombobox<unknown>;
14+
let comboboxElement: HTMLElement;
15+
16+
beforeEach(async(() => {
17+
TestBed.configureTestingModule({
18+
imports: [CdkComboboxModule],
19+
declarations: [ComboboxToggle],
20+
}).compileComponents();
21+
}));
22+
23+
beforeEach(() => {
24+
fixture = TestBed.createComponent(ComboboxToggle);
25+
fixture.detectChanges();
26+
27+
combobox = fixture.debugElement.query(By.directive(CdkCombobox));
28+
comboboxInstance = combobox.injector.get<CdkCombobox<unknown>>(CdkCombobox);
29+
comboboxElement = combobox.nativeElement;
30+
});
31+
32+
it('should have the combobox role', () => {
33+
expect(comboboxElement.getAttribute('role')).toBe('combobox');
34+
});
35+
36+
it('should update the aria disabled attribute', () => {
37+
comboboxInstance.disabled = true;
38+
fixture.detectChanges();
39+
40+
expect(comboboxElement.getAttribute('aria-disabled')).toBe('true');
41+
42+
comboboxInstance.disabled = false;
43+
fixture.detectChanges();
44+
45+
expect(comboboxElement.getAttribute('aria-disabled')).toBe('false');
46+
});
47+
48+
it('should have a panel that is closed by default', () => {
49+
expect(comboboxInstance.hasPanel()).toBeTrue();
50+
expect(comboboxInstance.isOpen()).toBeFalse();
51+
});
52+
53+
it('should have an open action of click by default', () => {
54+
expect(comboboxInstance.isOpen()).toBeFalse();
55+
56+
dispatchMouseEvent(comboboxElement, 'click');
57+
fixture.detectChanges();
58+
59+
expect(comboboxInstance.isOpen()).toBeTrue();
60+
});
61+
62+
it('should not open panel when disabled', () => {
63+
expect(comboboxInstance.isOpen()).toBeFalse();
64+
comboboxInstance.disabled = true;
65+
fixture.detectChanges();
66+
67+
dispatchMouseEvent(comboboxElement, 'click');
68+
fixture.detectChanges();
69+
70+
expect(comboboxInstance.isOpen()).toBeFalse();
71+
});
72+
});
73+
74+
});
75+
76+
@Component({
77+
template: `
78+
<button cdkCombobox #toggleCombobox class="example-combobox"
79+
[cdkComboboxTriggerFor]="panel"
80+
[openAction]="'focus'">
81+
No Value
82+
</button>
83+
84+
<ng-template cdkComboboxPanel #panel="cdkComboboxPanel">
85+
Panel Content
86+
</ng-template>`,
87+
})
88+
class ComboboxToggle {
89+
}

src/cdk-experimental/combobox/combobox.ts

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,189 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive} from '@angular/core';
9+
export type OpenAction = 'focus' | 'click' | 'downKey' | 'toggle';
10+
export type OpenActionInput = OpenAction | OpenAction[] | string | null | undefined;
11+
12+
import {
13+
AfterContentInit,
14+
Directive,
15+
ElementRef,
16+
EventEmitter,
17+
Input,
18+
OnDestroy,
19+
Optional,
20+
Output, ViewContainerRef
21+
} from '@angular/core';
22+
import {CdkComboboxPanel, AriaHasPopupValue} from './combobox-panel';
23+
import {TemplatePortal} from '@angular/cdk/portal';
24+
import {
25+
ConnectedPosition,
26+
FlexibleConnectedPositionStrategy,
27+
Overlay,
28+
OverlayConfig,
29+
OverlayRef
30+
} from '@angular/cdk/overlay';
31+
import {Directionality} from '@angular/cdk/bidi';
32+
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
33+
1034

1135
@Directive({
1236
selector: '[cdkCombobox]',
1337
exportAs: 'cdkCombobox',
1438
host: {
15-
'role': 'combobox'
39+
'role': 'combobox',
40+
'(click)': 'toggle()',
41+
'[attr.aria-disabled]': 'disabled',
42+
'[attr.aria-controls]': 'contentId',
43+
'[attr.aria-haspopup]': 'contentType'
1644
}
1745
})
18-
export class CdkCombobox<T = unknown> {
46+
export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
47+
@Input('cdkComboboxTriggerFor')
48+
get panel(): CdkComboboxPanel<T> | undefined { return this._panel; }
49+
set panel(panel: CdkComboboxPanel<T> | undefined) { this._panel = panel; }
50+
private _panel: CdkComboboxPanel<T> | undefined;
51+
52+
@Input()
53+
value: T;
54+
55+
@Input()
56+
get disabled(): boolean { return this._disabled; }
57+
set disabled(value: boolean) { this._disabled = coerceBooleanProperty(value); }
58+
private _disabled: boolean = false;
59+
60+
@Input()
61+
get openAction(): OpenAction[] {
62+
return this._openActions;
63+
}
64+
set openAction(action: OpenAction[]) {
65+
this._openActions = this._coerceOpenActionProperty(action);
66+
}
67+
private _openActions: OpenAction[] = ['click'];
68+
69+
@Output('comboboxPanelOpened') readonly opened: EventEmitter<void> = new EventEmitter<void>();
70+
@Output('comboboxPanelClosed') readonly closed: EventEmitter<void> = new EventEmitter<void>();
71+
@Output('panelValueChanged') readonly panelValueChanged: EventEmitter<T> = new EventEmitter<T>();
72+
73+
private _overlayRef: OverlayRef;
74+
private _panelContent: TemplatePortal;
75+
contentId: string = '';
76+
contentType: AriaHasPopupValue;
77+
78+
constructor(
79+
private readonly _elementRef: ElementRef<HTMLElement>,
80+
private readonly _overlay: Overlay,
81+
protected readonly _viewContainerRef: ViewContainerRef,
82+
@Optional() private readonly _directionality?: Directionality
83+
) {}
84+
85+
ngAfterContentInit() {
86+
this._panel?.valueUpdated.subscribe(data => {
87+
this._setComboboxValue(data);
88+
this.close();
89+
});
90+
91+
this._panel?.contentIdUpdated.subscribe(id => {
92+
this.contentId = id;
93+
});
94+
95+
this._panel?.contentTypeUpdated.subscribe(type => {
96+
this.contentType = type;
97+
});
98+
}
99+
100+
ngOnDestroy() {
101+
this.opened.complete();
102+
this.closed.complete();
103+
this.panelValueChanged.complete();
104+
}
105+
106+
/** Toggles the open state of the panel. */
107+
toggle() {
108+
if (this.hasPanel()) {
109+
this.isOpen() ? this.close() : this.open();
110+
}
111+
}
112+
113+
/** If the combobox is closed and not disabled, opens the panel. */
114+
open() {
115+
if (!this.isOpen() && !this.disabled) {
116+
this.opened.next();
117+
this._overlayRef = this._overlayRef || this._overlay.create(this._getOverlayConfig());
118+
this._overlayRef.attach(this._getPanelContent());
119+
}
120+
}
121+
122+
/** If the combobox is open and not disabled, closes the panel. */
123+
close() {
124+
if (this.isOpen() && !this.disabled) {
125+
this.closed.next();
126+
this._overlayRef.detach();
127+
}
128+
}
129+
130+
/** Returns true if panel is currently opened. */
131+
isOpen(): boolean {
132+
return this._overlayRef ? this._overlayRef.hasAttached() : false;
133+
}
134+
135+
/** Returns true if combobox has a child panel. */
136+
hasPanel(): boolean {
137+
return !!this.panel;
138+
}
139+
140+
private _setComboboxValue(value: T) {
141+
const valueChanged = (this.value !== value);
142+
this.value = value;
143+
144+
if (valueChanged) {
145+
this.panelValueChanged.emit(value);
146+
}
147+
}
148+
149+
private _getOverlayConfig() {
150+
return new OverlayConfig({
151+
positionStrategy: this._getOverlayPositionStrategy(),
152+
scrollStrategy: this._overlay.scrollStrategies.block(),
153+
direction: this._directionality,
154+
});
155+
}
156+
157+
private _getOverlayPositionStrategy(): FlexibleConnectedPositionStrategy {
158+
return this._overlay
159+
.position()
160+
.flexibleConnectedTo(this._elementRef)
161+
.withPositions(this._getOverlayPositions());
162+
}
163+
164+
private _getOverlayPositions(): ConnectedPosition[] {
165+
return [
166+
{originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top'},
167+
{originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom'},
168+
{originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top'},
169+
{originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom'},
170+
];
171+
}
172+
173+
private _getPanelContent() {
174+
const hasPanelChanged = this._panel?._templateRef !== this._panelContent?.templateRef;
175+
if (this._panel && (!this._panel || hasPanelChanged)) {
176+
this._panelContent = new TemplatePortal(this._panel._templateRef, this._viewContainerRef);
177+
}
178+
179+
return this._panelContent;
180+
}
181+
182+
private _coerceOpenActionProperty(input: string | OpenAction[]): OpenAction[] {
183+
let actions: OpenAction[] = [];
184+
if (typeof input === 'string') {
185+
actions.push(input as OpenAction);
186+
} else {
187+
actions = input;
188+
}
189+
return actions;
190+
}
19191

192+
static ngAcceptInputType_disabled: BooleanInput;
193+
static ngAcceptInputType_openActions: OpenActionInput;
20194
}

src/cdk-experimental/combobox/public-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
export * from './combobox-module';
910
export * from './combobox';
1011
export * from './combobox-panel';
11-
export * from './combobox-module';

0 commit comments

Comments
 (0)