@mui/x-tree-view
Version:
The community edition of the MUI X Tree View components.
305 lines (288 loc) • 11.4 kB
JavaScript
'use client';
import * as React from 'react';
import { useStore } from '@mui/x-internals/store';
import { useRtl } from '@mui/system/RtlProvider';
import { useTimeout } from '@base-ui/utils/useTimeout';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { getFirstNavigableItem, getLastNavigableItem, getNextNavigableItem, getPreviousNavigableItem, isTargetInDescendants } from "../../utils/tree.js";
import { hasPlugin } from "../../utils/plugins.js";
import { useTreeViewLabel } from "../useTreeViewLabel/index.js";
import { itemsSelectors } from "../useTreeViewItems/useTreeViewItems.selectors.js";
import { labelSelectors } from "../useTreeViewLabel/useTreeViewLabel.selectors.js";
import { selectionSelectors } from "../useTreeViewSelection/useTreeViewSelection.selectors.js";
import { expansionSelectors } from "../useTreeViewExpansion/useTreeViewExpansion.selectors.js";
function isPrintableKey(string) {
return !!string && string.length === 1 && !!string.match(/\S/);
}
const TYPEAHEAD_TIMEOUT = 500;
export const useTreeViewKeyboardNavigation = ({
instance,
store,
params
}) => {
const isRtl = useRtl();
const labelMap = React.useRef({});
const typeaheadQueryRef = React.useRef('');
const typeaheadTimeout = useTimeout();
const updateLabelMap = useStableCallback(callback => {
labelMap.current = callback(labelMap.current);
});
const itemMetaLookup = useStore(store, itemsSelectors.itemMetaLookup);
React.useEffect(() => {
if (instance.areItemUpdatesPrevented()) {
return;
}
const newLabelMap = {};
const processItem = item => {
newLabelMap[item.id] = item.label.toLowerCase();
};
Object.values(itemMetaLookup).forEach(processItem);
labelMap.current = newLabelMap;
}, [itemMetaLookup, params.getItemId, instance]);
const getNextItem = itemIdToCheck => {
const nextItemId = getNextNavigableItem(store.state, itemIdToCheck);
// We reached the end of the tree, check from the beginning
if (nextItemId === null) {
return getFirstNavigableItem(store.state);
}
return nextItemId;
};
const getNextMatchingItemId = (itemId, 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 = labelMap.current[currentItemId];
if (itemLabel?.startsWith(query)) {
matchingItemId = currentItemId;
} else {
checkedItems[currentItemId] = true;
currentItemId = getNextItem(currentItemId);
}
}
return matchingItemId;
};
const getFirstMatchingItem = (itemId, newKey) => {
const cleanNewKey = newKey.toLowerCase();
// Try matching with accumulated query + new key
const concatenatedQuery = `${typeaheadQueryRef.current}${cleanNewKey}`;
// check if the entire typed query matches an item
const concatenatedQueryMatchingItemId = getNextMatchingItemId(itemId, concatenatedQuery);
if (concatenatedQueryMatchingItemId != null) {
typeaheadQueryRef.current = concatenatedQuery;
return concatenatedQueryMatchingItemId;
}
const newKeyMatchingItemId = getNextMatchingItemId(itemId, cleanNewKey);
if (newKeyMatchingItemId != null) {
typeaheadQueryRef.current = cleanNewKey;
return newKeyMatchingItemId;
}
typeaheadQueryRef.current = '';
return null;
};
const canToggleItemSelection = itemId => selectionSelectors.canItemBeSelected(store.state, itemId);
const canToggleItemExpansion = itemId => {
return !itemsSelectors.isItemDisabled(store.state, itemId) && expansionSelectors.isItemExpandable(store.state, 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 = selectionSelectors.isMultiSelectEnabled(store.state);
// 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) && labelSelectors.isItemEditable(store.state, itemId) && !labelSelectors.isItemBeingEdited(store.state, 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 (!selectionSelectors.isItemSelected(store.state, itemId)) {
instance.setItemSelection({
event,
itemId
});
event.preventDefault();
}
}
break;
}
// Focus the next focusable item
case key === 'ArrowDown':
{
const nextItem = getNextNavigableItem(store.state, 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.state, 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 (expansionSelectors.isItemExpanded(store.state, itemId)) {
const nextItemId = getNextNavigableItem(store.state, 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) && expansionSelectors.isItemExpanded(store.state, itemId)) {
instance.setItemExpansion({
event,
itemId
});
event.preventDefault();
} else {
const parent = itemsSelectors.itemParentId(store.state, 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.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 (canToggleItemSelection(itemId) && isMultiSelectEnabled && ctrlPressed && event.shiftKey) {
instance.selectRangeFromItemToEnd(event, itemId);
} else {
instance.focusItem(event, getLastNavigableItem(store.state));
}
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 && selectionSelectors.enabled(store.state):
{
instance.selectAllNavigableItems(event);
event.preventDefault();
break;
}
// Type-ahead
case !ctrlPressed && !event.shiftKey && isPrintableKey(key):
{
typeaheadTimeout.clear();
const matchingItem = getFirstMatchingItem(itemId, key);
if (matchingItem != null) {
instance.focusItem(event, matchingItem);
event.preventDefault();
} else {
typeaheadQueryRef.current = '';
}
typeaheadTimeout.start(TYPEAHEAD_TIMEOUT, () => {
typeaheadQueryRef.current = '';
});
break;
}
}
};
return {
instance: {
updateLabelMap,
handleItemKeyDown
}
};
};
useTreeViewKeyboardNavigation.params = {};