Skip to content

Commit 5cc4a63

Browse files
committed
feat: add affix
1 parent bacb790 commit 5cc4a63

File tree

13 files changed

+503
-1
lines changed

13 files changed

+503
-1
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<template>
2+
<div class="flex h-[200vh] flex-col gap-2">
3+
<a-affix :offset-top="top" @change="onChange">
4+
<a-button type="primary" @click="top += 10">Affix top</a-button>
5+
</a-affix>
6+
<br />
7+
<a-affix :offset-bottom="bottom">
8+
<a-button type="primary" @click="bottom += 10">Affix bottom</a-button>
9+
</a-affix>
10+
</div>
11+
</template>
12+
13+
<script lang="ts" setup>
14+
import { ref } from 'vue'
15+
const top = ref<number>(10)
16+
const bottom = ref<number>(10)
17+
const onChange = (lastAffix: boolean) => {
18+
console.log('onChange', lastAffix)
19+
}
20+
</script>

apps/playground/src/typings/global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
declare module 'vue' {
33
export interface GlobalComponents {
44
AButton: typeof import('@ant-design-vue/ui').Button
5+
AAffix: typeof import('@ant-design-vue/ui').Affix
56
}
67
}
78
export {}

packages/ui/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
"@floating-ui/vue": "^1.1.6",
7575
"lodash-es": "^4.17.21",
7676
"@ctrl/tinycolor": "^4.0.0",
77-
"resize-observer-polyfill": "^1.5.1"
77+
"resize-observer-polyfill": "^1.5.1",
78+
"@vueuse/core": "^13.6.0"
7879
},
7980
"devDependencies": {
8081
"@ant-design-vue/eslint-config": "*",
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<template>
2+
<div ref="placeholderNode" :data-measure-status="state.status">
3+
<div v-if="enableFixed" :style="state.placeholderStyle" aria-hidden="true" />
4+
<div
5+
ref="fixedNode"
6+
:style="[state.affixStyle, { zIndex: props.zIndex }]"
7+
:class="enableFixed && 'ant-affix'"
8+
>
9+
<slot />
10+
</div>
11+
</div>
12+
</template>
13+
<script setup lang="ts">
14+
import {
15+
getCurrentInstance,
16+
reactive,
17+
shallowRef,
18+
computed,
19+
watch,
20+
onMounted,
21+
onUpdated,
22+
onUnmounted,
23+
} from 'vue'
24+
import {
25+
AFFIX_STATUS_NONE,
26+
AFFIX_STATUS_PREPARE,
27+
AffixProps,
28+
AffixState,
29+
affixDefaultProps,
30+
AffixEmits,
31+
} from './meta'
32+
import {
33+
addObserveTarget,
34+
getFixedBottom,
35+
getFixedTop,
36+
getTargetRect,
37+
removeObserveTarget,
38+
} from './utils'
39+
import throttleByAnimationFrame from '@/utils/throttleByAnimationFrame'
40+
import { useResizeObserver } from '@vueuse/core'
41+
42+
const props = withDefaults(defineProps<AffixProps>(), affixDefaultProps)
43+
const emit = defineEmits<AffixEmits>()
44+
const placeholderNode = shallowRef()
45+
46+
useResizeObserver(placeholderNode, () => {
47+
updatePosition()
48+
})
49+
50+
const fixedNode = shallowRef()
51+
const state = reactive<AffixState>({
52+
affixStyle: undefined,
53+
placeholderStyle: undefined,
54+
status: AFFIX_STATUS_NONE,
55+
lastAffix: false,
56+
})
57+
const prevTarget = shallowRef<Window | HTMLElement | null>(null)
58+
const timeout = shallowRef<any>(null)
59+
const currentInstance = getCurrentInstance()
60+
61+
const offsetTop = computed(() => {
62+
return props.offsetBottom === undefined && props.offsetTop === undefined ? 0 : props.offsetTop
63+
})
64+
const offsetBottom = computed(() => props.offsetBottom)
65+
const measure = () => {
66+
const { status, lastAffix } = state
67+
const { target } = props
68+
if (status !== AFFIX_STATUS_PREPARE || !fixedNode.value || !placeholderNode.value || !target) {
69+
return
70+
}
71+
72+
const targetNode = target()
73+
if (!targetNode) {
74+
return
75+
}
76+
77+
const newState = {
78+
status: AFFIX_STATUS_NONE,
79+
} as AffixState
80+
const placeholderRect = getTargetRect(placeholderNode.value as HTMLElement)
81+
82+
if (
83+
placeholderRect.top === 0 &&
84+
placeholderRect.left === 0 &&
85+
placeholderRect.width === 0 &&
86+
placeholderRect.height === 0
87+
) {
88+
return
89+
}
90+
91+
const targetRect = getTargetRect(targetNode)
92+
const fixedTop = getFixedTop(placeholderRect, targetRect, offsetTop.value)
93+
const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom.value)
94+
if (
95+
placeholderRect.top === 0 &&
96+
placeholderRect.left === 0 &&
97+
placeholderRect.width === 0 &&
98+
placeholderRect.height === 0
99+
) {
100+
return
101+
}
102+
103+
if (fixedTop !== undefined) {
104+
const width = `${placeholderRect.width}px`
105+
const height = `${placeholderRect.height}px`
106+
107+
newState.affixStyle = {
108+
position: 'fixed',
109+
top: fixedTop,
110+
width,
111+
height,
112+
}
113+
newState.placeholderStyle = {
114+
width,
115+
height,
116+
}
117+
} else if (fixedBottom !== undefined) {
118+
const width = `${placeholderRect.width}px`
119+
const height = `${placeholderRect.height}px`
120+
121+
newState.affixStyle = {
122+
position: 'fixed',
123+
bottom: fixedBottom,
124+
width,
125+
height,
126+
}
127+
newState.placeholderStyle = {
128+
width,
129+
height,
130+
}
131+
}
132+
133+
newState.lastAffix = !!newState.affixStyle
134+
if (lastAffix !== newState.lastAffix) {
135+
emit('change', newState.lastAffix)
136+
}
137+
// update state
138+
Object.assign(state, newState)
139+
}
140+
const prepareMeasure = () => {
141+
Object.assign(state, {
142+
status: AFFIX_STATUS_PREPARE,
143+
affixStyle: undefined,
144+
placeholderStyle: undefined,
145+
})
146+
}
147+
148+
const updatePosition = throttleByAnimationFrame(() => {
149+
prepareMeasure()
150+
})
151+
const lazyUpdatePosition = throttleByAnimationFrame(() => {
152+
const { target } = props
153+
const { affixStyle } = state
154+
155+
// Check position change before measure to make Safari smooth
156+
if (target && affixStyle) {
157+
const targetNode = target()
158+
if (targetNode && placeholderNode.value) {
159+
const targetRect = getTargetRect(targetNode)
160+
const placeholderRect = getTargetRect(placeholderNode.value as HTMLElement)
161+
const fixedTop = getFixedTop(placeholderRect, targetRect, offsetTop.value)
162+
const fixedBottom = getFixedBottom(placeholderRect, targetRect, offsetBottom.value)
163+
if (
164+
(fixedTop !== undefined && affixStyle.top === fixedTop) ||
165+
(fixedBottom !== undefined && affixStyle.bottom === fixedBottom)
166+
) {
167+
return
168+
}
169+
}
170+
}
171+
// Directly call prepare measure since it's already throttled.
172+
prepareMeasure()
173+
})
174+
175+
defineExpose({
176+
updatePosition,
177+
lazyUpdatePosition,
178+
})
179+
watch(
180+
() => props.target,
181+
val => {
182+
const newTarget = val?.() || null
183+
if (prevTarget.value !== newTarget) {
184+
removeObserveTarget(currentInstance)
185+
if (newTarget) {
186+
addObserveTarget(newTarget, currentInstance)
187+
// Mock Event object.
188+
updatePosition()
189+
}
190+
prevTarget.value = newTarget
191+
}
192+
},
193+
)
194+
watch(() => [props.offsetTop, props.offsetBottom], updatePosition)
195+
onMounted(() => {
196+
const { target } = props
197+
if (target) {
198+
// [Legacy] Wait for parent component ref has its value.
199+
// We should use target as directly element instead of function which makes element check hard.
200+
timeout.value = setTimeout(() => {
201+
addObserveTarget(target(), currentInstance)
202+
// Mock Event object.
203+
updatePosition()
204+
})
205+
}
206+
})
207+
onUpdated(() => {
208+
measure()
209+
})
210+
onUnmounted(() => {
211+
clearTimeout(timeout.value)
212+
removeObserveTarget(currentInstance)
213+
;(updatePosition as any).cancel()
214+
;(lazyUpdatePosition as any).cancel()
215+
})
216+
217+
const enableFixed = computed(() => {
218+
return !!state.affixStyle
219+
})
220+
</script>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { Affix, Button } from '@ant-design-vue/ui'
3+
import { mount } from '@vue/test-utils'
4+
5+
describe('Affix', () => {
6+
it('should render correctly', () => {
7+
const wrapper = mount(Affix)
8+
expect(wrapper.html()).toMatchSnapshot()
9+
})
10+
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { App, Plugin } from 'vue'
2+
import Affix from './Affix.vue'
3+
import './style/index.css'
4+
5+
export { default as Affix } from './Affix.vue'
6+
export * from './meta'
7+
8+
/* istanbul ignore next */
9+
Affix.install = function (app: App) {
10+
app.component('AAffix', Affix)
11+
return app
12+
}
13+
14+
export default Affix as typeof Affix & Plugin
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { CSSProperties } from 'vue'
2+
3+
function getDefaultTarget() {
4+
return typeof window !== 'undefined' ? window : null
5+
}
6+
export const AFFIX_STATUS_NONE = 0
7+
export const AFFIX_STATUS_PREPARE = 1
8+
9+
type AffixStatus = typeof AFFIX_STATUS_NONE | typeof AFFIX_STATUS_PREPARE
10+
11+
export interface AffixState {
12+
affixStyle?: CSSProperties
13+
placeholderStyle?: CSSProperties
14+
status: AffixStatus
15+
lastAffix: boolean
16+
}
17+
18+
export type AffixProps = {
19+
/**
20+
* Specifies the offset top of the affix
21+
*/
22+
offsetTop?: number
23+
/**
24+
* Specifies the offset bottom of the affix
25+
*/
26+
offsetBottom?: number
27+
/**
28+
* Specifies the target of the affix
29+
*/
30+
target?: () => Window | HTMLElement | null
31+
/**
32+
* Specifies the z-index of the affix
33+
*/
34+
zIndex?: number
35+
}
36+
37+
export const affixDefaultProps = {
38+
target: getDefaultTarget,
39+
zIndex: 10,
40+
} as const
41+
42+
export type AffixEmits = {
43+
/**
44+
* Triggered when the affix status changes
45+
* @param lastAffix - The last affix status
46+
*/
47+
(e: 'change', lastAffix: boolean): void
48+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@reference '../../../style/tailwind.css';
2+
3+
.ant-affix {
4+
@apply fixed z-10;
5+
}

0 commit comments

Comments
 (0)