Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Then open `http://localhost:8000`.
| `indicator` | `{ size?: GetIndicatorSize; align?: 'start' \| 'center' \| 'end' }` | - | Indicator size and alignment. |
| `items` | Tab[] | [] | Tab items. |
| `locale` | TabsLocale | - | Accessibility locale text. |
| `more` | MoreProps | - | Overflow dropdown config. |
| `more` | MoreProps | - | dropdown config, pass through `@rc-component/dropdown` props |
| `onChange` | `(activeKey: string) => void` | - | Triggered when active tab changes. |
| `onTabClick` | `(activeKey, event) => void` | - | Triggered when a tab is clicked. |
| `onTabScroll` | `({ direction }) => void` | - | Triggered when tab navigation scrolls. |
Expand All @@ -88,6 +88,17 @@ Then open `http://localhost:8000`.
| `tabBarStyle` | React.CSSProperties | - | Tab bar style. |
| `tabPosition` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'top'` | Tab position. |

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

### Tab

| Name | Type | Default | Description |
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>
84 changes: 84 additions & 0 deletions docs/examples/search-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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,
},
}}
/>

<h3>filter</h3>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
items={Array.from({ length: 30 }, (_, i) => ({
key: String(i + 1),
label: <span>Tab {i + 1}</span>,
children: `Content of Tab ${i + 1}`,
}))}
more={{
trigger: 'click',
showSearch: {
placeholder: 'Keep search value',
filter: (tab, value) => tab.key.includes(value),
},
}}
/>
</div>
);
};
117 changes: 98 additions & 19 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 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 +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 All @@ -134,17 +165,9 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop
}
}

function onKeyDown(e: React.KeyboardEvent) {
function onKeyboardNavigation(e: React.KeyboardEvent) {
const { which } = e;

if (!open) {
if ([KeyCode.DOWN, KeyCode.SPACE, KeyCode.ENTER].includes(which)) {
setOpen(true);
e.preventDefault();
}
return;
}

switch (which) {
case KeyCode.UP:
selectOffset(-1);
Expand All @@ -159,27 +182,72 @@ const OperationNode = React.forwardRef<HTMLDivElement, OperationNodeProps>((prop
break;
case KeyCode.SPACE:
case KeyCode.ENTER:
if (selectedKey !== null) {
if (selectedKey && filteredTabs.some(t => t.key === selectedKey)) {
onTabClick(selectedKey, e);
}
break;
}
}

function onKeyDown(e: React.KeyboardEvent) {
const { which } = e;

if (!open) {
if ([KeyCode.DOWN, KeyCode.SPACE, KeyCode.ENTER].includes(which)) {
setOpen(true);
e.preventDefault();
}
return;
}

onKeyboardNavigation(e);
}

// 搜索框
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={onKeyboardNavigation}
/>
</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) {
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 +261,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
Loading
Loading