Skip to content

Commit d7754e7

Browse files
authored
Combobox listbox refactor (#20339)
* 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): fixed trailing whitespace. * refactor(listbox): added default classes to listbox and option, and made aria-active descendant not the default. * refactor(listbox): added focus management for focusing listbox. * feat(listbox): added support for horizontal listbox. * refactor(combobox): added support for array of values which enables multiselect listbox to be compatible. * feat(combobox): let the user determine what element is focused first in the popup. * feat(combobox): pressing down when focused on text trigger will move focus to popup. * refactor(listbox): updated listbox key management to handle horizontal orientation. * refactor(listbox): prevented listbox from auto closing when in multiple mode in a combobox. * fix(combobox): fixed lint errors. * refactor(combobox): made updates to focus management and keyboard handling. * fix(combobox): fixed keyboard logic bug that only handled down arrow. * fix(combobox): close popup on Tab. * refactor(listbox): added comments and todos throughout. * fix(listbox): fixed listbox id to be cdk-listbox instead of cdk-option. * fix(listbox): prevent SPACE from scrolling scren.
1 parent 23d3c21 commit d7754e7

File tree

6 files changed

+150
-28
lines changed

6 files changed

+150
-28
lines changed

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {Subject} from 'rxjs';
2020
})
2121
export class CdkComboboxPanel<T = unknown> {
2222

23-
valueUpdated: Subject<T> = new Subject<T>();
23+
valueUpdated: Subject<T | T[]> = new Subject<T | T[]>();
2424
contentIdUpdated: Subject<string> = new Subject<string>();
2525
contentTypeUpdated: Subject<AriaHasPopupValue> = new Subject<AriaHasPopupValue>();
2626

@@ -32,7 +32,7 @@ export class CdkComboboxPanel<T = unknown> {
3232
) {}
3333

3434
/** Tells the parent combobox to close the panel and sends back the content value. */
35-
closePanel(data?: T) {
35+
closePanel(data?: T | T[]) {
3636
this.valueUpdated.next(data);
3737
}
3838

@@ -44,6 +44,11 @@ export class CdkComboboxPanel<T = unknown> {
4444

4545
/** Registers the content's id and the content type with the panel. */
4646
_registerContent(contentId: string, contentType: AriaHasPopupValue) {
47+
// If content has already been registered, no further contentIds are registered.
48+
if (this.contentType && this.contentType !== contentType) {
49+
return;
50+
}
51+
4752
this.contentId = contentId;
4853
if (contentType !== 'listbox' && contentType !== 'dialog') {
4954
throw Error('CdkComboboxPanel currently only supports listbox or dialog content.');

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive, Inject, InjectionToken, Input, OnInit, Optional} from '@angular/core';
9+
import {
10+
Directive,
11+
ElementRef,
12+
Inject,
13+
InjectionToken,
14+
Input,
15+
OnInit,
16+
Optional} from '@angular/core';
1017
import {AriaHasPopupValue, CdkComboboxPanel} from './combobox-panel';
1118

1219
export const PANEL = new InjectionToken<CdkComboboxPanel>('CdkComboboxPanel');
@@ -20,7 +27,8 @@ let nextId = 0;
2027
'class': 'cdk-combobox-popup',
2128
'[attr.role]': 'role',
2229
'[id]': 'id',
23-
'tabindex': '-1'
30+
'tabindex': '-1',
31+
'(focus)': 'focusFirstElement()'
2432
}
2533
})
2634
export class CdkComboboxPopup<T = unknown> implements OnInit {
@@ -33,11 +41,21 @@ export class CdkComboboxPopup<T = unknown> implements OnInit {
3341
}
3442
private _role: AriaHasPopupValue = 'dialog';
3543

44+
@Input()
45+
get firstFocus(): HTMLElement {
46+
return this._firstFocusElement;
47+
}
48+
set firstFocus(id: HTMLElement) {
49+
this._firstFocusElement = id;
50+
}
51+
private _firstFocusElement: HTMLElement;
52+
3653
@Input() id = `cdk-combobox-popup-${nextId++}`;
3754

3855
@Input('parentPanel') private readonly _explicitPanel: CdkComboboxPanel;
3956

4057
constructor(
58+
private readonly _elementRef: ElementRef<HTMLElement>,
4159
@Optional() @Inject(PANEL) readonly _parentPanel?: CdkComboboxPanel<T>,
4260
) { }
4361

@@ -52,4 +70,12 @@ export class CdkComboboxPopup<T = unknown> implements OnInit {
5270
this._parentPanel._registerContent(this.id, this._role);
5371
}
5472
}
73+
74+
focusFirstElement() {
75+
if (this._firstFocusElement) {
76+
this._firstFocusElement.focus();
77+
} else {
78+
this._elementRef.nativeElement.focus();
79+
}
80+
}
5581
}

src/cdk-experimental/combobox/combobox.ts

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

9+
910
export type OpenAction = 'focus' | 'click' | 'downKey' | 'toggle';
1011
export type OpenActionInput = OpenAction | OpenAction[] | string | null | undefined;
1112

@@ -30,7 +31,7 @@ import {
3031
} from '@angular/cdk/overlay';
3132
import {Directionality} from '@angular/cdk/bidi';
3233
import {BooleanInput, coerceBooleanProperty, coerceArray} from '@angular/cdk/coercion';
33-
import {DOWN_ARROW, ESCAPE} from '@angular/cdk/keycodes';
34+
import {DOWN_ARROW, ENTER, ESCAPE, TAB} from '@angular/cdk/keycodes';
3435

3536
const allowedOpenActions = ['focus', 'click', 'downKey', 'toggle'];
3637

@@ -58,7 +59,7 @@ export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
5859
private _panel: CdkComboboxPanel<T> | undefined;
5960

6061
@Input()
61-
value: T;
62+
value: T | T[];
6263

6364
@Input()
6465
get disabled(): boolean { return this._disabled; }
@@ -74,9 +75,16 @@ export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
7475
}
7576
private _openActions: OpenAction[] = ['click'];
7677

78+
/** Whether the textContent is automatically updated upon change of the combobox value. */
79+
@Input()
80+
get autoSetText(): boolean { return this._autoSetText; }
81+
set autoSetText(value: boolean) { this._autoSetText = coerceBooleanProperty(value); }
82+
private _autoSetText: boolean = true;
83+
7784
@Output('comboboxPanelOpened') readonly opened: EventEmitter<void> = new EventEmitter<void>();
7885
@Output('comboboxPanelClosed') readonly closed: EventEmitter<void> = new EventEmitter<void>();
79-
@Output('panelValueChanged') readonly panelValueChanged: EventEmitter<T> = new EventEmitter<T>();
86+
@Output('panelValueChanged') readonly panelValueChanged: EventEmitter<T[]>
87+
= new EventEmitter<T[]>();
8088

8189
private _overlayRef: OverlayRef;
8290
private _panelContent: TemplatePortal;
@@ -114,14 +122,28 @@ export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
114122
_keydown(event: KeyboardEvent) {
115123
const {keyCode} = event;
116124

117-
if (keyCode === DOWN_ARROW && this._openActions.indexOf('downKey') !== -1) {
118-
this.open();
125+
if (keyCode === DOWN_ARROW) {
126+
if (this.isOpen()) {
127+
this._panel?.focusContent();
128+
} else if (this._openActions.indexOf('downKey') !== -1) {
129+
this.open();
130+
}
131+
} else if (keyCode === ENTER) {
132+
if (this._openActions.indexOf('toggle') !== -1) {
133+
this.toggle();
134+
} else if (this._openActions.indexOf('click') !== -1) {
135+
this.open();
136+
}
137+
119138
} else if (keyCode === ESCAPE) {
120139
event.preventDefault();
121140
this.close();
141+
} else if (keyCode === TAB) {
142+
this.close();
122143
}
123144
}
124145

146+
/** Handles click or focus interactions. */
125147
_handleInteractions(interaction: OpenAction) {
126148
if (interaction === 'click') {
127149
if (this._openActions.indexOf('toggle') !== -1) {
@@ -136,6 +158,7 @@ export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
136158
}
137159
}
138160

161+
/** Given a click in the document, determines if the click was inside a combobox. */
139162
_attemptClose(event: MouseEvent) {
140163
if (this.isOpen()) {
141164
let target = event.composedPath ? event.composedPath()[0] : event.target;
@@ -163,7 +186,9 @@ export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
163186
this.opened.next();
164187
this._overlayRef = this._overlayRef || this._overlay.create(this._getOverlayConfig());
165188
this._overlayRef.attach(this._getPanelContent());
166-
this._panel?.focusContent();
189+
if (!this._isTextTrigger()) {
190+
this._panel?.focusContent();
191+
}
167192
}
168193
}
169194

@@ -189,22 +214,30 @@ export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
189214
return this.disabled ? null : '0';
190215
}
191216

192-
private _setComboboxValue(value: T) {
217+
private _setComboboxValue(value: T | T[]) {
193218

194219
const valueChanged = (this.value !== value);
195220
this.value = value;
196221

197222
if (valueChanged) {
198-
this.panelValueChanged.emit(value);
199-
this._setTextContent(value);
223+
this.panelValueChanged.emit(coerceArray(value));
224+
if (this._autoSetText) {
225+
this._setTextContent(value);
226+
}
200227
}
201228
}
202229

203-
private _setTextContent(content: T) {
230+
private _setTextContent(content: T | T[]) {
204231
const contentArray = coerceArray(content);
205232
this._elementRef.nativeElement.textContent = contentArray.join(' ');
206233
}
207234

235+
private _isTextTrigger() {
236+
// TODO: Should check if the trigger is contenteditable.
237+
const tagName = this._elementRef.nativeElement.tagName.toLowerCase();
238+
return tagName === 'input' || tagName === 'textarea' ? true : false;
239+
}
240+
208241
private _getOverlayConfig() {
209242
return new OverlayConfig({
210243
positionStrategy: this._getOverlayPositionStrategy(),
@@ -247,5 +280,6 @@ export class CdkCombobox<T = unknown> implements OnDestroy, AfterContentInit {
247280
}
248281

249282
static ngAcceptInputType_openActions: OpenActionInput;
283+
static ngAcceptInputType_autoSetText: OpenActionInput;
250284
static ngAcceptInputType_disabled: BooleanInput;
251285
}

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ 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';
19+
import {CdkCombobox, CdkComboboxModule, CdkComboboxPanel} from '@angular/cdk-experimental/combobox';
2020

2121

2222
describe('CdkOption and CdkListbox', () => {
@@ -853,7 +853,7 @@ describe('CdkOption and CdkListbox', () => {
853853
expect(comboboxInstance.value).toBe('solar');
854854
});
855855

856-
it('should not update combobox if listbox is in multiple mode', () => {
856+
it('should not close panel if listbox is in multiple mode', () => {
857857
expect(comboboxInstance.value).toBeUndefined();
858858
expect(comboboxInstance.isOpen()).toBeFalse();
859859

@@ -875,10 +875,11 @@ describe('CdkOption and CdkListbox', () => {
875875

876876
listboxInstance.setActiveOption(optionInstances[1]);
877877
dispatchKeyboardEvent(listboxElement, 'keydown', SPACE);
878+
testComponent.panel.closePanel(testComponent.listbox.getSelectedValues());
878879
fixture.detectChanges();
879880

880-
expect(comboboxInstance.isOpen()).toBeTrue();
881-
expect(comboboxInstance.value).toBeUndefined();
881+
expect(comboboxInstance.isOpen()).toBeFalse();
882+
expect(comboboxInstance.value).toEqual(['solar']);
882883
});
883884
});
884885
});
@@ -1009,6 +1010,7 @@ class ListboxInsideCombobox {
10091010
isDisabled: boolean = false;
10101011
isMultiselectable: boolean = false;
10111012
@ViewChild(CdkListbox) listbox: CdkListbox<string>;
1013+
@ViewChild('panel') panel: CdkComboboxPanel<string>;
10121014

10131015
onSelectionChange(event: ListboxSelectionChangeEvent<string>) {
10141016
this.changedOption = event.option;

0 commit comments

Comments
 (0)