diff --git a/README.md b/README.md index 8b89fc66..a75c3bec 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 | 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..a1fdc2a0 --- /dev/null +++ b/docs/examples/search-dropdown.tsx @@ -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 ( + {}} + 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

+ + +

filter

+ ({ + key: String(i + 1), + label: Tab {i + 1}, + children: `Content of Tab ${i + 1}`, + }))} + more={{ + trigger: 'click', + showSearch: { + placeholder: 'Keep search value', + filter: (tab, value) => tab.key.includes(value), + }, + }} + /> +
+ ); +}; diff --git a/src/TabNavList/OperationNode.tsx b/src/TabNavList/OperationNode.tsx index a56ee5a9..d6704605 100644 --- a/src/TabNavList/OperationNode.tsx +++ b/src/TabNavList/OperationNode.tsx @@ -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'; @@ -39,6 +39,7 @@ const OperationNode = React.forwardRef((prop tabs, locale, mobile, + activeKey, more: moreProps = {}, style, className, @@ -56,8 +57,36 @@ const OperationNode = React.forwardRef((prop // ======================== Dropdown ======================== const [open, setOpen] = useState(false); const [selectedKey, setSelectedKey] = useState(null); + const [searchValue, setSearchValue] = useState(''); + const searchInputRef = useRef(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; const popupId = `${id}-more-popup`; const dropdownPrefix = `${prefixCls}-dropdown`; @@ -85,7 +114,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 +149,12 @@ const OperationNode = React.forwardRef((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]; @@ -134,17 +165,9 @@ const OperationNode = React.forwardRef((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); @@ -159,27 +182,72 @@ const OperationNode = React.forwardRef((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 ? ( +
+ { + const value = e.target.value; + setSearchValueFn(value); + onSearch?.(value); + }} + onKeyDown={onKeyboardNavigation} + /> +
+ ) : 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) { + 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 = { @@ -193,10 +261,21 @@ const OperationNode = React.forwardRef((prop const overlayClassName = clsx(popupClassName, { [`${dropdownPrefix}-rtl`]: rtl }); + const dropdownContent = isSearchable ? ( +
+ {searchInput} + {menu} +
+ ) : ( + menu + ); + + const { showSearch: _s, ...dropdownProps } = moreProps; + const moreNode: React.ReactNode = mobile ? null : ( ((prop mouseEnterDelay={0.1} mouseLeaveDelay={0.1} getPopupContainer={getPopupContainer} - {...moreProps} + {...dropdownProps} >