kmcom-nuxt-layers 6.1.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/USAGE.md +8 -8
- package/layers/core/app/app.config.ts +6 -3
- package/layers/core/app/components/LoadingScreen.vue +142 -76
- package/layers/core/app/composables/useLoading.ts +57 -36
- package/layers/core/app/plugins/loading.client.ts +21 -27
- package/layers/core/app/types/loading.ts +18 -8
- package/layers/core/app/utils/index.ts +1 -0
- package/layers/core/app/utils/loading.test.ts +48 -0
- package/layers/core/app/utils/loading.ts +53 -0
- package/layers/motion/scroll/app/plugins/locomotive-scroll.client.ts +22 -0
- package/package.json +1 -1
package/docs/USAGE.md
CHANGED
|
@@ -188,14 +188,14 @@ export default defineAppConfig({
|
|
|
188
188
|
|
|
189
189
|
### Available Config Namespaces
|
|
190
190
|
|
|
191
|
-
| Namespace | Layer | Key Options
|
|
192
|
-
| ----------------------- | ------- |
|
|
193
|
-
| `coreLayer.loading` | core | `enabled`, `minDuration`, `maxDuration`, `background`, `textColor`
|
|
194
|
-
| `coreLayer.scrollGuard` | core | `enabled`, `strict`, `excludeSelectors`, `debug`
|
|
195
|
-
| `coreLayer.errors` | core | `logToConsole`, `logToExternal`, `externalUrl`
|
|
196
|
-
| `coreLayer.notFound` | core | `icon`, `title`, `description`, `showHomeButton`, `actions`, `suggestions`
|
|
197
|
-
| `motion` | motion | `gsapScrollTrigger`, `lenis.*` (duration, smoothWheel, smoothTouch, etc.)
|
|
198
|
-
| `themeLayer` | theming | `accents`, `defaultAccent`
|
|
191
|
+
| Namespace | Layer | Key Options |
|
|
192
|
+
| ----------------------- | ------- | --------------------------------------------------------------------------- |
|
|
193
|
+
| `coreLayer.loading` | core | `enabled`, `minDuration`, `maxDuration`, `label`, `background`, `textColor` |
|
|
194
|
+
| `coreLayer.scrollGuard` | core | `enabled`, `strict`, `excludeSelectors`, `debug` |
|
|
195
|
+
| `coreLayer.errors` | core | `logToConsole`, `logToExternal`, `externalUrl` |
|
|
196
|
+
| `coreLayer.notFound` | core | `icon`, `title`, `description`, `showHomeButton`, `actions`, `suggestions` |
|
|
197
|
+
| `motion` | motion | `gsapScrollTrigger`, `lenis.*` (duration, smoothWheel, smoothTouch, etc.) |
|
|
198
|
+
| `themeLayer` | theming | `accents`, `defaultAccent` |
|
|
199
199
|
|
|
200
200
|
## Optional Modules
|
|
201
201
|
|
|
@@ -33,10 +33,12 @@ export default defineAppConfig({
|
|
|
33
33
|
loading: {
|
|
34
34
|
// Master toggle - set to false to disable completely
|
|
35
35
|
enabled: true,
|
|
36
|
-
// Minimum time
|
|
37
|
-
minDuration:
|
|
38
|
-
//
|
|
36
|
+
// Minimum time (ms, from navigation start) the count runs before reaching 100 (prevents flash)
|
|
37
|
+
minDuration: 1500,
|
|
38
|
+
// Longest wait (ms, after mount) for fonts and images before finishing anyway
|
|
39
39
|
maxDuration: 3000,
|
|
40
|
+
// Small caption above the counter - empty string hides it
|
|
41
|
+
label: 'Loading',
|
|
40
42
|
// Background color
|
|
41
43
|
background: '#0a0a0a',
|
|
42
44
|
// Progress text color
|
|
@@ -74,6 +76,7 @@ declare module '@nuxt/schema' {
|
|
|
74
76
|
enabled?: boolean
|
|
75
77
|
minDuration?: number
|
|
76
78
|
maxDuration?: number
|
|
79
|
+
label?: string
|
|
77
80
|
background?: string
|
|
78
81
|
textColor?: string
|
|
79
82
|
zIndex?: number
|
|
@@ -1,105 +1,88 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
minDuration?: number
|
|
5
|
-
maxDuration?: number
|
|
6
|
-
background?: string
|
|
7
|
-
textColor?: string
|
|
8
|
-
zIndex?: number
|
|
9
|
-
}
|
|
2
|
+
import type { LoadingScreenState } from '../types/loading'
|
|
3
|
+
import { resolveLoadingConfig } from '../utils/loading'
|
|
10
4
|
|
|
11
|
-
|
|
12
|
-
const
|
|
5
|
+
// Beat on "100" so the finished count registers before the exit starts
|
|
6
|
+
const HOLD_MS = 350
|
|
7
|
+
|
|
8
|
+
const { enabled, label, background, textColor, zIndex } = resolveLoadingConfig(
|
|
9
|
+
useAppConfig().coreLayer
|
|
10
|
+
)
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return coreLayer?.loading || {}
|
|
18
|
-
})
|
|
12
|
+
const { isLoading, progress } = useLoading()
|
|
13
|
+
// Written only here; everything else reads it through useLoading().screen
|
|
14
|
+
const screen = useState<LoadingScreenState>('core:loading:screen', () => 'hidden')
|
|
19
15
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const textColor = computed(() => loadingConfig.value?.textColor || '#ffffff')
|
|
23
|
-
const zIndex = computed(() => loadingConfig.value?.zIndex || 10000)
|
|
16
|
+
const visible = ref(enabled)
|
|
17
|
+
if (enabled) screen.value = 'visible'
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
const displayProgress = computed(() => Math.round(progress.value))
|
|
19
|
+
const displayProgress = computed(() => Math.floor(progress.value))
|
|
27
20
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
if (enabled) {
|
|
22
|
+
useHead({
|
|
23
|
+
// Lock page scroll from the SSR'd first paint until the exit begins
|
|
24
|
+
htmlAttrs: {
|
|
25
|
+
style: computed(() => (screen.value === 'visible' ? 'overflow: hidden' : '')),
|
|
26
|
+
},
|
|
27
|
+
// Nothing dismisses the screen without JS - don't let it trap the page
|
|
28
|
+
noscript: [
|
|
29
|
+
{ innerHTML: '<style>#loading-screen{display:none}html{overflow:auto!important}</style>' },
|
|
30
|
+
],
|
|
31
|
+
})
|
|
32
|
+
}
|
|
31
33
|
|
|
32
|
-
// Watch for loading completion
|
|
33
34
|
watch(
|
|
34
|
-
|
|
35
|
-
(
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}, minDuration.value)
|
|
42
|
-
}
|
|
35
|
+
isLoading,
|
|
36
|
+
(loading) => {
|
|
37
|
+
if (loading || !visible.value) return
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
visible.value = false
|
|
40
|
+
screen.value = 'leaving'
|
|
41
|
+
}, HOLD_MS)
|
|
43
42
|
},
|
|
44
43
|
{ immediate: true }
|
|
45
44
|
)
|
|
46
45
|
</script>
|
|
47
46
|
|
|
48
47
|
<template>
|
|
49
|
-
<Transition name="
|
|
48
|
+
<Transition name="loading-screen" @after-leave="screen = 'hidden'">
|
|
50
49
|
<div
|
|
51
50
|
v-if="visible"
|
|
52
51
|
id="loading-screen"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
zIndex: zIndex,
|
|
56
|
-
}"
|
|
52
|
+
class="loading-screen"
|
|
53
|
+
:style="{ backgroundColor: background, color: textColor, zIndex }"
|
|
57
54
|
>
|
|
58
|
-
<div
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
<div
|
|
56
|
+
class="loading-content"
|
|
57
|
+
role="progressbar"
|
|
58
|
+
:aria-label="label || 'Loading'"
|
|
59
|
+
aria-valuemin="0"
|
|
60
|
+
aria-valuemax="100"
|
|
61
|
+
:aria-valuenow="displayProgress"
|
|
62
|
+
>
|
|
63
|
+
<span v-if="label" class="loading-label" aria-hidden="true">{{ label }}</span>
|
|
64
|
+
<span class="loading-progress" aria-hidden="true"
|
|
65
|
+
>{{ displayProgress }}<span class="loading-unit">%</span></span
|
|
66
|
+
>
|
|
62
67
|
</div>
|
|
68
|
+
<div class="loading-bar" :style="{ transform: `scaleX(${progress / 100})` }" />
|
|
63
69
|
</div>
|
|
64
70
|
</Transition>
|
|
65
71
|
</template>
|
|
66
72
|
|
|
67
73
|
<style scoped>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.fade-enter-from,
|
|
75
|
-
.fade-leave-to {
|
|
76
|
-
opacity: 0;
|
|
74
|
+
.loading-screen {
|
|
75
|
+
display: grid;
|
|
76
|
+
position: fixed;
|
|
77
|
+
inset: 0;
|
|
78
|
+
container-type: size;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
.loading-content {
|
|
80
82
|
display: flex;
|
|
81
|
-
position: fixed;
|
|
82
|
-
top: 0;
|
|
83
|
-
left: 0;
|
|
84
83
|
flex-direction: column;
|
|
85
|
-
justify-content: center;
|
|
86
84
|
align-items: center;
|
|
87
|
-
width: 100%;
|
|
88
|
-
height: 100vh;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
.loading-container {
|
|
92
|
-
display: grid;
|
|
93
|
-
container-type: size;
|
|
94
|
-
width: 100%;
|
|
95
|
-
height: 100vh;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
.loading-progress {
|
|
99
85
|
place-self: center;
|
|
100
|
-
font-weight: 900;
|
|
101
|
-
font-stretch: ultra-expanded;
|
|
102
|
-
font-size: clamp(3rem, 35cqw + 1rem, 18rem);
|
|
103
86
|
font-family:
|
|
104
87
|
system-ui,
|
|
105
88
|
-apple-system,
|
|
@@ -109,15 +92,98 @@
|
|
|
109
92
|
'Helvetica Neue',
|
|
110
93
|
Arial,
|
|
111
94
|
sans-serif;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
95
|
+
/* `backwards`, not `both` - a held end state would block the exit transition below */
|
|
96
|
+
animation: loading-enter 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.1s backwards;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.loading-label {
|
|
100
|
+
margin-bottom: 1em;
|
|
101
|
+
font-weight: 500;
|
|
102
|
+
font-size: clamp(0.75rem, 1cqw + 0.5rem, 1rem);
|
|
103
|
+
letter-spacing: 0.3em;
|
|
104
|
+
text-transform: uppercase;
|
|
105
|
+
opacity: 0.6;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.loading-progress {
|
|
109
|
+
font-weight: 900;
|
|
110
|
+
font-stretch: ultra-expanded;
|
|
111
|
+
font-size: clamp(3rem, 35cqw + 1rem, 18rem);
|
|
112
|
+
line-height: 0.8;
|
|
113
|
+
/* Equal-width digits - the counter doesn't jitter as it ticks */
|
|
114
|
+
font-variant-numeric: tabular-nums;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.loading-unit {
|
|
118
|
+
margin-left: 0.08em;
|
|
119
|
+
font-weight: 500;
|
|
120
|
+
font-size: 0.2em;
|
|
121
|
+
vertical-align: top;
|
|
122
|
+
opacity: 0.6;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.loading-bar {
|
|
126
|
+
position: absolute;
|
|
127
|
+
inset: auto 0 0;
|
|
128
|
+
height: 2px;
|
|
129
|
+
transform-origin: left;
|
|
130
|
+
background-color: currentcolor;
|
|
131
|
+
opacity: 0.5;
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
@media (width >= 768px) {
|
|
118
|
-
.loading-
|
|
135
|
+
.loading-content {
|
|
136
|
+
align-items: flex-start;
|
|
119
137
|
place-self: end start;
|
|
120
138
|
padding: 2rem;
|
|
121
139
|
}
|
|
122
140
|
}
|
|
141
|
+
|
|
142
|
+
@keyframes loading-enter {
|
|
143
|
+
from {
|
|
144
|
+
transform: translateY(0.75rem);
|
|
145
|
+
opacity: 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Exit: the count lifts away first, then the screen fades to reveal the page */
|
|
150
|
+
.loading-screen-leave-active {
|
|
151
|
+
transition: opacity 0.8s cubic-bezier(0.65, 0, 0.35, 1) 0.2s;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.loading-screen-leave-to {
|
|
155
|
+
opacity: 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.loading-screen-leave-active .loading-content,
|
|
159
|
+
.loading-screen-leave-active .loading-bar {
|
|
160
|
+
transition:
|
|
161
|
+
opacity 0.4s ease-in,
|
|
162
|
+
transform 0.5s cubic-bezier(0.7, 0, 0.84, 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.loading-screen-leave-to .loading-content {
|
|
166
|
+
transform: translateY(-1.5rem);
|
|
167
|
+
opacity: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.loading-screen-leave-to .loading-bar {
|
|
171
|
+
opacity: 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@media (prefers-reduced-motion: reduce) {
|
|
175
|
+
.loading-content {
|
|
176
|
+
animation-name: loading-fade-in;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.loading-screen-leave-to .loading-content {
|
|
180
|
+
transform: none;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@keyframes loading-fade-in {
|
|
185
|
+
from {
|
|
186
|
+
opacity: 0;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
123
189
|
</style>
|
|
@@ -1,65 +1,85 @@
|
|
|
1
1
|
// composables/useLoading.ts
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import type { LoadingScreenState } from '../types/loading'
|
|
4
|
+
import { approachProgress, resolveLoadingConfig, trickleProgress } from '../utils/loading'
|
|
5
|
+
|
|
6
|
+
// Frame-loop state is non-reactive and intentionally module-scope (singleton)
|
|
7
|
+
const _loop = {
|
|
8
|
+
frame: 0,
|
|
9
|
+
lastTime: 0,
|
|
10
|
+
startedAt: 0,
|
|
11
|
+
target: 0,
|
|
12
|
+
done: false,
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Loading state composable for app initialization and async operations
|
|
11
17
|
*
|
|
12
18
|
* Features:
|
|
13
|
-
* -
|
|
19
|
+
* - Smooth progress: a per-frame loop eases the displayed value toward a simulated target that
|
|
20
|
+
* slows as it nears 90%, then counts up to 100 once work is done
|
|
21
|
+
* - Holds below 100 until `minDuration` has passed, so fast loads don't flash
|
|
14
22
|
* - Shared state across all component instances
|
|
15
23
|
* - Can be disabled via app.config
|
|
16
24
|
* - Works without UI component (headless)
|
|
17
25
|
*
|
|
18
|
-
* @param id - Optional scoped loading ID (for future multi-loading support)
|
|
19
26
|
* @returns Loading state and control methods
|
|
20
27
|
*/
|
|
21
28
|
export function useLoading() {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
?.loading
|
|
25
|
-
const isLoading = useState('core:loading', () => loadingConfig?.enabled !== false)
|
|
29
|
+
const { enabled, minDuration } = resolveLoadingConfig(useAppConfig().coreLayer)
|
|
30
|
+
const isLoading = useState('core:loading', () => enabled)
|
|
26
31
|
const progress = useState('core:loading:progress', () => 0)
|
|
32
|
+
const screen = useState<LoadingScreenState>('core:loading:screen', () => 'hidden')
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
progress.value = 0
|
|
34
|
+
// Trickle pace follows minDuration: a typical load sits around 60% when it may finish
|
|
35
|
+
const trickleTau = Math.max(minDuration, 500) * 0.8
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
function tick(now: number): void {
|
|
38
|
+
// Cap at one 30fps frame so a main-thread hitch (hydration, a backgrounded tab) is followed
|
|
39
|
+
// by a quick count, not a leap
|
|
40
|
+
const dt = Math.min((now - _loop.lastTime) / 1000, 1 / 30)
|
|
41
|
+
_loop.lastTime = now
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
progress.value = Math.min(progress.value + increment, 90)
|
|
39
|
-
return
|
|
40
|
-
}
|
|
41
|
-
if (_timers.progressInterval) {
|
|
42
|
-
clearInterval(_timers.progressInterval)
|
|
43
|
-
_timers.progressInterval = null
|
|
44
|
-
}
|
|
45
|
-
}, 150)
|
|
46
|
-
}
|
|
43
|
+
const elapsed = now - _loop.startedAt
|
|
44
|
+
_loop.target = Math.max(_loop.target, trickleProgress(elapsed, trickleTau))
|
|
45
|
+
if (_loop.done && elapsed >= minDuration) _loop.target = 100
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
progress.value = approachProgress(progress.value, _loop.target, dt)
|
|
48
|
+
|
|
49
|
+
if (progress.value >= 100) {
|
|
50
|
+
_loop.frame = 0
|
|
51
|
+
isLoading.value = false
|
|
52
|
+
return
|
|
52
53
|
}
|
|
54
|
+
_loop.frame = requestAnimationFrame(tick)
|
|
55
|
+
}
|
|
53
56
|
|
|
54
|
-
|
|
57
|
+
function run(): void {
|
|
58
|
+
if (!import.meta.client || _loop.frame) return
|
|
59
|
+
_loop.lastTime = performance.now()
|
|
60
|
+
_loop.frame = requestAnimationFrame(tick)
|
|
61
|
+
}
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
function startLoading(since?: number): void {
|
|
64
|
+
isLoading.value = true
|
|
65
|
+
progress.value = 0
|
|
66
|
+
|
|
67
|
+
if (!import.meta.client) return
|
|
68
|
+
cancelAnimationFrame(_loop.frame)
|
|
69
|
+
_loop.frame = 0
|
|
70
|
+
_loop.startedAt = since ?? performance.now()
|
|
71
|
+
_loop.target = 0
|
|
72
|
+
_loop.done = false
|
|
73
|
+
run()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stopLoading(): void {
|
|
77
|
+
_loop.done = true
|
|
78
|
+
if (isLoading.value) run()
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
function updateProgress(value: number): void {
|
|
62
|
-
|
|
82
|
+
_loop.target = Math.max(_loop.target, Math.min(Math.max(value, 0), 99))
|
|
63
83
|
}
|
|
64
84
|
|
|
65
85
|
async function withLoading(fn: () => Promise<void>): Promise<void> {
|
|
@@ -74,6 +94,7 @@ export function useLoading() {
|
|
|
74
94
|
return {
|
|
75
95
|
isLoading: readonly(isLoading),
|
|
76
96
|
progress: readonly(progress),
|
|
97
|
+
screen: readonly(screen),
|
|
77
98
|
startLoading,
|
|
78
99
|
stopLoading,
|
|
79
100
|
updateProgress,
|
|
@@ -1,24 +1,17 @@
|
|
|
1
1
|
// plugins/loading.client.ts
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
enabled?: boolean
|
|
5
|
-
minDuration?: number
|
|
6
|
-
maxDuration?: number
|
|
7
|
-
background?: string
|
|
8
|
-
textColor?: string
|
|
9
|
-
zIndex?: number
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
type CoreLayerConfig = {
|
|
13
|
-
loading?: LoadingConfig
|
|
14
|
-
}
|
|
3
|
+
import { resolveLoadingConfig } from '../utils/loading'
|
|
15
4
|
|
|
16
5
|
/**
|
|
17
6
|
* Loading screen initialization plugin
|
|
18
7
|
*
|
|
19
|
-
*
|
|
20
|
-
* 1.
|
|
21
|
-
*
|
|
8
|
+
* Client-side only. Once the app has mounted (hydration done) it:
|
|
9
|
+
* 1. Starts the progress count, timed from navigation start - the SSR'd screen has been up
|
|
10
|
+
* since first paint, so the count catches up smoothly rather than starting late
|
|
11
|
+
* 2. Waits for fonts and the initial page's images, capped at `maxDuration`
|
|
12
|
+
* 3. Marks loading done - the count then finishes to 100 before the screen exits
|
|
13
|
+
*
|
|
14
|
+
* Starting after mount keeps the counter at the SSR'd 0 through hydration (no mismatch).
|
|
22
15
|
*
|
|
23
16
|
* Configuration via app.config.ts:
|
|
24
17
|
* - Set `coreLayer.loading.enabled: false` to disable
|
|
@@ -26,26 +19,27 @@ type CoreLayerConfig = {
|
|
|
26
19
|
export default defineNuxtPlugin({
|
|
27
20
|
name: 'core:loading',
|
|
28
21
|
setup(nuxtApp) {
|
|
29
|
-
const
|
|
30
|
-
const coreLayer = config.coreLayer as CoreLayerConfig | undefined
|
|
22
|
+
const { enabled, maxDuration } = resolveLoadingConfig(useAppConfig().coreLayer)
|
|
31
23
|
|
|
32
24
|
// Check if loading is enabled
|
|
33
|
-
if (
|
|
25
|
+
if (!enabled) {
|
|
34
26
|
return // Disabled, skip initialization
|
|
35
27
|
}
|
|
36
28
|
|
|
37
29
|
const { startLoading, stopLoading } = useLoading()
|
|
38
30
|
|
|
39
|
-
//
|
|
40
|
-
startLoading()
|
|
41
|
-
|
|
42
|
-
// Stop loading when app is fully mounted
|
|
31
|
+
// Not awaited: app:mounted handlers run serially, so waiting here would hold up other layers
|
|
43
32
|
nuxtApp.hook('app:mounted', () => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
33
|
+
startLoading(0)
|
|
34
|
+
void Promise.race([
|
|
35
|
+
Promise.all([pageLoaded(), document.fonts?.ready]),
|
|
36
|
+
new Promise((resolve) => setTimeout(resolve, maxDuration)),
|
|
37
|
+
]).then(stopLoading)
|
|
49
38
|
})
|
|
50
39
|
},
|
|
51
40
|
})
|
|
41
|
+
|
|
42
|
+
function pageLoaded(): Promise<unknown> {
|
|
43
|
+
if (document.readyState === 'complete') return Promise.resolve()
|
|
44
|
+
return new Promise((resolve) => window.addEventListener('load', resolve, { once: true }))
|
|
45
|
+
}
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
export type LoadingConfig = {
|
|
7
7
|
/** Master toggle - set to false to disable completely */
|
|
8
8
|
enabled: boolean
|
|
9
|
-
/** Minimum time
|
|
9
|
+
/** Minimum time (ms, from navigation start) the count runs before it may reach 100 */
|
|
10
10
|
minDuration: number
|
|
11
|
-
/**
|
|
11
|
+
/** Longest wait (ms, after the app mounts) for fonts and images before finishing anyway */
|
|
12
12
|
maxDuration: number
|
|
13
|
+
/** Small caption above the counter - empty string hides it */
|
|
14
|
+
label: string
|
|
13
15
|
/** Background color */
|
|
14
16
|
background: string
|
|
15
17
|
/** Progress text color */
|
|
@@ -18,6 +20,12 @@ export type LoadingConfig = {
|
|
|
18
20
|
zIndex: number
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Boot loading screen lifecycle, shared so other layers can hold their own motion until the
|
|
25
|
+
* page is revealed: covering the page → playing its exit → gone (or never mounted)
|
|
26
|
+
*/
|
|
27
|
+
export type LoadingScreenState = 'visible' | 'leaving' | 'hidden'
|
|
28
|
+
|
|
21
29
|
/**
|
|
22
30
|
* Loading state interface
|
|
23
31
|
*/
|
|
@@ -32,15 +40,17 @@ export type LoadingState = {
|
|
|
32
40
|
* Loading composable return interface
|
|
33
41
|
*/
|
|
34
42
|
export type UseLoadingReturn = {
|
|
35
|
-
/**
|
|
43
|
+
/** True until the displayed progress has counted up to 100 (readonly) */
|
|
36
44
|
isLoading: Readonly<Ref<boolean>>
|
|
37
|
-
/**
|
|
45
|
+
/** Displayed progress 0-100, eased every frame - never jumps or moves backwards (readonly) */
|
|
38
46
|
progress: Readonly<Ref<number>>
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
/**
|
|
47
|
+
/** Boot loading screen lifecycle (readonly) */
|
|
48
|
+
screen: Readonly<Ref<LoadingScreenState>>
|
|
49
|
+
/** Start simulated progress, timed from `since` (a `performance.now()` value; default now) */
|
|
50
|
+
startLoading: (since?: number) => void
|
|
51
|
+
/** Mark work done - the count finishes to 100 once `minDuration` has passed */
|
|
42
52
|
stopLoading: () => void
|
|
43
|
-
/**
|
|
53
|
+
/** Raise the progress floor (0-99) - only `stopLoading` completes */
|
|
44
54
|
updateProgress: (value: number) => void
|
|
45
55
|
/** Execute async function with loading state */
|
|
46
56
|
withLoading: (fn: () => Promise<void>) => Promise<void>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { approachProgress, resolveLoadingConfig, trickleProgress } from './loading'
|
|
4
|
+
|
|
5
|
+
const FRAME = 1 / 60
|
|
6
|
+
|
|
7
|
+
describe('resolveLoadingConfig', () => {
|
|
8
|
+
it('fills unset keys with defaults but keeps falsy values that were set', () => {
|
|
9
|
+
expect(resolveLoadingConfig(undefined)).toMatchObject({ enabled: true, minDuration: 1500 })
|
|
10
|
+
expect(resolveLoadingConfig({ loading: { minDuration: 0, label: '' } })).toMatchObject({
|
|
11
|
+
enabled: true,
|
|
12
|
+
minDuration: 0,
|
|
13
|
+
label: '',
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('trickleProgress', () => {
|
|
19
|
+
it('starts at 0, rises with time, and stays below the cap', () => {
|
|
20
|
+
expect(trickleProgress(0, 1200)).toBe(0)
|
|
21
|
+
expect(trickleProgress(-500, 1200)).toBe(0)
|
|
22
|
+
expect(trickleProgress(1200, 1200)).toBeGreaterThan(trickleProgress(600, 1200))
|
|
23
|
+
expect(trickleProgress(60_000, 1200)).toBeLessThanOrEqual(90)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('approachProgress', () => {
|
|
28
|
+
it('counts 0 → 100 in small per-frame steps and lands exactly on the target', () => {
|
|
29
|
+
let current = 0
|
|
30
|
+
let frames = 0
|
|
31
|
+
while (current < 100) {
|
|
32
|
+
const next = approachProgress(current, 100, FRAME)
|
|
33
|
+
expect(next).toBeGreaterThan(current)
|
|
34
|
+
expect(Math.floor(next) - Math.floor(current)).toBeLessThanOrEqual(3)
|
|
35
|
+
current = next
|
|
36
|
+
frames++
|
|
37
|
+
}
|
|
38
|
+
expect(current).toBe(100)
|
|
39
|
+
// A visible count of at least ~0.7s, not a jump
|
|
40
|
+
expect(frames).toBeGreaterThan(40)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('never moves backwards or overshoots', () => {
|
|
44
|
+
expect(approachProgress(60, 40, FRAME)).toBe(60)
|
|
45
|
+
expect(approachProgress(0, 90, -0.008)).toBe(0)
|
|
46
|
+
expect(approachProgress(99.9, 100, 1)).toBe(100)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { LoadingConfig } from '../types/loading'
|
|
2
|
+
|
|
3
|
+
/** Fallbacks for keys app.config leaves unset - core's app.config ships the same values. */
|
|
4
|
+
const LOADING_DEFAULTS: LoadingConfig = {
|
|
5
|
+
enabled: true,
|
|
6
|
+
minDuration: 1500,
|
|
7
|
+
maxDuration: 3000,
|
|
8
|
+
label: 'Loading',
|
|
9
|
+
background: '#0a0a0a',
|
|
10
|
+
textColor: '#ffffff',
|
|
11
|
+
zIndex: 10000,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Reads `coreLayer.loading` from app config, filling unset keys with the defaults. */
|
|
15
|
+
export function resolveLoadingConfig(coreLayer: unknown): LoadingConfig {
|
|
16
|
+
const loading = (coreLayer as { loading?: Partial<LoadingConfig> } | undefined)?.loading
|
|
17
|
+
return { ...LOADING_DEFAULTS, ...loading }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* Pure easing math behind `useLoading`'s progress counter. `useLoading` runs these once per
|
|
22
|
+
* animation frame: `trickleProgress` moves the target, `approachProgress` moves the displayed
|
|
23
|
+
* value toward it, so the counter climbs smoothly instead of jumping between random values.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Simulated progress stays below this until loading actually finishes. */
|
|
27
|
+
const TRICKLE_CAP = 90
|
|
28
|
+
/** Time constant (s) of the displayed value's exponential ease toward its target. */
|
|
29
|
+
const EASE = 0.15
|
|
30
|
+
/** Speed band (%/s) for the displayed value: no crawling at the tail, no leaps on big jumps. */
|
|
31
|
+
const MIN_SPEED = 24
|
|
32
|
+
const MAX_SPEED = 140
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Simulated progress `elapsed` ms after loading started: quick at first, then slowing as it
|
|
36
|
+
* approaches the cap. `tau` (ms) sets the pace — ~63% of the cap is reached after `tau`.
|
|
37
|
+
*/
|
|
38
|
+
export function trickleProgress(elapsed: number, tau: number): number {
|
|
39
|
+
return TRICKLE_CAP * (1 - Math.exp(-Math.max(elapsed, 0) / tau))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Advances `current` toward `target` over `dt` seconds. Eases out as the gap closes, within the
|
|
44
|
+
* speed band, and never moves backwards or past `target`. A rAF timestamp can precede the
|
|
45
|
+
* `performance.now()` the loop started from, so `dt <= 0` is a no-op rather than a step back.
|
|
46
|
+
*/
|
|
47
|
+
export function approachProgress(current: number, target: number, dt: number): number {
|
|
48
|
+
const gap = target - current
|
|
49
|
+
if (gap <= 0 || dt <= 0) return current
|
|
50
|
+
const eased = gap * (1 - Math.exp(-dt / EASE))
|
|
51
|
+
const step = Math.min(Math.max(eased, MIN_SPEED * dt), MAX_SPEED * dt)
|
|
52
|
+
return Math.min(current + step, target)
|
|
53
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gsap } from 'gsap'
|
|
1
2
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
|
2
3
|
import LocomotiveScroll from 'locomotive-scroll'
|
|
3
4
|
|
|
@@ -15,10 +16,13 @@ export default defineNuxtPlugin({
|
|
|
15
16
|
})
|
|
16
17
|
|
|
17
18
|
const instance = shallowRef<LocomotiveScroll | null>(null)
|
|
19
|
+
const { screen } = useLoading()
|
|
18
20
|
|
|
19
21
|
function init() {
|
|
20
22
|
if (instance.value) return
|
|
21
23
|
instance.value = new LocomotiveScroll({
|
|
24
|
+
// Under core's boot loading screen, wait for its exit to start (watcher below)
|
|
25
|
+
autoStart: screen.value !== 'visible',
|
|
22
26
|
lenisOptions: {
|
|
23
27
|
lerp: 0.1,
|
|
24
28
|
smoothWheel: true,
|
|
@@ -45,6 +49,24 @@ export default defineNuxtPlugin({
|
|
|
45
49
|
instance.value = null
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
// Hold motion until core's boot loading screen reveals the page: GSAP stays paused while the
|
|
53
|
+
// screen is up, so entrance animations play once it's gone; scrolling unlocks as it exits.
|
|
54
|
+
watch(
|
|
55
|
+
screen,
|
|
56
|
+
(state) => {
|
|
57
|
+
gsap.globalTimeline.paused(state !== 'hidden')
|
|
58
|
+
|
|
59
|
+
if (state !== 'leaving') return
|
|
60
|
+
instance.value?.start()
|
|
61
|
+
// Remeasure once the screen's scroll lock is off the page
|
|
62
|
+
requestAnimationFrame(() => {
|
|
63
|
+
instance.value?.resize()
|
|
64
|
+
ScrollTrigger.refresh()
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
{ immediate: true }
|
|
68
|
+
)
|
|
69
|
+
|
|
48
70
|
const router = useRouter()
|
|
49
71
|
const appConfig = useAppConfig()
|
|
50
72
|
const scrollConfig = appConfig.scroll as { smoothScroll?: boolean | string[] } | undefined
|
package/package.json
CHANGED