From 1e0b595f11ea3898dd374a2eca2720e96cd237d1 Mon Sep 17 00:00:00 2001 From: cikenerd Date: Mon, 18 May 2026 17:23:50 +0800 Subject: [PATCH 1/3] feat(Cascader): add filterable search support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 props:filterable / filter / filter-placeholder,开启后顶部展示 t-search 搜索框 - 默认匹配规则参考 ant-design:路径中所有 label 拼接 + 叶子节点 text,做大小写不敏感 includes 匹配 - 命中字用高亮样式(沿用 --td-cascader-active-color,新增 7 个相关 CSS 变量) - 选中扁平结果保留完整路径,自动更新 step/tab 视图后触发 change - 新增 filterable demo、API 文档、6 个单测、8 个语言包补全 filterPlaceholder/empty 文案 --- packages/components/cascader/README.en-US.md | 11 + packages/components/cascader/README.md | 15 ++ .../cascader/__test__/index.test.js | 112 +++++++++ .../cascader/_example/cascader.json | 3 +- .../cascader/_example/cascader.wxml | 4 + .../cascader/_example/filterable/index.js | 108 +++++++++ .../cascader/_example/filterable/index.json | 7 + .../cascader/_example/filterable/index.wxml | 12 + .../cascader/_example/filterable/index.wxss | 0 packages/components/cascader/cascader.json | 3 +- packages/components/cascader/cascader.less | 48 ++++ packages/components/cascader/cascader.ts | 224 +++++++++++++++++- packages/components/cascader/cascader.wxml | 131 ++++++---- packages/components/cascader/props.ts | 15 ++ packages/components/cascader/type.ts | 29 +++ .../__test__/__snapshots__/demo.test.js.snap | 4 + packages/components/locale/ar_KW.ts | 2 + packages/components/locale/en_US.ts | 2 + packages/components/locale/it_IT.ts | 2 + packages/components/locale/ja_JP.ts | 2 + packages/components/locale/ko_KR.ts | 2 + packages/components/locale/ru_RU.ts | 2 + packages/components/locale/zh_CN.ts | 2 + packages/components/locale/zh_TW.ts | 2 + 24 files changed, 687 insertions(+), 55 deletions(-) create mode 100644 packages/components/cascader/_example/filterable/index.js create mode 100644 packages/components/cascader/_example/filterable/index.json create mode 100644 packages/components/cascader/_example/filterable/index.wxml create mode 100644 packages/components/cascader/_example/filterable/index.wxss diff --git a/packages/components/cascader/README.en-US.md b/packages/components/cascader/README.en-US.md index f1e08cda84..a4b502822b 100644 --- a/packages/components/cascader/README.en-US.md +++ b/packages/components/cascader/README.en-US.md @@ -10,6 +10,9 @@ style | Object | - | CSS(Cascading Style Sheets) | N custom-style | Object | - | CSS(Cascading Style Sheets),used to set style on virtual component | N check-strictly | Boolean | false | \- | N close-btn | Boolean | true | \- | N +filterable | Boolean | false | Enable searching. When on, a search box is shown at the top; typing switches the panel to a flat list of matched paths | N +filter | Function | - | Custom filter function. Typescript: `(keyword: string, option: CascaderOption, path: CascaderOption[]) => boolean`. Falls back to a case-insensitive built-in matcher on label/text | N +filter-placeholder | String | - | Placeholder text of the search box, falls back to global locale | N keys | Object | - | Typescript: `CascaderKeysType` `type CascaderKeysType = TreeKeysType`。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/common/common.ts)。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/cascader/type.ts) | N options | Array | [] | Typescript: `Array` | N placeholder | String | - | \- | N @@ -47,6 +50,14 @@ Name | Default Value | Description --td-cascader-border-color | @component-stroke | - --td-cascader-content-height | 78vh | - --td-cascader-disabled-color | @text-color-disabled | - +--td-cascader-filter-empty-color | @text-color-placeholder | - +--td-cascader-filter-empty-padding | 96rpx @spacer-2 | - +--td-cascader-filter-item-color | @text-color-primary | - +--td-cascader-filter-item-disabled-color | @text-color-disabled | - +--td-cascader-filter-item-hover-bg | @bg-color-secondarycontainer | - +--td-cascader-filter-item-padding | 24rpx 32rpx | - +--td-cascader-filter-keyword-color | @brand-color | - +--td-cascader-filter-padding | 0 @spacer-2 @spacer-1 | - --td-cascader-options-height | calc(100% - @cascader-step-height) | - --td-cascader-options-title-color | @text-color-placeholder | - --td-cascader-step-arrow-color | @text-color-placeholder | - diff --git a/packages/components/cascader/README.md b/packages/components/cascader/README.md index d27a78a62c..eb6ee3a8c8 100644 --- a/packages/components/cascader/README.md +++ b/packages/components/cascader/README.md @@ -57,6 +57,10 @@ isComponent: true {{ check-strictly }} +#### 支持搜索 + +{{ filterable }} + ## API ### Cascader Props @@ -67,6 +71,9 @@ style | Object | - | 样式 | N custom-style | Object | - | 样式,一般用于开启虚拟化组件节点场景 | N check-strictly | Boolean | false | 父子节点选中状态不再关联,可各自选中或取消 | N close-btn | Boolean | true | 关闭按钮 | N +filterable | Boolean | false | 是否可搜索。开启后顶部展示搜索框,输入关键字将层级面板切换为扁平的匹配路径列表 | N +filter | Function | - | 自定义过滤函数,返回 true 表示匹配。TS 类型:`(keyword: string, option: CascaderOption, path: CascaderOption[]) => boolean`。缺省时使用大小写不敏感的内置匹配规则(命中路径中任一 label 或叶子节点 text) | N +filter-placeholder | String | - | 搜索框占位文案,缺省回退到全局语言包 | N keys | Object | - | 用来定义 value / label / children / disabled 在 `options` 中对应的字段别名。TS 类型:`CascaderKeysType` `type CascaderKeysType = TreeKeysType`。[通用类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/common/common.ts)。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/cascader/type.ts) | N options | Array | [] | 可选项数据源。TS 类型:`Array` | N placeholder | String | - | 未选中时的提示文案。组件内置默认值为:'选择选项' | N @@ -104,6 +111,14 @@ title | 自定义 `title` 显示内容 --td-cascader-border-color | @component-stroke | - --td-cascader-content-height | 78vh | - --td-cascader-disabled-color | @text-color-disabled | - +--td-cascader-filter-empty-color | @text-color-placeholder | - +--td-cascader-filter-empty-padding | 96rpx @spacer-2 | - +--td-cascader-filter-item-color | @text-color-primary | - +--td-cascader-filter-item-disabled-color | @text-color-disabled | - +--td-cascader-filter-item-hover-bg | @bg-color-secondarycontainer | - +--td-cascader-filter-item-padding | 24rpx 32rpx | - +--td-cascader-filter-keyword-color | @brand-color | - +--td-cascader-filter-padding | 0 @spacer-2 @spacer-1 | - --td-cascader-options-height | calc(100% - @cascader-step-height) | - --td-cascader-options-title-color | @text-color-placeholder | - --td-cascader-step-arrow-color | @text-color-placeholder | - diff --git a/packages/components/cascader/__test__/index.test.js b/packages/components/cascader/__test__/index.test.js index 773db48178..daa69bbe4b 100644 --- a/packages/components/cascader/__test__/index.test.js +++ b/packages/components/cascader/__test__/index.test.js @@ -26,4 +26,116 @@ describe('cascader', () => { expect($cascader.dom.getAttribute('style').includes(`${comp.data.customStyle}`)).toBeTruthy(); } }); + + describe(': filterable', () => { + const options = [ + { + label: '北京市', + value: '110000', + children: [ + { + label: '北京市', + value: '110100', + children: [ + { label: '海淀区', value: '110108' }, + { label: '朝阳区', value: '110105' }, + ], + }, + ], + }, + { + label: '上海市', + value: '310000', + children: [{ label: '上海市', value: '310100', children: [{ label: '浦东新区', value: '310115' }] }], + }, + { + label: '广东省', + value: '440000', + children: [{ label: '珠海市', value: '440400' }], + }, + ]; + + const renderCascader = (overrides = {}) => { + const id = simulate.load({ + template: ``, + data: { + options, + filter: null, + ...overrides, + }, + usingComponents: { 't-cascader': cascader }, + }); + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + return comp; + }; + + it('reflects filterable on instance data', () => { + const comp = renderCascader(); + const $cascader = comp.querySelector('#cas'); + expect($cascader.instance.data.filterable).toBe(true); + expect($cascader.instance.data.isSearching).toBe(false); + }); + + it('default filter matches full-path label (case-insensitive)', async () => { + const comp = renderCascader(); + const $cascader = comp.querySelector('#cas'); + $cascader.instance.applyFilter('海'); + await simulate.sleep(); + expect($cascader.instance.data.isSearching).toBe(true); + const keys = $cascader.instance.data.filterResults.map((r) => r.key); + expect(keys).toEqual(expect.arrayContaining(['110000/110100/110108', '440000/440400'])); + }); + + it('shows empty state when no path matches', async () => { + const comp = renderCascader(); + const $cascader = comp.querySelector('#cas'); + $cascader.instance.applyFilter('xxxxx'); + await simulate.sleep(); + expect($cascader.instance.data.isSearching).toBe(true); + expect($cascader.instance.data.filterResults).toHaveLength(0); + }); + + it('clear restores layered view', async () => { + const comp = renderCascader(); + const $cascader = comp.querySelector('#cas'); + $cascader.instance.applyFilter('北'); + await simulate.sleep(); + expect($cascader.instance.data.isSearching).toBe(true); + $cascader.instance.resetFilter(); + await simulate.sleep(); + expect($cascader.instance.data.isSearching).toBe(false); + expect($cascader.instance.data.filterKeyword).toBe(''); + }); + + it('uses custom filter function when provided', async () => { + const comp = renderCascader({ + filter: (keyword, option) => option.label === keyword, + }); + const $cascader = comp.querySelector('#cas'); + $cascader.instance.applyFilter('浦东新区'); + await simulate.sleep(); + expect($cascader.instance.data.filterResults).toHaveLength(1); + expect($cascader.instance.data.filterResults[0].key).toBe('310000/310100/310115'); + }); + + it('selecting a flat result writes selectedIndexes and clears search state', async () => { + const comp = renderCascader(); + const $cascader = comp.querySelector('#cas'); + + $cascader.instance.applyFilter('海淀'); + await simulate.sleep(); + const target = $cascader.instance.data.filterResults[0]; + $cascader.instance.onFilterResultTap({ currentTarget: { dataset: { key: target.key } } }); + await simulate.sleep(); + + expect($cascader.instance.data.isSearching).toBe(false); + expect($cascader.instance.data.filterKeyword).toBe(''); + expect($cascader.instance.data.selectedIndexes).toEqual(target.indexes); + const { items, selectedIndexes } = $cascader.instance.data; + const leaf = items[selectedIndexes.length - 1][selectedIndexes[selectedIndexes.length - 1]]; + expect(leaf.label).toBe('海淀区'); + expect(leaf.value).toBe('110108'); + }); + }); }); diff --git a/packages/components/cascader/_example/cascader.json b/packages/components/cascader/_example/cascader.json index 801ae042d9..ee90e17b08 100644 --- a/packages/components/cascader/_example/cascader.json +++ b/packages/components/cascader/_example/cascader.json @@ -6,6 +6,7 @@ "keys": "./keys", "with-value": "./with-value", "with-title": "./with-title", - "check-strictly": "./check-strictly" + "check-strictly": "./check-strictly", + "filterable": "./filterable" } } diff --git a/packages/components/cascader/_example/cascader.wxml b/packages/components/cascader/_example/cascader.wxml index c344b2309c..6ea7b8c60c 100644 --- a/packages/components/cascader/_example/cascader.wxml +++ b/packages/components/cascader/_example/cascader.wxml @@ -23,4 +23,8 @@ + + + + diff --git a/packages/components/cascader/_example/filterable/index.js b/packages/components/cascader/_example/filterable/index.js new file mode 100644 index 0000000000..f596c338e7 --- /dev/null +++ b/packages/components/cascader/_example/filterable/index.js @@ -0,0 +1,108 @@ +const data = { + areaList: [ + { + label: '北京市', + value: '110000', + children: [ + { + value: '110100', + label: '北京市', + children: [ + { value: '110101', label: '东城区' }, + { value: '110102', label: '西城区' }, + { value: '110105', label: '朝阳区' }, + { value: '110106', label: '丰台区' }, + { value: '110107', label: '石景山区' }, + { value: '110108', label: '海淀区' }, + { value: '110109', label: '门头沟区' }, + { value: '110111', label: '房山区' }, + { value: '110112', label: '通州区' }, + { value: '110113', label: '顺义区' }, + { value: '110114', label: '昌平区' }, + { value: '110115', label: '大兴区' }, + { value: '110116', label: '怀柔区' }, + { value: '110117', label: '平谷区' }, + { value: '110118', label: '密云区' }, + { value: '110119', label: '延庆区' }, + ], + }, + ], + }, + { + label: '上海市', + value: '310000', + children: [ + { + value: '310100', + label: '上海市', + children: [ + { value: '310101', label: '黄浦区' }, + { value: '310104', label: '徐汇区' }, + { value: '310105', label: '长宁区' }, + { value: '310106', label: '静安区' }, + { value: '310107', label: '普陀区' }, + { value: '310109', label: '虹口区' }, + { value: '310110', label: '杨浦区' }, + { value: '310112', label: '闵行区' }, + { value: '310113', label: '宝山区' }, + { value: '310114', label: '嘉定区' }, + { value: '310115', label: '浦东新区' }, + ], + }, + ], + }, + { + label: '广东省', + value: '440000', + children: [ + { + value: '440100', + label: '广州市', + children: [ + { value: '440103', label: '荔湾区' }, + { value: '440104', label: '越秀区' }, + { value: '440105', label: '海珠区' }, + { value: '440106', label: '天河区' }, + { value: '440111', label: '白云区' }, + ], + }, + { + value: '440300', + label: '深圳市', + children: [ + { value: '440303', label: '罗湖区' }, + { value: '440304', label: '福田区' }, + { value: '440305', label: '南山区' }, + { value: '440306', label: '宝安区' }, + { value: '440307', label: '龙岗区' }, + ], + }, + ], + }, + ], +}; + +Component({ + data: { + options: data.areaList, + note: '请选择地址', + visible: false, + value: '', + }, + methods: { + showCascader() { + this.setData({ visible: true }); + }, + onPick(e) { + console.log(e.detail); + }, + onChange(e) { + const { selectedOptions, value } = e.detail; + + this.setData({ + value, + note: selectedOptions.map((item) => item.label).join('/'), + }); + }, + }, +}); diff --git a/packages/components/cascader/_example/filterable/index.json b/packages/components/cascader/_example/filterable/index.json new file mode 100644 index 0000000000..64b0031227 --- /dev/null +++ b/packages/components/cascader/_example/filterable/index.json @@ -0,0 +1,7 @@ +{ + "component": true, + "usingComponents": { + "t-cell": "tdesign-miniprogram/cell/cell", + "t-cascader": "tdesign-miniprogram/cascader/cascader" + } +} diff --git a/packages/components/cascader/_example/filterable/index.wxml b/packages/components/cascader/_example/filterable/index.wxml new file mode 100644 index 0000000000..40f192a7d8 --- /dev/null +++ b/packages/components/cascader/_example/filterable/index.wxml @@ -0,0 +1,12 @@ + + + diff --git a/packages/components/cascader/_example/filterable/index.wxss b/packages/components/cascader/_example/filterable/index.wxss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/components/cascader/cascader.json b/packages/components/cascader/cascader.json index 6f4fec8c6b..29dff91820 100644 --- a/packages/components/cascader/cascader.json +++ b/packages/components/cascader/cascader.json @@ -6,6 +6,7 @@ "t-popup": "../popup/popup", "t-tabs": "../tabs/tabs", "t-tab-panel": "../tab-panel/tab-panel", - "t-radio-group": "../radio-group/radio-group" + "t-radio-group": "../radio-group/radio-group", + "t-search": "../search/search" } } diff --git a/packages/components/cascader/cascader.less b/packages/components/cascader/cascader.less index 66f5e1b052..b01dcb38f1 100644 --- a/packages/components/cascader/cascader.less +++ b/packages/components/cascader/cascader.less @@ -12,6 +12,15 @@ @cascader-border-color: var(--td-cascader-border-color, @component-stroke); @cascader-content-height: var(--td-cascader-content-height, 78vh); @cascader-options-height: var(--td-cascader-options-height, calc(100% - @cascader-step-height)); +// filter (search) +@cascader-filter-padding: var(--td-cascader-filter-padding, 0 @spacer-2 @spacer-1); +@cascader-filter-item-padding: var(--td-cascader-filter-item-padding, 24rpx 32rpx); +@cascader-filter-item-color: var(--td-cascader-filter-item-color, @text-color-primary); +@cascader-filter-item-hover-bg: var(--td-cascader-filter-item-hover-bg, @bg-color-secondarycontainer); +@cascader-filter-item-disabled-color: var(--td-cascader-filter-item-disabled-color, @text-color-disabled); +@cascader-filter-keyword-color: var(--td-cascader-filter-keyword-color, @brand-color); +@cascader-filter-empty-color: var(--td-cascader-filter-empty-color, @text-color-placeholder); +@cascader-filter-empty-padding: var(--td-cascader-filter-empty-padding, 96rpx @spacer-2); // steps @cascader-step-height: var(--td-cascader-step-height, 88rpx); @cascader-step-dot-size: var(--td-cascader-step-dot-size, 16rpx); @@ -116,4 +125,43 @@ margin-left: auto; } } + + &__filter { + padding: @cascader-filter-padding; + box-sizing: border-box; + + &-result { + flex: 1; + width: 100%; + box-sizing: border-box; + } + + &-result-item { + padding: @cascader-filter-item-padding; + color: @cascader-filter-item-color; + font: @font-body-medium; + box-sizing: border-box; + .border(bottom, @cascader-border-color); + + &--hover { + background-color: @cascader-filter-item-hover-bg; + } + + &--disabled { + color: @cascader-filter-item-disabled-color; + } + } + + &-keyword { + color: @cascader-filter-keyword-color; + font-weight: 600; + } + + &-empty { + padding: @cascader-filter-empty-padding; + text-align: center; + color: @cascader-filter-empty-color; + font: @font-body-medium; + } + } } diff --git a/packages/components/cascader/cascader.ts b/packages/components/cascader/cascader.ts index b5e5a58d52..5110d104d0 100644 --- a/packages/components/cascader/cascader.ts +++ b/packages/components/cascader/cascader.ts @@ -1,7 +1,7 @@ import { SuperComponent, wxComponent } from '../common/src/index'; import config from '../common/config'; import props from './props'; -import { TdCascaderProps } from './type'; +import { TdCascaderProps, CascaderFilterFunction } from './type'; import { getRect } from '../common/utils'; import usingConfig from '../mixins/using-config'; @@ -13,6 +13,24 @@ export interface CascaderProps extends TdCascaderProps {} type OptionsType = TdCascaderProps['options']['value']; type KeysType = TdCascaderProps['keys']['value']; +type FlatPath = { + key: string; + path: any[]; + indexes: number[]; + labels: string[]; + text: string; + disabled: boolean; +}; + +type ResultFragment = { id: number; text: string; highlight: boolean }; + +type FilterResult = { + key: string; + indexes: number[]; + disabled: boolean; + fragments: ResultFragment[]; +}; + function parseOptions(options: OptionsType, keys: KeysType) { const label = keys?.label ?? 'label'; const value = keys?.value ?? 'value'; @@ -27,6 +45,75 @@ function parseOptions(options: OptionsType, keys: KeysType) { }); } +function flattenPaths(options: OptionsType, keys: KeysType): FlatPath[] { + const labelKey = keys?.label ?? 'label'; + const valueKey = keys?.value ?? 'value'; + const childrenKey = keys?.children ?? 'children'; + const disabledKey = keys?.disabled ?? 'disabled'; + const result: FlatPath[] = []; + + const walk = (list: any[], path: any[], indexes: number[]) => { + list.forEach((item, idx) => { + const nextPath = [...path, item]; + const nextIndexes = [...indexes, idx]; + const children = item?.[childrenKey]; + if (Array.isArray(children) && children.length > 0) { + walk(children, nextPath, nextIndexes); + } else { + const labels = nextPath.map((node) => String(node?.[labelKey] ?? '')); + const text = [labels.join(''), String(item?.text ?? '')].filter(Boolean).join(''); + result.push({ + key: nextPath.map((node) => String(node?.[valueKey] ?? '')).join('/'), + path: nextPath, + indexes: nextIndexes, + labels, + text, + disabled: nextPath.some((node) => node?.[disabledKey]), + }); + } + }); + }; + + walk(options || [], [], []); + return result; +} + +function buildFragments(labels: string[], keyword: string): ResultFragment[] { + const joined = labels.join(' / '); + const push = (acc: ResultFragment[], text: string, highlight: boolean) => { + if (!text) return; + acc.push({ id: acc.length, text, highlight }); + }; + + if (!keyword) return [{ id: 0, text: joined, highlight: false }]; + + const fragments: ResultFragment[] = []; + const haystack = joined.toLowerCase(); + const needle = keyword.toLowerCase(); + let cursor = 0; + while (cursor < joined.length) { + const hit = haystack.indexOf(needle, cursor); + if (hit === -1) { + push(fragments, joined.slice(cursor), false); + break; + } + push(fragments, joined.slice(cursor, hit), false); + push(fragments, joined.slice(hit, hit + needle.length), true); + cursor = hit + needle.length; + } + return fragments.length ? fragments : [{ id: 0, text: joined, highlight: false }]; +} + +function defaultFilter(keyword: string, _option: any, path: any[], labelKey: string) { + const lower = keyword.toLowerCase(); + const joined = path + .map((node) => String(node?.[labelKey] ?? '')) + .join('') + .toLowerCase(); + const text = String(path[path.length - 1]?.text ?? '').toLowerCase(); + return joined.includes(lower) || (!!text && text.includes(lower)); +} + const defaultState = { contentHeight: 0, stepHeight: 0, @@ -43,7 +130,7 @@ export default class Cascader extends SuperComponent { options: WechatMiniprogram.Component.ComponentOptions = { multipleSlots: true, - pureDataPattern: /^options$/, + pureDataPattern: /^(options|flatPaths)$/, }; properties = props; @@ -68,6 +155,10 @@ export default class Cascader extends SuperComponent { scrollTopList: [], steps: [], _optionsHeight: 0, + filterKeyword: '', + filterResults: [] as FilterResult[], + isSearching: false, + flatPaths: [] as FlatPath[], }; observers = { @@ -83,6 +174,9 @@ export default class Cascader extends SuperComponent { this.initWithValue(); } else { this.state = { ...defaultState }; + if (this.data.isSearching) { + this.resetFilter(); + } } }, @@ -99,6 +193,24 @@ export default class Cascader extends SuperComponent { selectedValue, stepIndex: items.length - 1, }); + + this.invalidateFlatPaths(); + if (this.data.isSearching) { + this.applyFilter(this.data.filterKeyword); + } + }, + + keys() { + this.invalidateFlatPaths(); + if (this.data.isSearching) { + this.applyFilter(this.data.filterKeyword); + } + }, + + filterable(v: boolean) { + if (!v && this.data.isSearching) { + this.resetFilter(); + } }, selectedIndexes() { const { visible, theme } = this.properties; @@ -220,6 +332,114 @@ export default class Cascader extends SuperComponent { } this.hide('close-btn'); }, + invalidateFlatPaths() { + this.setData({ flatPaths: [] }); + }, + ensureFlatPaths() { + let { flatPaths } = this.data; + if (!flatPaths || flatPaths.length === 0) { + const { options, keys } = this.data; + flatPaths = flattenPaths(options, keys); + this.setData({ flatPaths }); + } + return flatPaths; + }, + resetFilter() { + this.setData({ + filterKeyword: '', + filterResults: [], + isSearching: false, + }); + }, + onFilterChange(e: { detail?: { value?: string } }) { + const value = e?.detail?.value ?? ''; + this.applyFilter(value); + }, + onFilterClear() { + this.resetFilter(); + }, + applyFilter(rawKeyword: string) { + const keyword = String(rawKeyword ?? '').trim(); + if (!keyword) { + this.resetFilter(); + return; + } + + const { keys, filter } = this.data; + const labelKey = keys?.label ?? 'label'; + const userFilter = filter as CascaderFilterFunction | null; + const flat = this.ensureFlatPaths(); + const results: FilterResult[] = []; + + flat.forEach((entry: FlatPath) => { + const leaf = entry.path[entry.path.length - 1]; + const matched = + typeof userFilter === 'function' + ? !!userFilter(keyword, leaf, entry.path) + : defaultFilter(keyword, leaf, entry.path, labelKey); + if (matched) { + results.push({ + key: entry.key, + indexes: entry.indexes, + disabled: entry.disabled, + fragments: buildFragments(entry.labels, keyword), + }); + } + }); + + this.setData({ + filterKeyword: rawKeyword, + filterResults: results, + isSearching: true, + }); + }, + onFilterResultTap(e: { currentTarget: { dataset: { key: string } } }) { + const { key } = e.currentTarget.dataset; + const target = this.data.filterResults.find((item: FilterResult) => item.key === key); + if (!target || target.disabled) return; + + const { indexes } = target; + const { items: newItems } = this.regenItemsByIndexes(indexes); + + this.resetFilter(); + this.setData( + { + items: newItems, + selectedIndexes: indexes, + stepIndex: indexes.length - 1, + }, + () => this.triggerChange(), + ); + this.hide('finish'); + }, + regenItemsByIndexes(selectedIndexes: number[]) { + const { options, keys, placeholder, globalConfig } = this.data; + const selectedValue: any[] = []; + const steps: string[] = []; + const items: any[] = [parseOptions(options, keys)]; + const labelKey = keys?.label ?? 'label'; + const valueKey = keys?.value ?? 'value'; + const childrenKey = keys?.children ?? 'children'; + + let current: any[] = options as any[]; + for (let i = 0, size = selectedIndexes.length; i < size; i += 1) { + const index = selectedIndexes[i]; + const next = current[index]; + selectedValue.push(next[valueKey]); + steps.push(next[labelKey]); + const children = next[childrenKey]; + if (Array.isArray(children) && children.length > 0) { + items.push(parseOptions(children, keys)); + current = children; + } + } + + if (steps.length < items.length) { + steps.push(placeholder || globalConfig.placeholder); + } + + return { selectedValue, steps, items }; + }, onStepClick(e) { const { index } = e.currentTarget.dataset; diff --git a/packages/components/cascader/cascader.wxml b/packages/components/cascader/cascader.wxml index bee766aba7..3a9cf8c0f0 100644 --- a/packages/components/cascader/cascader.wxml +++ b/packages/components/cascader/cascader.wxml @@ -13,66 +13,95 @@ + + + + - - - + + + - - {{ item }} + + + {{ item }} + + - - - - - - + + + + + - - - {{subTitles[stepIndex]}} - - + {{subTitles[stepIndex]}} - - - + + + + + + + + + + + + + + + {{frag.text}} + - + {{globalConfig.empty}} + diff --git a/packages/components/cascader/props.ts b/packages/components/cascader/props.ts index c4f805994f..642fdc19a3 100644 --- a/packages/components/cascader/props.ts +++ b/packages/components/cascader/props.ts @@ -16,6 +16,21 @@ const props: TdCascaderProps = { type: Boolean, value: true, }, + /** 是否可搜索。开启后顶部展示搜索框,输入关键字将层级面板切换为扁平的匹配路径列表 */ + filterable: { + type: Boolean, + value: false, + }, + /** 自定义过滤函数。返回 true 表示匹配;签名为 `(keyword, option, path) => boolean` */ + filter: { + type: null, + value: null, + }, + /** 搜索框占位文案 */ + filterPlaceholder: { + type: String, + value: '', + }, /** 用来定义 value / label / children / disabled 在 `options` 中对应的字段别名 */ keys: { type: Object, diff --git a/packages/components/cascader/type.ts b/packages/components/cascader/type.ts index 2fa8e0c62b..c0b54155f7 100644 --- a/packages/components/cascader/type.ts +++ b/packages/components/cascader/type.ts @@ -23,6 +23,29 @@ export interface TdCascaderProps boolean`。未设置时使用内置匹配规则:对路径中所有 label/text 拼接后做大小写不敏感的 includes 匹配 + */ + filter?: { + type: null; + value?: CascaderFilterFunction; + }; + /** + * 搜索框占位文案 + * @default '' + */ + filterPlaceholder?: { + type: StringConstructor; + value?: string; + }; /** * 用来定义 value / label / children / disabled 在 `options` 中对应的字段别名 */ @@ -96,3 +119,9 @@ export interface TdCascaderProps = ( + keyword: string, + option: CascaderOption, + path: CascaderOption[], +) => boolean; diff --git a/packages/components/config-provider/__test__/__snapshots__/demo.test.js.snap b/packages/components/config-provider/__test__/__snapshots__/demo.test.js.snap index 46f5aa5996..e1b8b1c811 100644 --- a/packages/components/config-provider/__test__/__snapshots__/demo.test.js.snap +++ b/packages/components/config-provider/__test__/__snapshots__/demo.test.js.snap @@ -43,6 +43,8 @@ exports[`ConfigProvider ConfigProvider chat-en demo works fine 1`] = ` ], }, "cascader": Object { + "empty": "No matching options", + "filterPlaceholder": "Search", "placeholder": "Select options", "title": "Title", }, @@ -313,6 +315,8 @@ exports[`ConfigProvider ConfigProvider upload-en demo works fine 1`] = ` ], }, "cascader": Object { + "empty": "No matching options", + "filterPlaceholder": "Search", "placeholder": "Select options", "title": "Title", }, diff --git a/packages/components/locale/ar_KW.ts b/packages/components/locale/ar_KW.ts index 3fea713ec3..54b7d9cda9 100644 --- a/packages/components/locale/ar_KW.ts +++ b/packages/components/locale/ar_KW.ts @@ -29,6 +29,8 @@ export default { cascader: { title: 'العنوان', placeholder: 'اختر الخيارات', + filterPlaceholder: 'بحث', + empty: 'لا توجد عناصر مطابقة', }, dropdownMenu: { reset: 'إعادة الضبط', diff --git a/packages/components/locale/en_US.ts b/packages/components/locale/en_US.ts index 807ad12a32..c249ecd323 100644 --- a/packages/components/locale/en_US.ts +++ b/packages/components/locale/en_US.ts @@ -29,6 +29,8 @@ export default { cascader: { title: 'Title', placeholder: 'Select options', + filterPlaceholder: 'Search', + empty: 'No matching options', }, dropdownMenu: { reset: 'Reset', diff --git a/packages/components/locale/it_IT.ts b/packages/components/locale/it_IT.ts index f3a03d2183..5c8aa36832 100644 --- a/packages/components/locale/it_IT.ts +++ b/packages/components/locale/it_IT.ts @@ -29,6 +29,8 @@ export default { cascader: { title: 'Titolo', placeholder: 'Seleziona opzioni', + filterPlaceholder: 'Cerca', + empty: 'Nessun risultato', }, dropdownMenu: { reset: 'Reimposta', diff --git a/packages/components/locale/ja_JP.ts b/packages/components/locale/ja_JP.ts index 393b6b5021..f716431058 100644 --- a/packages/components/locale/ja_JP.ts +++ b/packages/components/locale/ja_JP.ts @@ -16,6 +16,8 @@ export default { cascader: { title: 'タイトル', placeholder: 'オプションを選択', + filterPlaceholder: '検索', + empty: '該当する項目はありません', }, dropdownMenu: { reset: 'リセット', diff --git a/packages/components/locale/ko_KR.ts b/packages/components/locale/ko_KR.ts index 5b32411d61..efcb3bf983 100644 --- a/packages/components/locale/ko_KR.ts +++ b/packages/components/locale/ko_KR.ts @@ -16,6 +16,8 @@ export default { cascader: { title: '제목', placeholder: '옵션 선택', + filterPlaceholder: '검색', + empty: '일치하는 항목이 없습니다', }, dropdownMenu: { reset: '초기화', diff --git a/packages/components/locale/ru_RU.ts b/packages/components/locale/ru_RU.ts index da4bad60c5..8bc5bc5049 100644 --- a/packages/components/locale/ru_RU.ts +++ b/packages/components/locale/ru_RU.ts @@ -28,6 +28,8 @@ export default { cascader: { title: 'Название', placeholder: 'Выберите опцию', + filterPlaceholder: 'Поиск', + empty: 'Совпадений не найдено', }, dropdownMenu: { reset: 'Сброс', diff --git a/packages/components/locale/zh_CN.ts b/packages/components/locale/zh_CN.ts index dc7e2001cb..c4e7d8fa42 100644 --- a/packages/components/locale/zh_CN.ts +++ b/packages/components/locale/zh_CN.ts @@ -16,6 +16,8 @@ export default { cascader: { title: '标题', placeholder: '选择选项', + filterPlaceholder: '搜索', + empty: '暂无匹配项', }, dropdownMenu: { reset: '重置', diff --git a/packages/components/locale/zh_TW.ts b/packages/components/locale/zh_TW.ts index 62c0989d5c..2b2077d194 100644 --- a/packages/components/locale/zh_TW.ts +++ b/packages/components/locale/zh_TW.ts @@ -16,6 +16,8 @@ export default { cascader: { title: '標題', placeholder: '選擇選項', + filterPlaceholder: '搜尋', + empty: '暫無匹配項', }, dropdownMenu: { reset: '重置', From 63d3278e4497b7f484112687a465808ae386b9b7 Mon Sep 17 00:00:00 2001 From: cikenerd Date: Mon, 18 May 2026 17:40:43 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(Cascader):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=BC=80=E5=90=AF=20filterable=20=E5=90=8E=E5=BC=B9=E5=B1=82?= =?UTF-8?q?=E9=A1=B6=E5=88=B0=E8=83=B6=E5=9B=8A=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将搜索框移入 __content 内部,使弹层总高度回归到 title + 78vh, 不再因新增搜索条而超出安全区。同时在 initOptionsHeight / updateOptionsHeight 中扣除 filterHeight,保证选项滚动区高度计算正确。 --- packages/components/cascader/cascader.ts | 14 ++++++++++---- packages/components/cascader/cascader.wxml | 18 +++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/components/cascader/cascader.ts b/packages/components/cascader/cascader.ts index 5110d104d0..a12f1d182e 100644 --- a/packages/components/cascader/cascader.ts +++ b/packages/components/cascader/cascader.ts @@ -120,6 +120,7 @@ const defaultState = { tabsHeight: 0, subTitlesHeight: 0, stepsInitHeight: 0, + filterHeight: 0, }; @wxComponent() @@ -243,15 +244,15 @@ export default class Cascader extends SuperComponent { methods = { updateOptionsHeight(steps: number) { - const { contentHeight, stepsInitHeight, stepHeight, subTitlesHeight } = this.state; + const { contentHeight, stepsInitHeight, stepHeight, subTitlesHeight, filterHeight } = this.state; this.setData({ - _optionsHeight: contentHeight - stepsInitHeight - subTitlesHeight - (steps - 1) * stepHeight, + _optionsHeight: contentHeight - stepsInitHeight - subTitlesHeight - filterHeight - (steps - 1) * stepHeight, }); }, async initOptionsHeight(steps: number) { const { classPrefix } = this.data; - const { theme, subTitles } = this.properties; + const { theme, subTitles, filterable } = this.properties; const { height } = await getRect(this, `.${classPrefix}__content`); this.state.contentHeight = height; @@ -270,7 +271,12 @@ export default class Cascader extends SuperComponent { this.state.subTitlesHeight = height; } - const optionsInitHeight = this.state.contentHeight - this.state.subTitlesHeight; + if (filterable) { + const filterRect = await getRect(this, `.${classPrefix}__filter`); + this.state.filterHeight = filterRect.height; + } + + const optionsInitHeight = this.state.contentHeight - this.state.subTitlesHeight - this.state.filterHeight; this.setData({ _optionsHeight: theme === 'step' diff --git a/packages/components/cascader/cascader.wxml b/packages/components/cascader/cascader.wxml index 3a9cf8c0f0..db5e4f4c15 100644 --- a/packages/components/cascader/cascader.wxml +++ b/packages/components/cascader/cascader.wxml @@ -13,16 +13,16 @@ - - - - + + + + From 2dc53927124cc45cb8dca63ff936640cd687ba56 Mon Sep 17 00:00:00 2001 From: cikenerd Date: Tue, 2 Jun 2026 16:33:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?perf(Cascader):=20=E6=8C=89=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E5=BB=BA=E8=AE=AE=E4=BC=98=E5=8C=96=20filterable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 搜索输入加防抖,避免每次按键全量遍历 - flatPaths 缓存到实例 state,替代 setData - 高亮 class/变量由 filter-keyword 改名为 filter-highlight --- packages/components/cascader/README.en-US.md | 2 +- packages/components/cascader/README.md | 2 +- packages/components/cascader/cascader.less | 6 +++--- packages/components/cascader/cascader.ts | 19 ++++++++++++------- packages/components/cascader/cascader.wxml | 2 +- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/components/cascader/README.en-US.md b/packages/components/cascader/README.en-US.md index a4b502822b..af79fadd8b 100644 --- a/packages/components/cascader/README.en-US.md +++ b/packages/components/cascader/README.en-US.md @@ -52,11 +52,11 @@ Name | Default Value | Description --td-cascader-disabled-color | @text-color-disabled | - --td-cascader-filter-empty-color | @text-color-placeholder | - --td-cascader-filter-empty-padding | 96rpx @spacer-2 | - +--td-cascader-filter-highlight-color | @brand-color | - --td-cascader-filter-item-color | @text-color-primary | - --td-cascader-filter-item-disabled-color | @text-color-disabled | - --td-cascader-filter-item-hover-bg | @bg-color-secondarycontainer | - --td-cascader-filter-item-padding | 24rpx 32rpx | - ---td-cascader-filter-keyword-color | @brand-color | - --td-cascader-filter-padding | 0 @spacer-2 @spacer-1 | - --td-cascader-options-height | calc(100% - @cascader-step-height) | - --td-cascader-options-title-color | @text-color-placeholder | - diff --git a/packages/components/cascader/README.md b/packages/components/cascader/README.md index eb6ee3a8c8..1bf04383b1 100644 --- a/packages/components/cascader/README.md +++ b/packages/components/cascader/README.md @@ -113,11 +113,11 @@ title | 自定义 `title` 显示内容 --td-cascader-disabled-color | @text-color-disabled | - --td-cascader-filter-empty-color | @text-color-placeholder | - --td-cascader-filter-empty-padding | 96rpx @spacer-2 | - +--td-cascader-filter-highlight-color | @brand-color | - --td-cascader-filter-item-color | @text-color-primary | - --td-cascader-filter-item-disabled-color | @text-color-disabled | - --td-cascader-filter-item-hover-bg | @bg-color-secondarycontainer | - --td-cascader-filter-item-padding | 24rpx 32rpx | - ---td-cascader-filter-keyword-color | @brand-color | - --td-cascader-filter-padding | 0 @spacer-2 @spacer-1 | - --td-cascader-options-height | calc(100% - @cascader-step-height) | - --td-cascader-options-title-color | @text-color-placeholder | - diff --git a/packages/components/cascader/cascader.less b/packages/components/cascader/cascader.less index b01dcb38f1..df8b9f6fc1 100644 --- a/packages/components/cascader/cascader.less +++ b/packages/components/cascader/cascader.less @@ -18,7 +18,7 @@ @cascader-filter-item-color: var(--td-cascader-filter-item-color, @text-color-primary); @cascader-filter-item-hover-bg: var(--td-cascader-filter-item-hover-bg, @bg-color-secondarycontainer); @cascader-filter-item-disabled-color: var(--td-cascader-filter-item-disabled-color, @text-color-disabled); -@cascader-filter-keyword-color: var(--td-cascader-filter-keyword-color, @brand-color); +@cascader-filter-highlight-color: var(--td-cascader-filter-highlight-color, @brand-color); @cascader-filter-empty-color: var(--td-cascader-filter-empty-color, @text-color-placeholder); @cascader-filter-empty-padding: var(--td-cascader-filter-empty-padding, 96rpx @spacer-2); // steps @@ -152,8 +152,8 @@ } } - &-keyword { - color: @cascader-filter-keyword-color; + &-highlight { + color: @cascader-filter-highlight-color; font-weight: 600; } diff --git a/packages/components/cascader/cascader.ts b/packages/components/cascader/cascader.ts index a12f1d182e..17820751bd 100644 --- a/packages/components/cascader/cascader.ts +++ b/packages/components/cascader/cascader.ts @@ -2,7 +2,7 @@ import { SuperComponent, wxComponent } from '../common/src/index'; import config from '../common/config'; import props from './props'; import { TdCascaderProps, CascaderFilterFunction } from './type'; -import { getRect } from '../common/utils'; +import { getRect, debounce } from '../common/utils'; import usingConfig from '../mixins/using-config'; const { prefix } = config; @@ -121,6 +121,7 @@ const defaultState = { subTitlesHeight: 0, stepsInitHeight: 0, filterHeight: 0, + flatPaths: [] as FlatPath[], }; @wxComponent() @@ -131,11 +132,13 @@ export default class Cascader extends SuperComponent { options: WechatMiniprogram.Component.ComponentOptions = { multipleSlots: true, - pureDataPattern: /^(options|flatPaths)$/, + pureDataPattern: /^options$/, }; properties = props; + filterDebounced: ((value: string) => void) | null = null; + controlledProps = [ { key: 'value', @@ -159,7 +162,6 @@ export default class Cascader extends SuperComponent { filterKeyword: '', filterResults: [] as FilterResult[], isSearching: false, - flatPaths: [] as FlatPath[], }; observers = { @@ -339,14 +341,14 @@ export default class Cascader extends SuperComponent { this.hide('close-btn'); }, invalidateFlatPaths() { - this.setData({ flatPaths: [] }); + this.state.flatPaths = []; }, ensureFlatPaths() { - let { flatPaths } = this.data; + let { flatPaths } = this.state; if (!flatPaths || flatPaths.length === 0) { const { options, keys } = this.data; flatPaths = flattenPaths(options, keys); - this.setData({ flatPaths }); + this.state.flatPaths = flatPaths; } return flatPaths; }, @@ -359,7 +361,10 @@ export default class Cascader extends SuperComponent { }, onFilterChange(e: { detail?: { value?: string } }) { const value = e?.detail?.value ?? ''; - this.applyFilter(value); + if (!this.filterDebounced) { + this.filterDebounced = debounce((kw: string) => this.applyFilter(kw), 200); + } + this.filterDebounced(value); }, onFilterClear() { this.resetFilter(); diff --git a/packages/components/cascader/cascader.wxml b/packages/components/cascader/cascader.wxml index db5e4f4c15..40fa6b94b2 100644 --- a/packages/components/cascader/cascader.wxml +++ b/packages/components/cascader/cascader.wxml @@ -96,7 +96,7 @@ bind:tap="onFilterResultTap" > - {{frag.text}} + {{frag.text}}