diff --git a/docs/components/back-top.md b/docs/components/back-top.md index d5f9c96..238a90d 100644 --- a/docs/components/back-top.md +++ b/docs/components/back-top.md @@ -15,17 +15,40 @@ } `" /> +### 自定义滚动容器 + + + ## 可访问性 `BackTop` 默认渲染为带有 `aria-label="Back to top"` 的按钮,避免辅助技术读取装饰性箭头符号。可以通过 `aria-label` 提供本地化或更贴合业务语境的按钮名称。 -点击按钮时组件会先调用传入的 `onClick`,如果事件未被 `preventDefault()` 阻止,再执行平滑滚动到页面顶部。 +点击按钮时组件会先调用传入的 `onClick`,如果事件未被 `preventDefault()` 阻止,再执行平滑滚动到页面顶部或 `target` 指定容器顶部。 ## API | 属性 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | | visibilityHeight | 滚动高度达到此参数值才出现 | `number` | `200` | +| target | 自定义滚动容器 | `() => HTMLElement \| Window \| null` | `() => window` | | children | 自定义按钮内容 | `ReactNode` | `'↑ Top'` | | aria-label | 按钮可访问名称 | `string` | `'Back to top'` | | onClick | 点击回调;可通过 `event.preventDefault()` 阻止默认滚动 | `(event: MouseEvent) => void` | - | diff --git a/packages/ui/src/components/navigation/BackTop/BackTop.test.tsx b/packages/ui/src/components/navigation/BackTop/BackTop.test.tsx index 42721ce..55622d9 100644 --- a/packages/ui/src/components/navigation/BackTop/BackTop.test.tsx +++ b/packages/ui/src/components/navigation/BackTop/BackTop.test.tsx @@ -13,6 +13,13 @@ const setScrollY = (value: number) => { }) } +const setTargetScrollTop = (target: HTMLElement, value: number) => { + target.scrollTop = value + act(() => { + target.dispatchEvent(new Event('scroll')) + }) +} + beforeEach(() => { Object.defineProperty(window, 'scrollTo', { configurable: true, @@ -48,6 +55,25 @@ describe('BackTop', () => { expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }) }) + it('uses a custom scroll target for visibility and scrolling', () => { + const scrollTarget = document.createElement('div') + const scrollTo = vi.fn() + Object.defineProperty(scrollTarget, 'scrollTo', { + configurable: true, + value: scrollTo, + }) + + render( scrollTarget} />) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + + setTargetScrollTop(scrollTarget, 101) + fireEvent.click(screen.getByRole('button', { name: 'Back to top' })) + + expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }) + expect(window.scrollTo).not.toHaveBeenCalled() + }) + it('lets callers prevent the default scroll behavior', () => { render( { visibilityHeight?: number + /** 自定义滚动容器,默认监听并滚动 window */ + target?: () => BackTopTarget | null +} + +const isWindowTarget = (target: BackTopTarget): target is Window => + target === window || (typeof Window !== 'undefined' && target instanceof Window) + +const getScrollTop = (target: BackTopTarget) => (isWindowTarget(target) ? target.scrollY : target.scrollTop) + +const scrollToTop = (target: BackTopTarget) => { + target.scrollTo({ top: 0, behavior: 'smooth' }) } export const BackTop = forwardRef(function BackTop( { className, visibilityHeight = 200, + target, children = '↑ Top', onClick, 'aria-label': ariaLabel = 'Back to top', @@ -19,13 +33,16 @@ export const BackTop = forwardRef(function Back const [visible, setVisible] = useState(false) useEffect(() => { + const scrollTarget = target?.() ?? window const onScroll = () => { - setVisible(window.scrollY > visibilityHeight) + setVisible(getScrollTop(scrollTarget) > visibilityHeight) } - window.addEventListener('scroll', onScroll) + + scrollTarget.addEventListener('scroll', onScroll) onScroll() - return () => window.removeEventListener('scroll', onScroll) - }, [visibilityHeight]) + + return () => scrollTarget.removeEventListener('scroll', onScroll) + }, [target, visibilityHeight]) if (!visible) return null @@ -35,7 +52,7 @@ export const BackTop = forwardRef(function Back return } - window.scrollTo({ top: 0, behavior: 'smooth' }) + scrollToTop(target?.() ?? window) } return (