kmcom-nuxt-layers 1.6.0 → 1.6.1
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/layers/ui/app/app.config.ts +8 -0
- package/layers/ui/app/components/Base/Modal.vue +111 -0
- package/layers/ui/app/components/Mast/Nav.vue +15 -3
- package/layers/ui/app/components/Mast/NavModal.vue +70 -0
- package/layers/ui/app/composables/mastNav.ts +25 -0
- package/layers/ui/app/composables/toast.ts +22 -0
- package/layers/ui/app/layouts/default.vue +1 -0
- package/layers/ui/app/utils/createModal.ts +34 -0
- package/package.json +1 -1
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export default defineAppConfig({
|
|
2
|
+
mastNav: {
|
|
3
|
+
links: [] as Array<{ id: string; label: string; to: string | { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } }>,
|
|
4
|
+
scrollBehaviour: 'router' as 'smooth-scroll' | 'router',
|
|
5
|
+
},
|
|
2
6
|
uiLayer: {
|
|
3
7
|
gradients: {
|
|
4
8
|
brand: { shape: 'linear', direction: 'to-br', from: { color: 'primary', shade: 500 }, to: { color: 'secondary', shade: 600 } },
|
|
@@ -35,6 +39,10 @@ export default defineAppConfig({
|
|
|
35
39
|
|
|
36
40
|
declare module '@nuxt/schema' {
|
|
37
41
|
interface AppConfigInput {
|
|
42
|
+
mastNav?: {
|
|
43
|
+
links?: Array<{ id: string; label: string; to: string | { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } }>
|
|
44
|
+
scrollBehaviour?: 'smooth-scroll' | 'router'
|
|
45
|
+
}
|
|
38
46
|
uiLayer?: {
|
|
39
47
|
name?: string
|
|
40
48
|
gradients?: Record<string, import('./types/gradient').GradientConfig>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* BaseModal — overlay-compatible modal shell.
|
|
4
|
+
*
|
|
5
|
+
* Use this as a starting point for new modals:
|
|
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
|
|
9
|
+
*
|
|
10
|
+
* Example:
|
|
11
|
+
* // components/ConfirmModal.vue
|
|
12
|
+
* <BaseModal title="Are you sure?" @confirm="..." />
|
|
13
|
+
*
|
|
14
|
+
* // composables/confirmModal.ts
|
|
15
|
+
* export const useConfirmModal = createModal(ConfirmModal)
|
|
16
|
+
*/
|
|
17
|
+
const { open = false, title, description, size = 'md' } = defineProps<{
|
|
18
|
+
open?: boolean
|
|
19
|
+
title?: string
|
|
20
|
+
description?: string
|
|
21
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits<{
|
|
25
|
+
'update:open': [value: boolean]
|
|
26
|
+
'close': []
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const sizeClass: Record<string, string> = {
|
|
30
|
+
sm: 'max-w-sm',
|
|
31
|
+
md: 'max-w-md',
|
|
32
|
+
lg: 'max-w-lg',
|
|
33
|
+
xl: 'max-w-xl',
|
|
34
|
+
full: 'max-w-full',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dismiss() {
|
|
38
|
+
emit('update:open', false)
|
|
39
|
+
emit('close')
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<Transition name="base-modal">
|
|
45
|
+
<div
|
|
46
|
+
v-if="open"
|
|
47
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
48
|
+
@click.self="dismiss"
|
|
49
|
+
>
|
|
50
|
+
<!-- Backdrop -->
|
|
51
|
+
<div class="bg-(--ui-bg-muted) absolute inset-0 opacity-60" @click="dismiss" />
|
|
52
|
+
|
|
53
|
+
<!-- Panel -->
|
|
54
|
+
<div
|
|
55
|
+
class="bg-(--ui-bg) ring-(--ui-border) relative w-full rounded-2xl shadow-xl ring-1"
|
|
56
|
+
:class="sizeClass[size]"
|
|
57
|
+
>
|
|
58
|
+
<!-- Header -->
|
|
59
|
+
<div v-if="title || $slots.header" class="border-(--ui-border) flex items-start justify-between border-b px-6 py-4">
|
|
60
|
+
<slot name="header">
|
|
61
|
+
<div>
|
|
62
|
+
<p class="text-(--ui-text) text-base font-semibold">
|
|
63
|
+
{{ title }}
|
|
64
|
+
</p>
|
|
65
|
+
<p v-if="description" class="text-(--ui-text-muted) mt-0.5 text-sm">
|
|
66
|
+
{{ description }}
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
</slot>
|
|
70
|
+
<button
|
|
71
|
+
class="text-(--ui-text-muted) hover:text-(--ui-text) ml-4 cursor-pointer border-0 bg-transparent p-1 transition-colors"
|
|
72
|
+
aria-label="Close"
|
|
73
|
+
@click="dismiss"
|
|
74
|
+
>
|
|
75
|
+
<UIcon name="lucide:x" class="size-5" />
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<!-- Body -->
|
|
80
|
+
<div class="px-6 py-5">
|
|
81
|
+
<slot />
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<!-- Footer -->
|
|
85
|
+
<div v-if="$slots.footer" class="border-(--ui-border) flex justify-end gap-3 border-t px-6 py-4">
|
|
86
|
+
<slot name="footer" :dismiss="dismiss" />
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</Transition>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<style scoped>
|
|
94
|
+
.base-modal-enter-active,
|
|
95
|
+
.base-modal-leave-active {
|
|
96
|
+
transition: opacity 200ms ease;
|
|
97
|
+
}
|
|
98
|
+
.base-modal-enter-from,
|
|
99
|
+
.base-modal-leave-to {
|
|
100
|
+
opacity: 0;
|
|
101
|
+
}
|
|
102
|
+
.base-modal-enter-active .bg-\(--ui-bg\),
|
|
103
|
+
.base-modal-leave-active .bg-\(--ui-bg\) {
|
|
104
|
+
transition: opacity 200ms ease, transform 200ms ease;
|
|
105
|
+
}
|
|
106
|
+
.base-modal-enter-from .bg-\(--ui-bg\),
|
|
107
|
+
.base-modal-leave-to .bg-\(--ui-bg\) {
|
|
108
|
+
opacity: 0;
|
|
109
|
+
transform: scale(0.96) translateY(4px);
|
|
110
|
+
}
|
|
111
|
+
</style>
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { open } = useMastNav()
|
|
3
|
+
const z = useGridConfig().useZIndex('dropdown')
|
|
4
|
+
</script>
|
|
5
|
+
|
|
1
6
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
7
|
+
<button
|
|
8
|
+
aria-label="Open navigation menu"
|
|
9
|
+
class="fixed right-0 top-0 flex cursor-pointer items-center justify-center border-0 bg-transparent p-4"
|
|
10
|
+
:style="{ zIndex: z }"
|
|
11
|
+
@click="open"
|
|
12
|
+
>
|
|
13
|
+
<slot name="icon">
|
|
14
|
+
<UIcon name="lucide:menu" class="size-7" />
|
|
15
|
+
</slot>
|
|
16
|
+
</button>
|
|
5
17
|
</template>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { open } = defineProps<{ open?: boolean }>()
|
|
3
|
+
const emit = defineEmits<{
|
|
4
|
+
'update:open': [value: boolean]
|
|
5
|
+
'close': []
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const { links, scrollBehaviour } = useAppConfig().mastNav
|
|
9
|
+
const activeSection = useState<string>('activeSection', () => '')
|
|
10
|
+
const route = useRoute()
|
|
11
|
+
const { close: closeNav } = useMastNav()
|
|
12
|
+
|
|
13
|
+
function dismiss() {
|
|
14
|
+
emit('update:open', false)
|
|
15
|
+
emit('close')
|
|
16
|
+
closeNav()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function handleNav(link: { id: string; to: string | { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } }) {
|
|
20
|
+
if (scrollBehaviour === 'smooth-scroll' && route.name === 'index') {
|
|
21
|
+
try { useSmoothScroll().scrollTo(`#${link.id}`) }
|
|
22
|
+
catch {}
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
navigateTo(link.to)
|
|
26
|
+
}
|
|
27
|
+
dismiss()
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<Transition name="nav-modal">
|
|
33
|
+
<div
|
|
34
|
+
v-if="open"
|
|
35
|
+
class="bg-(--ui-bg) text-(--ui-text) fixed inset-0 flex flex-col items-center justify-center"
|
|
36
|
+
:style="{ zIndex: useGridConfig().useZIndex('modal') }"
|
|
37
|
+
>
|
|
38
|
+
<button
|
|
39
|
+
class="absolute right-[5vw] top-[3vh] cursor-pointer border-0 bg-transparent p-2"
|
|
40
|
+
aria-label="Close navigation"
|
|
41
|
+
@click="dismiss"
|
|
42
|
+
>
|
|
43
|
+
<UIcon name="lucide:x" class="size-7" />
|
|
44
|
+
</button>
|
|
45
|
+
|
|
46
|
+
<LinksGroup tag="nav" class="flex flex-col items-center gap-2">
|
|
47
|
+
<button
|
|
48
|
+
v-for="link in links"
|
|
49
|
+
:key="link.id"
|
|
50
|
+
class="cursor-pointer border-0 bg-transparent px-4 py-3 text-3xl uppercase tracking-widest transition-all"
|
|
51
|
+
:class="activeSection === link.id ? 'font-normal' : 'font-light'"
|
|
52
|
+
@click="handleNav(link)"
|
|
53
|
+
>
|
|
54
|
+
{{ link.label }}
|
|
55
|
+
</button>
|
|
56
|
+
</LinksGroup>
|
|
57
|
+
</div>
|
|
58
|
+
</Transition>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<style scoped>
|
|
62
|
+
.nav-modal-enter-active,
|
|
63
|
+
.nav-modal-leave-active {
|
|
64
|
+
transition: opacity 300ms ease;
|
|
65
|
+
}
|
|
66
|
+
.nav-modal-enter-from,
|
|
67
|
+
.nav-modal-leave-to {
|
|
68
|
+
opacity: 0;
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createSharedComposable } from '@vueuse/core'
|
|
2
|
+
import MastNavModal from '../components/Mast/NavModal.vue'
|
|
3
|
+
|
|
4
|
+
function _useMastNav() {
|
|
5
|
+
if (import.meta.server) return { open: () => {}, close: () => {} }
|
|
6
|
+
|
|
7
|
+
const overlay = useOverlay()
|
|
8
|
+
const modal = overlay.create(MastNavModal)
|
|
9
|
+
|
|
10
|
+
function open() {
|
|
11
|
+
try { useSmoothScroll().lockScrolling() }
|
|
12
|
+
catch {}
|
|
13
|
+
modal.open()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function close() {
|
|
17
|
+
modal.close()
|
|
18
|
+
try { useSmoothScroll().unlockScrolling() }
|
|
19
|
+
catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { open, close }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const useMastNav = createSharedComposable(_useMastNav)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type ToastOptions = {
|
|
2
|
+
description?: string
|
|
3
|
+
duration?: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function useAppToast() {
|
|
7
|
+
const toast = useToast()
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
success: (title: string, opts?: ToastOptions) =>
|
|
11
|
+
toast.add({ title, icon: 'lucide:check-circle', color: 'success', ...opts }),
|
|
12
|
+
|
|
13
|
+
error: (title: string, opts?: ToastOptions) =>
|
|
14
|
+
toast.add({ title, icon: 'lucide:x-circle', color: 'error', ...opts }),
|
|
15
|
+
|
|
16
|
+
info: (title: string, opts?: ToastOptions) =>
|
|
17
|
+
toast.add({ title, icon: 'lucide:info', color: 'info', ...opts }),
|
|
18
|
+
|
|
19
|
+
warning: (title: string, opts?: ToastOptions) =>
|
|
20
|
+
toast.add({ title, icon: 'lucide:triangle-alert', color: 'warning', ...opts }),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createSharedComposable } from '@vueuse/core'
|
|
2
|
+
import type { Component } from 'vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Factory that turns any overlay-compatible component into a shared modal composable.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* // composables/myModal.ts
|
|
9
|
+
* import MyModal from '../components/MyModal.vue'
|
|
10
|
+
* export const useMyModal = createModal(MyModal)
|
|
11
|
+
*
|
|
12
|
+
* // anywhere in the app
|
|
13
|
+
* const { open, close, patch } = useMyModal()
|
|
14
|
+
* open({ title: 'Hello' })
|
|
15
|
+
*
|
|
16
|
+
* The returned composable is wrapped with createSharedComposable so overlay.create()
|
|
17
|
+
* is only called once regardless of how many components call it.
|
|
18
|
+
*
|
|
19
|
+
* The modal component must accept `open: boolean` and emit `update:open` + `close`.
|
|
20
|
+
* Extend BaseModal.vue as a starting point.
|
|
21
|
+
*/
|
|
22
|
+
export function createModal<P extends Record<string, unknown>>(component: Component) {
|
|
23
|
+
return createSharedComposable(() => {
|
|
24
|
+
if (import.meta.server) return { open: () => {}, close: () => {}, patch: () => {} }
|
|
25
|
+
|
|
26
|
+
const overlay = useOverlay()
|
|
27
|
+
const modal = overlay.create<P>(component)
|
|
28
|
+
return {
|
|
29
|
+
open: (props?: Partial<P>) => modal.open(props),
|
|
30
|
+
close: () => modal.close(),
|
|
31
|
+
patch: (props: Partial<P>) => modal.patch(props),
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|