feat: add affix

feat/vapor
tangjinzhou 2025-08-10 13:52:32 +08:00
parent bacb790bfb
commit 5cc4a63480
13 changed files with 503 additions and 1 deletions

View File

@ -0,0 +1,20 @@
<template>
<div class="flex h-[200vh] flex-col gap-2">
<a-affix :offset-top="top" @change="onChange">
<a-button type="primary" @click="top += 10">Affix top</a-button>
</a-affix>
<br />
<a-affix :offset-bottom="bottom">
<a-button type="primary" @click="bottom += 10">Affix bottom</a-button>
</a-affix>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const top = ref<number>(10)
const bottom = ref<number>(10)
const onChange = (lastAffix: boolean) => {
console.log('onChange', lastAffix)
}
</script>

View File

@ -2,6 +2,7 @@
declare module 'vue' {
export interface GlobalComponents {
AButton: typeof import('@ant-design-vue/ui').Button
AAffix: typeof import('@ant-design-vue/ui').Affix
}
}
export {}

View File

@ -74,7 +74,8 @@
"@floating-ui/vue": "^1.1.6",
"lodash-es": "^4.17.21",
"@ctrl/tinycolor": "^4.0.0",
"resize-observer-polyfill": "^1.5.1"
"resize-observer-polyfill": "^1.5.1",
"@vueuse/core": "^13.6.0"
},
"devDependencies": {
"@ant-design-vue/eslint-config": "*",

View File

@ -0,0 +1,220 @@
<template>
<div ref="placeholderNode" :data-measure-status="state.status">
<div v-if="enableFixed" :style="state.placeholderStyle" aria-hidden="true" />
<div
ref="fixedNode"
:style="[state.affixStyle, { zIndex: props.zIndex }]"
:class="enableFixed && 'ant-affix'"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import {
getCurrentInstance,
reactive,
shallowRef,
computed,
watch,
onMounted,
onUpdated,
onUnmounted,
} from 'vue'
import {
AFFIX_STATUS_NONE,
AFFIX_STATUS_PREPARE,
AffixProps,
AffixState,
affixDefaultProps,
AffixEmits,
} from './meta'
import {
addObserveTarget,
getFixedBottom,
getFixedTop,
getTargetRect,
removeObserveTarget,
} from './utils'
import throttleByAnimationFrame from '@/utils/throttleByAnimationFrame'
import { useResizeObserver } from '@vueuse/core'
const props = withDefaults(defineProps<AffixProps>(), affixDefaultProps)
const emit = defineEmits<AffixEmits>()
const placeholderNode = shallowRef()
useResizeObserver(placeholderNode, () => {
updatePosition()
})
const fixedNode = shallowRef()
const state = reactive<AffixState>({
affixStyle: undefined,
placeholderStyle: undefined,
status: AFFIX_STATUS_NONE,
lastAffix: false,
})
const prevTarget = shallowRef<Window | HTMLElement | null>(null)
const timeout = shallowRef<any>(null)
const currentInstance = getCurrentInstance()
const offsetTop = computed(() => {
return props.offsetBottom === undefined && props.offsetTop === undefined ? 0 : props.offsetTop
})
const offsetBottom = computed(() => props.offsetBottom)
const measure = () => {
const { status, lastAffix } = state
const { target } = props
if (status !== AFFIX_STATUS_PREPARE || !fixedNode.value || !placeholderNode.value || !target) {
return
}
const targetNode = target()
if (!targetNode) {
return
}
const newState = {
status: AFFIX_STATUS_NONE,
} as AffixState
const placeholderRect = getTargetRect(placeholderNode.value as HTMLElement)
if (
placeholderRect.top === 0 &&
placeholderRect.left === 0 &&
placeholderRect.width === 0 &&
placeholderRect.height === 0
) {
return
}
const targetRect = getTargetRect(targetNode)
const fixedTop = getFixedTop(placeholderRect, targetRect, offsetTop.value)
const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom.value)
if (
placeholderRect.top === 0 &&
placeholderRect.left === 0 &&
placeholderRect.width === 0 &&
placeholderRect.height === 0
) {
return
}
if (fixedTop !== undefined) {
const width = `${placeholderRect.width}px`
const height = `${placeholderRect.height}px`
newState.affixStyle = {
position: 'fixed',
top: fixedTop,
width,
height,
}
newState.placeholderStyle = {
width,
height,
}
} else if (fixedBottom !== undefined) {
const width = `${placeholderRect.width}px`
const height = `${placeholderRect.height}px`
newState.affixStyle = {
position: 'fixed',
bottom: fixedBottom,
width,
height,
}
newState.placeholderStyle = {
width,
height,
}
}
newState.lastAffix = !!newState.affixStyle
if (lastAffix !== newState.lastAffix) {
emit('change', newState.lastAffix)
}
// update state
Object.assign(state, newState)
}
const prepareMeasure = () => {
Object.assign(state, {
status: AFFIX_STATUS_PREPARE,
affixStyle: undefined,
placeholderStyle: undefined,
})
}
const updatePosition = throttleByAnimationFrame(() => {
prepareMeasure()
})
const lazyUpdatePosition = throttleByAnimationFrame(() => {
const { target } = props
const { affixStyle } = state
// Check position change before measure to make Safari smooth
if (target && affixStyle) {
const targetNode = target()
if (targetNode && placeholderNode.value) {
const targetRect = getTargetRect(targetNode)
const placeholderRect = getTargetRect(placeholderNode.value as HTMLElement)
const fixedTop = getFixedTop(placeholderRect, targetRect, offsetTop.value)
const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom.value)
if (
(fixedTop !== undefined && affixStyle.top === fixedTop) ||
(fixedBottom !== undefined && affixStyle.bottom === fixedBottom)
) {
return
}
}
}
// Directly call prepare measure since it's already throttled.
prepareMeasure()
})
defineExpose({
updatePosition,
lazyUpdatePosition,
})
watch(
() => props.target,
val => {
const newTarget = val?.() || null
if (prevTarget.value !== newTarget) {
removeObserveTarget(currentInstance)
if (newTarget) {
addObserveTarget(newTarget, currentInstance)
// Mock Event object.
updatePosition()
}
prevTarget.value = newTarget
}
},
)
watch(() => [props.offsetTop, props.offsetBottom], updatePosition)
onMounted(() => {
const { target } = props
if (target) {
// [Legacy] Wait for parent component ref has its value.
// We should use target as directly element instead of function which makes element check hard.
timeout.value = setTimeout(() => {
addObserveTarget(target(), currentInstance)
// Mock Event object.
updatePosition()
})
}
})
onUpdated(() => {
measure()
})
onUnmounted(() => {
clearTimeout(timeout.value)
removeObserveTarget(currentInstance)
;(updatePosition as any).cancel()
;(lazyUpdatePosition as any).cancel()
})
const enableFixed = computed(() => {
return !!state.affixStyle
})
</script>

View File

@ -0,0 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { Affix, Button } from '@ant-design-vue/ui'
import { mount } from '@vue/test-utils'
describe('Affix', () => {
it('should render correctly', () => {
const wrapper = mount(Affix)
expect(wrapper.html()).toMatchSnapshot()
})
})

View File

@ -0,0 +1,14 @@
import { App, Plugin } from 'vue'
import Affix from './Affix.vue'
import './style/index.css'
export { default as Affix } from './Affix.vue'
export * from './meta'
/* istanbul ignore next */
Affix.install = function (app: App) {
app.component('AAffix', Affix)
return app
}
export default Affix as typeof Affix & Plugin

View File

@ -0,0 +1,48 @@
import { CSSProperties } from 'vue'
function getDefaultTarget() {
return typeof window !== 'undefined' ? window : null
}
export const AFFIX_STATUS_NONE = 0
export const AFFIX_STATUS_PREPARE = 1
type AffixStatus = typeof AFFIX_STATUS_NONE | typeof AFFIX_STATUS_PREPARE
export interface AffixState {
affixStyle?: CSSProperties
placeholderStyle?: CSSProperties
status: AffixStatus
lastAffix: boolean
}
export type AffixProps = {
/**
* Specifies the offset top of the affix
*/
offsetTop?: number
/**
* Specifies the offset bottom of the affix
*/
offsetBottom?: number
/**
* Specifies the target of the affix
*/
target?: () => Window | HTMLElement | null
/**
* Specifies the z-index of the affix
*/
zIndex?: number
}
export const affixDefaultProps = {
target: getDefaultTarget,
zIndex: 10,
} as const
export type AffixEmits = {
/**
* Triggered when the affix status changes
* @param lastAffix - The last affix status
*/
(e: 'change', lastAffix: boolean): void
}

View File

@ -0,0 +1,5 @@
@reference '../../../style/tailwind.css';
.ant-affix {
@apply fixed z-10;
}

View File

@ -0,0 +1,113 @@
import addEventListener from '../../utils/addEventListener'
import supportsPassive from '../../utils/supportsPassive'
export type BindElement = HTMLElement | Window | null | undefined
export function getTargetRect(target: BindElement): DOMRect {
return target !== window
? (target as HTMLElement).getBoundingClientRect()
: ({ top: 0, bottom: window.innerHeight } as DOMRect)
}
export function getFixedTop(placeholderRect: DOMRect, targetRect: DOMRect, offsetTop?: number) {
if (
offsetTop !== undefined &&
Math.round(targetRect.top) > Math.round(placeholderRect.top) - offsetTop
) {
return `${offsetTop + targetRect.top}px`
}
return undefined
}
export function getFixedBottom(
placeholderRect: DOMRect,
targetRect: DOMRect,
offsetBottom?: number,
) {
if (
offsetBottom !== undefined &&
Math.round(targetRect.bottom) < Math.round(placeholderRect.bottom) + offsetBottom
) {
const targetBottomOffset = window.innerHeight - targetRect.bottom
return `${offsetBottom + targetBottomOffset}px`
}
return undefined
}
// ======================== Observer ========================
const TRIGGER_EVENTS: (keyof WindowEventMap)[] = [
'resize',
'scroll',
'touchstart',
'touchmove',
'touchend',
'pageshow',
'load',
]
interface ObserverEntity {
target: HTMLElement | Window
affixList: any[]
eventHandlers: { [eventName: string]: any }
}
let observerEntities: ObserverEntity[] = []
export function getObserverEntities() {
// Only used in test env. Can be removed if refactor.
return observerEntities
}
export function addObserveTarget<T>(target: HTMLElement | Window | null, affix: T): void {
if (!target) return
let entity = observerEntities.find(item => item.target === target)
if (entity) {
entity.affixList.push(affix)
} else {
entity = {
target,
affixList: [affix],
eventHandlers: {},
}
observerEntities.push(entity)
// Add listener
TRIGGER_EVENTS.forEach(eventName => {
entity!.eventHandlers[eventName] = addEventListener(target, eventName, () => {
entity!.affixList.forEach(
targetAffix => {
const { lazyUpdatePosition } = (targetAffix as any).exposed
lazyUpdatePosition()
},
(eventName === 'touchstart' || eventName === 'touchmove') && supportsPassive
? ({ passive: true } as EventListenerOptions)
: false,
)
})
})
}
}
export function removeObserveTarget<T>(affix: T): void {
const observerEntity = observerEntities.find(oriObserverEntity => {
const hasAffix = oriObserverEntity.affixList.some(item => item === affix)
if (hasAffix) {
oriObserverEntity.affixList = oriObserverEntity.affixList.filter(item => item !== affix)
}
return hasAffix
})
if (observerEntity && observerEntity.affixList.length === 0) {
observerEntities = observerEntities.filter(item => item !== observerEntity)
// Remove listener
TRIGGER_EVENTS.forEach(eventName => {
const handler = observerEntity.eventHandlers[eventName]
if (handler && handler.remove) {
handler.remove()
}
})
}
}

View File

@ -1,3 +1,4 @@
export { default as Button } from './button'
export { default as Input } from './input'
export { default as Theme } from './theme'
export { default as Affix } from './affix'

View File

@ -0,0 +1,27 @@
import supportsPassive from './supportsPassive'
export default function addEventListenerWrap(
target: HTMLElement | Window,
eventType: string,
cb: EventListener,
option?: boolean | AddEventListenerOptions,
) {
if (target && target.addEventListener) {
let opt = option
if (
opt === undefined &&
supportsPassive &&
(eventType === 'touchstart' || eventType === 'touchmove' || eventType === 'wheel')
) {
opt = { passive: false }
}
target.addEventListener(eventType, cb, opt)
}
return {
remove: () => {
if (target && target.removeEventListener) {
target.removeEventListener(eventType, cb)
}
},
}
}

View File

@ -0,0 +1,13 @@
// Test via a getter in the options object to see if the passive property is accessed
let supportsPassive = false
try {
const opts = Object.defineProperty({}, 'passive', {
get() {
supportsPassive = true
},
})
window.addEventListener('testPassive', null, opts)
window.removeEventListener('testPassive', null, opts)
} catch (e) {}
export default supportsPassive

View File

@ -0,0 +1,29 @@
import raf from './raf'
type throttledFn = (...args: any[]) => void
type throttledCancelFn = { cancel: () => void }
function throttleByAnimationFrame<T extends any[]>(fn: (...args: T) => void) {
let requestId: number | null
const later = (args: T) => () => {
requestId = null
fn(...args)
}
const throttled: throttledFn & throttledCancelFn = (...args: T) => {
if (requestId == null) {
requestId = raf(later(args))
}
}
throttled.cancel = () => {
raf.cancel(requestId!)
requestId = null
}
return throttled
}
export default throttleByAnimationFrame