Skip to content

Commit 2e85fa6

Browse files
committed
feat(cascader): add panelHeader/panelFooter slots with cascade filter support
1 parent 63a8991 commit 2e85fa6

File tree

5 files changed

+191
-51
lines changed

5 files changed

+191
-51
lines changed
Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
<template>
2-
<t-cascader v-model="value" :options="options" clearable @change="onChange" @focus="onFocus" @blur="onBlur" />
2+
<t-space direction="vertical" style="width: 100%">
3+
<!-- 基础搜索:每个层级独立过滤 -->
4+
<t-cascader v-model="value" :options="options">
5+
<template #panelHeader="{ panelIndex, onFilter }">
6+
<t-input
7+
v-model="searchValues1[panelIndex]"
8+
:placeholder="`搜索第${panelIndex + 1}级`"
9+
@change="(val) => onFilter(val)"
10+
/>
11+
</template>
12+
</t-cascader>
13+
14+
<!-- 级联搜索:搜索某级后,后续级别只显示匹配项的子节点 -->
15+
<t-cascader v-model="value2" :options="options">
16+
<template #panelHeader="{ panelIndex, onFilter }">
17+
<t-input
18+
v-model="searchValues2[panelIndex]"
19+
:placeholder="`搜索第${panelIndex + 1}级(级联)`"
20+
@change="(val) => onFilter(val, { cascade: true })"
21+
/>
22+
</template>
23+
</t-cascader>
24+
</t-space>
325
</template>
426

527
<script setup>
6-
import { ref } from 'vue';
28+
import { ref, reactive } from 'vue';
729
830
const options = [
931
{
@@ -13,10 +35,15 @@ const options = [
1335
{
1436
label: '子选项一',
1537
value: '1.1',
38+
children: [
39+
{ label: '三级-1', value: '1.1.1' },
40+
{ label: '三级-2', value: '1.1.2' },
41+
],
1642
},
1743
{
1844
label: '子选项二',
1945
value: '1.2',
46+
children: [{ label: '三级-3', value: '1.2.1' }],
2047
},
2148
{
2249
label: '子选项三',
@@ -31,26 +58,30 @@ const options = [
3158
{
3259
label: '子选项一',
3360
value: '2.1',
61+
children: [{ label: '三级-4', value: '2.1.1' }],
3462
},
3563
{
3664
label: '子选项二',
3765
value: '2.2',
3866
},
3967
],
4068
},
69+
{
70+
label: '选项三',
71+
value: '3',
72+
children: [
73+
{
74+
label: '子选项一',
75+
value: '3.1',
76+
},
77+
],
78+
},
4179
];
4280
43-
const value = ref('1.1');
44-
45-
const onChange = (val, context) => {
46-
console.log(val, context);
47-
};
48-
49-
const onFocus = (ctx) => {
50-
console.log('focus', ctx);
51-
};
81+
const value = ref('1.1.1');
82+
const value2 = ref('1.1.1');
5283
53-
const onBlur = (ctx) => {
54-
console.log('blur', ctx);
55-
};
84+
// 使用 reactive 维护搜索状态
85+
const searchValues1 = reactive({});
86+
const searchValues2 = reactive({});
5687
</script>

packages/components/cascader/cascader.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,12 @@ export default defineComponent({
190190
loading={props.loading}
191191
loadingText={props.loadingText}
192192
cascaderContext={cascaderContext.value}
193-
v-slots={{ option: slots.option, empty: slots.empty, loadingText: slots.loadingText }}
193+
v-slots={{
194+
option: slots.option,
195+
empty: slots.empty,
196+
loadingText: slots.loadingText,
197+
panelHeader: slots.panelHeader,
198+
}}
194199
/>
195200
{renderTNodeJSX('panelBottomContent')}
196201
</>

packages/components/cascader/components/Panel.tsx

Lines changed: 112 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
import { defineComponent, PropType, computed, h } from 'vue';
1+
import { defineComponent, PropType, computed, h, shallowRef } from 'vue';
22

33
import Item from './Item';
44
import { TreeNode, CascaderContextType } from '../types';
55
import CascaderProps from '../props';
6-
import { useConfig, usePrefixClass, useTNodeDefault } from '@tdesign/shared-hooks';
6+
import { useConfig, usePrefixClass, useTNodeDefault, useTNodeJSX } from '@tdesign/shared-hooks';
77

88
import { getDefaultNode } from '@tdesign/shared-utils';
99
import { getPanels, expandClickEffect, valueChangeEffect } from '../utils';
1010

11+
interface FilterState {
12+
filters: Record<number, string>;
13+
cascade: boolean;
14+
maxLevel: number;
15+
}
16+
1117
export default defineComponent({
1218
name: 'TCascaderSubPanel',
1319
props: {
@@ -22,17 +28,76 @@ export default defineComponent({
2228
type: Object as PropType<CascaderContextType>,
2329
},
2430
},
25-
2631
setup(props) {
2732
const renderTNodeJSXDefault = useTNodeDefault();
33+
const renderTNodeJSX = useTNodeJSX();
2834
const COMPONENT_NAME = usePrefixClass('cascader');
2935
const { globalConfig } = useConfig('cascader');
3036

3137
const panels = computed(() => getPanels(props.cascaderContext.treeNodes));
3238

33-
const handleExpand = (node: TreeNode, trigger: 'hover' | 'click') => {
34-
const { trigger: propsTrigger, cascaderContext } = props;
35-
expandClickEffect(propsTrigger, trigger, node, cascaderContext);
39+
// 过滤状态 - 惰性初始化,不使用时为 null,无性能开销
40+
const filterState = shallowRef<FilterState | null>(null);
41+
42+
// 处理过滤
43+
const handleFilter = (index: number, keyword: string, options?: { cascade?: boolean }) => {
44+
const prev = filterState.value;
45+
const cascade = options?.cascade ?? prev?.cascade ?? false;
46+
const filters = { ...prev?.filters, [index]: keyword };
47+
48+
let maxLevel = prev?.maxLevel ?? -1;
49+
if (cascade) {
50+
// 有新搜索词时重置 maxLevel;全部清空时重置为 -1
51+
if (keyword?.trim()) {
52+
maxLevel = index;
53+
} else if (!Object.values(filters).some((f) => f?.trim())) {
54+
maxLevel = -1;
55+
}
56+
}
57+
58+
filterState.value = { filters, cascade, maxLevel };
59+
};
60+
61+
// 获取过滤后的节点 - 直接应用搜索词,确保父级变化后子级搜索仍生效
62+
const getFilteredNodes = (nodes: TreeNode[], index: number): TreeNode[] => {
63+
const state = filterState.value;
64+
if (!state) return nodes;
65+
66+
// 直接应用当前层级的搜索词(无论级联模式还是基础模式)
67+
const keyword = state.filters[index]?.trim().toLowerCase();
68+
if (!keyword) return nodes;
69+
70+
return nodes.filter((n) => n.label?.toLowerCase().includes(keyword));
71+
};
72+
73+
// 判断面板是否应该显示
74+
const shouldShowPanel = (index: number): boolean => {
75+
const state = filterState.value;
76+
if (!state?.cascade || state.maxLevel < 0) return true;
77+
// 只显示 maxLevel 范围内的面板
78+
return index <= state.maxLevel;
79+
};
80+
81+
// 处理节点展开
82+
const handleExpand = (node: TreeNode, trigger: 'hover' | 'click', level: number) => {
83+
const state = filterState.value;
84+
85+
// 级联模式下更新 maxLevel 以显示子级面板
86+
const { children } = node;
87+
if (
88+
state?.cascade &&
89+
state.maxLevel >= 0 &&
90+
props.trigger === trigger &&
91+
Array.isArray(children) &&
92+
children.length
93+
) {
94+
const childLevel = level + 1;
95+
if (childLevel > state.maxLevel) {
96+
filterState.value = { ...state, maxLevel: childLevel };
97+
}
98+
}
99+
100+
expandClickEffect(props.trigger, trigger, node, props.cascaderContext);
36101
};
37102

38103
const renderItem = (node: TreeNode, index: number) => {
@@ -42,7 +107,7 @@ export default defineComponent({
42107
params: {
43108
item: node.data,
44109
index,
45-
onExpand: () => handleExpand(node, 'click'),
110+
onExpand: () => handleExpand(node, 'click', index),
46111
onChange: () => valueChangeEffect(node, props.cascaderContext),
47112
},
48113
});
@@ -52,42 +117,53 @@ export default defineComponent({
52117
node={node}
53118
optionChild={optionChild}
54119
cascaderContext={props.cascaderContext}
55-
onClick={() => {
56-
handleExpand(node, 'click');
57-
}}
58-
onMouseenter={() => {
59-
handleExpand(node, 'hover');
60-
}}
61-
onChange={() => {
62-
valueChangeEffect(node, props.cascaderContext);
63-
}}
120+
onClick={() => handleExpand(node, 'click', index)}
121+
onMouseenter={() => handleExpand(node, 'hover', index)}
122+
onChange={() => valueChangeEffect(node, props.cascaderContext)}
64123
/>
65124
);
66125
};
67126

68-
const renderList = (treeNodes: TreeNode[], isFilter = false, segment = true, index = 1) => (
69-
<ul
70-
class={[
71-
`${COMPONENT_NAME.value}__menu`,
72-
'narrow-scrollbar',
73-
{
74-
[`${COMPONENT_NAME.value}__menu--segment`]: segment,
75-
[`${COMPONENT_NAME.value}__menu--filter`]: isFilter,
76-
},
77-
]}
78-
key={`${COMPONENT_NAME}__menu${index}`}
79-
>
80-
{treeNodes.map((node: TreeNode) => renderItem(node, index))}
81-
</ul>
82-
);
127+
const renderList = (treeNodes: TreeNode[], isFilter = false, segment = true, index = 0) => {
128+
const displayNodes = filterState.value ? getFilteredNodes(treeNodes, index) : treeNodes;
129+
130+
return (
131+
<ul
132+
class={[
133+
`${COMPONENT_NAME.value}__menu`,
134+
'narrow-scrollbar',
135+
{
136+
[`${COMPONENT_NAME.value}__menu--segment`]: segment,
137+
[`${COMPONENT_NAME.value}__menu--filter`]: isFilter,
138+
},
139+
]}
140+
key={`${COMPONENT_NAME}__menu${index}`}
141+
>
142+
{renderTNodeJSX('panelHeader', {
143+
params: {
144+
panelIndex: index,
145+
options: treeNodes,
146+
onFilter: (filter: string, opts?: { cascade?: boolean }) => handleFilter(index, filter, opts),
147+
},
148+
})}
149+
{displayNodes.map((node: TreeNode) => renderItem(node, index))}
150+
{renderTNodeJSX('panelFooter', { params: { panelIndex: index } })}
151+
</ul>
152+
);
153+
};
83154

84155
const renderPanels = () => {
85156
const { inputVal, treeNodes } = props.cascaderContext;
86-
return inputVal
87-
? renderList(treeNodes, true)
88-
: panels.value.map((treeNodes, index: number) =>
89-
renderList(treeNodes, false, index !== panels.value.length - 1, index),
90-
);
157+
if (inputVal) return renderList(treeNodes, true);
158+
159+
const result = [];
160+
const len = panels.value.length;
161+
for (let i = 0; i < len; i++) {
162+
if (shouldShowPanel(i)) {
163+
result.push(renderList(panels.value[i], false, i !== len - 1, i));
164+
}
165+
}
166+
return result;
91167
};
92168

93169
return () => {

packages/components/cascader/props.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ export default {
9595
panelTopContent: {
9696
type: [String, Function] as PropType<TdCascaderProps['panelTopContent']>,
9797
},
98+
/** 面板内的标题 */
99+
panelHeader: {
100+
type: Function as PropType<TdCascaderProps['panelHeader']>,
101+
},
102+
/** 面板内的页脚 */
103+
panelFooter: {
104+
type: Function as PropType<TdCascaderProps['panelFooter']>,
105+
},
98106
/** 占位符 */
99107
placeholder: {
100108
type: String,

packages/components/cascader/type.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,26 @@ export interface TdCascaderProps<CascaderOption extends TreeOptionData = TreeOpt
126126
* 面板内的顶部内容
127127
*/
128128
panelTopContent?: string | TNode;
129+
/**
130+
* 面板内的标题
131+
*/
132+
panelHeader?: TNode<{
133+
panelIndex: number;
134+
options: CascaderOption[];
135+
/**
136+
* 过滤当前面板选项的回调函数
137+
* @param filter - 过滤条件,可以是字符串或自定义过滤函数
138+
* @param options - 配置项,cascade 为 true 时启用级联过滤(搜索某级后,后续级别只显示匹配项的子节点)
139+
*/
140+
onFilter: (
141+
filter: string | ((node: CascaderOption, panelIndex: number) => boolean),
142+
options?: { cascade?: boolean },
143+
) => void;
144+
}>;
145+
/**
146+
* 面板内的页脚
147+
*/
148+
panelFooter?: TNode<{ panelIndex: number }>;
129149
/**
130150
* 占位符
131151
*/

0 commit comments

Comments
 (0)