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 @@
+
+
+
+ Affix top
+
+
+
+ Affix bottom
+
+
+
+
+
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