listbox.mjs 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925
  1. import * as i0 from '@angular/core';
  2. import { inject, signal, ElementRef, booleanAttribute, Directive, Input, NgZone, ChangeDetectorRef, Renderer2, forwardRef, Output, ContentChildren, NgModule } from '@angular/core';
  3. import { NG_VALUE_ACCESSOR } from '@angular/forms';
  4. import { Subject, defer, merge } from 'rxjs';
  5. import { startWith, switchMap, map, takeUntil, filter } from 'rxjs/operators';
  6. import { A, S as SPACE, c as ENTER, H as HOME, E as END, U as UP_ARROW, D as DOWN_ARROW, L as LEFT_ARROW, R as RIGHT_ARROW } from './keycodes-CpHkExLC.mjs';
  7. import { A as ActiveDescendantKeyManager } from './activedescendant-key-manager-DC3-fwQI.mjs';
  8. import { S as SelectionModel } from './selection-model-CeeHVIcP.mjs';
  9. import { _ as _IdGenerator } from './id-generator-Dw_9dSDu.mjs';
  10. import { D as Directionality } from './directionality-CBXD4hga.mjs';
  11. import { P as Platform } from './platform-DmdVEw_C.mjs';
  12. import { hasModifierKey } from './keycodes.mjs';
  13. import { c as coerceArray } from './array-I1yfCXUO.mjs';
  14. import './list-key-manager-CyOIXo8P.mjs';
  15. import './typeahead-9ZW4Dtsf.mjs';
  16. import '@angular/common';
  17. /**
  18. * An implementation of SelectionModel that internally always represents the selection as a
  19. * multi-selection. This is necessary so that we can recover the full selection if the user
  20. * switches the listbox from single-selection to multi-selection after initialization.
  21. *
  22. * This selection model may report multiple selected values, even if it is in single-selection
  23. * mode. It is up to the user (CdkListbox) to check for invalid selections.
  24. */
  25. class ListboxSelectionModel extends SelectionModel {
  26. multiple;
  27. constructor(multiple = false, initiallySelectedValues, emitChanges = true, compareWith) {
  28. super(true, initiallySelectedValues, emitChanges, compareWith);
  29. this.multiple = multiple;
  30. }
  31. isMultipleSelection() {
  32. return this.multiple;
  33. }
  34. select(...values) {
  35. // The super class is always in multi-selection mode, so we need to override the behavior if
  36. // this selection model actually belongs to a single-selection listbox.
  37. if (this.multiple) {
  38. return super.select(...values);
  39. }
  40. else {
  41. return super.setSelection(...values);
  42. }
  43. }
  44. }
  45. /** A selectable option in a listbox. */
  46. class CdkOption {
  47. /** The id of the option's host element. */
  48. get id() {
  49. return this._id || this._generatedId;
  50. }
  51. set id(value) {
  52. this._id = value;
  53. }
  54. _id;
  55. _generatedId = inject(_IdGenerator).getId('cdk-option-');
  56. /** The value of this option. */
  57. value;
  58. /**
  59. * The text used to locate this item during listbox typeahead. If not specified,
  60. * the `textContent` of the item will be used.
  61. */
  62. typeaheadLabel;
  63. /** Whether this option is disabled. */
  64. get disabled() {
  65. return this.listbox.disabled || this._disabled();
  66. }
  67. set disabled(value) {
  68. this._disabled.set(value);
  69. }
  70. _disabled = signal(false);
  71. /** The tabindex of the option when it is enabled. */
  72. get enabledTabIndex() {
  73. return this._enabledTabIndex() === undefined
  74. ? this.listbox.enabledTabIndex
  75. : this._enabledTabIndex();
  76. }
  77. set enabledTabIndex(value) {
  78. this._enabledTabIndex.set(value);
  79. }
  80. _enabledTabIndex = signal(undefined);
  81. /** The option's host element */
  82. element = inject(ElementRef).nativeElement;
  83. /** The parent listbox this option belongs to. */
  84. listbox = inject(CdkListbox);
  85. /** Emits when the option is destroyed. */
  86. destroyed = new Subject();
  87. /** Emits when the option is clicked. */
  88. _clicked = new Subject();
  89. ngOnDestroy() {
  90. this.destroyed.next();
  91. this.destroyed.complete();
  92. }
  93. /** Whether this option is selected. */
  94. isSelected() {
  95. return this.listbox.isSelected(this);
  96. }
  97. /** Whether this option is active. */
  98. isActive() {
  99. return this.listbox.isActive(this);
  100. }
  101. /** Toggle the selected state of this option. */
  102. toggle() {
  103. this.listbox.toggle(this);
  104. }
  105. /** Select this option if it is not selected. */
  106. select() {
  107. this.listbox.select(this);
  108. }
  109. /** Deselect this option if it is selected. */
  110. deselect() {
  111. this.listbox.deselect(this);
  112. }
  113. /** Focus this option. */
  114. focus() {
  115. this.element.focus();
  116. }
  117. /** Get the label for this element which is required by the FocusableOption interface. */
  118. getLabel() {
  119. return (this.typeaheadLabel ?? this.element.textContent?.trim()) || '';
  120. }
  121. /**
  122. * No-op implemented as a part of `Highlightable`.
  123. * @docs-private
  124. */
  125. setActiveStyles() {
  126. // If the listbox is using `aria-activedescendant` the option won't have focus so the
  127. // browser won't scroll them into view automatically so we need to do it ourselves.
  128. if (this.listbox.useActiveDescendant) {
  129. this.element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
  130. }
  131. }
  132. /**
  133. * No-op implemented as a part of `Highlightable`.
  134. * @docs-private
  135. */
  136. setInactiveStyles() { }
  137. /** Handle focus events on the option. */
  138. _handleFocus() {
  139. // Options can wind up getting focused in active descendant mode if the user clicks on them.
  140. // In this case, we push focus back to the parent listbox to prevent an extra tab stop when
  141. // the user performs a shift+tab.
  142. if (this.listbox.useActiveDescendant) {
  143. this.listbox._setActiveOption(this);
  144. this.listbox.focus();
  145. }
  146. }
  147. /** Get the tabindex for this option. */
  148. _getTabIndex() {
  149. if (this.listbox.useActiveDescendant || this.disabled) {
  150. return -1;
  151. }
  152. return this.isActive() ? this.enabledTabIndex : -1;
  153. }
  154. static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: CdkOption, deps: [], target: i0.ɵɵFactoryTarget.Directive });
  155. static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "19.2.6", type: CdkOption, isStandalone: true, selector: "[cdkOption]", inputs: { id: "id", value: ["cdkOption", "value"], typeaheadLabel: ["cdkOptionTypeaheadLabel", "typeaheadLabel"], disabled: ["cdkOptionDisabled", "disabled", booleanAttribute], enabledTabIndex: ["tabindex", "enabledTabIndex"] }, host: { attributes: { "role": "option" }, listeners: { "click": "_clicked.next($event)", "focus": "_handleFocus()" }, properties: { "id": "id", "attr.aria-selected": "isSelected()", "attr.tabindex": "_getTabIndex()", "attr.aria-disabled": "disabled", "class.cdk-option-active": "isActive()" }, classAttribute: "cdk-option" }, exportAs: ["cdkOption"], ngImport: i0 });
  156. }
  157. i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: CdkOption, decorators: [{
  158. type: Directive,
  159. args: [{
  160. selector: '[cdkOption]',
  161. exportAs: 'cdkOption',
  162. host: {
  163. 'role': 'option',
  164. 'class': 'cdk-option',
  165. '[id]': 'id',
  166. '[attr.aria-selected]': 'isSelected()',
  167. '[attr.tabindex]': '_getTabIndex()',
  168. '[attr.aria-disabled]': 'disabled',
  169. '[class.cdk-option-active]': 'isActive()',
  170. '(click)': '_clicked.next($event)',
  171. '(focus)': '_handleFocus()',
  172. },
  173. }]
  174. }], propDecorators: { id: [{
  175. type: Input
  176. }], value: [{
  177. type: Input,
  178. args: ['cdkOption']
  179. }], typeaheadLabel: [{
  180. type: Input,
  181. args: ['cdkOptionTypeaheadLabel']
  182. }], disabled: [{
  183. type: Input,
  184. args: [{ alias: 'cdkOptionDisabled', transform: booleanAttribute }]
  185. }], enabledTabIndex: [{
  186. type: Input,
  187. args: ['tabindex']
  188. }] } });
  189. class CdkListbox {
  190. _cleanupWindowBlur;
  191. /** The id of the option's host element. */
  192. get id() {
  193. return this._id || this._generatedId;
  194. }
  195. set id(value) {
  196. this._id = value;
  197. }
  198. _id;
  199. _generatedId = inject(_IdGenerator).getId('cdk-listbox-');
  200. /** The tabindex to use when the listbox is enabled. */
  201. get enabledTabIndex() {
  202. return this._enabledTabIndex() === undefined ? 0 : this._enabledTabIndex();
  203. }
  204. set enabledTabIndex(value) {
  205. this._enabledTabIndex.set(value);
  206. }
  207. _enabledTabIndex = signal(undefined);
  208. /** The value selected in the listbox, represented as an array of option values. */
  209. get value() {
  210. return this._invalid ? [] : this.selectionModel.selected;
  211. }
  212. set value(value) {
  213. this._setSelection(value);
  214. }
  215. /**
  216. * Whether the listbox allows multiple options to be selected. If the value switches from `true`
  217. * to `false`, and more than one option is selected, all options are deselected.
  218. */
  219. get multiple() {
  220. return this.selectionModel.multiple;
  221. }
  222. set multiple(value) {
  223. this.selectionModel.multiple = value;
  224. if (this.options) {
  225. this._updateInternalValue();
  226. }
  227. }
  228. /** Whether the listbox is disabled. */
  229. get disabled() {
  230. return this._disabled();
  231. }
  232. set disabled(value) {
  233. this._disabled.set(value);
  234. }
  235. _disabled = signal(false);
  236. /** Whether the listbox will use active descendant or will move focus onto the options. */
  237. get useActiveDescendant() {
  238. return this._useActiveDescendant();
  239. }
  240. set useActiveDescendant(value) {
  241. this._useActiveDescendant.set(value);
  242. }
  243. _useActiveDescendant = signal(false);
  244. /** The orientation of the listbox. Only affects keyboard interaction, not visual layout. */
  245. get orientation() {
  246. return this._orientation;
  247. }
  248. set orientation(value) {
  249. this._orientation = value === 'horizontal' ? 'horizontal' : 'vertical';
  250. if (value === 'horizontal') {
  251. this.listKeyManager?.withHorizontalOrientation(this._dir?.value || 'ltr');
  252. }
  253. else {
  254. this.listKeyManager?.withVerticalOrientation();
  255. }
  256. }
  257. _orientation = 'vertical';
  258. /** The function used to compare option values. */
  259. get compareWith() {
  260. return this.selectionModel.compareWith;
  261. }
  262. set compareWith(fn) {
  263. this.selectionModel.compareWith = fn;
  264. }
  265. /**
  266. * Whether the keyboard navigation should wrap when the user presses arrow down on the last item
  267. * or arrow up on the first item.
  268. */
  269. get navigationWrapDisabled() {
  270. return this._navigationWrapDisabled;
  271. }
  272. set navigationWrapDisabled(wrap) {
  273. this._navigationWrapDisabled = wrap;
  274. this.listKeyManager?.withWrap(!this._navigationWrapDisabled);
  275. }
  276. _navigationWrapDisabled = false;
  277. /** Whether keyboard navigation should skip over disabled items. */
  278. get navigateDisabledOptions() {
  279. return this._navigateDisabledOptions;
  280. }
  281. set navigateDisabledOptions(skip) {
  282. this._navigateDisabledOptions = skip;
  283. this.listKeyManager?.skipPredicate(this._navigateDisabledOptions ? this._skipNonePredicate : this._skipDisabledPredicate);
  284. }
  285. _navigateDisabledOptions = false;
  286. /** Emits when the selected value(s) in the listbox change. */
  287. valueChange = new Subject();
  288. /** The child options in this listbox. */
  289. options;
  290. /** The selection model used by the listbox. */
  291. selectionModel = new ListboxSelectionModel();
  292. /** The key manager that manages keyboard navigation for this listbox. */
  293. listKeyManager;
  294. /** Emits when the listbox is destroyed. */
  295. destroyed = new Subject();
  296. /** The host element of the listbox. */
  297. element = inject(ElementRef).nativeElement;
  298. /** The Angular zone. */
  299. ngZone = inject(NgZone);
  300. /** The change detector for this listbox. */
  301. changeDetectorRef = inject(ChangeDetectorRef);
  302. /** Whether the currently selected value in the selection model is invalid. */
  303. _invalid = false;
  304. /** The last user-triggered option. */
  305. _lastTriggered = null;
  306. /** Callback called when the listbox has been touched */
  307. _onTouched = () => { };
  308. /** Callback called when the listbox value changes */
  309. _onChange = () => { };
  310. /** Emits when an option has been clicked. */
  311. _optionClicked = defer(() => this.options.changes.pipe(startWith(this.options), switchMap(options => merge(...options.map(option => option._clicked.pipe(map(event => ({ option, event }))))))));
  312. /** The directionality of the page. */
  313. _dir = inject(Directionality, { optional: true });
  314. /** Whether the component is being rendered in the browser. */
  315. _isBrowser = inject(Platform).isBrowser;
  316. /** A predicate that skips disabled options. */
  317. _skipDisabledPredicate = (option) => option.disabled;
  318. /** A predicate that does not skip any options. */
  319. _skipNonePredicate = () => false;
  320. /** Whether the listbox currently has focus. */
  321. _hasFocus = false;
  322. /** A reference to the option that was active before the listbox lost focus. */
  323. _previousActiveOption = null;
  324. constructor() {
  325. if (this._isBrowser) {
  326. const renderer = inject(Renderer2);
  327. this._cleanupWindowBlur = this.ngZone.runOutsideAngular(() => {
  328. return renderer.listen('window', 'blur', () => {
  329. if (this.element.contains(document.activeElement) && this._previousActiveOption) {
  330. this._setActiveOption(this._previousActiveOption);
  331. this._previousActiveOption = null;
  332. }
  333. });
  334. });
  335. }
  336. }
  337. ngAfterContentInit() {
  338. if (typeof ngDevMode === 'undefined' || ngDevMode) {
  339. this._verifyNoOptionValueCollisions();
  340. this._verifyOptionValues();
  341. }
  342. this._initKeyManager();
  343. // Update the internal value whenever the options or the model value changes.
  344. merge(this.selectionModel.changed, this.options.changes)
  345. .pipe(startWith(null), takeUntil(this.destroyed))
  346. .subscribe(() => this._updateInternalValue());
  347. this._optionClicked
  348. .pipe(filter(({ option }) => !option.disabled), takeUntil(this.destroyed))
  349. .subscribe(({ option, event }) => this._handleOptionClicked(option, event));
  350. }
  351. ngOnDestroy() {
  352. this._cleanupWindowBlur?.();
  353. this.listKeyManager?.destroy();
  354. this.destroyed.next();
  355. this.destroyed.complete();
  356. }
  357. /**
  358. * Toggle the selected state of the given option.
  359. * @param option The option to toggle
  360. */
  361. toggle(option) {
  362. this.toggleValue(option.value);
  363. }
  364. /**
  365. * Toggle the selected state of the given value.
  366. * @param value The value to toggle
  367. */
  368. toggleValue(value) {
  369. if (this._invalid) {
  370. this.selectionModel.clear(false);
  371. }
  372. this.selectionModel.toggle(value);
  373. }
  374. /**
  375. * Select the given option.
  376. * @param option The option to select
  377. */
  378. select(option) {
  379. this.selectValue(option.value);
  380. }
  381. /**
  382. * Select the given value.
  383. * @param value The value to select
  384. */
  385. selectValue(value) {
  386. if (this._invalid) {
  387. this.selectionModel.clear(false);
  388. }
  389. this.selectionModel.select(value);
  390. }
  391. /**
  392. * Deselect the given option.
  393. * @param option The option to deselect
  394. */
  395. deselect(option) {
  396. this.deselectValue(option.value);
  397. }
  398. /**
  399. * Deselect the given value.
  400. * @param value The value to deselect
  401. */
  402. deselectValue(value) {
  403. if (this._invalid) {
  404. this.selectionModel.clear(false);
  405. }
  406. this.selectionModel.deselect(value);
  407. }
  408. /**
  409. * Set the selected state of all options.
  410. * @param isSelected The new selected state to set
  411. */
  412. setAllSelected(isSelected) {
  413. if (!isSelected) {
  414. this.selectionModel.clear();
  415. }
  416. else {
  417. if (this._invalid) {
  418. this.selectionModel.clear(false);
  419. }
  420. this.selectionModel.select(...this.options.map(option => option.value));
  421. }
  422. }
  423. /**
  424. * Get whether the given option is selected.
  425. * @param option The option to get the selected state of
  426. */
  427. isSelected(option) {
  428. return this.isValueSelected(option.value);
  429. }
  430. /**
  431. * Get whether the given option is active.
  432. * @param option The option to get the active state of
  433. */
  434. isActive(option) {
  435. return !!(this.listKeyManager?.activeItem === option);
  436. }
  437. /**
  438. * Get whether the given value is selected.
  439. * @param value The value to get the selected state of
  440. */
  441. isValueSelected(value) {
  442. if (this._invalid) {
  443. return false;
  444. }
  445. return this.selectionModel.isSelected(value);
  446. }
  447. /**
  448. * Registers a callback to be invoked when the listbox's value changes from user input.
  449. * @param fn The callback to register
  450. * @docs-private
  451. */
  452. registerOnChange(fn) {
  453. this._onChange = fn;
  454. }
  455. /**
  456. * Registers a callback to be invoked when the listbox is blurred by the user.
  457. * @param fn The callback to register
  458. * @docs-private
  459. */
  460. registerOnTouched(fn) {
  461. this._onTouched = fn;
  462. }
  463. /**
  464. * Sets the listbox's value.
  465. * @param value The new value of the listbox
  466. * @docs-private
  467. */
  468. writeValue(value) {
  469. this._setSelection(value);
  470. this._verifyOptionValues();
  471. }
  472. /**
  473. * Sets the disabled state of the listbox.
  474. * @param isDisabled The new disabled state
  475. * @docs-private
  476. */
  477. setDisabledState(isDisabled) {
  478. this.disabled = isDisabled;
  479. this.changeDetectorRef.markForCheck();
  480. }
  481. /** Focus the listbox's host element. */
  482. focus() {
  483. this.element.focus();
  484. }
  485. /**
  486. * Triggers the given option in response to user interaction.
  487. * - In single selection mode: selects the option and deselects any other selected option.
  488. * - In multi selection mode: toggles the selected state of the option.
  489. * @param option The option to trigger
  490. */
  491. triggerOption(option) {
  492. if (option && !option.disabled) {
  493. this._lastTriggered = option;
  494. const changed = this.multiple
  495. ? this.selectionModel.toggle(option.value)
  496. : this.selectionModel.select(option.value);
  497. if (changed) {
  498. this._onChange(this.value);
  499. this.valueChange.next({
  500. value: this.value,
  501. listbox: this,
  502. option: option,
  503. });
  504. }
  505. }
  506. }
  507. /**
  508. * Trigger the given range of options in response to user interaction.
  509. * Should only be called in multi-selection mode.
  510. * @param trigger The option that was triggered
  511. * @param from The start index of the options to toggle
  512. * @param to The end index of the options to toggle
  513. * @param on Whether to toggle the option range on
  514. */
  515. triggerRange(trigger, from, to, on) {
  516. if (this.disabled || (trigger && trigger.disabled)) {
  517. return;
  518. }
  519. this._lastTriggered = trigger;
  520. const isEqual = this.compareWith ?? Object.is;
  521. const updateValues = [...this.options]
  522. .slice(Math.max(0, Math.min(from, to)), Math.min(this.options.length, Math.max(from, to) + 1))
  523. .filter(option => !option.disabled)
  524. .map(option => option.value);
  525. const selected = [...this.value];
  526. for (const updateValue of updateValues) {
  527. const selectedIndex = selected.findIndex(selectedValue => isEqual(selectedValue, updateValue));
  528. if (on && selectedIndex === -1) {
  529. selected.push(updateValue);
  530. }
  531. else if (!on && selectedIndex !== -1) {
  532. selected.splice(selectedIndex, 1);
  533. }
  534. }
  535. let changed = this.selectionModel.setSelection(...selected);
  536. if (changed) {
  537. this._onChange(this.value);
  538. this.valueChange.next({
  539. value: this.value,
  540. listbox: this,
  541. option: trigger,
  542. });
  543. }
  544. }
  545. /**
  546. * Sets the given option as active.
  547. * @param option The option to make active
  548. */
  549. _setActiveOption(option) {
  550. this.listKeyManager.setActiveItem(option);
  551. }
  552. /** Called when the listbox receives focus. */
  553. _handleFocus() {
  554. if (!this.useActiveDescendant) {
  555. if (this.selectionModel.selected.length > 0) {
  556. this._setNextFocusToSelectedOption();
  557. }
  558. else {
  559. this.listKeyManager.setNextItemActive();
  560. }
  561. this._focusActiveOption();
  562. }
  563. }
  564. /** Called when the user presses keydown on the listbox. */
  565. _handleKeydown(event) {
  566. if (this.disabled) {
  567. return;
  568. }
  569. const { keyCode } = event;
  570. const previousActiveIndex = this.listKeyManager.activeItemIndex;
  571. const ctrlKeys = ['ctrlKey', 'metaKey'];
  572. if (this.multiple && keyCode === A && hasModifierKey(event, ...ctrlKeys)) {
  573. // Toggle all options off if they're all selected, otherwise toggle them all on.
  574. this.triggerRange(null, 0, this.options.length - 1, this.options.length !== this.value.length);
  575. event.preventDefault();
  576. return;
  577. }
  578. if (this.multiple &&
  579. (keyCode === SPACE || keyCode === ENTER) &&
  580. hasModifierKey(event, 'shiftKey')) {
  581. if (this.listKeyManager.activeItem && this.listKeyManager.activeItemIndex != null) {
  582. this.triggerRange(this.listKeyManager.activeItem, this._getLastTriggeredIndex() ?? this.listKeyManager.activeItemIndex, this.listKeyManager.activeItemIndex, !this.listKeyManager.activeItem.isSelected());
  583. }
  584. event.preventDefault();
  585. return;
  586. }
  587. if (this.multiple &&
  588. keyCode === HOME &&
  589. hasModifierKey(event, ...ctrlKeys) &&
  590. hasModifierKey(event, 'shiftKey')) {
  591. const trigger = this.listKeyManager.activeItem;
  592. if (trigger) {
  593. const from = this.listKeyManager.activeItemIndex;
  594. this.listKeyManager.setFirstItemActive();
  595. this.triggerRange(trigger, from, this.listKeyManager.activeItemIndex, !trigger.isSelected());
  596. }
  597. event.preventDefault();
  598. return;
  599. }
  600. if (this.multiple &&
  601. keyCode === END &&
  602. hasModifierKey(event, ...ctrlKeys) &&
  603. hasModifierKey(event, 'shiftKey')) {
  604. const trigger = this.listKeyManager.activeItem;
  605. if (trigger) {
  606. const from = this.listKeyManager.activeItemIndex;
  607. this.listKeyManager.setLastItemActive();
  608. this.triggerRange(trigger, from, this.listKeyManager.activeItemIndex, !trigger.isSelected());
  609. }
  610. event.preventDefault();
  611. return;
  612. }
  613. if (keyCode === SPACE || keyCode === ENTER) {
  614. this.triggerOption(this.listKeyManager.activeItem);
  615. event.preventDefault();
  616. return;
  617. }
  618. const isNavKey = keyCode === UP_ARROW ||
  619. keyCode === DOWN_ARROW ||
  620. keyCode === LEFT_ARROW ||
  621. keyCode === RIGHT_ARROW ||
  622. keyCode === HOME ||
  623. keyCode === END;
  624. this.listKeyManager.onKeydown(event);
  625. // Will select an option if shift was pressed while navigating to the option
  626. if (isNavKey && event.shiftKey && previousActiveIndex !== this.listKeyManager.activeItemIndex) {
  627. this.triggerOption(this.listKeyManager.activeItem);
  628. }
  629. }
  630. /** Called when a focus moves into the listbox. */
  631. _handleFocusIn() {
  632. // Note that we use a `focusin` handler for this instead of the existing `focus` handler,
  633. // because focus won't land on the listbox if `useActiveDescendant` is enabled.
  634. this._hasFocus = true;
  635. }
  636. /**
  637. * Called when the focus leaves an element in the listbox.
  638. * @param event The focusout event
  639. */
  640. _handleFocusOut(event) {
  641. // Some browsers (e.g. Chrome and Firefox) trigger the focusout event when the user returns back to the document.
  642. // To prevent losing the active option in this case, we store it in `_previousActiveOption` and restore it on the window `blur` event
  643. // This ensures that the `activeItem` matches the actual focused element when the user returns to the document.
  644. this._previousActiveOption = this.listKeyManager.activeItem;
  645. const otherElement = event.relatedTarget;
  646. if (this.element !== otherElement && !this.element.contains(otherElement)) {
  647. this._onTouched();
  648. this._hasFocus = false;
  649. this._setNextFocusToSelectedOption();
  650. }
  651. }
  652. /** Get the id of the active option if active descendant is being used. */
  653. _getAriaActiveDescendant() {
  654. return this.useActiveDescendant ? this.listKeyManager?.activeItem?.id : null;
  655. }
  656. /** Get the tabindex for the listbox. */
  657. _getTabIndex() {
  658. if (this.disabled) {
  659. return -1;
  660. }
  661. return this.useActiveDescendant || !this.listKeyManager.activeItem ? this.enabledTabIndex : -1;
  662. }
  663. /** Initialize the key manager. */
  664. _initKeyManager() {
  665. this.listKeyManager = new ActiveDescendantKeyManager(this.options)
  666. .withWrap(!this._navigationWrapDisabled)
  667. .withTypeAhead()
  668. .withHomeAndEnd()
  669. .withAllowedModifierKeys(['shiftKey'])
  670. .skipPredicate(this._navigateDisabledOptions ? this._skipNonePredicate : this._skipDisabledPredicate);
  671. if (this.orientation === 'vertical') {
  672. this.listKeyManager.withVerticalOrientation();
  673. }
  674. else {
  675. this.listKeyManager.withHorizontalOrientation(this._dir?.value || 'ltr');
  676. }
  677. if (this.selectionModel.selected.length) {
  678. Promise.resolve().then(() => this._setNextFocusToSelectedOption());
  679. }
  680. this.listKeyManager.change.subscribe(() => this._focusActiveOption());
  681. this.options.changes.pipe(takeUntil(this.destroyed)).subscribe(() => {
  682. const activeOption = this.listKeyManager.activeItem;
  683. // If the active option was deleted, we need to reset
  684. // the key manager so it can allow focus back in.
  685. if (activeOption && !this.options.find(option => option === activeOption)) {
  686. this.listKeyManager.setActiveItem(-1);
  687. this.changeDetectorRef.markForCheck();
  688. }
  689. });
  690. }
  691. /** Focus the active option. */
  692. _focusActiveOption() {
  693. if (!this.useActiveDescendant) {
  694. this.listKeyManager.activeItem?.focus();
  695. }
  696. this.changeDetectorRef.markForCheck();
  697. }
  698. /**
  699. * Set the selected values.
  700. * @param value The list of new selected values.
  701. */
  702. _setSelection(value) {
  703. if (this._invalid) {
  704. this.selectionModel.clear(false);
  705. }
  706. this.selectionModel.setSelection(...this._coerceValue(value));
  707. if (!this._hasFocus) {
  708. this._setNextFocusToSelectedOption();
  709. }
  710. }
  711. /** Sets the first selected option as first in the keyboard focus order. */
  712. _setNextFocusToSelectedOption() {
  713. // Null check the options since they only get defined after `ngAfterContentInit`.
  714. const selected = this.options?.find(option => option.isSelected());
  715. if (selected) {
  716. this.listKeyManager.updateActiveItem(selected);
  717. }
  718. }
  719. /** Update the internal value of the listbox based on the selection model. */
  720. _updateInternalValue() {
  721. const indexCache = new Map();
  722. this.selectionModel.sort((a, b) => {
  723. const aIndex = this._getIndexForValue(indexCache, a);
  724. const bIndex = this._getIndexForValue(indexCache, b);
  725. return aIndex - bIndex;
  726. });
  727. const selected = this.selectionModel.selected;
  728. this._invalid =
  729. (!this.multiple && selected.length > 1) || !!this._getInvalidOptionValues(selected).length;
  730. this.changeDetectorRef.markForCheck();
  731. }
  732. /**
  733. * Gets the index of the given value in the given list of options.
  734. * @param cache The cache of indices found so far
  735. * @param value The value to find
  736. * @return The index of the value in the options list
  737. */
  738. _getIndexForValue(cache, value) {
  739. const isEqual = this.compareWith || Object.is;
  740. if (!cache.has(value)) {
  741. let index = -1;
  742. for (let i = 0; i < this.options.length; i++) {
  743. if (isEqual(value, this.options.get(i).value)) {
  744. index = i;
  745. break;
  746. }
  747. }
  748. cache.set(value, index);
  749. }
  750. return cache.get(value);
  751. }
  752. /**
  753. * Handle the user clicking an option.
  754. * @param option The option that was clicked.
  755. */
  756. _handleOptionClicked(option, event) {
  757. event.preventDefault();
  758. this.listKeyManager.setActiveItem(option);
  759. if (event.shiftKey && this.multiple) {
  760. this.triggerRange(option, this._getLastTriggeredIndex() ?? this.listKeyManager.activeItemIndex, this.listKeyManager.activeItemIndex, !option.isSelected());
  761. }
  762. else {
  763. this.triggerOption(option);
  764. }
  765. }
  766. /** Verifies that no two options represent the same value under the compareWith function. */
  767. _verifyNoOptionValueCollisions() {
  768. this.options.changes.pipe(startWith(this.options), takeUntil(this.destroyed)).subscribe(() => {
  769. const isEqual = this.compareWith ?? Object.is;
  770. for (let i = 0; i < this.options.length; i++) {
  771. const option = this.options.get(i);
  772. let duplicate = null;
  773. for (let j = i + 1; j < this.options.length; j++) {
  774. const other = this.options.get(j);
  775. if (isEqual(option.value, other.value)) {
  776. duplicate = other;
  777. break;
  778. }
  779. }
  780. if (duplicate) {
  781. // TODO(mmalerba): Link to docs about this.
  782. if (this.compareWith) {
  783. console.warn(`Found multiple CdkOption representing the same value under the given compareWith function`, {
  784. option1: option.element,
  785. option2: duplicate.element,
  786. compareWith: this.compareWith,
  787. });
  788. }
  789. else {
  790. console.warn(`Found multiple CdkOption with the same value`, {
  791. option1: option.element,
  792. option2: duplicate.element,
  793. });
  794. }
  795. return;
  796. }
  797. }
  798. });
  799. }
  800. /** Verifies that the option values are valid. */
  801. _verifyOptionValues() {
  802. if (this.options && (typeof ngDevMode === 'undefined' || ngDevMode)) {
  803. const selected = this.selectionModel.selected;
  804. const invalidValues = this._getInvalidOptionValues(selected);
  805. if (!this.multiple && selected.length > 1) {
  806. throw Error('Listbox cannot have more than one selected value in multi-selection mode.');
  807. }
  808. if (invalidValues.length) {
  809. throw Error('Listbox has selected values that do not match any of its options.');
  810. }
  811. }
  812. }
  813. /**
  814. * Coerces a value into an array representing a listbox selection.
  815. * @param value The value to coerce
  816. * @return An array
  817. */
  818. _coerceValue(value) {
  819. return value == null ? [] : coerceArray(value);
  820. }
  821. /**
  822. * Get the sublist of values that do not represent valid option values in this listbox.
  823. * @param values The list of values
  824. * @return The sublist of values that are not valid option values
  825. */
  826. _getInvalidOptionValues(values) {
  827. const isEqual = this.compareWith || Object.is;
  828. const validValues = (this.options || []).map(option => option.value);
  829. return values.filter(value => !validValues.some(validValue => isEqual(value, validValue)));
  830. }
  831. /** Get the index of the last triggered option. */
  832. _getLastTriggeredIndex() {
  833. const index = this.options.toArray().indexOf(this._lastTriggered);
  834. return index === -1 ? null : index;
  835. }
  836. static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: CdkListbox, deps: [], target: i0.ɵɵFactoryTarget.Directive });
  837. static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "19.2.6", type: CdkListbox, isStandalone: true, selector: "[cdkListbox]", inputs: { id: "id", enabledTabIndex: ["tabindex", "enabledTabIndex"], value: ["cdkListboxValue", "value"], multiple: ["cdkListboxMultiple", "multiple", booleanAttribute], disabled: ["cdkListboxDisabled", "disabled", booleanAttribute], useActiveDescendant: ["cdkListboxUseActiveDescendant", "useActiveDescendant", booleanAttribute], orientation: ["cdkListboxOrientation", "orientation"], compareWith: ["cdkListboxCompareWith", "compareWith"], navigationWrapDisabled: ["cdkListboxNavigationWrapDisabled", "navigationWrapDisabled", booleanAttribute], navigateDisabledOptions: ["cdkListboxNavigatesDisabledOptions", "navigateDisabledOptions", booleanAttribute] }, outputs: { valueChange: "cdkListboxValueChange" }, host: { attributes: { "role": "listbox" }, listeners: { "focus": "_handleFocus()", "keydown": "_handleKeydown($event)", "focusout": "_handleFocusOut($event)", "focusin": "_handleFocusIn()" }, 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: [
  838. {
  839. provide: NG_VALUE_ACCESSOR,
  840. useExisting: forwardRef(() => CdkListbox),
  841. multi: true,
  842. },
  843. ], queries: [{ propertyName: "options", predicate: CdkOption, descendants: true }], exportAs: ["cdkListbox"], ngImport: i0 });
  844. }
  845. i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: CdkListbox, decorators: [{
  846. type: Directive,
  847. args: [{
  848. selector: '[cdkListbox]',
  849. exportAs: 'cdkListbox',
  850. host: {
  851. 'role': 'listbox',
  852. 'class': 'cdk-listbox',
  853. '[id]': 'id',
  854. '[attr.tabindex]': '_getTabIndex()',
  855. '[attr.aria-disabled]': 'disabled',
  856. '[attr.aria-multiselectable]': 'multiple',
  857. '[attr.aria-activedescendant]': '_getAriaActiveDescendant()',
  858. '[attr.aria-orientation]': 'orientation',
  859. '(focus)': '_handleFocus()',
  860. '(keydown)': '_handleKeydown($event)',
  861. '(focusout)': '_handleFocusOut($event)',
  862. '(focusin)': '_handleFocusIn()',
  863. },
  864. providers: [
  865. {
  866. provide: NG_VALUE_ACCESSOR,
  867. useExisting: forwardRef(() => CdkListbox),
  868. multi: true,
  869. },
  870. ],
  871. }]
  872. }], ctorParameters: () => [], propDecorators: { id: [{
  873. type: Input
  874. }], enabledTabIndex: [{
  875. type: Input,
  876. args: ['tabindex']
  877. }], value: [{
  878. type: Input,
  879. args: ['cdkListboxValue']
  880. }], multiple: [{
  881. type: Input,
  882. args: [{ alias: 'cdkListboxMultiple', transform: booleanAttribute }]
  883. }], disabled: [{
  884. type: Input,
  885. args: [{ alias: 'cdkListboxDisabled', transform: booleanAttribute }]
  886. }], useActiveDescendant: [{
  887. type: Input,
  888. args: [{ alias: 'cdkListboxUseActiveDescendant', transform: booleanAttribute }]
  889. }], orientation: [{
  890. type: Input,
  891. args: ['cdkListboxOrientation']
  892. }], compareWith: [{
  893. type: Input,
  894. args: ['cdkListboxCompareWith']
  895. }], navigationWrapDisabled: [{
  896. type: Input,
  897. args: [{ alias: 'cdkListboxNavigationWrapDisabled', transform: booleanAttribute }]
  898. }], navigateDisabledOptions: [{
  899. type: Input,
  900. args: [{ alias: 'cdkListboxNavigatesDisabledOptions', transform: booleanAttribute }]
  901. }], valueChange: [{
  902. type: Output,
  903. args: ['cdkListboxValueChange']
  904. }], options: [{
  905. type: ContentChildren,
  906. args: [CdkOption, { descendants: true }]
  907. }] } });
  908. const EXPORTED_DECLARATIONS = [CdkListbox, CdkOption];
  909. class CdkListboxModule {
  910. static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: CdkListboxModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
  911. static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.2.6", ngImport: i0, type: CdkListboxModule, imports: [CdkListbox, CdkOption], exports: [CdkListbox, CdkOption] });
  912. static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: CdkListboxModule });
  913. }
  914. i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.6", ngImport: i0, type: CdkListboxModule, decorators: [{
  915. type: NgModule,
  916. args: [{
  917. imports: [...EXPORTED_DECLARATIONS],
  918. exports: [...EXPORTED_DECLARATIONS],
  919. }]
  920. }] });
  921. export { CdkListbox, CdkListboxModule, CdkOption };
  922. //# sourceMappingURL=listbox.mjs.map