diff --git a/README.md b/README.md index 76ab4366..03acc4db 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,18 @@ ReactDom.render( | editable | { onEdit(type: 'add' \| 'remove', info: { key, event }), showAdd: boolean, removeIcon: ReactNode, addIcon: ReactNode } | - | config tab editable | | locale | { dropdownAriaLabel: string, removeAriaLabel: string, addAriaLabel: string } | - | Accessibility locale help text | | moreIcon | ReactNode | - | collapse icon | +| more | MoreProps | - | dropdown 配置,透传 `@rc-component/dropdown` 的属性 | + +### MoreProps + +| name | type | default | description | +| ---------------------- | --------------------------- | ---------- | ------------------------ | +| icon | ReactNode | - | 自定义更多按钮图标 | +| showSearch | boolean \| ShowSearchConfig | - | 是否显示搜索框 | +| - placeholder | string | `'Search'` | 搜索框占位文字 | +| - searchValue | string | - | 搜索框的值(受控模式) | +| - onSearch | (value: string) => void | - | 搜索值变化回调 | +| - autoClearSearchValue | boolean | `true` | 关闭时是否自动清空搜索值 | ### TabItem diff --git a/assets/dropdown.less b/assets/dropdown.less index 4089e70a..97ea94f3 100644 --- a/assets/dropdown.less +++ b/assets/dropdown.less @@ -5,16 +5,58 @@ background: #fefefe; border: 1px solid black; max-height: 200px; - overflow: auto; &-hidden { display: none; } + // 搜索框容器样式(有 search 时使用) + &-container { + display: flex; + flex-direction: column; + max-height: 200px; + overflow: hidden; + + // 搜索框固定在顶部 + .@{tabs-prefix-cls}-dropdown-search { + padding: 8px; + flex-shrink: 0; + border-bottom: 1px solid #f0f0f0; + box-sizing: border-box; + + input { + width: 100%; + max-width: 100%; + padding: 4px 8px; + border: 1px solid #d9d9d9; + border-radius: 4px; + outline: none; + box-sizing: border-box; + + &:focus { + border-color: #1677ff; + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1); + } + } + } + + // menu 区域可滚动 + .@{tabs-prefix-cls}-dropdown-menu { + margin: 0; + padding: 0; + list-style: none; + overflow: auto; + flex: 1; + } + } + + // 非 search 模式的 menu 样式 &-menu { margin: 0; padding: 0; list-style: none; + overflow: auto; + max-height: 200px; &-item { padding: 4px 8px; diff --git a/docs/demo/search-dropdown.md b/docs/demo/search-dropdown.md new file mode 100644 index 00000000..66b36c29 --- /dev/null +++ b/docs/demo/search-dropdown.md @@ -0,0 +1,8 @@ +--- +title: Search Dropdown +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/search-dropdown.tsx b/docs/examples/search-dropdown.tsx new file mode 100644 index 00000000..093c0d47 --- /dev/null +++ b/docs/examples/search-dropdown.tsx @@ -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 ( + {}} + 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 ( +
+

Basic Usage

+ + +

Controlled Mode

+ + +

Keep Search Value on Close

+ +
+ ); +}; diff --git a/src/TabNavList/OperationNode.tsx b/src/TabNavList/OperationNode.tsx index a56ee5a9..d3b721a9 100644 --- a/src/TabNavList/OperationNode.tsx +++ b/src/TabNavList/OperationNode.tsx @@ -39,6 +39,7 @@ const OperationNode = React.forwardRef((prop tabs, locale, mobile, + activeKey, more: moreProps = {}, style, className, @@ -56,8 +57,29 @@ const OperationNode = React.forwardRef((prop // ======================== Dropdown ======================== const [open, setOpen] = useState(false); const [selectedKey, setSelectedKey] = useState(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; const popupId = `${id}-more-popup`; const dropdownPrefix = `${prefixCls}-dropdown`; @@ -85,7 +107,7 @@ const OperationNode = React.forwardRef((prop selectedKeys={[selectedKey]} aria-label={dropdownAriaLabel !== undefined ? dropdownAriaLabel : 'expanded dropdown'} > - {tabs.map(tab => { + {filteredTabs.map(tab => { const { closable, disabled, closeIcon, key, label } = tab; const removable = getRemovable(closable, closeIcon, editable, disabled); return ( @@ -120,10 +142,13 @@ const OperationNode = React.forwardRef((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; 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((prop } } + // 搜索框 + const searchInput = isSearchable ? ( +
+ { + 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()} + /> +
+ ) : null; + // ========================= 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]); // ========================= Render ========================= const moreStyle: React.CSSProperties = { @@ -193,10 +256,23 @@ const OperationNode = React.forwardRef((prop const overlayClassName = clsx(popupClassName, { [`${dropdownPrefix}-rtl`]: rtl }); + // 搜索框包裹 menu + const dropdownContent = isSearchable ? ( +
+ {searchInput} + {menu} +
+ ) : ( + menu + ); + + // 过滤 showSearch 属性,避免传给 Dropdown + const { showSearch: _s, ...dropdownProps } = moreProps; + const moreNode: React.ReactNode = mobile ? null : ( ((prop mouseEnterDelay={0.1} mouseLeaveDelay={0.1} getPopupContainer={getPopupContainer} - {...moreProps} + {...dropdownProps} >