From 239b6fc0604804b2e34eb7adcfc17a1c619044bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E7=88=B1=E5=90=83=E7=99=BD=E8=90=9D?= =?UTF-8?q?=E5=8D=9C?= Date: Mon, 25 Sep 2023 16:34:46 +0800 Subject: [PATCH] feat: Add Cascader.Panel (#430) * chore: init * refactor: more hooks * refactor: more hooks * chore: more * chore: ab hooks * chore: update style * chore: update demo * test: add test case * test: add test case * test: add test case --- assets/index.less | 3 +- assets/list.less | 4 + assets/panel.less | 7 + docs/demo/panel.md | 8 ++ examples/panel.tsx | 72 ++++++++++ src/Cascader.tsx | 139 +++++-------------- src/OptionList/List.tsx | 245 ++++++++++++++++++++++++++++++++++ src/OptionList/index.tsx | 224 +------------------------------ src/OptionList/useActive.ts | 7 +- src/OptionList/useKeyboard.ts | 9 +- src/Panel.tsx | 171 ++++++++++++++++++++++++ src/hooks/useMissingValues.ts | 11 +- src/hooks/useOptions.ts | 34 +++++ src/hooks/useSelect.ts | 72 ++++++++++ src/hooks/useValues.ts | 35 +++++ src/index.tsx | 9 +- src/utils/commonUtil.ts | 17 +++ tests/Panel.spec.tsx | 83 ++++++++++++ 18 files changed, 814 insertions(+), 336 deletions(-) create mode 100644 assets/panel.less create mode 100644 docs/demo/panel.md create mode 100644 examples/panel.tsx create mode 100644 src/OptionList/List.tsx create mode 100644 src/Panel.tsx create mode 100644 src/hooks/useOptions.ts create mode 100644 src/hooks/useSelect.ts create mode 100644 src/hooks/useValues.ts create mode 100644 tests/Panel.spec.tsx diff --git a/assets/index.less b/assets/index.less index eb16af54..c21d6ea6 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,2 +1,3 @@ @import "./select.less"; -@import "./list.less"; \ No newline at end of file +@import "./list.less"; +@import "./panel.less"; \ No newline at end of file diff --git a/assets/list.less b/assets/list.less index da26faef..14f23303 100644 --- a/assets/list.less +++ b/assets/list.less @@ -30,6 +30,10 @@ padding-right: 20px; position: relative; + &:hover { + background: rgba(0, 0, 255, 0.1); + } + &-selected { background: rgba(0, 0, 255, 0.05); } diff --git a/assets/panel.less b/assets/panel.less new file mode 100644 index 00000000..b284ca5c --- /dev/null +++ b/assets/panel.less @@ -0,0 +1,7 @@ +@import (reference) './index.less'; + +.@{select-prefix} { + &-panel { + border: 1px solid green; + } +} diff --git a/docs/demo/panel.md b/docs/demo/panel.md new file mode 100644 index 00000000..d68bb42f --- /dev/null +++ b/docs/demo/panel.md @@ -0,0 +1,8 @@ +--- +title: Panel +nav: + title: Demo + path: /demo +--- + + diff --git a/examples/panel.tsx b/examples/panel.tsx new file mode 100644 index 00000000..7ab03cd0 --- /dev/null +++ b/examples/panel.tsx @@ -0,0 +1,72 @@ +/* eslint-disable no-console */ +import React from 'react'; +import '../assets/index.less'; +import Cascader from '../src'; + +const addressOptions = [ + { + label: '福建', + value: 'fj', + children: [ + { + label: '福州', + value: 'fuzhou', + children: [ + { + label: '马尾', + value: 'mawei', + }, + ], + }, + { + label: '泉州', + value: 'quanzhou', + }, + ], + }, + { + label: '浙江', + value: 'zj', + children: [ + { + label: '杭州', + value: 'hangzhou', + children: [ + { + label: '余杭', + value: 'yuhang', + }, + ], + }, + ], + }, + { + label: '北京', + value: 'bj', + children: [ + { + label: '朝阳区', + value: 'chaoyang', + }, + { + label: '海淀区', + value: 'haidian', + }, + ], + }, +]; + +export default () => { + return ( + <> +

Panel

+ { + console.log('Change:', value); + }} + /> + + ); +}; diff --git a/src/Cascader.tsx b/src/Cascader.tsx index d9a8ea99..d49d3b1e 100644 --- a/src/Cascader.tsx +++ b/src/Cascader.tsx @@ -3,18 +3,26 @@ import type { BaseSelectProps, BaseSelectPropsWithoutPrivate, BaseSelectRef } fr import { BaseSelect } from 'rc-select'; import type { DisplayValueType, Placement } from 'rc-select/lib/BaseSelect'; import useId from 'rc-select/lib/hooks/useId'; -import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; import useEvent from 'rc-util/lib/hooks/useEvent'; import useMergedState from 'rc-util/lib/hooks/useMergedState'; import * as React from 'react'; import CascaderContext from './context'; import useDisplayValues from './hooks/useDisplayValues'; -import useEntities from './hooks/useEntities'; import useMissingValues from './hooks/useMissingValues'; +import useOptions from './hooks/useOptions'; import useSearchConfig from './hooks/useSearchConfig'; import useSearchOptions from './hooks/useSearchOptions'; +import useSelect from './hooks/useSelect'; +import useValues from './hooks/useValues'; import OptionList from './OptionList'; -import { fillFieldNames, SHOW_CHILD, SHOW_PARENT, toPathKey, toPathKeys } from './utils/commonUtil'; +import Panel from './Panel'; +import { + fillFieldNames, + SHOW_CHILD, + SHOW_PARENT, + toPathKeys, + toRawValues, +} from './utils/commonUtil'; import { formatStrategyValues, toPathOptions } from './utils/treeUtil'; import warningProps, { warningNullOptions } from './utils/warningPropsUtil'; @@ -150,22 +158,6 @@ export type InternalCascaderProps; -function isMultipleValue(value: ValueType): value is SingleValueType[] { - return Array.isArray(value) && Array.isArray(value[0]); -} - -function toRawValues(value: ValueType): SingleValueType[] { - if (!value) { - return []; - } - - if (isMultipleValue(value)) { - return value; - } - - return (value.length === 0 ? [] : [value]).map(val => (Array.isArray(val) ? val : [val])); -} - const Cascader = React.forwardRef((props, ref) => { const { // MISC @@ -238,23 +230,9 @@ const Cascader = React.forwardRef((props, re ); // =========================== Option =========================== - const mergedOptions = React.useMemo(() => options || [], [options]); - - // Only used in multiple mode, this fn will not call in single mode - const getPathKeyEntities = useEntities(mergedOptions, mergedFieldNames); - - /** Convert path key back to value format */ - const getValueByKeyPath = React.useCallback( - (pathKeys: React.Key[]): SingleValueType[] => { - const keyPathEntities = getPathKeyEntities(); - - return pathKeys.map(pathKey => { - const { nodes } = keyPathEntities[pathKey]; - - return nodes.map(node => node[mergedFieldNames.value]); - }); - }, - [getPathKeyEntities, mergedFieldNames], + const [mergedOptions, getPathKeyEntities, getValueByKeyPath] = useOptions( + mergedFieldNames, + options, ); // =========================== Search =========================== @@ -286,21 +264,13 @@ const Cascader = React.forwardRef((props, re const getMissingValues = useMissingValues(mergedOptions, mergedFieldNames); // Fill `rawValues` with checked conduction values - const [checkedValues, halfCheckedValues, missingCheckedValues] = React.useMemo(() => { - const [existValues, missingValues] = getMissingValues(rawValues); - - if (!multiple || !rawValues.length) { - return [existValues, [], missingValues]; - } - - const keyPathValues = toPathKeys(existValues); - const keyPathEntities = getPathKeyEntities(); - - const { checkedKeys, halfCheckedKeys } = conductCheck(keyPathValues, true, keyPathEntities); - - // Convert key back to value cells - return [getValueByKeyPath(checkedKeys), getValueByKeyPath(halfCheckedKeys), missingValues]; - }, [multiple, rawValues, getPathKeyEntities, getValueByKeyPath, getMissingValues]); + const [checkedValues, halfCheckedValues, missingCheckedValues] = useValues( + multiple, + rawValues, + getPathKeyEntities, + getValueByKeyPath, + getMissingValues, + ); const deDuplicatedValues = React.useMemo(() => { const checkedKeys = toPathKeys(checkedValues); @@ -347,63 +317,23 @@ const Cascader = React.forwardRef((props, re }); // =========================== Select =========================== + const handleSelection = useSelect( + multiple, + triggerChange, + checkedValues, + halfCheckedValues, + missingCheckedValues, + getPathKeyEntities, + getValueByKeyPath, + showCheckedStrategy, + ); + const onInternalSelect = useEvent((valuePath: SingleValueType) => { if (!multiple || autoClearSearchValue) { setSearchValue(''); } - if (!multiple) { - triggerChange(valuePath); - } else { - // Prepare conduct required info - const pathKey = toPathKey(valuePath); - const checkedPathKeys = toPathKeys(checkedValues); - const halfCheckedPathKeys = toPathKeys(halfCheckedValues); - - const existInChecked = checkedPathKeys.includes(pathKey); - const existInMissing = missingCheckedValues.some( - valueCells => toPathKey(valueCells) === pathKey, - ); - // Do update - let nextCheckedValues = checkedValues; - let nextMissingValues = missingCheckedValues; - - if (existInMissing && !existInChecked) { - // Missing value only do filter - nextMissingValues = missingCheckedValues.filter( - valueCells => toPathKey(valueCells) !== pathKey, - ); - } else { - // Update checked key first - const nextRawCheckedKeys = existInChecked - ? checkedPathKeys.filter(key => key !== pathKey) - : [...checkedPathKeys, pathKey]; - - const pathKeyEntities = getPathKeyEntities(); - - // Conduction by selected or not - let checkedKeys: React.Key[]; - if (existInChecked) { - ({ checkedKeys } = conductCheck( - nextRawCheckedKeys, - { checked: false, halfCheckedKeys: halfCheckedPathKeys }, - pathKeyEntities, - )); - } else { - ({ checkedKeys } = conductCheck(nextRawCheckedKeys, true, pathKeyEntities)); - } - - // Roll up to parent level keys - const deDuplicatedKeys = formatStrategyValues( - checkedKeys, - getPathKeyEntities, - showCheckedStrategy, - ); - nextCheckedValues = getValueByKeyPath(deDuplicatedKeys); - } - - triggerChange([...nextMissingValues, ...nextCheckedValues]); - } + handleSelection(valuePath); }); // Display Value change logic @@ -527,6 +457,7 @@ const Cascader = React.forwardRef((props, re displayName?: string; SHOW_PARENT: typeof SHOW_PARENT; SHOW_CHILD: typeof SHOW_CHILD; + Panel: typeof Panel; }; if (process.env.NODE_ENV !== 'production') { @@ -535,4 +466,6 @@ if (process.env.NODE_ENV !== 'production') { Cascader.SHOW_PARENT = SHOW_PARENT; Cascader.SHOW_CHILD = SHOW_CHILD; +Cascader.Panel = Panel; + export default Cascader; diff --git a/src/OptionList/List.tsx b/src/OptionList/List.tsx new file mode 100644 index 00000000..9b0ce3a4 --- /dev/null +++ b/src/OptionList/List.tsx @@ -0,0 +1,245 @@ +/* eslint-disable default-case */ +import classNames from 'classnames'; +import type { useBaseProps } from 'rc-select'; +import type { RefOptionListProps } from 'rc-select/lib/OptionList'; +import * as React from 'react'; +import type { DefaultOptionType, SingleValueType } from '../Cascader'; +import CascaderContext from '../context'; +import { + getFullPathKeys, + isLeaf, + scrollIntoParentView, + toPathKey, + toPathKeys, + toPathValueStr, +} from '../utils/commonUtil'; +import { toPathOptions } from '../utils/treeUtil'; +import CacheContent from './CacheContent'; +import Column, { FIX_LABEL } from './Column'; +import useActive from './useActive'; +import useKeyboard from './useKeyboard'; + +export type RawOptionListProps = Pick< + ReturnType, + 'prefixCls' | 'multiple' | 'searchValue' | 'toggleOpen' | 'notFoundContent' | 'direction' | 'open' +>; + +const RawOptionList = React.forwardRef((props, ref) => { + const { prefixCls, multiple, searchValue, toggleOpen, notFoundContent, direction, open } = props; + + const containerRef = React.useRef(); + const rtl = direction === 'rtl'; + + const { + options, + values, + halfValues, + fieldNames, + changeOnSelect, + onSelect, + searchOptions, + dropdownPrefixCls, + loadData, + expandTrigger, + } = React.useContext(CascaderContext); + + const mergedPrefixCls = dropdownPrefixCls || prefixCls; + + // ========================= loadData ========================= + const [loadingKeys, setLoadingKeys] = React.useState([]); + + const internalLoadData = (valueCells: React.Key[]) => { + // Do not load when search + if (!loadData || searchValue) { + return; + } + + const optionList = toPathOptions(valueCells, options, fieldNames); + const rawOptions = optionList.map(({ option }) => option); + const lastOption = rawOptions[rawOptions.length - 1]; + + if (lastOption && !isLeaf(lastOption, fieldNames)) { + const pathKey = toPathKey(valueCells); + + setLoadingKeys(keys => [...keys, pathKey]); + + loadData(rawOptions); + } + }; + + // zombieJ: This is bad. We should make this same as `rc-tree` to use Promise instead. + React.useEffect(() => { + if (loadingKeys.length) { + loadingKeys.forEach(loadingKey => { + const valueStrCells = toPathValueStr(loadingKey); + const optionList = toPathOptions(valueStrCells, options, fieldNames, true).map( + ({ option }) => option, + ); + const lastOption = optionList[optionList.length - 1]; + + if (!lastOption || lastOption[fieldNames.children] || isLeaf(lastOption, fieldNames)) { + setLoadingKeys(keys => keys.filter(key => key !== loadingKey)); + } + }); + } + }, [options, loadingKeys, fieldNames]); + + // ========================== Values ========================== + const checkedSet = React.useMemo(() => new Set(toPathKeys(values)), [values]); + const halfCheckedSet = React.useMemo(() => new Set(toPathKeys(halfValues)), [halfValues]); + + // ====================== Accessibility ======================= + const [activeValueCells, setActiveValueCells] = useActive(multiple, open); + + // =========================== Path =========================== + const onPathOpen = (nextValueCells: React.Key[]) => { + setActiveValueCells(nextValueCells); + + // Trigger loadData + internalLoadData(nextValueCells); + }; + + const isSelectable = (option: DefaultOptionType) => { + const { disabled } = option; + + const isMergedLeaf = isLeaf(option, fieldNames); + return !disabled && (isMergedLeaf || changeOnSelect || multiple); + }; + + const onPathSelect = (valuePath: SingleValueType, leaf: boolean, fromKeyboard = false) => { + onSelect(valuePath); + + if (!multiple && (leaf || (changeOnSelect && (expandTrigger === 'hover' || fromKeyboard)))) { + toggleOpen(false); + } + }; + + // ========================== Option ========================== + const mergedOptions = React.useMemo(() => { + if (searchValue) { + return searchOptions; + } + + return options; + }, [searchValue, searchOptions, options]); + + // ========================== Column ========================== + const optionColumns = React.useMemo(() => { + const optionList = [{ options: mergedOptions }]; + let currentList = mergedOptions; + + const fullPathKeys = getFullPathKeys(currentList, fieldNames); + + for (let i = 0; i < activeValueCells.length; i += 1) { + const activeValueCell = activeValueCells[i]; + const currentOption = currentList.find( + (option, index) => + (fullPathKeys[index] ? toPathKey(fullPathKeys[index]) : option[fieldNames.value]) === + activeValueCell, + ); + + const subOptions = currentOption?.[fieldNames.children]; + if (!subOptions?.length) { + break; + } + + currentList = subOptions; + optionList.push({ options: subOptions }); + } + + return optionList; + }, [mergedOptions, activeValueCells, fieldNames]); + + // ========================= Keyboard ========================= + const onKeyboardSelect = (selectValueCells: SingleValueType, option: DefaultOptionType) => { + if (isSelectable(option)) { + onPathSelect(selectValueCells, isLeaf(option, fieldNames), true); + } + }; + + useKeyboard(ref, mergedOptions, fieldNames, activeValueCells, onPathOpen, onKeyboardSelect, { + direction, + searchValue, + toggleOpen, + open, + }); + + // >>>>> Active Scroll + React.useEffect(() => { + for (let i = 0; i < activeValueCells.length; i += 1) { + const cellPath = activeValueCells.slice(0, i + 1); + const cellKeyPath = toPathKey(cellPath); + const ele = containerRef.current?.querySelector( + `li[data-path-key="${cellKeyPath.replace(/\\{0,2}"/g, '\\"')}"]`, // matches unescaped double quotes + ); + if (ele) { + scrollIntoParentView(ele); + } + } + }, [activeValueCells]); + + // ========================== Render ========================== + // >>>>> Empty + const isEmpty = !optionColumns[0]?.options?.length; + + const emptyList: DefaultOptionType[] = [ + { + [fieldNames.value as 'value']: '__EMPTY__', + [FIX_LABEL as 'label']: notFoundContent, + disabled: true, + }, + ]; + + const columnProps = { + ...props, + multiple: !isEmpty && multiple, + onSelect: onPathSelect, + onActive: onPathOpen, + onToggleOpen: toggleOpen, + checkedSet, + halfCheckedSet, + loadingKeys, + isSelectable, + }; + + // >>>>> Columns + const mergedOptionColumns = isEmpty ? [{ options: emptyList }] : optionColumns; + + const columnNodes: React.ReactElement[] = mergedOptionColumns.map((col, index) => { + const prevValuePath = activeValueCells.slice(0, index); + const activeValue = activeValueCells[index]; + + return ( + + ); + }); + + // >>>>> Render + return ( + +
+ {columnNodes} +
+
+ ); +}); + +if (process.env.NODE_ENV !== 'production') { + RawOptionList.displayName = 'RawOptionList'; +} + +export default RawOptionList; diff --git a/src/OptionList/index.tsx b/src/OptionList/index.tsx index 45d8a9d7..1180a8a8 100644 --- a/src/OptionList/index.tsx +++ b/src/OptionList/index.tsx @@ -1,232 +1,14 @@ /* eslint-disable default-case */ -import classNames from 'classnames'; import { useBaseProps } from 'rc-select'; import type { RefOptionListProps } from 'rc-select/lib/OptionList'; import * as React from 'react'; -import type { DefaultOptionType, SingleValueType } from '../Cascader'; -import CascaderContext from '../context'; -import { - getFullPathKeys, - isLeaf, - scrollIntoParentView, - toPathKey, - toPathKeys, - toPathValueStr, -} from '../utils/commonUtil'; -import { toPathOptions } from '../utils/treeUtil'; -import CacheContent from './CacheContent'; -import Column, { FIX_LABEL } from './Column'; -import useActive from './useActive'; -import useKeyboard from './useKeyboard'; +import RawOptionList from './List'; const RefOptionList = React.forwardRef((props, ref) => { - const { prefixCls, multiple, searchValue, toggleOpen, notFoundContent, direction, open } = - useBaseProps(); - - const containerRef = React.useRef(); - const rtl = direction === 'rtl'; - - const { - options, - values, - halfValues, - fieldNames, - changeOnSelect, - onSelect, - searchOptions, - dropdownPrefixCls, - loadData, - expandTrigger, - } = React.useContext(CascaderContext); - - const mergedPrefixCls = dropdownPrefixCls || prefixCls; - - // ========================= loadData ========================= - const [loadingKeys, setLoadingKeys] = React.useState([]); - - const internalLoadData = (valueCells: React.Key[]) => { - // Do not load when search - if (!loadData || searchValue) { - return; - } - - const optionList = toPathOptions(valueCells, options, fieldNames); - const rawOptions = optionList.map(({ option }) => option); - const lastOption = rawOptions[rawOptions.length - 1]; - - if (lastOption && !isLeaf(lastOption, fieldNames)) { - const pathKey = toPathKey(valueCells); - - setLoadingKeys(keys => [...keys, pathKey]); - - loadData(rawOptions); - } - }; - - // zombieJ: This is bad. We should make this same as `rc-tree` to use Promise instead. - React.useEffect(() => { - if (loadingKeys.length) { - loadingKeys.forEach(loadingKey => { - const valueStrCells = toPathValueStr(loadingKey); - const optionList = toPathOptions(valueStrCells, options, fieldNames, true).map( - ({ option }) => option, - ); - const lastOption = optionList[optionList.length - 1]; - - if (!lastOption || lastOption[fieldNames.children] || isLeaf(lastOption, fieldNames)) { - setLoadingKeys(keys => keys.filter(key => key !== loadingKey)); - } - }); - } - }, [options, loadingKeys, fieldNames]); - - // ========================== Values ========================== - const checkedSet = React.useMemo(() => new Set(toPathKeys(values)), [values]); - const halfCheckedSet = React.useMemo(() => new Set(toPathKeys(halfValues)), [halfValues]); - - // ====================== Accessibility ======================= - const [activeValueCells, setActiveValueCells] = useActive(); - - // =========================== Path =========================== - const onPathOpen = (nextValueCells: React.Key[]) => { - setActiveValueCells(nextValueCells); - - // Trigger loadData - internalLoadData(nextValueCells); - }; - - const isSelectable = (option: DefaultOptionType) => { - const { disabled } = option; - - const isMergedLeaf = isLeaf(option, fieldNames); - return !disabled && (isMergedLeaf || changeOnSelect || multiple); - }; - - const onPathSelect = (valuePath: SingleValueType, leaf: boolean, fromKeyboard = false) => { - onSelect(valuePath); - - if (!multiple && (leaf || (changeOnSelect && (expandTrigger === 'hover' || fromKeyboard)))) { - toggleOpen(false); - } - }; - - // ========================== Option ========================== - const mergedOptions = React.useMemo(() => { - if (searchValue) { - return searchOptions; - } - - return options; - }, [searchValue, searchOptions, options]); - - // ========================== Column ========================== - const optionColumns = React.useMemo(() => { - const optionList = [{ options: mergedOptions }]; - let currentList = mergedOptions; - - const fullPathKeys = getFullPathKeys(currentList, fieldNames); - - for (let i = 0; i < activeValueCells.length; i += 1) { - const activeValueCell = activeValueCells[i]; - const currentOption = currentList.find( - (option, index) => - (fullPathKeys[index] ? toPathKey(fullPathKeys[index]) : option[fieldNames.value]) === - activeValueCell, - ); - - const subOptions = currentOption?.[fieldNames.children]; - if (!subOptions?.length) { - break; - } - - currentList = subOptions; - optionList.push({ options: subOptions }); - } - - return optionList; - }, [mergedOptions, activeValueCells, fieldNames]); - - // ========================= Keyboard ========================= - const onKeyboardSelect = (selectValueCells: SingleValueType, option: DefaultOptionType) => { - if (isSelectable(option)) { - onPathSelect(selectValueCells, isLeaf(option, fieldNames), true); - } - }; - - useKeyboard(ref, mergedOptions, fieldNames, activeValueCells, onPathOpen, onKeyboardSelect); - - // >>>>> Active Scroll - React.useEffect(() => { - for (let i = 0; i < activeValueCells.length; i += 1) { - const cellPath = activeValueCells.slice(0, i + 1); - const cellKeyPath = toPathKey(cellPath); - const ele = containerRef.current?.querySelector( - `li[data-path-key="${cellKeyPath.replace(/\\{0,2}"/g, '\\"')}"]`, // matches unescaped double quotes - ); - if (ele) { - scrollIntoParentView(ele); - } - } - }, [activeValueCells]); - - // ========================== Render ========================== - // >>>>> Empty - const isEmpty = !optionColumns[0]?.options?.length; - - const emptyList: DefaultOptionType[] = [ - { - [fieldNames.value as 'value']: '__EMPTY__', - [FIX_LABEL as 'label']: notFoundContent, - disabled: true, - }, - ]; - - const columnProps = { - ...props, - multiple: !isEmpty && multiple, - onSelect: onPathSelect, - onActive: onPathOpen, - onToggleOpen: toggleOpen, - checkedSet, - halfCheckedSet, - loadingKeys, - isSelectable, - }; - - // >>>>> Columns - const mergedOptionColumns = isEmpty ? [{ options: emptyList }] : optionColumns; - - const columnNodes: React.ReactElement[] = mergedOptionColumns.map((col, index) => { - const prevValuePath = activeValueCells.slice(0, index); - const activeValue = activeValueCells[index]; - - return ( - - ); - }); + const baseProps = useBaseProps(); // >>>>> Render - return ( - -
- {columnNodes} -
-
- ); + return ; }); export default RefOptionList; diff --git a/src/OptionList/useActive.ts b/src/OptionList/useActive.ts index 19134cd2..0040dfa3 100644 --- a/src/OptionList/useActive.ts +++ b/src/OptionList/useActive.ts @@ -1,12 +1,13 @@ import * as React from 'react'; import CascaderContext from '../context'; -import { useBaseProps } from 'rc-select'; /** * Control the active open options path. */ -export default (): [React.Key[], (activeValueCells: React.Key[]) => void] => { - const { multiple, open } = useBaseProps(); +export default ( + multiple: boolean, + open: boolean, +): [React.Key[], (activeValueCells: React.Key[]) => void] => { const { values } = React.useContext(CascaderContext); // Record current dropdown active options diff --git a/src/OptionList/useKeyboard.ts b/src/OptionList/useKeyboard.ts index bdc00d5b..cc607995 100644 --- a/src/OptionList/useKeyboard.ts +++ b/src/OptionList/useKeyboard.ts @@ -1,4 +1,3 @@ -import { useBaseProps } from 'rc-select'; import type { RefOptionListProps } from 'rc-select/lib/OptionList'; import KeyCode from 'rc-util/lib/KeyCode'; import * as React from 'react'; @@ -13,8 +12,14 @@ export default ( activeValueCells: React.Key[], setActiveValueCells: (activeValueCells: React.Key[]) => void, onKeyBoardSelect: (valueCells: SingleValueType, option: DefaultOptionType) => void, + contextProps: { + direction: 'ltr' | 'rtl'; + searchValue: string; + toggleOpen: (open?: boolean) => void; + open: boolean; + }, ) => { - const { direction, searchValue, toggleOpen, open } = useBaseProps(); + const { direction, searchValue, toggleOpen, open } = contextProps; const rtl = direction === 'rtl'; const [validActiveValueCells, lastActiveIndex, lastActiveOptions, fullPathKeys] = diff --git a/src/Panel.tsx b/src/Panel.tsx new file mode 100644 index 00000000..b7c003f2 --- /dev/null +++ b/src/Panel.tsx @@ -0,0 +1,171 @@ +import classNames from 'classnames'; +import { useEvent, useMergedState } from 'rc-util'; +import * as React from 'react'; +import type { CascaderProps, InternalCascaderProps, SingleValueType, ValueType } from './Cascader'; +import CascaderContext from './context'; +import useMissingValues from './hooks/useMissingValues'; +import useOptions from './hooks/useOptions'; +import useSelect from './hooks/useSelect'; +import useValues from './hooks/useValues'; +import RawOptionList from './OptionList/List'; +import { fillFieldNames, toRawValues } from './utils/commonUtil'; +import { toPathOptions } from './utils/treeUtil'; + +export type PickType = + | 'value' + | 'defaultValue' + | 'changeOnSelect' + | 'onChange' + | 'options' + | 'prefixCls' + | 'checkable' + | 'fieldNames' + | 'showCheckedStrategy' + | 'loadData' + | 'expandTrigger' + | 'expandIcon' + | 'loadingIcon' + | 'className' + | 'style'; + +export type PanelProps = Pick; + +function noop() {} + +export default function Panel(props: PanelProps) { + const { + prefixCls = 'rc-cascader', + style, + className, + options, + checkable, + defaultValue, + value, + fieldNames, + changeOnSelect, + onChange, + showCheckedStrategy, + loadData, + expandTrigger, + expandIcon = '>', + loadingIcon, + } = props as Pick; + + // ======================== Multiple ======================== + const multiple = !!checkable; + + // ========================= Values ========================= + const [rawValues, setRawValues] = useMergedState(defaultValue, { + value, + postState: toRawValues, + }); + + // ========================= FieldNames ========================= + const mergedFieldNames = React.useMemo( + () => fillFieldNames(fieldNames), + /* eslint-disable react-hooks/exhaustive-deps */ + [JSON.stringify(fieldNames)], + /* eslint-enable react-hooks/exhaustive-deps */ + ); + + // =========================== Option =========================== + const [mergedOptions, getPathKeyEntities, getValueByKeyPath] = useOptions( + mergedFieldNames, + options, + ); + + // ========================= Values ========================= + const getMissingValues = useMissingValues(mergedOptions, mergedFieldNames); + + // Fill `rawValues` with checked conduction values + const [checkedValues, halfCheckedValues, missingCheckedValues] = useValues( + multiple, + rawValues, + getPathKeyEntities, + getValueByKeyPath, + getMissingValues, + ); + + // =========================== Change =========================== + const triggerChange = useEvent((nextValues: ValueType) => { + setRawValues(nextValues); + + // Save perf if no need trigger event + if (onChange) { + const nextRawValues = toRawValues(nextValues); + + const valueOptions = nextRawValues.map(valueCells => + toPathOptions(valueCells, mergedOptions, mergedFieldNames).map(valueOpt => valueOpt.option), + ); + + const triggerValues = multiple ? nextRawValues : nextRawValues[0]; + const triggerOptions = multiple ? valueOptions : valueOptions[0]; + + onChange(triggerValues, triggerOptions); + } + }); + + // =========================== Select =========================== + const handleSelection = useSelect( + multiple, + triggerChange, + checkedValues, + halfCheckedValues, + missingCheckedValues, + getPathKeyEntities, + getValueByKeyPath, + showCheckedStrategy, + ); + + const onInternalSelect = useEvent((valuePath: SingleValueType) => { + handleSelection(valuePath); + }); + + // ======================== Context ========================= + const cascaderContext = React.useMemo( + () => ({ + options: mergedOptions, + fieldNames: mergedFieldNames, + values: checkedValues, + halfValues: halfCheckedValues, + changeOnSelect, + onSelect: onInternalSelect, + checkable, + searchOptions: [], + dropdownPrefixCls: null, + loadData, + expandTrigger, + expandIcon, + loadingIcon, + dropdownMenuColumnStyle: null, + }), + [ + mergedOptions, + mergedFieldNames, + checkedValues, + halfCheckedValues, + changeOnSelect, + onInternalSelect, + checkable, + loadData, + expandTrigger, + expandIcon, + loadingIcon, + ], + ); + + // ========================= Render ========================= + return ( + +
+ +
+
+ ); +} diff --git a/src/hooks/useMissingValues.ts b/src/hooks/useMissingValues.ts index bf08b959..0cef0332 100644 --- a/src/hooks/useMissingValues.ts +++ b/src/hooks/useMissingValues.ts @@ -1,8 +1,13 @@ import * as React from 'react'; -import type { SingleValueType, DefaultOptionType, InternalFieldNames } from '../Cascader'; +import type { DefaultOptionType, InternalFieldNames, SingleValueType } from '../Cascader'; import { toPathOptions } from '../utils/treeUtil'; -export default (options: DefaultOptionType[], fieldNames: InternalFieldNames) => { +export type GetMissValues = ReturnType; + +export default function useMissingValues( + options: DefaultOptionType[], + fieldNames: InternalFieldNames, +) { return React.useCallback( (rawValues: SingleValueType[]): [SingleValueType[], SingleValueType[]] => { const missingValues: SingleValueType[] = []; @@ -21,4 +26,4 @@ export default (options: DefaultOptionType[], fieldNames: InternalFieldNames) => }, [options, fieldNames], ); -}; +} diff --git a/src/hooks/useOptions.ts b/src/hooks/useOptions.ts new file mode 100644 index 00000000..f0759d71 --- /dev/null +++ b/src/hooks/useOptions.ts @@ -0,0 +1,34 @@ +import * as React from 'react'; +import type { DefaultOptionType } from '..'; +import type { InternalFieldNames, SingleValueType } from '../Cascader'; +import useEntities, { type GetEntities } from './useEntities'; + +export default function useOptions( + mergedFieldNames: InternalFieldNames, + options?: DefaultOptionType[], +): [ + mergedOptions: DefaultOptionType[], + getPathKeyEntities: GetEntities, + getValueByKeyPath: (pathKeys: React.Key[]) => SingleValueType[], +] { + const mergedOptions = React.useMemo(() => options || [], [options]); + + // Only used in multiple mode, this fn will not call in single mode + const getPathKeyEntities = useEntities(mergedOptions, mergedFieldNames); + + /** Convert path key back to value format */ + const getValueByKeyPath = React.useCallback( + (pathKeys: React.Key[]): SingleValueType[] => { + const keyPathEntities = getPathKeyEntities(); + + return pathKeys.map(pathKey => { + const { nodes } = keyPathEntities[pathKey]; + + return nodes.map(node => node[mergedFieldNames.value]); + }); + }, + [getPathKeyEntities, mergedFieldNames], + ); + + return [mergedOptions, getPathKeyEntities, getValueByKeyPath]; +} diff --git a/src/hooks/useSelect.ts b/src/hooks/useSelect.ts new file mode 100644 index 00000000..60338cd4 --- /dev/null +++ b/src/hooks/useSelect.ts @@ -0,0 +1,72 @@ +import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; +import type { ShowCheckedStrategy, SingleValueType, ValueType } from '../Cascader'; +import { toPathKey, toPathKeys } from '../utils/commonUtil'; +import { formatStrategyValues } from '../utils/treeUtil'; +import type { GetEntities } from './useEntities'; + +export default function useSelect( + multiple: boolean, + triggerChange: (nextValues: ValueType) => void, + checkedValues: SingleValueType[], + halfCheckedValues: SingleValueType[], + missingCheckedValues: SingleValueType[], + getPathKeyEntities: GetEntities, + getValueByKeyPath: (pathKeys: React.Key[]) => SingleValueType[], + showCheckedStrategy: ShowCheckedStrategy, +) { + return (valuePath: SingleValueType) => { + if (!multiple) { + triggerChange(valuePath); + } else { + // Prepare conduct required info + const pathKey = toPathKey(valuePath); + const checkedPathKeys = toPathKeys(checkedValues); + const halfCheckedPathKeys = toPathKeys(halfCheckedValues); + + const existInChecked = checkedPathKeys.includes(pathKey); + const existInMissing = missingCheckedValues.some( + valueCells => toPathKey(valueCells) === pathKey, + ); + + // Do update + let nextCheckedValues = checkedValues; + let nextMissingValues = missingCheckedValues; + + if (existInMissing && !existInChecked) { + // Missing value only do filter + nextMissingValues = missingCheckedValues.filter( + valueCells => toPathKey(valueCells) !== pathKey, + ); + } else { + // Update checked key first + const nextRawCheckedKeys = existInChecked + ? checkedPathKeys.filter(key => key !== pathKey) + : [...checkedPathKeys, pathKey]; + + const pathKeyEntities = getPathKeyEntities(); + + // Conduction by selected or not + let checkedKeys: React.Key[]; + if (existInChecked) { + ({ checkedKeys } = conductCheck( + nextRawCheckedKeys, + { checked: false, halfCheckedKeys: halfCheckedPathKeys }, + pathKeyEntities, + )); + } else { + ({ checkedKeys } = conductCheck(nextRawCheckedKeys, true, pathKeyEntities)); + } + + // Roll up to parent level keys + const deDuplicatedKeys = formatStrategyValues( + checkedKeys, + getPathKeyEntities, + showCheckedStrategy, + ); + nextCheckedValues = getValueByKeyPath(deDuplicatedKeys); + } + + triggerChange([...nextMissingValues, ...nextCheckedValues]); + } + }; +} diff --git a/src/hooks/useValues.ts b/src/hooks/useValues.ts new file mode 100644 index 00000000..c2abfd0f --- /dev/null +++ b/src/hooks/useValues.ts @@ -0,0 +1,35 @@ +import type { DataEntity } from 'rc-tree/lib/interface'; +import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; +import * as React from 'react'; +import type { SingleValueType } from '../Cascader'; +import { toPathKeys } from '../utils/commonUtil'; +import type { GetMissValues } from './useMissingValues'; + +export default function useValues( + multiple: boolean, + rawValues: SingleValueType[], + getPathKeyEntities: () => Record, + getValueByKeyPath: (pathKeys: React.Key[]) => SingleValueType[], + getMissingValues: GetMissValues, +): [ + checkedValues: SingleValueType[], + halfCheckedValues: SingleValueType[], + missingCheckedValues: SingleValueType[], +] { + // Fill `rawValues` with checked conduction values + return React.useMemo(() => { + const [existValues, missingValues] = getMissingValues(rawValues); + + if (!multiple || !rawValues.length) { + return [existValues, [], missingValues]; + } + + const keyPathValues = toPathKeys(existValues); + const keyPathEntities = getPathKeyEntities(); + + const { checkedKeys, halfCheckedKeys } = conductCheck(keyPathValues, true, keyPathEntities); + + // Convert key back to value cells + return [getValueByKeyPath(checkedKeys), getValueByKeyPath(halfCheckedKeys), missingValues]; + }, [multiple, rawValues, getPathKeyEntities, getValueByKeyPath, getMissingValues]); +} diff --git a/src/index.tsx b/src/index.tsx index 9842b3c1..35ab7064 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,12 +1,15 @@ import Cascader from './Cascader'; +import Panel from './Panel'; export type { + BaseOptionType, CascaderProps, + DefaultOptionType, FieldNames, + MultipleCascaderProps, ShowSearchType, - DefaultOptionType, - BaseOptionType, SingleCascaderProps, - MultipleCascaderProps, } from './Cascader'; +export { Panel }; + export default Cascader; diff --git a/src/utils/commonUtil.ts b/src/utils/commonUtil.ts index 8a355425..c54eb4c0 100644 --- a/src/utils/commonUtil.ts +++ b/src/utils/commonUtil.ts @@ -3,6 +3,7 @@ import type { FieldNames, InternalFieldNames, SingleValueType, + ValueType, } from '../Cascader'; import { SEARCH_MARK } from '../hooks/useSearchOptions'; @@ -60,3 +61,19 @@ export function scrollIntoParentView(element: HTMLElement) { export function getFullPathKeys(options: DefaultOptionType[], fieldNames: FieldNames) { return options.map(item => item[SEARCH_MARK]?.map(opt => opt[fieldNames.value])); } + +function isMultipleValue(value: ValueType): value is SingleValueType[] { + return Array.isArray(value) && Array.isArray(value[0]); +} + +export function toRawValues(value: ValueType): SingleValueType[] { + if (!value) { + return []; + } + + if (isMultipleValue(value)) { + return value; + } + + return (value.length === 0 ? [] : [value]).map(val => (Array.isArray(val) ? val : [val])); +} \ No newline at end of file diff --git a/tests/Panel.spec.tsx b/tests/Panel.spec.tsx new file mode 100644 index 00000000..929b77a7 --- /dev/null +++ b/tests/Panel.spec.tsx @@ -0,0 +1,83 @@ +/* eslint-disable react/jsx-no-bind */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import Cascader, { type CascaderProps } from '../src'; + +describe('Cascader.Panel', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const options: CascaderProps['options'] = [ + { + label: 'Light', + value: 'light', + }, + { + label: 'Bamboo', + value: 'bamboo', + children: [ + { + label: 'Little', + value: 'little', + }, + ], + }, + ]; + + it('basic', () => { + const onChange = jest.fn(); + const { container } = render(); + + expect(container.querySelector('.rc-cascader-panel')).toBeTruthy(); + expect(container.querySelectorAll('.rc-cascader-menu')).toHaveLength(1); + + // Click first column + fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[1]); + expect(container.querySelectorAll('.rc-cascader-menu')).toHaveLength(2); + + // Click second column + fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[2]); + expect(onChange).toHaveBeenCalledWith(['bamboo', 'little'], expect.anything()); + }); + + it('multiple', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Click first column - light + fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[0]); + expect(onChange).toHaveBeenCalledWith([['light']], expect.anything()); + onChange.mockReset(); + + // Click first column - bamboo (no trigger onChange) + fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[1]); + expect(onChange).not.toHaveBeenCalled(); + + // Click second column - little + fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[2]); + expect(onChange).toHaveBeenCalledWith([['light'], ['bamboo']], expect.anything()); + }); + + it('multiple with showCheckedStrategy', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.querySelectorAll('.rc-cascader-checkbox')[1]); + expect(onChange).toHaveBeenCalledWith([['bamboo', 'little']], expect.anything()); + }); +});