react-cascading-menu
Version:
A powerful React cascading dropdown component with multi-selection, search functionality, and hierarchical navigation. Perfect for building category selectors, dependent dropdowns, and nested menu systems with TypeScript support.
410 lines (379 loc) • 12 kB
text/typescript
import { useEffect } from "react";
import {
CompleteObj,
FormatedSelections,
ItemId,
MenuGroup,
MenuGroupMap,
SelectedItemType,
} from "../types";
/**
* convert menuGroup tree to map
* for each node access without need to store the node details redundantly
* and simplify the management complexity
*
* downward propogation through childIds
* upward propogation through parentId
*/
export const menuGroupTreeToMap = (menuGroup: MenuGroup) => {
const menuGroupTreeToMapHelper = (
node: MenuGroup,
parentId: ItemId,
map: MenuGroupMap
) => {
if (!node) return;
map[node.id] = {
id: node.id,
label: node.label,
value: node.value,
groupHeading: node.groupHeading,
childIds: node.options?.map((e) => e.id),
parentId: parentId,
};
node.options?.forEach((e) => {
menuGroupTreeToMapHelper(e, node.id, map);
});
};
const mapObj: MenuGroupMap = {};
menuGroupTreeToMapHelper(menuGroup, "", mapObj);
return mapObj;
};
export const getConnectedItemByDirection = (
obj: MenuGroupMap,
isForward = true,
nodeId: ItemId,
selectedItems: SelectedItemType,
isSingleSelection: boolean = false // valid only for forward propogation
): SelectedItemType => {
const connectedResult: SelectedItemType = isSingleSelection
? {}
: { ...selectedItems };
// console.log(isForward ? "forward" : "backward");
const getConnectedItemByDirectionHelper = (
obj: MenuGroupMap,
isForward = true,
nodeId: ItemId,
selectedItems: SelectedItemType,
connectedResult: SelectedItemType
) => {
try {
if (!nodeId) return connectedResult;
// forward propogation using childIds of selections
if (isForward) {
let childIds = selectedItems[nodeId].childIds;
if (isSingleSelection) {
// pick the first child of all the children if single selection
const firstChild = selectedItems[nodeId].childIds?.[0];
childIds = firstChild ? [firstChild] : [];
}
// console.log("picking", childIds, "for", nodeId);
connectedResult[nodeId] = {
...selectedItems[nodeId],
childIds,
};
if (childIds) {
childIds.forEach((childId) => {
getConnectedItemByDirectionHelper(
obj,
isForward,
childId,
selectedItems,
connectedResult
);
});
}
return connectedResult;
}
// backward propofation using parentId of selections
const parentId = obj[nodeId].parentId;
// add the nodeId to the connectedResult if not present.
if (!connectedResult[nodeId]) {
// console.log("adding", nodeId, "to connectedResult");
connectedResult[nodeId] = {
id: nodeId,
childIds: [
...(isSingleSelection
? [nodeId]
: selectedItems[nodeId]?.childIds || []),
],
};
}
// console.log("parentId", parentId);
// able to connect the path even if the item is not part of the selections, works for back propogation
if (parentId && !selectedItems[nodeId]) {
connectedResult[parentId] = {
id: parentId,
childIds: isSingleSelection
? [nodeId]
: [...(selectedItems[parentId]?.childIds || []), nodeId],
};
}
// console.log("connectedResult", connectedResult);
if (parentId) {
getConnectedItemByDirectionHelper(
obj,
isForward,
parentId,
selectedItems,
connectedResult
);
}
return connectedResult;
} catch (e) {
console.log("issue while getting connected item", e);
return connectedResult;
}
};
getConnectedItemByDirectionHelper(
obj,
isForward,
nodeId,
selectedItems,
connectedResult
);
return connectedResult;
};
export const getConnectedItems = (
obj: MenuGroupMap,
nodeId: ItemId,
selectedItems: SelectedItemType,
isSingleSelection: boolean = false
): SelectedItemType => {
const selectedItemsUsed = selectedItems;
const prevPath = getConnectedItemByDirection(
obj,
false,
nodeId,
selectedItemsUsed,
isSingleSelection
);
const forwardPath = getConnectedItemByDirection(
obj,
true,
nodeId,
selectedItemsUsed,
isSingleSelection
);
return {
...prevPath,
...forwardPath,
};
};
export const addItemSelection = (
menuGroupMap: MenuGroupMap,
selectedItems: SelectedItemType,
itemId: ItemId
): SelectedItemType => {
let newSelectedItems = { ...selectedItems };
try {
const parentId = menuGroupMap[itemId].parentId || "";
const parentItem = menuGroupMap[parentId];
let isMultiSelection = true;
if (parentId && menuGroupMap[parentId].isMultiSelection !== undefined) {
isMultiSelection = menuGroupMap[parentId].isMultiSelection ?? true; // Provide default value
}
// cut the previous selections in the group if its not mulitselect
// already has some values in the current group, so need to clear them
if (
!isMultiSelection &&
parentItem?.childIds?.[0] &&
newSelectedItems?.[parentItem?.childIds[0]]
) {
// "remove the prev selection in the group as it is single selection"
const { newSelectedItems: newSelections } =
cascadeSelectionRemovalWithProps(
menuGroupMap,
newSelectedItems,
parentItem?.childIds?.[0],
{ isParentUpdateRequired: true, isMultiSelection }
);
newSelectedItems = newSelections;
}
// item addition, works even if direct parent is not selected
newSelectedItems = getConnectedItemByDirection(
menuGroupMap,
false,
itemId,
newSelectedItems,
!isMultiSelection
);
} catch (e) {
// setError("issue while adding item");
console.log("issue while adding item", e, selectedItems, itemId);
} finally {
return newSelectedItems;
}
};
/**
*
* cascading item removal and highlights the next available selection
*/
export const cascadeSelectionRemovalWithProps = (
menuGroupMap: MenuGroupMap,
selectedItems: SelectedItemType,
itemId: ItemId,
additionalProps?: {
isParentUpdateRequired?: boolean;
getNextAvailableSelection?: boolean;
isMultiSelection?: boolean;
}
): {
newSelectedItems: SelectedItemType;
newChildId?: ItemId;
} => {
const {
isParentUpdateRequired = false,
getNextAvailableSelection = false,
isMultiSelection = false, // TODO: is there dependency on this?
} = additionalProps || {};
// taking the orginal complete selections
try {
// cascading item removal
const updatedSelections = cascadeSelectionRemoval(
menuGroupMap,
selectedItems,
itemId
);
// console.log("updatedSelections", updatedSelections);
// apply the passed props
// if there are other elements in the same group then make it active
if (isParentUpdateRequired) {
// update the parent's childIds
let parentId = menuGroupMap[itemId].parentId;
if (parentId && selectedItems?.[parentId]) {
const updatedChildren =
selectedItems[parentId].childIds?.filter((e) => e !== itemId) || [];
updatedSelections[parentId] = {
...selectedItems[parentId],
childIds: updatedChildren,
};
// console.log("updated childIds in parent", updatedChildren, parentId);
if (getNextAvailableSelection) {
// follow the selection order and pick the latest selection
let childId;
let children = [...updatedChildren];
childId = children?.[children.length - 1]; //latest selection
// console.log("potential childId found", childId);
if (childId && selectedItems[childId]) {
const otherPath = getConnectedItems(
menuGroupMap,
childId,
updatedSelections
);
// console.log("found other path", otherPath);
return {
newSelectedItems: otherPath,
newChildId: childId,
};
} else {
// no childId found, so return the updatedSelections with parentId
return {
newSelectedItems: updatedSelections,
newChildId: parentId,
};
}
}
}
} else if (getNextAvailableSelection) {
/**
* if there is any other item in the same group having no parent i.e, in the first level
*/
// TODO: can't use selectedItems as, we should will the group of deleted ids not just item.id
}
// TODO, need to return some childId, because activeItemLeaf may be looking for it.
return { newSelectedItems: updatedSelections };
} catch (e) {
console.log(
"error in highlighting the other selection in the same group",
e
);
return { newSelectedItems: selectedItems };
}
};
export const initPreSelections = (selections: FormatedSelections | null) => {
const newSelectedItems: SelectedItemType = {};
const newActiveItems = {};
if (!selections) return { newSelectedItems, newActiveItems };
const initPreSelectionsHelper = (e: FormatedSelections) => {
if (!e) return;
newSelectedItems[e.id] = {
id: e.id,
childIds: e.options?.map((e) => e?.id) || [],
};
e.options.forEach((opt) => {
initPreSelectionsHelper(opt);
});
};
initPreSelectionsHelper(selections);
return { newSelectedItems };
};
/**
* Cascading item removal(current+children)
* Removes the current selection and its children selections
*/
export const cascadeSelectionRemoval = (
obj: MenuGroupMap,
selectedItems: SelectedItemType,
nodeId: ItemId
): SelectedItemType => {
try {
if (!nodeId) return {};
const result = { ...selectedItems };
const cascadeSelectionRemovalHelper = (
obj: MenuGroupMap,
selectedItems: SelectedItemType,
nodeId: ItemId,
result: SelectedItemType
) => {
const childIds = selectedItems[nodeId]?.childIds;
delete result[nodeId];
if (childIds) {
childIds.forEach((childId) => {
cascadeSelectionRemovalHelper(obj, selectedItems, childId, result);
});
}
};
cascadeSelectionRemovalHelper(obj, selectedItems, nodeId, result);
// console.log("cascadeSelectionRemoval", result);
return result;
} catch (e) {
console.log("Issue while remove item", e);
// setError("Issue while remove item");
return selectedItems;
}
};
// /**
// * get all leaf-nodes with paths through indexes
// * used for search
// */
export function getAllLeafNodes(
treeObj: MenuGroup,
index: number
): CompleteObj[] {
try {
const { label, options } = treeObj;
if (!options?.length) {
return [{ label, indexes: [index] }];
}
const childRes = options.reduce(
(acc: CompleteObj[], item, index: number): CompleteObj[] => {
return [...acc, ...getAllLeafNodes(item, index)];
},
[]
);
return childRes.map((e) => {
const { label, indexes } = e;
if (!treeObj.label) {
return e;
}
return {
label: `${treeObj.label}=>${label}`,
indexes: [index, ...indexes],
};
});
} catch (e) {
console.log("error in finding all the leafs", e);
}
// not necessary, will not be able to reach this
return [{ label: treeObj.label, indexes: [index] }];
}