From 39e7c31fa09928b557568cfaf727d86b1b5bc108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?w=C5=AB=20y=C4=81ng?= Date: Thu, 20 Nov 2025 01:00:32 +0800 Subject: [PATCH 1/9] feat(Select): support keyboard control --- packages/components/select/base/Option.tsx | 5 + .../components/select/base/PopupContent.tsx | 4 + packages/components/select/base/Select.tsx | 16 ++- .../select/hooks/useKeyboardControl.ts | 113 ++++++++++++++++++ 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 packages/components/select/hooks/useKeyboardControl.ts diff --git a/packages/components/select/base/Option.tsx b/packages/components/select/base/Option.tsx index ea2e97325b..c4871c0dc4 100644 --- a/packages/components/select/base/Option.tsx +++ b/packages/components/select/base/Option.tsx @@ -34,6 +34,8 @@ export interface SelectOptionProps optionLength?: number; isVirtual?: boolean; onRowMounted?: (rowData: { ref: HTMLElement; data: SelectOption }) => void; + hoverIndex?: number; + index: number; } const componentType = 'select'; @@ -57,6 +59,8 @@ const Option: React.FC = (props) => { style, className, isVirtual, + hoverIndex, + index, } = props; const label = propLabel || value; @@ -164,6 +168,7 @@ const Option: React.FC = (props) => { [`${classPrefix}-is-selected`]: selected, [`${classPrefix}-size-s`]: size === 'small', [`${classPrefix}-size-l`]: size === 'large', + [`${classPrefix}-${componentType}-option__hover`]: index === hoverIndex, })} key={value} onClick={handleSelect} diff --git a/packages/components/select/base/PopupContent.tsx b/packages/components/select/base/PopupContent.tsx index 7e3bd5d909..e25284b50f 100644 --- a/packages/components/select/base/PopupContent.tsx +++ b/packages/components/select/base/PopupContent.tsx @@ -57,6 +57,7 @@ interface SelectPopupProps children?: React.ReactNode; onCheckAllChange?: (checkAll: boolean, e: React.MouseEvent) => void; getPopupInstance?: () => HTMLDivElement; + hoverIndex: number; } const PopupContent = React.forwardRef((props, ref) => { @@ -80,6 +81,7 @@ const PopupContent = React.forwardRef((props, getPopupInstance, options: propsOptions, scroll: propsScroll, + hoverIndex, } = props; // 国际化文本初始化 @@ -191,6 +193,8 @@ const PopupContent = React.forwardRef((props, disabled={disabled} restData={restData} keys={keys} + index={index} + hoverIndex={hoverIndex} onCheckAllChange={onCheckAllChange} onRowMounted={handleRowMounted} {...(isVirtual diff --git a/packages/components/select/base/Select.tsx b/packages/components/select/base/Select.tsx index 6f390328a7..beceff12ba 100644 --- a/packages/components/select/base/Select.tsx +++ b/packages/components/select/base/Select.tsx @@ -25,6 +25,7 @@ import SelectInput, { type SelectInputValue, type SelectInputValueChangeContext import Tag from '../../tag'; import { selectDefaultProps } from '../defaultProps'; import useOptions, { isSelectOptionGroup } from '../hooks/useOptions'; +import useKeyboardControl from '../hooks/useKeyboardControl'; import { getKeyMapping, getSelectValueArr, getSelectedOptions } from '../util/helper'; import Option from './Option'; import OptionGroup from './OptionGroup'; @@ -118,6 +119,16 @@ const Select = forwardRefWithStatics( reserveKeyword, ); + const { handleKeyDown, hoverIndex } = useKeyboardControl({ + displayOptions: currentOptions as TdOptionProps[], + max, + multiple, + setInnerPopupVisible: setShowPopup, + innerPopupVisible: showPopup, + setInnerValue: onChange, + innerValue: value, + }); + const selectedLabel = useMemo(() => { const { labelKey } = getKeyMapping(keys); if (multiple) { @@ -128,8 +139,7 @@ const Select = forwardRefWithStatics( const handleShowPopup = (visible: boolean, ctx: PopupVisibleChangeContext) => { if (disabled) return; - visible && toggleIsScrolling(false); - !visible && onInputChange('', { trigger: 'blur' }); + visible ? toggleIsScrolling(false) : onInputChange('', { trigger: 'blur' }); setShowPopup(visible, ctx); }; @@ -415,6 +425,7 @@ const Select = forwardRefWithStatics( onCheckAllChange, getPopupInstance, scroll, + hoverIndex, }; return {childrenWithProps}; }; @@ -580,6 +591,7 @@ const Select = forwardRefWithStatics( inputProps={{ size, ...inputProps, + onKeydown: handleKeyDown, }} minCollapsedNum={minCollapsedNum} collapsedItems={collapsedItems} diff --git a/packages/components/select/hooks/useKeyboardControl.ts b/packages/components/select/hooks/useKeyboardControl.ts new file mode 100644 index 0000000000..7ed82cfc07 --- /dev/null +++ b/packages/components/select/hooks/useKeyboardControl.ts @@ -0,0 +1,113 @@ +import { useState, KeyboardEvent } from 'react'; +import type { SelectOption, TdOptionProps, SelectValue } from '../type'; +import { getSelectValueArr } from '../util/helper'; + +export type useKeyboardControlType = { + displayOptions: TdOptionProps[]; + innerPopupVisible: boolean; + setInnerPopupVisible: any; + setInnerValue: Function; + innerValue: SelectValue; + multiple: boolean; + max: number; +}; +export default function useKeyboardControl({ + displayOptions, + innerPopupVisible, + setInnerPopupVisible, + setInnerValue, + innerValue, + multiple, + max, +}: useKeyboardControlType) { + const [hoverIndex, changeHoverIndex] = useState(-1); + const filteredOptions = useState([]); // 处理普通场景选项过滤键盘选中的问题 + const virtualFilteredOptions = useState([]); // 处理虚拟滚动下选项过滤通过键盘选择的问题 + + const handleKeyDown = (_value, { e }: { e: KeyboardEvent }) => { + const optionsListLength = displayOptions.length; + + let newIndex = hoverIndex; + + switch (e.code) { + case 'ArrowUp': + e.preventDefault(); + if (hoverIndex === -1) { + newIndex = 0; + } else if (hoverIndex === 0 || hoverIndex > optionsListLength - 1) { + newIndex = optionsListLength - 1; + } else { + newIndex -= 1; + } + if (displayOptions[newIndex]?.disabled) { + newIndex -= 1; + } + changeHoverIndex(newIndex); + break; + case 'ArrowDown': + e.preventDefault(); + + if (hoverIndex === -1 || hoverIndex >= optionsListLength - 1) { + newIndex = 0; + } else { + newIndex += 1; + } + if (displayOptions[newIndex]?.disabled) { + newIndex += 1; + } + changeHoverIndex(newIndex); + break; + case 'Enter': + if (hoverIndex === -1) break; + + if (!innerPopupVisible) { + setInnerPopupVisible(true, { e }); + break; + } + + if (!multiple) { + const selectedOptions = displayOptions[hoverIndex]; + setInnerValue(selectedOptions.value, { + option: selectedOptions?.[0], + selectedOptions: displayOptions[hoverIndex], + trigger: 'check', + e, + }); + setInnerPopupVisible(false, { e }); + } else { + if (hoverIndex === -1) return; + + if (displayOptions[hoverIndex].checkAll) { + // onCheckAllChange(!isCheckAll); + return; + } + + const optionValue = displayOptions[hoverIndex]?.value; + + if (!optionValue) return; + const newValue = innerValue as Array; + const valueIndex = newValue.indexOf(optionValue); + const isSelected = valueIndex > -1; + + const values = getSelectValueArr(innerValue, optionValue, isSelected); + + if (max > 0 && values.length > max) return; // 如果已选达到最大值 则不处理 + setInnerValue(values, { + option: [], + trigger: !isSelected ? 'check' : 'uncheck', + e, + }); + } + break; + case 'Escape': + setInnerPopupVisible(false, { e }); + } + }; + + return { + handleKeyDown, + hoverIndex, + filteredOptions, + virtualFilteredOptions, + }; +} From c08ac57b53611c616b239d9ccec78d70e34068d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?w=C5=AB=20y=C4=81ng?= Date: Mon, 24 Nov 2025 15:29:51 +0800 Subject: [PATCH 2/9] chore: complete keyboard control --- packages/components/select/base/Option.tsx | 8 +- .../components/select/base/PopupContent.tsx | 4 +- packages/components/select/base/Select.tsx | 139 +++++++++--------- .../select/hooks/useKeyboardControl.ts | 39 ++++- packages/components/tag-input/TagInput.tsx | 14 +- 5 files changed, 125 insertions(+), 79 deletions(-) diff --git a/packages/components/select/base/Option.tsx b/packages/components/select/base/Option.tsx index c4871c0dc4..aab2855a32 100644 --- a/packages/components/select/base/Option.tsx +++ b/packages/components/select/base/Option.tsx @@ -34,8 +34,7 @@ export interface SelectOptionProps optionLength?: number; isVirtual?: boolean; onRowMounted?: (rowData: { ref: HTMLElement; data: SelectOption }) => void; - hoverIndex?: number; - index: number; + isKeyboardHovered?: boolean; } const componentType = 'select'; @@ -59,8 +58,7 @@ const Option: React.FC = (props) => { style, className, isVirtual, - hoverIndex, - index, + isKeyboardHovered, } = props; const label = propLabel || value; @@ -168,7 +166,7 @@ const Option: React.FC = (props) => { [`${classPrefix}-is-selected`]: selected, [`${classPrefix}-size-s`]: size === 'small', [`${classPrefix}-size-l`]: size === 'large', - [`${classPrefix}-${componentType}-option__hover`]: index === hoverIndex, + [`${classPrefix}-${componentType}-option__hover`]: isKeyboardHovered, })} key={value} onClick={handleSelect} diff --git a/packages/components/select/base/PopupContent.tsx b/packages/components/select/base/PopupContent.tsx index e25284b50f..0969967445 100644 --- a/packages/components/select/base/PopupContent.tsx +++ b/packages/components/select/base/PopupContent.tsx @@ -179,6 +179,7 @@ const PopupContent = React.forwardRef((props, // 当 keys 属性配置 content 作为 value 或 label 时,确保 restData 中也包含它, 不参与渲染计算 const { content } = item as TdOptionProps; const shouldOmitContent = Object.values(keys || {}).includes('content'); + const isKeyboardHovered = hoverIndex === index; return (