UNPKG

@mui/x-tree-view

Version:

The community edition of the MUI X Tree View components.

306 lines (287 loc) 12.2 kB
import { expansionSelectors } from "../expansion/index.js"; import { itemsSelectors } from "../items/index.js"; import { labelSelectors } from "../labelEditing/index.js"; import { selectionSelectors } from "../selection/selectors.js"; import { getFirstNavigableItem, getLastNavigableItem, getNextNavigableItem, getPreviousNavigableItem, isTargetInDescendants } from "../../utils/tree.js"; const TYPEAHEAD_TIMEOUT = 500; export class TreeViewKeyboardNavigationPlugin { typeaheadQuery = ''; // We can't type `store`, otherwise we get the following TS error: // 'keyboardNavigation' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. constructor(store) { this.store = store; this.labelMap = createLabelMapFromItemMetaLookup(itemsSelectors.itemMetaLookup(this.store.state)); // Whenever the itemMetaLookup changes, we need to regen the label map. this.store.registerStoreEffect(itemsSelectors.itemMetaLookup, (_, itemMetaLookup) => { if (this.store.shouldIgnoreItemsStateUpdate()) { return; } this.labelMap = createLabelMapFromItemMetaLookup(itemMetaLookup); }); } canToggleItemSelection = itemId => selectionSelectors.canItemBeSelected(this.store.state, itemId); canToggleItemExpansion = itemId => { return !itemsSelectors.isItemDisabled(this.store.state, itemId) && expansionSelectors.isItemExpandable(this.store.state, itemId); }; getFirstItemMatchingTypeaheadQuery = (itemId, newKey) => { const getNextItem = itemIdToCheck => { const nextItemId = getNextNavigableItem(this.store.state, itemIdToCheck); // We reached the end of the tree, check from the beginning if (nextItemId === null) { return getFirstNavigableItem(this.store.state); } return nextItemId; }; const getNextMatchingItemId = query => { let matchingItemId = null; const checkedItems = {}; // If query length > 1, first check if current item matches let currentItemId = query.length > 1 ? itemId : getNextItem(itemId); // The "!checkedItems[currentItemId]" condition avoids an infinite loop when there is no matching item. while (matchingItemId == null && !checkedItems[currentItemId]) { const itemLabel = this.labelMap[currentItemId]; if (itemLabel?.startsWith(query)) { matchingItemId = currentItemId; } else { checkedItems[currentItemId] = true; currentItemId = getNextItem(currentItemId); } } return matchingItemId; }; const cleanNewKey = newKey.toLowerCase(); // Try matching with accumulated query + new key const concatenatedQuery = `${this.typeaheadQuery}${cleanNewKey}`; // check if the entire typed query matches an item const concatenatedQueryMatchingItemId = getNextMatchingItemId(concatenatedQuery); if (concatenatedQueryMatchingItemId != null) { this.typeaheadQuery = concatenatedQuery; return concatenatedQueryMatchingItemId; } const newKeyMatchingItemId = getNextMatchingItemId(cleanNewKey); if (newKeyMatchingItemId != null) { this.typeaheadQuery = cleanNewKey; return newKeyMatchingItemId; } this.typeaheadQuery = ''; return null; }; /** * Updates the `labelMap` to add/remove the first character of some item's labels. * This map is used to navigate the tree using type-ahead search. * This method is only used by the `useTreeViewJSXItems` plugin, otherwise the updates are handled internally. * @param {(map: TreeViewLabelMap) => TreeViewLabelMap} updater The function to update the map. */ updateLabelMap = callback => { this.labelMap = callback(this.labelMap); }; // ARIA specification: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction /** * Callback fired when a key is pressed on an item. * Handles all the keyboard navigation logic. * @param {React.KeyboardEvent<HTMLElement> & TreeViewCancellableEvent} event The keyboard event that triggered the callback. * @param {TreeViewItemId} itemId The id of the item that the event was triggered on. */ handleItemKeyDown = async (event, itemId) => { if (event.defaultMuiPrevented) { return; } if (event.altKey || isTargetInDescendants(event.target, event.currentTarget)) { return; } const ctrlPressed = event.ctrlKey || event.metaKey; const key = event.key; const isMultiSelectEnabled = selectionSelectors.isMultiSelectEnabled(this.store.state); // eslint-disable-next-line default-case switch (true) { // Select the item when pressing "Space" case key === ' ' && this.canToggleItemSelection(itemId): { event.preventDefault(); if (isMultiSelectEnabled && event.shiftKey) { this.store.selection.expandSelectionRange(event, itemId); } else { this.store.selection.setItemSelection({ event, itemId, keepExistingSelection: isMultiSelectEnabled, shouldBeSelected: undefined }); } break; } // If the focused item has children, we expand it. // If the focused item has no children, we select it. case key === 'Enter': { if (this.store.labelEditing?.setEditedItem && labelSelectors.isItemEditable(this.store.state, itemId) && !labelSelectors.isItemBeingEdited(this.store.state, itemId)) { this.store.labelEditing.setEditedItem(itemId); } else if (this.canToggleItemExpansion(itemId)) { this.store.expansion.setItemExpansion({ event, itemId }); event.preventDefault(); } else if (this.canToggleItemSelection(itemId)) { if (isMultiSelectEnabled) { event.preventDefault(); this.store.selection.setItemSelection({ event, itemId, keepExistingSelection: true }); } else if (!selectionSelectors.isItemSelected(this.store.state, itemId)) { this.store.selection.setItemSelection({ event, itemId }); event.preventDefault(); } } break; } // Focus the next focusable item case key === 'ArrowDown': { const nextItem = getNextNavigableItem(this.store.state, itemId); if (nextItem) { event.preventDefault(); this.store.focus.focusItem(event, nextItem); // Multi select behavior when pressing Shift + ArrowDown // Toggles the selection state of the next item if (isMultiSelectEnabled && event.shiftKey && this.canToggleItemSelection(nextItem)) { this.store.selection.selectItemFromArrowNavigation(event, itemId, nextItem); } } break; } // Focuses the previous focusable item case key === 'ArrowUp': { const previousItem = getPreviousNavigableItem(this.store.state, itemId); if (previousItem) { event.preventDefault(); this.store.focus.focusItem(event, previousItem); // Multi select behavior when pressing Shift + ArrowUp // Toggles the selection state of the previous item if (isMultiSelectEnabled && event.shiftKey && this.canToggleItemSelection(previousItem)) { this.store.selection.selectItemFromArrowNavigation(event, itemId, previousItem); } } break; } // If the focused item is expanded, we move the focus to its first child // If the focused item is collapsed and has children, we expand it case key === 'ArrowRight' && !this.store.parameters.isRtl || key === 'ArrowLeft' && this.store.parameters.isRtl: { if (ctrlPressed) { return; } if (expansionSelectors.isItemExpanded(this.store.state, itemId)) { const nextItemId = getNextNavigableItem(this.store.state, itemId); if (nextItemId) { this.store.focus.focusItem(event, nextItemId); event.preventDefault(); } } else if (this.canToggleItemExpansion(itemId)) { this.store.expansion.setItemExpansion({ event, itemId }); event.preventDefault(); } break; } // If the focused item is expanded, we collapse it // If the focused item is collapsed and has a parent, we move the focus to this parent case key === 'ArrowLeft' && !this.store.parameters.isRtl || key === 'ArrowRight' && this.store.parameters.isRtl: { if (ctrlPressed) { return; } if (this.canToggleItemExpansion(itemId) && expansionSelectors.isItemExpanded(this.store.state, itemId)) { this.store.expansion.setItemExpansion({ event, itemId }); event.preventDefault(); } else { const parent = itemsSelectors.itemParentId(this.store.state, itemId); if (parent) { this.store.focus.focusItem(event, parent); event.preventDefault(); } } break; } // Focuses the first item in the tree case key === 'Home': { // Multi select behavior when pressing Ctrl + Shift + Home // Selects the focused item and all items up to the first item. if (this.canToggleItemSelection(itemId) && isMultiSelectEnabled && ctrlPressed && event.shiftKey) { this.store.selection.selectRangeFromStartToItem(event, itemId); } else { this.store.focus.focusItem(event, getFirstNavigableItem(this.store.state)); } event.preventDefault(); break; } // Focuses the last item in the tree case key === 'End': { // Multi select behavior when pressing Ctrl + Shirt + End // Selects the focused item and all the items down to the last item. if (this.canToggleItemSelection(itemId) && isMultiSelectEnabled && ctrlPressed && event.shiftKey) { this.store.selection.selectRangeFromItemToEnd(event, itemId); } else { this.store.focus.focusItem(event, getLastNavigableItem(this.store.state)); } event.preventDefault(); break; } // Expand all siblings that are at the same level as the focused item case key === '*': { this.store.expansion.expandAllSiblings(event, itemId); event.preventDefault(); break; } // Multi select behavior when pressing Ctrl + a // Selects all the items case String.fromCharCode(event.keyCode) === 'A' && ctrlPressed && isMultiSelectEnabled && selectionSelectors.enabled(this.store.state): { this.store.selection.selectAllNavigableItems(event); event.preventDefault(); break; } // Type-ahead case !ctrlPressed && !event.shiftKey && isPrintableKey(key): { this.store.timeoutManager.clearTimeout('typeahead'); const matchingItem = this.getFirstItemMatchingTypeaheadQuery(itemId, key); if (matchingItem != null) { this.store.focus.focusItem(event, matchingItem); event.preventDefault(); } else { this.typeaheadQuery = ''; } this.store.timeoutManager.startTimeout('typeahead', TYPEAHEAD_TIMEOUT, () => { this.typeaheadQuery = ''; }); break; } } }; } function isPrintableKey(string) { return !!string && string.length === 1 && !!string.match(/\S/); } function createLabelMapFromItemMetaLookup(itemMetaLookup) { const labelMap = {}; const processItem = item => { labelMap[item.id] = item.label.toLowerCase(); }; Object.values(itemMetaLookup).forEach(processItem); return labelMap; }