kmcom-nuxt-layers 6.2.1 → 6.4.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/MIGRATION-v6.3.0.md +234 -0
- package/docs/MIGRATION-v6.4.0.md +215 -0
- package/docs/MIGRATION.md +3 -0
- package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +16 -0
- package/docs/component-levels.md +117 -0
- package/docs/layout-shift-causes.md +18 -0
- package/layers/content/app/components/Blog/ItemPage.vue +2 -7
- package/layers/content/app/components/Blog/List.vue +9 -2
- package/layers/content/app/components/Blog/Page.vue +9 -19
- package/layers/content/app/components/Gallery/Detail.vue +1 -0
- package/layers/content/app/components/Gallery/Grid.vue +9 -2
- package/layers/content/app/components/Gallery/ImageDetail.vue +0 -6
- package/layers/content/app/components/Gallery/ImagePage.vue +10 -6
- package/layers/content/app/components/Gallery/Page.vue +9 -19
- package/layers/content/app/components/Gallery/SlugPage.vue +2 -7
- package/layers/content/app/components/NuxtContent/Detail.vue +20 -11
- package/layers/content/app/components/NuxtContent/IndexTemplate.vue +21 -0
- package/layers/content/app/components/NuxtContent/List.vue +6 -1
- package/layers/content/app/components/Portfolio/ColorPalette.vue +2 -2
- package/layers/content/app/components/Portfolio/ItemPage.vue +2 -6
- package/layers/content/app/components/Portfolio/List.vue +19 -8
- package/layers/content/app/components/Portfolio/Page.vue +9 -19
- package/layers/content/app/composables/useContentItemSeo.ts +19 -0
- package/layers/content/app/composables/useContentSection.ts +9 -0
- package/layers/content/app/utils/galleryImageMeta.test.ts +30 -0
- package/layers/content/app/utils/galleryImageMeta.ts +11 -0
- package/layers/content/nuxt.config.ts +1 -1
- package/layers/core/app/app.config.ts +2 -2
- package/layers/core/app/app.vue +2 -1
- package/layers/core/app/assets/css/core.css +5 -11
- package/layers/core/app/assets/css/tokens/z-index.css +16 -0
- package/layers/core/app/components/DiagnosticsPage.vue +41 -41
- package/layers/core/app/components/LoadingScreen.vue +2 -0
- package/layers/core/app/utils/loading.test.ts +2 -2
- package/layers/core/app/utils/loading.ts +1 -1
- package/layers/delivery/feeds/app/components/Feeds/Index.vue +36 -75
- package/layers/delivery/feeds/app/components/Feeds/RouteCard.vue +6 -8
- package/layers/design-system/theming/nuxt.config.ts +1 -3
- package/layers/design-system/typography/app/assets/css/typography.css +41 -5
- package/layers/design-system/visual/app/components/Base/Modal.vue +34 -86
- package/layers/design-system/visual/app/components/HoverDimGroup.test.ts +38 -0
- package/layers/design-system/visual/app/components/HoverDimGroup.vue +54 -0
- package/layers/design-system/visual/app/components/Progress/Bar.vue +9 -29
- package/layers/motion/animations/app/components/Motion/Cursor.vue +1 -1
- package/layers/motion/animations/app/components/Motion/Marquee.vue +5 -0
- package/layers/motion/animations/app/components/Motion/MarqueeText.vue +2 -1
- package/layers/motion/animations/app/components/Motion/Staggered.test.ts +40 -0
- package/layers/motion/animations/app/components/Motion/Staggered.vue +37 -42
- package/layers/motion/animations/app/components/Motion/TextReveal.vue +8 -1
- package/layers/motion/animations/app/components/Motion/VelocityEffect.vue +6 -0
- package/layers/motion/animations/app/composables/useCountUp.ts +6 -0
- package/layers/motion/animations/app/composables/useCursorFollower.ts +4 -1
- package/layers/motion/animations/app/utils/pointerMotion.ts +7 -2
- package/layers/motion/scroll/app/components/Motion/HorizontalScroll.vue +9 -0
- package/layers/motion/scroll/app/components/Motion/Parallax.vue +13 -12
- package/layers/motion/scroll/app/components/Motion/PinnedSection.vue +16 -9
- package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +23 -0
- package/layers/motion/scroll/app/composables/useReducedMotion.ts +14 -0
- package/layers/motion/scroll/app/utils/parallax.test.ts +24 -0
- package/layers/motion/scroll/app/utils/parallax.ts +16 -0
- package/layers/motion/scroll/nuxt.config.ts +1 -1
- package/layers/structure/layout/app/components/Layout/Grid/Debug.vue +1 -1
- package/layers/structure/layout/app/components/Layout/Page/index.vue +9 -10
- package/layers/structure/navigation/app/app.config.ts +37 -14
- package/layers/structure/navigation/app/components/Links/Group.vue +12 -38
- package/layers/structure/navigation/app/components/Links/Named.vue +9 -0
- package/layers/structure/navigation/app/components/Mast/Footer.vue +1 -1
- package/layers/structure/navigation/app/components/Mast/Nav.vue +14 -11
- package/layers/structure/navigation/app/components/Mast/NavModal.vue +95 -45
- package/layers/structure/navigation/app/components/Site/Logo.vue +35 -0
- package/layers/structure/navigation/app/components/Site/Subtitle.vue +10 -0
- package/layers/structure/navigation/app/components/Site/Title.vue +8 -0
- package/layers/structure/navigation/app/composables/mastNav.ts +38 -13
- package/layers/structure/navigation/app/types/nav.ts +34 -0
- package/layers/structure/navigation/nuxt.config.ts +7 -1
- package/package.json +1 -1
- /package/layers/{design-system/theming/app/assets/css/tokens.css → core/app/assets/css/tokens/elevation-motion.css} +0 -0
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* BaseModal — overlay-compatible modal
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 1. Copy or extend this component
|
|
7
|
-
* 2. Add your props and content in the slots
|
|
8
|
-
* 3. Register with createModal() in a composable
|
|
3
|
+
* BaseModal — overlay-compatible modal preset on UModal, which supplies the dialog role,
|
|
4
|
+
* focus trap, Escape, portal and transitions. This adds a size scale and a footer slot that
|
|
5
|
+
* receives `dismiss`.
|
|
9
6
|
*
|
|
10
7
|
* Example:
|
|
11
8
|
* // components/ConfirmModal.vue
|
|
@@ -31,13 +28,13 @@
|
|
|
31
28
|
close: []
|
|
32
29
|
}>()
|
|
33
30
|
|
|
34
|
-
const sizeClass
|
|
35
|
-
sm: 'max-w-sm',
|
|
36
|
-
md: 'max-w-md',
|
|
37
|
-
lg: 'max-w-lg',
|
|
38
|
-
xl: 'max-w-xl',
|
|
39
|
-
full: '
|
|
40
|
-
}
|
|
31
|
+
const sizeClass = {
|
|
32
|
+
sm: 'sm:max-w-sm',
|
|
33
|
+
md: 'sm:max-w-md',
|
|
34
|
+
lg: 'sm:max-w-lg',
|
|
35
|
+
xl: 'sm:max-w-xl',
|
|
36
|
+
full: '',
|
|
37
|
+
} as const
|
|
41
38
|
|
|
42
39
|
function dismiss() {
|
|
43
40
|
emit('update:open', false)
|
|
@@ -46,77 +43,28 @@
|
|
|
46
43
|
</script>
|
|
47
44
|
|
|
48
45
|
<template>
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<p v-if="description" class="text-muted mt-0.5 text-sm">
|
|
74
|
-
{{ description }}
|
|
75
|
-
</p>
|
|
76
|
-
</div>
|
|
77
|
-
</slot>
|
|
78
|
-
<button
|
|
79
|
-
class="text-muted hover:text-default ml-4 cursor-pointer border-0 bg-transparent p-1 transition-colors"
|
|
80
|
-
aria-label="Close"
|
|
81
|
-
@click="dismiss"
|
|
82
|
-
>
|
|
83
|
-
<UIcon name="lucide:x" class="size-5" />
|
|
84
|
-
</button>
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<!-- Body -->
|
|
88
|
-
<div class="px-6 py-5">
|
|
89
|
-
<slot />
|
|
90
|
-
</div>
|
|
91
|
-
|
|
92
|
-
<!-- Footer -->
|
|
93
|
-
<div v-if="$slots.footer" class="border-default flex justify-end gap-3 border-t px-6 py-4">
|
|
94
|
-
<slot name="footer" :dismiss />
|
|
95
|
-
</div>
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
98
|
-
</Transition>
|
|
46
|
+
<UModal
|
|
47
|
+
:open
|
|
48
|
+
v-bind="{
|
|
49
|
+
...(title !== undefined && { title }),
|
|
50
|
+
...(description !== undefined && { description }),
|
|
51
|
+
}"
|
|
52
|
+
:fullscreen="size === 'full'"
|
|
53
|
+
:ui="{
|
|
54
|
+
overlay: 'z-(--z-modal)',
|
|
55
|
+
content: `z-(--z-modal) ${sizeClass[size]}`,
|
|
56
|
+
footer: 'justify-end gap-3',
|
|
57
|
+
}"
|
|
58
|
+
@update:open="(value) => !value && dismiss()"
|
|
59
|
+
>
|
|
60
|
+
<template v-if="$slots.header" #header>
|
|
61
|
+
<slot name="header" />
|
|
62
|
+
</template>
|
|
63
|
+
<template v-if="$slots.default" #body>
|
|
64
|
+
<slot />
|
|
65
|
+
</template>
|
|
66
|
+
<template v-if="$slots.footer" #footer>
|
|
67
|
+
<slot name="footer" :dismiss />
|
|
68
|
+
</template>
|
|
69
|
+
</UModal>
|
|
99
70
|
</template>
|
|
100
|
-
|
|
101
|
-
<style scoped>
|
|
102
|
-
.base-modal-enter-active,
|
|
103
|
-
.base-modal-leave-active {
|
|
104
|
-
transition: opacity 200ms ease;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
.base-modal-enter-from,
|
|
108
|
-
.base-modal-leave-to {
|
|
109
|
-
opacity: 0;
|
|
110
|
-
}
|
|
111
|
-
.base-modal-enter-active .bg-\(--ui-bg\),
|
|
112
|
-
.base-modal-leave-active .bg-\(--ui-bg\) {
|
|
113
|
-
transition:
|
|
114
|
-
opacity 200ms ease,
|
|
115
|
-
transform 200ms ease;
|
|
116
|
-
}
|
|
117
|
-
.base-modal-enter-from .bg-\(--ui-bg\),
|
|
118
|
-
.base-modal-leave-to .bg-\(--ui-bg\) {
|
|
119
|
-
transform: scale(0.96) translateY(4px);
|
|
120
|
-
opacity: 0;
|
|
121
|
-
}
|
|
122
|
-
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { defineComponent } from 'vue'
|
|
3
|
+
import { describe, expect, it } from 'vite-plus/test'
|
|
4
|
+
|
|
5
|
+
import HoverDimGroup from './HoverDimGroup.vue'
|
|
6
|
+
|
|
7
|
+
// Same template shape as MastNavModal: <template v-for> around a <slot> with fallback content,
|
|
8
|
+
// which compiles to fragments nested two deep
|
|
9
|
+
const Parent = defineComponent({
|
|
10
|
+
components: { HoverDimGroup },
|
|
11
|
+
data: () => ({ ids: ['a', 'b', 'c'] }),
|
|
12
|
+
template: `
|
|
13
|
+
<HoverDimGroup tag="nav">
|
|
14
|
+
<template v-for="id in ids" :key="id">
|
|
15
|
+
<slot name="link" :id="id"><button>{{ id }}</button></slot>
|
|
16
|
+
</template>
|
|
17
|
+
</HoverDimGroup>`,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('HoverDimGroup', () => {
|
|
21
|
+
it('renders and dims children nested in v-for and slot fragments', async () => {
|
|
22
|
+
const wrapper = mount(Parent)
|
|
23
|
+
const items = wrapper.findAll('nav > button')
|
|
24
|
+
expect(items.map((b) => b.text())).toEqual(['a', 'b', 'c'])
|
|
25
|
+
|
|
26
|
+
await items[0]?.trigger('mouseenter')
|
|
27
|
+
expect(items.map((b) => b.attributes('style'))).toEqual([
|
|
28
|
+
expect.stringContaining('opacity: 1'),
|
|
29
|
+
expect.stringContaining('opacity: 0.3'),
|
|
30
|
+
expect.stringContaining('opacity: 0.3'),
|
|
31
|
+
])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('uses slot content when the parent passes it', () => {
|
|
35
|
+
const wrapper = mount(Parent, { slots: { link: '<a href="#">x</a>' } })
|
|
36
|
+
expect(wrapper.findAll('nav > a')).toHaveLength(3)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cloneVNode, defineComponent, Fragment, h, ref, type VNode } from 'vue'
|
|
3
|
+
|
|
4
|
+
export default defineComponent({
|
|
5
|
+
name: 'HoverDimGroup',
|
|
6
|
+
inheritAttrs: false,
|
|
7
|
+
props: {
|
|
8
|
+
tag: { type: String, default: 'div' },
|
|
9
|
+
dimOpacity: { type: Number, default: 0.3 },
|
|
10
|
+
duration: { type: Number, default: 200 },
|
|
11
|
+
},
|
|
12
|
+
setup(props, { slots, attrs }) {
|
|
13
|
+
const hoveredIndex = ref<number | null>(null)
|
|
14
|
+
|
|
15
|
+
// v-for over slots nests fragments (v-for → slot → element), so flatten all the way down
|
|
16
|
+
const flatten = (nodes: VNode[]): VNode[] =>
|
|
17
|
+
nodes.flatMap((node) =>
|
|
18
|
+
node.type === Fragment && Array.isArray(node.children)
|
|
19
|
+
? flatten(node.children as VNode[])
|
|
20
|
+
: [node]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return () => {
|
|
24
|
+
const children = flatten(slots.default?.() ?? []).filter(
|
|
25
|
+
(node) => typeof node.type === 'string' || typeof node.type === 'object'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const styledChildren = children.map((vnode, index) =>
|
|
29
|
+
cloneVNode(vnode, {
|
|
30
|
+
onMouseenter: () => {
|
|
31
|
+
hoveredIndex.value = index
|
|
32
|
+
},
|
|
33
|
+
style: {
|
|
34
|
+
opacity:
|
|
35
|
+
hoveredIndex.value !== null && hoveredIndex.value !== index ? props.dimOpacity : 1,
|
|
36
|
+
transition: `opacity ${props.duration}ms ease-out`,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return h(
|
|
42
|
+
props.tag,
|
|
43
|
+
{
|
|
44
|
+
...attrs,
|
|
45
|
+
onMouseleave: () => {
|
|
46
|
+
hoveredIndex.value = null
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
styledChildren
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
</script>
|
|
@@ -1,35 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
progress = null,
|
|
4
|
-
color = undefined,
|
|
5
|
-
size = undefined,
|
|
6
|
-
orientation = undefined,
|
|
7
|
-
status = undefined,
|
|
8
|
-
animation = undefined,
|
|
9
|
-
inverted = undefined,
|
|
10
|
-
} = defineProps<{
|
|
11
|
-
progress?: number | null
|
|
12
|
-
color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
13
|
-
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
|
14
|
-
orientation?: 'horizontal' | 'vertical'
|
|
15
|
-
status?: boolean
|
|
16
|
-
animation?: 'carousel' | 'carousel-inverse' | 'swing' | 'elastic'
|
|
17
|
-
inverted?: boolean
|
|
18
|
-
}>()
|
|
2
|
+
import type { ProgressProps } from '@nuxt/ui'
|
|
19
3
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
...(status !== undefined && { status }),
|
|
28
|
-
...(animation !== undefined && { animation }),
|
|
29
|
-
...(inverted !== undefined && { inverted }),
|
|
30
|
-
}))
|
|
4
|
+
// The one decision over UProgress: progress is 0–1 (null = indeterminate). Every other
|
|
5
|
+
// UProgress prop stays typed here but falls through as an attr (@vue-ignore).
|
|
6
|
+
/* eslint-disable vue/no-unused-properties -- @vue-ignore props fall through as attrs */
|
|
7
|
+
const { progress = null } = defineProps<
|
|
8
|
+
{ progress?: number | null } & /* @vue-ignore */ Omit<ProgressProps, 'modelValue'>
|
|
9
|
+
>()
|
|
10
|
+
/* eslint-enable vue/no-unused-properties */
|
|
31
11
|
</script>
|
|
32
12
|
|
|
33
13
|
<template>
|
|
34
|
-
<UProgress
|
|
14
|
+
<UProgress :model-value="progress == null ? null : progress * 100" />
|
|
35
15
|
</template>
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
<template>
|
|
53
53
|
<Teleport to="body">
|
|
54
54
|
<div
|
|
55
|
-
class="motion-cursor pointer-events-none fixed inset-0 z-
|
|
55
|
+
class="motion-cursor pointer-events-none fixed inset-0 z-(--z-cursor) transition-opacity duration-300"
|
|
56
56
|
:style="{ opacity: visible ? 1 : 0 }"
|
|
57
57
|
aria-hidden="true"
|
|
58
58
|
>
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
}>()
|
|
51
51
|
|
|
52
52
|
const { gsap } = useGsap()
|
|
53
|
+
const reduced = useReducedMotion()
|
|
53
54
|
const { velocity: scrollVelocity, direction: scrollDirection } = useSmoothScroll()
|
|
54
55
|
|
|
55
56
|
const containerRef = useTemplateRef<HTMLElement>('containerRef')
|
|
@@ -78,9 +79,13 @@
|
|
|
78
79
|
return direction === 'left' ? parseFloat(x) % mod : Math.abs(parseFloat(x) % mod)
|
|
79
80
|
}),
|
|
80
81
|
},
|
|
82
|
+
paused: reduced.value,
|
|
81
83
|
})
|
|
82
84
|
})
|
|
83
85
|
|
|
86
|
+
// Reduced motion holds the marquee still (and resumes it if the preference is turned off)
|
|
87
|
+
watch(reduced, (r) => tweenRef.value?.paused(r))
|
|
88
|
+
|
|
84
89
|
// Watch velocity changes for velocity-based animation
|
|
85
90
|
watch(
|
|
86
91
|
[scrollVelocity, scrollDirection],
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
}>()
|
|
34
34
|
|
|
35
35
|
const { gsap, ScrollTrigger } = useGsap()
|
|
36
|
+
const reduced = useReducedMotion()
|
|
36
37
|
const { velocityFactor } = useMarqueeVelocity({ damping, stiffness, velocityMapping })
|
|
37
38
|
|
|
38
39
|
const containerRef = useTemplateRef<HTMLElement[]>('containerRef')
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
function animate() {
|
|
74
|
-
if (isPaused.value) return
|
|
75
|
+
if (isPaused.value || reduced.value) return
|
|
75
76
|
|
|
76
77
|
const now = performance.now()
|
|
77
78
|
const delta = animLastTime ? now - animLastTime : 16
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
|
4
|
+
|
|
5
|
+
import Staggered from './Staggered.vue'
|
|
6
|
+
|
|
7
|
+
// Auto-imported from the scroll layer in Nuxt; stubbed here (no Nuxt context in the 'vue' project)
|
|
8
|
+
const reduced = ref(false)
|
|
9
|
+
vi.stubGlobal('useReducedMotion', () => reduced)
|
|
10
|
+
|
|
11
|
+
describe('MotionStaggered', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
reduced.value = false
|
|
14
|
+
vi.useFakeTimers()
|
|
15
|
+
})
|
|
16
|
+
afterEach(() => vi.useRealTimers())
|
|
17
|
+
|
|
18
|
+
it('renders each child once and reveals every one after the stagger', () => {
|
|
19
|
+
const wrapper = mount(Staggered, {
|
|
20
|
+
props: { staggerDelay: 50 },
|
|
21
|
+
slots: { default: '<p>a</p><p>b</p><p>c</p>' },
|
|
22
|
+
})
|
|
23
|
+
const items = wrapper.findAll('p')
|
|
24
|
+
expect(items).toHaveLength(3)
|
|
25
|
+
expect(items.every((p) => !p.classes('animated'))).toBe(true)
|
|
26
|
+
|
|
27
|
+
vi.advanceTimersByTime(100)
|
|
28
|
+
expect(items.map((p) => p.classes('animated'))).toEqual([true, true, true])
|
|
29
|
+
expect(items[0]?.classes()).toContain('animate-fade-in')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('reveals every child at once, without the animation class, under reduced motion', () => {
|
|
33
|
+
reduced.value = true
|
|
34
|
+
const wrapper = mount(Staggered, { slots: { default: '<p>a</p><p>b</p>' } })
|
|
35
|
+
const items = wrapper.findAll('p')
|
|
36
|
+
expect(items.map((p) => p.classes('animated'))).toEqual([true, true])
|
|
37
|
+
expect(items[0]?.classes()).not.toContain('animate-fade-in')
|
|
38
|
+
expect(wrapper.attributes('data-reduced')).toBe('')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
2
|
+
// Explicit import so Staggered mounts under vitest's 'vue' project (no Nuxt auto-imports)
|
|
3
|
+
// — same exemption as ElementTw.vue (eslint.config.mjs).
|
|
4
|
+
import { onBeforeUnmount, onMounted, useTemplateRef } from 'vue'
|
|
5
|
+
|
|
3
6
|
const {
|
|
4
7
|
staggerDelay = 100,
|
|
5
8
|
initialDelay = 0,
|
|
@@ -13,61 +16,53 @@
|
|
|
13
16
|
animation?: string
|
|
14
17
|
}>()
|
|
15
18
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const slots = useSlots()
|
|
19
|
+
// Stagger the rendered DOM children, not slot vnodes: a `ref`/`class` on `<slot>` becomes a
|
|
20
|
+
// slot prop and never reaches an element, which left every item hidden for good.
|
|
21
|
+
const reduced = useReducedMotion()
|
|
22
|
+
const root = useTemplateRef<HTMLElement>('root')
|
|
23
|
+
const timers: ReturnType<typeof setTimeout>[] = []
|
|
22
24
|
|
|
23
|
-
// Methods
|
|
24
|
-
const setItemRef = (el: HTMLElement | null, index: number) => {
|
|
25
|
-
if (el) {
|
|
26
|
-
items.value[index] = el
|
|
27
|
-
animatedItems.value[index] = false
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Lifecycle
|
|
32
25
|
onMounted(() => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}, 100)
|
|
26
|
+
const items = [...(root.value?.children ?? [])]
|
|
27
|
+
if (reduced.value) {
|
|
28
|
+
root.value?.setAttribute('data-reduced', '')
|
|
29
|
+
return items.forEach((item) => item.classList.add('animated'))
|
|
30
|
+
}
|
|
31
|
+
items.forEach((item, index) => {
|
|
32
|
+
timers.push(
|
|
33
|
+
setTimeout(
|
|
34
|
+
() => item.classList.add(`animate-${animation}`, 'animated'),
|
|
35
|
+
initialDelay + index * staggerDelay
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
})
|
|
47
39
|
})
|
|
40
|
+
|
|
41
|
+
onBeforeUnmount(() => timers.forEach(clearTimeout))
|
|
48
42
|
</script>
|
|
49
43
|
|
|
50
44
|
<template>
|
|
51
|
-
<div class="motion-staggered">
|
|
52
|
-
<slot
|
|
53
|
-
v-for="(_, index) in slots.default?.()"
|
|
54
|
-
:key="index"
|
|
55
|
-
:ref="(el: HTMLElement | null) => setItemRef(el, index)"
|
|
56
|
-
:class="{ animated: animatedItems[index] }"
|
|
57
|
-
/>
|
|
45
|
+
<div ref="root" class="motion-staggered">
|
|
46
|
+
<slot />
|
|
58
47
|
</div>
|
|
59
48
|
</template>
|
|
60
49
|
|
|
61
50
|
<style scoped>
|
|
62
51
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
|
63
|
-
.motion-staggered :deep(
|
|
52
|
+
.motion-staggered > :deep(*) {
|
|
53
|
+
transform: translateY(20px);
|
|
54
|
+
opacity: 0;
|
|
55
|
+
transition:
|
|
56
|
+
opacity 0.3s ease-out,
|
|
57
|
+
transform 0.3s ease-out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.motion-staggered > :deep(.animated) {
|
|
64
61
|
transform: translateY(0);
|
|
65
62
|
opacity: 1;
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
.motion-staggered :deep(*) {
|
|
69
|
-
|
|
70
|
-
opacity: 0;
|
|
71
|
-
transition: all 0.3s ease-out;
|
|
65
|
+
.motion-staggered[data-reduced] > :deep(*) {
|
|
66
|
+
transition: none;
|
|
72
67
|
}
|
|
73
68
|
</style>
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
start?: string
|
|
28
28
|
}>()
|
|
29
29
|
|
|
30
|
+
// Swapped to a plain span after mount: the server can't know the preference, so deciding in
|
|
31
|
+
// render would mismatch on hydration.
|
|
32
|
+
const reduced = useReducedMotion()
|
|
33
|
+
const isStatic = ref(false)
|
|
34
|
+
onMounted(() => (isStatic.value = reduced.value))
|
|
35
|
+
|
|
30
36
|
const animBinding = computed(() => ({
|
|
31
37
|
y: 40,
|
|
32
38
|
opacity: 0,
|
|
@@ -39,8 +45,9 @@
|
|
|
39
45
|
</script>
|
|
40
46
|
|
|
41
47
|
<template>
|
|
48
|
+
<span v-if="isStatic" class="motion-text-reveal">{{ text }}</span>
|
|
42
49
|
<span
|
|
43
|
-
v-if="type === 'chars'"
|
|
50
|
+
v-else-if="type === 'chars'"
|
|
44
51
|
v-gsap.whenVisible.once.splitText.chars.from="animBinding"
|
|
45
52
|
class="motion-text-reveal"
|
|
46
53
|
>{{ text }}</span
|
|
@@ -25,11 +25,17 @@
|
|
|
25
25
|
|
|
26
26
|
const { gsap } = useGsap()
|
|
27
27
|
const { velocity } = useSmoothScroll()
|
|
28
|
+
const reduced = useReducedMotion()
|
|
28
29
|
|
|
29
30
|
const smoothVelocity = ref(0)
|
|
30
31
|
const smoothAbsVelocity = ref(0)
|
|
31
32
|
|
|
32
33
|
function tick() {
|
|
34
|
+
if (reduced.value) {
|
|
35
|
+
smoothVelocity.value = 0
|
|
36
|
+
smoothAbsVelocity.value = 0
|
|
37
|
+
return
|
|
38
|
+
}
|
|
33
39
|
const d = damping / 1000
|
|
34
40
|
const s = stiffness / 1000
|
|
35
41
|
|
|
@@ -15,6 +15,7 @@ export function useCountUp(
|
|
|
15
15
|
opts: UseCountUpOptions
|
|
16
16
|
) {
|
|
17
17
|
const { gsap, ScrollTrigger } = useGsap()
|
|
18
|
+
const reduced = useReducedMotion()
|
|
18
19
|
|
|
19
20
|
const displayValue = ref(String(opts.from ?? 0))
|
|
20
21
|
const isComplete = ref(false)
|
|
@@ -26,6 +27,11 @@ export function useCountUp(
|
|
|
26
27
|
let tween: gsap.core.Tween | null = null
|
|
27
28
|
|
|
28
29
|
function startCount() {
|
|
30
|
+
if (reduced.value) {
|
|
31
|
+
displayValue.value = format(opts.to)
|
|
32
|
+
isComplete.value = true
|
|
33
|
+
return
|
|
34
|
+
}
|
|
29
35
|
tween = gsap.to(counter, {
|
|
30
36
|
value: opts.to,
|
|
31
37
|
duration: opts.duration ?? 2,
|
|
@@ -4,11 +4,14 @@ export function useCursorFollower(opts: { smoothing?: MaybeRef<number> } = {}) {
|
|
|
4
4
|
const { gsap } = useGsap()
|
|
5
5
|
const { x: mouseX, y: mouseY } = useMouse({ type: 'client' })
|
|
6
6
|
|
|
7
|
+
const reduced = useReducedMotion()
|
|
8
|
+
|
|
7
9
|
const x = ref(0)
|
|
8
10
|
const y = ref(0)
|
|
9
11
|
|
|
10
12
|
function tick() {
|
|
11
|
-
|
|
13
|
+
// Reduced motion: track the pointer exactly instead of trailing it
|
|
14
|
+
const factor = reduced.value ? 1 : (unref(opts.smoothing) ?? 0.15)
|
|
12
15
|
x.value += (mouseX.value - x.value) * factor
|
|
13
16
|
y.value += (mouseY.value - y.value) * factor
|
|
14
17
|
}
|
|
@@ -14,13 +14,18 @@ export function resolvePointerSpring(
|
|
|
14
14
|
|
|
15
15
|
export function usePointerMotionFrame(update: () => void, cleanup?: () => void) {
|
|
16
16
|
const { gsap } = useGsap()
|
|
17
|
+
const reduced = useReducedMotion()
|
|
18
|
+
|
|
19
|
+
// Checked per frame so a reduced-motion toggle applies live; reduced holds the element at rest.
|
|
20
|
+
// ponytail: cleanup re-runs every frame while reduced — cheap gsap.set, gate it if it ever shows up in a profile
|
|
21
|
+
const frame = () => (reduced.value ? cleanup?.() : update())
|
|
17
22
|
|
|
18
23
|
onMounted(() => {
|
|
19
|
-
gsap.ticker.add(
|
|
24
|
+
gsap.ticker.add(frame)
|
|
20
25
|
})
|
|
21
26
|
|
|
22
27
|
onUnmounted(() => {
|
|
23
|
-
gsap.ticker.remove(
|
|
28
|
+
gsap.ticker.remove(frame)
|
|
24
29
|
cleanup?.()
|
|
25
30
|
})
|
|
26
31
|
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
}>()
|
|
12
12
|
|
|
13
13
|
const { gsap } = useGsap()
|
|
14
|
+
const reduced = useReducedMotion()
|
|
14
15
|
|
|
15
16
|
const sectionRef = useTemplateRef<HTMLElement>('sectionRef')
|
|
16
17
|
const trackRef = useTemplateRef<HTMLElement>('trackRef')
|
|
@@ -21,6 +22,14 @@
|
|
|
21
22
|
if (!sectionRef.value || !trackRef.value) return
|
|
22
23
|
|
|
23
24
|
const track = trackRef.value
|
|
25
|
+
|
|
26
|
+
// Reduced motion: no pinned scrub — panels scroll natively sideways instead.
|
|
27
|
+
// ponytail: checked once at mount — a later reduced-motion toggle applies on next mount/reload; re-run set-up in a watch if live toggling matters
|
|
28
|
+
if (reduced.value) {
|
|
29
|
+
sectionRef.value.style.overflowX = 'auto'
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
const trackWidth = track.scrollWidth - window.innerWidth
|
|
25
34
|
|
|
26
35
|
const tween = gsap.to(track, {
|
|
@@ -34,32 +34,33 @@
|
|
|
34
34
|
}>()
|
|
35
35
|
|
|
36
36
|
const { gsap } = useGsap()
|
|
37
|
+
const reduced = useReducedMotion()
|
|
37
38
|
|
|
38
39
|
const containerRef = useTemplateRef<HTMLElement | null>('containerRef')
|
|
39
40
|
const contentRef = useTemplateRef<HTMLElement | null>('contentRef')
|
|
40
41
|
|
|
42
|
+
let tween: gsap.core.Tween | null = null
|
|
43
|
+
|
|
41
44
|
onMounted(() => {
|
|
42
45
|
if (!containerRef.value || !contentRef.value) return
|
|
46
|
+
// ponytail: checked once at mount — a later reduced-motion toggle applies on next mount/reload; re-run set-up in a watch if live toggling matters
|
|
47
|
+
if (reduced.value) return
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
const xAmount = direction === 'horizontal' ? speed * 200 : 0
|
|
46
|
-
|
|
47
|
-
const animationProps: gsap.TweenVars = {
|
|
49
|
+
tween = gsap.to(contentRef.value, {
|
|
48
50
|
scrollTrigger: {
|
|
49
51
|
trigger: containerRef.value,
|
|
50
52
|
start: 'top bottom',
|
|
51
53
|
end: 'bottom top',
|
|
52
|
-
scrub
|
|
54
|
+
scrub,
|
|
53
55
|
},
|
|
54
56
|
ease: 'none',
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (xAmount !== 0) animationProps.x = xAmount
|
|
59
|
-
if (rotate !== 0) animationProps.rotation = rotate
|
|
60
|
-
if (scale !== 0) animationProps.scale = 1 + scale
|
|
57
|
+
...parallaxMotionVars({ speed, direction, rotate, scale }),
|
|
58
|
+
})
|
|
59
|
+
})
|
|
61
60
|
|
|
62
|
-
|
|
61
|
+
onUnmounted(() => {
|
|
62
|
+
tween?.scrollTrigger?.kill()
|
|
63
|
+
tween?.kill()
|
|
63
64
|
})
|
|
64
65
|
</script>
|
|
65
66
|
|