Skip to content

Commit 35b0cee

Browse files
committed
fix(cdk-experimental/listbox): add forms validator support
1 parent 81d3d90 commit 35b0cee

File tree

3 files changed

+106
-7
lines changed

3 files changed

+106
-7
lines changed

src/cdk-experimental/listbox/listbox.ts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,16 @@ import {BooleanInput, coerceArray, coerceBooleanProperty} from '@angular/cdk/coe
2727
import {SelectionModel} from '@angular/cdk/collections';
2828
import {BehaviorSubject, combineLatest, defer, merge, Observable, Subject} from 'rxjs';
2929
import {filter, mapTo, startWith, switchMap, take, takeUntil} from 'rxjs/operators';
30-
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
30+
import {
31+
AbstractControl,
32+
ControlValueAccessor,
33+
NG_VALIDATORS,
34+
NG_VALUE_ACCESSOR,
35+
ValidationErrors,
36+
Validator,
37+
ValidatorFn,
38+
Validators,
39+
} from '@angular/forms';
3140
import {Directionality} from '@angular/cdk/bidi';
3241
import {CdkCombobox} from '@angular/cdk-experimental/combobox';
3342

@@ -187,13 +196,19 @@ export class CdkOption<T = unknown> implements ListKeyManagerOption, Highlightab
187196
useExisting: forwardRef(() => CdkListbox),
188197
multi: true,
189198
},
199+
{
200+
provide: NG_VALIDATORS,
201+
useExisting: forwardRef(() => CdkListbox),
202+
multi: true,
203+
},
190204
],
191205
})
192-
export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, ControlValueAccessor {
206+
export class CdkListbox<T = unknown>
207+
implements AfterContentInit, OnDestroy, ControlValueAccessor, Validator
208+
{
193209
/** The id of the option's host element. */
194210
@Input() id = `cdk-listbox-${nextId++}`;
195211

196-
// TODO(mmalerba): Add forms validation support.
197212
/** The value selected in the listbox, represented as an array of option values. */
198213
@Input('cdkListboxValue')
199214
get value(): T[] {
@@ -215,6 +230,7 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
215230
set multiple(value: BooleanInput) {
216231
this._multiple = coerceBooleanProperty(value);
217232
this._updateSelectionModel();
233+
this._onValidatorChange();
218234
}
219235
private _multiple: boolean = false;
220236

@@ -276,11 +292,14 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
276292
protected readonly changeDetectorRef = inject(ChangeDetectorRef);
277293

278294
/** Callback called when the listbox has been touched */
279-
private _onTouched: () => void = () => {};
295+
private _onTouched: () => {};
280296

281297
/** Callback called when the listbox value changes */
282298
private _onChange: (value: T[]) => void = () => {};
283299

300+
/** Callback called when the form validator changes. */
301+
private _onValidatorChange = () => {};
302+
284303
/** Emits when an option has been clicked. */
285304
private _optionClicked = defer(() =>
286305
(this.options.changes as Observable<CdkOption<T>[]>).pipe(
@@ -295,6 +314,44 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
295314
// TODO(mmalerba): Should not depend on combobox
296315
private readonly _combobox = inject(CdkCombobox, InjectFlags.Optional);
297316

317+
/**
318+
* Validator that produces an error if multiple values are selected in a single selection
319+
* listbox.
320+
* @param control The control to validate
321+
* @return A validation error or null
322+
*/
323+
private _validateMultipleValues: ValidatorFn = (control: AbstractControl) => {
324+
const controlValue = this._coerceValue(control.value);
325+
if (!this.multiple && controlValue.length > 1) {
326+
return {'cdkListboxMultipleValues': true};
327+
}
328+
return null;
329+
};
330+
331+
/**
332+
* Validator that produces an error if any selected values are not valid options for this listbox.
333+
* @param control The control to validate
334+
* @return A validation error or null
335+
*/
336+
private _validateInvalidValues: ValidatorFn = (control: AbstractControl) => {
337+
const validValues = (this.options ?? []).map(option => option.value);
338+
const controlValue = this._coerceValue(control.value);
339+
const isEqual = this.compareWith ?? Object.is;
340+
const invalidValues = controlValue.filter(
341+
value => !validValues.some(validValue => isEqual(value, validValue)),
342+
);
343+
if (invalidValues.length) {
344+
return {'cdkListboxInvalidValues': {'values': invalidValues}};
345+
}
346+
return null;
347+
};
348+
349+
/** The combined set of validators for this listbox. */
350+
private _validators = Validators.compose([
351+
this._validateMultipleValues,
352+
this._validateInvalidValues,
353+
])!;
354+
298355
constructor() {
299356
this.selectionModelSubject
300357
.pipe(
@@ -312,6 +369,9 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
312369
}
313370
this._initKeyManager();
314371
this._combobox?._registerContent(this.id, 'listbox');
372+
this.options.changes.pipe(takeUntil(this.destroyed)).subscribe(() => {
373+
this._onValidatorChange();
374+
});
315375
this._optionClicked
316376
.pipe(
317377
filter(option => !option.disabled),
@@ -406,6 +466,23 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
406466
this.disabled = isDisabled;
407467
}
408468

469+
/**
470+
* Validate the given control
471+
* @docs-private
472+
*/
473+
validate(control: AbstractControl<any, any>): ValidationErrors | null {
474+
return this._validators(control);
475+
}
476+
477+
/**
478+
* Registers a callback to be called when the form validator changes.
479+
* @param fn The callback to call
480+
* @docs-private
481+
*/
482+
registerOnValidatorChange(fn: () => void) {
483+
this._onValidatorChange = fn;
484+
}
485+
409486
/** Focus the listbox's host element. */
410487
focus() {
411488
this.element.focus();
@@ -558,7 +635,7 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
558635
* @param value The list of new selected values.
559636
*/
560637
private _setSelection(value: T[]) {
561-
this.selectionModel().setSelection(...(value == null ? [] : coerceArray(value)));
638+
this.selectionModel().setSelection(...this._coerceValue(value));
562639
}
563640

564641
/** Update the internal value of the listbox based on the selection model. */
@@ -620,6 +697,15 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
620697
}
621698
});
622699
}
700+
701+
/**
702+
* Coerces a value into an array representing a listbox selection.
703+
* @param value The value to coerce
704+
* @return An array
705+
*/
706+
private _coerceValue(value: T[]) {
707+
return value == null ? [] : coerceArray(value);
708+
}
623709
}
624710

625711
/** Change event that is fired whenever the value of the listbox changes. */

src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@
1515
.demo-listbox .cdk-option-selected {
1616
background: cornflowerblue;
1717
}
18+
19+
.demo-listbox.ng-invalid {
20+
box-shadow: 0 0 0 4px red;
21+
}

src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<h2>formControl</h2>
2-
<ul cdkListbox
2+
<ul #fruitForm="ngForm"
3+
cdkListbox
34
class="demo-listbox"
45
[cdkListboxMultiple]="multiSelectable"
56
[cdkListboxUseActiveDescendant]="activeDescendant"
@@ -9,16 +10,21 @@ <h2>formControl</h2>
910
<li cdkOption="orange">Orange</li>
1011
<li cdkOption="grapefruit">Grapefruit</li>
1112
<li cdkOption="peach">Peach</li>
13+
<li cdkOption="kiwi">Kiwi</li>
1214
</ul>
1315
<select [multiple]="multiSelectable" [formControl]="nativeFruitControl">
1416
<option value="apple">Apple</option>
1517
<option value="orange">Orange</option>
1618
<option value="grapefruit">Grapefruit</option>
1719
<option value="peach">Peach</option>
1820
</select>
21+
<div>
22+
Errors: {{fruitForm.errors | json}}
23+
</div>
1924

2025
<h2>ngModel</h2>
21-
<ul cdkListbox
26+
<ul #fruitModel="ngModel"
27+
cdkListbox
2228
class="demo-listbox"
2329
[cdkListboxMultiple]="multiSelectable"
2430
[cdkListboxUseActiveDescendant]="activeDescendant"
@@ -36,6 +42,9 @@ <h2>ngModel</h2>
3642
<option value="grapefruit">Grapefruit</option>
3743
<option value="peach">Peach</option>
3844
</select>
45+
<div>
46+
Errors: {{fruitModel.errors | json}}
47+
</div>
3948

4049
<h2>value binding</h2>
4150
<ul cdkListbox

0 commit comments

Comments
 (0)