-
-
Notifications
You must be signed in to change notification settings - Fork 241
feat(Tabs): support showSearch in more dropdown #992
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| title: Search Dropdown | ||
| nav: | ||
| title: Demo | ||
| path: /demo | ||
| --- | ||
|
|
||
| <code src="../examples/search-dropdown.tsx"></code> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import React, { useState } from 'react'; | ||
| import '../../assets/index.less'; | ||
| import Tabs from '../../src'; | ||
|
|
||
| // Controlled mode example | ||
| const ControlledDemo = ({ items }: { items: any[] }) => { | ||
| const [searchValue, setSearchValue] = useState(''); | ||
|
|
||
| return ( | ||
| <Tabs | ||
| activeKey="1" | ||
| onChange={() => {}} | ||
| items={items} | ||
| more={{ | ||
| showSearch: { | ||
| placeholder: 'Controlled search...', | ||
| searchValue, | ||
| onSearch: setSearchValue, | ||
| }, | ||
| }} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| export default () => { | ||
| const [activeKey, setActiveKey] = useState('1'); | ||
|
|
||
| // Generate many tabs to trigger the "more" button | ||
| const items = Array.from({ length: 30 }, (_, i) => ({ | ||
| key: String(i + 1), | ||
| label: `Tab ${i + 1}`, | ||
| children: `Content of Tab ${i + 1}`, | ||
| })); | ||
|
|
||
| return ( | ||
| <div> | ||
| <h3>Basic Usage</h3> | ||
| <Tabs | ||
| activeKey={activeKey} | ||
| onChange={setActiveKey} | ||
| items={items} | ||
| more={{ | ||
| showSearch: { | ||
| placeholder: 'Search...', | ||
| }, | ||
| }} | ||
| /> | ||
|
|
||
| <h3>Controlled Mode</h3> | ||
| <ControlledDemo items={items} /> | ||
|
|
||
| <h3>Keep Search Value on Close</h3> | ||
| <Tabs | ||
| activeKey={activeKey} | ||
| onChange={setActiveKey} | ||
| items={items} | ||
| more={{ | ||
| showSearch: { | ||
| placeholder: 'Keep search value', | ||
| autoClearSearchValue: false, | ||
| }, | ||
| }} | ||
| /> | ||
| </div> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,7 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop | |
| tabs, | ||
| locale, | ||
| mobile, | ||
| activeKey, | ||
| more: moreProps = {}, | ||
| style, | ||
| className, | ||
|
|
@@ -56,8 +57,29 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop | |
| // ======================== Dropdown ======================== | ||
| const [open, setOpen] = useState(false); | ||
| const [selectedKey, setSelectedKey] = useState<string>(null); | ||
| const [searchValue, setSearchValue] = useState(''); | ||
|
|
||
| const { icon: moreIcon = 'More' } = moreProps; | ||
| const { icon: moreIcon = 'More', showSearch } = moreProps; | ||
|
|
||
| // 是否启用搜索 | ||
| const isSearchable = !!showSearch; | ||
| const showSearchConfig = typeof showSearch === 'object' ? showSearch : {}; | ||
| const { | ||
| placeholder = 'Search', | ||
| onSearch, | ||
| searchValue: controlledSearchValue, | ||
| autoClearSearchValue = true, | ||
| } = showSearchConfig; | ||
|
|
||
| // 支持受控和非受控 searchValue | ||
| const mergedSearchValue = | ||
| controlledSearchValue !== undefined ? controlledSearchValue : searchValue; | ||
| const setSearchValueFn = controlledSearchValue !== undefined ? () => {} : setSearchValue; | ||
|
|
||
| // 根据搜索值过滤 tabs | ||
| const filteredTabs = mergedSearchValue | ||
| ? tabs.filter(tab => String(tab.label).toLowerCase().includes(mergedSearchValue.toLowerCase())) | ||
| : tabs; | ||
|
Comment on lines
+80
to
+82
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 在 建议实现一个简单的文本提取函数,或者在过滤时安全地处理非字符串类型的
Comment on lines
+80
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 不要用 Line 81 这里把 🤖 Prompt for AI Agents |
||
|
|
||
| const popupId = `${id}-more-popup`; | ||
| const dropdownPrefix = `${prefixCls}-dropdown`; | ||
|
|
@@ -85,7 +107,7 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop | |
| selectedKeys={[selectedKey]} | ||
| aria-label={dropdownAriaLabel !== undefined ? dropdownAriaLabel : 'expanded dropdown'} | ||
| > | ||
| {tabs.map<React.ReactNode>(tab => { | ||
| {filteredTabs.map<React.ReactNode>(tab => { | ||
| const { closable, disabled, closeIcon, key, label } = tab; | ||
| const removable = getRemovable(closable, closeIcon, editable, disabled); | ||
| return ( | ||
|
|
@@ -120,10 +142,13 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop | |
| ); | ||
|
|
||
| function selectOffset(offset: -1 | 1) { | ||
| const enabledTabs = tabs.filter(tab => !tab.disabled); | ||
| // 键盘导航只在过滤后的 tabs 上生效 | ||
| const enabledTabs = filteredTabs.filter(tab => !tab.disabled); | ||
| let selectedIndex = enabledTabs.findIndex(tab => tab.key === selectedKey) || 0; | ||
|
Comment on lines
+145
to
147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 过滤结果变化后要同步 Line 236 会在打开时无条件把 Also applies to: 206-217, 233-245 🤖 Prompt for AI Agents |
||
| const len = enabledTabs.length; | ||
|
|
||
| if (len === 0) return; | ||
|
|
||
| for (let i = 0; i < len; i += 1) { | ||
| selectedIndex = (selectedIndex + offset + len) % len; | ||
| const tab = enabledTabs[selectedIndex]; | ||
|
|
@@ -166,20 +191,58 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop | |
| } | ||
| } | ||
|
|
||
| // 搜索框 | ||
| const searchInput = isSearchable ? ( | ||
| <div className={`${dropdownPrefix}-search`}> | ||
| <input | ||
| type="text" | ||
| placeholder={placeholder} | ||
| value={mergedSearchValue} | ||
| onChange={e => { | ||
| const value = e.target.value; | ||
| setSearchValueFn(value); | ||
| onSearch?.(value); | ||
| }} | ||
| onKeyDown={e => { | ||
| if (e.key === 'ArrowDown') { | ||
| e.preventDefault(); | ||
| selectOffset(1); | ||
| } else if (e.key === 'ArrowUp') { | ||
| e.preventDefault(); | ||
| selectOffset(-1); | ||
| } else if (e.key === 'Enter' && selectedKey) { | ||
| e.preventDefault(); | ||
| onTabClick(selectedKey, e); | ||
| setOpen(false); | ||
| } | ||
| }} | ||
| onClick={e => e.stopPropagation()} | ||
| /> | ||
| </div> | ||
| ) : null; | ||
|
Comment on lines
+195
to
+222
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 为了提升用户体验和无障碍支持(Accessibility),建议在搜索框中加入以下改进:
|
||
|
|
||
| // ========================= Effect ========================= | ||
| useEffect(() => { | ||
| // We use query element here to avoid React strict warning | ||
| const ele = document.getElementById(selectedItemId); | ||
| if (ele?.scrollIntoView) { | ||
| ele.scrollIntoView(false); | ||
| ele.scrollIntoView({ block: 'center', behavior: 'smooth' }); | ||
| } | ||
| }, [selectedItemId, selectedKey]); | ||
|
|
||
| useEffect(() => { | ||
| if (!open) { | ||
| if (open) { | ||
| // 打开时,默认选中当前 activeKey 对应的 tab | ||
| if (!selectedKey && activeKey) { | ||
| setSelectedKey(activeKey); | ||
| } | ||
| } else { | ||
| setSelectedKey(null); | ||
| if (autoClearSearchValue && controlledSearchValue === undefined) { | ||
| setSearchValue(''); | ||
| } | ||
| } | ||
| }, [open]); | ||
| }, [open, activeKey]); | ||
|
Comment on lines
233
to
+245
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 当启用搜索并输入关键字过滤 Tab 时,如果当前选中的 建议在 |
||
|
|
||
| // ========================= Render ========================= | ||
| const moreStyle: React.CSSProperties = { | ||
|
|
@@ -193,18 +256,31 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop | |
|
|
||
| const overlayClassName = clsx(popupClassName, { [`${dropdownPrefix}-rtl`]: rtl }); | ||
|
|
||
| // 搜索框包裹 menu | ||
| const dropdownContent = isSearchable ? ( | ||
| <div className={`${dropdownPrefix}-container`}> | ||
| {searchInput} | ||
| {menu} | ||
| </div> | ||
| ) : ( | ||
| menu | ||
| ); | ||
|
|
||
| // 过滤 showSearch 属性,避免传给 Dropdown | ||
| const { showSearch: _s, ...dropdownProps } = moreProps; | ||
|
|
||
| const moreNode: React.ReactNode = mobile ? null : ( | ||
| <Dropdown | ||
| prefixCls={dropdownPrefix} | ||
| overlay={menu} | ||
| overlay={dropdownContent} | ||
| visible={tabs.length ? open : false} | ||
| onVisibleChange={setOpen} | ||
| overlayClassName={overlayClassName} | ||
| overlayStyle={popupStyle} | ||
| mouseEnterDelay={0.1} | ||
| mouseLeaveDelay={0.1} | ||
| getPopupContainer={getPopupContainer} | ||
| {...moreProps} | ||
| {...dropdownProps} | ||
| > | ||
| <button | ||
| type="button" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
README 这里请保持英文文档一致性。
Line 121 到 Line 132 新增的
more/MoreProps描述切成了中文,但这个 README 其余部分仍是英文。公开文档在 API 表格里突然切换语言会影响 npm / GitHub 读者阅读,建议统一翻成英文后再合入。🤖 Prompt for AI Agents