From 19d444d6f6c3aca621e0e2e8f8dbfc46b3aba151 Mon Sep 17 00:00:00 2001 From: ZQDesigned <2990918167@qq.com> Date: Thu, 7 May 2026 14:10:54 +0800 Subject: [PATCH 1/4] feat: support tokenize prop for custom multi-tag tokenization --- src/BaseSelect/index.tsx | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/BaseSelect/index.tsx b/src/BaseSelect/index.tsx index 2b56b4dc..6b80748b 100644 --- a/src/BaseSelect/index.tsx +++ b/src/BaseSelect/index.tsx @@ -183,6 +183,12 @@ export interface BaseSelectProps // >>> Search tokenSeparators?: string[]; + /** + * Custom tokenization. When provided, takes precedence over `tokenSeparators`. + * Receives the current input text and returns an array of tags. Return `[input]` + * (or any single-element array equal to input) to indicate "no split, keep typing". + */ + tokenize?: (input: string) => string[]; // >>> Icons allowClear?: boolean | { clearIcon?: React.ReactNode }; @@ -282,6 +288,7 @@ const BaseSelect = React.forwardRef((props, ref) onSearch, onSearchSplit, tokenSeparators, + tokenize, // Icons allowClear, @@ -371,8 +378,10 @@ const BaseSelect = React.forwardRef((props, ref) // ============================= Search ============================= const tokenWithEnter = React.useMemo( - () => (tokenSeparators || []).some((tokenSeparator) => ['\n', '\r\n'].includes(tokenSeparator)), - [tokenSeparators], + () => + !!tokenize || + (tokenSeparators || []).some((tokenSeparator) => ['\n', '\r\n'].includes(tokenSeparator)), + [tokenize, tokenSeparators], ); const onInternalSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => { @@ -383,13 +392,22 @@ const BaseSelect = React.forwardRef((props, ref) let newSearchText = searchText; onActiveValueChange?.(null); - const separatedList = getSeparatedContent( - searchText, - tokenSeparators, - isValidCount(maxCount) ? maxCount - displayValues.length : undefined, - ); + const cap = isValidCount(maxCount) ? maxCount - displayValues.length : undefined; + + let separatedList: string[] | null; + if (tokenize) { + const tokens = tokenize(searchText); + const isUnchanged = Array.isArray(tokens) && tokens.length === 1 && tokens[0] === searchText; + if (Array.isArray(tokens) && tokens.length > 0 && !isUnchanged) { + separatedList = typeof cap !== 'undefined' ? tokens.slice(0, cap) : tokens; + } else { + separatedList = null; + } + } else { + separatedList = getSeparatedContent(searchText, tokenSeparators, cap); + } - // Check if match the `tokenSeparators` + // Check if match the `tokenSeparators` or custom `tokenize` const patchLabels: string[] = isCompositing ? null : separatedList; // Ignore combobox since it's not split-able From f8e57d7c85faccb0ba1a91a421bf9c35076b7d1c Mon Sep 17 00:00:00 2001 From: ZQDesigned <2990918167@qq.com> Date: Thu, 7 May 2026 14:11:34 +0800 Subject: [PATCH 2/4] test: add tokenize prop tests --- tests/Multiple.test.tsx | 28 ++++++++++++++++++ tests/Tags.test.tsx | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/tests/Multiple.test.tsx b/tests/Multiple.test.tsx index ee76ebcf..b3fa88c9 100644 --- a/tests/Multiple.test.tsx +++ b/tests/Multiple.test.tsx @@ -80,6 +80,34 @@ describe('Select.Multiple', () => { expectOpen(container, false); }); + it('tokenize prop on multiple only adds values that match options', () => { + const handleChange = jest.fn(); + const tokenize = (input: string) => input.split(',').map((s) => s.trim()); + const { container } = render( + , + ); + fireEvent.paste(container.querySelector('input'), { + clipboardData: { getData: () => 'One,Two,Unknown' }, + }); + fireEvent.change(container.querySelector('input'), { + target: { value: 'One,Two,Unknown' }, + }); + expect(handleChange).toHaveBeenCalledWith(['1', '2'], expect.anything()); + expect(container.querySelector('input').value).toBe(''); + }); + it('tokenize input when mode=tags and open=false', () => { const handleChange = jest.fn(); const handleSelect = jest.fn(); diff --git a/tests/Tags.test.tsx b/tests/Tags.test.tsx index 74da8995..172e76f9 100644 --- a/tests/Tags.test.tsx +++ b/tests/Tags.test.tsx @@ -82,6 +82,71 @@ describe('Select.Tags', () => { expectOpen(container, false); }); + it('tokenize prop overrides tokenSeparators with quote-aware splitting', () => { + const handleChange = jest.fn(); + const tokenize = jest.fn((input: string) => { + const tokens: string[] = []; + const regex = /"([^"]*)"|([^,\n]+)/g; + let m: RegExpExecArray | null = regex.exec(input); + while (m !== null) { + tokens.push((m[1] ?? m[2]).trim()); + m = regex.exec(input); + } + return tokens.filter(Boolean); + }); + const { container } = render( + , + ); + + fireEvent.change(container.querySelector('input'), { target: { value: '"a, b", c' } }); + + expect(tokenize).toHaveBeenCalled(); + expect(handleChange).toHaveBeenCalledWith(['a, b', 'c'], expect.anything()); + expect(container.querySelector('input').value).toBe(''); + }); + + it('tokenize prop respects maxCount', () => { + const handleChange = jest.fn(); + const tokenize = () => ['a', 'b', 'c', 'd', 'e']; + const { container } = render( + + + , + ); + fireEvent.compositionStart(container.querySelector('input')); + fireEvent.change(container.querySelector('input'), { target: { value: '2,3,4' } }); + expect(handleChange).not.toHaveBeenCalled(); + handleChange.mockReset(); + fireEvent.compositionEnd(container.querySelector('input')); + fireEvent.change(container.querySelector('input'), { target: { value: '2,3,4' } }); + expect(handleChange).toHaveBeenCalledWith(['2', '3', '4'], expect.anything()); + }); + + it('tokenize prop returning [input] keeps typing', () => { + const handleChange = jest.fn(); + const tokenize = (input: string) => [input]; + const { container } = render( + , + ); + fireEvent.change(container.querySelector('input'), { target: { value: 'hello' } }); + expect(handleChange).not.toHaveBeenCalled(); + expect(container.querySelector('input').value).toBe('hello'); + }); + it('should not separate words when compositing but trigger after composition end', () => { const handleChange = jest.fn(); const handleSelect = jest.fn(); From 88c5cc93e04b44b87a525d287c6bc32114b1c444 Mon Sep 17 00:00:00 2001 From: ZQDesigned <2990918167@qq.com> Date: Thu, 7 May 2026 14:12:16 +0800 Subject: [PATCH 3/4] docs: add custom-tokenize demo --- docs/demo/custom-tokenize.md | 8 ++++++++ docs/examples/custom-tokenize.tsx | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 docs/demo/custom-tokenize.md create mode 100644 docs/examples/custom-tokenize.tsx diff --git a/docs/demo/custom-tokenize.md b/docs/demo/custom-tokenize.md new file mode 100644 index 00000000..c7ebb2a4 --- /dev/null +++ b/docs/demo/custom-tokenize.md @@ -0,0 +1,8 @@ +--- +title: custom-tokenize +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/custom-tokenize.tsx b/docs/examples/custom-tokenize.tsx new file mode 100644 index 00000000..b9d8c9cb --- /dev/null +++ b/docs/examples/custom-tokenize.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Select from '@rc-component/select'; +import '../../assets/index.less'; + +const tokenize = (input: string): string[] => { + const tokens: string[] = []; + const regex = /"([^"]*)"|([^,\n]+)/g; + let m: RegExpExecArray | null = regex.exec(input); + while (m !== null) { + tokens.push((m[1] ?? m[2]).trim()); + m = regex.exec(input); + } + return tokens.filter(Boolean); +}; + +const Demo: React.FC = () => ( + <> +

自定义分词(引号感知)

+