1121 lines
34 KiB
Vue
1121 lines
34 KiB
Vue
/**
|
|
* ARIA: https://www.w3.org/TR/wai-aria/#combobox
|
|
* Sample 1: https://www.w3.org/TR/2017/NOTE-wai-aria-practices-1.1-20171214/examples/combobox/aria1.1pattern/listbox-combo.html
|
|
* Sample 2: https://www.w3.org/blog/wai-components-gallery/widget/combobox-with-aria-autocompleteinline/
|
|
*
|
|
* Tab logic:
|
|
* Popup is close
|
|
* 1. Focus input (mark component as focused)
|
|
* 2. Press enter to show the popup
|
|
* 3. If popup has input, focus it
|
|
*
|
|
* Popup is open
|
|
* 1. press tab to close the popup
|
|
* 2. Focus back to the selection input box
|
|
* 3. Let the native tab going on
|
|
*
|
|
* TreeSelect use 2 design type.
|
|
* In single mode, we should focus on the `span`
|
|
* In multiple mode, we should focus on the `input`
|
|
*/
|
|
import { provide } from 'vue';
|
|
import shallowEqual from 'shallowequal';
|
|
import raf from 'raf';
|
|
import scrollIntoView from 'dom-scroll-into-view';
|
|
import warning from 'warning';
|
|
import PropTypes from '../../_util/vue-types';
|
|
import KeyCode from '../../_util/KeyCode';
|
|
|
|
import SelectTrigger from './SelectTrigger';
|
|
import SingleSelector from './Selector/SingleSelector';
|
|
import MultipleSelector from './Selector/MultipleSelector';
|
|
import SinglePopup from './Popup/SinglePopup';
|
|
import MultiplePopup from './Popup/MultiplePopup';
|
|
|
|
import { SHOW_ALL, SHOW_PARENT, SHOW_CHILD } from './strategies';
|
|
import BaseMixin from '../../_util/BaseMixin';
|
|
import {
|
|
createRef,
|
|
generateAriaId,
|
|
formatInternalValue,
|
|
formatSelectorValue,
|
|
parseSimpleTreeData,
|
|
convertDataToTree,
|
|
convertTreeToEntities,
|
|
conductCheck,
|
|
getHalfCheckedKeys,
|
|
flatToHierarchy,
|
|
isPosRelated,
|
|
isLabelInValue,
|
|
getFilterTree,
|
|
cleanEntity,
|
|
findPopupContainer,
|
|
} from './util';
|
|
import SelectNode from './SelectNode';
|
|
import {
|
|
initDefaultProps,
|
|
getOptionProps,
|
|
getPropsData,
|
|
findDOMNode,
|
|
} from '../../_util/props-util';
|
|
function getWatch(keys = []) {
|
|
const watch = {};
|
|
keys.forEach(k => {
|
|
watch[k] = function() {
|
|
this.needSyncKeys[k] = true;
|
|
};
|
|
});
|
|
return watch;
|
|
}
|
|
const Select = {
|
|
name: 'Select',
|
|
inheritAttrs: false,
|
|
mixins: [BaseMixin],
|
|
props: initDefaultProps(
|
|
{
|
|
prefixCls: PropTypes.string,
|
|
prefixAria: PropTypes.string,
|
|
multiple: PropTypes.bool,
|
|
showArrow: PropTypes.bool,
|
|
open: PropTypes.bool,
|
|
value: PropTypes.any,
|
|
|
|
autofocus: PropTypes.bool,
|
|
|
|
defaultOpen: PropTypes.bool,
|
|
defaultValue: PropTypes.any,
|
|
|
|
showSearch: PropTypes.bool,
|
|
placeholder: PropTypes.any,
|
|
inputValue: PropTypes.string, // [Legacy] Deprecated. Use `searchValue` instead.
|
|
searchValue: PropTypes.string,
|
|
autoClearSearchValue: PropTypes.bool,
|
|
searchPlaceholder: PropTypes.any, // [Legacy] Confuse with placeholder
|
|
disabled: PropTypes.bool,
|
|
children: PropTypes.any,
|
|
labelInValue: PropTypes.bool,
|
|
maxTagCount: PropTypes.number,
|
|
maxTagPlaceholder: PropTypes.oneOfType([PropTypes.any, PropTypes.func]),
|
|
maxTagTextLength: PropTypes.number,
|
|
showCheckedStrategy: PropTypes.oneOf([SHOW_ALL, SHOW_PARENT, SHOW_CHILD]),
|
|
dropdownClassName: PropTypes.string,
|
|
dropdownStyle: PropTypes.object,
|
|
dropdownVisibleChange: PropTypes.func,
|
|
dropdownMatchSelectWidth: PropTypes.bool,
|
|
treeData: PropTypes.array,
|
|
treeDataSimpleMode: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]),
|
|
treeNodeFilterProp: PropTypes.string,
|
|
treeNodeLabelProp: PropTypes.string,
|
|
treeCheckable: PropTypes.oneOfType([PropTypes.any, PropTypes.object, PropTypes.bool]),
|
|
// treeCheckable: PropTypes.any,
|
|
treeCheckStrictly: PropTypes.bool,
|
|
treeIcon: PropTypes.bool,
|
|
treeLine: PropTypes.bool,
|
|
treeDefaultExpandAll: PropTypes.bool,
|
|
treeDefaultExpandedKeys: PropTypes.array,
|
|
treeExpandedKeys: PropTypes.array,
|
|
loadData: PropTypes.func,
|
|
filterTreeNode: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
|
|
|
|
notFoundContent: PropTypes.any,
|
|
getPopupContainer: PropTypes.func,
|
|
|
|
// onSearch: PropTypes.func,
|
|
// onSelect: PropTypes.func,
|
|
// onDeselect: PropTypes.func,
|
|
// onChange: PropTypes.func,
|
|
// onDropdownVisibleChange: PropTypes.func,
|
|
|
|
// onTreeExpand: PropTypes.func,
|
|
allowClear: PropTypes.bool,
|
|
transitionName: PropTypes.string,
|
|
animation: PropTypes.string,
|
|
choiceTransitionName: PropTypes.string,
|
|
inputIcon: PropTypes.any,
|
|
clearIcon: PropTypes.any,
|
|
removeIcon: PropTypes.any,
|
|
switcherIcon: PropTypes.any,
|
|
__propsSymbol__: PropTypes.any,
|
|
},
|
|
{
|
|
prefixCls: 'rc-tree-select',
|
|
prefixAria: 'rc-tree-select',
|
|
showArrow: true,
|
|
showSearch: true,
|
|
autoClearSearchValue: true,
|
|
showCheckedStrategy: SHOW_CHILD,
|
|
|
|
// dropdownMatchSelectWidth change the origin design, set to false now
|
|
// ref: https://github.com/react-component/select/blob/4cad95e098a341a09de239ad6981067188842020/src/Select.jsx#L344
|
|
// ref: https://github.com/react-component/select/pull/71
|
|
treeNodeFilterProp: 'value',
|
|
treeNodeLabelProp: 'title',
|
|
treeIcon: false,
|
|
notFoundContent: 'Not Found',
|
|
dropdownStyle: {},
|
|
dropdownVisibleChange: () => {
|
|
return true;
|
|
},
|
|
},
|
|
),
|
|
|
|
data() {
|
|
warning(this.$props.__propsSymbol__, 'must pass __propsSymbol__');
|
|
const { prefixAria, defaultOpen, open } = this.$props;
|
|
this.needSyncKeys = {};
|
|
this.selectorRef = createRef();
|
|
this.selectTriggerRef = createRef();
|
|
|
|
// ARIA need `aria-controls` props mapping
|
|
// Since this need user input. Let's generate ourselves
|
|
this.ariaId = generateAriaId(`${prefixAria}-list`);
|
|
|
|
const state = {
|
|
_open: open || defaultOpen,
|
|
_valueList: [],
|
|
_searchHalfCheckedKeys: [],
|
|
_missValueList: [], // Contains the value not in the tree
|
|
_selectorValueList: [], // Used for multiple selector
|
|
_valueEntities: {},
|
|
_posEntities: new Map(),
|
|
_keyEntities: new Map(),
|
|
_searchValue: '',
|
|
_prevProps: {},
|
|
_init: true,
|
|
_focused: undefined,
|
|
_treeNodes: undefined,
|
|
_filteredTreeNodes: undefined,
|
|
};
|
|
const newState = this.getDerivedState(this.$props, state);
|
|
return {
|
|
...state,
|
|
...newState,
|
|
};
|
|
},
|
|
created() {
|
|
provide('vcTreeSelect', {
|
|
onSelectorFocus: this.onSelectorFocus,
|
|
onSelectorBlur: this.onSelectorBlur,
|
|
onSelectorKeyDown: this.onComponentKeyDown,
|
|
onSelectorClear: this.onSelectorClear,
|
|
onMultipleSelectorRemove: this.onMultipleSelectorRemove,
|
|
|
|
onTreeNodeSelect: this.onTreeNodeSelect,
|
|
onTreeNodeCheck: this.onTreeNodeCheck,
|
|
onPopupKeyDown: this.onComponentKeyDown,
|
|
|
|
onSearchInputChange: this.onSearchInputChange,
|
|
onSearchInputKeyDown: this.onSearchInputKeyDown,
|
|
});
|
|
},
|
|
watch: {
|
|
...getWatch(['treeData', 'defaultValue', 'value']),
|
|
__propsSymbol__() {
|
|
const state = this.getDerivedState(this.$props, this.$data);
|
|
this.setState(state);
|
|
this.needSyncKeys = {};
|
|
},
|
|
'$data._valueList'() {
|
|
this.$nextTick(() => {
|
|
this.forcePopupAlign();
|
|
});
|
|
},
|
|
'$data._open'(open) {
|
|
this.$nextTick(() => {
|
|
const { prefixCls } = this.$props;
|
|
const { _selectorValueList: selectorValueList, _valueEntities: valueEntities } = this.$data;
|
|
const isMultiple = this.isMultiple();
|
|
|
|
// Scroll to value position, only need sync on single mode
|
|
if (!isMultiple && selectorValueList.length && open && this.popup) {
|
|
const { value } = selectorValueList[0];
|
|
const { domTreeNodes } = this.popup.getTree();
|
|
const { key } = valueEntities[value] || {};
|
|
const treeNode = domTreeNodes[key];
|
|
|
|
if (treeNode) {
|
|
const domNode = findDOMNode(treeNode);
|
|
raf(() => {
|
|
const popupNode = findDOMNode(this.popup);
|
|
const triggerContainer = findPopupContainer(popupNode, `${prefixCls}-dropdown`);
|
|
const searchNode = this.popup.searchRef.current;
|
|
|
|
if (domNode && triggerContainer && searchNode) {
|
|
scrollIntoView(domNode, triggerContainer, {
|
|
onlyScrollIfNeeded: true,
|
|
offsetTop: searchNode.offsetHeight,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
},
|
|
},
|
|
mounted() {
|
|
this.$nextTick(() => {
|
|
const { autofocus, disabled } = this.$props;
|
|
if (autofocus && !disabled) {
|
|
this.focus();
|
|
}
|
|
});
|
|
},
|
|
|
|
methods: {
|
|
getDerivedState(nextProps, prevState) {
|
|
const { _prevProps: prevProps = {} } = prevState;
|
|
const {
|
|
treeCheckable,
|
|
treeCheckStrictly,
|
|
filterTreeNode,
|
|
treeNodeFilterProp,
|
|
treeDataSimpleMode,
|
|
} = nextProps;
|
|
const newState = {
|
|
_prevProps: { ...nextProps },
|
|
_init: false,
|
|
};
|
|
const self = this;
|
|
// Process the state when props updated
|
|
function processState(propName, updater) {
|
|
if (prevProps[propName] !== nextProps[propName] || self.needSyncKeys[propName]) {
|
|
updater(nextProps[propName], prevProps[propName]);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let valueRefresh = false;
|
|
|
|
// Open
|
|
processState('open', propValue => {
|
|
newState._open = propValue;
|
|
});
|
|
|
|
// Tree Nodes
|
|
let treeNodes;
|
|
let treeDataChanged = false;
|
|
let treeDataModeChanged = false;
|
|
processState('treeData', propValue => {
|
|
treeNodes = convertDataToTree(propValue);
|
|
treeDataChanged = true;
|
|
});
|
|
|
|
processState('treeDataSimpleMode', (propValue, prevValue) => {
|
|
if (!propValue) return;
|
|
|
|
const prev = !prevValue || prevValue === true ? {} : prevValue;
|
|
|
|
// Shallow equal to avoid dynamic prop object
|
|
if (!shallowEqual(propValue, prev)) {
|
|
treeDataModeChanged = true;
|
|
}
|
|
});
|
|
|
|
// Parse by `treeDataSimpleMode`
|
|
if (treeDataSimpleMode && (treeDataChanged || treeDataModeChanged)) {
|
|
const simpleMapper = {
|
|
id: 'id',
|
|
pId: 'pId',
|
|
rootPId: null,
|
|
...(treeDataSimpleMode !== true ? treeDataSimpleMode : {}),
|
|
};
|
|
treeNodes = convertDataToTree(parseSimpleTreeData(nextProps.treeData, simpleMapper));
|
|
}
|
|
|
|
// If `treeData` not provide, use children TreeNodes
|
|
if (!nextProps.treeData) {
|
|
// processState('children', (propValue) => {
|
|
// treeNodes = Array.isArray(propValue) ? propValue : [propValue]
|
|
// })
|
|
treeNodes = this.children || [];
|
|
}
|
|
|
|
// Convert `treeData` to entities
|
|
if (treeNodes) {
|
|
const entitiesMap = convertTreeToEntities(treeNodes);
|
|
newState._treeNodes = treeNodes;
|
|
newState._posEntities = entitiesMap.posEntities;
|
|
newState._valueEntities = entitiesMap.valueEntities;
|
|
newState._keyEntities = entitiesMap.keyEntities;
|
|
|
|
valueRefresh = true;
|
|
}
|
|
|
|
// Value List
|
|
if (prevState._init) {
|
|
processState('defaultValue', propValue => {
|
|
newState._valueList = formatInternalValue(propValue, nextProps);
|
|
valueRefresh = true;
|
|
});
|
|
}
|
|
|
|
processState('value', propValue => {
|
|
newState._valueList = formatInternalValue(propValue, nextProps);
|
|
valueRefresh = true;
|
|
});
|
|
|
|
// Selector Value List
|
|
if (valueRefresh) {
|
|
// Find out that value not exist in the tree
|
|
const missValueList = [];
|
|
const filteredValueList = [];
|
|
const keyList = [];
|
|
|
|
// Get latest value list
|
|
let latestValueList = newState._valueList;
|
|
if (!latestValueList) {
|
|
// Also need add prev missValueList to avoid new treeNodes contains the value
|
|
latestValueList = [...prevState._valueList, ...prevState._missValueList];
|
|
}
|
|
|
|
// Get key by value
|
|
const valueLabels = {};
|
|
latestValueList.forEach(wrapperValue => {
|
|
const { value, label } = wrapperValue;
|
|
const entity = (newState._valueEntities || prevState._valueEntities)[value];
|
|
valueLabels[value] = label;
|
|
|
|
if (entity) {
|
|
keyList.push(entity.key);
|
|
filteredValueList.push(wrapperValue);
|
|
return;
|
|
}
|
|
|
|
// If not match, it may caused by ajax load. We need keep this
|
|
missValueList.push(wrapperValue);
|
|
});
|
|
|
|
// We need calculate the value when tree is checked tree
|
|
if (treeCheckable && !treeCheckStrictly) {
|
|
// Calculate the keys need to be checked
|
|
const { checkedKeys } = conductCheck(
|
|
keyList,
|
|
true,
|
|
newState._keyEntities || prevState._keyEntities,
|
|
);
|
|
|
|
// Format value list again for internal usage
|
|
newState._valueList = checkedKeys.map(key => {
|
|
const val = (newState._keyEntities || prevState._keyEntities).get(key).value;
|
|
|
|
const wrappedValue = {
|
|
value: val,
|
|
};
|
|
|
|
if (valueLabels[val] !== undefined) {
|
|
wrappedValue.label = valueLabels[val];
|
|
}
|
|
|
|
return wrappedValue;
|
|
});
|
|
} else {
|
|
newState._valueList = filteredValueList;
|
|
}
|
|
|
|
// Fill the missValueList, we still need display in the selector
|
|
newState._missValueList = missValueList;
|
|
|
|
// Calculate the value list for `Selector` usage
|
|
newState._selectorValueList = formatSelectorValue(
|
|
newState._valueList,
|
|
nextProps,
|
|
newState._valueEntities || prevState._valueEntities,
|
|
);
|
|
}
|
|
|
|
// [Legacy] To align with `Select` component,
|
|
// We use `searchValue` instead of `inputValue` but still keep the api
|
|
// `inputValue` support `null` to work as `autoClearSearchValue`
|
|
processState('inputValue', propValue => {
|
|
if (propValue !== null) {
|
|
newState._searchValue = propValue;
|
|
}
|
|
});
|
|
|
|
// Search value
|
|
processState('searchValue', propValue => {
|
|
newState._searchValue = propValue;
|
|
});
|
|
|
|
// Do the search logic
|
|
if (newState._searchValue !== undefined || (prevState._searchValue && treeNodes)) {
|
|
const searchValue =
|
|
newState._searchValue !== undefined ? newState._searchValue : prevState._searchValue;
|
|
const upperSearchValue = String(searchValue).toUpperCase();
|
|
|
|
let filterTreeNodeFn = filterTreeNode;
|
|
if (filterTreeNode === false) {
|
|
// Don't filter if is false
|
|
filterTreeNodeFn = () => true;
|
|
} else if (typeof filterTreeNodeFn !== 'function') {
|
|
// When is not function (true or undefined), use inner filter
|
|
filterTreeNodeFn = (_, node) => {
|
|
const nodeValue = String(getPropsData(node)[treeNodeFilterProp]).toUpperCase();
|
|
return nodeValue.indexOf(upperSearchValue) !== -1;
|
|
};
|
|
}
|
|
|
|
newState._filteredTreeNodes = getFilterTree(
|
|
newState._treeNodes || prevState._treeNodes,
|
|
searchValue,
|
|
filterTreeNodeFn,
|
|
newState._valueEntities || prevState._valueEntities,
|
|
SelectNode,
|
|
);
|
|
}
|
|
|
|
// We should re-calculate the halfCheckedKeys when in search mode
|
|
if (
|
|
valueRefresh &&
|
|
treeCheckable &&
|
|
!treeCheckStrictly &&
|
|
(newState._searchValue || prevState._searchValue)
|
|
) {
|
|
newState._searchHalfCheckedKeys = getHalfCheckedKeys(
|
|
newState._valueList,
|
|
newState._valueEntities || prevState._valueEntities,
|
|
);
|
|
}
|
|
|
|
// Checked Strategy
|
|
processState('showCheckedStrategy', () => {
|
|
newState._selectorValueList =
|
|
newState._selectorValueList ||
|
|
formatSelectorValue(
|
|
newState._valueList || prevState._valueList,
|
|
nextProps,
|
|
newState._valueEntities || prevState._valueEntities,
|
|
);
|
|
});
|
|
|
|
return newState;
|
|
},
|
|
// ==================== Selector ====================
|
|
onSelectorFocus() {
|
|
this.setState({ _focused: true });
|
|
},
|
|
|
|
onSelectorBlur() {
|
|
this.setState({ _focused: false });
|
|
|
|
// TODO: Close when Popup is also not focused
|
|
// this.setState({ open: false });
|
|
},
|
|
|
|
// Handle key board event in both Selector and Popup
|
|
onComponentKeyDown(event) {
|
|
const { _open: open } = this.$data;
|
|
const { keyCode } = event;
|
|
|
|
if (!open) {
|
|
if ([KeyCode.ENTER, KeyCode.DOWN].indexOf(keyCode) !== -1) {
|
|
this.setOpenState(true);
|
|
}
|
|
} else if (KeyCode.ESC === keyCode) {
|
|
this.setOpenState(false);
|
|
} else if ([KeyCode.UP, KeyCode.DOWN, KeyCode.LEFT, KeyCode.RIGHT].indexOf(keyCode) !== -1) {
|
|
// TODO: Handle `open` state
|
|
event.stopPropagation();
|
|
}
|
|
},
|
|
|
|
onDeselect(wrappedValue, node, nodeEventInfo) {
|
|
this.__emit('deselect', wrappedValue, node, nodeEventInfo);
|
|
},
|
|
|
|
onSelectorClear(event) {
|
|
const { disabled } = this.$props;
|
|
if (disabled) return;
|
|
|
|
this.triggerChange([], []);
|
|
|
|
if (!this.isSearchValueControlled()) {
|
|
this.setUncontrolledState({
|
|
_searchValue: '',
|
|
_filteredTreeNodes: null,
|
|
});
|
|
}
|
|
|
|
event.stopPropagation();
|
|
},
|
|
|
|
onMultipleSelectorRemove(event, removeValue) {
|
|
event.stopPropagation();
|
|
|
|
const {
|
|
_valueList: valueList,
|
|
_missValueList: missValueList,
|
|
_valueEntities: valueEntities,
|
|
} = this.$data;
|
|
|
|
const { treeCheckable, treeCheckStrictly, treeNodeLabelProp, disabled } = this.$props;
|
|
if (disabled) return;
|
|
|
|
// Find trigger entity
|
|
const triggerEntity = valueEntities[removeValue];
|
|
|
|
// Clean up value
|
|
let newValueList = valueList;
|
|
if (triggerEntity) {
|
|
// If value is in tree
|
|
if (treeCheckable && !treeCheckStrictly) {
|
|
newValueList = valueList.filter(({ value }) => {
|
|
const entity = valueEntities[value];
|
|
return !isPosRelated(entity.pos, triggerEntity.pos);
|
|
});
|
|
} else {
|
|
newValueList = valueList.filter(({ value }) => value !== removeValue);
|
|
}
|
|
}
|
|
|
|
const triggerNode = triggerEntity ? triggerEntity.node : null;
|
|
|
|
const extraInfo = {
|
|
triggerValue: removeValue,
|
|
triggerNode,
|
|
};
|
|
const deselectInfo = {
|
|
node: triggerNode,
|
|
};
|
|
|
|
// [Legacy] Little hack on this to make same action as `onCheck` event.
|
|
if (treeCheckable) {
|
|
const filteredEntityList = newValueList.map(({ value }) => valueEntities[value]);
|
|
|
|
deselectInfo.event = 'check';
|
|
deselectInfo.checked = false;
|
|
deselectInfo.checkedNodes = filteredEntityList.map(({ node }) => node);
|
|
deselectInfo.checkedNodesPositions = filteredEntityList.map(({ node, pos }) => ({
|
|
node,
|
|
pos,
|
|
}));
|
|
|
|
if (treeCheckStrictly) {
|
|
extraInfo.allCheckedNodes = deselectInfo.checkedNodes;
|
|
} else {
|
|
// TODO: It's too expansive to get `halfCheckedKeys` in onDeselect. Not pass this.
|
|
extraInfo.allCheckedNodes = flatToHierarchy(filteredEntityList).map(({ node }) => node);
|
|
}
|
|
} else {
|
|
deselectInfo.event = 'select';
|
|
deselectInfo.selected = false;
|
|
deselectInfo.selectedNodes = newValueList.map(
|
|
({ value }) => (valueEntities[value] || {}).node,
|
|
);
|
|
}
|
|
|
|
// Some value user pass prop is not in the tree, we also need clean it
|
|
const newMissValueList = missValueList.filter(({ value }) => value !== removeValue);
|
|
let wrappedValue;
|
|
if (this.isLabelInValue()) {
|
|
wrappedValue = {
|
|
label: triggerNode ? getPropsData(triggerNode)[treeNodeLabelProp] : null,
|
|
value: removeValue,
|
|
};
|
|
} else {
|
|
wrappedValue = removeValue;
|
|
}
|
|
|
|
this.onDeselect(wrappedValue, triggerNode, deselectInfo);
|
|
|
|
this.triggerChange(newMissValueList, newValueList, extraInfo);
|
|
},
|
|
|
|
// ===================== Popup ======================
|
|
onValueTrigger(isAdd, nodeList, nodeEventInfo, nodeExtraInfo) {
|
|
const { node } = nodeEventInfo;
|
|
const { value } = node.$props;
|
|
const {
|
|
_missValueList: missValueList,
|
|
_valueEntities: valueEntities,
|
|
_keyEntities: keyEntities,
|
|
_searchValue: searchValue,
|
|
} = this.$data;
|
|
const {
|
|
disabled,
|
|
inputValue,
|
|
treeNodeLabelProp,
|
|
multiple,
|
|
treeCheckable,
|
|
treeCheckStrictly,
|
|
autoClearSearchValue,
|
|
} = this.$props;
|
|
const label = node.$props[treeNodeLabelProp];
|
|
|
|
if (disabled) return;
|
|
|
|
// Wrap the return value for user
|
|
let wrappedValue;
|
|
if (this.isLabelInValue()) {
|
|
wrappedValue = {
|
|
value,
|
|
label,
|
|
};
|
|
} else {
|
|
wrappedValue = value;
|
|
}
|
|
|
|
// [Legacy] Origin code not trigger `onDeselect` every time. Let's align the behaviour.
|
|
if (isAdd) {
|
|
this.__emit('select', wrappedValue, node, nodeEventInfo);
|
|
} else {
|
|
this.__emit('deselect', wrappedValue, node, nodeEventInfo);
|
|
}
|
|
|
|
// Get wrapped value list.
|
|
// This is a bit hack cause we use key to match the value.
|
|
let newValueList = nodeList.map(node => {
|
|
const props = getPropsData(node);
|
|
return {
|
|
value: props.value,
|
|
label: props[treeNodeLabelProp],
|
|
};
|
|
});
|
|
|
|
// When is `treeCheckable` and with `searchValue`, `valueList` is not full filled.
|
|
// We need calculate the missing nodes.
|
|
if (treeCheckable && !treeCheckStrictly) {
|
|
let keyList = newValueList.map(({ value: val }) => valueEntities[val].key);
|
|
if (isAdd) {
|
|
keyList = conductCheck(keyList, true, keyEntities).checkedKeys;
|
|
} else {
|
|
keyList = conductCheck([valueEntities[value].key], false, keyEntities, {
|
|
checkedKeys: keyList,
|
|
}).checkedKeys;
|
|
}
|
|
newValueList = keyList.map(key => {
|
|
const props = getPropsData(keyEntities.get(key).node);
|
|
return {
|
|
value: props.value,
|
|
label: props[treeNodeLabelProp],
|
|
};
|
|
});
|
|
}
|
|
|
|
// Clean up `searchValue` when this prop is set
|
|
if (autoClearSearchValue || inputValue === null) {
|
|
// Clean state `searchValue` if uncontrolled
|
|
if (!this.isSearchValueControlled() && (multiple || treeCheckable)) {
|
|
this.setUncontrolledState({
|
|
_searchValue: '',
|
|
_filteredTreeNodes: null,
|
|
});
|
|
}
|
|
|
|
// Trigger onSearch if `searchValue` to be empty.
|
|
// We should also trigger onSearch with empty string here
|
|
// since if user use `treeExpandedKeys`, it need user have the ability to reset it.
|
|
if (searchValue && searchValue.length) {
|
|
this.__emit('update:searchValue', '');
|
|
this.__emit('search', '');
|
|
}
|
|
}
|
|
|
|
// [Legacy] Provide extra info
|
|
const extraInfo = {
|
|
...nodeExtraInfo,
|
|
triggerValue: value,
|
|
triggerNode: node,
|
|
};
|
|
|
|
this.triggerChange(missValueList, newValueList, extraInfo);
|
|
},
|
|
|
|
onTreeNodeSelect(_, nodeEventInfo) {
|
|
const { _valueList: valueList, _valueEntities: valueEntities } = this.$data;
|
|
const { treeCheckable, multiple } = this.$props;
|
|
if (treeCheckable) return;
|
|
|
|
if (!multiple) {
|
|
this.setOpenState(false);
|
|
}
|
|
|
|
const isAdd = nodeEventInfo.selected;
|
|
const {
|
|
$props: { value: selectedValue },
|
|
} = nodeEventInfo.node;
|
|
|
|
let newValueList;
|
|
|
|
if (!multiple) {
|
|
newValueList = [{ value: selectedValue }];
|
|
} else {
|
|
newValueList = valueList.filter(({ value }) => value !== selectedValue);
|
|
if (isAdd) {
|
|
newValueList.push({ value: selectedValue });
|
|
}
|
|
}
|
|
|
|
const selectedNodes = newValueList
|
|
.map(({ value }) => valueEntities[value])
|
|
.filter(entity => entity)
|
|
.map(({ node }) => node);
|
|
|
|
this.onValueTrigger(isAdd, selectedNodes, nodeEventInfo, { selected: isAdd });
|
|
},
|
|
|
|
onTreeNodeCheck(_, nodeEventInfo) {
|
|
const {
|
|
_searchValue: searchValue,
|
|
_keyEntities: keyEntities,
|
|
_valueEntities: valueEntities,
|
|
_valueList: valueList,
|
|
} = this.$data;
|
|
const { treeCheckStrictly } = this.$props;
|
|
|
|
const { checkedNodes, checkedNodesPositions } = nodeEventInfo;
|
|
const isAdd = nodeEventInfo.checked;
|
|
|
|
const extraInfo = {
|
|
checked: isAdd,
|
|
};
|
|
|
|
let checkedNodeList = checkedNodes;
|
|
|
|
// [Legacy] Check event provide `allCheckedNodes`.
|
|
// When `treeCheckStrictly` or internal `searchValue` is set, TreeNode will be unrelated:
|
|
// - Related: Show the top checked nodes and has children prop.
|
|
// - Unrelated: Show all the checked nodes.
|
|
if (searchValue) {
|
|
const oriKeyList = valueList
|
|
.map(({ value }) => valueEntities[value])
|
|
.filter(entity => entity)
|
|
.map(({ key }) => key);
|
|
|
|
let keyList;
|
|
if (isAdd) {
|
|
keyList = Array.from(
|
|
new Set([
|
|
...oriKeyList,
|
|
...checkedNodeList.map(node => {
|
|
const { value } = getPropsData(node);
|
|
return valueEntities[value].key;
|
|
}),
|
|
]),
|
|
);
|
|
} else {
|
|
keyList = conductCheck([getPropsData(nodeEventInfo.node).eventKey], false, keyEntities, {
|
|
checkedKeys: oriKeyList,
|
|
}).checkedKeys;
|
|
}
|
|
|
|
checkedNodeList = keyList.map(key => keyEntities.get(key).node);
|
|
|
|
// Let's follow as not `treeCheckStrictly` format
|
|
extraInfo.allCheckedNodes = keyList.map(key => cleanEntity(keyEntities.get(key)));
|
|
} else if (treeCheckStrictly) {
|
|
extraInfo.allCheckedNodes = nodeEventInfo.checkedNodes;
|
|
} else {
|
|
extraInfo.allCheckedNodes = flatToHierarchy(checkedNodesPositions);
|
|
}
|
|
|
|
this.onValueTrigger(isAdd, checkedNodeList, nodeEventInfo, extraInfo);
|
|
},
|
|
|
|
// ==================== Trigger =====================
|
|
|
|
onDropdownVisibleChange(open) {
|
|
const { multiple, treeCheckable } = this.$props;
|
|
const { _searchValue } = this.$data;
|
|
|
|
// When set open success and single mode,
|
|
// we will reset the input content.
|
|
if (open && !multiple && !treeCheckable && _searchValue) {
|
|
this.setUncontrolledState({
|
|
_searchValue: '',
|
|
_filteredTreeNodes: null,
|
|
});
|
|
}
|
|
this.setOpenState(open, true);
|
|
},
|
|
|
|
onSearchInputChange(event) {
|
|
const value = event.target.value;
|
|
const { _treeNodes: treeNodes, _valueEntities: valueEntities } = this.$data;
|
|
const { filterTreeNode, treeNodeFilterProp } = this.$props;
|
|
this.__emit('update:searchValue', value);
|
|
this.__emit('search', value);
|
|
|
|
let isSet = false;
|
|
|
|
if (!this.isSearchValueControlled()) {
|
|
isSet = this.setUncontrolledState({
|
|
_searchValue: value,
|
|
});
|
|
this.setOpenState(true);
|
|
}
|
|
|
|
if (isSet) {
|
|
// Do the search logic
|
|
const upperSearchValue = String(value).toUpperCase();
|
|
|
|
let filterTreeNodeFn = filterTreeNode;
|
|
if (filterTreeNode === false) {
|
|
filterTreeNodeFn = () => true;
|
|
} else if (!filterTreeNodeFn) {
|
|
filterTreeNodeFn = (_, node) => {
|
|
const nodeValue = String(getPropsData(node)[treeNodeFilterProp]).toUpperCase();
|
|
return nodeValue.indexOf(upperSearchValue) !== -1;
|
|
};
|
|
}
|
|
|
|
this.setState({
|
|
_filteredTreeNodes: getFilterTree(
|
|
treeNodes,
|
|
value,
|
|
filterTreeNodeFn,
|
|
valueEntities,
|
|
SelectNode,
|
|
),
|
|
});
|
|
}
|
|
},
|
|
|
|
onSearchInputKeyDown(event) {
|
|
const { _searchValue: searchValue, _valueList: valueList } = this.$data;
|
|
|
|
const { keyCode } = event;
|
|
|
|
if (KeyCode.BACKSPACE === keyCode && this.isMultiple() && !searchValue && valueList.length) {
|
|
const lastValue = valueList[valueList.length - 1].value;
|
|
this.onMultipleSelectorRemove(event, lastValue);
|
|
}
|
|
},
|
|
|
|
onChoiceAnimationLeave() {
|
|
raf(() => {
|
|
this.forcePopupAlign();
|
|
});
|
|
},
|
|
|
|
setPopupRef(popup) {
|
|
this.popup = popup;
|
|
},
|
|
|
|
/**
|
|
* Only update the value which is not in props
|
|
*/
|
|
setUncontrolledState(state) {
|
|
let needSync = false;
|
|
const newState = {};
|
|
const props = getOptionProps(this);
|
|
Object.keys(state).forEach(name => {
|
|
if (name.slice(1) in props) return;
|
|
|
|
needSync = true;
|
|
newState[name] = state[name];
|
|
});
|
|
|
|
if (needSync) {
|
|
this.setState(newState);
|
|
}
|
|
|
|
return needSync;
|
|
},
|
|
|
|
// [Legacy] Origin provide `documentClickClose` which triggered by `Trigger`
|
|
// Currently `TreeSelect` align the hide popup logic as `Select` which blur to hide.
|
|
// `documentClickClose` is not accurate anymore. Let's just keep the key word.
|
|
setOpenState(open, byTrigger = false) {
|
|
const { dropdownVisibleChange } = this.$props;
|
|
|
|
if (
|
|
dropdownVisibleChange &&
|
|
dropdownVisibleChange(open, { documentClickClose: !open && byTrigger }) === false
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.setUncontrolledState({ _open: open });
|
|
},
|
|
|
|
// Tree checkable is also a multiple case
|
|
isMultiple() {
|
|
const { multiple, treeCheckable } = this.$props;
|
|
return !!(multiple || treeCheckable);
|
|
},
|
|
|
|
isLabelInValue() {
|
|
return isLabelInValue(this.$props);
|
|
},
|
|
|
|
// [Legacy] To align with `Select` component,
|
|
// We use `searchValue` instead of `inputValue`
|
|
// but currently still need support that.
|
|
// Add this method the check if is controlled
|
|
isSearchValueControlled() {
|
|
const props = getOptionProps(this);
|
|
const { inputValue } = props;
|
|
if ('searchValue' in props) return true;
|
|
return 'inputValue' in props && inputValue !== null;
|
|
},
|
|
|
|
forcePopupAlign() {
|
|
const $trigger = this.selectTriggerRef.current;
|
|
if ($trigger) {
|
|
$trigger.forcePopupAlign();
|
|
}
|
|
},
|
|
|
|
delayForcePopupAlign() {
|
|
// Wait 2 frame to avoid dom update & dom algin in the same time
|
|
// https://github.com/ant-design/ant-design/issues/12031
|
|
raf(() => {
|
|
raf(this.forcePopupAlign);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 1. Update state valueList.
|
|
* 2. Fire `onChange` event to user.
|
|
*/
|
|
triggerChange(missValueList, valueList, extraInfo = {}) {
|
|
const {
|
|
_valueEntities: valueEntities,
|
|
_searchValue: searchValue,
|
|
_selectorValueList: prevSelectorValueList,
|
|
} = this.$data;
|
|
const props = getOptionProps(this);
|
|
const { disabled, treeCheckable, treeCheckStrictly } = props;
|
|
if (disabled) return;
|
|
|
|
// Trigger
|
|
const extra = {
|
|
// [Legacy] Always return as array contains label & value
|
|
preValue: prevSelectorValueList.map(({ label, value }) => ({ label, value })),
|
|
...extraInfo,
|
|
};
|
|
|
|
// Format value by `treeCheckStrictly`
|
|
const selectorValueList = formatSelectorValue(valueList, props, valueEntities);
|
|
|
|
if (!('value' in props)) {
|
|
const newState = {
|
|
_missValueList: missValueList,
|
|
_valueList: valueList,
|
|
_selectorValueList: selectorValueList,
|
|
};
|
|
|
|
if (searchValue && treeCheckable && !treeCheckStrictly) {
|
|
newState._searchHalfCheckedKeys = getHalfCheckedKeys(valueList, valueEntities);
|
|
}
|
|
|
|
this.setState(newState);
|
|
}
|
|
|
|
// Only do the logic when `onChange` function provided
|
|
if (this.$attrs.onChange) {
|
|
let connectValueList;
|
|
|
|
// Get value by mode
|
|
if (this.isMultiple()) {
|
|
connectValueList = [...missValueList, ...selectorValueList];
|
|
} else {
|
|
connectValueList = selectorValueList.slice(0, 1);
|
|
}
|
|
|
|
let labelList = null;
|
|
let returnValue;
|
|
|
|
if (this.isLabelInValue()) {
|
|
returnValue = connectValueList.map(({ label, value }) => ({ label, value }));
|
|
} else {
|
|
labelList = [];
|
|
returnValue = connectValueList.map(({ label, value }) => {
|
|
labelList.push(label);
|
|
return value;
|
|
});
|
|
}
|
|
|
|
if (!this.isMultiple()) {
|
|
returnValue = returnValue[0];
|
|
}
|
|
// this.__emit('update:value', returnValue);
|
|
this.__emit('change', returnValue, labelList, extra);
|
|
}
|
|
},
|
|
|
|
focus() {
|
|
this.selectorRef.current.focus();
|
|
},
|
|
|
|
blur() {
|
|
this.selectorRef.current.blur();
|
|
},
|
|
},
|
|
|
|
// ===================== Render =====================
|
|
|
|
render() {
|
|
const {
|
|
_valueList: valueList,
|
|
_missValueList: missValueList,
|
|
_selectorValueList: selectorValueList,
|
|
_searchHalfCheckedKeys: searchHalfCheckedKeys,
|
|
_valueEntities: valueEntities,
|
|
_keyEntities: keyEntities,
|
|
_searchValue: searchValue,
|
|
_open: open,
|
|
_focused: focused,
|
|
_treeNodes: treeNodes,
|
|
_filteredTreeNodes: filteredTreeNodes,
|
|
} = this.$data;
|
|
const props = getOptionProps(this);
|
|
const { prefixCls, treeExpandedKeys } = props;
|
|
const isMultiple = this.isMultiple();
|
|
|
|
const passProps = {
|
|
...props,
|
|
...this.$attrs,
|
|
isMultiple,
|
|
valueList,
|
|
searchHalfCheckedKeys,
|
|
selectorValueList: [...missValueList, ...selectorValueList],
|
|
valueEntities,
|
|
keyEntities,
|
|
searchValue,
|
|
upperSearchValue: (searchValue || '').toUpperCase(), // Perf save
|
|
open,
|
|
focused,
|
|
dropdownPrefixCls: `${prefixCls}-dropdown`,
|
|
ariaId: this.ariaId,
|
|
onChoiceAnimationLeave: this.onChoiceAnimationLeave,
|
|
vSlots: this.$slots,
|
|
};
|
|
const popupProps = {
|
|
...passProps,
|
|
treeNodes,
|
|
filteredTreeNodes,
|
|
// Tree expanded control
|
|
treeExpandedKeys,
|
|
__propsSymbol__: Symbol(),
|
|
onTreeExpanded: this.delayForcePopupAlign,
|
|
ref: this.setPopupRef,
|
|
};
|
|
|
|
const Popup = isMultiple ? MultiplePopup : SinglePopup;
|
|
const $popup = <Popup {...popupProps} />;
|
|
|
|
const Selector = isMultiple ? MultipleSelector : SingleSelector;
|
|
const $selector = <Selector {...passProps} ref={this.selectorRef} />;
|
|
const selectTriggerProps = {
|
|
...passProps,
|
|
popupElement: $popup,
|
|
dropdownVisibleChange: this.onDropdownVisibleChange,
|
|
ref: this.selectTriggerRef,
|
|
};
|
|
return <SelectTrigger {...selectTriggerProps}>{$selector}</SelectTrigger>;
|
|
},
|
|
};
|
|
|
|
Select.TreeNode = SelectNode;
|
|
Select.SHOW_ALL = SHOW_ALL;
|
|
Select.SHOW_PARENT = SHOW_PARENT;
|
|
Select.SHOW_CHILD = SHOW_CHILD;
|
|
|
|
// Let warning show correct component name
|
|
Select.name = 'TreeSelect';
|
|
|
|
export default Select;
|