@mui/x-tree-view
Version:
The community edition of the MUI X Tree View components.
275 lines (260 loc) • 10.3 kB
JavaScript
'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 = {};