mirror of
https://gitee.com/vant-contrib/vant.git
synced 2025-04-06 03:57:59 +08:00
feat(Sticky): support sticky to bottom (#7979)
This commit is contained in:
parent
0f3610bafe
commit
6c5315f0b1
@ -38,6 +38,14 @@ app.use(Sticky);
|
||||
</div>
|
||||
```
|
||||
|
||||
### Offset Bottom
|
||||
|
||||
```html
|
||||
<van-sticky :offset-bottom="50" position="bottom">
|
||||
<van-button type="primary">Offset Bottom</van-button>
|
||||
</van-sticky>
|
||||
```
|
||||
|
||||
```js
|
||||
export default {
|
||||
setup() {
|
||||
@ -54,6 +62,8 @@ export default {
|
||||
| Attribute | Description | Type | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| offset-top | Offset top, supports `px` `vw` `vh` `rem` unit, default `px` | _number \| string_ | `0` |
|
||||
| offset-bottom | Offset bottom, supports `px` `vw` `vh` `rem` unit, default `px` | _number \| string_ | `0` |
|
||||
| position | Offset position, supports `top` `bottom` | _string_ | `top` |
|
||||
| z-index | z-index when sticky | _number \| string_ | `99` |
|
||||
| container | Container DOM | _Element_ | - |
|
||||
|
||||
|
@ -48,6 +48,16 @@ app.use(Sticky);
|
||||
</div>
|
||||
```
|
||||
|
||||
### 吸底距离
|
||||
|
||||
通过 `offset-bottom` 属性可以设置组件在吸底时与底部的距离。通过 `position` 属性控制吸附位置,默认值为 `top`。
|
||||
|
||||
```html
|
||||
<van-sticky :offset-bottom="50" position="bottom">
|
||||
<van-button type="primary">吸底距离</van-button>
|
||||
</van-sticky>
|
||||
```
|
||||
|
||||
```js
|
||||
export default {
|
||||
setup() {
|
||||
@ -64,6 +74,8 @@ export default {
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| --- | --- | --- | --- |
|
||||
| offset-top | 吸顶时与顶部的距离,支持 `px` `vw` `vh` `rem` 单位,默认 `px` | _number \| string_ | `0` |
|
||||
| offset-bottom | 吸底时与底部的距离,支持 `px` `vw` `vh` `rem` 单位,默认 `px` | _number \| string_ | `0` |
|
||||
| position | 吸附位置,支持 `top` `bottom` | _string_ | `top` |
|
||||
| z-index | 吸顶时的 z-index | _number \| string_ | `99` |
|
||||
| container | 容器对应的 HTML 节点 | _Element_ | - |
|
||||
|
||||
|
@ -24,6 +24,15 @@
|
||||
</van-sticky>
|
||||
</div>
|
||||
</demo-block>
|
||||
|
||||
<demo-block :title="t('offsetBottom')">
|
||||
<div style="height: 200px"></div>
|
||||
<van-sticky :offset-bottom="50" position="bottom">
|
||||
<van-button type="primary" style="margin-left: 15px">
|
||||
{{ t('offsetBottom') }}
|
||||
</van-button>
|
||||
</van-sticky>
|
||||
</demo-block>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@ -33,10 +42,12 @@ import { useTranslate } from '@demo/use-translate';
|
||||
const i18n = {
|
||||
'zh-CN': {
|
||||
offsetTop: '吸顶距离',
|
||||
offsetBottom: '吸底距离',
|
||||
setContainer: '指定容器',
|
||||
},
|
||||
'en-US': {
|
||||
offsetTop: 'Offset Top',
|
||||
offsetBottom: 'Offset Bottom',
|
||||
setContainer: 'Set Container',
|
||||
},
|
||||
};
|
||||
|
@ -3,9 +3,6 @@
|
||||
.van-sticky {
|
||||
&--fixed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: @sticky-z-index;
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,16 @@
|
||||
import { ref, reactive, computed, CSSProperties } from 'vue';
|
||||
import { computed, CSSProperties, PropType, reactive, ref } from 'vue';
|
||||
|
||||
// Utils
|
||||
import {
|
||||
isHidden,
|
||||
unitToPx,
|
||||
getScrollTop,
|
||||
getElementTop,
|
||||
createNamespace,
|
||||
} from '../utils';
|
||||
import { createNamespace, getScrollTop, isHidden, unitToPx } from '../utils';
|
||||
|
||||
// Composition
|
||||
import { useScrollParent, useEventListener } from '@vant/use';
|
||||
import { useEventListener, useScrollParent } from '@vant/use';
|
||||
import { useVisibilityChange } from '../composables/use-visibility-change';
|
||||
|
||||
const [createComponent, bem] = createNamespace('sticky');
|
||||
|
||||
export type Position = 'top' | 'bottom';
|
||||
|
||||
export default createComponent({
|
||||
props: {
|
||||
zIndex: [Number, String],
|
||||
@ -23,6 +19,14 @@ export default createComponent({
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
offsetBottom: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
position: {
|
||||
type: String as PropType<Position>,
|
||||
default: 'top',
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['scroll'],
|
||||
@ -33,24 +37,31 @@ export default createComponent({
|
||||
|
||||
const state = reactive({
|
||||
fixed: false,
|
||||
height: 0,
|
||||
height: 0, // root 高度
|
||||
width: 0, // root 宽度
|
||||
clientHeight: 0, // documentElement clientHeight
|
||||
transform: 0,
|
||||
});
|
||||
|
||||
const offsetTop = computed(() => unitToPx(props.offsetTop));
|
||||
const offsetBottom = computed(() => unitToPx(props.offsetBottom));
|
||||
|
||||
const style = computed<CSSProperties | undefined>(() => {
|
||||
if (!state.fixed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const top = offsetTop.value ? `${offsetTop.value}px` : undefined;
|
||||
const top = offsetTop.value ? `${offsetTop.value}px` : 0;
|
||||
const bottom = offsetBottom.value ? `${offsetBottom.value}px` : 0;
|
||||
const transform = state.transform
|
||||
? `translate3d(0, ${state.transform}px, 0)`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
top,
|
||||
height: `${state.height}px`,
|
||||
width: `${state.width}px`,
|
||||
top: props.position === 'top' ? top : undefined,
|
||||
bottom: props.position === 'bottom' ? bottom : undefined,
|
||||
zIndex: props.zIndex !== undefined ? +props.zIndex : undefined,
|
||||
transform,
|
||||
};
|
||||
@ -68,38 +79,43 @@ export default createComponent({
|
||||
return;
|
||||
}
|
||||
|
||||
state.height = root.value.offsetHeight;
|
||||
|
||||
const { container } = props;
|
||||
const rootRect = root.value.getBoundingClientRect();
|
||||
const containerRect = container?.getBoundingClientRect();
|
||||
state.height = rootRect.height;
|
||||
state.width = rootRect.width;
|
||||
state.clientHeight = document.documentElement.clientHeight;
|
||||
|
||||
const scrollTop = getScrollTop(window);
|
||||
const topToPageTop = getElementTop(root.value);
|
||||
|
||||
// The sticky component should be kept inside the container element
|
||||
if (container) {
|
||||
const bottomToPageTop = topToPageTop + container.offsetHeight;
|
||||
|
||||
if (scrollTop + offsetTop.value + state.height > bottomToPageTop) {
|
||||
const distanceToBottom = state.height + scrollTop - bottomToPageTop;
|
||||
|
||||
if (distanceToBottom < state.height) {
|
||||
state.fixed = true;
|
||||
state.transform = -(distanceToBottom + offsetTop.value);
|
||||
} else {
|
||||
state.fixed = false;
|
||||
}
|
||||
|
||||
emitScrollEvent(scrollTop);
|
||||
return;
|
||||
if (props.position === 'top') {
|
||||
if (container) {
|
||||
const difference =
|
||||
containerRect.bottom - offsetTop.value - state.height;
|
||||
state.fixed =
|
||||
offsetTop.value > rootRect.top && containerRect.bottom > 0;
|
||||
state.transform = difference < 0 ? difference : 0;
|
||||
} else {
|
||||
state.fixed = offsetTop.value > rootRect.top;
|
||||
}
|
||||
} else if (props.position === 'bottom') {
|
||||
if (container) {
|
||||
const difference =
|
||||
state.clientHeight -
|
||||
containerRect.top -
|
||||
offsetBottom.value -
|
||||
state.height;
|
||||
state.fixed =
|
||||
state.clientHeight - offsetBottom.value < rootRect.bottom &&
|
||||
state.clientHeight > containerRect.top;
|
||||
state.transform = difference < 0 ? -difference : 0;
|
||||
} else {
|
||||
state.fixed =
|
||||
state.clientHeight - offsetBottom.value < rootRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
if (scrollTop + offsetTop.value > topToPageTop) {
|
||||
state.fixed = true;
|
||||
state.transform = 0;
|
||||
} else {
|
||||
state.fixed = false;
|
||||
}
|
||||
|
||||
emitScrollEvent(scrollTop);
|
||||
};
|
||||
|
||||
@ -107,9 +123,10 @@ export default createComponent({
|
||||
useVisibilityChange(root, onScroll);
|
||||
|
||||
return () => {
|
||||
const { fixed, height } = state;
|
||||
const { fixed, height, width } = state;
|
||||
const rootStyle: CSSProperties = {
|
||||
height: fixed ? `${height}px` : undefined,
|
||||
width: fixed ? `${width}px` : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -51,4 +51,22 @@ exports[`should render demo and match snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="height: 200px;">
|
||||
</div>
|
||||
<div>
|
||||
<div class="van-sticky">
|
||||
<button type="button"
|
||||
class="van-button van-button--primary van-button--normal"
|
||||
style="margin-left: 15px;"
|
||||
>
|
||||
<div class="van-button__content">
|
||||
<span class="van-button__text">
|
||||
Offset Bottom
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
@ -30,11 +30,39 @@ exports[`should allow to using offset-top prop with vw unit 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`should sticky inside container when using container prop 1`] = `
|
||||
<div style="height: 20px;">
|
||||
<div style="height: 10px;">
|
||||
exports[`should sticky inside container bottom when using container prop 1`] = `
|
||||
<div style="margin-top: 640px;">
|
||||
<div style="height: 150px;">
|
||||
</div>
|
||||
<div style="height: 44px; width: 88px;">
|
||||
<div class="van-sticky van-sticky--fixed"
|
||||
style="transform: translate3d(0, -5px, 0);"
|
||||
style="height: 44px; width: 88px; bottom: 0px;"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`should sticky inside container bottom when using container prop 2`] = `
|
||||
<div style="margin-top: 640px;">
|
||||
<div style="height: 150px;">
|
||||
</div>
|
||||
<div style="height: 44px; width: 88px;">
|
||||
<div class="van-sticky van-sticky--fixed"
|
||||
style="height: 44px; width: 88px; bottom: 0px; transform: translate3d(0, 24px, 0);"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`should sticky inside container when using container prop 1`] = `
|
||||
<div style="height: 150px;">
|
||||
<div style="height: 44px; width: 88px;">
|
||||
<div class="van-sticky van-sticky--fixed"
|
||||
style="height: 44px; width: 88px; top: 0px;"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
@ -43,26 +71,32 @@ exports[`should sticky inside container when using container prop 1`] = `
|
||||
`;
|
||||
|
||||
exports[`should sticky inside container when using container prop 2`] = `
|
||||
<div style="height: 20px;">
|
||||
<div style="height: 10px;">
|
||||
<div class="van-sticky">
|
||||
<div style="height: 150px;">
|
||||
<div style="height: 44px; width: 88px;">
|
||||
<div class="van-sticky van-sticky--fixed"
|
||||
style="height: 44px; width: 88px; top: 0px; transform: translate3d(0, -14px, 0);"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`should sticky to top after scrolling 1`] = `
|
||||
exports[`should sticky to bottom after scrolling 1`] = `
|
||||
<div style="height: 10px;">
|
||||
<div class="van-sticky">
|
||||
<div class="van-sticky van-sticky--fixed"
|
||||
style="bottom: 10px;"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`should sticky to top after scrolling 2`] = `
|
||||
exports[`should sticky to top after scrolling 1`] = `
|
||||
<div style="height: 10px;">
|
||||
<div class="van-sticky van-sticky--fixed">
|
||||
<div class="van-sticky van-sticky--fixed"
|
||||
style="top: 0px;"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
</div>
|
||||
@ -71,7 +105,7 @@ exports[`should sticky to top after scrolling 2`] = `
|
||||
exports[`should update z-index when using z-index prop 1`] = `
|
||||
<div style="height: 10px;">
|
||||
<div class="van-sticky van-sticky--fixed"
|
||||
style="z-index: 0;"
|
||||
style="top: 0px; z-index: 0;"
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
|
@ -1,19 +1,51 @@
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { mount, mockScrollTop } from '../../../test';
|
||||
import { mockScrollTop, mount } from '../../../test';
|
||||
import Sticky from '..';
|
||||
|
||||
Object.defineProperty(window.HTMLElement.prototype, 'clientHeight', {
|
||||
value: 640,
|
||||
});
|
||||
|
||||
test('should sticky to top after scrolling', async () => {
|
||||
const wrapper = mount({
|
||||
render() {
|
||||
return <Sticky style="height: 10px;">Content</Sticky>;
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
const mockStickyRect = jest
|
||||
.spyOn(wrapper.element, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
top: -100,
|
||||
bottom: -90,
|
||||
});
|
||||
|
||||
await mockScrollTop(100);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
|
||||
mockStickyRect.mockRestore();
|
||||
});
|
||||
|
||||
test('should sticky to bottom after scrolling', async () => {
|
||||
const wrapper = mount({
|
||||
render() {
|
||||
return (
|
||||
<Sticky style="height: 10px;" offsetBottom={10} position="bottom">
|
||||
Content
|
||||
</Sticky>
|
||||
);
|
||||
},
|
||||
});
|
||||
const mockStickyRect = jest
|
||||
.spyOn(wrapper.element, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
top: 640,
|
||||
bottom: 650,
|
||||
});
|
||||
|
||||
await mockScrollTop(0);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
|
||||
mockStickyRect.mockRestore();
|
||||
});
|
||||
|
||||
test('should update z-index when using z-index prop', async () => {
|
||||
@ -27,25 +59,41 @@ test('should update z-index when using z-index prop', async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockStickyRect = jest
|
||||
.spyOn(wrapper.element, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
top: -100,
|
||||
bottom: -90,
|
||||
});
|
||||
|
||||
await mockScrollTop(100);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
await mockScrollTop(0);
|
||||
|
||||
mockStickyRect.mockRestore();
|
||||
});
|
||||
|
||||
test('should add offset top when using offset-top prop', async () => {
|
||||
const wrapper = mount({
|
||||
render() {
|
||||
return (
|
||||
<Sticky style="height: 10px;" offsetTop={10}>
|
||||
<Sticky style="height: 10px;" offsetTop={10} position="top">
|
||||
Content
|
||||
</Sticky>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const mockStickyRect = jest
|
||||
.spyOn(wrapper.element, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
top: -100,
|
||||
bottom: -90,
|
||||
});
|
||||
|
||||
await mockScrollTop(100);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
await mockScrollTop(0);
|
||||
|
||||
mockStickyRect.mockRestore();
|
||||
});
|
||||
|
||||
test('should allow to using offset-top prop with rem unit', async () => {
|
||||
@ -63,10 +111,17 @@ test('should allow to using offset-top prop with rem unit', async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockStickyRect = jest
|
||||
.spyOn(wrapper.element, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
top: -100,
|
||||
bottom: -90,
|
||||
});
|
||||
|
||||
await mockScrollTop(100);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
|
||||
await mockScrollTop(0);
|
||||
mockStickyRect.mockRestore();
|
||||
window.getComputedStyle = originGetComputedStyle;
|
||||
});
|
||||
|
||||
@ -83,9 +138,17 @@ test('should allow to using offset-top prop with vw unit', async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockStickyRect = jest
|
||||
.spyOn(wrapper.element, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
top: -100,
|
||||
bottom: -90,
|
||||
});
|
||||
|
||||
await mockScrollTop(100);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
await mockScrollTop(0);
|
||||
|
||||
mockStickyRect.mockRestore();
|
||||
});
|
||||
|
||||
test('should not trigger scroll event when hidden', () => {
|
||||
@ -114,8 +177,8 @@ test('should sticky inside container when using container prop', async () => {
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<div ref="container" style="height: 20px;">
|
||||
<Sticky ref="sticky" style="height: 10px;" container={this.container}>
|
||||
<div ref="container" style="height: 150px;">
|
||||
<Sticky ref="sticky" style="height: 44px;" container={this.container}>
|
||||
Content
|
||||
</Sticky>
|
||||
</div>
|
||||
@ -123,12 +186,97 @@ test('should sticky inside container when using container prop', async () => {
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await mockScrollTop(15);
|
||||
const mockStickyRect = jest
|
||||
.spyOn(wrapper.element.firstElementChild, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
height: 44,
|
||||
width: 88,
|
||||
top: -100,
|
||||
bottom: -56,
|
||||
});
|
||||
const mockContainerRect = jest
|
||||
.spyOn(wrapper.element, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
top: -100,
|
||||
bottom: 50,
|
||||
});
|
||||
|
||||
await mockScrollTop(100);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
await mockScrollTop(25);
|
||||
|
||||
mockStickyRect.mockReturnValue({
|
||||
height: 44,
|
||||
width: 88,
|
||||
top: -120,
|
||||
bottom: -76,
|
||||
});
|
||||
mockContainerRect.mockReturnValue({
|
||||
top: -120,
|
||||
bottom: 30,
|
||||
});
|
||||
await mockScrollTop(120);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
await mockScrollTop(0);
|
||||
mockContainerRect.mockRestore();
|
||||
mockStickyRect.mockRestore();
|
||||
});
|
||||
|
||||
test('should sticky inside container bottom when using container prop', async () => {
|
||||
const wrapper = mount({
|
||||
setup() {
|
||||
const container = ref();
|
||||
return {
|
||||
container,
|
||||
};
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<div ref="container" style="margin-top: 640px">
|
||||
<div style="height: 150px" />
|
||||
<Sticky
|
||||
ref="sticky"
|
||||
style="height: 44px;"
|
||||
container={this.container}
|
||||
position="bottom"
|
||||
>
|
||||
Content
|
||||
</Sticky>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const mockStickyRect = jest
|
||||
.spyOn(wrapper.element.children[1], 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
height: 44,
|
||||
width: 88,
|
||||
top: 690,
|
||||
bottom: 734,
|
||||
});
|
||||
const mockContainerRect = jest
|
||||
.spyOn(wrapper.element, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
top: 540,
|
||||
bottom: 734,
|
||||
});
|
||||
|
||||
await mockScrollTop(100);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
|
||||
mockStickyRect.mockReturnValue({
|
||||
height: 44,
|
||||
width: 88,
|
||||
top: 770,
|
||||
bottom: 814,
|
||||
});
|
||||
mockContainerRect.mockReturnValue({
|
||||
top: 620,
|
||||
bottom: 814,
|
||||
});
|
||||
await mockScrollTop(20);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
mockContainerRect.mockRestore();
|
||||
mockStickyRect.mockRestore();
|
||||
});
|
||||
|
||||
test('should emit scroll event when visibility changed', async () => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user