UNPKG

@mui/x-tree-view

Version:

The community edition of the MUI X Tree View components.

275 lines (260 loc) 10.3 kB
'use client'; import * as React from 'react'; import { useRtl } from '@mui/system/RtlProvider'; import useEventCallback from '@mui/utils/useEventCallback'; import { getFirstNavigableItem, getLastNavigableItem, getNextNavigableItem, getPreviousNavigableItem, isTargetInDescendants } from "../../utils/tree.js"; import { hasPlugin } from "../../utils/plugins.js"; import { useTreeViewLabel } from "../useTreeViewLabel/index.js"; import { useSelector } from "../../hooks/useSelector.js"; import { selectorItemMetaLookup, selectorIsItemDisabled, selectorItemParentId } from "../useTreeViewItems/useTreeViewItems.selectors.js"; import { selectorIsItemBeingEdited, selectorIsItemEditable } from "../useTreeViewLabel/useTreeViewLabel.selectors.js"; import { selectorIsItemSelected, selectorIsMultiSelectEnabled, selectorIsSelectionEnabled } from "../useTreeViewSelection/useTreeViewSelection.selectors.js"; import { selectorIsItemExpandable, selectorIsItemExpanded } from "../useTreeViewExpansion/useTreeViewExpansion.selectors.js"; function isPrintableKey(string) { return !!string && string.length === 1 && !!string.match(/\S/); } export const useTreeViewKeyboardNavigation = ({ instance, store, params }) => { const isRtl = useRtl(); const firstCharMap = React.useRef({}); const updateFirstCharMap = useEventCallback(callback => { firstCharMap.current = callback(firstCharMap.current); }); const itemMetaLookup = useSelector(store, selectorItemMetaLookup); React.useEffect(() => { if (instance.areItemUpdatesPrevented()) { return; } const newFirstCharMap = {}; const processItem = item => { newFirstCharMap[item.id] = item.label.substring(0, 1).toLowerCase(); }; Object.values(itemMetaLookup).forEach(processItem); firstCharMap.current = newFirstCharMap; }, [itemMetaLookup, params.getItemId, instance]); const getFirstMatchingItem = (itemId, query) => { const cleanQuery = query.toLowerCase(); const getNextItem = itemIdToCheck => { const nextItemId = getNextNavigableItem(store.value, itemIdToCheck); // We reached the end of the tree, check from the beginning if (nextItemId === null) { return getFirstNavigableItem(store.value); } return nextItemId; }; let matchingItemId = null; let currentItemId = getNextItem(itemId); const checkedItems = {}; // The "!checkedItems[currentItemId]" condition avoids an infinite loop when there is no matching item. while (matchingItemId == null && !checkedItems[currentItemId]) { if (firstCharMap.current[currentItemId] === cleanQuery) { matchingItemId = currentItemId; } else { checkedItems[currentItemId] = true; currentItemId = getNextItem(currentItemId); } } return matchingItemId; }; const canToggleItemSelection = itemId => selectorIsSelectionEnabled(store.value) && !selectorIsItemDisabled(store.value, itemId); const canToggleItemExpansion = itemId => { return !selectorIsItemDisabled(store.value, itemId) && selectorIsItemExpandable(store.value, itemId); }; // ARIA specification: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboardinteraction const 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 = selectorIsMultiSelectEnabled(store.value); // eslint-disable-next-line default-case switch (true) { // Select the item when pressing "Space" case key === ' ' && canToggleItemSelection(itemId): { event.preventDefault(); if (isMultiSelectEnabled && event.shiftKey) { instance.expandSelectionRange(event, itemId); } else { instance.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 (hasPlugin(instance, useTreeViewLabel) && selectorIsItemEditable(store.value, itemId) && !selectorIsItemBeingEdited(store.value, itemId)) { instance.setEditedItem(itemId); } else if (canToggleItemExpansion(itemId)) { instance.setItemExpansion({ event, itemId }); event.preventDefault(); } else if (canToggleItemSelection(itemId)) { if (isMultiSelectEnabled) { event.preventDefault(); instance.setItemSelection({ event, itemId, keepExistingSelection: true }); } else if (!selectorIsItemSelected(store.value, itemId)) { instance.setItemSelection({ event, itemId }); event.preventDefault(); } } break; } // Focus the next focusable item case key === 'ArrowDown': { const nextItem = getNextNavigableItem(store.value, itemId); if (nextItem) { event.preventDefault(); instance.focusItem(event, nextItem); // Multi select behavior when pressing Shift + ArrowDown // Toggles the selection state of the next item if (isMultiSelectEnabled && event.shiftKey && canToggleItemSelection(nextItem)) { instance.selectItemFromArrowNavigation(event, itemId, nextItem); } } break; } // Focuses the previous focusable item case key === 'ArrowUp': { const previousItem = getPreviousNavigableItem(store.value, itemId); if (previousItem) { event.preventDefault(); instance.focusItem(event, previousItem); // Multi select behavior when pressing Shift + ArrowUp // Toggles the selection state of the previous item if (isMultiSelectEnabled && event.shiftKey && canToggleItemSelection(previousItem)) { instance.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' && !isRtl || key === 'ArrowLeft' && isRtl: { if (ctrlPressed) { return; } if (selectorIsItemExpanded(store.value, itemId)) { const nextItemId = getNextNavigableItem(store.value, itemId); if (nextItemId) { instance.focusItem(event, nextItemId); event.preventDefault(); } } else if (canToggleItemExpansion(itemId)) { instance.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' && !isRtl || key === 'ArrowRight' && isRtl: { if (ctrlPressed) { return; } if (canToggleItemExpansion(itemId) && selectorIsItemExpanded(store.value, itemId)) { instance.setItemExpansion({ event, itemId }); event.preventDefault(); } else { const parent = selectorItemParentId(store.value, itemId); if (parent) { instance.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 (canToggleItemSelection(itemId) && isMultiSelectEnabled && ctrlPressed && event.shiftKey) { instance.selectRangeFromStartToItem(event, itemId); } else { instance.focusItem(event, getFirstNavigableItem(store.value)); } 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 (canToggleItemSelection(itemId) && isMultiSelectEnabled && ctrlPressed && event.shiftKey) { instance.selectRangeFromItemToEnd(event, itemId); } else { instance.focusItem(event, getLastNavigableItem(store.value)); } event.preventDefault(); break; } // Expand all siblings that are at the same level as the focused item case key === '*': { instance.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 && selectorIsSelectionEnabled(store.value): { instance.selectAllNavigableItems(event); event.preventDefault(); break; } // Type-ahead // TODO: Support typing multiple characters case !ctrlPressed && !event.shiftKey && isPrintableKey(key): { const matchingItem = getFirstMatchingItem(itemId, key); if (matchingItem != null) { instance.focusItem(event, matchingItem); event.preventDefault(); } break; } } }; return { instance: { updateFirstCharMap, handleItemKeyDown } }; }; useTreeViewKeyboardNavigation.params = {};