list-key-manager-CyOIXo8P.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import { signal, QueryList, isSignal, effect } from '@angular/core';
  2. import { Subscription, Subject } from 'rxjs';
  3. import { T as Typeahead } from './typeahead-9ZW4Dtsf.mjs';
  4. import { hasModifierKey } from './keycodes.mjs';
  5. import { P as PAGE_DOWN, a as PAGE_UP, E as END, H as HOME, L as LEFT_ARROW, R as RIGHT_ARROW, U as UP_ARROW, D as DOWN_ARROW, T as TAB } from './keycodes-CpHkExLC.mjs';
  6. /**
  7. * This class manages keyboard events for selectable lists. If you pass it a query list
  8. * of items, it will set the active item correctly when arrow events occur.
  9. */
  10. class ListKeyManager {
  11. _items;
  12. _activeItemIndex = -1;
  13. _activeItem = signal(null);
  14. _wrap = false;
  15. _typeaheadSubscription = Subscription.EMPTY;
  16. _itemChangesSubscription;
  17. _vertical = true;
  18. _horizontal;
  19. _allowedModifierKeys = [];
  20. _homeAndEnd = false;
  21. _pageUpAndDown = { enabled: false, delta: 10 };
  22. _effectRef;
  23. _typeahead;
  24. /**
  25. * Predicate function that can be used to check whether an item should be skipped
  26. * by the key manager. By default, disabled items are skipped.
  27. */
  28. _skipPredicateFn = (item) => item.disabled;
  29. constructor(_items, injector) {
  30. this._items = _items;
  31. // We allow for the items to be an array because, in some cases, the consumer may
  32. // not have access to a QueryList of the items they want to manage (e.g. when the
  33. // items aren't being collected via `ViewChildren` or `ContentChildren`).
  34. if (_items instanceof QueryList) {
  35. this._itemChangesSubscription = _items.changes.subscribe((newItems) => this._itemsChanged(newItems.toArray()));
  36. }
  37. else if (isSignal(_items)) {
  38. if (!injector && (typeof ngDevMode === 'undefined' || ngDevMode)) {
  39. throw new Error('ListKeyManager constructed with a signal must receive an injector');
  40. }
  41. this._effectRef = effect(() => this._itemsChanged(_items()), { injector });
  42. }
  43. }
  44. /**
  45. * Stream that emits any time the TAB key is pressed, so components can react
  46. * when focus is shifted off of the list.
  47. */
  48. tabOut = new Subject();
  49. /** Stream that emits whenever the active item of the list manager changes. */
  50. change = new Subject();
  51. /**
  52. * Sets the predicate function that determines which items should be skipped by the
  53. * list key manager.
  54. * @param predicate Function that determines whether the given item should be skipped.
  55. */
  56. skipPredicate(predicate) {
  57. this._skipPredicateFn = predicate;
  58. return this;
  59. }
  60. /**
  61. * Configures wrapping mode, which determines whether the active item will wrap to
  62. * the other end of list when there are no more items in the given direction.
  63. * @param shouldWrap Whether the list should wrap when reaching the end.
  64. */
  65. withWrap(shouldWrap = true) {
  66. this._wrap = shouldWrap;
  67. return this;
  68. }
  69. /**
  70. * Configures whether the key manager should be able to move the selection vertically.
  71. * @param enabled Whether vertical selection should be enabled.
  72. */
  73. withVerticalOrientation(enabled = true) {
  74. this._vertical = enabled;
  75. return this;
  76. }
  77. /**
  78. * Configures the key manager to move the selection horizontally.
  79. * Passing in `null` will disable horizontal movement.
  80. * @param direction Direction in which the selection can be moved.
  81. */
  82. withHorizontalOrientation(direction) {
  83. this._horizontal = direction;
  84. return this;
  85. }
  86. /**
  87. * Modifier keys which are allowed to be held down and whose default actions will be prevented
  88. * as the user is pressing the arrow keys. Defaults to not allowing any modifier keys.
  89. */
  90. withAllowedModifierKeys(keys) {
  91. this._allowedModifierKeys = keys;
  92. return this;
  93. }
  94. /**
  95. * Turns on typeahead mode which allows users to set the active item by typing.
  96. * @param debounceInterval Time to wait after the last keystroke before setting the active item.
  97. */
  98. withTypeAhead(debounceInterval = 200) {
  99. if (typeof ngDevMode === 'undefined' || ngDevMode) {
  100. const items = this._getItemsArray();
  101. if (items.length > 0 && items.some(item => typeof item.getLabel !== 'function')) {
  102. throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
  103. }
  104. }
  105. this._typeaheadSubscription.unsubscribe();
  106. const items = this._getItemsArray();
  107. this._typeahead = new Typeahead(items, {
  108. debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
  109. skipPredicate: item => this._skipPredicateFn(item),
  110. });
  111. this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => {
  112. this.setActiveItem(item);
  113. });
  114. return this;
  115. }
  116. /** Cancels the current typeahead sequence. */
  117. cancelTypeahead() {
  118. this._typeahead?.reset();
  119. return this;
  120. }
  121. /**
  122. * Configures the key manager to activate the first and last items
  123. * respectively when the Home or End key is pressed.
  124. * @param enabled Whether pressing the Home or End key activates the first/last item.
  125. */
  126. withHomeAndEnd(enabled = true) {
  127. this._homeAndEnd = enabled;
  128. return this;
  129. }
  130. /**
  131. * Configures the key manager to activate every 10th, configured or first/last element in up/down direction
  132. * respectively when the Page-Up or Page-Down key is pressed.
  133. * @param enabled Whether pressing the Page-Up or Page-Down key activates the first/last item.
  134. * @param delta Whether pressing the Home or End key activates the first/last item.
  135. */
  136. withPageUpDown(enabled = true, delta = 10) {
  137. this._pageUpAndDown = { enabled, delta };
  138. return this;
  139. }
  140. setActiveItem(item) {
  141. const previousActiveItem = this._activeItem();
  142. this.updateActiveItem(item);
  143. if (this._activeItem() !== previousActiveItem) {
  144. this.change.next(this._activeItemIndex);
  145. }
  146. }
  147. /**
  148. * Sets the active item depending on the key event passed in.
  149. * @param event Keyboard event to be used for determining which element should be active.
  150. */
  151. onKeydown(event) {
  152. const keyCode = event.keyCode;
  153. const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'];
  154. const isModifierAllowed = modifiers.every(modifier => {
  155. return !event[modifier] || this._allowedModifierKeys.indexOf(modifier) > -1;
  156. });
  157. switch (keyCode) {
  158. case TAB:
  159. this.tabOut.next();
  160. return;
  161. case DOWN_ARROW:
  162. if (this._vertical && isModifierAllowed) {
  163. this.setNextItemActive();
  164. break;
  165. }
  166. else {
  167. return;
  168. }
  169. case UP_ARROW:
  170. if (this._vertical && isModifierAllowed) {
  171. this.setPreviousItemActive();
  172. break;
  173. }
  174. else {
  175. return;
  176. }
  177. case RIGHT_ARROW:
  178. if (this._horizontal && isModifierAllowed) {
  179. this._horizontal === 'rtl' ? this.setPreviousItemActive() : this.setNextItemActive();
  180. break;
  181. }
  182. else {
  183. return;
  184. }
  185. case LEFT_ARROW:
  186. if (this._horizontal && isModifierAllowed) {
  187. this._horizontal === 'rtl' ? this.setNextItemActive() : this.setPreviousItemActive();
  188. break;
  189. }
  190. else {
  191. return;
  192. }
  193. case HOME:
  194. if (this._homeAndEnd && isModifierAllowed) {
  195. this.setFirstItemActive();
  196. break;
  197. }
  198. else {
  199. return;
  200. }
  201. case END:
  202. if (this._homeAndEnd && isModifierAllowed) {
  203. this.setLastItemActive();
  204. break;
  205. }
  206. else {
  207. return;
  208. }
  209. case PAGE_UP:
  210. if (this._pageUpAndDown.enabled && isModifierAllowed) {
  211. const targetIndex = this._activeItemIndex - this._pageUpAndDown.delta;
  212. this._setActiveItemByIndex(targetIndex > 0 ? targetIndex : 0, 1);
  213. break;
  214. }
  215. else {
  216. return;
  217. }
  218. case PAGE_DOWN:
  219. if (this._pageUpAndDown.enabled && isModifierAllowed) {
  220. const targetIndex = this._activeItemIndex + this._pageUpAndDown.delta;
  221. const itemsLength = this._getItemsArray().length;
  222. this._setActiveItemByIndex(targetIndex < itemsLength ? targetIndex : itemsLength - 1, -1);
  223. break;
  224. }
  225. else {
  226. return;
  227. }
  228. default:
  229. if (isModifierAllowed || hasModifierKey(event, 'shiftKey')) {
  230. this._typeahead?.handleKey(event);
  231. }
  232. // Note that we return here, in order to avoid preventing
  233. // the default action of non-navigational keys.
  234. return;
  235. }
  236. this._typeahead?.reset();
  237. event.preventDefault();
  238. }
  239. /** Index of the currently active item. */
  240. get activeItemIndex() {
  241. return this._activeItemIndex;
  242. }
  243. /** The active item. */
  244. get activeItem() {
  245. return this._activeItem();
  246. }
  247. /** Gets whether the user is currently typing into the manager using the typeahead feature. */
  248. isTyping() {
  249. return !!this._typeahead && this._typeahead.isTyping();
  250. }
  251. /** Sets the active item to the first enabled item in the list. */
  252. setFirstItemActive() {
  253. this._setActiveItemByIndex(0, 1);
  254. }
  255. /** Sets the active item to the last enabled item in the list. */
  256. setLastItemActive() {
  257. this._setActiveItemByIndex(this._getItemsArray().length - 1, -1);
  258. }
  259. /** Sets the active item to the next enabled item in the list. */
  260. setNextItemActive() {
  261. this._activeItemIndex < 0 ? this.setFirstItemActive() : this._setActiveItemByDelta(1);
  262. }
  263. /** Sets the active item to a previous enabled item in the list. */
  264. setPreviousItemActive() {
  265. this._activeItemIndex < 0 && this._wrap
  266. ? this.setLastItemActive()
  267. : this._setActiveItemByDelta(-1);
  268. }
  269. updateActiveItem(item) {
  270. const itemArray = this._getItemsArray();
  271. const index = typeof item === 'number' ? item : itemArray.indexOf(item);
  272. const activeItem = itemArray[index];
  273. // Explicitly check for `null` and `undefined` because other falsy values are valid.
  274. this._activeItem.set(activeItem == null ? null : activeItem);
  275. this._activeItemIndex = index;
  276. this._typeahead?.setCurrentSelectedItemIndex(index);
  277. }
  278. /** Cleans up the key manager. */
  279. destroy() {
  280. this._typeaheadSubscription.unsubscribe();
  281. this._itemChangesSubscription?.unsubscribe();
  282. this._effectRef?.destroy();
  283. this._typeahead?.destroy();
  284. this.tabOut.complete();
  285. this.change.complete();
  286. }
  287. /**
  288. * This method sets the active item, given a list of items and the delta between the
  289. * currently active item and the new active item. It will calculate differently
  290. * depending on whether wrap mode is turned on.
  291. */
  292. _setActiveItemByDelta(delta) {
  293. this._wrap ? this._setActiveInWrapMode(delta) : this._setActiveInDefaultMode(delta);
  294. }
  295. /**
  296. * Sets the active item properly given "wrap" mode. In other words, it will continue to move
  297. * down the list until it finds an item that is not disabled, and it will wrap if it
  298. * encounters either end of the list.
  299. */
  300. _setActiveInWrapMode(delta) {
  301. const items = this._getItemsArray();
  302. for (let i = 1; i <= items.length; i++) {
  303. const index = (this._activeItemIndex + delta * i + items.length) % items.length;
  304. const item = items[index];
  305. if (!this._skipPredicateFn(item)) {
  306. this.setActiveItem(index);
  307. return;
  308. }
  309. }
  310. }
  311. /**
  312. * Sets the active item properly given the default mode. In other words, it will
  313. * continue to move down the list until it finds an item that is not disabled. If
  314. * it encounters either end of the list, it will stop and not wrap.
  315. */
  316. _setActiveInDefaultMode(delta) {
  317. this._setActiveItemByIndex(this._activeItemIndex + delta, delta);
  318. }
  319. /**
  320. * Sets the active item to the first enabled item starting at the index specified. If the
  321. * item is disabled, it will move in the fallbackDelta direction until it either
  322. * finds an enabled item or encounters the end of the list.
  323. */
  324. _setActiveItemByIndex(index, fallbackDelta) {
  325. const items = this._getItemsArray();
  326. if (!items[index]) {
  327. return;
  328. }
  329. while (this._skipPredicateFn(items[index])) {
  330. index += fallbackDelta;
  331. if (!items[index]) {
  332. return;
  333. }
  334. }
  335. this.setActiveItem(index);
  336. }
  337. /** Returns the items as an array. */
  338. _getItemsArray() {
  339. if (isSignal(this._items)) {
  340. return this._items();
  341. }
  342. return this._items instanceof QueryList ? this._items.toArray() : this._items;
  343. }
  344. /** Callback for when the items have changed. */
  345. _itemsChanged(newItems) {
  346. this._typeahead?.setItems(newItems);
  347. const activeItem = this._activeItem();
  348. if (activeItem) {
  349. const newIndex = newItems.indexOf(activeItem);
  350. if (newIndex > -1 && newIndex !== this._activeItemIndex) {
  351. this._activeItemIndex = newIndex;
  352. this._typeahead?.setCurrentSelectedItemIndex(newIndex);
  353. }
  354. }
  355. }
  356. }
  357. export { ListKeyManager as L };
  358. //# sourceMappingURL=list-key-manager-CyOIXo8P.mjs.map