Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/components/back-top.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,40 @@
}
`" />

### 自定义滚动容器

<LivePlayground :code="`
() => {
return (
<div id='back-top-scroll-panel' className='relative h-40 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900'>
<div className='space-y-4 pb-56 text-sm text-slate-600 dark:text-slate-300'>
<Text>在容器内滚动超过阈值后显示按钮。</Text>
<Text>target 可以让 BackTop 监听并滚动指定容器。</Text>
</div>
<BackTop
target={() => document.getElementById('back-top-scroll-panel')}
visibilityHeight={80}
aria-label='回到容器顶部'
>
容器顶部
</BackTop>
</div>
)
}
`" />

## 可访问性

`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<HTMLButtonElement>) => void` | - |
26 changes: 26 additions & 0 deletions packages/ui/src/components/navigation/BackTop/BackTop.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(<BackTop visibilityHeight={100} target={() => 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(
<BackTop
Expand Down
27 changes: 22 additions & 5 deletions packages/ui/src/components/navigation/BackTop/BackTop.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { forwardRef, type ButtonHTMLAttributes, type MouseEvent, useEffect, useState } from 'react'
import { cn } from '../../../utils/cn'

type BackTopTarget = HTMLElement | Window

export interface BackTopProps extends ButtonHTMLAttributes<HTMLButtonElement> {
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<HTMLButtonElement, BackTopProps>(function BackTop(
{
className,
visibilityHeight = 200,
target,
children = '↑ Top',
onClick,
'aria-label': ariaLabel = 'Back to top',
Expand All @@ -19,13 +33,16 @@ export const BackTop = forwardRef<HTMLButtonElement, BackTopProps>(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

Expand All @@ -35,7 +52,7 @@ export const BackTop = forwardRef<HTMLButtonElement, BackTopProps>(function Back
return
}

window.scrollTo({ top: 0, behavior: 'smooth' })
scrollToTop(target?.() ?? window)
}

return (
Expand Down
Loading