mui-tree-select
Version:
Material-UI auto select component for tree data structures.
894 lines (893 loc) • 27.4 kB
JavaScript
import { createFilterOptions } from "@mui/material";
import useControlled from "@mui/utils/useControlled";
import { useCallback, useMemo, useRef } from "react";
import usePromise from "./usePromise";
/**
* Wrapper for free solo values.
*
* FreeSoloNode is always a leaf node.
*
*/
export class FreeSoloNode extends String {
constructor(freeSoloValue, parent = null) {
super(freeSoloValue);
this.parent = parent;
}
}
/**
* @internal
* @ignore
*/
export var NodeType;
(function (NodeType) {
NodeType[(NodeType["LEAF"] = 0)] = "LEAF";
NodeType[(NodeType["DOWN_BRANCH"] = 1)] = "DOWN_BRANCH";
NodeType[(NodeType["UP_BRANCH"] = 2)] = "UP_BRANCH";
})(NodeType || (NodeType = {}));
const asyncOrAsyncBlock = (
it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => {
return (function getReturn(
result
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) {
if (result.done) {
return result.value;
} else if (result.value instanceof Promise) {
return result.value.then((value) => getReturn(it.next(value)));
} else {
return getReturn(it.next(result.value));
}
})(it.next());
};
/**
* @internal
* @ignore
*/
export class InternalOption {
constructor(node, type, path) {
this.node = node;
this.type = type;
this.path = path;
}
toString() {
return String(this.node);
}
}
const getPathToNode = (toNode, getParent) => {
function* it() {
var _a, _b;
const path = [];
let parent =
(_a = yield toNode instanceof FreeSoloNode
? toNode.parent
: getParent(toNode)) !== null && _a !== void 0
? _a
: null;
while (parent !== null) {
path.push(parent);
parent =
(_b = yield getParent(parent)) !== null && _b !== void 0 ? _b : null;
}
return path;
}
return asyncOrAsyncBlock(it());
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const defaultFilterOptions = createFilterOptions();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const defaultGetOptionLabel = (node) => String(node);
const defaultGetOptionDisabled = () => false;
const defaultIsBranchSelectable = () => false;
/**
* @internal
* @ignore
*/
export const useTreeSelect = ({
branch: branchProp,
branchDelimiter = " > ",
componentName = "useTreeSelect",
defaultBranch,
defaultValue,
filterOptions: filterOptionsProp = defaultFilterOptions,
freeSolo,
getPathLabel: getPathLabelProp,
getChildren,
getOptionDisabled: getOptionDisabledProp = defaultGetOptionDisabled,
getOptionLabel: getOptionLabelProp = defaultGetOptionLabel,
getParent,
groupBy: groupByProp,
inputValue: inputValueProp,
isBranch: isBranchProp,
isBranchSelectable = defaultIsBranchSelectable,
isOptionEqualToValue: isOptionEqualToValueProp,
multiple,
onBranchChange,
onChange: onChangeProp,
onClose: onCloseProp,
onHighlightChange: onHighlightChangeProp,
onInputChange: onInputChangeProp,
onOpen: onOpenProp,
open: openProp,
value: valueProp,
}) => {
const [inputValue, setInputValue] = useControlled({
controlled: inputValueProp,
default: "",
name: componentName,
state: "inputValue",
});
const [curValue, setValue] = useControlled({
controlled: valueProp,
default: multiple && defaultValue === undefined ? [] : defaultValue,
name: componentName,
state: "value",
});
const [curBranch, setBranch] = useControlled({
controlled: branchProp,
default:
defaultBranch !== null && defaultBranch !== void 0 ? defaultBranch : null,
name: componentName,
state: "branch",
});
const [open, setOpen] = useControlled({
controlled: openProp,
default: false,
name: componentName,
state: "open",
});
const isBranch = useCallback(
(node) =>
(
isBranchProp ||
((node) => {
const result = getChildren(node);
if (result instanceof Promise) {
return result.then((result) => !!result);
}
return !!result;
})
)(node),
[getChildren, isBranchProp]
);
const pathArg = useMemo(() => {
if (
(curBranch !== null && curBranch !== void 0 ? curBranch : null) === null
) {
return [];
}
const path = getPathToNode(curBranch, getParent);
if (path instanceof Promise) {
return path.then((path) => {
path.unshift(curBranch);
return path;
});
} else {
path.unshift(curBranch);
return path;
}
}, [curBranch, getParent]);
const pathResult = usePromise(pathArg);
const optionsResult = usePromise(
useMemo(() => {
const options = [];
function* getOpts() {
const [path, children] = yield (() => {
const children = getChildren(curBranch);
if (pathArg instanceof Promise || children instanceof Promise) {
return Promise.all([pathArg, children]).then(([path, children]) => [
path,
children || [],
]);
} else {
return [pathArg, children || []];
}
})();
if (
curBranch !== null && curBranch !== void 0 ? curBranch : null !== null
) {
options.push(
new InternalOption(curBranch, NodeType.UP_BRANCH, path.slice(1))
);
}
options.push(
...(yield (() => {
const options = [];
function* parseChildNode(childNode) {
if (yield isBranch(childNode)) {
options.push(
new InternalOption(childNode, NodeType.DOWN_BRANCH, path)
);
if (!(yield isBranchSelectable(childNode))) {
return;
}
}
options.push(new InternalOption(childNode, NodeType.LEAF, path));
}
const promises = [];
for (const childNode of children) {
const result = asyncOrAsyncBlock(parseChildNode(childNode));
if (result instanceof Promise) {
promises.push(result);
}
}
return promises.length
? Promise.all(promises).then(() => options)
: options;
})())
);
return options.sort(({ type: a }, { type: b }) => {
if (a === b) {
return 0;
} else if (a === NodeType.UP_BRANCH) {
return -1;
} else if (b === NodeType.UP_BRANCH) {
return 1;
} else if (a === NodeType.DOWN_BRANCH) {
return -1;
} else if (b === NodeType.DOWN_BRANCH) {
return 1;
}
return 0; // This should never happen.
});
}
return asyncOrAsyncBlock(getOpts());
}, [curBranch, getChildren, pathArg, isBranch, isBranchSelectable])
);
const valueResult = usePromise(
useMemo(() => {
if (
(curValue !== null && curValue !== void 0 ? curValue : null) === null
) {
return null;
} else if (multiple) {
const multiValue = [];
let hasPromise = false;
for (const node of curValue) {
const path = getPathToNode(node, getParent);
if (path instanceof Promise) {
hasPromise = true;
multiValue.push(
path.then((path) => new InternalOption(node, NodeType.LEAF, path))
);
} else {
multiValue.push(new InternalOption(node, NodeType.LEAF, path));
}
}
if (hasPromise) {
return Promise.all(multiValue);
} else {
return multiValue;
}
} else {
const path = getPathToNode(curValue, getParent);
return path instanceof Promise
? path.then(
(path) => new InternalOption(curValue, NodeType.LEAF, path)
)
: new InternalOption(curValue, NodeType.LEAF, path);
}
}, [curValue, getParent, multiple])
);
const value = useMemo(() => {
var _a;
if (multiple) {
return (
valueResult.data ||
curValue.map((value) => new InternalOption(value, NodeType.LEAF, []))
);
} else {
return (_a = valueResult.data) !== null && _a !== void 0
? _a
: (curValue !== null && curValue !== void 0 ? curValue : null) === null
? null
: new InternalOption(curValue, NodeType.LEAF, []);
}
}, [curValue, multiple, valueResult.data]);
const isOptionEqualToValue = useCallback(
(option, value) => {
if (
option.type === NodeType.UP_BRANCH ||
option.type === NodeType.DOWN_BRANCH ||
value.type === NodeType.UP_BRANCH ||
value.type === NodeType.DOWN_BRANCH
) {
return false;
}
/**
* Handle this case:
* Add freeSolo call to selectNewValue and `multiple === true`
* https://github.com/mui/material-ui/blob/f8520c409c6682a75e117947c9104a73e30de5c7/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js#L622
*/
const optionNode =
multiple && freeSolo && typeof option === "string"
? new FreeSoloNode(option, curBranch)
: option.node;
if (isOptionEqualToValueProp) {
return isOptionEqualToValueProp(optionNode, value.node);
} else if (optionNode instanceof FreeSoloNode) {
if (value.node instanceof FreeSoloNode) {
return (
value.node.toString() === optionNode.toString() &&
optionNode.parent === value.node.parent
);
}
return false;
} else if (value.node instanceof FreeSoloNode) {
return false;
} else {
return (
option.node === value.node &&
option.path.length === value.path.length &&
option.path.every((node, index) => node === value.path[index])
);
}
},
[curBranch, freeSolo, isOptionEqualToValueProp, multiple]
);
const getOptionLabel = useCallback(
(arg) => getOptionLabelProp(arg.node),
[getOptionLabelProp]
);
const options = useMemo(() => {
if (optionsResult.data) {
// Determine if "inputValue" should be an "add" free solo option.
if (
freeSolo &&
inputValue &&
(multiple || !value || getOptionLabel(value) !== inputValue)
) {
const freeSoloOption = new InternalOption(
new FreeSoloNode(inputValue, curBranch),
NodeType.LEAF,
pathResult.data || []
);
if (
(multiple ? value : [value]).every(
(value) =>
// NOT the following
!(
value &&
value.node instanceof FreeSoloNode &&
isOptionEqualToValue(freeSoloOption, value)
)
)
) {
return [...optionsResult.data, freeSoloOption];
}
}
return optionsResult.data;
} else if (curBranch === null) {
return [];
} else {
return [new InternalOption(curBranch, NodeType.UP_BRANCH, [])];
}
}, [
curBranch,
freeSolo,
getOptionLabel,
inputValue,
isOptionEqualToValue,
multiple,
optionsResult.data,
pathResult.data,
value,
]);
const getOptionDisabled = useCallback(
({ node, type }) => {
if (type === NodeType.UP_BRANCH || node instanceof FreeSoloNode) {
return false;
}
return getOptionDisabledProp(node);
},
[getOptionDisabledProp]
);
const getPathLabel = useCallback(
(to, includeTo) => {
if (getPathLabelProp) {
return getPathLabelProp(includeTo ? [to.node, ...to.path] : to.path);
} else {
if (!to.path.length && !includeTo) {
return "";
}
const [first, rest] = (() => {
if (includeTo) {
return [to.node, to.path];
}
return [to.path[0], to.path.slice(1)];
})();
return rest.reduce((label, node) => {
return `${getOptionLabelProp(node)}${branchDelimiter}${label}`;
}, getOptionLabelProp(first));
}
},
[getPathLabelProp, getOptionLabelProp, branchDelimiter]
);
// Will NEVER be called unless groupByProp is defined
const handleGroupBy = useCallback(
({ node, type }) => {
if (type === NodeType.UP_BRANCH) {
return "";
} else {
// Will never be called unless groupByProp is defined
return groupByProp(node);
}
},
[groupByProp]
);
// handleGroupBy is only assigned to groupBy when groupByProp IS defined
const groupBy = groupByProp && handleGroupBy;
const noOptions = useRef(
!options.length ||
(options.length === 1 && options[0].type === NodeType.UP_BRANCH)
);
const filterOptions = useCallback(
(options, state) => {
const {
upBranch,
freeSoloOptions,
branchOptionsMap,
leafOptionsMap,
optionKeys,
} = options.reduce(
(result, option) => {
if (option.type === NodeType.UP_BRANCH) {
result.upBranch = option;
} else if (option.node instanceof FreeSoloNode) {
result.freeSoloOptions.push(option);
} else if (option.type === NodeType.DOWN_BRANCH) {
result.branchOptionsMap.set(option.node, option);
result.optionKeys.add(option.node);
} else {
result.leafOptionsMap.set(option.node, option);
result.optionKeys.add(option.node);
}
return result;
},
{
upBranch: null,
freeSoloOptions: [],
branchOptionsMap: new Map(),
leafOptionsMap: new Map(),
optionKeys: new Set(),
}
);
// Prevent a selected value from filtering against branch options
// from which it does NOT belong.
if (
!multiple &&
value &&
state.getOptionLabel(value) === state.inputValue &&
!options.find((option) => isOptionEqualToValue(option, value))
) {
return options;
}
const filteredOptions = (() => {
const [branchOptions, leafOptions] = filterOptionsProp(
Array.from(optionKeys),
{
...state,
getOptionLabel: getOptionLabelProp,
}
).reduce(
(filteredOptions, node) => {
const branchOption = branchOptionsMap.get(node);
if (branchOption) {
filteredOptions[0].push(branchOption);
}
const leafOption = leafOptionsMap.get(node);
if (leafOption) {
filteredOptions[1].push(leafOption);
}
return filteredOptions;
},
[[], []]
);
// Sort branch options to top
return [...branchOptions, ...leafOptions];
})();
noOptions.current = !filteredOptions.length && !freeSoloOptions.length;
return upBranch === null
? [...filteredOptions, ...freeSoloOptions]
: [upBranch, ...filteredOptions, ...freeSoloOptions];
},
[
filterOptionsProp,
getOptionLabelProp,
isOptionEqualToValue,
multiple,
value,
]
);
const selectedOption = useRef(null);
const onHighlightChange = useCallback(
(event, option, reason) => {
var _a;
selectedOption.current = option;
if (onHighlightChangeProp) {
onHighlightChangeProp(
event,
(_a = option === null || option === void 0 ? void 0 : option.node) !==
null && _a !== void 0
? _a
: null,
reason
);
}
},
[onHighlightChangeProp]
);
const onInputChange = useCallback(
(...args) => {
const [, , reason] = args;
if (
selectedOption.current &&
selectedOption.current.type !== NodeType.LEAF &&
reason === "reset"
) {
if (
multiple ||
(value !== null && value !== void 0 ? value : null) === null
) {
args[1] = "";
} else {
args[1] = getOptionLabel(value);
}
}
if (onInputChangeProp) {
onInputChangeProp(...args);
}
const [, newInputValue] = args;
setInputValue(newInputValue);
},
[getOptionLabel, multiple, onInputChangeProp, setInputValue, value]
);
const onKeyDown = useCallback(
(event) => {
var _a;
if (
!selectedOption.current ||
selectedOption.current.type === NodeType.LEAF ||
event.which == 229
) {
return;
} else if (event.key === "ArrowRight") {
if (selectedOption.current.type === NodeType.DOWN_BRANCH) {
event.preventDefault();
// https://github.com/mui/mui-x/issues/1403
// https://github.com/mui/material-ui/blob/b3645b3fd11dc26a06ea370a41b5bac1026c6792/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js#L727
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event["defaultMuiPrevented"] = true;
const node = selectedOption.current.node;
if (onInputChange) {
onInputChange(event, "", "reset");
}
if (onBranchChange) {
onBranchChange(event, node, "down");
}
setBranch(node);
}
} else if (event.key === "ArrowLeft") {
if (selectedOption.current.type === NodeType.UP_BRANCH) {
event.preventDefault();
// https://github.com/mui/mui-x/issues/1403
// https://github.com/mui/material-ui/blob/b3645b3fd11dc26a06ea370a41b5bac1026c6792/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js#L727
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event["defaultMuiPrevented"] = true;
const node =
(_a = selectedOption.current.path[0]) !== null && _a !== void 0
? _a
: null;
if (onInputChange) {
onInputChange(event, "", "reset");
}
if (onBranchChange) {
onBranchChange(event, node, "up");
}
setBranch(node);
}
}
},
[onBranchChange, onInputChange, setBranch]
);
const handleOptionClick = useCallback((branchOption) => {
selectedOption.current = branchOption;
}, []);
const onChange = useCallback(
(...args) => {
const handleBranchChange = (event, option) => {
var _a;
const isUpBranch = option.type === NodeType.UP_BRANCH;
if (isUpBranch || option.type === NodeType.DOWN_BRANCH) {
const node = isUpBranch
? (_a = option.path[0]) !== null && _a !== void 0
? _a
: null
: option.node;
if (onBranchChange) {
onBranchChange(event, node, isUpBranch ? "up" : "down");
}
setBranch(node);
return true;
}
return false;
};
/**
* Allows for recursive call when `autoSelect` and `freeSolo` are `true` and `reason` is "blur" to determine if auto select is selecting an option or creating a free solo value.
*
* @internal
* @ignore
* */
const handleChange = (args, reasonIsBlur) => {
const [event, , reason] = args;
if (multiple) {
switch (reason) {
case "selectOption": {
const [, rawValues, , details] = args;
const [newValue] = rawValues.slice(-1);
const values = rawValues.map(({ node }) => node);
// Selected Branch
if (handleBranchChange(event, newValue)) {
break;
}
if (onChangeProp) {
onChangeProp(
event,
values,
reasonIsBlur ? "blur" : reason,
details
? {
...details,
option: newValue.node,
}
: details
);
}
setValue(values);
break;
}
case "createOption": {
// make copy of value
const [, [...rawValues], , details] = args;
const [freeSoloValue] = rawValues.splice(-1);
const freeSoloNode = new FreeSoloNode(freeSoloValue, curBranch);
const values = [
...rawValues.map(({ node }) => node),
freeSoloNode,
];
if (onChangeProp) {
onChangeProp(
event,
values,
reasonIsBlur ? "blur" : reason,
details
? {
...details,
option: freeSoloNode,
}
: details
);
}
setValue(values);
break;
}
case "blur": {
const [, rawValues, , details] = args;
const [newValue] = rawValues.slice(-1);
handleChange(
[
event,
args[1],
typeof newValue === "string"
? "createOption"
: "selectOption",
details,
],
true
);
break;
}
case "removeOption":
case "clear": {
const [, rawValues, , details] = args;
const values = rawValues.map(({ node }) => node);
if (onChangeProp) {
onChangeProp(
event,
values,
reason,
details
? {
...details,
option: details.option.node,
}
: details
);
}
setValue(values);
break;
}
}
} else {
switch (reason) {
case "selectOption": {
const [, value, , details] = args;
// Selected Branch
if (handleBranchChange(event, value)) {
break;
}
if (onChangeProp) {
onChangeProp(
event,
value.node,
reasonIsBlur ? "blur" : reason,
details
? {
...details,
option: value.node,
}
: details
);
}
setValue(value.node);
break;
}
case "createOption": {
const [, freeSoloValue, , details] = args;
const freeSoloNode = new FreeSoloNode(freeSoloValue, curBranch);
if (onChangeProp) {
onChangeProp(
event,
freeSoloNode,
reasonIsBlur ? "blur" : reason,
details
? {
...details,
option: freeSoloNode,
}
: details
);
}
setValue(freeSoloNode);
break;
}
case "blur": {
const [, newValue, , details] = args;
handleChange(
[
event,
args[1],
typeof newValue === "string"
? "createOption"
: "selectOption",
details,
],
true
);
break;
}
case "removeOption": // Note remove only fires for multiple
case "clear": {
const [, , , details] = args;
if (onChangeProp) {
onChangeProp(
event,
null,
reasonIsBlur ? "blur" : reason,
details
? {
...details,
option: details.option.node,
}
: details
);
}
setValue(null);
break;
}
}
}
};
handleChange(args, false);
},
[curBranch, multiple, onBranchChange, onChangeProp, setBranch, setValue]
);
const onClose = useCallback(
(...args) => {
const [, reason] = args;
if (
reason === "selectOption" &&
selectedOption.current &&
selectedOption.current.type !== NodeType.LEAF
) {
return;
}
if (onCloseProp) {
onCloseProp(...args);
}
setOpen(false);
},
[onCloseProp, setOpen]
);
const onOpen = useCallback(
(...args) => {
if (onOpenProp) {
onOpenProp(...args);
}
setOpen(true);
},
[onOpenProp, setOpen]
);
const _return = {
filterOptions,
getPathLabel,
getOptionDisabled,
getOptionLabel,
groupBy,
onKeyDown,
handleOptionClick,
inputValue,
isAtRoot: curBranch === null,
isOptionEqualToValue,
loadingOptions: pathResult.loading || optionsResult.loading,
noOptions,
onChange,
onClose,
onHighlightChange,
onInputChange,
onOpen,
open,
options,
value: value,
};
/**
* Turn OFF the following warning:
* https://github.com/mui/material-ui/blob/8f7b7514e64f126f0f2a0ced8dcee252b25c68e9/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js#L245
* Not applicable to Tree Select
*/
if (process.env.NODE_ENV !== "production") {
const missingValue = (() => {
// https://github.com/mui/material-ui/blob/8f7b7514e64f126f0f2a0ced8dcee252b25c68e9/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js#L246
if (value !== null && !freeSolo && _return.options.length > 0) {
return (multiple ? value : [value]).filter(
(value2) =>
!_return.options.some((option) =>
isOptionEqualToValue(option, value2)
)
);
} else {
return null;
}
})();
// eslint-disable-next-line react-hooks/rules-of-hooks
_return.options = useMemo(
() =>
(
missingValue === null || missingValue === void 0
? void 0
: missingValue.length
)
? [..._return.options, ...missingValue]
: _return.options,
[_return.options, missingValue]
);
const _filterOptions = _return.filterOptions;
// eslint-disable-next-line react-hooks/rules-of-hooks
_return.filterOptions = useCallback(
(options, ...rest) =>
_filterOptions(
options.filter(
(option) =>
!(missingValue === null || missingValue === void 0
? void 0
: missingValue.includes(option))
),
...rest
),
[_filterOptions, missingValue]
);
}
return _return;
};
export default useTreeSelect;