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

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,15 +1,14 @@
1
1
  import * as i0 from '@angular/core';
2
- import { forwardRef, EventEmitter, Directive, Inject, Input, Output, Optional, ContentChildren, NgModule } from '@angular/core';
2
+ import { inject, ElementRef, Directive, Input, ChangeDetectorRef, InjectFlags, forwardRef, Output, ContentChildren, NgModule } from '@angular/core';
3
3
  import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
4
4
  import { SPACE, ENTER, 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 { defer, merge, Subject } from 'rxjs';
8
- import { startWith, switchMap, takeUntil } from 'rxjs/operators';
9
- import { NG_VALUE_ACCESSOR } from '@angular/forms';
10
- import * as i1 from '@angular/cdk/bidi';
11
- import * as i2 from '@angular/cdk-experimental/combobox';
12
- import { CDK_COMBOBOX } from '@angular/cdk-experimental/combobox';
7
+ import { Subject, BehaviorSubject, defer, merge, combineLatest } from 'rxjs';
8
+ import { startWith, switchMap, mapTo, takeUntil, filter, take } from 'rxjs/operators';
9
+ import { Validators, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
10
+ import { Directionality } from '@angular/cdk/bidi';
11
+ import { CdkCombobox } from '@angular/cdk-experimental/combobox';
13
12
 
14
13
  /**
15
14
  * @license
@@ -18,115 +17,116 @@ import { CDK_COMBOBOX } from '@angular/cdk-experimental/combobox';
18
17
  * Use of this source code is governed by an MIT-style license that can be
19
18
  * found in the LICENSE file at https://angular.io/license
20
19
  */
20
+ /** The next id to use for creating unique DOM IDs. */
21
21
  let nextId = 0;
22
- let listboxId = 0;
23
- const CDK_LISTBOX_VALUE_ACCESSOR = {
24
- provide: NG_VALUE_ACCESSOR,
25
- useExisting: forwardRef(() => CdkListbox),
26
- multi: true,
27
- };
22
+ // TODO(mmalerba):
23
+ // - should listbox wrap be configurable?
24
+ // - should skipping disabled options be configurable?
25
+ /** A selectable option in a listbox. */
28
26
  class CdkOption {
29
- constructor(_elementRef, listbox) {
30
- this._elementRef = _elementRef;
31
- this.listbox = listbox;
32
- this._selected = false;
27
+ constructor() {
28
+ this._generatedId = `cdk-option-${nextId++}`;
33
29
  this._disabled = false;
30
+ /** The option's host element */
31
+ this.element = inject(ElementRef).nativeElement;
32
+ /** The parent listbox this option belongs to. */
33
+ this.listbox = inject(CdkListbox);
34
+ /** Emits when the option is destroyed. */
35
+ this.destroyed = new Subject();
36
+ /** Emits when the option is clicked. */
37
+ this._clicked = new Subject();
38
+ /** Whether the option is currently active. */
34
39
  this._active = false;
35
- /** The id of the option, set to a uniqueid if the user does not provide one. */
36
- this.id = `cdk-option-${nextId++}`;
37
- this.selectionChange = new EventEmitter();
38
40
  }
39
- get selected() {
40
- return this._selected;
41
+ /** The id of the option's host element. */
42
+ get id() {
43
+ return this._id || this._generatedId;
41
44
  }
42
- set selected(value) {
43
- if (!this._disabled) {
44
- this._selected = coerceBooleanProperty(value);
45
- }
45
+ set id(value) {
46
+ this._id = value;
46
47
  }
48
+ /** Whether this option is disabled. */
47
49
  get disabled() {
48
- return this._disabled;
50
+ return this.listbox.disabled || this._disabled;
49
51
  }
50
52
  set disabled(value) {
51
53
  this._disabled = coerceBooleanProperty(value);
52
54
  }
53
- /** The form value of the option. */
54
- get value() {
55
- return this._value;
55
+ /** The tabindex of the option when it is enabled. */
56
+ get enabledTabIndex() {
57
+ return this._enabledTabIndex === undefined
58
+ ? this.listbox.enabledTabIndex
59
+ : this._enabledTabIndex;
56
60
  }
57
- set value(value) {
58
- if (this.selected && value !== this._value) {
59
- this.deselect();
60
- }
61
- this._value = value;
61
+ set enabledTabIndex(value) {
62
+ this._enabledTabIndex = value;
62
63
  }
63
- /** Toggles the selected state, emits a change event through the injected listbox. */
64
- toggle() {
65
- if (!this._isInteractionDisabled()) {
66
- this.selected = !this.selected;
67
- this._emitSelectionChange(true);
68
- }
64
+ ngOnDestroy() {
65
+ this.destroyed.next();
66
+ this.destroyed.complete();
69
67
  }
70
- /** Sets the active property true if the option and listbox aren't disabled. */
71
- activate() {
72
- if (!this._isInteractionDisabled()) {
73
- this._active = true;
74
- }
68
+ /** Whether this option is selected. */
69
+ isSelected() {
70
+ return this.listbox.isSelected(this.value);
75
71
  }
76
- /** Sets the active property false. */
77
- deactivate() {
78
- if (!this._isInteractionDisabled()) {
79
- this._active = false;
80
- }
72
+ /** Whether this option is active. */
73
+ isActive() {
74
+ return this._active;
81
75
  }
82
- /** Sets the selected property true if it was false. */
76
+ /** Toggle the selected state of this option. */
77
+ toggle() {
78
+ this.listbox.toggle(this);
79
+ }
80
+ /** Select this option if it is not selected. */
83
81
  select() {
84
- if (!this.selected) {
85
- this.selected = true;
86
- this._emitSelectionChange();
87
- }
82
+ this.listbox.select(this);
88
83
  }
89
- /** Sets the selected property false if it was true. */
84
+ /** Deselect this option if it is selected. */
90
85
  deselect() {
91
- if (this.selected) {
92
- this.selected = false;
93
- this._emitSelectionChange();
94
- }
86
+ this.listbox.deselect(this);
95
87
  }
96
- /** Applies focus to the option. */
88
+ /** Focus this option. */
97
89
  focus() {
98
- this._elementRef.nativeElement.focus();
99
- }
100
- /** Returns true if the option or listbox are disabled, and false otherwise. */
101
- _isInteractionDisabled() {
102
- return this.listbox.disabled || this._disabled;
103
- }
104
- /** Emits a change event extending the Option Selection Change Event interface. */
105
- _emitSelectionChange(isUserInput = false) {
106
- this.selectionChange.emit({
107
- source: this,
108
- isUserInput: isUserInput,
109
- });
110
- }
111
- /** Returns the tab index which depends on the disabled property. */
112
- _getTabIndex() {
113
- return this._isInteractionDisabled() ? null : '-1';
90
+ this.element.focus();
114
91
  }
115
92
  /** Get the label for this element which is required by the FocusableOption interface. */
116
93
  getLabel() {
117
- return (this.typeahead ?? this._elementRef.nativeElement.textContent?.trim()) || '';
94
+ return (this.typeaheadLabel ?? this.element.textContent?.trim()) || '';
118
95
  }
119
- /** Sets the active property to true to enable the active css class. */
96
+ /**
97
+ * Set the option as active.
98
+ * @docs-private
99
+ */
120
100
  setActiveStyles() {
121
101
  this._active = true;
122
102
  }
123
- /** Sets the active property to false to disable the active css class. */
103
+ /**
104
+ * Set the option as inactive.
105
+ * @docs-private
106
+ */
124
107
  setInactiveStyles() {
125
108
  this._active = false;
126
109
  }
110
+ /** Handle focus events on the option. */
111
+ _handleFocus() {
112
+ // Options can wind up getting focused in active descendant mode if the user clicks on them.
113
+ // In this case, we push focus back to the parent listbox to prevent an extra tab stop when
114
+ // the user performs a shift+tab.
115
+ if (this.listbox.useActiveDescendant) {
116
+ this.listbox._setActiveOption(this);
117
+ this.listbox.focus();
118
+ }
119
+ }
120
+ /** Get the tabindex for this option. */
121
+ _getTabIndex() {
122
+ if (this.listbox.useActiveDescendant || this.disabled) {
123
+ return -1;
124
+ }
125
+ return this.isActive() ? this.enabledTabIndex : -1;
126
+ }
127
127
  }
128
- CdkOption.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.0.1", ngImport: i0, type: CdkOption, deps: [{ token: i0.ElementRef }, { token: forwardRef(() => CdkListbox) }], target: i0.ɵɵFactoryTarget.Directive });
129
- CdkOption.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.0.1", type: CdkOption, selector: "[cdkOption]", inputs: { id: "id", selected: "selected", disabled: "disabled", value: "value", typeahead: "typeahead" }, outputs: { selectionChange: "selectionChange" }, host: { attributes: { "role": "option" }, listeners: { "click": "toggle()", "focus": "activate()", "blur": "deactivate()" }, properties: { "id": "id", "attr.aria-selected": "selected || null", "attr.tabindex": "_getTabIndex()", "attr.aria-disabled": "_isInteractionDisabled()", "class.cdk-option-disabled": "_isInteractionDisabled()", "class.cdk-option-active": "_active", "class.cdk-option-selected": "selected" }, classAttribute: "cdk-option" }, exportAs: ["cdkOption"], ngImport: i0 });
128
+ 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 });
130
130
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImport: i0, type: CdkOption, decorators: [{
131
131
  type: Directive,
132
132
  args: [{
@@ -135,70 +135,134 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImpor
135
135
  host: {
136
136
  'role': 'option',
137
137
  'class': 'cdk-option',
138
- '(click)': 'toggle()',
139
- '(focus)': 'activate()',
140
- '(blur)': 'deactivate()',
141
138
  '[id]': 'id',
142
- '[attr.aria-selected]': 'selected || null',
139
+ '[attr.aria-selected]': 'isSelected() || null',
143
140
  '[attr.tabindex]': '_getTabIndex()',
144
- '[attr.aria-disabled]': '_isInteractionDisabled()',
145
- '[class.cdk-option-disabled]': '_isInteractionDisabled()',
146
- '[class.cdk-option-active]': '_active',
147
- '[class.cdk-option-selected]': 'selected',
141
+ '[attr.aria-disabled]': 'disabled',
142
+ '[class.cdk-option-disabled]': 'disabled',
143
+ '[class.cdk-option-active]': 'isActive()',
144
+ '[class.cdk-option-selected]': 'isSelected()',
145
+ '(click)': '_clicked.next()',
146
+ '(focus)': '_handleFocus()',
148
147
  },
149
148
  }]
150
- }], ctorParameters: function () { return [{ type: i0.ElementRef }, { type: CdkListbox, decorators: [{
151
- type: Inject,
152
- args: [forwardRef(() => CdkListbox)]
153
- }] }]; }, propDecorators: { id: [{
154
- type: Input
155
- }], selected: [{
156
- type: Input
157
- }], disabled: [{
149
+ }], propDecorators: { id: [{
158
150
  type: Input
159
151
  }], value: [{
160
- type: Input
161
- }], typeahead: [{
162
- type: Input
163
- }], selectionChange: [{
164
- type: Output
152
+ type: Input,
153
+ args: ['cdkOption']
154
+ }], typeaheadLabel: [{
155
+ type: Input,
156
+ args: ['cdkOptionTypeaheadLabel']
157
+ }], disabled: [{
158
+ type: Input,
159
+ args: ['cdkOptionDisabled']
160
+ }], enabledTabIndex: [{
161
+ type: Input,
162
+ args: ['tabindex']
165
163
  }] } });
166
164
  class CdkListbox {
167
- constructor(_combobox, _dir) {
168
- this._combobox = _combobox;
169
- this._dir = _dir;
170
- this._tabIndex = 0;
171
- /** `View -> model callback called when select has been touched` */
172
- this._onTouched = () => { };
173
- /** `View -> model callback called when value changes` */
174
- this._onChange = () => { };
175
- this.optionSelectionChanges = defer(() => {
176
- const options = this._options;
177
- return options.changes.pipe(startWith(options), switchMap(() => merge(...options.map(option => option.selectionChange))));
178
- });
179
- this._disabled = false;
165
+ constructor() {
166
+ this._generatedId = `cdk-listbox-${nextId++}`;
180
167
  this._multiple = false;
168
+ this._disabled = false;
181
169
  this._useActiveDescendant = false;
182
- this._autoFocus = true;
183
- this._destroyed = new Subject();
184
- this.selectionChange = new EventEmitter();
185
- this.id = `cdk-listbox-${listboxId++}`;
186
- /** Determines the orientation for the list key manager. Affects keyboard interaction. */
170
+ /** The orientation of the listbox. Only affects keyboard interaction, not visual layout. */
187
171
  this.orientation = 'vertical';
188
- this.compareWith = (a1, a2) => a1 === a2;
172
+ /** Emits when the selected value(s) in the listbox change. */
173
+ this.valueChange = new Subject();
174
+ // TODO(mmalerba): Refactor SelectionModel so that its not necessary to create new instances
175
+ /** The selection model used by the listbox. */
176
+ this.selectionModelSubject = new BehaviorSubject(new SelectionModel(this.multiple, [], true, this._compareWith));
177
+ /** Emits when the listbox is destroyed. */
178
+ this.destroyed = new Subject();
179
+ /** The host element of the listbox. */
180
+ this.element = inject(ElementRef).nativeElement;
181
+ /** The change detector for this listbox. */
182
+ this.changeDetectorRef = inject(ChangeDetectorRef);
183
+ /** Callback called when the listbox has been touched */
184
+ this._onTouched = () => { };
185
+ /** Callback called when the listbox value changes */
186
+ this._onChange = () => { };
187
+ /** Callback called when the form validator changes. */
188
+ this._onValidatorChange = () => { };
189
+ /** 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)))))));
191
+ /** The directionality of the page. */
192
+ this._dir = inject(Directionality, InjectFlags.Optional);
193
+ // TODO(mmalerba): Should not depend on combobox
194
+ this._combobox = inject(CdkCombobox, InjectFlags.Optional);
195
+ /**
196
+ * Validator that produces an error if multiple values are selected in a single selection
197
+ * listbox.
198
+ * @param control The control to validate
199
+ * @return A validation error or null
200
+ */
201
+ this._validateMultipleValues = (control) => {
202
+ const controlValue = this._coerceValue(control.value);
203
+ if (!this.multiple && controlValue.length > 1) {
204
+ return { 'cdkListboxMultipleValues': true };
205
+ }
206
+ return null;
207
+ };
208
+ /**
209
+ * Validator that produces an error if any selected values are not valid options for this listbox.
210
+ * @param control The control to validate
211
+ * @return A validation error or null
212
+ */
213
+ this._validateInvalidValues = (control) => {
214
+ const controlValue = this._coerceValue(control.value);
215
+ const invalidValues = this._getValuesWithValidity(controlValue, false);
216
+ if (invalidValues.length) {
217
+ return { 'cdkListboxInvalidValues': { 'values': invalidValues } };
218
+ }
219
+ return null;
220
+ };
221
+ /** The combined set of validators for this listbox. */
222
+ this._validators = Validators.compose([
223
+ this._validateMultipleValues,
224
+ this._validateInvalidValues,
225
+ ]);
226
+ this.selectionModelSubject
227
+ .pipe(switchMap(selectionModel => selectionModel.changed), takeUntil(this.destroyed))
228
+ .subscribe(() => {
229
+ this._updateInternalValue();
230
+ });
231
+ }
232
+ /** The id of the option's host element. */
233
+ get id() {
234
+ return this._id || this._generatedId;
235
+ }
236
+ set id(value) {
237
+ this._id = value;
238
+ }
239
+ /** The tabindex to use when the listbox is enabled. */
240
+ get enabledTabIndex() {
241
+ return this._enabledTabIndex === undefined ? 0 : this._enabledTabIndex;
242
+ }
243
+ set enabledTabIndex(value) {
244
+ this._enabledTabIndex = value;
245
+ }
246
+ /** The value selected in the listbox, represented as an array of option values. */
247
+ get value() {
248
+ return this.selectionModel().selected;
249
+ }
250
+ set value(value) {
251
+ this._setSelection(value);
189
252
  }
190
253
  /**
191
- * Whether the listbox allows multiple options to be selected.
192
- * If `multiple` switches from `true` to `false`, all options are deselected.
254
+ * Whether the listbox allows multiple options to be selected. If the value switches from `true`
255
+ * to `false`, and more than one option is selected, all options are deselected.
193
256
  */
194
257
  get multiple() {
195
258
  return this._multiple;
196
259
  }
197
260
  set multiple(value) {
198
- const coercedValue = coerceBooleanProperty(value);
199
- this._updateSelectionOnMultiSelectionChange(coercedValue);
200
- this._multiple = coercedValue;
261
+ this._multiple = coerceBooleanProperty(value);
262
+ this._updateSelectionModel();
263
+ this._onValidatorChange();
201
264
  }
265
+ /** Whether the listbox is disabled. */
202
266
  get disabled() {
203
267
  return this._disabled;
204
268
  }
@@ -212,226 +276,400 @@ class CdkListbox {
212
276
  set useActiveDescendant(shouldUseActiveDescendant) {
213
277
  this._useActiveDescendant = coerceBooleanProperty(shouldUseActiveDescendant);
214
278
  }
215
- /** Whether on focus the listbox will focus its active option, default to true. */
216
- get autoFocus() {
217
- return this._autoFocus;
279
+ /** The function used to compare option values. */
280
+ get compareWith() {
281
+ return this._compareWith;
218
282
  }
219
- set autoFocus(shouldAutoFocus) {
220
- this._autoFocus = coerceBooleanProperty(shouldAutoFocus);
221
- }
222
- ngOnInit() {
223
- this._selectionModel = new SelectionModel(this.multiple);
283
+ set compareWith(fn) {
284
+ this._compareWith = fn;
285
+ this._updateSelectionModel();
224
286
  }
225
287
  ngAfterContentInit() {
288
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
289
+ this._verifyNoOptionValueCollisions();
290
+ }
226
291
  this._initKeyManager();
227
- this._initSelectionModel();
228
292
  this._combobox?._registerContent(this.id, 'listbox');
229
- this.optionSelectionChanges.subscribe(event => {
230
- this._emitChangeEvent(event.source);
231
- this._updateSelectionModel(event.source);
232
- this.setActiveOption(event.source);
233
- this._updatePanelForSelection(event.source);
293
+ this.options.changes.pipe(takeUntil(this.destroyed)).subscribe(() => {
294
+ this._updateInternalValue();
295
+ this._onValidatorChange();
234
296
  });
297
+ this._optionClicked
298
+ .pipe(filter(option => !option.disabled), takeUntil(this.destroyed))
299
+ .subscribe(option => this._handleOptionClicked(option));
235
300
  }
236
301
  ngOnDestroy() {
237
- this._listKeyManager.change.complete();
238
- this._destroyed.next();
239
- this._destroyed.complete();
302
+ this.listKeyManager.change.complete();
303
+ this.destroyed.next();
304
+ this.destroyed.complete();
240
305
  }
241
- _initKeyManager() {
242
- this._listKeyManager = new ActiveDescendantKeyManager(this._options)
243
- .withWrap()
244
- .withTypeAhead()
245
- .withHomeAndEnd()
246
- .withAllowedModifierKeys(['shiftKey']);
247
- if (this.orientation === 'vertical') {
248
- this._listKeyManager.withVerticalOrientation();
306
+ /**
307
+ * Toggle the selected state of the given option.
308
+ * @param option The option to toggle
309
+ */
310
+ toggle(option) {
311
+ this.toggleValue(option.value);
312
+ }
313
+ /**
314
+ * Toggle the selected state of the given value.
315
+ * @param value The value to toggle
316
+ */
317
+ toggleValue(value) {
318
+ this.selectionModel().toggle(value);
319
+ }
320
+ /**
321
+ * Select the given option.
322
+ * @param option The option to select
323
+ */
324
+ select(option) {
325
+ this.selectValue(option.value);
326
+ }
327
+ /**
328
+ * Select the given value.
329
+ * @param value The value to select
330
+ */
331
+ selectValue(value) {
332
+ this.selectionModel().select(value);
333
+ }
334
+ /**
335
+ * Deselect the given option.
336
+ * @param option The option to deselect
337
+ */
338
+ deselect(option) {
339
+ this.deselectValue(option.value);
340
+ }
341
+ /**
342
+ * Deselect the given value.
343
+ * @param value The value to deselect
344
+ */
345
+ deselectValue(value) {
346
+ this.selectionModel().deselect(value);
347
+ }
348
+ /**
349
+ * Set the selected state of all options.
350
+ * @param isSelected The new selected state to set
351
+ */
352
+ setAllSelected(isSelected) {
353
+ if (!isSelected) {
354
+ this.selectionModel().clear();
249
355
  }
250
356
  else {
251
- this._listKeyManager.withHorizontalOrientation(this._dir?.value || 'ltr');
357
+ this.selectionModel().select(...this.options.toArray().map(option => option.value));
252
358
  }
253
- this._listKeyManager.change.pipe(takeUntil(this._destroyed)).subscribe(() => {
254
- this._updateActiveOption();
255
- });
256
359
  }
257
- _initSelectionModel() {
258
- this._selectionModel.changed
259
- .pipe(takeUntil(this._destroyed))
260
- .subscribe((event) => {
261
- for (const option of event.added) {
262
- option.selected = true;
360
+ /**
361
+ * Get whether the given option is selected.
362
+ * @param option The option to get the selected state of
363
+ */
364
+ isSelected(option) {
365
+ return this.selectionModel().isSelected(option instanceof CdkOption ? option.value : option);
366
+ }
367
+ /**
368
+ * Registers a callback to be invoked when the listbox's value changes from user input.
369
+ * @param fn The callback to register
370
+ * @docs-private
371
+ */
372
+ registerOnChange(fn) {
373
+ this._onChange = fn;
374
+ }
375
+ /**
376
+ * Registers a callback to be invoked when the listbox is blurred by the user.
377
+ * @param fn The callback to register
378
+ * @docs-private
379
+ */
380
+ registerOnTouched(fn) {
381
+ this._onTouched = fn;
382
+ }
383
+ /**
384
+ * Sets the listbox's value.
385
+ * @param value The new value of the listbox
386
+ * @docs-private
387
+ */
388
+ writeValue(value) {
389
+ this._setSelection(value);
390
+ }
391
+ /**
392
+ * Sets the disabled state of the listbox.
393
+ * @param isDisabled The new disabled state
394
+ * @docs-private
395
+ */
396
+ setDisabledState(isDisabled) {
397
+ this.disabled = isDisabled;
398
+ }
399
+ /**
400
+ * Validate the given control
401
+ * @docs-private
402
+ */
403
+ validate(control) {
404
+ return this._validators(control);
405
+ }
406
+ /**
407
+ * Registers a callback to be called when the form validator changes.
408
+ * @param fn The callback to call
409
+ * @docs-private
410
+ */
411
+ registerOnValidatorChange(fn) {
412
+ this._onValidatorChange = fn;
413
+ }
414
+ /** Focus the listbox's host element. */
415
+ focus() {
416
+ this.element.focus();
417
+ }
418
+ /** The selection model used to track the listbox's value. */
419
+ selectionModel() {
420
+ return this.selectionModelSubject.value;
421
+ }
422
+ /**
423
+ * Triggers the given option in response to user interaction.
424
+ * - In single selection mode: selects the option and deselects any other selected option.
425
+ * - In multi selection mode: toggles the selected state of the option.
426
+ * @param option The option to trigger
427
+ */
428
+ triggerOption(option) {
429
+ 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);
263
436
  }
264
- for (const option of event.removed) {
265
- option.selected = false;
437
+ else {
438
+ this.select(option);
266
439
  }
267
- });
440
+ if (changed) {
441
+ this._onChange(this.value);
442
+ this.valueChange.next({
443
+ value: this.value,
444
+ listbox: this,
445
+ option: option,
446
+ });
447
+ }
448
+ }
449
+ }
450
+ /**
451
+ * Sets the given option as active.
452
+ * @param option The option to make active
453
+ */
454
+ _setActiveOption(option) {
455
+ this.listKeyManager.setActiveItem(option);
268
456
  }
269
- _keydown(event) {
457
+ /** Called when the listbox receives focus. */
458
+ _handleFocus() {
459
+ if (!this.useActiveDescendant) {
460
+ this.listKeyManager.setNextItemActive();
461
+ this._focusActiveOption();
462
+ }
463
+ }
464
+ /** Called when the user presses keydown on the listbox. */
465
+ _handleKeydown(event) {
270
466
  if (this._disabled) {
271
467
  return;
272
468
  }
273
- const manager = this._listKeyManager;
274
469
  const { keyCode } = event;
275
- const previousActiveIndex = manager.activeItemIndex;
470
+ const previousActiveIndex = this.listKeyManager.activeItemIndex;
276
471
  if (keyCode === SPACE || keyCode === ENTER) {
277
- if (manager.activeItem && !manager.isTyping()) {
278
- this._toggleActiveOption();
279
- }
472
+ this.triggerOption(this.listKeyManager.activeItem);
280
473
  event.preventDefault();
281
474
  }
282
475
  else {
283
- manager.onKeydown(event);
476
+ this.listKeyManager.onKeydown(event);
284
477
  }
285
478
  /** Will select an option if shift was pressed while navigating to the option */
286
479
  const isArrow = keyCode === UP_ARROW ||
287
480
  keyCode === DOWN_ARROW ||
288
481
  keyCode === LEFT_ARROW ||
289
482
  keyCode === RIGHT_ARROW;
290
- if (isArrow && event.shiftKey && previousActiveIndex !== this._listKeyManager.activeItemIndex) {
291
- this._toggleActiveOption();
483
+ if (isArrow && event.shiftKey && previousActiveIndex !== this.listKeyManager.activeItemIndex) {
484
+ this.triggerOption(this.listKeyManager.activeItem);
292
485
  }
293
486
  }
294
- /** Emits a selection change event, called when an option has its selected state changed. */
295
- _emitChangeEvent(option) {
296
- this.selectionChange.emit({
297
- source: this,
298
- option: option,
299
- });
487
+ /**
488
+ * Called when the focus leaves an element in the listbox.
489
+ * @param event The focusout event
490
+ */
491
+ _handleFocusOut(event) {
492
+ const otherElement = event.relatedTarget;
493
+ if (this.element !== otherElement && !this.element.contains(otherElement)) {
494
+ this._onTouched();
495
+ }
496
+ }
497
+ /** Get the id of the active option if active descendant is being used. */
498
+ _getAriaActiveDescendant() {
499
+ return this._useActiveDescendant ? this.listKeyManager?.activeItem?.id : null;
300
500
  }
301
- /** Updates the selection model after a toggle. */
302
- _updateSelectionModel(option) {
303
- if (!this.multiple && this._selectionModel.selected.length !== 0) {
304
- const previouslySelected = this._selectionModel.selected[0];
305
- this.deselect(previouslySelected);
501
+ /** Get the tabindex for the listbox. */
502
+ _getTabIndex() {
503
+ if (this.disabled) {
504
+ return -1;
505
+ }
506
+ return this.useActiveDescendant || !this.listKeyManager.activeItem ? this.enabledTabIndex : -1;
507
+ }
508
+ /** Initialize the key manager. */
509
+ _initKeyManager() {
510
+ this.listKeyManager = new ActiveDescendantKeyManager(this.options)
511
+ .withWrap()
512
+ .withTypeAhead()
513
+ .withHomeAndEnd()
514
+ .withAllowedModifierKeys(['shiftKey']);
515
+ if (this.orientation === 'vertical') {
516
+ this.listKeyManager.withVerticalOrientation();
517
+ }
518
+ else {
519
+ this.listKeyManager.withHorizontalOrientation(this._dir?.value || 'ltr');
306
520
  }
307
- option.selected ? this._selectionModel.select(option) : this._selectionModel.deselect(option);
521
+ this.listKeyManager.change
522
+ .pipe(takeUntil(this.destroyed))
523
+ .subscribe(() => this._focusActiveOption());
308
524
  }
525
+ // TODO(mmalerba): Should not depend on combobox.
309
526
  _updatePanelForSelection(option) {
310
527
  if (this._combobox) {
311
528
  if (!this.multiple) {
312
- this._combobox.updateAndClose(option.selected ? option.value : []);
529
+ this._combobox.updateAndClose(option.isSelected() ? option.value : []);
313
530
  }
314
531
  else {
315
- this._combobox.updateAndClose(this.getSelectedValues());
532
+ this._combobox.updateAndClose(this.value);
316
533
  }
317
534
  }
318
535
  }
319
- /** Toggles the selected state of the active option if not disabled. */
320
- _toggleActiveOption() {
321
- const activeOption = this._listKeyManager.activeItem;
322
- if (activeOption && !activeOption.disabled) {
323
- activeOption.toggle();
324
- }
325
- }
326
- /** Returns the id of the active option if active descendant is being used. */
327
- _getAriaActiveDescendant() {
328
- return this._useActiveDescendant ? this._listKeyManager?.activeItem?.id : null;
329
- }
330
- /** Updates the activeOption and the active and focus properties of the option. */
331
- _updateActiveOption() {
332
- if (!this._listKeyManager.activeItem) {
333
- return;
334
- }
335
- this._activeOption?.deactivate();
336
- this._activeOption = this._listKeyManager.activeItem;
337
- this._activeOption.activate();
338
- if (!this.useActiveDescendant) {
339
- this._activeOption.focus();
340
- }
341
- }
342
- /** Updates selection states of options when the 'multiple' property changes. */
343
- _updateSelectionOnMultiSelectionChange(value) {
344
- if (this.multiple && !value) {
345
- // Deselect all options instead of arbitrarily keeping one of the selected options.
346
- this.setAllSelected(false);
347
- }
348
- else if (!this.multiple && value) {
349
- this._selectionModel = new SelectionModel(value, this._selectionModel?.selected);
350
- }
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));
351
539
  }
540
+ /** Focus the active option. */
352
541
  _focusActiveOption() {
353
- if (!this.autoFocus) {
354
- return;
355
- }
356
- if (this._listKeyManager.activeItem) {
357
- this.setActiveOption(this._listKeyManager.activeItem);
358
- }
359
- else if (this._options.first) {
360
- this.setActiveOption(this._options.first);
361
- }
362
- }
363
- /** Selects the given option if the option and listbox aren't disabled. */
364
- select(option) {
365
- if (!this.disabled && !option.disabled) {
366
- option.select();
367
- }
368
- }
369
- /** Deselects the given option if the option and listbox aren't disabled. */
370
- deselect(option) {
371
- if (!this.disabled && !option.disabled) {
372
- option.deselect();
373
- }
374
- }
375
- /** Sets the selected state of all options to be the given value. */
376
- setAllSelected(isSelected) {
377
- for (const option of this._options.toArray()) {
378
- isSelected ? this.select(option) : this.deselect(option);
542
+ if (!this.useActiveDescendant) {
543
+ this.listKeyManager.activeItem?.focus();
379
544
  }
380
- }
381
- /** Updates the key manager's active item to the given option. */
382
- setActiveOption(option) {
383
- this._listKeyManager.updateActiveItem(option);
384
- this._updateActiveOption();
545
+ this.changeDetectorRef.markForCheck();
385
546
  }
386
547
  /**
387
- * Saves a callback function to be invoked when the select's value
388
- * changes from user input. Required to implement ControlValueAccessor.
548
+ * Set the selected values.
549
+ * @param value The list of new selected values.
389
550
  */
390
- registerOnChange(fn) {
391
- this._onChange = fn;
551
+ _setSelection(value) {
552
+ const coercedValue = this._coerceValue(value);
553
+ this.selectionModel().setSelection(...(!this.multiple && coercedValue.length > 1
554
+ ? []
555
+ : this._getValuesWithValidity(coercedValue, true)));
556
+ }
557
+ /** Update the internal value of the listbox based on the selection model. */
558
+ _updateInternalValue() {
559
+ 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) => {
568
+ const aIndex = this._getIndexForValue(indexCache, a);
569
+ const bIndex = this._getIndexForValue(indexCache, b);
570
+ return aIndex - bIndex;
571
+ });
572
+ this.changeDetectorRef.markForCheck();
392
573
  }
393
574
  /**
394
- * Saves a callback function to be invoked when the select is blurred
395
- * by the user. Required to implement ControlValueAccessor.
575
+ * Gets the index of the given value in the given list of options.
576
+ * @param cache The cache of indices found so far
577
+ * @param value The value to find
578
+ * @return The index of the value in the options list
396
579
  */
397
- registerOnTouched(fn) {
398
- this._onTouched = fn;
399
- }
400
- /** Sets the select's value. Required to implement ControlValueAccessor. */
401
- writeValue(values) {
402
- if (this._options) {
403
- this._setSelectionByValue(values);
580
+ _getIndexForValue(cache, value) {
581
+ const isEqual = this.compareWith || Object.is;
582
+ if (!cache.has(value)) {
583
+ let index = -1;
584
+ for (let i = 0; i < this.options.length; i++) {
585
+ if (isEqual(value, this.options.get(i).value)) {
586
+ index = i;
587
+ break;
588
+ }
589
+ }
590
+ cache.set(value, index);
404
591
  }
592
+ return cache.get(value);
405
593
  }
406
- /** Disables the select. Required to implement ControlValueAccessor. */
407
- setDisabledState(isDisabled) {
408
- this.disabled = isDisabled;
409
- }
410
- /** Returns the values of the currently selected options. */
411
- getSelectedValues() {
412
- return this._options.filter(option => option.selected).map(option => option.value);
413
- }
414
- /** Selects an option that has the corresponding given value. */
415
- _setSelectionByValue(values) {
416
- for (const option of this._options.toArray()) {
417
- this.deselect(option);
418
- }
419
- const valuesArray = coerceArray(values);
420
- for (const value of valuesArray) {
421
- const correspondingOption = this._options.find((option) => {
422
- return option.value != null && this.compareWith(option.value, value);
423
- });
424
- if (correspondingOption) {
425
- this.select(correspondingOption);
426
- if (!this.multiple) {
594
+ /**
595
+ * Handle the user clicking an option.
596
+ * @param option The option that was clicked.
597
+ */
598
+ _handleOptionClicked(option) {
599
+ this.listKeyManager.setActiveItem(option);
600
+ this.triggerOption(option);
601
+ this._updatePanelForSelection(option);
602
+ }
603
+ /** Verifies that no two options represent the same value under the compareWith function. */
604
+ _verifyNoOptionValueCollisions() {
605
+ combineLatest([
606
+ this.selectionModelSubject,
607
+ this.options.changes.pipe(startWith(this.options)),
608
+ ]).subscribe(() => {
609
+ const isEqual = this.compareWith ?? Object.is;
610
+ for (let i = 0; i < this.options.length; i++) {
611
+ const option = this.options.get(i);
612
+ let duplicate = null;
613
+ for (let j = i + 1; j < this.options.length; j++) {
614
+ const other = this.options.get(j);
615
+ if (isEqual(option.value, other.value)) {
616
+ duplicate = other;
617
+ break;
618
+ }
619
+ }
620
+ if (duplicate) {
621
+ // TODO(mmalerba): Link to docs about this.
622
+ if (this.compareWith) {
623
+ console.warn(`Found multiple CdkOption representing the same value under the given compareWith function`, {
624
+ option1: option.element,
625
+ option2: duplicate.element,
626
+ compareWith: this.compareWith,
627
+ });
628
+ }
629
+ else {
630
+ console.warn(`Found multiple CdkOption with the same value`, {
631
+ option1: option.element,
632
+ option2: duplicate.element,
633
+ });
634
+ }
427
635
  return;
428
636
  }
429
637
  }
430
- }
638
+ });
639
+ }
640
+ /**
641
+ * Coerces a value into an array representing a listbox selection.
642
+ * @param value The value to coerce
643
+ * @return An array
644
+ */
645
+ _coerceValue(value) {
646
+ return value == null ? [] : coerceArray(value);
647
+ }
648
+ /**
649
+ * Get the sublist of values with the given validity.
650
+ * @param values The list of values
651
+ * @param valid Whether to get valid values
652
+ * @return The sublist of values with the requested validity
653
+ */
654
+ _getValuesWithValidity(values, valid) {
655
+ const isEqual = this.compareWith || Object.is;
656
+ const validValues = (this.options || []).map(option => option.value);
657
+ return values.filter(value => valid === validValues.some(validValue => isEqual(value, validValue)));
431
658
  }
432
659
  }
433
- CdkListbox.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.0.1", ngImport: i0, type: CdkListbox, deps: [{ token: CDK_COMBOBOX, optional: true }, { token: i1.Directionality, optional: true }], target: i0.ɵɵFactoryTarget.Directive });
434
- CdkListbox.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "14.0.1", type: CdkListbox, selector: "[cdkListbox]", inputs: { id: "id", multiple: "multiple", disabled: "disabled", useActiveDescendant: "useActiveDescendant", autoFocus: "autoFocus", orientation: ["listboxOrientation", "orientation"], compareWith: "compareWith" }, outputs: { selectionChange: "selectionChange" }, host: { attributes: { "role": "listbox" }, listeners: { "focus": "_focusActiveOption()", "keydown": "_keydown($event)" }, properties: { "id": "id", "attr.tabindex": "_tabIndex", "attr.aria-disabled": "disabled", "attr.aria-multiselectable": "multiple", "attr.aria-activedescendant": "_getAriaActiveDescendant()", "attr.aria-orientation": "orientation" }, classAttribute: "cdk-listbox" }, providers: [CDK_LISTBOX_VALUE_ACCESSOR], queries: [{ propertyName: "_options", predicate: CdkOption, descendants: true }], exportAs: ["cdkListbox"], ngImport: i0 });
660
+ 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: [
662
+ {
663
+ provide: NG_VALUE_ACCESSOR,
664
+ useExisting: forwardRef(() => CdkListbox),
665
+ multi: true,
666
+ },
667
+ {
668
+ provide: NG_VALIDATORS,
669
+ useExisting: forwardRef(() => CdkListbox),
670
+ multi: true,
671
+ },
672
+ ], queries: [{ propertyName: "options", predicate: CdkOption, descendants: true }], exportAs: ["cdkListbox"], ngImport: i0 });
435
673
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImport: i0, type: CdkListbox, decorators: [{
436
674
  type: Directive,
437
675
  args: [{
@@ -441,43 +679,57 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImpor
441
679
  'role': 'listbox',
442
680
  'class': 'cdk-listbox',
443
681
  '[id]': 'id',
444
- '(focus)': '_focusActiveOption()',
445
- '(keydown)': '_keydown($event)',
446
- '[attr.tabindex]': '_tabIndex',
682
+ '[attr.tabindex]': '_getTabIndex()',
447
683
  '[attr.aria-disabled]': 'disabled',
448
684
  '[attr.aria-multiselectable]': 'multiple',
449
685
  '[attr.aria-activedescendant]': '_getAriaActiveDescendant()',
450
686
  '[attr.aria-orientation]': 'orientation',
687
+ '(focus)': '_handleFocus()',
688
+ '(keydown)': '_handleKeydown($event)',
689
+ '(focusout)': '_handleFocusOut($event)',
451
690
  },
452
- providers: [CDK_LISTBOX_VALUE_ACCESSOR],
691
+ providers: [
692
+ {
693
+ provide: NG_VALUE_ACCESSOR,
694
+ useExisting: forwardRef(() => CdkListbox),
695
+ multi: true,
696
+ },
697
+ {
698
+ provide: NG_VALIDATORS,
699
+ useExisting: forwardRef(() => CdkListbox),
700
+ multi: true,
701
+ },
702
+ ],
453
703
  }]
454
- }], ctorParameters: function () { return [{ type: i2.CdkCombobox, decorators: [{
455
- type: Optional
456
- }, {
457
- type: Inject,
458
- args: [CDK_COMBOBOX]
459
- }] }, { type: i1.Directionality, decorators: [{
460
- type: Optional
461
- }] }]; }, propDecorators: { _options: [{
462
- type: ContentChildren,
463
- args: [CdkOption, { descendants: true }]
464
- }], selectionChange: [{
465
- type: Output
466
- }], id: [{
704
+ }], ctorParameters: function () { return []; }, propDecorators: { id: [{
467
705
  type: Input
706
+ }], enabledTabIndex: [{
707
+ type: Input,
708
+ args: ['tabindex']
709
+ }], value: [{
710
+ type: Input,
711
+ args: ['cdkListboxValue']
468
712
  }], multiple: [{
469
- type: Input
713
+ type: Input,
714
+ args: ['cdkListboxMultiple']
470
715
  }], disabled: [{
471
- type: Input
716
+ type: Input,
717
+ args: ['cdkListboxDisabled']
472
718
  }], useActiveDescendant: [{
473
- type: Input
474
- }], autoFocus: [{
475
- type: Input
719
+ type: Input,
720
+ args: ['cdkListboxUseActiveDescendant']
476
721
  }], orientation: [{
477
722
  type: Input,
478
- args: ['listboxOrientation']
723
+ args: ['cdkListboxOrientation']
479
724
  }], compareWith: [{
480
- type: Input
725
+ type: Input,
726
+ args: ['cdkListboxCompareWith']
727
+ }], valueChange: [{
728
+ type: Output,
729
+ args: ['cdkListboxValueChange']
730
+ }], options: [{
731
+ type: ContentChildren,
732
+ args: [CdkOption, { descendants: true }]
481
733
  }] } });
482
734
 
483
735
  /**
@@ -521,5 +773,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.0.1", ngImpor
521
773
  * Generated bundle index. Do not edit.
522
774
  */
523
775
 
524
- export { CDK_LISTBOX_VALUE_ACCESSOR, CdkListbox, CdkListboxModule, CdkOption };
776
+ export { CdkListbox, CdkListboxModule, CdkOption };
525
777
  //# sourceMappingURL=listbox.mjs.map