tree-key-manager-KnCoIkIC.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import { QueryList, InjectionToken } from '@angular/core';
  2. import { Subscription, isObservable, Subject, of } from 'rxjs';
  3. import { take } from 'rxjs/operators';
  4. import { T as Typeahead } from './typeahead-9ZW4Dtsf.mjs';
  5. import { coerceObservable } from './coercion/private.mjs';
  6. /**
  7. * This class manages keyboard events for trees. If you pass it a QueryList or other list of tree
  8. * items, it will set the active item, focus, handle expansion and typeahead correctly when
  9. * keyboard events occur.
  10. */
  11. class TreeKeyManager {
  12. /** The index of the currently active (focused) item. */
  13. _activeItemIndex = -1;
  14. /** The currently active (focused) item. */
  15. _activeItem = null;
  16. /** Whether or not we activate the item when it's focused. */
  17. _shouldActivationFollowFocus = false;
  18. /**
  19. * The orientation that the tree is laid out in. In `rtl` mode, the behavior of Left and
  20. * Right arrow are switched.
  21. */
  22. _horizontalOrientation = 'ltr';
  23. /**
  24. * Predicate function that can be used to check whether an item should be skipped
  25. * by the key manager.
  26. *
  27. * The default value for this doesn't skip any elements in order to keep tree items focusable
  28. * when disabled. This aligns with ARIA guidelines:
  29. * https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols.
  30. */
  31. _skipPredicateFn = (_item) => false;
  32. /** Function to determine equivalent items. */
  33. _trackByFn = (item) => item;
  34. /** Synchronous cache of the items to manage. */
  35. _items = [];
  36. _typeahead;
  37. _typeaheadSubscription = Subscription.EMPTY;
  38. _hasInitialFocused = false;
  39. _initializeFocus() {
  40. if (this._hasInitialFocused || this._items.length === 0) {
  41. return;
  42. }
  43. let activeIndex = 0;
  44. for (let i = 0; i < this._items.length; i++) {
  45. if (!this._skipPredicateFn(this._items[i]) && !this._isItemDisabled(this._items[i])) {
  46. activeIndex = i;
  47. break;
  48. }
  49. }
  50. const activeItem = this._items[activeIndex];
  51. // Use `makeFocusable` here, because we want the item to just be focusable, not actually
  52. // capture the focus since the user isn't interacting with it. See #29628.
  53. if (activeItem.makeFocusable) {
  54. this._activeItem?.unfocus();
  55. this._activeItemIndex = activeIndex;
  56. this._activeItem = activeItem;
  57. this._typeahead?.setCurrentSelectedItemIndex(activeIndex);
  58. activeItem.makeFocusable();
  59. }
  60. else {
  61. // Backwards compatibility for items that don't implement `makeFocusable`.
  62. this.focusItem(activeIndex);
  63. }
  64. this._hasInitialFocused = true;
  65. }
  66. /**
  67. *
  68. * @param items List of TreeKeyManager options. Can be synchronous or asynchronous.
  69. * @param config Optional configuration options. By default, use 'ltr' horizontal orientation. By
  70. * default, do not skip any nodes. By default, key manager only calls `focus` method when items
  71. * are focused and does not call `activate`. If `typeaheadDefaultInterval` is `true`, use a
  72. * default interval of 200ms.
  73. */
  74. constructor(items, config) {
  75. // We allow for the items to be an array or Observable because, in some cases, the consumer may
  76. // not have access to a QueryList of the items they want to manage (e.g. when the
  77. // items aren't being collected via `ViewChildren` or `ContentChildren`).
  78. if (items instanceof QueryList) {
  79. this._items = items.toArray();
  80. items.changes.subscribe((newItems) => {
  81. this._items = newItems.toArray();
  82. this._typeahead?.setItems(this._items);
  83. this._updateActiveItemIndex(this._items);
  84. this._initializeFocus();
  85. });
  86. }
  87. else if (isObservable(items)) {
  88. items.subscribe(newItems => {
  89. this._items = newItems;
  90. this._typeahead?.setItems(newItems);
  91. this._updateActiveItemIndex(newItems);
  92. this._initializeFocus();
  93. });
  94. }
  95. else {
  96. this._items = items;
  97. this._initializeFocus();
  98. }
  99. if (typeof config.shouldActivationFollowFocus === 'boolean') {
  100. this._shouldActivationFollowFocus = config.shouldActivationFollowFocus;
  101. }
  102. if (config.horizontalOrientation) {
  103. this._horizontalOrientation = config.horizontalOrientation;
  104. }
  105. if (config.skipPredicate) {
  106. this._skipPredicateFn = config.skipPredicate;
  107. }
  108. if (config.trackBy) {
  109. this._trackByFn = config.trackBy;
  110. }
  111. if (typeof config.typeAheadDebounceInterval !== 'undefined') {
  112. this._setTypeAhead(config.typeAheadDebounceInterval);
  113. }
  114. }
  115. /** Stream that emits any time the focused item changes. */
  116. change = new Subject();
  117. /** Cleans up the key manager. */
  118. destroy() {
  119. this._typeaheadSubscription.unsubscribe();
  120. this._typeahead?.destroy();
  121. this.change.complete();
  122. }
  123. /**
  124. * Handles a keyboard event on the tree.
  125. * @param event Keyboard event that represents the user interaction with the tree.
  126. */
  127. onKeydown(event) {
  128. const key = event.key;
  129. switch (key) {
  130. case 'Tab':
  131. // Return early here, in order to allow Tab to actually tab out of the tree
  132. return;
  133. case 'ArrowDown':
  134. this._focusNextItem();
  135. break;
  136. case 'ArrowUp':
  137. this._focusPreviousItem();
  138. break;
  139. case 'ArrowRight':
  140. this._horizontalOrientation === 'rtl'
  141. ? this._collapseCurrentItem()
  142. : this._expandCurrentItem();
  143. break;
  144. case 'ArrowLeft':
  145. this._horizontalOrientation === 'rtl'
  146. ? this._expandCurrentItem()
  147. : this._collapseCurrentItem();
  148. break;
  149. case 'Home':
  150. this._focusFirstItem();
  151. break;
  152. case 'End':
  153. this._focusLastItem();
  154. break;
  155. case 'Enter':
  156. case ' ':
  157. this._activateCurrentItem();
  158. break;
  159. default:
  160. if (event.key === '*') {
  161. this._expandAllItemsAtCurrentItemLevel();
  162. break;
  163. }
  164. this._typeahead?.handleKey(event);
  165. // Return here, in order to avoid preventing the default action of non-navigational
  166. // keys or resetting the buffer of pressed letters.
  167. return;
  168. }
  169. // Reset the typeahead since the user has used a navigational key.
  170. this._typeahead?.reset();
  171. event.preventDefault();
  172. }
  173. /** Index of the currently active item. */
  174. getActiveItemIndex() {
  175. return this._activeItemIndex;
  176. }
  177. /** The currently active item. */
  178. getActiveItem() {
  179. return this._activeItem;
  180. }
  181. /** Focus the first available item. */
  182. _focusFirstItem() {
  183. this.focusItem(this._findNextAvailableItemIndex(-1));
  184. }
  185. /** Focus the last available item. */
  186. _focusLastItem() {
  187. this.focusItem(this._findPreviousAvailableItemIndex(this._items.length));
  188. }
  189. /** Focus the next available item. */
  190. _focusNextItem() {
  191. this.focusItem(this._findNextAvailableItemIndex(this._activeItemIndex));
  192. }
  193. /** Focus the previous available item. */
  194. _focusPreviousItem() {
  195. this.focusItem(this._findPreviousAvailableItemIndex(this._activeItemIndex));
  196. }
  197. focusItem(itemOrIndex, options = {}) {
  198. // Set default options
  199. options.emitChangeEvent ??= true;
  200. let index = typeof itemOrIndex === 'number'
  201. ? itemOrIndex
  202. : this._items.findIndex(item => this._trackByFn(item) === this._trackByFn(itemOrIndex));
  203. if (index < 0 || index >= this._items.length) {
  204. return;
  205. }
  206. const activeItem = this._items[index];
  207. // If we're just setting the same item, don't re-call activate or focus
  208. if (this._activeItem !== null &&
  209. this._trackByFn(activeItem) === this._trackByFn(this._activeItem)) {
  210. return;
  211. }
  212. const previousActiveItem = this._activeItem;
  213. this._activeItem = activeItem ?? null;
  214. this._activeItemIndex = index;
  215. this._typeahead?.setCurrentSelectedItemIndex(index);
  216. this._activeItem?.focus();
  217. previousActiveItem?.unfocus();
  218. if (options.emitChangeEvent) {
  219. this.change.next(this._activeItem);
  220. }
  221. if (this._shouldActivationFollowFocus) {
  222. this._activateCurrentItem();
  223. }
  224. }
  225. _updateActiveItemIndex(newItems) {
  226. const activeItem = this._activeItem;
  227. if (!activeItem) {
  228. return;
  229. }
  230. const newIndex = newItems.findIndex(item => this._trackByFn(item) === this._trackByFn(activeItem));
  231. if (newIndex > -1 && newIndex !== this._activeItemIndex) {
  232. this._activeItemIndex = newIndex;
  233. this._typeahead?.setCurrentSelectedItemIndex(newIndex);
  234. }
  235. }
  236. _setTypeAhead(debounceInterval) {
  237. this._typeahead = new Typeahead(this._items, {
  238. debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
  239. skipPredicate: item => this._skipPredicateFn(item),
  240. });
  241. this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => {
  242. this.focusItem(item);
  243. });
  244. }
  245. _findNextAvailableItemIndex(startingIndex) {
  246. for (let i = startingIndex + 1; i < this._items.length; i++) {
  247. if (!this._skipPredicateFn(this._items[i])) {
  248. return i;
  249. }
  250. }
  251. return startingIndex;
  252. }
  253. _findPreviousAvailableItemIndex(startingIndex) {
  254. for (let i = startingIndex - 1; i >= 0; i--) {
  255. if (!this._skipPredicateFn(this._items[i])) {
  256. return i;
  257. }
  258. }
  259. return startingIndex;
  260. }
  261. /**
  262. * If the item is already expanded, we collapse the item. Otherwise, we will focus the parent.
  263. */
  264. _collapseCurrentItem() {
  265. if (!this._activeItem) {
  266. return;
  267. }
  268. if (this._isCurrentItemExpanded()) {
  269. this._activeItem.collapse();
  270. }
  271. else {
  272. const parent = this._activeItem.getParent();
  273. if (!parent || this._skipPredicateFn(parent)) {
  274. return;
  275. }
  276. this.focusItem(parent);
  277. }
  278. }
  279. /**
  280. * If the item is already collapsed, we expand the item. Otherwise, we will focus the first child.
  281. */
  282. _expandCurrentItem() {
  283. if (!this._activeItem) {
  284. return;
  285. }
  286. if (!this._isCurrentItemExpanded()) {
  287. this._activeItem.expand();
  288. }
  289. else {
  290. coerceObservable(this._activeItem.getChildren())
  291. .pipe(take(1))
  292. .subscribe(children => {
  293. const firstChild = children.find(child => !this._skipPredicateFn(child));
  294. if (!firstChild) {
  295. return;
  296. }
  297. this.focusItem(firstChild);
  298. });
  299. }
  300. }
  301. _isCurrentItemExpanded() {
  302. if (!this._activeItem) {
  303. return false;
  304. }
  305. return typeof this._activeItem.isExpanded === 'boolean'
  306. ? this._activeItem.isExpanded
  307. : this._activeItem.isExpanded();
  308. }
  309. _isItemDisabled(item) {
  310. return typeof item.isDisabled === 'boolean' ? item.isDisabled : item.isDisabled?.();
  311. }
  312. /** For all items that are the same level as the current item, we expand those items. */
  313. _expandAllItemsAtCurrentItemLevel() {
  314. if (!this._activeItem) {
  315. return;
  316. }
  317. const parent = this._activeItem.getParent();
  318. let itemsToExpand;
  319. if (!parent) {
  320. itemsToExpand = of(this._items.filter(item => item.getParent() === null));
  321. }
  322. else {
  323. itemsToExpand = coerceObservable(parent.getChildren());
  324. }
  325. itemsToExpand.pipe(take(1)).subscribe(items => {
  326. for (const item of items) {
  327. item.expand();
  328. }
  329. });
  330. }
  331. _activateCurrentItem() {
  332. this._activeItem?.activate();
  333. }
  334. }
  335. /**
  336. * @docs-private
  337. * @deprecated No longer used, will be removed.
  338. * @breaking-change 21.0.0
  339. */
  340. function TREE_KEY_MANAGER_FACTORY() {
  341. return (items, options) => new TreeKeyManager(items, options);
  342. }
  343. /** Injection token that determines the key manager to use. */
  344. const TREE_KEY_MANAGER = new InjectionToken('tree-key-manager', {
  345. providedIn: 'root',
  346. factory: TREE_KEY_MANAGER_FACTORY,
  347. });
  348. /**
  349. * @docs-private
  350. * @deprecated No longer used, will be removed.
  351. * @breaking-change 21.0.0
  352. */
  353. const TREE_KEY_MANAGER_FACTORY_PROVIDER = {
  354. provide: TREE_KEY_MANAGER,
  355. useFactory: TREE_KEY_MANAGER_FACTORY,
  356. };
  357. export { TREE_KEY_MANAGER as T, TreeKeyManager as a, TREE_KEY_MANAGER_FACTORY as b, TREE_KEY_MANAGER_FACTORY_PROVIDER as c };
  358. //# sourceMappingURL=tree-key-manager-KnCoIkIC.mjs.map