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
|
@@ -11,21 +11,31 @@
|
|
|
11
11
|
}>()
|
|
12
12
|
|
|
13
13
|
const { gsap, ScrollTrigger } = useGsap()
|
|
14
|
+
const reduced = useReducedMotion()
|
|
14
15
|
|
|
15
16
|
const sectionRef = useTemplateRef<HTMLElement>('sectionRef')
|
|
16
17
|
const contentRef = useTemplateRef<HTMLElement>('contentRef')
|
|
17
18
|
|
|
18
19
|
const cleanups: Array<() => void> = []
|
|
19
20
|
|
|
21
|
+
// Reduced motion: no pin, no reveal — show every child at rest (inline style beats the
|
|
22
|
+
// first-paint hide rule below).
|
|
23
|
+
// ponytail: checked once at mount — a later reduced-motion toggle applies on next mount/reload
|
|
20
24
|
onMounted(() => {
|
|
21
|
-
|
|
25
|
+
const section = sectionRef.value
|
|
26
|
+
const content = contentRef.value
|
|
27
|
+
if (!section || !content) return
|
|
28
|
+
const items = Array.from(content.querySelectorAll(':scope > *'))
|
|
29
|
+
if (reduced.value) return void gsap.set(items, { opacity: 1, y: 0, scale: 1 })
|
|
30
|
+
pinAndReveal(section, items)
|
|
31
|
+
})
|
|
22
32
|
|
|
23
|
-
|
|
24
|
-
const endValue = `+=${
|
|
33
|
+
function pinAndReveal(section: HTMLElement, items: Element[]) {
|
|
34
|
+
const endValue = `+=${(duration * window.innerHeight) / 100}`
|
|
25
35
|
|
|
26
36
|
// Pin the section
|
|
27
37
|
const pinSt = ScrollTrigger.create({
|
|
28
|
-
trigger:
|
|
38
|
+
trigger: section,
|
|
29
39
|
start: 'top top',
|
|
30
40
|
end: endValue,
|
|
31
41
|
pin: true,
|
|
@@ -34,10 +44,7 @@
|
|
|
34
44
|
cleanups.push(() => pinSt.kill())
|
|
35
45
|
|
|
36
46
|
// Animate each direct child
|
|
37
|
-
const items = Array.from(contentRef.value.querySelectorAll(':scope > *'))
|
|
38
47
|
const total = items.length
|
|
39
|
-
if (total === 0) return
|
|
40
|
-
|
|
41
48
|
items.forEach((item, i) => {
|
|
42
49
|
gsap.set(item, { opacity: 0, y: 60, scale: 0.9 })
|
|
43
50
|
|
|
@@ -46,7 +53,7 @@
|
|
|
46
53
|
|
|
47
54
|
const tween = gsap.to(item, {
|
|
48
55
|
scrollTrigger: {
|
|
49
|
-
trigger:
|
|
56
|
+
trigger: section,
|
|
50
57
|
start: 'top top',
|
|
51
58
|
end: endValue,
|
|
52
59
|
scrub,
|
|
@@ -63,7 +70,7 @@
|
|
|
63
70
|
tween.kill()
|
|
64
71
|
})
|
|
65
72
|
})
|
|
66
|
-
}
|
|
73
|
+
}
|
|
67
74
|
|
|
68
75
|
onUnmounted(() => {
|
|
69
76
|
cleanups.forEach((fn) => fn())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { computed, ref } from 'vue'
|
|
2
|
+
import { describe, expect, it, vi } from 'vite-plus/test'
|
|
3
|
+
|
|
4
|
+
const system = ref<'reduce' | 'no-preference'>('no-preference')
|
|
5
|
+
vi.mock('@vueuse/core', () => ({ usePreferredReducedMotion: () => system }))
|
|
6
|
+
const state = ref<'on' | 'off' | 'system'>('system')
|
|
7
|
+
vi.stubGlobal('useState', () => state)
|
|
8
|
+
vi.stubGlobal('computed', computed)
|
|
9
|
+
|
|
10
|
+
const { useReducedMotion } = await import('./useReducedMotion')
|
|
11
|
+
|
|
12
|
+
describe('useReducedMotion', () => {
|
|
13
|
+
it.each([
|
|
14
|
+
['system', 'no-preference', false],
|
|
15
|
+
['system', 'reduce', true],
|
|
16
|
+
['on', 'no-preference', true],
|
|
17
|
+
['off', 'reduce', false],
|
|
18
|
+
] as const)('override %s + OS %s → %s', (override, os, expected) => {
|
|
19
|
+
state.value = override
|
|
20
|
+
system.value = os
|
|
21
|
+
expect(useReducedMotion().value).toBe(expected)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { usePreferredReducedMotion } from '@vueuse/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when motion should be reduced. Honours the ThemePicker override when the theming layer
|
|
5
|
+
* is loaded — it keeps it in `useState('theme-motion')`, read here by key so scroll doesn't
|
|
6
|
+
* depend on theming — and falls back to the OS `prefers-reduced-motion` setting.
|
|
7
|
+
*/
|
|
8
|
+
export function useReducedMotion() {
|
|
9
|
+
const override = useState<'on' | 'off' | 'system'>('theme-motion', () => 'system')
|
|
10
|
+
const system = usePreferredReducedMotion()
|
|
11
|
+
return computed(
|
|
12
|
+
() => override.value === 'on' || (override.value === 'system' && system.value === 'reduce')
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { parallaxMotionVars } from './parallax'
|
|
4
|
+
|
|
5
|
+
const base = { speed: 0.5, direction: 'vertical', rotate: 0, scale: 0 } as const
|
|
6
|
+
|
|
7
|
+
describe('parallaxMotionVars', () => {
|
|
8
|
+
it('moves up on y for vertical, right on x for horizontal', () => {
|
|
9
|
+
expect(parallaxMotionVars(base)).toEqual({ y: -100 })
|
|
10
|
+
expect(parallaxMotionVars({ ...base, direction: 'horizontal' })).toEqual({ x: 100 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('adds rotation and scale only when set', () => {
|
|
14
|
+
expect(parallaxMotionVars({ ...base, rotate: 15, scale: 0.2 })).toEqual({
|
|
15
|
+
y: -100,
|
|
16
|
+
rotation: 15,
|
|
17
|
+
scale: 1.2,
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns nothing to animate at zero speed', () => {
|
|
22
|
+
expect(parallaxMotionVars({ ...base, speed: 0 })).toEqual({})
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type ParallaxMotion = {
|
|
2
|
+
speed: number
|
|
3
|
+
direction: 'vertical' | 'horizontal'
|
|
4
|
+
rotate: number
|
|
5
|
+
scale: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** MotionParallax tween vars — only the properties that actually move, so GSAP leaves the rest. */
|
|
9
|
+
export function parallaxMotionVars({ speed, direction, rotate, scale }: ParallaxMotion) {
|
|
10
|
+
const vars: { x?: number; y?: number; rotation?: number; scale?: number } = {}
|
|
11
|
+
const travel = direction === 'vertical' ? speed * -200 : speed * 200
|
|
12
|
+
if (travel !== 0) vars[direction === 'vertical' ? 'y' : 'x'] = travel
|
|
13
|
+
if (rotate !== 0) vars.rotation = rotate
|
|
14
|
+
if (scale !== 0) vars.scale = 1 + scale
|
|
15
|
+
return vars
|
|
16
|
+
}
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
|
|
73
73
|
<template>
|
|
74
74
|
<Teleport to="body">
|
|
75
|
-
<div v-if="visible" :style class="grid-debug fixed inset-0 z-
|
|
75
|
+
<div v-if="visible" :style class="grid-debug fixed inset-0 z-(--z-debug)" aria-hidden="true">
|
|
76
76
|
<div v-for="i in cols" :key="i" :style="{ backgroundColor: color }" class="h-full" />
|
|
77
77
|
</div>
|
|
78
78
|
</Teleport>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* <Transition> and Nuxt's page-root check (NUXT_E4004) require.
|
|
11
11
|
*
|
|
12
12
|
* Responsibilities:
|
|
13
|
-
* -
|
|
13
|
+
* - title/description meta via useSeoMeta() (pages may override)
|
|
14
14
|
* - provides 'pageTitle' to child components
|
|
15
15
|
* - optional visible header (LayoutSection + LayoutPageHeader)
|
|
16
16
|
*
|
|
@@ -45,15 +45,14 @@
|
|
|
45
45
|
|
|
46
46
|
const { title, description = undefined, showHeader = false } = defineProps<Props>()
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
: undefined,
|
|
48
|
+
// Deliberately a "connected" template: pages (and consumers such as KMCOMv5) set their title
|
|
49
|
+
// and description through these props. useSeoMeta rather than hand-written useHead tags, so a
|
|
50
|
+
// page that also calls useSeoMeta overrides these keys instead of adding duplicates.
|
|
51
|
+
useSeoMeta({
|
|
52
|
+
title: () => title,
|
|
53
|
+
ogTitle: () => title,
|
|
54
|
+
description: () => description,
|
|
55
|
+
ogDescription: () => description,
|
|
57
56
|
})
|
|
58
57
|
|
|
59
58
|
provide('pageTitle', title)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { SiteConfig } from '#layers/core/app/types/site'
|
|
2
2
|
|
|
3
|
+
import type { MastNavLink, MastNavTrigger, MastNavUi, SiteLogoConfig } from './types/nav'
|
|
4
|
+
|
|
3
5
|
export default defineAppConfig({
|
|
4
6
|
site: {
|
|
5
7
|
title: '',
|
|
@@ -7,29 +9,50 @@ export default defineAppConfig({
|
|
|
7
9
|
description: '',
|
|
8
10
|
} satisfies SiteConfig,
|
|
9
11
|
mastNav: {
|
|
10
|
-
links: [] as
|
|
11
|
-
id: string
|
|
12
|
-
label: string
|
|
13
|
-
to:
|
|
14
|
-
| string
|
|
15
|
-
| { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> }
|
|
16
|
-
}>,
|
|
12
|
+
links: [] as MastNavLink[],
|
|
17
13
|
scrollBehaviour: 'router' as 'smooth-scroll' | 'router',
|
|
14
|
+
// UButton props for <MastNav>; attrs on the component win
|
|
15
|
+
trigger: {
|
|
16
|
+
icon: 'lucide:menu',
|
|
17
|
+
color: 'neutral',
|
|
18
|
+
variant: 'ghost',
|
|
19
|
+
size: 'xl',
|
|
20
|
+
} as MastNavTrigger,
|
|
21
|
+
ui: {
|
|
22
|
+
content: 'bg-default text-default',
|
|
23
|
+
close: 'absolute top-4 right-4',
|
|
24
|
+
nav: 'flex flex-col items-center gap-2',
|
|
25
|
+
// Weight keys off aria-current so active and inactive never carry two font-* classes
|
|
26
|
+
link: 'cursor-pointer border-0 bg-transparent px-4 py-3 text-3xl tracking-widest uppercase transition-all font-light aria-[current=page]:font-normal',
|
|
27
|
+
linkActive: '',
|
|
28
|
+
} satisfies MastNavUi,
|
|
18
29
|
},
|
|
30
|
+
siteLogo: {
|
|
31
|
+
stacked: true,
|
|
32
|
+
ui: { root: 'font-display leading-none tracking-tight', word: '' },
|
|
33
|
+
sizes: {
|
|
34
|
+
sm: 'text-step-1',
|
|
35
|
+
md: 'text-step-4',
|
|
36
|
+
lg: 'text-step-6',
|
|
37
|
+
display: 'text-step-display',
|
|
38
|
+
},
|
|
39
|
+
} satisfies SiteLogoConfig,
|
|
19
40
|
})
|
|
20
41
|
|
|
21
42
|
declare module '@nuxt/schema' {
|
|
22
43
|
interface AppConfigInput {
|
|
23
44
|
// `site` is already typed by core's own AppConfigInput augmentation — no need to redeclare it.
|
|
24
45
|
mastNav?: {
|
|
25
|
-
links?:
|
|
26
|
-
id: string
|
|
27
|
-
label: string
|
|
28
|
-
to:
|
|
29
|
-
| string
|
|
30
|
-
| { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> }
|
|
31
|
-
}>
|
|
46
|
+
links?: MastNavLink[]
|
|
32
47
|
scrollBehaviour?: 'smooth-scroll' | 'router'
|
|
48
|
+
trigger?: MastNavTrigger
|
|
49
|
+
ui?: Partial<MastNavUi>
|
|
50
|
+
}
|
|
51
|
+
siteLogo?: {
|
|
52
|
+
stacked?: boolean
|
|
53
|
+
ui?: Partial<SiteLogoConfig['ui']>
|
|
54
|
+
/** Merged per key, so a site can retune one size without restating the others. */
|
|
55
|
+
sizes?: Partial<SiteLogoConfig['sizes']>
|
|
33
56
|
}
|
|
34
57
|
}
|
|
35
58
|
}
|
|
@@ -1,46 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
3
|
|
|
4
|
+
import HoverDimGroup from '#layers/visual/app/components/HoverDimGroup.vue'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @deprecated Renamed to `HoverDimGroup` (visual layer) — it dims any children, not just
|
|
8
|
+
* links. Kept for one release so consumers can migrate; remove after that.
|
|
9
|
+
*/
|
|
4
10
|
export default defineComponent({
|
|
5
11
|
name: 'LinksGroup',
|
|
6
12
|
inheritAttrs: false,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
setup(props, { slots, attrs }) {
|
|
13
|
-
const hoveredIndex = ref<number | null>(null)
|
|
14
|
-
|
|
15
|
-
return () => {
|
|
16
|
-
const children = (slots.default?.() ?? [])
|
|
17
|
-
.flatMap((node) => (node.type === Fragment ? (node.children as VNode[]) : [node]))
|
|
18
|
-
.filter((node) => typeof node.type === 'string' || typeof node.type === 'object')
|
|
19
|
-
|
|
20
|
-
const styledChildren = children.map((vnode, index) =>
|
|
21
|
-
cloneVNode(vnode, {
|
|
22
|
-
onMouseenter: () => {
|
|
23
|
-
hoveredIndex.value = index
|
|
24
|
-
},
|
|
25
|
-
style: {
|
|
26
|
-
opacity:
|
|
27
|
-
hoveredIndex.value !== null && hoveredIndex.value !== index ? props.dimOpacity : 1,
|
|
28
|
-
transition: `opacity ${props.duration}ms ease-out`,
|
|
29
|
-
},
|
|
30
|
-
})
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
return h(
|
|
34
|
-
props.tag,
|
|
35
|
-
{
|
|
36
|
-
...attrs,
|
|
37
|
-
onMouseleave: () => {
|
|
38
|
-
hoveredIndex.value = null
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
styledChildren
|
|
42
|
-
)
|
|
43
|
-
}
|
|
13
|
+
setup(_, { attrs, slots }) {
|
|
14
|
+
if (import.meta.dev)
|
|
15
|
+
// eslint-disable-next-line no-console -- one-time deprecation notice, dev only
|
|
16
|
+
console.warn('[navigation] <LinksGroup> is deprecated — use <HoverDimGroup>.')
|
|
17
|
+
return () => h(HoverDimGroup, attrs, slots)
|
|
44
18
|
},
|
|
45
19
|
})
|
|
46
20
|
</script>
|
|
@@ -17,6 +17,15 @@
|
|
|
17
17
|
variant?: string
|
|
18
18
|
color?: UiColors
|
|
19
19
|
}>()
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @deprecated Thin wrapper over `ULink` — use `<ULink :to="{ name, params, query }">`.
|
|
23
|
+
* Unused in this repo and KMCOMv5; remove in the next major.
|
|
24
|
+
*/
|
|
25
|
+
if (import.meta.dev)
|
|
26
|
+
// eslint-disable-next-line no-console -- one-time deprecation notice, dev only
|
|
27
|
+
console.warn('[navigation] <LinksNamed> is deprecated — use <ULink :to="{ name }">.')
|
|
28
|
+
|
|
20
29
|
const colorClass = useColor(() => color, 'text')
|
|
21
30
|
// `name` is a plain string here, not one of the app's typed route names
|
|
22
31
|
const to = computed(
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { ButtonProps } from '@nuxt/ui'
|
|
3
|
+
|
|
4
|
+
// A UButton styled by `mastNav.trigger` in app.config; attrs (class, label, icon…) win.
|
|
5
|
+
// Positioning is the parent's call, e.g. `<MastNav class="fixed top-0 right-0" />`.
|
|
6
|
+
defineOptions({ inheritAttrs: false })
|
|
7
|
+
|
|
2
8
|
const { open } = useMastNav()
|
|
3
|
-
|
|
9
|
+
// app.config types the trigger loosely (see MastNavTrigger); UButton validates the values
|
|
10
|
+
const trigger = ((useAppConfig().mastNav as { trigger?: unknown } | undefined)?.trigger ??
|
|
11
|
+
{}) as ButtonProps
|
|
4
12
|
</script>
|
|
5
13
|
|
|
6
14
|
<template>
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
>
|
|
13
|
-
<slot name="icon">
|
|
14
|
-
<UIcon name="lucide:menu" class="size-7" />
|
|
15
|
-
</slot>
|
|
16
|
-
</button>
|
|
15
|
+
<UButton aria-label="Open navigation menu" v-bind="{ ...trigger, ...$attrs }" @click="open">
|
|
16
|
+
<template v-if="$slots.default" #default>
|
|
17
|
+
<slot />
|
|
18
|
+
</template>
|
|
19
|
+
</UButton>
|
|
17
20
|
</template>
|
|
@@ -1,20 +1,61 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { MastNavLink } from '../../types/nav'
|
|
2
|
+
import type { MastNavLink, MastNavUi } from '../../types/nav'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Render once, in the layout. Behaviour lives here (dialog, scroll lock via useMastNav,
|
|
5
|
+
// smooth-scroll vs router, active link); the look comes from `mastNav.ui`, `ui` and slots.
|
|
6
|
+
const {
|
|
7
|
+
open = undefined,
|
|
8
|
+
links = undefined,
|
|
9
|
+
ui = undefined,
|
|
10
|
+
} = defineProps<{
|
|
11
|
+
/** Manual control; omit to follow useMastNav().isOpen. */
|
|
12
|
+
open?: boolean
|
|
13
|
+
/** Replaces `mastNav.links`, e.g. filtered by the site's own page flags. */
|
|
14
|
+
links?: MastNavLink[]
|
|
15
|
+
/** Per-key override of `mastNav.ui` (strings replace, no class merging). */
|
|
16
|
+
ui?: Partial<MastNavUi>
|
|
17
|
+
}>()
|
|
5
18
|
const emit = defineEmits<{
|
|
6
19
|
'update:open': [value: boolean]
|
|
7
20
|
close: []
|
|
8
21
|
}>()
|
|
9
22
|
|
|
10
|
-
const
|
|
23
|
+
const config = useAppConfig().mastNav as {
|
|
11
24
|
links: MastNavLink[]
|
|
12
|
-
scrollBehaviour:
|
|
25
|
+
scrollBehaviour: 'smooth-scroll' | 'router'
|
|
26
|
+
ui: MastNavUi
|
|
13
27
|
}
|
|
28
|
+
const classes = computed(() => ({ ...config.ui, ...ui }))
|
|
29
|
+
const visibleLinks = computed(() => (links ?? config.links).filter((link) => !link.hidden))
|
|
30
|
+
|
|
14
31
|
const activeSection = useState<string>('activeSection', () => '')
|
|
15
32
|
const route = useRoute()
|
|
16
|
-
const
|
|
17
|
-
const {
|
|
33
|
+
const router = useRouter()
|
|
34
|
+
const { isOpen, close: closeNav } = useMastNav()
|
|
35
|
+
// On the smooth-scroll index every link routes to index, so only the section counts there
|
|
36
|
+
const smoothOnIndex = computed(
|
|
37
|
+
() => config.scrollBehaviour === 'smooth-scroll' && route.name === 'index'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
function matchesRoute(link: MastNavLink) {
|
|
41
|
+
try {
|
|
42
|
+
return router.resolve(link.to).path === route.path
|
|
43
|
+
} catch {
|
|
44
|
+
return false // a named route that doesn't exist
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const activeIds = computed(
|
|
49
|
+
() =>
|
|
50
|
+
new Set(
|
|
51
|
+
visibleLinks.value
|
|
52
|
+
.filter(
|
|
53
|
+
(link) =>
|
|
54
|
+
activeSection.value === link.id || (!smoothOnIndex.value && matchesRoute(link))
|
|
55
|
+
)
|
|
56
|
+
.map((link) => link.id)
|
|
57
|
+
)
|
|
58
|
+
)
|
|
18
59
|
|
|
19
60
|
function dismiss() {
|
|
20
61
|
emit('update:open', false)
|
|
@@ -22,8 +63,8 @@
|
|
|
22
63
|
closeNav()
|
|
23
64
|
}
|
|
24
65
|
|
|
25
|
-
function
|
|
26
|
-
if (
|
|
66
|
+
function navigate(link: MastNavLink) {
|
|
67
|
+
if (smoothOnIndex.value) {
|
|
27
68
|
try {
|
|
28
69
|
useSmoothScroll().scrollTo(`#${link.id}`)
|
|
29
70
|
} catch {}
|
|
@@ -35,43 +76,52 @@
|
|
|
35
76
|
</script>
|
|
36
77
|
|
|
37
78
|
<template>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
>
|
|
49
|
-
<
|
|
50
|
-
|
|
79
|
+
<!-- UModal supplies the dialog role, focus trap, Escape and portal; the title stays
|
|
80
|
+
visually hidden because the content slot replaces the header. -->
|
|
81
|
+
<UModal
|
|
82
|
+
:open="open ?? isOpen"
|
|
83
|
+
fullscreen
|
|
84
|
+
title="Site navigation"
|
|
85
|
+
:ui="{ overlay: 'z-(--z-modal)', content: `${classes.content} z-(--z-modal)` }"
|
|
86
|
+
@update:open="(value) => !value && dismiss()"
|
|
87
|
+
>
|
|
88
|
+
<template #content>
|
|
89
|
+
<div class="relative flex h-full flex-col items-center justify-center">
|
|
90
|
+
<slot name="close" :close="dismiss">
|
|
91
|
+
<UButton
|
|
92
|
+
icon="lucide:x"
|
|
93
|
+
color="neutral"
|
|
94
|
+
variant="ghost"
|
|
95
|
+
size="xl"
|
|
96
|
+
aria-label="Close navigation"
|
|
97
|
+
:class="classes.close"
|
|
98
|
+
@click="dismiss"
|
|
99
|
+
/>
|
|
100
|
+
</slot>
|
|
51
101
|
|
|
52
|
-
|
|
53
|
-
<button
|
|
54
|
-
v-for="link in links"
|
|
55
|
-
:key="link.id"
|
|
56
|
-
class="cursor-pointer border-0 bg-transparent px-4 py-3 text-3xl tracking-widest uppercase transition-all"
|
|
57
|
-
:class="activeSection === link.id ? 'font-normal' : 'font-light'"
|
|
58
|
-
@click="() => handleNav(link)"
|
|
59
|
-
>
|
|
60
|
-
{{ link.label }}
|
|
61
|
-
</button>
|
|
62
|
-
</LinksGroup>
|
|
63
|
-
</div>
|
|
64
|
-
</Transition>
|
|
65
|
-
</template>
|
|
102
|
+
<slot name="header" />
|
|
66
103
|
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
104
|
+
<HoverDimGroup tag="nav" :class="classes.nav">
|
|
105
|
+
<template v-for="link in visibleLinks" :key="link.id">
|
|
106
|
+
<slot
|
|
107
|
+
name="link"
|
|
108
|
+
:link
|
|
109
|
+
:active="activeIds.has(link.id)"
|
|
110
|
+
:navigate="() => navigate(link)"
|
|
111
|
+
>
|
|
112
|
+
<button
|
|
113
|
+
:class="[classes.link, activeIds.has(link.id) && classes.linkActive]"
|
|
114
|
+
:aria-current="activeIds.has(link.id) ? 'page' : undefined"
|
|
115
|
+
@click="() => navigate(link)"
|
|
116
|
+
>
|
|
117
|
+
{{ link.label }}
|
|
118
|
+
</button>
|
|
119
|
+
</slot>
|
|
120
|
+
</template>
|
|
121
|
+
</HoverDimGroup>
|
|
72
122
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
</
|
|
123
|
+
<slot name="footer" />
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
126
|
+
</UModal>
|
|
127
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SiteLogoConfig, SiteLogoSize } from '../../types/nav'
|
|
3
|
+
|
|
4
|
+
// The site wordmark from `site.title`, sized on the step scale. Look comes from `siteLogo` in
|
|
5
|
+
// app.config; `ui` overrides per instance. Replaces the deprecated SiteTitle/SiteSubtitle.
|
|
6
|
+
const {
|
|
7
|
+
as = 'span',
|
|
8
|
+
size = 'sm',
|
|
9
|
+
stacked = undefined,
|
|
10
|
+
ui = undefined,
|
|
11
|
+
} = defineProps<{
|
|
12
|
+
as?: string
|
|
13
|
+
size?: SiteLogoSize
|
|
14
|
+
/** One word per line. Defaults to `siteLogo.stacked`. */
|
|
15
|
+
stacked?: boolean
|
|
16
|
+
ui?: Partial<SiteLogoConfig['ui']>
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const config = useAppConfig().siteLogo as SiteLogoConfig
|
|
20
|
+
const site = useSite()
|
|
21
|
+
const isStacked = computed(() => stacked ?? config.stacked)
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<component :is="as" :class="[ui?.root ?? config.ui.root, config.sizes[size]]">
|
|
26
|
+
<slot :words="site.titleWords" :title="site.title">
|
|
27
|
+
<span
|
|
28
|
+
v-for="(word, i) in site.titleWords"
|
|
29
|
+
:key="i"
|
|
30
|
+
:class="[isStacked && 'block', ui?.word ?? config.ui.word]"
|
|
31
|
+
>{{ word }}{{ !isStacked && i < site.titleWords.length - 1 ? ' ' : '' }}</span
|
|
32
|
+
>
|
|
33
|
+
</slot>
|
|
34
|
+
</component>
|
|
35
|
+
</template>
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* @deprecated Render `useSite().subtitleWords` directly. Unused in this repo and KMCOMv5;
|
|
4
|
+
* remove in the next major.
|
|
5
|
+
*/
|
|
6
|
+
if (import.meta.dev)
|
|
7
|
+
// eslint-disable-next-line no-console -- one-time deprecation notice, dev only
|
|
8
|
+
console.warn(
|
|
9
|
+
'[navigation] <SiteSubtitle> is deprecated — render useSite().subtitleWords directly.'
|
|
10
|
+
)
|
|
11
|
+
|
|
2
12
|
const site = useSite()
|
|
3
13
|
</script>
|
|
4
14
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* @deprecated Use `<SiteLogo :stacked="false">` (or `useSite().titleWords`). Remove in the
|
|
4
|
+
* next major.
|
|
5
|
+
*/
|
|
6
|
+
if (import.meta.dev)
|
|
7
|
+
// eslint-disable-next-line no-console -- one-time deprecation notice, dev only
|
|
8
|
+
console.warn('[navigation] <SiteTitle> is deprecated — use <SiteLogo>.')
|
|
9
|
+
|
|
2
10
|
const site = useSite()
|
|
3
11
|
</script>
|
|
4
12
|
|