Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### 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';

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>
);
};

// Controlled mode example
const ControlledDemo = ({ items }: { items: any[] }) => {
const [searchValue, setSearchValue] = useState('');

return (
<Tabs
activeKey="1"
onChange={() => {}}
items={items}
Comment thread
EmilyyyLiu marked this conversation as resolved.
Outdated
more={{
showSearch: {
placeholder: 'Controlled search...',
searchValue,
onSearch: setSearchValue,
},
}}
/>
);
};
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 thread
EmilyyyLiu marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 thread
EmilyyyLiu marked this conversation as resolved.
Outdated
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 thread
EmilyyyLiu marked this conversation as resolved.

// ========================= 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 thread
EmilyyyLiu marked this conversation as resolved.
Outdated

// ========================= 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