Skip to content

Commit b9ecd5f

Browse files
committed
feat(button): adds a go back to top floating button
1 parent a2433ba commit b9ecd5f

File tree

8 files changed

+193
-31
lines changed

8 files changed

+193
-31
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script setup lang="ts">
2+
import { NFlex, NFloatButton, NIcon } from 'naive-ui';
3+
4+
import type { Component, PropType, Transition } from 'vue';
5+
6+
import IconTopArrow from '~/components/icons/IconTopArrow.vue';
7+
8+
defineProps({
9+
show: {
10+
type: Boolean,
11+
required: true,
12+
},
13+
icon: {
14+
type: Object as PropType<Component>,
15+
required: false,
16+
default: IconTopArrow,
17+
},
18+
});
19+
20+
const emit = defineEmits<{
21+
(e: 'onClick'): void;
22+
}>();
23+
</script>
24+
25+
<template>
26+
<Transition name="scale">
27+
<NFloatButton v-if="show" class="button" width="fit-content" @click="emit('onClick')">
28+
<NFlex size="small" align="center" justify="space-evenly">
29+
<NIcon :component="icon" />
30+
<span class="text">
31+
<slot />
32+
</span>
33+
</NFlex>
34+
</NFloatButton>
35+
</Transition>
36+
</template>
37+
38+
<style lang="scss" scoped>
39+
@use '~/styles/mixin' as mixin;
40+
@use '~/styles/transition' as transition;
41+
@include transition.scale($scale: 0.6);
42+
43+
.button {
44+
@include mixin.hover-background;
45+
46+
right: 2rem;
47+
bottom: 2rem;
48+
display: flex;
49+
flex-direction: row;
50+
padding: 0.5rem;
51+
52+
.text {
53+
display: inline-flex;
54+
width: 0;
55+
overflow: hidden;
56+
font-size: 0.75rem;
57+
text-wrap: nowrap;
58+
transition: width 0.3s var(--n-bezier);
59+
will-change: width;
60+
}
61+
62+
&:hover .text {
63+
width: 4.5rem;
64+
margin-left: 0.5rem;
65+
}
66+
67+
@media (width <= 720px) {
68+
right: 1rem;
69+
bottom: 1rem;
70+
}
71+
}
72+
</style>

src/components/common/list/ListScroll.model.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { NVirtualList, VirtualListInst } from 'naive-ui';
22
import type { Ref } from 'vue';
33

4-
export type VirtualListRef = VirtualListInst & typeof NVirtualList;
4+
export type VirtualListRef = VirtualListInst & InstanceType<typeof NVirtualList>;
55
export type VirtualListProps = {
66
itemSize?: number;
77
visibleItemsTag?: string | ObjectConstructor;

src/components/common/list/ListScroll.vue

+26-7
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { TraktClientPagination } from '~/models/trakt/trakt-client.model';
1515
import ListEmpty from '~/components/common/list/ListEmpty.vue';
1616
import ListItem from '~/components/common/list/ListItem.vue';
1717
18-
const virtualList = ref<VirtualListRef>();
18+
const listRef = ref<VirtualListRef>();
1919
2020
const props = defineProps({
2121
items: {
@@ -42,29 +42,48 @@ const props = defineProps({
4242
type: Boolean,
4343
required: false,
4444
},
45+
scrollThreshold: {
46+
type: Number,
47+
required: false,
48+
default: 0,
49+
},
4550
});
4651
4752
const emits = defineEmits<{
4853
(e: 'onScrollBottom', listRef: Ref<VirtualListRef | undefined>): void;
4954
(e: 'onScrollTop', listRef: Ref<VirtualListRef | undefined>): void;
55+
(e: 'onScroll', listRef: Ref<VirtualListRef | undefined>): void;
5056
(e: 'onUpdated', listRef: Ref<VirtualListRef | undefined>): void;
5157
}>();
5258
53-
const { items, loading, pagination } = toRefs(props);
59+
defineExpose({
60+
list: listRef,
61+
});
62+
63+
const { items, loading, pagination, scrollThreshold } = toRefs(props);
64+
65+
const scrolled = ref(false);
5466
5567
const onScrollHandler = async (e: Event) => {
5668
if (loading.value) return;
5769
if (!e?.target) return;
5870
const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLElement;
5971
if (scrollHeight === clientHeight) return;
60-
if (!scrollTop) return emits('onScrollTop', virtualList);
72+
if (!scrollTop) {
73+
scrolled.value = false;
74+
return emits('onScrollTop', listRef);
75+
}
76+
if (!scrolled.value && scrollTop > scrollThreshold.value) {
77+
emits('onScroll', listRef);
78+
scrolled.value = true;
79+
}
6180
if (scrollHeight !== scrollTop + clientHeight) return;
6281
if (pagination?.value?.page === pagination?.value?.pageCount) return;
63-
return emits('onScrollBottom', virtualList);
82+
return emits('onScrollBottom', listRef);
6483
};
6584
6685
const onUpdatedHandler = () => {
67-
return emits('onUpdated', virtualList);
86+
return emits('onUpdated', listRef);
6887
};
6988
7089
const hoverDate = ref<string>();
@@ -77,7 +96,7 @@ const onHover = ({ item, hover }: { item: ListScrollItem; hover: boolean }) => {
7796
<Transition name="fade" mode="out-in">
7897
<NVirtualList
7998
v-if="items.length || loading"
80-
ref="virtualList"
99+
ref="listRef"
81100
class="list-scroll"
82101
:data-length="items.length"
83102
:data-page-size="pageSize"
@@ -89,7 +108,7 @@ const onHover = ({ item, hover }: { item: ListScrollItem; hover: boolean }) => {
89108
...listOptions?.visibleItemsProps,
90109
}"
91110
:padding-top="listOptions?.paddingTop ?? 60"
92-
:padding-bottom="listOptions?.paddingBottom ?? 16"
111+
:padding-bottom="listOptions?.paddingBottom ?? 32"
93112
@scroll="onScrollHandler"
94113
@vue:updated="onUpdatedHandler"
95114
>

src/components/icons/IconTopArrow.vue

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<template>
2+
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
3+
<g transform="rotate(-90 12 12) translate(24 0) scale(-1 1)">
4+
<g
5+
fill="none"
6+
stroke="currentColor"
7+
stroke-linecap="round"
8+
stroke-linejoin="round"
9+
stroke-width="2"
10+
>
11+
<path stroke-dasharray="20" stroke-dashoffset="20" d="M3 3V21">
12+
<animate
13+
fill="freeze"
14+
attributeName="stroke-dashoffset"
15+
dur="0.3s"
16+
values="20;0"
17+
/>
18+
</path>
19+
<path stroke-dasharray="15" stroke-dashoffset="15" d="M21 12H7.5">
20+
<animate
21+
fill="freeze"
22+
attributeName="stroke-dashoffset"
23+
begin="0.4s"
24+
dur="0.2s"
25+
values="15;0"
26+
/>
27+
</path>
28+
<path stroke-dasharray="8" stroke-dashoffset="8" d="M7 12L11 16M7 12L11 8">
29+
<animate
30+
fill="freeze"
31+
attributeName="stroke-dashoffset"
32+
begin="0.6s"
33+
dur="0.2s"
34+
values="8;0"
35+
/>
36+
</path>
37+
</g>
38+
</g>
39+
</svg>
40+
</template>

src/components/views/history/HistoryComponent.vue

+42-14
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,23 @@ import type {
55
ListScrollItem,
66
OnScroll,
77
OnUpdated,
8+
VirtualListRef,
89
} from '~/components/common/list/ListScroll.model';
910
11+
import FloatingButton from '~/components/common/buttons/FloatingButton.vue';
1012
import ListScroll from '~/components/common/list/ListScroll.vue';
11-
1213
import { useHistoryStore, useHistoryStoreRefs } from '~/stores/data/history.store';
1314
import { useUserSettingsStoreRefs } from '~/stores/settings/user.store';
15+
import { useI18n } from '~/utils';
1416
1517
const { filteredHistory, pagination, loading, pageSize, belowThreshold } =
1618
useHistoryStoreRefs();
1719
const { fetchHistory, clearState } = useHistoryStore();
1820
1921
const { user } = useUserSettingsStoreRefs();
2022
23+
const i18n = useI18n('history');
24+
2125
const active = ref(false);
2226
2327
onActivated(() => {
@@ -80,20 +84,44 @@ const onUpdated: OnUpdated = listRef => {
8084
page: pagination.value?.page ? pagination.value.page + 1 : 0,
8185
});
8286
};
87+
88+
const listRef = ref<{ list: VirtualListRef }>();
89+
90+
const scrolled = ref(false);
91+
92+
const onClick = () => {
93+
listRef.value?.list?.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
94+
scrolled.value = false;
95+
};
8396
</script>
8497

8598
<template>
86-
<ListScroll
87-
:items="history"
88-
:loading="loading"
89-
:pagination="pagination"
90-
:page-size="pageSize"
91-
@on-scroll-bottom="onScroll"
92-
@on-scroll-top="() => console.info('Scrolled to top')"
93-
@on-updated="onUpdated"
94-
>
95-
<template #default>
96-
<!-- TODO buttons here-->
97-
</template>
98-
</ListScroll>
99+
<div class="container">
100+
<ListScroll
101+
ref="listRef"
102+
:items="history"
103+
:loading="loading"
104+
:pagination="pagination"
105+
:page-size="pageSize"
106+
:scroll-threshold="300"
107+
@on-scroll="scrolled = true"
108+
@on-scroll-top="scrolled = false"
109+
@on-scroll-bottom="onScroll"
110+
@on-updated="onUpdated"
111+
>
112+
<template #default>
113+
<!-- TODO buttons here-->
114+
</template>
115+
</ListScroll>
116+
<FloatingButton :show="scrolled" @on-click="onClick">
117+
{{ i18n('button_top') }}
118+
</FloatingButton>
119+
</div>
99120
</template>
121+
122+
<style lang="scss" scoped>
123+
.container {
124+
width: 100%;
125+
height: 100%;
126+
}
127+
</style>

src/i18n/en/history/history.json

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"history__button_top": {
3+
"message": "Back to top",
4+
"description": "Button to scroll to the top of the history."
5+
}
6+
}

src/styles/mixin.scss

+4-9
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,14 @@
22
$from: var(--bg-color),
33
$to: var(--bg-color-hover),
44
$blur: var(--bg-blur),
5-
$transition: 0.5s var(--n-bezier)
5+
$transition: 0.5s var(--n-bezier),
6+
$additional-transition: ""
67
) {
78
background: $from;
89
backdrop-filter: blur($blur);
9-
transition:
10-
color $transition,
11-
background $transition,
12-
background-color $transition,
13-
backdrop-filter $transition,
14-
box-shadow $transition,
15-
border-color $transition;
10+
transition: all $transition;
1611
will-change: color, background, background-color, backdrop-filter, box-shadow,
17-
border-color;
12+
border-color, scale, opacity;
1813

1914
&:hover {
2015
background: $to;

src/styles/transition.scss

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
.fade-enter-active,
66
.fade-leave-active {
77
transition: opacity $transition;
8+
will-change: opacity;
89
}
910

1011
.fade-enter-from,
@@ -21,6 +22,7 @@
2122
.scale-enter-active,
2223
.scale-leave-active {
2324
transition: all $transition;
25+
will-change: transform, opacity;
2426
}
2527

2628
.scale-enter-from,

0 commit comments

Comments
 (0)