in packages/fluentui/react-northstar/src/components/Tree/hooks/useTree.ts [96:297]
export function useTree(options: UseTreeOptions): UseTreeResult {
// We need this because we want to handle `expanded` prop on `items`, should be deprecated and removed
const deprecated_initialActiveItemIds = React.useMemo(
() => deprecated_getInitialActiveItemIds(options.items),
// eslint-disable-next-line react-hooks/exhaustive-deps
[], // initialValue only needs to be computed on mount
);
const [activeItemIds, setActiveItemIdsState] = useAutoControlled<string[]>({
defaultValue: options.defaultActiveItemIds,
value: options.activeItemIds,
initialValue: deprecated_initialActiveItemIds, // will become []
});
// selectedItemIds is only valid for leaf nodes.
// For non-leaf nodes, their 'selected' states are defered from all their descendents
const [selectedItemIds, setSelectedItemIdsState] = useAutoControlled<string[]>({
defaultValue: options.defaultSelectedItemIds,
value: options.selectedItemIds,
initialValue: [],
});
// We want to set `visibleItemIds` to simplify rendering later
const { flatTree, visibleItemIds } = React.useMemo(() => flattenTree(options.items, activeItemIds, selectedItemIds), [
activeItemIds,
options.items,
selectedItemIds,
]);
const getItemById = useGetItemById(flatTree);
const stableProps = useStableProps(options);
const toggleItemActive = React.useCallback(
(e: React.SyntheticEvent, idToToggle: string) => {
const item = getItemById(idToToggle);
if (!item || !item.hasSubtree) {
// leaf node does not have the concept of active/inactive
return;
}
setActiveItemIdsState(activeItemIds => {
let nextActiveItemIds: string[];
const isActiveId = activeItemIds.indexOf(idToToggle) !== -1;
if (isActiveId) {
nextActiveItemIds = _.without(activeItemIds, idToToggle);
} else {
nextActiveItemIds = [...activeItemIds, idToToggle];
if (options.exclusive) {
// remove active siblings, if any, from activeItemIds
const parent = getItemById(idToToggle)?.parent;
const activeSibling = getItemById(parent)?.childrenIds?.find(
id => id !== idToToggle && nextActiveItemIds.indexOf(id) >= 0,
);
if (activeSibling != null) {
nextActiveItemIds = _.without(nextActiveItemIds, activeSibling);
}
}
}
_.invoke(stableProps.current, 'onActiveItemIdsChange', e, {
...stableProps.current,
activeItemIds: nextActiveItemIds,
});
return nextActiveItemIds;
});
},
[getItemById, options.exclusive, setActiveItemIdsState, stableProps],
);
const expandSiblings = React.useCallback(
(e: React.KeyboardEvent, focusedItemId: string) => {
if (options.exclusive) {
return;
}
const focusedItem = getItemById(focusedItemId);
if (!focusedItem) {
return;
}
const parentItem = getItemById(focusedItem?.parent);
const siblingsIds = parentItem?.childrenIds;
if (!siblingsIds) {
return;
}
setActiveItemIdsState(activeItemIds => {
const nextActiveItemIds = _.uniq(activeItemIds.concat(siblingsIds));
_.invoke(stableProps.current, 'onActiveItemIdsChange', e, {
...stableProps.current,
activeItemIds: nextActiveItemIds,
});
return nextActiveItemIds;
});
},
[getItemById, options.exclusive, setActiveItemIdsState, stableProps],
);
const toggleItemSelect = React.useCallback(
(e: React.SyntheticEvent, idToToggle: string) => {
const item = getItemById(idToToggle);
if (!item) {
return;
}
const leafs = getLeafNodes(getItemById, idToToggle);
setSelectedItemIdsState(selectedItemIds => {
const nextSelectedItemIds =
item.selected === true
? _.without(selectedItemIds, ...leafs) // remove all leaves from selected
: _.uniq(selectedItemIds.concat(leafs)); // add all leaves to selected
_.invoke(stableProps.current, 'onSelectedItemIdsChange', e, {
...stableProps.current,
selectedItemIds: nextSelectedItemIds,
});
return nextSelectedItemIds;
});
},
[getItemById, setSelectedItemIdsState, stableProps],
);
// Maintains stable collection of refs to avoid unnecessary React context updates
const nodes = React.useRef<Record<string, HTMLElement>>({});
const registerItemRef = React.useCallback((id: string, node: HTMLElement) => {
nodes.current[id] = node;
}, []);
const getItemRef = React.useCallback((id): HTMLElement => nodes.current[id], []);
// can be used for keyboard navigation ===
const focusItemById = React.useCallback(
(id: string) => {
const itemRef = getItemRef(id);
if (itemRef instanceof HTMLElement) {
if (getItemById(id)?.hasSubtree) {
itemRef.focus();
} else {
// when node is leaf, need to focus on the inner treeTitle
(itemRef.firstElementChild as HTMLElement)?.focus();
}
}
},
[getItemById, getItemRef],
);
const searchByFirstChar = React.useCallback(
(startIndex: number, endIndex: number, char: string) => {
for (let i = startIndex; i < endIndex; ++i) {
// get first charater of tree node using the same way aria does (https://www.w3.org/TR/wai-aria-practices-1.1/examples/treeview/treeview-2/js/treeitemLinks.js)
const itemFirstChar = getItemRef(visibleItemIds[i])?.textContent?.trim()?.charAt(0)?.toLowerCase();
if (itemFirstChar === char.toLowerCase()) {
return i;
}
}
return -1;
},
[getItemRef, visibleItemIds],
);
const getToFocusIDByFirstCharacter = React.useCallback(
(e: React.KeyboardEvent, idToStartSearch: string) => {
// Get start index for search
let starIndex = visibleItemIds.indexOf(idToStartSearch) + 1;
if (starIndex === visibleItemIds.length) {
starIndex = 0;
}
// Check following nodes in tree
let toFocusIndex = searchByFirstChar(starIndex, visibleItemIds.length, e.key);
// If not found in following nodes, check from beginning
if (toFocusIndex === -1) {
toFocusIndex = searchByFirstChar(0, starIndex - 1, e.key);
}
if (toFocusIndex === -1) {
return idToStartSearch;
}
return visibleItemIds[toFocusIndex];
},
[searchByFirstChar, visibleItemIds],
);
return {
flatTree,
getItemById,
activeItemIds,
visibleItemIds,
registerItemRef,
getItemRef,
toggleItemActive,
focusItemById,
expandSiblings,
toggleItemSelect,
getToFocusIDByFirstCharacter,
};
}