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' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AButton: typeof import('@ant-design-vue/ui').Button
|
AButton: typeof import('@ant-design-vue/ui').Button
|
||||||
|
AAffix: typeof import('@ant-design-vue/ui').Affix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export {}
|
export {}
|
||||||
|
|
|
@ -74,7 +74,8 @@
|
||||||
"@floating-ui/vue": "^1.1.6",
|
"@floating-ui/vue": "^1.1.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"@ctrl/tinycolor": "^4.0.0",
|
"@ctrl/tinycolor": "^4.0.0",
|
||||||
"resize-observer-polyfill": "^1.5.1"
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
|
"@vueuse/core": "^13.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ant-design-vue/eslint-config": "*",
|
"@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 Button } from './button'
|
||||||
export { default as Input } from './input'
|
export { default as Input } from './input'
|
||||||
export { default as Theme } from './theme'
|
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