Skip to content

Commit 123d3c4

Browse files
committed
Add lazy loader component
1 parent b8ab1a6 commit 123d3c4

File tree

7 files changed

+403
-168
lines changed

7 files changed

+403
-168
lines changed

src/components/BaseLazy.vue

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<template>
2+
<component :is="tag" ref="hostEl">
3+
<slot v-if="isVisible" />
4+
<slot v-else name="placeholder" />
5+
</component>
6+
</template>
7+
8+
<script setup>
9+
import { onMounted, onBeforeUnmount, ref, watch, computed } from "vue";
10+
11+
const props = defineProps({
12+
bounds: { type: String, default: '500px' },
13+
threshold: { type: [Number, Array], default() { return 0 } },
14+
once: { type: Boolean, default: false },
15+
root: { type: [String, HTMLElement], default: null },
16+
tag: { type: String, default: 'div' },
17+
enabled: { type: Boolean, default: true }
18+
});
19+
20+
const emit = defineEmits(['enter', 'leave', 'load', 'change'])
21+
22+
const hostEl = ref(null);
23+
const isVisible = ref(false);
24+
let observer = null;
25+
let hasLoadedOnce = false;
26+
27+
const resolvedRoot = computed(() => {
28+
if (!props.root) return null;
29+
if (props.root instanceof HTMLElement) return props.root;
30+
try {
31+
const el = document.querySelector(props.root);
32+
return el instanceof HTMLElement ? el : null;
33+
} catch {
34+
return null;
35+
}
36+
});
37+
38+
function createObserver() {
39+
if (typeof window === "undefined" || !("IntersectionObserver" in window)) {
40+
// SSR or unsupported: reveal immediately
41+
reveal();
42+
return;
43+
}
44+
if (!hostEl.value) return;
45+
46+
destroyObserver();
47+
48+
observer = new IntersectionObserver(
49+
(entries) => {
50+
const entry = entries[0];
51+
const visible = entry.isIntersecting;
52+
53+
if (visible) {
54+
if (!hasLoadedOnce) {
55+
reveal();
56+
emit("load");
57+
hasLoadedOnce = true;
58+
}
59+
emit("enter");
60+
if (props.once) {
61+
destroyObserver();
62+
}
63+
} else {
64+
emit("leave");
65+
}
66+
67+
isVisible.value = visible;
68+
emit("change", visible);
69+
},
70+
{
71+
root: resolvedRoot.value,
72+
rootMargin: props.bounds,
73+
threshold: props.threshold,
74+
}
75+
);
76+
77+
observer.observe(hostEl.value);
78+
}
79+
80+
function destroyObserver() {
81+
if (observer) {
82+
observer.disconnect();
83+
observer = null;
84+
}
85+
}
86+
87+
function reveal() {
88+
isVisible.value = true;
89+
}
90+
91+
onMounted(() => {
92+
if (props.enabled) createObserver();
93+
});
94+
95+
onBeforeUnmount(() => {
96+
destroyObserver();
97+
});
98+
99+
watch(
100+
() => [props.bounds, props.threshold, props.root, props.enabled],
101+
([, , , enabled]) => {
102+
if (!enabled) {
103+
destroyObserver();
104+
return;
105+
}
106+
createObserver();
107+
}
108+
);
109+
110+
111+
function refresh() {
112+
if (props.enabled) createObserver();
113+
}
114+
115+
function revealNow() {
116+
destroyObserver();
117+
reveal();
118+
}
119+
120+
defineExpose({ refresh, revealNow, isVisible });
121+
</script>

src/components/BaseSpinner.vue

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,55 @@
11
<script setup>
2-
import { ref, computed } from "vue";
2+
import { computed } from "vue";
33
import { useMainStore } from "../stores";
44
5+
const props = defineProps({
6+
visible: { type: Boolean, default: true },
7+
duration: { type: Number, default: 250 },
8+
easing: { type: String, default: "ease" },
9+
});
10+
511
const store = useMainStore();
6-
const isDarkMode = computed(() => {
7-
return store.isDarkMode;
8-
})
12+
const isDarkMode = computed(() => store.isDarkMode);
913
14+
const fadeStyle = computed(() => ({
15+
"--fade-duration": `${props.duration}ms`,
16+
"--fade-easing": props.easing,
17+
"z-index": "2147483640"
18+
}));
1019
</script>
1120

1221
<template>
13-
<div class="fixed top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2">
14-
<img class="loader-logo fixed top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2" alt="Vue Data UI logo" src="../assets/logo.png" width="80" height="80"/>
15-
<span :class="{ 'loader': true, 'loader-dark': isDarkMode, 'loader-light': !isDarkMode }"></span>
16-
</div>
22+
<Transition name="fade" appear>
23+
<div v-if="props.visible" class="fixed top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2" :style="fadeStyle">
24+
<img class="loader-logo fixed top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2" alt="Vue Data UI logo"
25+
src="../assets/logo.png" width="80" height="80" />
26+
<span :class="{
27+
loader: true,
28+
'loader-dark': isDarkMode,
29+
'loader-light': !isDarkMode,
30+
}"></span>
31+
</div>
32+
</Transition>
1733
</template>
1834

1935
<style scoped>
36+
.fade-enter-active,
37+
.fade-leave-active {
38+
transition: opacity var(--fade-duration) var(--fade-easing);
39+
}
40+
41+
.fade-enter-from,
42+
.fade-leave-to {
43+
opacity: 0;
44+
}
2045
2146
.loader-dark,
2247
.loader-light {
2348
border-top: 4px solid #5f8aee;
2449
}
2550
2651
.loader {
52+
position: relative;
2753
width: 120px;
2854
height: 120px;
2955
border-radius: 50%;
@@ -34,8 +60,7 @@ const isDarkMode = computed(() => {
3460
}
3561
3662
.loader::after {
37-
content: '';
38-
box-sizing: border-box;
63+
content: "";
3964
position: absolute;
4065
left: 0;
4166
top: 0;
@@ -64,8 +89,22 @@ const isDarkMode = computed(() => {
6489
from {
6590
transform: translateX(-50%) translateY(-50%) scale(0.5);
6691
}
92+
6793
to {
6894
transform: translateX(-50%) translateY(-50%) scale(1);
6995
}
7096
}
71-
</style>
97+
98+
@media (prefers-reduced-motion: reduce) {
99+
100+
.loader,
101+
.loader-logo {
102+
animation: none;
103+
}
104+
105+
.fade-enter-active,
106+
.fade-leave-active {
107+
transition: none;
108+
}
109+
}
110+
</style>

src/components/BaseSuspense.vue

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup>
2+
import { ref } from "vue";
3+
import BaseSpinner from "./BaseSpinner.vue";
4+
5+
const showSpinner = ref(true);
6+
7+
function onFallback() { showSpinner.value = true; }
8+
function onResolve() { showSpinner.value = false; }
9+
</script>
10+
11+
<template>
12+
<BaseSpinner :visible="showSpinner" :duration="300" easing="ease-in-out" />
13+
14+
<Suspense @fallback="onFallback" @resolve="onResolve">
15+
<template #default>
16+
<slot name="default"/>
17+
</template>
18+
<template #fallback>
19+
<slot name="fallback"/>
20+
</template>
21+
</Suspense>
22+
</template>

src/components/Header.vue

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,28 @@ function openChartMaker() {
7373
chartMkr.value.openDialog();
7474
}
7575
76-
function changeTheme() {
76+
function updateTheme() {
7777
if (localStorage.theme === "dark") {
78-
localStorage.theme = "light";
79-
document.documentElement.classList.remove("dark");
80-
store.isDarkMode = false;
81-
} else {
82-
localStorage.theme = "dark";
83-
document.documentElement.classList.add("dark");
84-
store.isDarkMode = true;
78+
localStorage.theme = "light";
79+
document.documentElement.classList.remove("dark");
80+
store.isDarkMode = false;
81+
} else {
82+
localStorage.theme = "dark";
83+
document.documentElement.classList.add("dark");
84+
store.isDarkMode = true;
85+
}
86+
}
87+
88+
function changeTheme() {
89+
90+
if (!document.startViewTransition) {
91+
updateTheme();
92+
return;
8593
}
94+
95+
document.startViewTransition(() => {
96+
updateTheme();
97+
})
8698
}
8799
88100
const currentRoute = computed(() => {

src/style.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,30 @@ input[type="range"]:hover {
258258
.code-container {
259259
box-shadow: inset 0 4px 6px rgba(0,0,0,0.2), inset 0 -3px 4px rgba(255,255,255,0.2);
260260
border-radius: 14px !important;
261+
}
262+
263+
264+
@media (prefers-reduced-motion: no-preference) {
265+
::view-transition-old(root) {
266+
animation-delay: 500ms;
267+
}
268+
269+
::view-transition-new(root) {
270+
animation: circle-in 500ms;
271+
}
272+
273+
/* @keyframes move-in {
274+
from { translate: 100% 0; }
275+
to { translate: 0 0; }
276+
}
277+
278+
@keyframes move-out {
279+
from { translate: 0 0; }
280+
to { translate: -100% 0; }
281+
} */
282+
283+
@keyframes circle-in {
284+
from { clip-path: circle(0% at 100% 0%); }
285+
to { clip-path: circle(150% at 100% 0%); }
286+
}
261287
}

src/useExamples.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,8 @@ export default function useExamples() {
880880
series: ds,
881881
scaleMin: 0,
882882
scaleMax: 100,
883-
scaleSteps: 3
883+
scaleSteps: 3,
884+
dataLabels: false
884885
},
885886
{
886887
name: 'Channel 2',
@@ -889,7 +890,8 @@ export default function useExamples() {
889890
color: colors.value.orange,
890891
scaleMin: 0,
891892
scaleMax: 60,
892-
scaleSteps: 5
893+
scaleSteps: 5,
894+
dataLabels: false
893895
}
894896
]
895897
})

0 commit comments

Comments
 (0)