Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 5cc4a63

Browse files
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)

AltStyle によって変換されたページ (->オリジナル) /