JavaScript/React键盘导航ul列表时如何实现自适应滚动
列表键盘导航滚动问题修复方案
现有效果
静态效果截图:
异常效果演示:
相关变量说明
dataList:ul列表容器的DOM引用selectedCountry:当前选中li元素的DOM引用selectedCountryIndex:当前选中元素的索引autoCompleteSuggestions:输入匹配的国家联想建议列表
核心问题原因
当前实现存在两个核心问题,导致硬编码失效和向上滚动异常:
- 硬编码阈值不通用:使用
selectedCountryIndex > 3作为滚动触发条件,完全依赖固定容器高度、固定列表项高度的前提,只要样式调整(比如容器变高/变矮、项高度变化)逻辑就会失效 - 向上滚动逻辑错误:
- 触发条件和向下逻辑完全一致,当选中索引回退到3及以下时,即使选中项已经超出容器可视区顶部,也不会触发滚动,导致元素逐步露出、显示不全
- 滚动量硬编码为单个项高度,没有根据实际位置差计算,容易出现偏移误差
- React的
setSelectedCountryIndex是异步更新,更新后立刻读取selectedCountry.current会拿到上一次渲染的旧DOM,导致滚动计算基准错误
通用滚动实现方案
不需要提前计算可见项总数,直接通过DOM元素的几何位置判断是否需要滚动是最通用的方案,不受容器尺寸、项高度变化影响:
- 按向下键切换选中项时,如果新选中项的底部超出ul容器可视区底部,就滚动容器让选中项刚好完整显示在可视区底部
- 按向上键切换选中项时,如果新选中项的顶部超出ul容器可视区顶部,就滚动容器让选中项刚好完整显示在可视区顶部
修复后的完整代码如下:
const arrowNavigation = ( e: React.KeyboardEvent<HTMLInputElement> ) => { const listContainer = dataList.current; if (!listContainer) return; // 提前获取容器可视区域边界 const containerRect = listContainer.getBoundingClientRect(); const maxIndex = autoCompleteSuggestions.length - 1; if (e.key === 'ArrowDown') { e.preventDefault(); // 提前计算下一个选中索引,不依赖异步更新的state const nextIndex = Math.min(selectedCountryIndex + 1, maxIndex); setSelectedCountryIndex(nextIndex); // 直接从容器子节点拿目标项,避开ref更新延迟问题 const targetItem = listContainer.children[nextIndex] as HTMLElement; if (!targetItem) return; const itemRect = targetItem.getBoundingClientRect(); // 项超出容器底部时才滚动,滚动量为实际超出距离 if (itemRect.bottom > containerRect.bottom) { listContainer.scrollTop += itemRect.bottom - containerRect.bottom; } } else if (e.key === 'ArrowUp') { e.preventDefault(); const nextIndex = Math.max(selectedCountryIndex - 1, 0); setSelectedCountryIndex(nextIndex); const targetItem = listContainer.children[nextIndex] as HTMLElement; if (!targetItem) return; const itemRect = targetItem.getBoundingClientRect(); // 项超出容器顶部时才滚动,滚动量为实际超出距离 if (itemRect.top < containerRect.top) { listContainer.scrollTop -= containerRect.top - itemRect.top; } } else if (e.key === 'Enter') { e.preventDefault(); const selectedValue = autoCompleteSuggestions[selectedCountryIndex]; if (!selectedValue) return; if (textInput.current) { textInput.current.value = selectedValue; } setInputValue(selectedValue); } };
附:获取可见项信息的通用方法
如果确实需要获取ul内可见项总数、首尾可见项,可以通过遍历列表项判断位置实现,不需要依赖硬编码阈值:
const getVisibleListInfo = (container: HTMLUListElement) => { const containerRect = container.getBoundingClientRect(); const allItems = Array.from(container.children) as HTMLElement[]; const visibleItems = allItems.filter(item => { const itemRect = item.getBoundingClientRect(); // 项和容器可视区域有重叠即为可见 return itemRect.top < containerRect.bottom && itemRect.bottom > containerRect.top; }); return { visibleCount: visibleItems.length, firstVisibleItem: visibleItems[0], lastVisibleItem: visibleItems.at(-1), firstVisibleIndex: allItems.indexOf(visibleItems[0]), lastVisibleIndex: allItems.indexOf(visibleItems.at(-1) as HTMLElement) } };
对于键盘导航跟随滚动的场景,直接判断当前选中项和容器的位置关系即可,不需要遍历全量列表项,性能更好。
内容的提问来源于stack exchange,提问作者Mirou
相关产品推荐
相关产品推荐

