From f60aaf0bd11442200b647132beeb5b964c7a1afa Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 24 Jul 2026 12:11:36 +0800 Subject: [PATCH 1/3] fix(DatePicker): unexpected scroll position reset in the year selector --- .../components/date-picker/base/Header.tsx | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/components/date-picker/base/Header.tsx b/packages/components/date-picker/base/Header.tsx index a59f2e3a0e..694da45e7d 100644 --- a/packages/components/date-picker/base/Header.tsx +++ b/packages/components/date-picker/base/Header.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import useConfig from '../../hooks/useConfig'; import { useLocaleReceiver } from '../../locale/LocalReceiver'; @@ -44,6 +44,9 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { const { now, months, preMonth, preYear, nextMonth, nextYear, preDecade, nextDecade } = useDatePickerLocalConfig(); const scrollAnchorRef = useRef('default'); + const scrollTopRef = useRef(null); // 底部追加前记录 scrollTop,用于 DOM 更新后恢复滚动位置 + const yearPopupContentRef = useRef(null); + const { paginationDisabled, monthHasAnyAllowed, @@ -70,7 +73,11 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { const end = i + 9; // 仅加入可选的年代 if (decadeHasAnyAllowed(end)) { - options.push({ label: `${i} - ${end}`, value: i + 9, disabled: false }); + options.push({ + label: `${i} - ${end}`, + value: i + 9, + disabled: false, + }); } } } else { @@ -78,8 +85,18 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { yearHasAnyAllowed(year) && options.push({ label: `${year}`, value: year, disabled: false }); for (let i = 1; i <= 10; i++) { - yearHasAnyAllowed(year + i) && options.push({ label: `${year + i}`, value: year + i, disabled: false }); - yearHasAnyAllowed(year - i) && options.unshift({ label: `${year - i}`, value: year - i, disabled: false }); + yearHasAnyAllowed(year + i) && + options.push({ + label: `${year + i}`, + value: year + i, + disabled: false, + }); + yearHasAnyAllowed(year - i) && + options.unshift({ + label: `${year - i}`, + value: year - i, + disabled: false, + }); } } @@ -134,7 +151,12 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { } } else { for (let i = year - extraYear - 1; i > year - extraYear - 50; i -= 10) { - decadeHasAnyAllowed(i) && options.unshift({ label: `${i - 9} - ${i}`, value: i, disabled: false }); + decadeHasAnyAllowed(i) && + options.unshift({ + label: `${i - 9} - ${i}`, + value: i, + disabled: false, + }); } } } else if (type === 'add') { @@ -178,35 +200,40 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { const firstYear = yearOptions[0].value; const options = loadMoreYear(firstYear, 'reduce'); + if (!options.length) return; setYearOptions([...options, ...yearOptions]); } function handlePanelBottomClick(e?: React.MouseEvent) { e?.stopPropagation?.(); e?.nativeEvent?.stopImmediatePropagation(); - const lastYear = yearOptions.slice(-1)[0].value; const options = loadMoreYear(lastYear, 'add'); + if (!options.length) return; + const contentEl = yearPopupContentRef.current; + if (contentEl) { + scrollTopRef.current = contentEl.scrollTop; + } setYearOptions([...yearOptions, ...options]); } // 滚动顶部底部自动加载 function handleScroll({ e }) { - if (e.target.scrollTop === 0) { - showPanelTop && handlePanelTopClick(); + const target = e.target as HTMLElement; + yearPopupContentRef.current = target; + if (target.scrollTop <= 0) { + if (showPanelTop) handlePanelTopClick(); scrollAnchorRef.current = 'top'; - } else if (e.target.scrollTop === e.target.scrollHeight - e.target.clientHeight) { - showPanelBottom && handlePanelBottomClick(); + } else if (Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) <= 1) { + if (showPanelBottom) handlePanelBottomClick(); scrollAnchorRef.current = 'bottom'; } } function handleUpdateScrollTop(content: HTMLElement) { if (scrollAnchorRef.current === 'top') { - // eslint-disable-next-line no-param-reassign content.scrollTop = 30 * 10; } else if (scrollAnchorRef.current === 'bottom') { - // eslint-disable-next-line no-param-reassign content.scrollTop = content.scrollHeight - 30 * 10; } else { const firstSelectedNode: HTMLDivElement = content?.querySelector(`.${classPrefix}-is-selected`); @@ -221,7 +248,6 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { content.offsetTop - (content.clientHeight - firstSelectedNode.clientHeight) + elementBottomHeight; - // eslint-disable-next-line no-param-reassign content.scrollTop = updateValue; } } @@ -232,6 +258,14 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { setYearOptions(yearRange); }, [initOptions, year]); + useLayoutEffect(() => { + const savedScrollTop = scrollTopRef.current; + const contentEl = yearPopupContentRef.current; + if (savedScrollTop == null || !contentEl) return; + scrollTopRef.current = null; + contentEl.scrollTop = savedScrollTop; + }, [yearOptions]); + return (
From 81ae5aea11d7be8e64d28f7ada383b16c133b723 Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 24 Jul 2026 15:14:47 +0800 Subject: [PATCH 2/3] chore: optimize ts --- packages/components/date-picker/base/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/date-picker/base/Header.tsx b/packages/components/date-picker/base/Header.tsx index 694da45e7d..9910724290 100644 --- a/packages/components/date-picker/base/Header.tsx +++ b/packages/components/date-picker/base/Header.tsx @@ -43,7 +43,7 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { const { now, months, preMonth, preYear, nextMonth, nextYear, preDecade, nextDecade } = useDatePickerLocalConfig(); - const scrollAnchorRef = useRef('default'); + const scrollAnchorRef = useRef<'default' | 'top' | 'bottom'>('default'); const scrollTopRef = useRef(null); // 底部追加前记录 scrollTop,用于 DOM 更新后恢复滚动位置 const yearPopupContentRef = useRef(null); From 0c0ef0793dd823d762dd35cb36dc62a39c8a7844 Mon Sep 17 00:00:00 2001 From: Rylan Date: Mon, 27 Jul 2026 17:03:54 +0800 Subject: [PATCH 3/3] feat(DatePicker): load previous years on upward year select scroll --- .../components/date-picker/base/Header.tsx | 205 ++++++++++++------ packages/components/select/base/Select.tsx | 41 ++-- .../select/hooks/useKeyboardControl.ts | 85 ++++++-- 3 files changed, 237 insertions(+), 94 deletions(-) diff --git a/packages/components/date-picker/base/Header.tsx b/packages/components/date-picker/base/Header.tsx index 9910724290..dc05272879 100644 --- a/packages/components/date-picker/base/Header.tsx +++ b/packages/components/date-picker/base/Header.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import useConfig from '../../hooks/useConfig'; +import useLatest from '../../hooks/useLatest'; import { useLocaleReceiver } from '../../locale/LocalReceiver'; import { PaginationMini } from '../../pagination'; import Select from '../../select'; @@ -43,9 +44,30 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { const { now, months, preMonth, preYear, nextMonth, nextYear, preDecade, nextDecade } = useDatePickerLocalConfig(); + const handleTopRef = useLatest(handlePanelTopClick); + const scrollAnchorRef = useRef<'default' | 'top' | 'bottom'>('default'); - const scrollTopRef = useRef(null); // 底部追加前记录 scrollTop,用于 DOM 更新后恢复滚动位置 + const anchorRef = useRef<{ + direction: 'top' | 'bottom'; + scrollHeight: number; + scrollTop: number; + } | null>(null); // 顶和底加载锚点 const yearPopupContentRef = useRef(null); + const wheelBoundRef = useRef(false); // wheel 事件是否已经绑定过 + const loadingRef = useRef(false); // 顶和底加载共用的守卫 + const wheelDeltaRef = useRef(0); // 向上超过阈值才触发(控制触控板灵敏度) + + function resetLoadState(resetDom = false) { + scrollAnchorRef.current = 'default'; + loadingRef.current = false; + wheelDeltaRef.current = 0; + anchorRef.current = null; + if (resetDom) { + // 弹层关闭,重置 DOM 和事件绑定引用 + yearPopupContentRef.current = null; + wheelBoundRef.current = false; + } + } const { paginationDisabled, @@ -124,15 +146,14 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { disabled: !monthHasAnyAllowed(year, index), })); - // 顶部/底部是否展示“加载更多”内容(...) - const showPanelTop = useMemo(() => { + const canLoadTop = useMemo(() => { const options = yearOptions; if (!options.length) return false; const first = options[0].value; return canLoadMoreTop(first); }, [canLoadMoreTop, yearOptions]); - const showPanelBottom = useMemo(() => { + const canLoadBottom = useMemo(() => { const options = yearOptions; if (!options.length) return false; const last = options[options.length - 1].value; @@ -194,76 +215,140 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { const headerClassName = `${classPrefix}-date-picker__header`; const showMonthPicker = mode === 'date' || mode === 'week'; - function handlePanelTopClick(e?: React.MouseEvent) { - e?.stopPropagation?.(); - e?.nativeEvent?.stopImmediatePropagation?.(); + function loadMore(direction: 'top' | 'bottom') { + if (loadingRef.current) return; + if (direction === 'top' && !canLoadTop) return; + if (direction === 'bottom' && !canLoadBottom) return; - const firstYear = yearOptions[0].value; - const options = loadMoreYear(firstYear, 'reduce'); + const options = + direction === 'top' + ? loadMoreYear(yearOptions[0]?.value, 'reduce') + : loadMoreYear(yearOptions[yearOptions.length - 1]?.value, 'add'); if (!options.length) return; - setYearOptions([...options, ...yearOptions]); + + // 记录锚点,采用 scrollHeight 差值 + // 不依赖 DOM 节点,直接用容器高度变化量补偿 + const contentEl = yearPopupContentRef.current; + if (contentEl) { + anchorRef.current = { + direction, + scrollHeight: contentEl.scrollHeight, + scrollTop: contentEl.scrollTop, + }; + } else { + anchorRef.current = null; + } + + loadingRef.current = true; + scrollAnchorRef.current = direction; + setYearOptions((prev) => (direction === 'top' ? [...options, ...prev] : [...prev, ...options])); + } + + function triggerLoadTop() { + loadMore('top'); + } + + function handlePanelTopClick() { + triggerLoadTop(); } function handlePanelBottomClick(e?: React.MouseEvent) { e?.stopPropagation?.(); e?.nativeEvent?.stopImmediatePropagation(); - const lastYear = yearOptions.slice(-1)[0].value; - const options = loadMoreYear(lastYear, 'add'); - if (!options.length) return; - const contentEl = yearPopupContentRef.current; - if (contentEl) { - scrollTopRef.current = contentEl.scrollTop; - } - setYearOptions([...yearOptions, ...options]); + loadMore('bottom'); + } + + // 首次拿到弹层内容容器时绑定 wheel 事件 + // (scrollTop=0 后继续上滚不再触发 scroll,需 wheel 兜底) + function bindWheel(target: HTMLElement) { + if (wheelBoundRef.current || !target) return; + wheelBoundRef.current = true; + target.addEventListener( + 'wheel', + (ev: WheelEvent) => { + const el = yearPopupContentRef.current; + if (!el || el.scrollTop > 0) { + // 离开顶部时重置累积,确保下次到顶需要重新积累 + wheelDeltaRef.current = 0; + return; + } + if (ev.deltaY < 0) { + wheelDeltaRef.current += Math.abs(ev.deltaY); + if (wheelDeltaRef.current >= 50) { + wheelDeltaRef.current = 0; + handleTopRef.current?.(); + } + } else { + wheelDeltaRef.current = 0; + } + }, + { passive: true }, + ); } // 滚动顶部底部自动加载 function handleScroll({ e }) { const target = e.target as HTMLElement; yearPopupContentRef.current = target; - if (target.scrollTop <= 0) { - if (showPanelTop) handlePanelTopClick(); - scrollAnchorRef.current = 'top'; - } else if (Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) <= 1) { - if (showPanelBottom) handlePanelBottomClick(); - scrollAnchorRef.current = 'bottom'; + bindWheel(target); + // 触底加载 + if (Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) <= 1) { + loadMore('bottom'); } } function handleUpdateScrollTop(content: HTMLElement) { - if (scrollAnchorRef.current === 'top') { - content.scrollTop = 30 * 10; - } else if (scrollAnchorRef.current === 'bottom') { - content.scrollTop = content.scrollHeight - 30 * 10; - } else { - const firstSelectedNode: HTMLDivElement = content?.querySelector(`.${classPrefix}-is-selected`); - - if (firstSelectedNode) { - const { paddingBottom } = getComputedStyle(firstSelectedNode); - const { marginBottom } = getComputedStyle(content); - const elementBottomHeight = parseInt(paddingBottom, 10) + parseInt(marginBottom, 10); - // 小于0时不需要特殊处理,会被设为0 - const updateValue = - firstSelectedNode.offsetTop - - content.offsetTop - - (content.clientHeight - firstSelectedNode.clientHeight) + - elementBottomHeight; - content.scrollTop = updateValue; - } - } + // 首次打开弹层时,将选中项滚动到可视区域 + const firstSelectedNode: HTMLDivElement = content?.querySelector(`.${classPrefix}-is-selected`); + if (!firstSelectedNode) return; + const { paddingBottom } = getComputedStyle(firstSelectedNode); + const { marginBottom } = getComputedStyle(content); + const elementBottomHeight = parseInt(paddingBottom, 10) + parseInt(marginBottom, 10); + const updateValue = + firstSelectedNode.offsetTop - + content.offsetTop - + (content.clientHeight - firstSelectedNode.clientHeight) + + elementBottomHeight; + content.scrollTop = updateValue; } useEffect(() => { const yearRange = initOptions(year); + // year 切换时重置加载相关状态,避免 anchor 残留 + resetLoadState(); setYearOptions(yearRange); }, [initOptions, year]); useLayoutEffect(() => { - const savedScrollTop = scrollTopRef.current; const contentEl = yearPopupContentRef.current; - if (savedScrollTop == null || !contentEl) return; - scrollTopRef.current = null; - contentEl.scrollTop = savedScrollTop; + const anchor = scrollAnchorRef.current; + if (anchor !== 'top' && anchor !== 'bottom') return; + + if (!contentEl || !anchorRef.current) { + resetLoadState(); + return; + } + + // Select 通过 Portal 渲染,useLayoutEffect 触发时 Popup 里的子 DOM 尚未更新, + // scrollHeight 仍是旧值。使用 ResizeObserver 精确监听内容尺寸变化,避免 rAF 轮询猜测。 + const oldScrollHeight = anchorRef.current.scrollHeight; + const oldScrollTop = anchorRef.current.scrollTop; + + const ro = new ResizeObserver(() => { + const newScrollHeight = contentEl.scrollHeight; + if (newScrollHeight <= oldScrollHeight) return; // DOM 尚未更新,等待下一次触发 + const heightDelta = newScrollHeight - oldScrollHeight; + // prepend:将新增内容的高度差补偿到 scrollTop,保持用户视口不变 + // append:保持用户当前 scrollTop 不变 + contentEl.scrollTop = anchor === 'top' ? oldScrollTop + heightDelta : oldScrollTop; + resetLoadState(); + ro.disconnect(); + }); + + const target = (contentEl.firstElementChild as Element) || contentEl; + ro.observe(target); + + return () => ro.disconnect(); }, [yearOptions]); return ( @@ -285,13 +370,21 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { className={`${headerClassName}-controller-year`} value={mode === 'year' ? nearestYear : year} options={yearOptions} + keyboardCircular={false} + onKeyboardReachTop={handleTopRef.current} + onKeyboardReachBottom={handlePanelBottomClick} onChange={(val) => onYearChange(val)} onPopupVisibleChange={(visible) => { - if (!visible) scrollAnchorRef.current = 'default'; + if (!visible) resetLoadState(true); }} popupProps={{ onScroll: handleScroll, updateScrollTop: (el) => { + // updateScrollTop 传入的是内层 .t-select__dropdown-inner,但真正的滚动容器是外层 .t-popup__content + // 需要向上查找到实际带 overflow 的祖先节点,否则 scrollTop 写在不滚动的元素上会失效 + const scrollEl = (el?.closest(`.${classPrefix}-popup__content`) as HTMLElement) || el; + yearPopupContentRef.current = scrollEl; + bindWheel(scrollEl); setTimeout(() => { handleUpdateScrollTop(el); }, 0); @@ -299,20 +392,6 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { attach: (triggerElement: HTMLElement) => triggerElement.parentNode as HTMLElement, overlayClassName: `${headerClassName}-controller-year-popup`, }} - panelTopContent={ - showPanelTop && ( -
- ... -
- ) - } - panelBottomContent={ - showPanelBottom && ( -
- ... -
- ) - } />
diff --git a/packages/components/select/base/Select.tsx b/packages/components/select/base/Select.tsx index 31e8c10ff0..2591ad2eac 100644 --- a/packages/components/select/base/Select.tsx +++ b/packages/components/select/base/Select.tsx @@ -1,13 +1,4 @@ -import React, { - Children, - cloneElement, - isValidElement, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { Children, cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef } from 'react'; import classNames from 'classnames'; import { debounce, get, isFunction } from 'lodash-es'; @@ -42,6 +33,12 @@ export interface SelectProps extends TdSelectProps, StyledP children?: React.ReactNode; onMouseEnter?: (event: React.MouseEvent) => void; onMouseLeave?: (event: React.MouseEvent) => void; + /** @internal Whether keyboard navigation wraps around at the boundaries. Defaults to true. */ + keyboardCircular?: boolean; + /** @internal Called when keyboard navigation reaches the first option. */ + onKeyboardReachTop?: () => void; + /** @internal Called when keyboard navigation reaches the last option. */ + onKeyboardReachBottom?: () => void; } type OptionsType = TdOptionProps[]; @@ -97,6 +94,9 @@ const Select = forwardRefWithStatics( onSearch, onEnter, onPopupVisibleChange, + keyboardCircular = true, + onKeyboardReachTop, + onKeyboardReachBottom, } = props; const readOnly = props.readOnly || props.readonly; const tagProps = { ...props.tagProps, ...props.tagInputProps?.tagProps }; @@ -108,7 +108,11 @@ const Select = forwardRefWithStatics( const selectInputRef = useRef(null); const { classPrefix } = useConfig(); const { overlayClassName, onScroll, onScrollToBottom, ...restPopupProps } = popupProps || {}; - const [isScrolling, toggleIsScrolling] = useState(false); + + const isScrollingRef = useRef(false); + const toggleIsScrollingRef = (val: boolean) => { + isScrollingRef.current = val; + }; const name = `${classPrefix}-select`; // t-select const [inputValue, onInputChange] = useControlled(props, 'inputValue', props.onInputChange); @@ -117,7 +121,11 @@ const Select = forwardRefWithStatics( const handlePopupVisibleChange = (visible: boolean, ctx: PopupVisibleChangeContext) => { if (disabled) return; - visible ? toggleIsScrolling(false) : onInputChange('', { trigger: 'blur' }); + if (visible) { + isScrollingRef.current = false; + } else { + onInputChange('', { trigger: 'blur' }); + } setInnerPopupVisible(visible, ctx); }; @@ -303,7 +311,10 @@ const Select = forwardRefWithStatics( handlePopupVisibleChange, handleChange, onCheckAllChange, - toggleIsScrolling, + toggleIsScrolling: toggleIsScrollingRef, + circular: keyboardCircular, + onReachTop: onKeyboardReachTop, + onReachBottom: onKeyboardReachBottom, }); // 处理filter逻辑 @@ -513,7 +524,7 @@ const Select = forwardRefWithStatics( // 将第一个选中的 option 置于列表可见范围的最后一位 const updateScrollTop = (content: HTMLDivElement) => { - if (!content || isScrolling) { + if (!content || isScrollingRef.current) { return; } const firstSelectedNode: HTMLDivElement = content.querySelector(`.${classPrefix}-is-selected`); @@ -543,7 +554,7 @@ const Select = forwardRefWithStatics( }; const handleScroll = ({ e }: { e: React.WheelEvent }) => { - toggleIsScrolling(true); + isScrollingRef.current = true; onScroll?.({ e }); if (onScrollToBottom) { diff --git a/packages/components/select/hooks/useKeyboardControl.ts b/packages/components/select/hooks/useKeyboardControl.ts index 7699cbb027..fa57d90277 100644 --- a/packages/components/select/hooks/useKeyboardControl.ts +++ b/packages/components/select/hooks/useKeyboardControl.ts @@ -24,6 +24,12 @@ export type useKeyboardControlType = { onCheckAllChange: (checkAll: boolean, e?: React.KeyboardEvent) => void; selectInputRef: any; toggleIsScrolling: (isScrolling: boolean) => void; + /** Whether keyboard navigation wraps around at the boundaries. Defaults to false. */ + circular?: boolean; + /** Called when keyboard navigation reaches the first option. */ + onReachTop?: () => void; + /** Called when keyboard navigation reaches the last option. */ + onReachBottom?: () => void; }; export default function useKeyboardControl({ @@ -39,6 +45,9 @@ export default function useKeyboardControl({ onCheckAllChange, selectInputRef, toggleIsScrolling, + circular = false, + onReachTop, + onReachBottom, }: useKeyboardControlType) { const { classPrefix } = useConfig(); @@ -48,9 +57,34 @@ export default function useKeyboardControl({ const isObjectType = useMemo(() => valueType === 'object', [valueType]); const { valueKey, disabledKey } = useMemo(() => getKeyMapping(keys), [keys]); + // Track whether the user is actively navigating with keyboard + const isKeyboardNavigating = useRef(false); + const prevFirstOptionValueRef = useRef(undefined); + // Ref to handleKeyboardScroll so it can be called inside useEffect + const handleKeyboardScrollRef = useRef<(targetIndex: number) => void>(null); + useEffect(() => { if (!innerPopupVisible) { + isKeyboardNavigating.current = false; + prevFirstOptionValueRef.current = undefined; changeHoverIndex(-1); + return; + } + + const firstValue = displayOptions[0]?.value; + + if (isKeyboardNavigating.current) { + // Options prepended at top: + // first item changed → shift hoverIndex by the prepended count + if (prevFirstOptionValueRef.current !== undefined && prevFirstOptionValueRef.current !== firstValue) { + const prependedCount = displayOptions.findIndex((o) => o.value === prevFirstOptionValueRef.current); + if (prependedCount > 0) { + changeHoverIndex((prev) => prev + prependedCount); + // Delay scroll until after React re-renders the hover class onto the DOM, + // otherwise querySelector('.t-select-option__hover') returns null and scroll is skipped + setTimeout(() => handleKeyboardScrollRef.current?.(hoverIndex + prependedCount), 0); + } + } } else if (!multiple) { // 单选时,hoverIndex 初始值为选中值的索引 const index = displayOptions.findIndex((option) => option.value === value); @@ -58,6 +92,8 @@ export default function useKeyboardControl({ } else { changeHoverIndex(-1); } + + prevFirstOptionValueRef.current = firstValue; // eslint-disable-next-line react-hooks/exhaustive-deps }, [innerPopupVisible, displayOptions]); @@ -67,25 +103,31 @@ export default function useKeyboardControl({ value.length === displayOptions.filter((v) => !((v.disabled || v.checkAll) && !value.includes(v.value))).length; }, [value, displayOptions]); - const handleKeyboardScroll = (hoverIndex: number) => { + const handleKeyboardScroll = (targetIndex: number) => { const popupContent = selectInputRef.current.getPopupContentElement(); + if (!popupContent) return; const optionSelector = `.${classPrefix}-select-option`; - const selector = `.${classPrefix}-select-option__hover`; - const firstSelectedNode: HTMLDivElement = popupContent.querySelector(selector); - if (firstSelectedNode) { - // 避免与 updateScrollTop 冲突 - toggleIsScrolling(true); - - // 小于0时不需要特殊处理,会被设为0 - const scrollHeight = popupContent.querySelector(optionSelector).clientHeight * hoverIndex; - - popupContent.scrollTo({ - top: scrollHeight, - behavior: 'smooth', - }); + const allOptions: NodeListOf = popupContent.querySelectorAll(optionSelector); + const targetNode = allOptions[targetIndex]; + if (!targetNode) return; + + // 避免与 updateScrollTop 冲突 + toggleIsScrolling(true); + + const { top: containerTop, bottom: containerBottom } = popupContent.getBoundingClientRect(); + const { top: optionTop, bottom: optionBottom } = targetNode.getBoundingClientRect(); + + if (optionTop < containerTop) { + // option 在可视区域上方,滚动使其出现在顶部 + popupContent.scrollTop -= containerTop - optionTop; + } else if (optionBottom > containerBottom) { + // option 在可视区域下方,滚动使其出现在底部 + popupContent.scrollTop += optionBottom - containerBottom; } + // option 已在可视区域内,不滚动 }; + handleKeyboardScrollRef.current = handleKeyboardScroll; const handleKeyDown = (_value: string, { e }: { e: React.KeyboardEvent }) => { const optionsListLength = displayOptions.length; @@ -95,22 +137,33 @@ export default function useKeyboardControl({ switch (e.code) { case 'ArrowUp': e.preventDefault(); + isKeyboardNavigating.current = true; if (hoverIndex === -1) newIndex = 0; - else if (hoverIndex === 0 || hoverIndex > optionsListLength - 1) newIndex = optionsListLength - 1; + else if (hoverIndex === 0 || hoverIndex > optionsListLength - 1) + newIndex = circular ? optionsListLength - 1 : 0; else newIndex -= 1; if (displayOptions[newIndex]?.[disabledKey]) newIndex -= 1; changeHoverIndex(newIndex); handleKeyboardScroll(newIndex); + + if (newIndex === 0) { + onReachTop?.(); + } break; case 'ArrowDown': e.preventDefault(); - if (hoverIndex === -1 || hoverIndex >= optionsListLength - 1) newIndex = 0; + isKeyboardNavigating.current = true; + if (hoverIndex === -1 || hoverIndex >= optionsListLength - 1) newIndex = circular ? 0 : optionsListLength - 1; else newIndex += 1; if (displayOptions[newIndex]?.disabled) newIndex += 1; + if (newIndex === optionsListLength - 1 && hoverIndex === optionsListLength - 1) { + onReachBottom?.(); + } + changeHoverIndex(newIndex); handleKeyboardScroll(newIndex); break;