mui-tree-select
Version:
Material-UI auto select component for tree data structures.
467 lines (466 loc) • 13.7 kB
JavaScript
import React, { forwardRef, useCallback, useMemo } from "react";
import {
Autocomplete,
ListItemButton,
ListItemIcon,
ListItemText,
Tooltip,
Chip,
SvgIcon,
styled,
Paper,
getAutocompleteUtilityClass,
unstable_composeClasses as composeClasses,
} from "@mui/material";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import useTreeSelect, { NodeType, FreeSoloNode } from "./useTreeSelect";
// Cloned from Autocomplete for loading and noOptions components
// https://github.com/mui/material-ui/blob/b3645b3fd11dc26a06ea370a41b5bac1026c6792/packages/mui-material/src/Autocomplete/Autocomplete.js#L27
const useUtilityClasses = (classes) => {
const slots = {
loading: ["loading"],
noOptions: ["noOptions"],
};
return composeClasses(slots, getAutocompleteUtilityClass, classes);
};
/**
* Default Option Component.
*/
export const DefaultOption = (props) => {
const {
pathDirection = "",
pathLabel = "",
enterIcon = null,
enterText = "",
exitIcon = null,
exitText = "",
TooltipProps: tooltipProps,
ListItemTextProps: listItemTextProps,
...listItemButtonProps
} = props;
return React.createElement(
ListItemButton,
{ component: "li", dense: true, ...listItemButtonProps },
pathDirection === "up"
? React.createElement(
React.Fragment,
null,
React.createElement(
Tooltip,
{
title: exitText,
...(tooltipProps === null || tooltipProps === void 0
? void 0
: tooltipProps.exit),
},
React.createElement(ListItemIcon, null, exitIcon)
),
React.createElement(
Tooltip,
{
title: pathLabel,
...(tooltipProps === null || tooltipProps === void 0
? void 0
: tooltipProps.currentPath),
},
React.createElement(ListItemText, { ...listItemTextProps })
)
)
: React.createElement(ListItemText, { ...listItemTextProps }),
pathDirection === "down" &&
React.createElement(
Tooltip,
{ title: enterText, ...tooltipProps },
React.createElement(
ListItemIcon,
{
sx: {
minWidth: "auto",
},
},
enterIcon
)
)
);
};
/**
* Returns props for {@link DefaultOption} from arguments of {@link RenderOption}
*/
export const getDefaultOptionProps = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args
) => {
const [props, node, state] = args;
const baseProps = {
dense: true,
divider: state.pathDirection === "up",
...props,
ListItemTextProps: {
primary: `${
node instanceof FreeSoloNode && state.addFreeSoloText
? state.addFreeSoloText
: ""
}${state.optionLabel}`,
},
};
if (state.pathDirection === "up") {
return {
...baseProps,
pathDirection: "up",
pathLabel: state.pathLabel,
divider: true,
exitIcon: state.exitIcon,
exitText: state.exitText,
TooltipProps: state.TooltipProps,
};
} else if (state.pathDirection === "down") {
return {
...baseProps,
pathDirection: "down",
enterIcon: state.enterIcon,
enterText: state.enterText,
TooltipProps: state.TooltipProps,
};
} else {
return baseProps;
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const defaultRenderOption = (...args) =>
React.createElement(DefaultOption, { ...getDefaultOptionProps(...args) });
const defaultGetOptionKey = (_, { key }) => key;
export const PathIcon = forwardRef(function PathIcon(props, ref) {
return React.createElement(
SvgIcon,
{
style: {
...props.style,
cursor: "default",
},
ref: ref,
...props,
},
React.createElement("path", {
d: "M20 9C18.69 9 17.58 9.83 17.17 11H14.82C14.4 9.84 13.3 9 12 9S9.6 9.84 9.18 11H6.83C6.42 9.83 5.31 9 4 9C2.34 9 1 10.34 1 12S2.34 15 4 15C5.31 15 6.42 14.17 6.83 13H9.18C9.6 14.16 10.7 15 12 15S14.4 14.16 14.82 13H17.17C17.58 14.17 18.69 15 20 15C21.66 15 23 13.66 23 12S21.66 9 20 9",
})
);
});
const defaultEnterIcon = React.createElement(ChevronRightIcon, null);
const defaultExitIcon = React.createElement(ChevronLeftIcon, null);
const defaultPathIcon = React.createElement(PathIcon, { fontSize: "small" });
// Cloned from Autocomplete
// https://github.com/mui/material-ui/blob/b3645b3fd11dc26a06ea370a41b5bac1026c6792/packages/mui-material/src/Autocomplete/Autocomplete.js#L251-L258
const AutocompleteLoading = styled("div", {
name: "MuiAutocomplete",
slot: "Loading",
overridesResolver: (_, styles) => styles.loading,
})(({ theme }) => ({
color: theme.palette.text.secondary,
padding: "14px 16px",
}));
// Cloned from Autocomplete
// https://github.com/mui/material-ui/blob/b3645b3fd11dc26a06ea370a41b5bac1026c6792/packages/mui-material/src/Autocomplete/Autocomplete.js#L260-L267
const AutocompleteNoOptions = styled("div", {
name: "MuiAutocomplete",
slot: "NoOptions",
overridesResolver: (props, styles) => styles.noOptions,
})(({ theme }) => ({
color: theme.palette.text.secondary,
padding: "14px 16px",
}));
const _TreeSelect = (props, ref) => {
const {
addFreeSoloText = "Add: ",
branch,
branchDelimiter,
defaultBranch,
enterIcon = defaultEnterIcon,
enterText = "Enter",
exitIcon = defaultExitIcon,
exitText = "Exit",
getChildren,
getParent,
getOptionKey = defaultGetOptionKey,
isBranch,
isBranchSelectable,
loadingText = "Loading…",
noOptionsText = "No options",
onBranchChange,
PaperComponent: PaperComponentProp = Paper,
pathIcon = defaultPathIcon,
renderOption: renderOptionProp = defaultRenderOption,
renderInput: renderInputProp,
renderTags: renderTagsProp,
TooltipProps,
...restProps
} = props;
const {
getPathLabel,
getOptionLabel,
handleOptionClick,
isAtRoot,
loadingOptions,
noOptions,
...restTreeOpts
} = useTreeSelect({
branch,
defaultBranch,
getChildren,
getParent,
isBranch,
isBranchSelectable,
onBranchChange,
branchDelimiter,
...restProps,
});
const classesClone = useUtilityClasses(props.classes);
const PaperComponent = useMemo(() => {
return forwardRef(({ children = null, ...paperProps }, ref) => {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
React.createElement(
PaperComponentProp,
{ ...paperProps, ref: ref },
children,
loadingOptions && !isAtRoot
? React.createElement(
AutocompleteLoading,
{ className: classesClone.loading },
loadingText
)
: null,
!isAtRoot && !!noOptions.current && !loadingOptions
? React.createElement(
AutocompleteNoOptions,
{
className: classesClone.noOptions,
role: "presentation",
onMouseDown: (event) => {
// Prevent input blur when interacting with the "no options" content
event.preventDefault();
},
},
noOptionsText
)
: null
)
);
});
}, [
PaperComponentProp,
classesClone.loading,
classesClone.noOptions,
isAtRoot,
loadingOptions,
loadingText,
noOptions,
noOptionsText,
]);
const toolTipProps = useMemo(() => {
if (!TooltipProps) {
return undefined;
} else if (
"enter" in TooltipProps ||
"exit" in TooltipProps ||
"currentPath" in TooltipProps ||
"valuePath" in TooltipProps
) {
return TooltipProps;
} else {
return {
enter: TooltipProps,
exit: TooltipProps,
currentPath: TooltipProps,
valuePath: TooltipProps,
};
}
}, [TooltipProps]);
const renderInput = useCallback(
(params) => {
if (props.multiple) {
return renderInputProp(params);
} else {
return renderInputProp({
...params,
InputProps: {
...params.InputProps,
startAdornment: (() => {
if (restTreeOpts.value) {
return React.createElement(
React.Fragment,
null,
React.createElement(
Tooltip,
{
title: getPathLabel(restTreeOpts.value, true),
...(toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.valuePath),
},
pathIcon ||
React.createElement(PathIcon, { fontSize: "small" })
),
params.InputProps.startAdornment || null
);
} else {
return params.InputProps.startAdornment;
}
})(),
},
});
}
},
[
getPathLabel,
pathIcon,
props.multiple,
renderInputProp,
restTreeOpts.value,
toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.valuePath,
]
);
const renderOption = useCallback(
({ onClick, ...props }, option, state) => {
const { type, node } = option;
const isUpBranch = type === NodeType.UP_BRANCH;
const isDownBranch = type === NodeType.DOWN_BRANCH;
return renderOptionProp(
{
...props,
key: getOptionKey(node, {
key: `${props.key}-${type}`,
}),
onClick: (...args) => {
handleOptionClick(option);
onClick(...args);
},
},
node,
{
...state,
addFreeSoloText,
pathDirection: isUpBranch ? "up" : isDownBranch ? "down" : undefined,
pathLabel:
isUpBranch || isDownBranch
? getPathLabel(option, true)
: getPathLabel(option, false),
disabled: isUpBranch ? false : !!props["aria-disabled"],
enterIcon,
enterText,
exitIcon,
exitText,
optionLabel: getOptionLabel(option),
TooltipProps: isUpBranch
? (toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.exit) ||
(toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.currentPath)
? {
exit:
toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.exit,
currentPath:
toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.currentPath,
}
: undefined
: isDownBranch
? toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.enter
: undefined,
}
);
},
[
renderOptionProp,
getOptionKey,
addFreeSoloText,
getPathLabel,
enterIcon,
enterText,
exitIcon,
exitText,
getOptionLabel,
toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.exit,
toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.currentPath,
toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.enter,
handleOptionClick,
]
);
const renderTags = useCallback(
(value, getTagProps) => {
if (renderTagsProp) {
return renderTagsProp(
value.map(({ node }) => node),
getTagProps,
{
getPathLabel: (index) => getPathLabel(value[index], true),
}
);
}
return value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index });
const title = getPathLabel(option, true);
return React.createElement(
Tooltip,
{
title: title,
...(toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.valuePath),
key: key,
},
React.createElement(Chip, {
label: getOptionLabel(option),
size: props.size || "medium",
...tagProps,
...props.ChipProps,
})
);
});
},
[
renderTagsProp,
getPathLabel,
toolTipProps === null || toolTipProps === void 0
? void 0
: toolTipProps.valuePath,
getOptionLabel,
props.size,
props.ChipProps,
]
);
return React.createElement(
Autocomplete,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{
...restProps,
...restTreeOpts,
getOptionLabel: getOptionLabel,
loading: loadingOptions,
loadingText: loadingText,
noOptionsText: noOptionsText,
PaperComponent: PaperComponent,
renderInput: renderInput,
renderOption: renderOption,
renderTags: renderTags,
ref: ref,
}
);
};
export const TreeSelect = forwardRef(_TreeSelect);
export default TreeSelect;