From a11c6d61c4fa347562dc26d206710be29df97562 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 26 Dec 2024 08:22:56 +0800 Subject: [PATCH 01/10] feat: cascader --- .../uikit/src/biz/Cascader/CascaderPanel.tsx | 139 +++++ packages/uikit/src/biz/Cascader/index.tsx | 239 +++++++++ .../uikit/src/biz/Cascader/useCascader.ts | 132 +++++ .../uikit/src/biz/Cascader/useTreeStore.tsx | 88 ++++ packages/uikit/src/biz/Cascader/utils.ts | 78 +++ packages/uikit/src/biz/index.ts | 4 + packages/uikit/src/primitive/index.ts | 1 + stories/uikit/biz/Cascader.stories.tsx | 484 ++++++++++++++++++ 8 files changed, 1165 insertions(+) create mode 100644 packages/uikit/src/biz/Cascader/CascaderPanel.tsx create mode 100644 packages/uikit/src/biz/Cascader/index.tsx create mode 100644 packages/uikit/src/biz/Cascader/useCascader.ts create mode 100644 packages/uikit/src/biz/Cascader/useTreeStore.tsx create mode 100644 packages/uikit/src/biz/Cascader/utils.ts create mode 100644 stories/uikit/biz/Cascader.stories.tsx diff --git a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx new file mode 100644 index 000000000..8fe5b6863 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx @@ -0,0 +1,139 @@ +import { ReactNode, useMemo } from 'react' + +import { IconChevronRight } from '../../icons/index.js' +import { ActionIcon, Box, Checkbox, Divider, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' + +import { TreeSelectOption, useTreeContext } from './useTreeStore.js' +import { getAllLeafNodes } from './utils.js' + +import type { OptionProps, RenderOption } from './index.js' + +export interface CascaderPanelProps { + fixedGroup: number + multiple?: boolean + optionGroupTitle?: (index: number) => ReactNode + optionProps?: OptionProps + renderOption?: RenderOption + onClick?: (target: TreeSelectOption, newValue: string[]) => void +} + +export const CascaderPanel = (props: CascaderPanelProps) => { + const { options } = useTreeContext() + const { optionGroupTitle, fixedGroup } = props + const optionGroups = useMemo(() => { + const groups: TreeSelectOption[][] = [options] + const walk = (tree: TreeSelectOption[]) => { + tree.forEach((option) => { + if (option.expanded) { + groups.push(option.children || []) + walk(option.children || []) + } + }) + } + + walk(options) + + if (fixedGroup > 1 && groups.length < fixedGroup) { + groups.push(...new Array(fixedGroup - groups.length).fill([])) + } + + return groups + }, [options]) + + return ( + + {optionGroups.map((group, index) => ( + <> + {index > 0 && } + + {!!optionGroupTitle && optionGroupTitle(index)} + {group.map((option) => ( + + ))} + + + ))} + + ) +} + +interface CascaderItemProps extends CascaderPanelProps { + option: TreeSelectOption +} + +const CascaderItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { + const { defaultRadius, colors } = useMantineTheme() + const { wrapperProps, textProps } = optionProps || {} + const { loadChildren, toggleExpand, toggleCheck } = useTreeContext() + const { label, value, disabled, isChecked, isLeaf, isLoading, expanded, children } = option + const everyChildrenChecked = !!children?.length && getAllLeafNodes(children).every((child) => child.isChecked) + const someChildrenChecked = !!children?.length && getAllLeafNodes(children).some((child) => child.isChecked) + const isIndeterminate = !isLeaf && !everyChildrenChecked && someChildrenChecked + const _isChecked = (isLeaf && isChecked) || (!isLeaf && everyChildrenChecked) + + return ( + + { + if (disabled) { + return + } + const updatedOptions = toggleCheck?.(option) + onClick?.( + option, + multiple + ? getAllLeafNodes(updatedOptions) + .filter((n) => n.isChecked) + .map((n) => n.value) + : [value] + ) + }} + > + + + + {multiple && ( + + )} + {!!renderOption ? ( + renderOption({ label, value, disabled }) + ) : ( + + {label} + + )} + + + + {!isLeaf ? ( + { + e.stopPropagation() + + if (!isLoading && !children?.length) { + await loadChildren(option) + } + toggleExpand(option, true) + }} + > + + + ) : ( + <> + )} + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/index.tsx b/packages/uikit/src/biz/Cascader/index.tsx new file mode 100644 index 000000000..de536562a --- /dev/null +++ b/packages/uikit/src/biz/Cascader/index.tsx @@ -0,0 +1,239 @@ +import { ReactNode, Ref, useEffect, useImperativeHandle } from 'react' + +import { IconChevronSelectorVertical } from '../../icons/index.js' +import { + Box, + BoxProps, + Button, + Combobox, + ComboboxProps, + ComboboxStore, + Divider, + ElementProps, + Flex, + Group, + Input, + InputProps, + LoadingOverlay, + TextProps, + ComboboxSearchProps, + useCombobox +} from '../../primitive/index.js' + +import { CascaderPanel } from './CascaderPanel.js' +import { useCascader } from './useCascader.js' +import { SelectionProtectType, SelectOption, TreeProvider, TreeSelectOption, TreeStore } from './useTreeStore.js' +import { getAllLeafNodes } from './utils.js' + +export interface CascaderProps { + value: T[] + // target will be null when changeTrigger is onConfirm + onChange?: (target: TreeSelectOption | null, value: T[]) => void + // works when multiple is true + changeTrigger?: 'onSelect' | 'onConfirm' + + options?: SelectOption[] + store?: TreeStore + + // multi-selection or single-selection + multiple?: boolean + emptyMessage?: string + // should the empty array be check all status + allWithEmpty?: boolean + loading?: boolean + + // combobox + comboboxProps?: Omit + comboboxRef?: Ref + + // target + target?: ReactNode + defaultTargetProps?: InputProps & ElementProps<'input'> + + // options + fixedGroup?: number + optionGroupTitle?: (index: number) => ReactNode + optionProps?: OptionProps + renderOption?: RenderOption + + // search + searchable?: boolean + searchProps?: ComboboxSearchProps & ElementProps<'input'> +} + +export interface OptionProps { + wrapperProps?: BoxProps + textProps?: TextProps +} + +export interface RenderOption { + (currentOption: TreeSelectOption): React.ReactNode +} + +export const Cascader = ({ + value, + onChange, + changeTrigger = 'onSelect', + + options = [], + store, + + multiple, + emptyMessage, + allWithEmpty, + loading, + + comboboxProps, + comboboxRef, + + target, + defaultTargetProps, + + fixedGroup = 1, + optionGroupTitle, + optionProps, + renderOption, + + searchable, + searchProps +}: CascaderProps) => { + let cascader = useCascader({ options }) + if (store) { + cascader = store + } + const { options: treeOptions, checkByValues, foldAll } = cascader + + const resetCheckedStatusByValues = () => { + const isCheckAll = value.length === 0 && allWithEmpty + checkByValues(isCheckAll ? getAllLeafNodes(treeOptions).map((n) => n.value) : value) + } + + const combobox = useCombobox({ + onDropdownOpen: () => { + combobox.focusSearchInput() + resetCheckedStatusByValues() + }, + onDropdownClose: () => { + foldAll() + } + }) + useImperativeHandle(comboboxRef, () => combobox, [combobox]) + + useEffect(() => { + if (!multiple) { + return + } + resetCheckedStatusByValues() + }, [value]) + + return ( + + + + {target ? ( + target + ) : ( + } + onClick={() => { + combobox.toggleDropdown() + combobox.focusSearchInput() + }} + {...defaultTargetProps} + /> + )} + + + {searchable && ( + + + + + )} + + + {treeOptions.length ? ( + <> + + } + optionGroupTitle={optionGroupTitle} + onClick={(target, newValue) => { + if (changeTrigger === 'onSelect' || !multiple) { + onChange?.(target as TreeSelectOption, newValue as T[]) + } + if (!multiple) { + combobox.closeDropdown() + } + }} + /> + + {multiple && changeTrigger === 'onConfirm' && ( + <> + + + + + + + )} + + ) : ( + + {emptyMessage} + + )} + + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/useCascader.ts b/packages/uikit/src/biz/Cascader/useCascader.ts new file mode 100644 index 000000000..5161abb32 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/useCascader.ts @@ -0,0 +1,132 @@ +import { useState } from 'react' + +import type { + ToggleExpand, + LoadChildren, + OnOptionChange, + SelectionProtectType, + ToggleLoading, + TreeSelectOption, + TreeStore, + TreeStoreConfig, + UpdateChildren, + ToggleCheck +} from './useTreeStore.js' +import { flatArrayToTree, getAllLeafNodes, treeToFlatArray } from './utils.js' + +export const useCascader = ({ + options, + onOptionChange, + onLoadChildren, + onLoadChildrenAsync +}: TreeStoreConfig): TreeStore => { + const [_options, setOptions] = useState(flatArrayToTree(options)) + const _updateOptions = ( + tree: TreeSelectOption[], + identifier: T | ((opt: TreeSelectOption) => boolean), + newData: Partial> | ((opt: TreeSelectOption) => Partial>), + preprocess?: (node: TreeSelectOption) => TreeSelectOption, + postprocess?: (node: TreeSelectOption) => TreeSelectOption + ): TreeSelectOption[] => { + return tree.map((opt) => { + debugger + let _opt = opt + if (preprocess) { + _opt = preprocess(opt) + } + + const shouldUpdate = typeof identifier === 'function' ? identifier(_opt) : _opt.value === identifier + if (shouldUpdate) { + const _newData = typeof newData === 'function' ? newData(_opt) : newData + _opt = { ..._opt, ..._newData } + } + + if (_opt.children) { + _opt = { ..._opt, children: _updateOptions(_opt.children, identifier, newData, preprocess, postprocess) } + } + + return _opt + }) + } + + const toggleCheck: ToggleCheck = (target) => { + const allLeafNodes = getAllLeafNodes([target]) + const allValues = allLeafNodes.map((n) => n.value) + const isAllLeaesChecked = allLeafNodes.every((n) => n.isChecked) + const newData = { isChecked: !isAllLeaesChecked } + const updatedOptions = _updateOptions( + _options, + (opt) => !opt.disabled && allValues.includes(opt.value), + newData, + undefined + ) + + setOptions(updatedOptions) + onOptionChange?.({ type: 'check', target, newData }) + + return updatedOptions + } + const checkByValues = (values: T[]) => { + const updatedOptions = _updateOptions( + _options, + (opt) => !opt.disabled, + (opt) => ({ isChecked: values.includes(opt.value) }) + ) + setOptions(updatedOptions) + + return updatedOptions + } + + const updateOption: OnOptionChange = (evt, preprocess) => { + const { target, newData } = evt + const updatedOptions = _updateOptions(_options, target.value, newData, preprocess) + + setOptions(updatedOptions) + onOptionChange?.(evt) + } + const updateChildren: UpdateChildren = (target, children) => + updateOption({ type: 'updateChildren', target, newData: { children } }) + const toggleLoading: ToggleLoading = (target) => { + updateOption({ type: 'loading', target, newData: { isLoading: !target.isLoading } }) + } + const toggleExpand: ToggleExpand = (target, reset) => { + updateOption( + { + type: 'expand', + target, + newData: { expanded: !target.expanded } + }, + (node) => { + if (reset && node.parentValue === target.parentValue) { + return { ...node, expanded: false } + } + return node + } + ) + } + const foldAll = () => { + const updatedOptions = _updateOptions(_options, () => true, { expanded: false }) + setOptions(updatedOptions) + } + const loadChildren: LoadChildren = async (target) => { + onLoadChildren?.(target) + if (onLoadChildrenAsync) { + toggleLoading(target) + const children = await onLoadChildrenAsync(target) + updateChildren(target, children) + toggleLoading(target) + } + } + + return { + options: _options, + updateOption, + updateChildren, + toggleLoading, + loadChildren, + toggleExpand, + foldAll, + toggleCheck, + checkByValues + } +} diff --git a/packages/uikit/src/biz/Cascader/useTreeStore.tsx b/packages/uikit/src/biz/Cascader/useTreeStore.tsx new file mode 100644 index 000000000..078d14aa3 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/useTreeStore.tsx @@ -0,0 +1,88 @@ +import { createContext, PropsWithChildren, useContext } from 'react' + +export type SelectionProtectType = string | number + +// Tree Option +export interface TreeSelectOption { + label: string + value: T + disabled?: boolean + isChecked?: boolean + isLeaf?: boolean + isLoading?: boolean + expanded?: boolean + children?: TreeSelectOption[] + parentValue?: T +} + +export interface SelectOption extends Omit, 'children' | 'parentValue'> { + parentValue: T +} + +// Tree Store +export interface TreeStoreConfig { + options: SelectOption[] + onOptionChange?: OnOptionChange + onLoadChildren?: LoadChildren + // Load children asynchronously function, return a promise with children options. + // Automatically update the children of the target option. + onLoadChildrenAsync?: LoadChildrenAsync +} + +export interface LoadChildren { + (option: TreeSelectOption): void +} + +export interface LoadChildrenAsync { + (option: TreeSelectOption): Promise[]> +} + +// Tree Store Returns +export interface TreeStore { + options: TreeSelectOption[] + updateOption: OnOptionChange + updateChildren: UpdateChildren + toggleLoading: ToggleLoading + loadChildren: LoadChildren + toggleExpand: ToggleExpand + foldAll: () => void + toggleCheck: ToggleCheck + checkByValues: (values: T[]) => TreeSelectOption[] +} + +export interface OnOptionChange { + (evt: OptionChangeEvent, preprocess?: (node: TreeSelectOption) => TreeSelectOption): void +} +export type StatusChangeType = 'check' | 'expand' | 'loading' | 'updateChildren' +export interface OptionChangeEvent { + type: StatusChangeType + target: TreeSelectOption + newData: Partial> +} + +export interface UpdateChildren { + (target: TreeSelectOption, children: TreeSelectOption[]): void +} + +export interface ToggleLoading { + (target: TreeSelectOption): void +} + +export interface ToggleExpand { + (target: TreeSelectOption, reset?: boolean): void +} + +export interface ToggleCheck { + (target: TreeSelectOption): TreeSelectOption[] +} + +const TreeContext = createContext>(null as any) + +export const TreeProvider = ({ + value, + children +}: PropsWithChildren<{ value: TreeStore }>) => ( + }>{children} +) + +export const useTreeContext = () => useContext(TreeContext) diff --git a/packages/uikit/src/biz/Cascader/utils.ts b/packages/uikit/src/biz/Cascader/utils.ts new file mode 100644 index 000000000..f1eccb08f --- /dev/null +++ b/packages/uikit/src/biz/Cascader/utils.ts @@ -0,0 +1,78 @@ +import type { SelectOption, TreeSelectOption } from './useTreeStore' + +/** + * Flatten all leaf nodes from a TreeSelectOption array. + * + * A leaf node is a node that has no children and is not disabled. + * + * @param value - TreeSelectOption array + * @returns An array of leaf nodes + */ +export const getAllLeafNodes = (value: TreeSelectOption[], parent?: TreeSelectOption) => + value.reduce((prev, cur) => { + const { children, ...rest } = cur + if (cur.isLeaf && !cur.disabled) { + prev.push({ ...rest, parentValue: parent?.value! }) + } + if (children) { + prev.push(...getAllLeafNodes(children, rest)) + } + return prev + }, [] as SelectOption[]) + +/** + * Flatten a TreeSelectOption array into a plain array of SelectOption. + * + * This function will traverse the tree recursively and return all the leaf + * nodes in a single array. Each leaf node will have a parent field pointing to + * its parent node's value. + * + * @param value - The TreeSelectOption array to be flattened + * @param parent - The parent node of the current node + * @returns An array of SelectOption + */ +export const treeToFlatArray = (value: TreeSelectOption[], parent?: TreeSelectOption) => { + return value.reduce((prev, cur) => { + const { children, ...rest } = cur + prev.push({ ...rest, parentValue: parent?.value! }) + if (children) { + prev.push(...treeToFlatArray(children, cur)) + } + return prev + }, [] as SelectOption[]) +} + +/** + * Converts a flat array of SelectOption items into a tree structure of TreeSelectOption. + * + * This function organizes the flat array by mapping each option to its parent based on the parent field. + * Options without a parent are considered root nodes and are directly added to the tree array. + * Children are recursively added to their respective parent nodes. + * + * @param value - The flat array of SelectOption items to be converted into a tree structure. + * @returns A TreeSelectOption array representing the hierarchical tree structure. + */ + +export const flatArrayToTree = (value: SelectOption[]) => { + const treeArr: TreeSelectOption[] = [] + const treeMap = new Map>() + value.forEach((v) => { + treeMap.set(v.value, { ...v, children: [] }) + }) + value.forEach((v) => { + const cur = treeMap.get(v.value)! + if (!v.parentValue) { + treeArr.push(cur) + return + } + + const parent = treeMap.get(v.parentValue) + if (!parent) { + return + } + + parent.children!.push(cur) + }) + + return treeArr +} diff --git a/packages/uikit/src/biz/index.ts b/packages/uikit/src/biz/index.ts index 59a870cae..700411d79 100644 --- a/packages/uikit/src/biz/index.ts +++ b/packages/uikit/src/biz/index.ts @@ -13,3 +13,7 @@ export * from './PageShell/index.js' export * from './TimeRangePicker/index.js' export * from './DateTimePicker/index.js' export * from './ProMultiSelect/index.js' +export * from './Cascader/index.js' +export * from './Cascader/useCascader.js' +export * from './Cascader/utils.js' +// export * from './Cascader/useTreeStore.js' diff --git a/packages/uikit/src/primitive/index.ts b/packages/uikit/src/primitive/index.ts index 477c72460..e0a8763f1 100644 --- a/packages/uikit/src/primitive/index.ts +++ b/packages/uikit/src/primitive/index.ts @@ -54,6 +54,7 @@ export type { TextareaProps, AutocompleteProps, ComboboxProps, + ComboboxSearchProps, ComboboxItem, ComboboxData, PillProps, diff --git a/stories/uikit/biz/Cascader.stories.tsx b/stories/uikit/biz/Cascader.stories.tsx new file mode 100644 index 000000000..7371fa659 --- /dev/null +++ b/stories/uikit/biz/Cascader.stories.tsx @@ -0,0 +1,484 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Box, Button, ComboboxStore, Stack, Text } from '@tidbcloud/uikit' +import { Cascader, TreeSelectOption, treeToFlatArray, useCascader } from '@tidbcloud/uikit/biz' +import { useRef, useState } from 'react' + +type Story = StoryObj + +const meta: Meta = { + title: 'Biz/Cascader', + component: Cascader, + tags: ['autodocs'], + parameters: {} +} + +export default meta + +function getTreeData(): TreeSelectOption[] { + return [ + { + label: 'TiDB Serverless', + value: 'TiDB Serverless', + isLeaf: false, + children: [ + // { + // label: 'Row-based Storage', + // value: 'TiDB Serverless - Row-based Storage', + // isLeaf: true + // }, + // { + // label: 'Columnar Storage', + // value: 'TiDB Serverless - Columnar Storage', + // isLeaf: true + // }, + // { + // label: 'Request Units', + // value: 'TiDB Serverless - Request Units', + // isLeaf: true + // } + ] + }, + { + label: 'TiDB Dedicated', + value: 'TiDB Dedicated', + isLeaf: false, + children: [ + { + label: 'Node Compute', + value: 'Node Compute', + isLeaf: false, + children: [ + { + label: 'TiDB', + value: 'TiDB Dedicated - Node Compute - TiDB', + isLeaf: true + }, + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Compute - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Compute - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Node Storage', + value: 'Node Storage', + isLeaf: false, + children: [ + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Storage - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Storage - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Backup', + value: 'Backup', + isLeaf: false, + children: [ + { + label: 'Single Region Storage', + value: 'TiDB Dedicated - Backup - Single Region Storage', + isLeaf: true + }, + { + label: 'Dual Region Storage', + value: 'TiDB Dedicated - Backup - Dual Region Storage', + isLeaf: true + }, + { + label: 'Replication', + value: 'TiDB Dedicated - Backup - Replication', + isLeaf: true + } + ] + }, + { + label: 'Data Migration', + value: 'Data Migration', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units (RCU)', + value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)', + isLeaf: true + } + ] + }, + { + label: 'Changefeed', + value: 'Changefeed', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units', + value: 'TiDB Dedicated - Changefeed - Replication Capacity Units', + isLeaf: true + } + ] + }, + { + label: 'Data Transfer', + value: 'Data Transfer', + isLeaf: false, + children: [ + { + label: 'Internet', + value: 'TiDB Dedicated - Data Transfer - Internet', + isLeaf: true + }, + { + label: 'Cross Region', + value: 'TiDB Dedicated - Data Transfer - Cross Region', + isLeaf: true + }, + { + label: 'Same Region', + value: 'TiDB Dedicated - Data Transfer - Same Region', + isLeaf: true + }, + { + label: 'Load Balancing', + value: 'TiDB Dedicated - Data Transfer - Load Balancing', + isLeaf: true + }, + { + label: 'DM NAT Gateway', + value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway', + isLeaf: true + }, + { + label: 'Private Data Link', + value: 'TiDB Dedicated - Data Transfer - Private Data Link', + isLeaf: true + } + ] + }, + { + label: 'Recovery Group', + value: 'Recovery Group', + disabled: true, + isLeaf: false, + children: [ + { + label: 'Recovery Group Service', + value: 'TiDB Dedicated - Recovery Group - Recovery Group Service', + isLeaf: true + }, + { + label: 'Same Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing', + isLeaf: true + }, + { + label: 'Cross Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing', + isLeaf: true + } + ] + } + ] + }, + { + label: 'Support Plan', + value: 'Support Plan', + isLeaf: true + } + ] +} + +const TITLES = ['Group 1', 'Group 2', 'Group 3'] +function MultipleDemo() { + const cascader = useCascader({ + options: treeToFlatArray(getTreeData()), + onLoadChildren: (target) => { + cascader.toggleLoading(target) + } + }) + const [value, setValue] = useState([]) + return ( + { + console.log(`target:`, target) + console.log(`checked:`, v) + setValue(v) + }} + fixedGroup={2} + multiple + searchable + allWithEmpty + changeTrigger="onConfirm" + optionGroupTitle={(index) => ( + + {TITLES[index]} + + )} + // loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + /> + ) +} + +// function SingleDemo() { +// const [value, setValue] = useState([]) +// const treeSelectRef = useRef(null) + +// return ( +// +// Selected: {value.join(', ')} +// { +// console.log(`checked:`, v, target) +// setValue(v) +// }} +// loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} +// allowSelectAll={false} +// target={} +// /> +// +// ) +// } + +const code = ` +import { TreeSelect, TreeSelectOption } from '@tidbcloud/uikit/biz' + +function getTreeData(): TreeSelectOption[] { + return [ + { + label: 'TiDB Serverless', + value: 'TiDB Serverless', + isLeaf: false, + children: [ + { + label: 'Row-based Storage', + value: 'TiDB Serverless - Row-based Storage', + isLeaf: true + }, + { + label: 'Columnar Storage', + value: 'TiDB Serverless - Columnar Storage', + isLeaf: true + }, + { + label: 'Request Units', + value: 'TiDB Serverless - Request Units', + isLeaf: true + } + ] + }, + { + label: 'TiDB Dedicated', + value: 'TiDB Dedicated', + isLeaf: false, + children: [ + { + label: 'Node Compute', + value: 'Node Compute', + isLeaf: false, + children: [ + { + label: 'TiDB', + value: 'TiDB Dedicated - Node Compute - TiDB', + isLeaf: true + }, + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Compute - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Compute - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Node Storage', + value: 'Node Storage', + isLeaf: false, + children: [ + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Storage - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Storage - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Backup', + value: 'Backup', + isLeaf: false, + children: [ + { + label: 'Single Region Storage', + value: 'TiDB Dedicated - Backup - Single Region Storage', + isLeaf: true + }, + { + label: 'Dual Region Storage', + value: 'TiDB Dedicated - Backup - Dual Region Storage', + isLeaf: true + }, + { + label: 'Replication', + value: 'TiDB Dedicated - Backup - Replication', + isLeaf: true + } + ] + }, + { + label: 'Data Migration', + value: 'Data Migration', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units (RCU)', + value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)', + isLeaf: true + } + ] + }, + { + label: 'Changefeed', + value: 'Changefeed', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units', + value: 'TiDB Dedicated - Changefeed - Replication Capacity Units', + isLeaf: true + } + ] + }, + { + label: 'Data Transfer', + value: 'Data Transfer', + isLeaf: false, + children: [ + { + label: 'Internet', + value: 'TiDB Dedicated - Data Transfer - Internet', + isLeaf: true + }, + { + label: 'Cross Region', + value: 'TiDB Dedicated - Data Transfer - Cross Region', + isLeaf: true + }, + { + label: 'Same Region', + value: 'TiDB Dedicated - Data Transfer - Same Region', + isLeaf: true + }, + { + label: 'Load Balancing', + value: 'TiDB Dedicated - Data Transfer - Load Balancing', + isLeaf: true + }, + { + label: 'DM NAT Gateway', + value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway', + isLeaf: true + }, + { + label: 'Private Data Link', + value: 'TiDB Dedicated - Data Transfer - Private Data Link', + isLeaf: true + } + ] + }, + { + label: 'Recovery Group', + value: 'Recovery Group', + isLeaf: false, + children: [ + { + label: 'Recovery Group Service', + value: 'TiDB Dedicated - Recovery Group - Recovery Group Service', + isLeaf: true + }, + { + label: 'Same Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing', + isLeaf: true + }, + { + label: 'Cross Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing', + isLeaf: true + } + ] + } + ] + }, + { + label: 'Support Plan', + value: 'Support Plan', + isLeaf: true + } + ] +} + +function Demo() { + return + new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + /> +} +` + +// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing +export const Multiple: Story = { + parameters: { + controls: { expanded: true }, + docs: { + source: { + language: 'jsx', + code + } + } + }, + render: () => , + args: {} +} + +// export const Single: Story = { +// parameters: { +// controls: { expanded: true }, +// docs: { +// source: { +// language: 'jsx', +// code +// } +// } +// }, +// render: () => , +// args: {} +// } From de9bd30e1faec7f205762506952fbac1072f8927 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 26 Dec 2024 10:00:54 +0800 Subject: [PATCH 02/10] chore: reexport combobox store --- packages/uikit/src/primitive/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/uikit/src/primitive/index.ts b/packages/uikit/src/primitive/index.ts index e0a8763f1..2a7aa4742 100644 --- a/packages/uikit/src/primitive/index.ts +++ b/packages/uikit/src/primitive/index.ts @@ -57,6 +57,7 @@ export type { ComboboxSearchProps, ComboboxItem, ComboboxData, + ComboboxStore, PillProps, PillsInputProps, OptionsFilter, From 2b48259fad1c8f9a3b6ea3fe1e84fb04e5ffa126 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 26 Dec 2024 17:01:42 +0800 Subject: [PATCH 03/10] feat: search --- .../uikit/src/biz/Cascader/CascaderPanel.tsx | 27 +++-- .../uikit/src/biz/Cascader/SearchPanel.tsx | 93 ++++++++++++++++ packages/uikit/src/biz/Cascader/index.tsx | 102 ++++++++++++++---- .../uikit/src/biz/Cascader/useCascader.ts | 3 +- packages/uikit/src/biz/index.ts | 2 +- stories/uikit/biz/Cascader.stories.tsx | 7 +- 6 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 packages/uikit/src/biz/Cascader/SearchPanel.tsx diff --git a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx index 8fe5b6863..b3662773e 100644 --- a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx +++ b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx @@ -8,18 +8,17 @@ import { getAllLeafNodes } from './utils.js' import type { OptionProps, RenderOption } from './index.js' -export interface CascaderPanelProps { +export const DEFAULT_PANEL_HEIGHT = 240 +export const DEFAULT_PANEL_WIDTH = 260 + +export interface CascaderPanelProps extends Omit { fixedGroup: number - multiple?: boolean optionGroupTitle?: (index: number) => ReactNode - optionProps?: OptionProps - renderOption?: RenderOption - onClick?: (target: TreeSelectOption, newValue: string[]) => void } export const CascaderPanel = (props: CascaderPanelProps) => { const { options } = useTreeContext() - const { optionGroupTitle, fixedGroup } = props + const { optionGroupTitle, fixedGroup, optionProps } = props const optionGroups = useMemo(() => { const groups: TreeSelectOption[][] = [options] const walk = (tree: TreeSelectOption[]) => { @@ -45,7 +44,15 @@ export const CascaderPanel = (props: CascaderPanelProps) => { {optionGroups.map((group, index) => ( <> {index > 0 && } - + {!!optionGroupTitle && optionGroupTitle(index)} {group.map((option) => ( @@ -57,8 +64,12 @@ export const CascaderPanel = (props: CascaderPanelProps) => { ) } -interface CascaderItemProps extends CascaderPanelProps { +export interface CascaderItemProps { + multiple?: boolean option: TreeSelectOption + optionProps?: OptionProps + renderOption?: RenderOption + onClick?: (target: TreeSelectOption, newValue: string[]) => void } const CascaderItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { diff --git a/packages/uikit/src/biz/Cascader/SearchPanel.tsx b/packages/uikit/src/biz/Cascader/SearchPanel.tsx new file mode 100644 index 000000000..744df25ca --- /dev/null +++ b/packages/uikit/src/biz/Cascader/SearchPanel.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react' + +import { Box, Checkbox, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' + +import { CascaderItemProps, DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from './CascaderPanel.js' +import { SelectOption, useTreeContext } from './useTreeStore.js' +import { getAllLeafNodes, treeToFlatArray } from './utils.js' + +interface SearchPanelProps extends Omit { + currentValue: string[] + searchOptions?: SelectOption[] +} + +export const SearchPanel = (props: SearchPanelProps) => { + const { options } = useTreeContext() + const { searchOptions = [], optionProps, currentValue } = props + const displayedOptions = useMemo(() => { + const currentOptionMap = new Map(treeToFlatArray(options).map((o) => [o.value, o])) + + return searchOptions.map((opt) => { + const currentOpt = currentOptionMap.get(opt.value) + const currentChecked = currentOpt?.isChecked + const allChildren = !!currentOpt && getAllLeafNodes([currentOpt]) + const allChildrenChecked = allChildren && allChildren.every((n) => n.isChecked) + return { ...opt, isChecked: currentChecked || allChildrenChecked || false } + }) + }, [currentValue, optionProps]) + + return ( + + {displayedOptions.map((option) => ( + + ))} + + ) +} + +const SearchItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { + const { defaultRadius, colors } = useMantineTheme() + const { wrapperProps, textProps } = optionProps || {} + const { toggleCheck } = useTreeContext() + const { label, value, disabled, isChecked, expanded } = option + + return ( + + { + if (disabled) { + return + } + const updatedOptions = toggleCheck?.(option) + onClick?.( + option, + multiple + ? getAllLeafNodes(updatedOptions) + .filter((n) => n.isChecked) + .map((n) => n.value) + : [value] + ) + }} + > + + + + {multiple && } + {!!renderOption ? ( + renderOption({ label, value, disabled }) + ) : ( + + {label} + + )} + + + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/index.tsx b/packages/uikit/src/biz/Cascader/index.tsx index de536562a..18ab21bc8 100644 --- a/packages/uikit/src/biz/Cascader/index.tsx +++ b/packages/uikit/src/biz/Cascader/index.tsx @@ -1,6 +1,7 @@ -import { ReactNode, Ref, useEffect, useImperativeHandle } from 'react' +import { IconSearch } from '@tabler/icons-react' +import { ReactNode, Ref, useEffect, useImperativeHandle, useState } from 'react' -import { IconChevronSelectorVertical } from '../../icons/index.js' +import { IconChevronSelectorVertical, IconXCircle } from '../../icons/index.js' import { Box, BoxProps, @@ -17,10 +18,12 @@ import { LoadingOverlay, TextProps, ComboboxSearchProps, - useCombobox + useCombobox, + ActionIcon } from '../../primitive/index.js' -import { CascaderPanel } from './CascaderPanel.js' +import { CascaderPanel, DEFAULT_PANEL_HEIGHT } from './CascaderPanel.js' +import { SearchPanel } from './SearchPanel.js' import { useCascader } from './useCascader.js' import { SelectionProtectType, SelectOption, TreeProvider, TreeSelectOption, TreeStore } from './useTreeStore.js' import { getAllLeafNodes } from './utils.js' @@ -58,12 +61,16 @@ export interface CascaderProps { // search searchable?: boolean - searchProps?: ComboboxSearchProps & ElementProps<'input'> + searchProps?: ComboboxSearchProps & ElementProps<'input', 'onChange'> & { onChange?: (search: string) => void } + searchOptions?: SelectOption[] + renderSearchOption?: RenderOption } export interface OptionProps { wrapperProps?: BoxProps textProps?: TextProps + panelHeight?: number + panelWidth?: number } export interface RenderOption { @@ -95,7 +102,9 @@ export const Cascader = ({ renderOption, searchable, - searchProps + searchProps, + searchOptions, + renderSearchOption }: CascaderProps) => { let cascader = useCascader({ options }) if (store) { @@ -119,6 +128,13 @@ export const Cascader = ({ }) useImperativeHandle(comboboxRef, () => combobox, [combobox]) + const [search, setSearch] = useState('') + const { onChange: onSearchChange } = searchProps || {} + const onSearch = (search: string) => { + setSearch(search) + onSearchChange?.(search) + } + useEffect(() => { if (!multiple) { return @@ -169,7 +185,29 @@ export const Cascader = ({ {searchable && ( - + { + onSearch(event.currentTarget.value) + }} + leftSection={} + rightSectionPointerEvents="all" + rightSection={ + !!search && ( + { + onSearch('') + combobox.focusSearchInput() + }} + > + + + ) + } + /> )} @@ -177,22 +215,40 @@ export const Cascader = ({ {treeOptions.length ? ( <> - - } - optionGroupTitle={optionGroupTitle} - onClick={(target, newValue) => { - if (changeTrigger === 'onSelect' || !multiple) { - onChange?.(target as TreeSelectOption, newValue as T[]) - } - if (!multiple) { - combobox.closeDropdown() - } - }} - /> + + {searchable && search && searchOptions?.length ? ( + []} + multiple={multiple} + optionProps={optionProps} + renderOption={renderSearchOption as RenderOption} + onClick={(target, newValue) => { + if (changeTrigger === 'onSelect' || !multiple) { + onChange?.(target as TreeSelectOption, newValue as T[]) + } + if (!multiple) { + combobox.closeDropdown() + } + }} + /> + ) : ( + } + optionGroupTitle={optionGroupTitle} + onClick={(target, newValue) => { + if (changeTrigger === 'onSelect' || !multiple) { + onChange?.(target as TreeSelectOption, newValue as T[]) + } + if (!multiple) { + combobox.closeDropdown() + } + }} + /> + )} {multiple && changeTrigger === 'onConfirm' && ( <> diff --git a/packages/uikit/src/biz/Cascader/useCascader.ts b/packages/uikit/src/biz/Cascader/useCascader.ts index 5161abb32..ef9c42ab1 100644 --- a/packages/uikit/src/biz/Cascader/useCascader.ts +++ b/packages/uikit/src/biz/Cascader/useCascader.ts @@ -12,7 +12,7 @@ import type { UpdateChildren, ToggleCheck } from './useTreeStore.js' -import { flatArrayToTree, getAllLeafNodes, treeToFlatArray } from './utils.js' +import { flatArrayToTree, getAllLeafNodes } from './utils.js' export const useCascader = ({ options, @@ -29,7 +29,6 @@ export const useCascader = ({ postprocess?: (node: TreeSelectOption) => TreeSelectOption ): TreeSelectOption[] => { return tree.map((opt) => { - debugger let _opt = opt if (preprocess) { _opt = preprocess(opt) diff --git a/packages/uikit/src/biz/index.ts b/packages/uikit/src/biz/index.ts index 700411d79..01c3eadd0 100644 --- a/packages/uikit/src/biz/index.ts +++ b/packages/uikit/src/biz/index.ts @@ -16,4 +16,4 @@ export * from './ProMultiSelect/index.js' export * from './Cascader/index.js' export * from './Cascader/useCascader.js' export * from './Cascader/utils.js' -// export * from './Cascader/useTreeStore.js' +export * from './Cascader/useTreeStore.js' diff --git a/stories/uikit/biz/Cascader.stories.tsx b/stories/uikit/biz/Cascader.stories.tsx index 7371fa659..401d153d5 100644 --- a/stories/uikit/biz/Cascader.stories.tsx +++ b/stories/uikit/biz/Cascader.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' -import { Box, Button, ComboboxStore, Stack, Text } from '@tidbcloud/uikit' +import { Box } from '@tidbcloud/uikit' import { Cascader, TreeSelectOption, treeToFlatArray, useCascader } from '@tidbcloud/uikit/biz' -import { useRef, useState } from 'react' +import { useState } from 'react' type Story = StoryObj @@ -220,10 +220,11 @@ function MultipleDemo() { fixedGroup={2} multiple searchable + searchOptions={treeToFlatArray(getTreeData())} allWithEmpty changeTrigger="onConfirm" optionGroupTitle={(index) => ( - + {TITLES[index]} )} From d0905b1ce9c659f37eaf10891d0fbac08c426fb8 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 26 Dec 2024 17:11:30 +0800 Subject: [PATCH 04/10] tweak: search --- .../uikit/src/biz/Cascader/SearchPanel.tsx | 25 +++++++++++++++---- packages/uikit/src/biz/Cascader/index.tsx | 4 +-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/uikit/src/biz/Cascader/SearchPanel.tsx b/packages/uikit/src/biz/Cascader/SearchPanel.tsx index 744df25ca..9f1c7bd31 100644 --- a/packages/uikit/src/biz/Cascader/SearchPanel.tsx +++ b/packages/uikit/src/biz/Cascader/SearchPanel.tsx @@ -3,8 +3,8 @@ import { useMemo } from 'react' import { Box, Checkbox, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' import { CascaderItemProps, DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from './CascaderPanel.js' -import { SelectOption, useTreeContext } from './useTreeStore.js' -import { getAllLeafNodes, treeToFlatArray } from './utils.js' +import { SelectOption, TreeSelectOption, useTreeContext } from './useTreeStore.js' +import { getAllLeafNodes } from './utils.js' interface SearchPanelProps extends Omit { currentValue: string[] @@ -15,14 +15,29 @@ export const SearchPanel = (props: SearchPanelProps) => { const { options } = useTreeContext() const { searchOptions = [], optionProps, currentValue } = props const displayedOptions = useMemo(() => { - const currentOptionMap = new Map(treeToFlatArray(options).map((o) => [o.value, o])) + const currentOptionMap = new Map() + const walk = (tree: TreeSelectOption[]) => { + tree.forEach((option) => { + currentOptionMap.set(option.value, option) + walk(option.children || []) + }) + } + + walk(options) return searchOptions.map((opt) => { + // value not in options(lazy load) + const currentValueChecked = currentValue.includes(opt.value) + + // leaf const currentOpt = currentOptionMap.get(opt.value) const currentChecked = currentOpt?.isChecked + + // all children checked const allChildren = !!currentOpt && getAllLeafNodes([currentOpt]) - const allChildrenChecked = allChildren && allChildren.every((n) => n.isChecked) - return { ...opt, isChecked: currentChecked || allChildrenChecked || false } + const allChildrenChecked = allChildren && !!allChildren.length && allChildren.every((n) => n.isChecked) + + return { ...opt, isChecked: currentValueChecked || currentChecked || allChildrenChecked } }) }, [currentValue, optionProps]) diff --git a/packages/uikit/src/biz/Cascader/index.tsx b/packages/uikit/src/biz/Cascader/index.tsx index 18ab21bc8..2c7070a5a 100644 --- a/packages/uikit/src/biz/Cascader/index.tsx +++ b/packages/uikit/src/biz/Cascader/index.tsx @@ -224,9 +224,7 @@ export const Cascader = ({ optionProps={optionProps} renderOption={renderSearchOption as RenderOption} onClick={(target, newValue) => { - if (changeTrigger === 'onSelect' || !multiple) { - onChange?.(target as TreeSelectOption, newValue as T[]) - } + onChange?.(target as TreeSelectOption, newValue as T[]) if (!multiple) { combobox.closeDropdown() } From 943614a570d0f2ce7fb1f065c9760e8a17cc61aa Mon Sep 17 00:00:00 2001 From: Suhaha Date: Wed, 5 Mar 2025 17:26:25 +0800 Subject: [PATCH 05/10] refactor: cascader --- .../uikit/src/biz/Cascader/CascaderPanel.tsx | 107 ++++--- .../src/biz/Cascader/CascaderPanel.tsx.bk | 150 +++++++++ .../uikit/src/biz/Cascader/SearchPanel.tsx | 51 +-- .../uikit/src/biz/Cascader/SearchPanel.tsx.bk | 108 +++++++ packages/uikit/src/biz/Cascader/index.tsx | 122 ++++---- packages/uikit/src/biz/Cascader/index.tsx.bk | 293 ++++++++++++++++++ .../{useCascader.ts => useCascader.ts.bk} | 4 +- packages/uikit/src/biz/Cascader/useTree.tsx | 111 +++++++ .../{useTreeStore.tsx => useTreeStore.tsx.bk} | 0 .../biz/Cascader/{utils.ts => utils.ts.bk} | 2 +- packages/uikit/src/biz/index.ts | 4 +- packages/uikit/src/primitive/index.ts | 7 + stories/uikit/biz/Cascader.stories.tsx | 149 ++++++--- 13 files changed, 917 insertions(+), 191 deletions(-) create mode 100644 packages/uikit/src/biz/Cascader/CascaderPanel.tsx.bk create mode 100644 packages/uikit/src/biz/Cascader/SearchPanel.tsx.bk create mode 100644 packages/uikit/src/biz/Cascader/index.tsx.bk rename packages/uikit/src/biz/Cascader/{useCascader.ts => useCascader.ts.bk} (97%) create mode 100644 packages/uikit/src/biz/Cascader/useTree.tsx rename packages/uikit/src/biz/Cascader/{useTreeStore.tsx => useTreeStore.tsx.bk} (100%) rename packages/uikit/src/biz/Cascader/{utils.ts => utils.ts.bk} (99%) diff --git a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx index b3662773e..94ac5d8e6 100644 --- a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx +++ b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx @@ -1,43 +1,55 @@ import { ReactNode, useMemo } from 'react' import { IconChevronRight } from '../../icons/index.js' -import { ActionIcon, Box, Checkbox, Divider, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' +import { + ActionIcon, + Box, + Checkbox, + Divider, + Group, + Stack, + Text, + TreeNodeData, + useMantineTheme +} from '../../primitive/index.js' -import { TreeSelectOption, useTreeContext } from './useTreeStore.js' -import { getAllLeafNodes } from './utils.js' +import { useTreeContext } from './useTree.js' -import type { OptionProps, RenderOption } from './index.js' +import type { OptionProps } from './index.js' export const DEFAULT_PANEL_HEIGHT = 240 export const DEFAULT_PANEL_WIDTH = 260 -export interface CascaderPanelProps extends Omit { +export interface CascaderPanelProps extends Omit { + data: TreeNodeData[] fixedGroup: number optionGroupTitle?: (index: number) => ReactNode } export const CascaderPanel = (props: CascaderPanelProps) => { - const { options } = useTreeContext() - const { optionGroupTitle, fixedGroup, optionProps } = props + const { expandedState } = useTreeContext() + const { data, optionGroupTitle, fixedGroup, optionProps } = props const optionGroups = useMemo(() => { - const groups: TreeSelectOption[][] = [options] - const walk = (tree: TreeSelectOption[]) => { - tree.forEach((option) => { - if (option.expanded) { + const groups: TreeNodeData[][] = [data] + const walk = (tree: TreeNodeData[]) => { + tree.some((option) => { + if (expandedState[option.value]) { groups.push(option.children || []) walk(option.children || []) + return true } + return false }) } - walk(options) + walk(data) if (fixedGroup > 1 && groups.length < fixedGroup) { groups.push(...new Array(fixedGroup - groups.length).fill([])) } return groups - }, [options]) + }, [data, expandedState]) return ( @@ -55,7 +67,7 @@ export const CascaderPanel = (props: CascaderPanelProps) => { > {!!optionGroupTitle && optionGroupTitle(index)} {group.map((option) => ( - + ))} @@ -64,23 +76,35 @@ export const CascaderPanel = (props: CascaderPanelProps) => { ) } -export interface CascaderItemProps { +export interface CascaderItemProps { multiple?: boolean - option: TreeSelectOption + option: TreeNodeData + siblings: TreeNodeData[] optionProps?: OptionProps - renderOption?: RenderOption - onClick?: (target: TreeSelectOption, newValue: string[]) => void + onCheck?: (target: TreeNodeData, prev: boolean, next: boolean) => void } -const CascaderItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { +const CascaderItem = ({ multiple, option, siblings, optionProps, onCheck }: CascaderItemProps) => { const { defaultRadius, colors } = useMantineTheme() const { wrapperProps, textProps } = optionProps || {} - const { loadChildren, toggleExpand, toggleCheck } = useTreeContext() - const { label, value, disabled, isChecked, isLeaf, isLoading, expanded, children } = option - const everyChildrenChecked = !!children?.length && getAllLeafNodes(children).every((child) => child.isChecked) - const someChildrenChecked = !!children?.length && getAllLeafNodes(children).some((child) => child.isChecked) - const isIndeterminate = !isLeaf && !everyChildrenChecked && someChildrenChecked - const _isChecked = (isLeaf && isChecked) || (!isLeaf && everyChildrenChecked) + const { + loadNodes, + toggleExpanded, + toggleCheck, + loadingState, + disabledState, + expandedState, + isNodeChecked, + isNodeIndeterminate, + collapse + } = useTreeContext() + const { label, value, children, nodeProps } = option + const isLeaf = nodeProps?.isLeaf + const isLoading = loadingState[value] + const disabled = disabledState[value] + const expanded = expandedState[value] + const isChecked = isNodeChecked(value) + const isIndeterminate = isNodeIndeterminate(value) return ( @@ -96,30 +120,19 @@ const CascaderItem = ({ multiple, option, optionProps, renderOption, onClick }: if (disabled) { return } - const updatedOptions = toggleCheck?.(option) - onClick?.( - option, - multiple - ? getAllLeafNodes(updatedOptions) - .filter((n) => n.isChecked) - .map((n) => n.value) - : [value] - ) + toggleCheck?.(value) + onCheck?.(option, isChecked, !isChecked) }} > {multiple && ( - - )} - {!!renderOption ? ( - renderOption({ label, value, disabled }) - ) : ( - - {label} - + )} + + {label} + @@ -133,9 +146,15 @@ const CascaderItem = ({ multiple, option, optionProps, renderOption, onClick }: e.stopPropagation() if (!isLoading && !children?.length) { - await loadChildren(option) + await loadNodes(value) } - toggleExpand(option, true) + + siblings.forEach((sibling) => { + if (sibling.value !== value) { + collapse(sibling.value) + } + }) + toggleExpanded(value) }} > diff --git a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx.bk b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx.bk new file mode 100644 index 000000000..93de67d34 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx.bk @@ -0,0 +1,150 @@ +import { ReactNode, useMemo } from 'react' + +import { IconChevronRight } from '../../icons/index.js' +import { ActionIcon, Box, Checkbox, Divider, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' + +import { TreeSelectOption, useTreeContext } from './useTreeStore.tsx.bk' +import { getAllLeafNodes } from './utils.ts.bk' + +import type { OptionProps, RenderOption } from './index.tsx.bk' + +export const DEFAULT_PANEL_HEIGHT = 240 +export const DEFAULT_PANEL_WIDTH = 260 + +export interface CascaderPanelProps extends Omit { + fixedGroup: number + optionGroupTitle?: (index: number) => ReactNode +} + +export const CascaderPanel = (props: CascaderPanelProps) => { + const { options } = useTreeContext() + const { optionGroupTitle, fixedGroup, optionProps } = props + const optionGroups = useMemo(() => { + const groups: TreeSelectOption[][] = [options] + const walk = (tree: TreeSelectOption[]) => { + tree.forEach((option) => { + if (option.expanded) { + groups.push(option.children || []) + walk(option.children || []) + } + }) + } + + walk(options) + + if (fixedGroup > 1 && groups.length < fixedGroup) { + groups.push(...new Array(fixedGroup - groups.length).fill([])) + } + + return groups + }, [options]) + + return ( + + {optionGroups.map((group, index) => ( + <> + {index > 0 && } + + {!!optionGroupTitle && optionGroupTitle(index)} + {group.map((option) => ( + + ))} + + + ))} + + ) +} + +export interface CascaderItemProps { + multiple?: boolean + option: TreeSelectOption + optionProps?: OptionProps + renderOption?: RenderOption + onClick?: (target: TreeSelectOption, newValue: string[]) => void +} + +const CascaderItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { + const { defaultRadius, colors } = useMantineTheme() + const { wrapperProps, textProps } = optionProps || {} + const { loadChildren, toggleExpand, toggleCheck } = useTreeContext() + const { label, value, disabled, isChecked, isLeaf, isLoading, expanded, children } = option + const everyChildrenChecked = !!children?.length && getAllLeafNodes(children).every((child) => child.isChecked) + const someChildrenChecked = !!children?.length && getAllLeafNodes(children).some((child) => child.isChecked) + const isIndeterminate = !isLeaf && !everyChildrenChecked && someChildrenChecked + const _isChecked = (isLeaf && isChecked) || (!isLeaf && everyChildrenChecked) + + return ( + + { + if (disabled) { + return + } + const updatedOptions = toggleCheck?.(option) + onClick?.( + option, + multiple + ? getAllLeafNodes(updatedOptions) + .filter((n) => n.isChecked) + .map((n) => n.value) + : [value] + ) + }} + > + + + + {multiple && ( + + )} + {!!renderOption ? ( + renderOption({ label, value, disabled }) + ) : ( + + {label} + + )} + + + + {!isLeaf ? ( + { + e.stopPropagation() + + if (!isLoading && !children?.length) { + await loadChildren(option) + } + toggleExpand(option, true) + }} + > + + + ) : ( + <> + )} + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/SearchPanel.tsx b/packages/uikit/src/biz/Cascader/SearchPanel.tsx index 9f1c7bd31..9a5cbaa75 100644 --- a/packages/uikit/src/biz/Cascader/SearchPanel.tsx +++ b/packages/uikit/src/biz/Cascader/SearchPanel.tsx @@ -1,45 +1,17 @@ import { useMemo } from 'react' -import { Box, Checkbox, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' +import { Box, Checkbox, Group, Stack, Text, TreeNodeData, useMantineTheme } from '../../primitive/index.js' import { CascaderItemProps, DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from './CascaderPanel.js' -import { SelectOption, TreeSelectOption, useTreeContext } from './useTreeStore.js' -import { getAllLeafNodes } from './utils.js' +import { useTreeContext } from './useTree.js' interface SearchPanelProps extends Omit { currentValue: string[] - searchOptions?: SelectOption[] + searchData?: TreeNodeData[] } export const SearchPanel = (props: SearchPanelProps) => { - const { options } = useTreeContext() - const { searchOptions = [], optionProps, currentValue } = props - const displayedOptions = useMemo(() => { - const currentOptionMap = new Map() - const walk = (tree: TreeSelectOption[]) => { - tree.forEach((option) => { - currentOptionMap.set(option.value, option) - walk(option.children || []) - }) - } - - walk(options) - - return searchOptions.map((opt) => { - // value not in options(lazy load) - const currentValueChecked = currentValue.includes(opt.value) - - // leaf - const currentOpt = currentOptionMap.get(opt.value) - const currentChecked = currentOpt?.isChecked - - // all children checked - const allChildren = !!currentOpt && getAllLeafNodes([currentOpt]) - const allChildrenChecked = allChildren && !!allChildren.length && allChildren.every((n) => n.isChecked) - - return { ...opt, isChecked: currentValueChecked || currentChecked || allChildrenChecked } - }) - }, [currentValue, optionProps]) + const { searchData = [], optionProps } = props return ( { align="flex-start" sx={{ overflow: 'auto' }} > - {displayedOptions.map((option) => ( + {searchData.map((option) => ( ))} ) } -const SearchItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { +const SearchItem = ({ multiple, option, optionProps, onCheck }: CascaderItemProps) => { const { defaultRadius, colors } = useMantineTheme() const { wrapperProps, textProps } = optionProps || {} - const { toggleCheck } = useTreeContext() - const { label, value, disabled, isChecked, expanded } = option + const { toggleCheck, isNodeChecked, disabledState, expandedState } = useTreeContext() + const { label, value } = option + const disabled = disabledState[option.value] + const expanded = expandedState[option.value] + const isChecked = isNodeChecked(option.value) return ( @@ -77,8 +52,8 @@ const SearchItem = ({ multiple, option, optionProps, renderOption, onClick }: Ca if (disabled) { return } - const updatedOptions = toggleCheck?.(option) - onClick?.( + const updatedOptions = toggleCheck?.(option.value) + onCheck?.( option, multiple ? getAllLeafNodes(updatedOptions) diff --git a/packages/uikit/src/biz/Cascader/SearchPanel.tsx.bk b/packages/uikit/src/biz/Cascader/SearchPanel.tsx.bk new file mode 100644 index 000000000..c2b75531e --- /dev/null +++ b/packages/uikit/src/biz/Cascader/SearchPanel.tsx.bk @@ -0,0 +1,108 @@ +import { useMemo } from 'react' + +import { Box, Checkbox, Group, Stack, Text, useMantineTheme } from '../../primitive/index.js' + +import { CascaderItemProps, DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from './CascaderPanel.tsx.bk' +import { SelectOption, TreeSelectOption, useTreeContext } from './useTreeStore.js' +import { getAllLeafNodes } from './utils.js' + +interface SearchPanelProps extends Omit { + currentValue: string[] + searchOptions?: SelectOption[] +} + +export const SearchPanel = (props: SearchPanelProps) => { + const { options } = useTreeContext() + const { searchOptions = [], optionProps, currentValue } = props + const displayedOptions = useMemo(() => { + const currentOptionMap = new Map() + const walk = (tree: TreeSelectOption[]) => { + tree.forEach((option) => { + currentOptionMap.set(option.value, option) + walk(option.children || []) + }) + } + + walk(options) + + return searchOptions.map((opt) => { + // value not in options(lazy load) + const currentValueChecked = currentValue.includes(opt.value) + + // leaf + const currentOpt = currentOptionMap.get(opt.value) + const currentChecked = currentOpt?.isChecked + + // all children checked + const allChildren = !!currentOpt && getAllLeafNodes([currentOpt]) + const allChildrenChecked = allChildren && !!allChildren.length && allChildren.every((n) => n.isChecked) + + return { ...opt, isChecked: currentValueChecked || currentChecked || allChildrenChecked } + }) + }, [currentValue, optionProps]) + + return ( + + {displayedOptions.map((option) => ( + + ))} + + ) +} + +const SearchItem = ({ multiple, option, optionProps, renderOption, onClick }: CascaderItemProps) => { + const { defaultRadius, colors } = useMantineTheme() + const { wrapperProps, textProps } = optionProps || {} + const { toggleCheck } = useTreeContext() + const { label, value, disabled, isChecked, expanded } = option + + return ( + + { + if (disabled) { + return + } + const updatedOptions = toggleCheck?.(option) + onClick?.( + option, + multiple + ? getAllLeafNodes(updatedOptions) + .filter((n) => n.isChecked) + .map((n) => n.value) + : [value] + ) + }} + > + + + + {multiple && } + {!!renderOption ? ( + renderOption({ label, value, disabled }) + ) : ( + + {label} + + )} + + + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/index.tsx b/packages/uikit/src/biz/Cascader/index.tsx index 2c7070a5a..68eced123 100644 --- a/packages/uikit/src/biz/Cascader/index.tsx +++ b/packages/uikit/src/biz/Cascader/index.tsx @@ -19,24 +19,23 @@ import { TextProps, ComboboxSearchProps, useCombobox, - ActionIcon + ActionIcon, + TreeNodeData } from '../../primitive/index.js' import { CascaderPanel, DEFAULT_PANEL_HEIGHT } from './CascaderPanel.js' -import { SearchPanel } from './SearchPanel.js' -import { useCascader } from './useCascader.js' -import { SelectionProtectType, SelectOption, TreeProvider, TreeSelectOption, TreeStore } from './useTreeStore.js' -import { getAllLeafNodes } from './utils.js' +// import { SearchPanel } from './SearchPanel.js' +import { TreeProvider, useTreeStore, TreeStore } from './useTree.js' -export interface CascaderProps { - value: T[] +export interface CascaderProps { + value: string[] // target will be null when changeTrigger is onConfirm - onChange?: (target: TreeSelectOption | null, value: T[]) => void + onChange?: (target: string | null, value: string[]) => void // works when multiple is true changeTrigger?: 'onSelect' | 'onConfirm' - options?: SelectOption[] - store?: TreeStore + data: TreeNodeData[] + tree?: TreeStore // multi-selection or single-selection multiple?: boolean @@ -57,13 +56,11 @@ export interface CascaderProps { fixedGroup?: number optionGroupTitle?: (index: number) => ReactNode optionProps?: OptionProps - renderOption?: RenderOption // search searchable?: boolean searchProps?: ComboboxSearchProps & ElementProps<'input', 'onChange'> & { onChange?: (search: string) => void } - searchOptions?: SelectOption[] - renderSearchOption?: RenderOption + searchData?: TreeNodeData[] } export interface OptionProps { @@ -73,17 +70,13 @@ export interface OptionProps { panelWidth?: number } -export interface RenderOption { - (currentOption: TreeSelectOption): React.ReactNode -} - -export const Cascader = ({ +export const Cascader = ({ value, onChange, changeTrigger = 'onSelect', - options = [], - store, + data = [], + tree, multiple, emptyMessage, @@ -99,31 +92,29 @@ export const Cascader = ({ fixedGroup = 1, optionGroupTitle, optionProps, - renderOption, searchable, searchProps, - searchOptions, - renderSearchOption -}: CascaderProps) => { - let cascader = useCascader({ options }) - if (store) { - cascader = store + searchData +}: CascaderProps) => { + let controller = useTreeStore({ multiple, initialCheckedState: value }) + if (!!tree) { + controller = tree } - const { options: treeOptions, checkByValues, foldAll } = cascader + const { collapseAllNodes, getCheckedNodes } = controller - const resetCheckedStatusByValues = () => { - const isCheckAll = value.length === 0 && allWithEmpty - checkByValues(isCheckAll ? getAllLeafNodes(treeOptions).map((n) => n.value) : value) - } + // const resetCheckedStatusByValues = () => { + // const isCheckAll = value.length === 0 && allWithEmpty + // checkByValues(isCheckAll ? getAllLeafNodes(treeOptions).map((n) => n.value) : value) + // } const combobox = useCombobox({ onDropdownOpen: () => { combobox.focusSearchInput() - resetCheckedStatusByValues() + // resetCheckedStatusByValues() }, onDropdownClose: () => { - foldAll() + collapseAllNodes() } }) useImperativeHandle(comboboxRef, () => combobox, [combobox]) @@ -136,14 +127,20 @@ export const Cascader = ({ } useEffect(() => { - if (!multiple) { - return + if (!!data) { + controller.initialize(data) } - resetCheckedStatusByValues() - }, [value]) + }, [data]) + + // useEffect(() => { + // if (!multiple) { + // return + // } + // resetCheckedStatusByValues() + // }, [value]) return ( - + {target ? ( @@ -213,36 +210,39 @@ export const Cascader = ({ )} - {treeOptions.length ? ( + {data.length ? ( <> - {searchable && search && searchOptions?.length ? ( - []} - multiple={multiple} - optionProps={optionProps} - renderOption={renderSearchOption as RenderOption} - onClick={(target, newValue) => { - onChange?.(target as TreeSelectOption, newValue as T[]) - if (!multiple) { - combobox.closeDropdown() - } - }} - /> + {searchable && search && searchData?.length ? ( + <> ) : ( + // { + // onChange?.(target , newValue as T[]) + // if (!multiple) { + // combobox.closeDropdown() + // } + // }} + // /> } optionGroupTitle={optionGroupTitle} - onClick={(target, newValue) => { - if (changeTrigger === 'onSelect' || !multiple) { - onChange?.(target as TreeSelectOption, newValue as T[]) - } + onCheck={(target) => { if (!multiple) { + onChange?.(target.value, [target.value]) combobox.closeDropdown() + } else if (multiple && changeTrigger === 'onSelect') { + onChange?.( + target.value, + getCheckedNodes().map((n) => n.value) + ) } }} /> @@ -267,9 +267,7 @@ export const Cascader = ({ onClick={() => { onChange?.( null, - getAllLeafNodes(treeOptions) - .filter((n) => n.isChecked) - .map((n) => n.value) + getCheckedNodes().map((n) => n.value) ) combobox.closeDropdown() }} diff --git a/packages/uikit/src/biz/Cascader/index.tsx.bk b/packages/uikit/src/biz/Cascader/index.tsx.bk new file mode 100644 index 000000000..5793b3f77 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/index.tsx.bk @@ -0,0 +1,293 @@ +import { IconSearch } from '@tabler/icons-react' +import { ReactNode, Ref, useEffect, useImperativeHandle, useState } from 'react' + +import { IconChevronSelectorVertical, IconXCircle } from '../../icons/index.js' +import { + Box, + BoxProps, + Button, + Combobox, + ComboboxProps, + ComboboxStore, + Divider, + ElementProps, + Flex, + Group, + Input, + InputProps, + LoadingOverlay, + TextProps, + ComboboxSearchProps, + useCombobox, + ActionIcon +} from '../../primitive/index.js' + +import { CascaderPanel, DEFAULT_PANEL_HEIGHT } from './CascaderPanel.tsx.bk' +import { SearchPanel } from './SearchPanel.tsx.bk' +import { useCascader } from './useCascader.ts.bk' +import { SelectionProtectType, SelectOption, TreeProvider, TreeSelectOption, TreeStore } from './useTreeStore.tsx.bk' +import { getAllLeafNodes } from './utils.ts.bk' + +export interface CascaderProps { + value: T[] + // target will be null when changeTrigger is onConfirm + onChange?: (target: TreeSelectOption | null, value: T[]) => void + // works when multiple is true + changeTrigger?: 'onSelect' | 'onConfirm' + + options?: SelectOption[] + store?: TreeStore + + // multi-selection or single-selection + multiple?: boolean + emptyMessage?: string + // should the empty array be check all status + allWithEmpty?: boolean + loading?: boolean + + // combobox + comboboxProps?: Omit + comboboxRef?: Ref + + // target + target?: ReactNode + defaultTargetProps?: InputProps & ElementProps<'input'> + + // options + fixedGroup?: number + optionGroupTitle?: (index: number) => ReactNode + optionProps?: OptionProps + renderOption?: RenderOption + + // search + searchable?: boolean + searchProps?: ComboboxSearchProps & ElementProps<'input', 'onChange'> & { onChange?: (search: string) => void } + searchOptions?: SelectOption[] + renderSearchOption?: RenderOption +} + +export interface OptionProps { + wrapperProps?: BoxProps + textProps?: TextProps + panelHeight?: number + panelWidth?: number +} + +export interface RenderOption { + (currentOption: TreeSelectOption): React.ReactNode +} + +export const Cascader = ({ + value, + onChange, + changeTrigger = 'onSelect', + + options = [], + store, + + multiple, + emptyMessage, + allWithEmpty, + loading, + + comboboxProps, + comboboxRef, + + target, + defaultTargetProps, + + fixedGroup = 1, + optionGroupTitle, + optionProps, + renderOption, + + searchable, + searchProps, + searchOptions, + renderSearchOption +}: CascaderProps) => { + let cascader = useCascader({ options }) + if (store) { + cascader = store + } + const { options: treeOptions, checkByValues, foldAll } = cascader + + const resetCheckedStatusByValues = () => { + const isCheckAll = value.length === 0 && allWithEmpty + checkByValues(isCheckAll ? getAllLeafNodes(treeOptions).map((n) => n.value) : value) + } + + const combobox = useCombobox({ + onDropdownOpen: () => { + combobox.focusSearchInput() + resetCheckedStatusByValues() + }, + onDropdownClose: () => { + foldAll() + } + }) + useImperativeHandle(comboboxRef, () => combobox, [combobox]) + + const [search, setSearch] = useState('') + const { onChange: onSearchChange } = searchProps || {} + const onSearch = (search: string) => { + setSearch(search) + onSearchChange?.(search) + } + + useEffect(() => { + if (!multiple) { + return + } + resetCheckedStatusByValues() + }, [value]) + + return ( + + + + {target ? ( + target + ) : ( + } + onClick={() => { + combobox.toggleDropdown() + combobox.focusSearchInput() + }} + {...defaultTargetProps} + /> + )} + + + {searchable && ( + + { + onSearch(event.currentTarget.value) + }} + leftSection={} + rightSectionPointerEvents="all" + rightSection={ + !!search && ( + { + onSearch('') + combobox.focusSearchInput() + }} + > + + + ) + } + /> + + + )} + + + {treeOptions.length ? ( + <> + + {searchable && search && searchOptions?.length ? ( + []} + multiple={multiple} + optionProps={optionProps} + renderOption={renderSearchOption as RenderOption} + onClick={(target, newValue) => { + onChange?.(target as TreeSelectOption, newValue as T[]) + if (!multiple) { + combobox.closeDropdown() + } + }} + /> + ) : ( + } + optionGroupTitle={optionGroupTitle} + onClick={(target, newValue) => { + if (changeTrigger === 'onSelect' || !multiple) { + onChange?.(target as TreeSelectOption, newValue as T[]) + } + if (!multiple) { + combobox.closeDropdown() + } + }} + /> + )} + + {multiple && changeTrigger === 'onConfirm' && ( + <> + + + + + + + )} + + ) : ( + + {emptyMessage} + + )} + + + + + ) +} diff --git a/packages/uikit/src/biz/Cascader/useCascader.ts b/packages/uikit/src/biz/Cascader/useCascader.ts.bk similarity index 97% rename from packages/uikit/src/biz/Cascader/useCascader.ts rename to packages/uikit/src/biz/Cascader/useCascader.ts.bk index ef9c42ab1..981c027bc 100644 --- a/packages/uikit/src/biz/Cascader/useCascader.ts +++ b/packages/uikit/src/biz/Cascader/useCascader.ts.bk @@ -11,7 +11,7 @@ import type { TreeStoreConfig, UpdateChildren, ToggleCheck -} from './useTreeStore.js' +} from './useTreeStore.tsx.bk' import { flatArrayToTree, getAllLeafNodes } from './utils.js' export const useCascader = ({ @@ -60,7 +60,7 @@ export const useCascader = ({ undefined ) - setOptions(updatedOptions) + setOptions((prev) => ({ ...prev, ...updatedOptions })) onOptionChange?.({ type: 'check', target, newData }) return updatedOptions diff --git a/packages/uikit/src/biz/Cascader/useTree.tsx b/packages/uikit/src/biz/Cascader/useTree.tsx new file mode 100644 index 000000000..72872d383 --- /dev/null +++ b/packages/uikit/src/biz/Cascader/useTree.tsx @@ -0,0 +1,111 @@ +import { useState, useCallback, createContext, useContext, PropsWithChildren } from 'react' + +import { useTree as useTreePrimitive, UseTreeInput, UseTreeReturnType, TreeNodeData } from '../../primitive/index.js' + +export interface TreeStoreProps extends UseTreeInput { + loadNodesFn?: ( + parent: string, + updator: (tree: TreeNodeData[], newChildren: TreeNodeData[]) => TreeNodeData[] + ) => Promise +} + +export interface TreeStore extends UseTreeReturnType { + loadNodes: (parent: string) => Promise + loadingState: { [key: string]: boolean } + disabledState: { [key: string]: boolean } + setLoading: (parent: string, value: boolean) => void + setDisabled: (parent: string, value: boolean) => void + toggleLoading: (parent: string) => void + toggleDisabled: (parent: string) => void + toggleCheck: (value: string) => void +} + +export const useTreeStore = (props: TreeStoreProps): TreeStore => { + const { loadNodesFn, ...treeProps } = props + const treeMethods = useTreePrimitive(treeProps) + const { + initialize: _initialize, + checkNode, + uncheckNode, + isNodeChecked, + toggleExpanded: _toggleExpanded + } = treeMethods + + const [loadingState, setLoadingState] = useState<{ [key: string]: boolean }>({}) + const [disabledState, setDisabledState] = useState<{ [key: string]: boolean }>({}) + + const setLoading = useCallback((parent: string, value: boolean) => { + setLoadingState((prev) => ({ ...prev, [parent]: value })) + }, []) + + const setDisabled = useCallback((parent: string, value: boolean) => { + setDisabledState((prev) => ({ ...prev, [parent]: value })) + }, []) + + const toggleLoading = useCallback((parent: string) => { + setLoadingState((prev) => ({ ...prev, [parent]: !prev[parent] })) + }, []) + + const toggleDisabled = useCallback((parent: string) => { + setDisabledState((prev) => ({ ...prev, [parent]: !prev[parent] })) + }, []) + + const toggleCheck = (value: string) => { + if (isNodeChecked(value)) { + uncheckNode(value) + } else { + checkNode(value) + } + } + + const loadNodes = useCallback( + async (parent: string) => { + if (!loadNodesFn) { + return + } + + toggleLoading(parent) + + await loadNodesFn(parent, (tree: TreeNodeData[], newChildren: TreeNodeData[]) => { + const updateNodes = (nodeList: TreeNodeData[]): TreeNodeData[] => { + return nodeList.map((node) => { + if (node.value === parent) { + return { + ...node, + children: node.children ? [...node.children, ...newChildren] : newChildren + } + } + if (node.children) { + return { ...node, children: updateNodes(node.children) } + } + return node + }) + } + return updateNodes(tree) + }) + + toggleLoading(parent) + }, + [loadNodesFn] + ) + + return { + ...treeMethods, + loadingState, + disabledState, + loadNodes, + toggleCheck, + setLoading, + setDisabled, + toggleLoading, + toggleDisabled + } +} + +const TreeContext = createContext(null as any) + +export const TreeProvider = ({ value, children }: PropsWithChildren<{ value: TreeStore }>) => ( + {children} +) + +export const useTreeContext = () => useContext(TreeContext) diff --git a/packages/uikit/src/biz/Cascader/useTreeStore.tsx b/packages/uikit/src/biz/Cascader/useTreeStore.tsx.bk similarity index 100% rename from packages/uikit/src/biz/Cascader/useTreeStore.tsx rename to packages/uikit/src/biz/Cascader/useTreeStore.tsx.bk diff --git a/packages/uikit/src/biz/Cascader/utils.ts b/packages/uikit/src/biz/Cascader/utils.ts.bk similarity index 99% rename from packages/uikit/src/biz/Cascader/utils.ts rename to packages/uikit/src/biz/Cascader/utils.ts.bk index f1eccb08f..187134002 100644 --- a/packages/uikit/src/biz/Cascader/utils.ts +++ b/packages/uikit/src/biz/Cascader/utils.ts.bk @@ -1,4 +1,4 @@ -import type { SelectOption, TreeSelectOption } from './useTreeStore' +import type { SelectOption, TreeSelectOption } from './useTreeStore.tsx.bk' /** * Flatten all leaf nodes from a TreeSelectOption array. diff --git a/packages/uikit/src/biz/index.ts b/packages/uikit/src/biz/index.ts index 01c3eadd0..c504e3f59 100644 --- a/packages/uikit/src/biz/index.ts +++ b/packages/uikit/src/biz/index.ts @@ -14,6 +14,4 @@ export * from './TimeRangePicker/index.js' export * from './DateTimePicker/index.js' export * from './ProMultiSelect/index.js' export * from './Cascader/index.js' -export * from './Cascader/useCascader.js' -export * from './Cascader/utils.js' -export * from './Cascader/useTreeStore.js' +export * from './Cascader/useTree.js' diff --git a/packages/uikit/src/primitive/index.ts b/packages/uikit/src/primitive/index.ts index 2a7aa4742..b5a2c2945 100644 --- a/packages/uikit/src/primitive/index.ts +++ b/packages/uikit/src/primitive/index.ts @@ -242,6 +242,13 @@ export { Title, TypographyStylesProvider, + // Tree + useTree, + type UseTreeInput, + type UseTreeReturnType, + type TreeNodeData, + type CheckedNodeStatus, + // Misc Box, Collapse, diff --git a/stories/uikit/biz/Cascader.stories.tsx b/stories/uikit/biz/Cascader.stories.tsx index 401d153d5..6e42efb18 100644 --- a/stories/uikit/biz/Cascader.stories.tsx +++ b/stories/uikit/biz/Cascader.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' -import { Box } from '@tidbcloud/uikit' -import { Cascader, TreeSelectOption, treeToFlatArray, useCascader } from '@tidbcloud/uikit/biz' -import { useState } from 'react' +import { Box, TreeNodeData } from '@tidbcloud/uikit' +import { Cascader, useTreeStore } from '@tidbcloud/uikit/biz' +import { useMemo, useState } from 'react' type Story = StoryObj @@ -14,12 +14,11 @@ const meta: Meta = { export default meta -function getTreeData(): TreeSelectOption[] { +function getTreeData(): TreeNodeData[] { return [ { label: 'TiDB Serverless', value: 'TiDB Serverless', - isLeaf: false, children: [ // { // label: 'Row-based Storage', @@ -41,150 +40,179 @@ function getTreeData(): TreeSelectOption[] { { label: 'TiDB Dedicated', value: 'TiDB Dedicated', - isLeaf: false, children: [ { label: 'Node Compute', value: 'Node Compute', - isLeaf: false, children: [ { label: 'TiDB', value: 'TiDB Dedicated - Node Compute - TiDB', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'TiKV', value: 'TiDB Dedicated - Node Compute - TiKV', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'TiFlash', value: 'TiDB Dedicated - Node Compute - TiFlash', - isLeaf: true + nodeProps: { + isLeaf: true + } } ] }, { label: 'Node Storage', value: 'Node Storage', - isLeaf: false, children: [ { label: 'TiKV', value: 'TiDB Dedicated - Node Storage - TiKV', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'TiFlash', value: 'TiDB Dedicated - Node Storage - TiFlash', - isLeaf: true + nodeProps: { + isLeaf: true + } } ] }, { label: 'Backup', value: 'Backup', - isLeaf: false, children: [ { label: 'Single Region Storage', value: 'TiDB Dedicated - Backup - Single Region Storage', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'Dual Region Storage', value: 'TiDB Dedicated - Backup - Dual Region Storage', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'Replication', value: 'TiDB Dedicated - Backup - Replication', - isLeaf: true + nodeProps: { + isLeaf: true + } } ] }, { label: 'Data Migration', value: 'Data Migration', - isLeaf: false, children: [ { label: 'Replication Capacity Units (RCU)', value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)', - isLeaf: true + nodeProps: { + isLeaf: true + } } ] }, { label: 'Changefeed', value: 'Changefeed', - isLeaf: false, children: [ { label: 'Replication Capacity Units', value: 'TiDB Dedicated - Changefeed - Replication Capacity Units', - isLeaf: true + nodeProps: { + isLeaf: true + } } ] }, { label: 'Data Transfer', value: 'Data Transfer', - isLeaf: false, children: [ { label: 'Internet', value: 'TiDB Dedicated - Data Transfer - Internet', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'Cross Region', value: 'TiDB Dedicated - Data Transfer - Cross Region', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'Same Region', value: 'TiDB Dedicated - Data Transfer - Same Region', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'Load Balancing', value: 'TiDB Dedicated - Data Transfer - Load Balancing', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'DM NAT Gateway', value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'Private Data Link', value: 'TiDB Dedicated - Data Transfer - Private Data Link', - isLeaf: true + nodeProps: { + isLeaf: true + } } ] }, { label: 'Recovery Group', value: 'Recovery Group', - disabled: true, - isLeaf: false, children: [ { label: 'Recovery Group Service', value: 'TiDB Dedicated - Recovery Group - Recovery Group Service', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'Same Region Data Processing', value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing', - isLeaf: true + nodeProps: { + isLeaf: true + } }, { label: 'Cross Region Data Processing', value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing', - isLeaf: true + nodeProps: { + isLeaf: true + } } ] } @@ -193,23 +221,62 @@ function getTreeData(): TreeSelectOption[] { { label: 'Support Plan', value: 'Support Plan', - isLeaf: true + nodeProps: { + isLeaf: true + } } ] } const TITLES = ['Group 1', 'Group 2', 'Group 3'] function MultipleDemo() { - const cascader = useCascader({ - options: treeToFlatArray(getTreeData()), - onLoadChildren: (target) => { - cascader.toggleLoading(target) - } + const cascader = useTreeStore({ + // loadNodesFn: (target, updator) => { + // // cascader.toggleLoading(target) + // // cascader.updateChildren(target, [ + // // { + // // label: 'Row-based Storage', + // // value: 'TiDB Serverless - Row-based Storage', + // // isLeaf: true + // // }, + // // { + // // label: 'Columnar Storage', + // // value: 'TiDB Serverless - Columnar Storage', + // // isLeaf: true + // // }, + // // { + // // label: 'Request Units', + // // value: 'TiDB Serverless - Request Units', + // // isLeaf: true + // // } + // // ]) + // const d = Promise.resolve([ + // { + // label: 'Row-based Storage', + // value: 'TiDB Serverless - Row-based Storage', + // isLeaf: true + // }, + // { + // label: 'Columnar Storage', + // value: 'TiDB Serverless - Columnar Storage', + // isLeaf: true + // }, + // { + // label: 'Request Units', + // value: 'TiDB Serverless - Request Units', + // isLeaf: true + // } + // ]) + // updator(d) + // return d + // } }) const [value, setValue] = useState([]) + const data = useMemo(() => getTreeData(), []) return ( { @@ -220,7 +287,7 @@ function MultipleDemo() { fixedGroup={2} multiple searchable - searchOptions={treeToFlatArray(getTreeData())} + // searchOptions={treeToFlatArray(getTreeData())} allWithEmpty changeTrigger="onConfirm" optionGroupTitle={(index) => ( From 7d384269581ebc57a66a2d503eae71b75da08c03 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Wed, 5 Mar 2025 17:33:54 +0800 Subject: [PATCH 06/10] chore: update story --- stories/uikit/biz/Cascader.stories.tsx | 69 +++++++++++--------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/stories/uikit/biz/Cascader.stories.tsx b/stories/uikit/biz/Cascader.stories.tsx index 6e42efb18..7f4201ff1 100644 --- a/stories/uikit/biz/Cascader.stories.tsx +++ b/stories/uikit/biz/Cascader.stories.tsx @@ -230,49 +230,37 @@ function getTreeData(): TreeNodeData[] { const TITLES = ['Group 1', 'Group 2', 'Group 3'] function MultipleDemo() { + const [data, setData] = useState(() => getTreeData()) const cascader = useTreeStore({ - // loadNodesFn: (target, updator) => { - // // cascader.toggleLoading(target) - // // cascader.updateChildren(target, [ - // // { - // // label: 'Row-based Storage', - // // value: 'TiDB Serverless - Row-based Storage', - // // isLeaf: true - // // }, - // // { - // // label: 'Columnar Storage', - // // value: 'TiDB Serverless - Columnar Storage', - // // isLeaf: true - // // }, - // // { - // // label: 'Request Units', - // // value: 'TiDB Serverless - Request Units', - // // isLeaf: true - // // } - // // ]) - // const d = Promise.resolve([ - // { - // label: 'Row-based Storage', - // value: 'TiDB Serverless - Row-based Storage', - // isLeaf: true - // }, - // { - // label: 'Columnar Storage', - // value: 'TiDB Serverless - Columnar Storage', - // isLeaf: true - // }, - // { - // label: 'Request Units', - // value: 'TiDB Serverless - Request Units', - // isLeaf: true - // } - // ]) - // updator(d) - // return d - // } + loadNodesFn: async (target, updator) => { + const dp = Promise.resolve([ + { + label: 'Row-based Storage', + value: 'TiDB Serverless - Row-based Storage', + nodeProps: { + isLeaf: true + } + }, + { + label: 'Columnar Storage', + value: 'TiDB Serverless - Columnar Storage', + nodeProps: { + isLeaf: true + } + }, + { + label: 'Request Units', + value: 'TiDB Serverless - Request Units', + nodeProps: { + isLeaf: true + } + } + ]) + const d = await dp + setData(updator(data, d)) + } }) const [value, setValue] = useState([]) - const data = useMemo(() => getTreeData(), []) return ( )} - // loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} /> ) } From 2cb2d6de363860c9bbaae2a49d740669c885e2a3 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 6 Mar 2025 10:30:23 +0800 Subject: [PATCH 07/10] tweak: parent expand --- .../uikit/src/biz/Cascader/CascaderPanel.tsx | 6 +- stories/uikit/biz/Cascader.stories.tsx | 136 ++++-------------- 2 files changed, 29 insertions(+), 113 deletions(-) diff --git a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx index 94ac5d8e6..eb042e650 100644 --- a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx +++ b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx @@ -99,7 +99,7 @@ const CascaderItem = ({ multiple, option, siblings, optionProps, onCheck }: Casc collapse } = useTreeContext() const { label, value, children, nodeProps } = option - const isLeaf = nodeProps?.isLeaf + const isParent = nodeProps?.isParent const isLoading = loadingState[value] const disabled = disabledState[value] const expanded = expandedState[value] @@ -136,7 +136,7 @@ const CascaderItem = ({ multiple, option, siblings, optionProps, onCheck }: Casc - {!isLeaf ? ( + {(isParent || children?.length) && ( - ) : ( - <> )} diff --git a/stories/uikit/biz/Cascader.stories.tsx b/stories/uikit/biz/Cascader.stories.tsx index 7f4201ff1..55b9e9370 100644 --- a/stories/uikit/biz/Cascader.stories.tsx +++ b/stories/uikit/biz/Cascader.stories.tsx @@ -19,23 +19,10 @@ function getTreeData(): TreeNodeData[] { { label: 'TiDB Serverless', value: 'TiDB Serverless', - children: [ - // { - // label: 'Row-based Storage', - // value: 'TiDB Serverless - Row-based Storage', - // isLeaf: true - // }, - // { - // label: 'Columnar Storage', - // value: 'TiDB Serverless - Columnar Storage', - // isLeaf: true - // }, - // { - // label: 'Request Units', - // value: 'TiDB Serverless - Request Units', - // isLeaf: true - // } - ] + nodeProps: { + isParent: true + }, + children: [] }, { label: 'TiDB Dedicated', @@ -47,24 +34,15 @@ function getTreeData(): TreeNodeData[] { children: [ { label: 'TiDB', - value: 'TiDB Dedicated - Node Compute - TiDB', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Node Compute - TiDB' }, { label: 'TiKV', - value: 'TiDB Dedicated - Node Compute - TiKV', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Node Compute - TiKV' }, { label: 'TiFlash', - value: 'TiDB Dedicated - Node Compute - TiFlash', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Node Compute - TiFlash' } ] }, @@ -74,17 +52,11 @@ function getTreeData(): TreeNodeData[] { children: [ { label: 'TiKV', - value: 'TiDB Dedicated - Node Storage - TiKV', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Node Storage - TiKV' }, { label: 'TiFlash', - value: 'TiDB Dedicated - Node Storage - TiFlash', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Node Storage - TiFlash' } ] }, @@ -94,24 +66,15 @@ function getTreeData(): TreeNodeData[] { children: [ { label: 'Single Region Storage', - value: 'TiDB Dedicated - Backup - Single Region Storage', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Backup - Single Region Storage' }, { label: 'Dual Region Storage', - value: 'TiDB Dedicated - Backup - Dual Region Storage', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Backup - Dual Region Storage' }, { label: 'Replication', - value: 'TiDB Dedicated - Backup - Replication', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Backup - Replication' } ] }, @@ -121,10 +84,7 @@ function getTreeData(): TreeNodeData[] { children: [ { label: 'Replication Capacity Units (RCU)', - value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)' } ] }, @@ -134,10 +94,7 @@ function getTreeData(): TreeNodeData[] { children: [ { label: 'Replication Capacity Units', - value: 'TiDB Dedicated - Changefeed - Replication Capacity Units', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Changefeed - Replication Capacity Units' } ] }, @@ -147,45 +104,27 @@ function getTreeData(): TreeNodeData[] { children: [ { label: 'Internet', - value: 'TiDB Dedicated - Data Transfer - Internet', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Data Transfer - Internet' }, { label: 'Cross Region', - value: 'TiDB Dedicated - Data Transfer - Cross Region', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Data Transfer - Cross Region' }, { label: 'Same Region', - value: 'TiDB Dedicated - Data Transfer - Same Region', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Data Transfer - Same Region' }, { label: 'Load Balancing', - value: 'TiDB Dedicated - Data Transfer - Load Balancing', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Data Transfer - Load Balancing' }, { label: 'DM NAT Gateway', - value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway' }, { label: 'Private Data Link', - value: 'TiDB Dedicated - Data Transfer - Private Data Link', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Data Transfer - Private Data Link' } ] }, @@ -195,24 +134,15 @@ function getTreeData(): TreeNodeData[] { children: [ { label: 'Recovery Group Service', - value: 'TiDB Dedicated - Recovery Group - Recovery Group Service', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Recovery Group - Recovery Group Service' }, { label: 'Same Region Data Processing', - value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing' }, { label: 'Cross Region Data Processing', - value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing', - nodeProps: { - isLeaf: true - } + value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing' } ] } @@ -220,10 +150,7 @@ function getTreeData(): TreeNodeData[] { }, { label: 'Support Plan', - value: 'Support Plan', - nodeProps: { - isLeaf: true - } + value: 'Support Plan' } ] } @@ -236,24 +163,15 @@ function MultipleDemo() { const dp = Promise.resolve([ { label: 'Row-based Storage', - value: 'TiDB Serverless - Row-based Storage', - nodeProps: { - isLeaf: true - } + value: 'TiDB Serverless - Row-based Storage' }, { label: 'Columnar Storage', - value: 'TiDB Serverless - Columnar Storage', - nodeProps: { - isLeaf: true - } + value: 'TiDB Serverless - Columnar Storage' }, { label: 'Request Units', - value: 'TiDB Serverless - Request Units', - nodeProps: { - isLeaf: true - } + value: 'TiDB Serverless - Request Units' } ]) const d = await dp From 04f82aff2ac4d619c2b94d8f41223e632767ecce Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 6 Mar 2025 10:35:51 +0800 Subject: [PATCH 08/10] tweak: collapse --- packages/uikit/src/biz/Cascader/CascaderPanel.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx index eb042e650..48e6f249c 100644 --- a/packages/uikit/src/biz/Cascader/CascaderPanel.tsx +++ b/packages/uikit/src/biz/Cascader/CascaderPanel.tsx @@ -106,6 +106,15 @@ const CascaderItem = ({ multiple, option, siblings, optionProps, onCheck }: Casc const isChecked = isNodeChecked(value) const isIndeterminate = isNodeIndeterminate(value) + const collapseSiblings = (nodes: TreeNodeData[]) => { + nodes.forEach((node) => { + if (node.value !== value && expandedState[node.value]) { + collapse(node.value) + collapseSiblings(node.children || []) + } + }) + } + return ( { - if (sibling.value !== value) { - collapse(sibling.value) - } - }) + collapseSiblings(siblings) toggleExpanded(value) }} > From 4063bf90989dd97e1ef917ba6eb694a1ef6663a3 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Thu, 6 Mar 2025 14:36:59 +0800 Subject: [PATCH 09/10] chore: rename --- packages/uikit/src/biz/Cascader/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uikit/src/biz/Cascader/index.tsx b/packages/uikit/src/biz/Cascader/index.tsx index 68eced123..8f397da41 100644 --- a/packages/uikit/src/biz/Cascader/index.tsx +++ b/packages/uikit/src/biz/Cascader/index.tsx @@ -35,7 +35,7 @@ export interface CascaderProps { changeTrigger?: 'onSelect' | 'onConfirm' data: TreeNodeData[] - tree?: TreeStore + treeStore?: TreeStore // multi-selection or single-selection multiple?: boolean @@ -76,7 +76,7 @@ export const Cascader = ({ changeTrigger = 'onSelect', data = [], - tree, + treeStore, multiple, emptyMessage, @@ -98,8 +98,8 @@ export const Cascader = ({ searchData }: CascaderProps) => { let controller = useTreeStore({ multiple, initialCheckedState: value }) - if (!!tree) { - controller = tree + if (!!treeStore) { + controller = treeStore } const { collapseAllNodes, getCheckedNodes } = controller From 379133e7afaa99d91a1ed26d06417f80a77b8902 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Fri, 7 Mar 2025 13:32:14 +0800 Subject: [PATCH 10/10] chore: update story --- stories/uikit/biz/Cascader.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stories/uikit/biz/Cascader.stories.tsx b/stories/uikit/biz/Cascader.stories.tsx index 55b9e9370..69d6423bc 100644 --- a/stories/uikit/biz/Cascader.stories.tsx +++ b/stories/uikit/biz/Cascader.stories.tsx @@ -158,7 +158,7 @@ function getTreeData(): TreeNodeData[] { const TITLES = ['Group 1', 'Group 2', 'Group 3'] function MultipleDemo() { const [data, setData] = useState(() => getTreeData()) - const cascader = useTreeStore({ + const treeStore = useTreeStore({ loadNodesFn: async (target, updator) => { const dp = Promise.resolve([ { @@ -181,7 +181,7 @@ function MultipleDemo() { const [value, setValue] = useState([]) return (