feat: add affix
parent
bacb790bfb
commit
5cc4a63480
|
@ -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>
|
|
@ -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 {}
|
||||
|
|
|
@ -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": "*",
|
||||
|
|
|
@ -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>
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
@reference '../../../style/tailwind.css';
|
||||
|
||||
.ant-affix {
|
||||
@apply fixed z-10;
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue