@angular/cdk-experimental 14.1.0-next.2 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,13 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { inject, ElementRef, Directive, Input, ChangeDetectorRef, InjectFlags, forwardRef, Output, ContentChildren, NgModule } from '@angular/core';
3
3
  import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
4
- import { SPACE, ENTER, UP_ARROW, DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW } from '@angular/cdk/keycodes';
4
+ import { A, hasModifierKey, SPACE, ENTER, HOME, END, UP_ARROW, DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW } from '@angular/cdk/keycodes';
5
5
  import { coerceBooleanProperty, coerceArray } from '@angular/cdk/coercion';
6
6
  import { SelectionModel } from '@angular/cdk/collections';
7
- import { Subject, BehaviorSubject, defer, merge, combineLatest } from 'rxjs';
8
- import { startWith, switchMap, mapTo, takeUntil, filter, take } from 'rxjs/operators';
7
+ import { Subject, defer, merge } from 'rxjs';
8
+ import { startWith, switchMap, map, takeUntil, filter } from 'rxjs/operators';
9
9
  import { Validators, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
10
10
  import { Directionality } from '@angular/cdk/bidi';
11
- import { CdkCombobox } from '@angular/cdk-experimental/combobox';
12
11
 
13
12
  /**
14
13
  * @license
@@ -19,9 +18,33 @@ import { CdkCombobox } from '@angular/cdk-experimental/combobox';
19
18
  */
20
19
  /** The next id to use for creating unique DOM IDs. */
21
20
  let nextId = 0;
22
- // TODO(mmalerba):
23
- // - should listbox wrap be configurable?
24
- // - should skipping disabled options be configurable?
21
+ /**
22
+ * An implementation of SelectionModel that internally always represents the selection as a
23
+ * multi-selection. This is necessary so that we can recover the full selection if the user
24
+ * switches the listbox from single-selection to multi-selection after initialization.
25
+ *
26
+ * This selection model may report multiple selected values, even if it is in single-selection
27
+ * mode. It is up to the user (CdkListbox) to check for invalid selections.
28
+ */
29
+ class ListboxSelectionModel extends SelectionModel {
30
+ constructor(multiple = false, initiallySelectedValues, emitChanges = true, compareWith) {
31
+ super(true, initiallySelectedValues, emitChanges, compareWith);
32
+ this.multiple = multiple;
33
+ }
34
+ isMultipleSelection() {
35
+ return this.multiple;
36
+ }
37
+ select(...values) {
38
+ // The super class is always in multi-selection mode, so we need to override the behavior if
39
+ // this selection model actually belongs to a single-selection listbox.
40
+ if (this.multiple) {
41
+ return super.select(...values);
42
+ }
43
+ else {
44
+ return super.setSelection(...values);
45
+ }
46
+ }
47
+ }
25
48
  /** A selectable option in a listbox. */
26
49
  class CdkOption {
27
50
  constructor() {
@@ -67,7 +90,7 @@ class CdkOption {
67
90
  }
68
91
  /** Whether this option is selected. */
69
92
  isSelected() {
70
- return this.listbox.isSelected(this.value);
93
+ return this.listbox.isSelected(this);
71
94
  }
72
95
  /** Whether this option is active. */
73
96
  isActive() {
@@ -126,7 +149,7 @@ class CdkOption {
126
149
  }
127
150
  }
128
151
  CdkOption.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.0.1", ngImport: i0, type: CdkOption, deps: [], target: i0.ɵɵFactoryTarget.Directive });
129
- CdkOption.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.0.1", type: CdkOption, selector: "[cdkOption]", inputs: { id: "id", value: ["cdkOption", "value"], typeaheadLabel: ["cdkOptionTypeaheadLabel", "typeaheadLabel"], disabled: ["cdkOptionDisabled", "disabled"], enabledTabIndex: ["tabindex", "enabledTabIndex"] }, host: { attributes: { "role": "option" }, listeners: { "click": "_clicked.next()", "focus": "_handleFocus()" }, properties: { "id": "id", "attr.aria-selected": "isSelected() || null", "attr.tabindex": "_getTabIndex()", "attr.aria-disabled": "disabled", "class.cdk-option-disabled": "disabled", "class.cdk-option-active": "isActive()", "class.cdk-option-selected": "isSelected()" }, classAttribute: "cdk-option" }, exportAs: ["cdkOption"], ngImport: i0 });
152
+ CdkOption.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.0.1", type: CdkOption, selector: "[cdkOption]", inputs: { id: "id", value: ["cdkOption", "value"], typeaheadLabel: ["cdkOptionTypeaheadLabel", "typeaheadLabel"], disabled: ["cdkOptionDisabled", "disabled"], enabledTabIndex: ["tabindex", "enabledTabIndex"] }, host: { attributes: { "role": "option" }, listeners: { "click": "_clicked.next($event)", "focus": "_handleFocus()" }, properties: { "id": "id", "attr.aria-selected": "isSelected() || null", "attr.tabindex": "_getTabIndex()", "attr.aria-disabled": "disabled", "class.cdk-option-disabled": "disabled", "class.cdk-option-active": "isActive()", "class.cdk-option-selected": "isSelected()" }, classAttribute: "cdk-option" }, exportAs: ["cdkOption"], ngImport: i0 });
130
153
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImport: i0, type: CdkOption, decorators: [{
131
154
  type: Directive,
132
155
  args: [{
@@ -142,7 +165,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImpor
142
165
  '[class.cdk-option-disabled]': 'disabled',
143
166
  '[class.cdk-option-active]': 'isActive()',
144
167
  '[class.cdk-option-selected]': 'isSelected()',
145
- '(click)': '_clicked.next()',
168
+ '(click)': '_clicked.next($event)',
146
169
  '(focus)': '_handleFocus()',
147
170
  },
148
171
  }]
@@ -164,22 +187,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImpor
164
187
  class CdkListbox {
165
188
  constructor() {
166
189
  this._generatedId = `cdk-listbox-${nextId++}`;
167
- this._multiple = false;
168
190
  this._disabled = false;
169
191
  this._useActiveDescendant = false;
170
- /** The orientation of the listbox. Only affects keyboard interaction, not visual layout. */
171
- this.orientation = 'vertical';
192
+ this._orientation = 'vertical';
193
+ this._keyboardNavigationWraps = true;
194
+ this._keyboardNavigationSkipsDisabled = true;
172
195
  /** Emits when the selected value(s) in the listbox change. */
173
196
  this.valueChange = new Subject();
174
- // TODO(mmalerba): Refactor SelectionModel so that its not necessary to create new instances
175
197
  /** The selection model used by the listbox. */
176
- this.selectionModelSubject = new BehaviorSubject(new SelectionModel(this.multiple, [], true, this._compareWith));
198
+ this.selectionModel = new ListboxSelectionModel();
177
199
  /** Emits when the listbox is destroyed. */
178
200
  this.destroyed = new Subject();
179
201
  /** The host element of the listbox. */
180
202
  this.element = inject(ElementRef).nativeElement;
181
203
  /** The change detector for this listbox. */
182
204
  this.changeDetectorRef = inject(ChangeDetectorRef);
205
+ /** Whether the currently selected value in the selection model is invalid. */
206
+ this._invalid = false;
207
+ /** The last user-triggered option. */
208
+ this._lastTriggered = null;
183
209
  /** Callback called when the listbox has been touched */
184
210
  this._onTouched = () => { };
185
211
  /** Callback called when the listbox value changes */
@@ -187,21 +213,23 @@ class CdkListbox {
187
213
  /** Callback called when the form validator changes. */
188
214
  this._onValidatorChange = () => { };
189
215
  /** Emits when an option has been clicked. */
190
- this._optionClicked = defer(() => this.options.changes.pipe(startWith(this.options), switchMap(options => merge(...options.map(option => option._clicked.pipe(mapTo(option)))))));
216
+ this._optionClicked = defer(() => this.options.changes.pipe(startWith(this.options), switchMap(options => merge(...options.map(option => option._clicked.pipe(map(event => ({ option, event }))))))));
191
217
  /** The directionality of the page. */
192
218
  this._dir = inject(Directionality, InjectFlags.Optional);
193
- // TODO(mmalerba): Should not depend on combobox
194
- this._combobox = inject(CdkCombobox, InjectFlags.Optional);
219
+ /** A predicate that skips disabled options. */
220
+ this._skipDisabledPredicate = (option) => option.disabled;
221
+ /** A predicate that does not skip any options. */
222
+ this._skipNonePredicate = () => false;
195
223
  /**
196
224
  * Validator that produces an error if multiple values are selected in a single selection
197
225
  * listbox.
198
226
  * @param control The control to validate
199
227
  * @return A validation error or null
200
228
  */
201
- this._validateMultipleValues = (control) => {
229
+ this._validateUnexpectedMultipleValues = (control) => {
202
230
  const controlValue = this._coerceValue(control.value);
203
231
  if (!this.multiple && controlValue.length > 1) {
204
- return { 'cdkListboxMultipleValues': true };
232
+ return { 'cdkListboxUnexpectedMultipleValues': true };
205
233
  }
206
234
  return null;
207
235
  };
@@ -210,22 +238,21 @@ class CdkListbox {
210
238
  * @param control The control to validate
211
239
  * @return A validation error or null
212
240
  */
213
- this._validateInvalidValues = (control) => {
241
+ this._validateUnexpectedOptionValues = (control) => {
214
242
  const controlValue = this._coerceValue(control.value);
215
- const invalidValues = this._getValuesWithValidity(controlValue, false);
243
+ const invalidValues = this._getInvalidOptionValues(controlValue);
216
244
  if (invalidValues.length) {
217
- return { 'cdkListboxInvalidValues': { 'values': invalidValues } };
245
+ return { 'cdkListboxUnexpectedOptionValues': { 'values': invalidValues } };
218
246
  }
219
247
  return null;
220
248
  };
221
249
  /** The combined set of validators for this listbox. */
222
250
  this._validators = Validators.compose([
223
- this._validateMultipleValues,
224
- this._validateInvalidValues,
251
+ this._validateUnexpectedMultipleValues,
252
+ this._validateUnexpectedOptionValues,
225
253
  ]);
226
- this.selectionModelSubject
227
- .pipe(switchMap(selectionModel => selectionModel.changed), takeUntil(this.destroyed))
228
- .subscribe(() => {
254
+ // Update the internal value whenever the selection model's selection changes.
255
+ this.selectionModel.changed.pipe(startWith(null), takeUntil(this.destroyed)).subscribe(() => {
229
256
  this._updateInternalValue();
230
257
  });
231
258
  }
@@ -245,7 +272,7 @@ class CdkListbox {
245
272
  }
246
273
  /** The value selected in the listbox, represented as an array of option values. */
247
274
  get value() {
248
- return this.selectionModel().selected;
275
+ return this._invalid ? [] : this.selectionModel.selected;
249
276
  }
250
277
  set value(value) {
251
278
  this._setSelection(value);
@@ -255,12 +282,11 @@ class CdkListbox {
255
282
  * to `false`, and more than one option is selected, all options are deselected.
256
283
  */
257
284
  get multiple() {
258
- return this._multiple;
285
+ return this.selectionModel.multiple;
259
286
  }
260
287
  set multiple(value) {
261
- this._multiple = coerceBooleanProperty(value);
262
- this._updateSelectionModel();
263
- this._onValidatorChange();
288
+ this.selectionModel.multiple = coerceBooleanProperty(value);
289
+ this._updateInternalValue();
264
290
  }
265
291
  /** Whether the listbox is disabled. */
266
292
  get disabled() {
@@ -276,27 +302,58 @@ class CdkListbox {
276
302
  set useActiveDescendant(shouldUseActiveDescendant) {
277
303
  this._useActiveDescendant = coerceBooleanProperty(shouldUseActiveDescendant);
278
304
  }
305
+ /** The orientation of the listbox. Only affects keyboard interaction, not visual layout. */
306
+ get orientation() {
307
+ return this._orientation;
308
+ }
309
+ set orientation(value) {
310
+ this._orientation = value === 'horizontal' ? 'horizontal' : 'vertical';
311
+ if (value === 'horizontal') {
312
+ this.listKeyManager?.withHorizontalOrientation(this._dir?.value || 'ltr');
313
+ }
314
+ else {
315
+ this.listKeyManager?.withVerticalOrientation();
316
+ }
317
+ }
279
318
  /** The function used to compare option values. */
280
319
  get compareWith() {
281
- return this._compareWith;
320
+ return this.selectionModel.compareWith;
282
321
  }
283
322
  set compareWith(fn) {
284
- this._compareWith = fn;
285
- this._updateSelectionModel();
323
+ this.selectionModel.compareWith = fn;
324
+ }
325
+ /**
326
+ * Whether the keyboard navigation should wrap when the user presses arrow down on the last item
327
+ * or arrow up on the first item.
328
+ */
329
+ get keyboardNavigationWraps() {
330
+ return this._keyboardNavigationWraps;
331
+ }
332
+ set keyboardNavigationWraps(wrap) {
333
+ this._keyboardNavigationWraps = coerceBooleanProperty(wrap);
334
+ this.listKeyManager?.withWrap(this._keyboardNavigationWraps);
335
+ }
336
+ /** Whether keyboard navigation should skip over disabled items. */
337
+ get keyboardNavigationSkipsDisabled() {
338
+ return this._keyboardNavigationSkipsDisabled;
339
+ }
340
+ set keyboardNavigationSkipsDisabled(skip) {
341
+ this._keyboardNavigationSkipsDisabled = coerceBooleanProperty(skip);
342
+ this.listKeyManager?.skipPredicate(this._keyboardNavigationSkipsDisabled ? this._skipDisabledPredicate : this._skipNonePredicate);
286
343
  }
287
344
  ngAfterContentInit() {
288
345
  if (typeof ngDevMode === 'undefined' || ngDevMode) {
289
346
  this._verifyNoOptionValueCollisions();
290
347
  }
291
348
  this._initKeyManager();
292
- this._combobox?._registerContent(this.id, 'listbox');
293
- this.options.changes.pipe(takeUntil(this.destroyed)).subscribe(() => {
349
+ // Update the internal value whenever the options change, as this may change the validity of
350
+ // the current selection
351
+ this.options.changes.pipe(startWith(this.options), takeUntil(this.destroyed)).subscribe(() => {
294
352
  this._updateInternalValue();
295
- this._onValidatorChange();
296
353
  });
297
354
  this._optionClicked
298
- .pipe(filter(option => !option.disabled), takeUntil(this.destroyed))
299
- .subscribe(option => this._handleOptionClicked(option));
355
+ .pipe(filter(({ option }) => !option.disabled), takeUntil(this.destroyed))
356
+ .subscribe(({ option, event }) => this._handleOptionClicked(option, event));
300
357
  }
301
358
  ngOnDestroy() {
302
359
  this.listKeyManager.change.complete();
@@ -315,7 +372,10 @@ class CdkListbox {
315
372
  * @param value The value to toggle
316
373
  */
317
374
  toggleValue(value) {
318
- this.selectionModel().toggle(value);
375
+ if (this._invalid) {
376
+ this.selectionModel.clear(false);
377
+ }
378
+ this.selectionModel.toggle(value);
319
379
  }
320
380
  /**
321
381
  * Select the given option.
@@ -329,7 +389,10 @@ class CdkListbox {
329
389
  * @param value The value to select
330
390
  */
331
391
  selectValue(value) {
332
- this.selectionModel().select(value);
392
+ if (this._invalid) {
393
+ this.selectionModel.clear(false);
394
+ }
395
+ this.selectionModel.select(value);
333
396
  }
334
397
  /**
335
398
  * Deselect the given option.
@@ -343,7 +406,10 @@ class CdkListbox {
343
406
  * @param value The value to deselect
344
407
  */
345
408
  deselectValue(value) {
346
- this.selectionModel().deselect(value);
409
+ if (this._invalid) {
410
+ this.selectionModel.clear(false);
411
+ }
412
+ this.selectionModel.deselect(value);
347
413
  }
348
414
  /**
349
415
  * Set the selected state of all options.
@@ -351,10 +417,13 @@ class CdkListbox {
351
417
  */
352
418
  setAllSelected(isSelected) {
353
419
  if (!isSelected) {
354
- this.selectionModel().clear();
420
+ this.selectionModel.clear();
355
421
  }
356
422
  else {
357
- this.selectionModel().select(...this.options.toArray().map(option => option.value));
423
+ if (this._invalid) {
424
+ this.selectionModel.clear(false);
425
+ }
426
+ this.selectionModel.select(...this.options.map(option => option.value));
358
427
  }
359
428
  }
360
429
  /**
@@ -362,7 +431,17 @@ class CdkListbox {
362
431
  * @param option The option to get the selected state of
363
432
  */
364
433
  isSelected(option) {
365
- return this.selectionModel().isSelected(option instanceof CdkOption ? option.value : option);
434
+ return this.isValueSelected(option.value);
435
+ }
436
+ /**
437
+ * Get whether the given value is selected.
438
+ * @param value The value to get the selected state of
439
+ */
440
+ isValueSelected(value) {
441
+ if (this._invalid) {
442
+ return false;
443
+ }
444
+ return this.selectionModel.isSelected(value);
366
445
  }
367
446
  /**
368
447
  * Registers a callback to be invoked when the listbox's value changes from user input.
@@ -415,10 +494,6 @@ class CdkListbox {
415
494
  focus() {
416
495
  this.element.focus();
417
496
  }
418
- /** The selection model used to track the listbox's value. */
419
- selectionModel() {
420
- return this.selectionModelSubject.value;
421
- }
422
497
  /**
423
498
  * Triggers the given option in response to user interaction.
424
499
  * - In single selection mode: selects the option and deselects any other selected option.
@@ -427,16 +502,10 @@ class CdkListbox {
427
502
  */
428
503
  triggerOption(option) {
429
504
  if (option && !option.disabled) {
430
- let changed = false;
431
- this.selectionModel()
432
- .changed.pipe(take(1), takeUntil(this.destroyed))
433
- .subscribe(() => (changed = true));
434
- if (this.multiple) {
435
- this.toggle(option);
436
- }
437
- else {
438
- this.select(option);
439
- }
505
+ this._lastTriggered = option;
506
+ const changed = this.multiple
507
+ ? this.selectionModel.toggle(option.value)
508
+ : this.selectionModel.select(option.value);
440
509
  if (changed) {
441
510
  this._onChange(this.value);
442
511
  this.valueChange.next({
@@ -447,6 +516,44 @@ class CdkListbox {
447
516
  }
448
517
  }
449
518
  }
519
+ /**
520
+ * Trigger the given range of options in response to user interaction.
521
+ * Should only be called in multi-selection mode.
522
+ * @param trigger The option that was triggered
523
+ * @param from The start index of the options to toggle
524
+ * @param to The end index of the options to toggle
525
+ * @param on Whether to toggle the option range on
526
+ */
527
+ triggerRange(trigger, from, to, on) {
528
+ if (this.disabled || (trigger && trigger.disabled)) {
529
+ return;
530
+ }
531
+ this._lastTriggered = trigger;
532
+ const isEqual = this.compareWith ?? Object.is;
533
+ const updateValues = [...this.options]
534
+ .slice(Math.max(0, Math.min(from, to)), Math.min(this.options.length, Math.max(from, to) + 1))
535
+ .filter(option => !option.disabled)
536
+ .map(option => option.value);
537
+ const selected = [...this.value];
538
+ for (const updateValue of updateValues) {
539
+ const selectedIndex = selected.findIndex(selectedValue => isEqual(selectedValue, updateValue));
540
+ if (on && selectedIndex === -1) {
541
+ selected.push(updateValue);
542
+ }
543
+ else if (!on && selectedIndex !== -1) {
544
+ selected.splice(selectedIndex, 1);
545
+ }
546
+ }
547
+ let changed = this.selectionModel.setSelection(...selected);
548
+ if (changed) {
549
+ this._onChange(this.value);
550
+ this.valueChange.next({
551
+ value: this.value,
552
+ listbox: this,
553
+ option: trigger,
554
+ });
555
+ }
556
+ }
450
557
  /**
451
558
  * Sets the given option as active.
452
559
  * @param option The option to make active
@@ -468,19 +575,62 @@ class CdkListbox {
468
575
  }
469
576
  const { keyCode } = event;
470
577
  const previousActiveIndex = this.listKeyManager.activeItemIndex;
578
+ const ctrlKeys = ['ctrlKey', 'metaKey'];
579
+ if (this.multiple && keyCode === A && hasModifierKey(event, ...ctrlKeys)) {
580
+ // Toggle all options off if they're all selected, otherwise toggle them all on.
581
+ this.triggerRange(null, 0, this.options.length - 1, this.options.length !== this.value.length);
582
+ event.preventDefault();
583
+ return;
584
+ }
585
+ if (this.multiple &&
586
+ (keyCode === SPACE || keyCode === ENTER) &&
587
+ hasModifierKey(event, 'shiftKey')) {
588
+ if (this.listKeyManager.activeItem && this.listKeyManager.activeItemIndex != null) {
589
+ this.triggerRange(this.listKeyManager.activeItem, this._getLastTriggeredIndex() ?? this.listKeyManager.activeItemIndex, this.listKeyManager.activeItemIndex, !this.listKeyManager.activeItem.isSelected());
590
+ }
591
+ event.preventDefault();
592
+ return;
593
+ }
594
+ if (this.multiple &&
595
+ keyCode === HOME &&
596
+ hasModifierKey(event, ...ctrlKeys) &&
597
+ hasModifierKey(event, 'shiftKey')) {
598
+ const trigger = this.listKeyManager.activeItem;
599
+ if (trigger) {
600
+ const from = this.listKeyManager.activeItemIndex;
601
+ this.listKeyManager.setFirstItemActive();
602
+ this.triggerRange(trigger, from, this.listKeyManager.activeItemIndex, !trigger.isSelected());
603
+ }
604
+ event.preventDefault();
605
+ return;
606
+ }
607
+ if (this.multiple &&
608
+ keyCode === END &&
609
+ hasModifierKey(event, ...ctrlKeys) &&
610
+ hasModifierKey(event, 'shiftKey')) {
611
+ const trigger = this.listKeyManager.activeItem;
612
+ if (trigger) {
613
+ const from = this.listKeyManager.activeItemIndex;
614
+ this.listKeyManager.setLastItemActive();
615
+ this.triggerRange(trigger, from, this.listKeyManager.activeItemIndex, !trigger.isSelected());
616
+ }
617
+ event.preventDefault();
618
+ return;
619
+ }
471
620
  if (keyCode === SPACE || keyCode === ENTER) {
472
621
  this.triggerOption(this.listKeyManager.activeItem);
473
622
  event.preventDefault();
623
+ return;
474
624
  }
475
- else {
476
- this.listKeyManager.onKeydown(event);
477
- }
478
- /** Will select an option if shift was pressed while navigating to the option */
479
- const isArrow = keyCode === UP_ARROW ||
625
+ const isNavKey = keyCode === UP_ARROW ||
480
626
  keyCode === DOWN_ARROW ||
481
627
  keyCode === LEFT_ARROW ||
482
- keyCode === RIGHT_ARROW;
483
- if (isArrow && event.shiftKey && previousActiveIndex !== this.listKeyManager.activeItemIndex) {
628
+ keyCode === RIGHT_ARROW ||
629
+ keyCode === HOME ||
630
+ keyCode === END;
631
+ this.listKeyManager.onKeydown(event);
632
+ // Will select an option if shift was pressed while navigating to the option
633
+ if (isNavKey && event.shiftKey && previousActiveIndex !== this.listKeyManager.activeItemIndex) {
484
634
  this.triggerOption(this.listKeyManager.activeItem);
485
635
  }
486
636
  }
@@ -508,10 +658,13 @@ class CdkListbox {
508
658
  /** Initialize the key manager. */
509
659
  _initKeyManager() {
510
660
  this.listKeyManager = new ActiveDescendantKeyManager(this.options)
511
- .withWrap()
661
+ .withWrap(this._keyboardNavigationWraps)
512
662
  .withTypeAhead()
513
663
  .withHomeAndEnd()
514
- .withAllowedModifierKeys(['shiftKey']);
664
+ .withAllowedModifierKeys(['shiftKey'])
665
+ .skipPredicate(this._keyboardNavigationSkipsDisabled
666
+ ? this._skipDisabledPredicate
667
+ : this._skipNonePredicate);
515
668
  if (this.orientation === 'vertical') {
516
669
  this.listKeyManager.withVerticalOrientation();
517
670
  }
@@ -522,21 +675,6 @@ class CdkListbox {
522
675
  .pipe(takeUntil(this.destroyed))
523
676
  .subscribe(() => this._focusActiveOption());
524
677
  }
525
- // TODO(mmalerba): Should not depend on combobox.
526
- _updatePanelForSelection(option) {
527
- if (this._combobox) {
528
- if (!this.multiple) {
529
- this._combobox.updateAndClose(option.isSelected() ? option.value : []);
530
- }
531
- else {
532
- this._combobox.updateAndClose(this.value);
533
- }
534
- }
535
- }
536
- /** Update the selection mode when the 'multiple' property changes. */
537
- _updateSelectionModel() {
538
- this.selectionModelSubject.next(new SelectionModel(this.multiple, !this.multiple && this.value.length > 1 ? [] : this.value.slice(), true, this._compareWith));
539
- }
540
678
  /** Focus the active option. */
541
679
  _focusActiveOption() {
542
680
  if (!this.useActiveDescendant) {
@@ -549,26 +687,23 @@ class CdkListbox {
549
687
  * @param value The list of new selected values.
550
688
  */
551
689
  _setSelection(value) {
552
- const coercedValue = this._coerceValue(value);
553
- this.selectionModel().setSelection(...(!this.multiple && coercedValue.length > 1
554
- ? []
555
- : this._getValuesWithValidity(coercedValue, true)));
690
+ if (this._invalid) {
691
+ this.selectionModel.clear(false);
692
+ }
693
+ this.selectionModel.setSelection(...this._coerceValue(value));
556
694
  }
557
695
  /** Update the internal value of the listbox based on the selection model. */
558
696
  _updateInternalValue() {
559
697
  const indexCache = new Map();
560
- // Check if we need to remove any values due to them becoming invalid
561
- // (e.g. if the option was removed from the DOM.)
562
- const selected = this.selectionModel().selected;
563
- const validSelected = this._getValuesWithValidity(selected, true);
564
- if (validSelected.length != selected.length) {
565
- this.selectionModel().setSelection(...validSelected);
566
- }
567
- this.selectionModel().sort((a, b) => {
698
+ this.selectionModel.sort((a, b) => {
568
699
  const aIndex = this._getIndexForValue(indexCache, a);
569
700
  const bIndex = this._getIndexForValue(indexCache, b);
570
701
  return aIndex - bIndex;
571
702
  });
703
+ const selected = this.selectionModel.selected;
704
+ this._invalid =
705
+ (!this.multiple && selected.length > 1) || !!this._getInvalidOptionValues(selected).length;
706
+ this._onValidatorChange();
572
707
  this.changeDetectorRef.markForCheck();
573
708
  }
574
709
  /**
@@ -595,17 +730,18 @@ class CdkListbox {
595
730
  * Handle the user clicking an option.
596
731
  * @param option The option that was clicked.
597
732
  */
598
- _handleOptionClicked(option) {
733
+ _handleOptionClicked(option, event) {
599
734
  this.listKeyManager.setActiveItem(option);
600
- this.triggerOption(option);
601
- this._updatePanelForSelection(option);
735
+ if (event.shiftKey && this.multiple) {
736
+ this.triggerRange(option, this._getLastTriggeredIndex() ?? this.listKeyManager.activeItemIndex, this.listKeyManager.activeItemIndex, !option.isSelected());
737
+ }
738
+ else {
739
+ this.triggerOption(option);
740
+ }
602
741
  }
603
742
  /** Verifies that no two options represent the same value under the compareWith function. */
604
743
  _verifyNoOptionValueCollisions() {
605
- combineLatest([
606
- this.selectionModelSubject,
607
- this.options.changes.pipe(startWith(this.options)),
608
- ]).subscribe(() => {
744
+ this.options.changes.pipe(startWith(this.options), takeUntil(this.destroyed)).subscribe(() => {
609
745
  const isEqual = this.compareWith ?? Object.is;
610
746
  for (let i = 0; i < this.options.length; i++) {
611
747
  const option = this.options.get(i);
@@ -646,19 +782,23 @@ class CdkListbox {
646
782
  return value == null ? [] : coerceArray(value);
647
783
  }
648
784
  /**
649
- * Get the sublist of values with the given validity.
785
+ * Get the sublist of values that do not represent valid option values in this listbox.
650
786
  * @param values The list of values
651
- * @param valid Whether to get valid values
652
- * @return The sublist of values with the requested validity
787
+ * @return The sublist of values that are not valid option values
653
788
  */
654
- _getValuesWithValidity(values, valid) {
789
+ _getInvalidOptionValues(values) {
655
790
  const isEqual = this.compareWith || Object.is;
656
791
  const validValues = (this.options || []).map(option => option.value);
657
- return values.filter(value => valid === validValues.some(validValue => isEqual(value, validValue)));
792
+ return values.filter(value => !validValues.some(validValue => isEqual(value, validValue)));
793
+ }
794
+ /** Get the index of the last triggered option. */
795
+ _getLastTriggeredIndex() {
796
+ const index = this.options.toArray().indexOf(this._lastTriggered);
797
+ return index === -1 ? null : index;
658
798
  }
659
799
  }
660
800
  CdkListbox.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.0.1", ngImport: i0, type: CdkListbox, deps: [], target: i0.ɵɵFactoryTarget.Directive });
661
- CdkListbox.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.0.1", type: CdkListbox, selector: "[cdkListbox]", inputs: { id: "id", enabledTabIndex: ["tabindex", "enabledTabIndex"], value: ["cdkListboxValue", "value"], multiple: ["cdkListboxMultiple", "multiple"], disabled: ["cdkListboxDisabled", "disabled"], useActiveDescendant: ["cdkListboxUseActiveDescendant", "useActiveDescendant"], orientation: ["cdkListboxOrientation", "orientation"], compareWith: ["cdkListboxCompareWith", "compareWith"] }, outputs: { valueChange: "cdkListboxValueChange" }, host: { attributes: { "role": "listbox" }, listeners: { "focus": "_handleFocus()", "keydown": "_handleKeydown($event)", "focusout": "_handleFocusOut($event)" }, properties: { "id": "id", "attr.tabindex": "_getTabIndex()", "attr.aria-disabled": "disabled", "attr.aria-multiselectable": "multiple", "attr.aria-activedescendant": "_getAriaActiveDescendant()", "attr.aria-orientation": "orientation" }, classAttribute: "cdk-listbox" }, providers: [
801
+ CdkListbox.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.0.1", type: CdkListbox, selector: "[cdkListbox]", inputs: { id: "id", enabledTabIndex: ["tabindex", "enabledTabIndex"], value: ["cdkListboxValue", "value"], multiple: ["cdkListboxMultiple", "multiple"], disabled: ["cdkListboxDisabled", "disabled"], useActiveDescendant: ["cdkListboxUseActiveDescendant", "useActiveDescendant"], orientation: ["cdkListboxOrientation", "orientation"], compareWith: ["cdkListboxCompareWith", "compareWith"], keyboardNavigationWraps: ["cdkListboxKeyboardNavigationWraps", "keyboardNavigationWraps"], keyboardNavigationSkipsDisabled: ["cdkListboxKeyboardNavigationSkipsDisabled", "keyboardNavigationSkipsDisabled"] }, outputs: { valueChange: "cdkListboxValueChange" }, host: { attributes: { "role": "listbox" }, listeners: { "focus": "_handleFocus()", "keydown": "_handleKeydown($event)", "focusout": "_handleFocusOut($event)" }, properties: { "id": "id", "attr.tabindex": "_getTabIndex()", "attr.aria-disabled": "disabled", "attr.aria-multiselectable": "multiple", "attr.aria-activedescendant": "_getAriaActiveDescendant()", "attr.aria-orientation": "orientation" }, classAttribute: "cdk-listbox" }, providers: [
662
802
  {
663
803
  provide: NG_VALUE_ACCESSOR,
664
804
  useExisting: forwardRef(() => CdkListbox),
@@ -724,6 +864,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImpor
724
864
  }], compareWith: [{
725
865
  type: Input,
726
866
  args: ['cdkListboxCompareWith']
867
+ }], keyboardNavigationWraps: [{
868
+ type: Input,
869
+ args: ['cdkListboxKeyboardNavigationWraps']
870
+ }], keyboardNavigationSkipsDisabled: [{
871
+ type: Input,
872
+ args: ['cdkListboxKeyboardNavigationSkipsDisabled']
727
873
  }], valueChange: [{
728
874
  type: Output,
729
875
  args: ['cdkListboxValueChange']