Skip to content

Commit 23d3c21

Browse files
authored
feat(cdk-experimental/combobox): make CdkListbox compatible with CdkCombobox (#20291)
Combobox listbox compatible * 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. * refactor(combobox): changed names and made coerceOpenActionProperty simpler for this PR. * fix(combobox): updated syntax for casting. * fix(combobox): fixed trailing whitespace. * feat(listbox): added functions and panel injection to make listbox automatically compatible with being inside a combobox. * feat(listbox): added tests for listbox inside combobox. * test(listbox): added more tests for putting a listbox inside a combobox. * refactor(listbox): used lightweight injection token for CdkComboboxPanel inside listbox. * fix(combobox): reformatted build file. * fix(listbox): fixed an issue where the order of emitting change events messed up the tests. * feat(listbox): added additional focus management logic and a getSelectedValues function. * lint(combobox): removed trailing whitespace. * refactor(listbox): realized disabled listbox still closes combobox popup which needs to be fixed, and test is temporarily removed. * fix(listbox): fixed lint errors. * refactor(listbox): simplified register and update panel functions. * refactor(listbox): changed updatePanel to updatePanelForSelection. * fix(listbox): removed unused variable.
1 parent 809f157 commit 23d3c21

File tree

5 files changed

+192
-3
lines changed

5 files changed

+192
-3
lines changed

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

+1
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ describe('Combobox', () => {
379379
No Value
380380
</button>
381381
<div id="other-content"></div>
382+
382383
<ng-template cdkComboboxPanel #panel="cdkComboboxPanel">
383384
<div dialogContent #dialog="dialogContent" [parentPanel]="panel">
384385
<input #input>

src/cdk-experimental/combobox/combobox.ts

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

9-
109
export type OpenAction = 'focus' | 'click' | 'downKey' | 'toggle';
1110
export type OpenActionInput = OpenAction | OpenAction[] | string | null | undefined;
1211

src/cdk-experimental/listbox/BUILD.bazel

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ng_module(
1010
),
1111
module_name = "@angular/cdk-experimental/listbox",
1212
deps = [
13+
"//src/cdk-experimental/combobox",
1314
"//src/cdk/a11y",
1415
"//src/cdk/collections",
1516
"//src/cdk/keycodes",
@@ -25,6 +26,7 @@ ng_test_library(
2526
),
2627
deps = [
2728
":listbox",
29+
"//src/cdk-experimental/combobox",
2830
"//src/cdk/keycodes",
2931
"//src/cdk/testing/private",
3032
"@npm//@angular/forms",

src/cdk-experimental/listbox/listbox.spec.ts

+152
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
} from '@angular/cdk/testing/private';
1717
import {A, DOWN_ARROW, END, HOME, SPACE} from '@angular/cdk/keycodes';
1818
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
19+
import {CdkCombobox, CdkComboboxModule} from '@angular/cdk-experimental/combobox';
20+
1921

2022
describe('CdkOption and CdkListbox', () => {
2123

@@ -657,6 +659,11 @@ describe('CdkOption and CdkListbox', () => {
657659
listboxInstance.writeValue(['arc', 'stasis']);
658660
fixture.detectChanges();
659661

662+
const selectedValues = listboxInstance.getSelectedValues();
663+
expect(selectedValues.length).toBe(2);
664+
expect(selectedValues[0]).toBe('arc');
665+
expect(selectedValues[1]).toBe('stasis');
666+
660667
expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse();
661668
expect(optionElements[1].hasAttribute('aria-selected')).toBeFalse();
662669

@@ -762,6 +769,118 @@ describe('CdkOption and CdkListbox', () => {
762769
expect(testComponent.form.value).toEqual(['solar']);
763770
});
764771
});
772+
773+
describe('inside a combobox', () => {
774+
let fixture: ComponentFixture<ListboxInsideCombobox>;
775+
let testComponent: ListboxInsideCombobox;
776+
777+
let listbox: DebugElement;
778+
let listboxInstance: CdkListbox<unknown>;
779+
let listboxElement: HTMLElement;
780+
781+
let combobox: DebugElement;
782+
let comboboxInstance: CdkCombobox<string>;
783+
let comboboxElement: HTMLElement;
784+
785+
let options: DebugElement[];
786+
let optionInstances: CdkOption[];
787+
let optionElements: HTMLElement[];
788+
789+
beforeEach(async(() => {
790+
TestBed.configureTestingModule({
791+
imports: [CdkListboxModule, CdkComboboxModule],
792+
declarations: [ListboxInsideCombobox],
793+
}).compileComponents();
794+
}));
795+
796+
beforeEach(() => {
797+
fixture = TestBed.createComponent(ListboxInsideCombobox);
798+
fixture.detectChanges();
799+
800+
testComponent = fixture.debugElement.componentInstance;
801+
802+
combobox = fixture.debugElement.query(By.directive(CdkCombobox));
803+
comboboxInstance = combobox.injector.get<CdkCombobox<string>>(CdkCombobox);
804+
comboboxElement = combobox.nativeElement;
805+
806+
});
807+
808+
it('should update combobox value on selection of an option', () => {
809+
expect(comboboxInstance.value).toBeUndefined();
810+
expect(comboboxInstance.isOpen()).toBeFalse();
811+
812+
dispatchMouseEvent(comboboxElement, 'click');
813+
fixture.detectChanges();
814+
815+
listbox = fixture.debugElement.query(By.directive(CdkListbox));
816+
listboxInstance = listbox.injector.get<CdkListbox<unknown>>(CdkListbox);
817+
818+
options = fixture.debugElement.queryAll(By.directive(CdkOption));
819+
optionInstances = options.map(o => o.injector.get<CdkOption>(CdkOption));
820+
optionElements = options.map(o => o.nativeElement);
821+
822+
expect(comboboxInstance.isOpen()).toBeTrue();
823+
824+
dispatchMouseEvent(optionElements[0], 'click');
825+
fixture.detectChanges();
826+
827+
expect(comboboxInstance.isOpen()).toBeFalse();
828+
expect(comboboxInstance.value).toBe('purple');
829+
});
830+
831+
it('should update combobox value on selection via keyboard', () => {
832+
expect(comboboxInstance.value).toBeUndefined();
833+
expect(comboboxInstance.isOpen()).toBeFalse();
834+
835+
dispatchMouseEvent(comboboxElement, 'click');
836+
fixture.detectChanges();
837+
838+
listbox = fixture.debugElement.query(By.directive(CdkListbox));
839+
listboxInstance = listbox.injector.get<CdkListbox<unknown>>(CdkListbox);
840+
listboxElement = listbox.nativeElement;
841+
842+
options = fixture.debugElement.queryAll(By.directive(CdkOption));
843+
optionInstances = options.map(o => o.injector.get<CdkOption>(CdkOption));
844+
optionElements = options.map(o => o.nativeElement);
845+
846+
expect(comboboxInstance.isOpen()).toBeTrue();
847+
848+
listboxInstance.setActiveOption(optionInstances[1]);
849+
dispatchKeyboardEvent(listboxElement, 'keydown', SPACE);
850+
fixture.detectChanges();
851+
852+
expect(comboboxInstance.isOpen()).toBeFalse();
853+
expect(comboboxInstance.value).toBe('solar');
854+
});
855+
856+
it('should not update combobox if listbox is in multiple mode', () => {
857+
expect(comboboxInstance.value).toBeUndefined();
858+
expect(comboboxInstance.isOpen()).toBeFalse();
859+
860+
dispatchMouseEvent(comboboxElement, 'click');
861+
fixture.detectChanges();
862+
863+
listbox = fixture.debugElement.query(By.directive(CdkListbox));
864+
listboxInstance = listbox.injector.get<CdkListbox<unknown>>(CdkListbox);
865+
listboxElement = listbox.nativeElement;
866+
867+
testComponent.isMultiselectable = true;
868+
fixture.detectChanges();
869+
870+
options = fixture.debugElement.queryAll(By.directive(CdkOption));
871+
optionInstances = options.map(o => o.injector.get<CdkOption>(CdkOption));
872+
optionElements = options.map(o => o.nativeElement);
873+
874+
expect(comboboxInstance.isOpen()).toBeTrue();
875+
876+
listboxInstance.setActiveOption(optionInstances[1]);
877+
dispatchKeyboardEvent(listboxElement, 'keydown', SPACE);
878+
fixture.detectChanges();
879+
880+
expect(comboboxInstance.isOpen()).toBeTrue();
881+
expect(comboboxInstance.value).toBeUndefined();
882+
});
883+
});
765884
});
766885

767886
@Component({
@@ -862,3 +981,36 @@ class ListboxControlValueAccessor {
862981
this.changedOption = event.option;
863982
}
864983
}
984+
985+
@Component({
986+
template: `
987+
<button cdkCombobox #toggleCombobox class="example-combobox"
988+
[cdkComboboxTriggerFor]="panel"
989+
[openActions]="'click'">
990+
No Value
991+
</button>
992+
993+
<ng-template cdkComboboxPanel #panel="cdkComboboxPanel">
994+
<select cdkListbox
995+
[parentPanel]="panel"
996+
[disabled]="isDisabled"
997+
[multiple]="isMultiselectable"
998+
(selectionChange)="onSelectionChange($event)">
999+
<option cdkOption [value]="'purple'">Purple</option>
1000+
<option cdkOption [value]="'solar'">Solar</option>
1001+
<option cdkOption [value]="'arc'">Arc</option>
1002+
<option cdkOption [value]="'stasis'">Stasis</option>
1003+
</select>
1004+
</ng-template>
1005+
`
1006+
})
1007+
class ListboxInsideCombobox {
1008+
changedOption: CdkOption<string>;
1009+
isDisabled: boolean = false;
1010+
isMultiselectable: boolean = false;
1011+
@ViewChild(CdkListbox) listbox: CdkListbox<string>;
1012+
1013+
onSelectionChange(event: ListboxSelectionChangeEvent<string>) {
1014+
this.changedOption = event.option;
1015+
}
1016+
}

src/cdk-experimental/listbox/listbox.ts

+37-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {
1111
ContentChildren,
1212
Directive,
1313
ElementRef, EventEmitter, forwardRef,
14-
Inject,
15-
Input, OnDestroy, OnInit, Output,
14+
Inject, InjectionToken,
15+
Input, OnDestroy, OnInit, Optional, Output,
1616
QueryList
1717
} from '@angular/core';
1818
import {ActiveDescendantKeyManager, Highlightable, ListKeyManagerOption} from '@angular/cdk/a11y';
@@ -22,15 +22,19 @@ import {SelectionChange, SelectionModel} from '@angular/cdk/collections';
2222
import {defer, merge, Observable, Subject} from 'rxjs';
2323
import {startWith, switchMap, takeUntil} from 'rxjs/operators';
2424
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
25+
import {CdkComboboxPanel} from '@angular/cdk-experimental/combobox';
2526

2627
let nextId = 0;
28+
let listboxId = 0;
2729

2830
export const CDK_LISTBOX_VALUE_ACCESSOR: any = {
2931
provide: NG_VALUE_ACCESSOR,
3032
useExisting: forwardRef(() => CdkListbox),
3133
multi: true
3234
};
3335

36+
export const PANEL = new InjectionToken<CdkComboboxPanel>('CdkComboboxPanel');
37+
3438
@Directive({
3539
selector: '[cdkOption]',
3640
exportAs: 'cdkOption',
@@ -172,6 +176,10 @@ export class CdkOption<T = unknown> implements ListKeyManagerOption, Highlightab
172176
}
173177
}
174178

179+
getElementRef() {
180+
return this._elementRef;
181+
}
182+
175183
/** Sets the active property to true to enable the active css class. */
176184
setActiveStyles() {
177185
this._active = true;
@@ -191,6 +199,7 @@ export class CdkOption<T = unknown> implements ListKeyManagerOption, Highlightab
191199
exportAs: 'cdkListbox',
192200
host: {
193201
'role': 'listbox',
202+
'[id]': 'id',
194203
'(keydown)': '_keydown($event)',
195204
'[attr.tabindex]': '_tabIndex',
196205
'[attr.aria-disabled]': 'disabled',
@@ -231,6 +240,8 @@ export class CdkListbox<T> implements AfterContentInit, OnDestroy, OnInit, Contr
231240
@Output() readonly selectionChange: EventEmitter<ListboxSelectionChangeEvent<T>> =
232241
new EventEmitter<ListboxSelectionChangeEvent<T>>();
233242

243+
@Input() id = `cdk-option-${listboxId++}`;
244+
234245
/**
235246
* Whether the listbox allows multiple options to be selected.
236247
* If `multiple` switches from `true` to `false`, all options are deselected.
@@ -263,18 +274,24 @@ export class CdkListbox<T> implements AfterContentInit, OnDestroy, OnInit, Contr
263274

264275
@Input() compareWith: (o1: T, o2: T) => boolean = (a1, a2) => a1 === a2;
265276

277+
@Input('parentPanel') private readonly _explicitPanel: CdkComboboxPanel;
278+
279+
constructor(@Optional() @Inject(PANEL) readonly _parentPanel?: CdkComboboxPanel<T>) { }
280+
266281
ngOnInit() {
267282
this._selectionModel = new SelectionModel<CdkOption<T>>(this.multiple);
268283
}
269284

270285
ngAfterContentInit() {
271286
this._initKeyManager();
272287
this._initSelectionModel();
288+
this._registerWithPanel();
273289

274290
this.optionSelectionChanges.subscribe(event => {
275291
this._emitChangeEvent(event.source);
276292
this._updateSelectionModel(event.source);
277293
this.setActiveOption(event.source);
294+
this._updatePanelForSelection(event.source);
278295
});
279296
}
280297

@@ -284,6 +301,11 @@ export class CdkListbox<T> implements AfterContentInit, OnDestroy, OnInit, Contr
284301
this._destroyed.complete();
285302
}
286303

304+
private _registerWithPanel(): void {
305+
const panel = this._parentPanel || this._explicitPanel;
306+
panel?._registerContent(this.id, 'listbox');
307+
}
308+
287309
private _initKeyManager() {
288310
this._listKeyManager = new ActiveDescendantKeyManager(this._options)
289311
.withWrap()
@@ -358,6 +380,13 @@ export class CdkListbox<T> implements AfterContentInit, OnDestroy, OnInit, Contr
358380
this._selectionModel.deselect(option);
359381
}
360382

383+
_updatePanelForSelection(option: CdkOption<T>) {
384+
if (!this.multiple) {
385+
const panel = this._parentPanel || this._explicitPanel;
386+
option.selected ? panel?.closePanel(option.value) : panel?.closePanel();
387+
}
388+
}
389+
361390
/** Toggles the selected state of the active option if not disabled. */
362391
private _toggleActiveOption() {
363392
const activeOption = this._listKeyManager.activeItem;
@@ -420,6 +449,7 @@ export class CdkListbox<T> implements AfterContentInit, OnDestroy, OnInit, Contr
420449
/** Updates the key manager's active item to the given option. */
421450
setActiveOption(option: CdkOption<T>) {
422451
this._listKeyManager.updateActiveItem(option);
452+
this._updateActiveOption();
423453
}
424454

425455
/**
@@ -450,6 +480,11 @@ export class CdkListbox<T> implements AfterContentInit, OnDestroy, OnInit, Contr
450480
this.disabled = isDisabled;
451481
}
452482

483+
/** Returns the values of the currently selected options. */
484+
getSelectedValues(): T[] {
485+
return this._options.filter(option => option.selected).map(option => option.value);
486+
}
487+
453488
/** Selects an option that has the corresponding given value. */
454489
private _setSelectionByValue(values: T | T[]) {
455490
for (const option of this._options.toArray()) {

0 commit comments

Comments
 (0)