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
	
	 tangjinzhou
						tangjinzhou