Skip to content
Open
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 config, pass through `@rc-component/dropdown` props |

### MoreProps

| name | type | default | description |
| --- | --- | --- | --- |
| icon | ReactNode | - | custom more button icon |
| showSearch | boolean \| ShowSearchConfig | - | whether to show search input |
| - placeholder | string | `'Search'` | search input placeholder |
| - searchValue | string | - | search input value (controlled) |
| - onSearch | (value: string) => void | - | search value change callback |
| - autoClearSearchValue | boolean | `true` | whether to clear search on close |

### 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>
);
};
103 changes: 94 additions & 9 deletions src/TabNavList/OperationNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Dropdown from '@rc-component/dropdown';
import Menu, { MenuItem } from '@rc-component/menu';
import { KeyCode } from '@rc-component/util';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { EditableConfig, Tab, TabsLocale, MoreProps } from '../interface';
import { getRemovable } from '../util';
import AddButton from './AddButton';
Expand Down 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,36 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop
// ======================== Dropdown ========================
const [open, setOpen] = useState(false);
const [selectedKey, setSelectedKey] = useState<string>(null);
const [searchValue, setSearchValue] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);

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,
filter: filterOption,
} = showSearchConfig;

const mergedSearchValue =
controlledSearchValue !== undefined ? controlledSearchValue : searchValue;
const setSearchValueFn = controlledSearchValue !== undefined ? () => {} : setSearchValue;

const filteredTabs = mergedSearchValue
? tabs.filter(tab => {
if (filterOption) {
return filterOption(tab, mergedSearchValue);
}
if (typeof tab.label === 'string') {
return tab.label.toLowerCase().includes(mergedSearchValue.toLowerCase());
}
return false;
})
: tabs;
Comment on lines +79 to +89

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 thread
coderabbitai[bot] marked this conversation as resolved.

const popupId = `${id}-more-popup`;
const dropdownPrefix = `${prefixCls}-dropdown`;
Expand Down Expand Up @@ -85,7 +114,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 +149,12 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop
);

function selectOffset(offset: -1 | 1) {
const enabledTabs = tabs.filter(tab => !tab.disabled);
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];
Expand Down Expand Up @@ -166,20 +197,63 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop
}
}

// 搜索框
const searchInput = isSearchable ? (
<div className={`${dropdownPrefix}-search`}>
<input
ref={searchInputRef}
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);
}
}}
/>
</div>
) : null;
Comment on lines +201 to +228

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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

自动聚焦已经完成,esc 本身支持


// ========================= 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) {
if (!selectedKey && activeKey) {
setSelectedKey(activeKey);
}

if (isSearchable) {
requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
}
} else {
setSelectedKey(null);
if (autoClearSearchValue && controlledSearchValue === undefined) {
setSearchValue('');
}
}
}, [open]);
}, [open, activeKey, isSearchable, autoClearSearchValue, controlledSearchValue]);

// ========================= Render =========================
const moreStyle: React.CSSProperties = {
Expand All @@ -193,18 +267,29 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop

const overlayClassName = clsx(popupClassName, { [`${dropdownPrefix}-rtl`]: rtl });

const dropdownContent = isSearchable ? (
<div className={`${dropdownPrefix}-container`}>
{searchInput}
{menu}
</div>
) : (
menu
);

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

export type ShowSearchConfig = {
placeholder?: string;
onSearch?: (value: string) => void;
searchValue?: string;
autoClearSearchValue?: boolean;
filter?: (tab: Tab, searchValue: string) => boolean;
};

export type MoreProps = {
icon?: moreIcon;
showSearch?: boolean | ShowSearchConfig;
} & Omit<DropdownProps, 'children'>;

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