From 5cc4a63480b70a0ebe33e93249b7399173e6cfe9 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Sun, 10 Aug 2025 13:52:32 +0800 Subject: [PATCH] feat: add affix --- apps/playground/src/pages/affix/basic.vue | 20 ++ apps/playground/src/typings/global.d.ts | 1 + packages/ui/package.json | 3 +- packages/ui/src/components/affix/Affix.vue | 220 ++++++++++++++++++ .../components/affix/__tests__/index.test.ts | 10 + packages/ui/src/components/affix/index.ts | 14 ++ packages/ui/src/components/affix/meta.ts | 48 ++++ .../ui/src/components/affix/style/index.css | 5 + packages/ui/src/components/affix/utils.ts | 113 +++++++++ packages/ui/src/components/index.ts | 1 + packages/ui/src/utils/addEventListener.ts | 27 +++ packages/ui/src/utils/supportsPassive.ts | 13 ++ .../ui/src/utils/throttleByAnimationFrame.ts | 29 +++ 13 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 apps/playground/src/pages/affix/basic.vue create mode 100644 packages/ui/src/components/affix/Affix.vue create mode 100644 packages/ui/src/components/affix/__tests__/index.test.ts create mode 100644 packages/ui/src/components/affix/index.ts create mode 100644 packages/ui/src/components/affix/meta.ts create mode 100644 packages/ui/src/components/affix/style/index.css create mode 100644 packages/ui/src/components/affix/utils.ts create mode 100644 packages/ui/src/utils/addEventListener.ts create mode 100644 packages/ui/src/utils/supportsPassive.ts create mode 100644 packages/ui/src/utils/throttleByAnimationFrame.ts diff --git a/apps/playground/src/pages/affix/basic.vue b/apps/playground/src/pages/affix/basic.vue new file mode 100644 index 000000000..9db750d8a --- /dev/null +++ b/apps/playground/src/pages/affix/basic.vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/playground/src/typings/global.d.ts b/apps/playground/src/typings/global.d.ts index 03e23f68c..7e1b1b52e 100644 --- a/apps/playground/src/typings/global.d.ts +++ b/apps/playground/src/typings/global.d.ts @@ -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 {} diff --git a/packages/ui/package.json b/packages/ui/package.json index 979b1196d..53cb18dca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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": "*", diff --git a/packages/ui/src/components/affix/Affix.vue b/packages/ui/src/components/affix/Affix.vue new file mode 100644 index 000000000..9a1095a02 --- /dev/null +++ b/packages/ui/src/components/affix/Affix.vue @@ -0,0 +1,220 @@ + + diff --git a/packages/ui/src/components/affix/__tests__/index.test.ts b/packages/ui/src/components/affix/__tests__/index.test.ts new file mode 100644 index 000000000..65e584872 --- /dev/null +++ b/packages/ui/src/components/affix/__tests__/index.test.ts @@ -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() + }) +}) diff --git a/packages/ui/src/components/affix/index.ts b/packages/ui/src/components/affix/index.ts new file mode 100644 index 000000000..b12521188 --- /dev/null +++ b/packages/ui/src/components/affix/index.ts @@ -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 diff --git a/packages/ui/src/components/affix/meta.ts b/packages/ui/src/components/affix/meta.ts new file mode 100644 index 000000000..c107c86ff --- /dev/null +++ b/packages/ui/src/components/affix/meta.ts @@ -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 +} diff --git a/packages/ui/src/components/affix/style/index.css b/packages/ui/src/components/affix/style/index.css new file mode 100644 index 000000000..87ebba844 --- /dev/null +++ b/packages/ui/src/components/affix/style/index.css @@ -0,0 +1,5 @@ +@reference '../../../style/tailwind.css'; + +.ant-affix { + @apply fixed z-10; +} diff --git a/packages/ui/src/components/affix/utils.ts b/packages/ui/src/components/affix/utils.ts new file mode 100644 index 000000000..db8087adc --- /dev/null +++ b/packages/ui/src/components/affix/utils.ts @@ -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(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(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() + } + }) + } +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 9b6ce5f04..6bb401da3 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -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' diff --git a/packages/ui/src/utils/addEventListener.ts b/packages/ui/src/utils/addEventListener.ts new file mode 100644 index 000000000..c872c7fc4 --- /dev/null +++ b/packages/ui/src/utils/addEventListener.ts @@ -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) + } + }, + } +} diff --git a/packages/ui/src/utils/supportsPassive.ts b/packages/ui/src/utils/supportsPassive.ts new file mode 100644 index 000000000..3c75547aa --- /dev/null +++ b/packages/ui/src/utils/supportsPassive.ts @@ -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 diff --git a/packages/ui/src/utils/throttleByAnimationFrame.ts b/packages/ui/src/utils/throttleByAnimationFrame.ts new file mode 100644 index 000000000..2617b3ccb --- /dev/null +++ b/packages/ui/src/utils/throttleByAnimationFrame.ts @@ -0,0 +1,29 @@ +import raf from './raf' + +type throttledFn = (...args: any[]) => void + +type throttledCancelFn = { cancel: () => void } + +function throttleByAnimationFrame(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