Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 关闭时是否自动清空搜索值 |
Comment on lines +121 to +132

Copy link
Copy Markdown

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 121 - 132, The new `more` / `MoreProps` API table
entries in the README are written in Chinese while the rest of the document is
English; update these descriptions to English to keep the public documentation
language consistent. Please revise the `MoreProps` section and its nested fields
(`icon`, `showSearch`, `placeholder`, `searchValue`, `onSearch`,
`autoClearSearchValue`) so the wording matches the surrounding README style.


### TabItem

Expand Down
44 changes: 43 additions & 1 deletion assets/dropdown.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions docs/demo/search-dropdown.md
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>
66 changes: 66 additions & 0 deletions docs/examples/search-dropdown.tsx
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>
);
};
92 changes: 84 additions & 8 deletions src/TabNavList/OperationNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop
tabs,
locale,
mobile,
activeKey,
more: moreProps = {},
style,
className,
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

rc-tabs 中,tab.label 的类型是 React.ReactNode。如果用户传入了非字符串的 React 节点(例如包含图标或 HTML 标签的 <span>Tab 1</span>),直接使用 String(tab.label) 会得到 "[object Object]",导致搜索过滤失效,甚至在搜索 "object" 时错误地匹配到所有这类 Tab。

建议实现一个简单的文本提取函数,或者在过滤时安全地处理非字符串类型的 label

  const getLabelText = (node: React.ReactNode): string => {
    if (!node) return '';
    if (typeof node === 'string' || typeof node === 'number') {
      return String(node);
    }
    if (React.isValidElement(node) && node.props && 'children' in node.props) {
      return getLabelText(node.props.children);
    }
    if (Array.isArray(node)) {
      return node.map(getLabelText).join('');
    }
    return '';
  };

  const filteredTabs = mergedSearchValue
    ? tabs.filter(tab => getLabelText(tab.label).toLowerCase().includes(mergedSearchValue.toLowerCase()))
    : tabs;

Comment on lines +80 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

不要用 String(tab.label) 作为搜索源。

Line 81 这里把 label 直接转成字符串,遇到 ReactNode 会变成 "[object Object]"Tabs 的公开用法本身就允许元素标签,README.md Line 63 和 Line 70 也是 <p>...</p>,所以这会让带样式的标签在 “more” 搜索里完全搜不到。这里需要提取可见文本,或者提供可配置的 filter text,而不是依赖 String(...)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TabNavList/OperationNode.tsx` around lines 80 - 82, The search filtering
in OperationNode should not rely on String(tab.label), because ReactNode labels
can stringify to “[object Object]” and fail in the “more” search. Update the
filtering logic around filteredTabs to use visible text derived from the tab
label, or add a configurable filter text source, so tabs with element labels
still match correctly. Keep the fix localized to the tab filtering path and
preserve the existing Tabs API behavior.


const popupId = `${id}-more-popup`;
const dropdownPrefix = `${prefixCls}-dropdown`;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

过滤结果变化后要同步 selectedKey

Line 236 会在打开时无条件把 activeKey 设为 selectedKey,但 OperationNode 接到的是隐藏 tab 列表,activeKey 很可能根本不在当前结果集里。之后 Line 213 到 Line 216 的 Enter 分支仍会按这个过期 key 触发 onTabClick,而且 Line 147 的 findIndex(...) || 0 也不会兜底,因为 -1 在 JS 里是真值。结果就是:过滤把当前项排除后,键盘确认和高亮都可能落到一个根本没渲染的项上。建议在 openfilteredTabs 变化时,把 selectedKey 收敛到当前结果集里的 activeKey / 第一个未禁用项 / null

Also applies to: 206-217, 233-245

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TabNavList/OperationNode.tsx` around lines 145 - 147, `OperationNode`
needs to keep `selectedKey` in sync with the current `filteredTabs` result set.
Update the selection logic around `enabledTabs`, the Enter handler, and the
`open`/`filteredTabs` effect so `selectedKey` is always coerced to a valid key
in the visible tabs (prefer current `activeKey`, otherwise the first enabled
tab, otherwise `null`). Also fix the `findIndex(...) || 0` fallback in
`OperationNode` so a missing match does not preserve an invalid index,
preventing keyboard confirm and highlight from pointing at hidden tabs.

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];
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了提升用户体验和无障碍支持(Accessibility),建议在搜索框中加入以下改进:

  1. 自动聚焦(Auto-focus):当下拉菜单打开且启用了搜索时,搜索输入框应该自动获得焦点,方便用户直接开始输入。
  2. 支持 Esc 键关闭:在输入框中按下 Esc 键时,应该能够关闭下拉菜单。
  const searchInput = isSearchable ? (
    <div className={`${dropdownPrefix}-search`}>
      <input
        ref={node => {
          if (node && open && document.activeElement !== node) {
            setTimeout(() => node.focus(), 0);
          }
        }}
        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 === 'Escape') {
            e.preventDefault();
            setOpen(false);
          } else if (e.key === 'Enter' && selectedKey) {
            e.preventDefault();
            onTabClick(selectedKey, e);
            setOpen(false);
          }
        }}
        onClick={e => e.stopPropagation()}
      />
    </div>
  ) : 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]);
Comment on lines 233 to +245

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

当启用搜索并输入关键字过滤 Tab 时,如果当前选中的 selectedKey 被过滤掉了(不再显示在列表中),按下 Enter 键仍然会触发 onTabClick(selectedKey),导致选中了一个已被隐藏的 Tab。此外,键盘上下键导航也会从一个不可见的项开始,导致体验不佳。

建议在 filteredTabs 变化时,如果当前的 selectedKey 不在过滤后的可见列表中,自动将 selectedKey 重置为过滤后列表中的第一个有效项。

  useEffect(() => {
    if (open) {
      // 打开时,默认选中当前 activeKey 对应的 tab
      if (!selectedKey && activeKey) {
        setSelectedKey(activeKey);
      }
    } else {
      setSelectedKey(null);
      if (autoClearSearchValue && controlledSearchValue === undefined) {
        setSearchValue('');
      }
    }
  }, [open, activeKey]);

  useEffect(() => {
    if (open && isSearchable) {
      const enabledTabs = filteredTabs.filter(tab => !tab.disabled);
      if (enabledTabs.length > 0 && !enabledTabs.some(tab => tab.key === selectedKey)) {
        setSelectedKey(enabledTabs[0].key);
      } else if (enabledTabs.length === 0) {
        setSelectedKey(null);
      }
    }
  }, [open, filteredTabs, isSearchable, selectedKey]);


// ========================= Render =========================
const moreStyle: React.CSSProperties = {
Expand All @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@ export type TriggerProps = {
trigger?: 'hover' | 'click';
};
export type moreIcon = React.ReactNode;

export type ShowSearchConfig = {
/** 搜索框占位文字 */
placeholder?: string;
/** 搜索值变化回调 */
onSearch?: (value: string) => void;
/** 搜索框的值(受控模式) */
searchValue?: string;
/** 是否在关闭时自动清空搜索值,默认 true */
autoClearSearchValue?: boolean;
};

export type MoreProps = {
icon?: moreIcon;
/** 是否显示搜索框,或配置搜索框选项 */
showSearch?: boolean | ShowSearchConfig;
} & Omit<DropdownProps, 'children'>;

export type SizeInfo = [width: number, height: number];
Expand Down
Loading
Loading