feat(Sticky): support sticky to bottom (#7979)

This commit is contained in:
kooriookami 2021-01-31 02:43:49 -06:00 committed by GitHub
parent 0f3610bafe
commit 6c5315f0b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 314 additions and 67 deletions

View File

@ -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_ | - |

View File

@ -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_ | - |

View File

@ -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',
},
};

View File

@ -3,9 +3,6 @@
.van-sticky {
&--fixed {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: @sticky-z-index;
}
}

View File

@ -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 (

View File

@ -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>
`;

View File

@ -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>

View File

@ -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 () => {