first commit
This commit is contained in:
40
resources/js/@layouts/components/HorizontalNav.vue
Normal file
40
resources/js/@layouts/components/HorizontalNav.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import {
|
||||
HorizontalNavGroup,
|
||||
HorizontalNavLink,
|
||||
} from '@layouts/components'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const resolveNavItemComponent = item => {
|
||||
if ('children' in item)
|
||||
return HorizontalNavGroup
|
||||
|
||||
return HorizontalNavLink
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="nav-items">
|
||||
<Component
|
||||
:is="resolveNavItemComponent(item)"
|
||||
v-for="(item, index) in navItems"
|
||||
:key="index"
|
||||
:item="item"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-wrapper.layout-nav-type-horizontal {
|
||||
.nav-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
117
resources/js/@layouts/components/HorizontalNavGroup.vue
Normal file
117
resources/js/@layouts/components/HorizontalNavGroup.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { layoutConfig } from '@layouts'
|
||||
import {
|
||||
HorizontalNavLink,
|
||||
HorizontalNavPopper,
|
||||
} from '@layouts/components'
|
||||
import { canViewNavMenuGroup } from '@layouts/plugins/casl'
|
||||
import { useLayoutConfigStore } from '@layouts/stores/config'
|
||||
import {
|
||||
getDynamicI18nProps,
|
||||
isNavGroupActive,
|
||||
} from '@layouts/utils'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
childrenAtEnd: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isSubItem: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
name: 'HorizontalNavGroup',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const configStore = useLayoutConfigStore()
|
||||
const isGroupActive = ref(false)
|
||||
|
||||
/*Watch for route changes, more specifically route path. Do note that this won't trigger if route's query is updated.
|
||||
|
||||
updates isActive & isOpen based on active state of group.
|
||||
*/
|
||||
watch(() => route.path, () => {
|
||||
const isActive = isNavGroupActive(props.item.children, router)
|
||||
|
||||
isGroupActive.value = isActive
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HorizontalNavPopper
|
||||
v-if="canViewNavMenuGroup(item)"
|
||||
:is-rtl="configStore.isAppRTL"
|
||||
class="nav-group"
|
||||
tag="li"
|
||||
content-container-tag="ul"
|
||||
:class="[{
|
||||
'active': isGroupActive,
|
||||
'children-at-end': childrenAtEnd,
|
||||
'sub-item': isSubItem,
|
||||
'disabled': item.disable,
|
||||
}]"
|
||||
:popper-inline-end="childrenAtEnd"
|
||||
>
|
||||
<div class="nav-group-label">
|
||||
<Component
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
class="nav-item-icon"
|
||||
v-bind="item.icon || layoutConfig.verticalNav.defaultNavItemIconProps"
|
||||
/>
|
||||
<Component
|
||||
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
|
||||
v-bind="getDynamicI18nProps(item.title, 'span')"
|
||||
class="nav-item-title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</Component>
|
||||
<Component
|
||||
v-bind="layoutConfig.icons.chevronDown"
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
class="nav-group-arrow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #content>
|
||||
<Component
|
||||
:is="'children' in child ? 'HorizontalNavGroup' : HorizontalNavLink"
|
||||
v-for="child in item.children"
|
||||
:key="child.title"
|
||||
:item="child"
|
||||
children-at-end
|
||||
is-sub-item
|
||||
/>
|
||||
</template>
|
||||
</HorizontalNavPopper>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-horizontal-nav {
|
||||
.nav-group {
|
||||
.nav-group-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popper-content {
|
||||
z-index: 1;
|
||||
|
||||
> div {
|
||||
overflow: hidden auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
153
resources/js/@layouts/components/HorizontalNavLayout.vue
Normal file
153
resources/js/@layouts/components/HorizontalNavLayout.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { HorizontalNav } from '@layouts/components'
|
||||
|
||||
// ℹ️ Using import from `@layouts` causing build to hangup
|
||||
|
||||
// import { useLayouts } from '@layouts'
|
||||
import { useLayoutConfigStore } from '@layouts/stores/config'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const configStore = useLayoutConfigStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="layout-wrapper"
|
||||
:class="configStore._layoutClasses"
|
||||
>
|
||||
<div
|
||||
class="layout-navbar-and-nav-container"
|
||||
:class="configStore.isNavbarBlurEnabled && 'header-blur'"
|
||||
>
|
||||
<!-- 👉 Navbar -->
|
||||
<div class="layout-navbar">
|
||||
<div class="navbar-content-container">
|
||||
<slot name="navbar" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 👉 Navigation -->
|
||||
<div class="layout-horizontal-nav">
|
||||
<div class="horizontal-nav-content-container">
|
||||
<HorizontalNav :nav-items="navItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="layout-page-content">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- 👉 Footer -->
|
||||
<footer class="layout-footer">
|
||||
<div class="footer-content-container">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@configured-variables" as variables;
|
||||
@use "@layouts/styles/placeholders";
|
||||
@use "@layouts/styles/mixins";
|
||||
|
||||
.layout-wrapper {
|
||||
&.layout-nav-type-horizontal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// // TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
// min-height: 100%;
|
||||
min-block-size: 100dvh;
|
||||
|
||||
.layout-navbar-and-nav-container {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
z-index: variables.$layout-horizontal-nav-layout-navbar-z-index;
|
||||
block-size: variables.$layout-horizontal-nav-navbar-height;
|
||||
|
||||
// ℹ️ For now we are not independently managing navbar and horizontal nav so we won't use below style to avoid conflicting with combo style of navbar and horizontal nav
|
||||
// If we add independent style of navbar & horizontal nav then we have to add :not for avoiding conflict with combo styles
|
||||
// .layout-navbar-sticky & {
|
||||
// @extend %layout-navbar-sticky;
|
||||
// }
|
||||
|
||||
// ℹ️ For now we are not independently managing navbar and horizontal nav so we won't use below style to avoid conflicting with combo style of navbar and horizontal nav
|
||||
// If we add independent style of navbar & horizontal nav then we have to add :not for avoiding conflict with combo styles
|
||||
// .layout-navbar-hidden & {
|
||||
// @extend %layout-navbar-hidden;
|
||||
// }
|
||||
}
|
||||
|
||||
// 👉 Navbar
|
||||
.navbar-content-container {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
|
||||
// 👉 Content height fixed
|
||||
&.layout-content-height-fixed {
|
||||
max-block-size: 100dvh;
|
||||
|
||||
.layout-page-content {
|
||||
overflow: hidden;
|
||||
|
||||
> :first-child {
|
||||
max-block-size: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Footer
|
||||
// Boxed content
|
||||
.layout-footer {
|
||||
.footer-content-container {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If both navbar & horizontal nav sticky
|
||||
&.layout-navbar-sticky.horizontal-nav-sticky {
|
||||
.layout-navbar-and-nav-container {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-navbar-hidden.horizontal-nav-hidden {
|
||||
.layout-navbar-and-nav-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Horizontal nav nav
|
||||
.layout-horizontal-nav {
|
||||
z-index: variables.$layout-horizontal-nav-z-index;
|
||||
|
||||
// .horizontal-nav-sticky & {
|
||||
// width: 100%;
|
||||
// will-change: transform;
|
||||
// position: sticky;
|
||||
// top: 0;
|
||||
// }
|
||||
|
||||
// .horizontal-nav-hidden & {
|
||||
// display: none;
|
||||
// }
|
||||
|
||||
.horizontal-nav-content-container {
|
||||
@include mixins.boxed-content(true);
|
||||
}
|
||||
}
|
||||
</style>
|
60
resources/js/@layouts/components/HorizontalNavLink.vue
Normal file
60
resources/js/@layouts/components/HorizontalNavLink.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { layoutConfig } from '@layouts'
|
||||
import { can } from '@layouts/plugins/casl'
|
||||
import {
|
||||
getComputedNavLinkToProp,
|
||||
getDynamicI18nProps,
|
||||
isNavLinkActive,
|
||||
} from '@layouts/utils'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
isSubItem: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
v-if="can(item.action, item.subject)"
|
||||
class="nav-link"
|
||||
:class="[{
|
||||
'sub-item': props.isSubItem,
|
||||
'disabled': item.disable,
|
||||
}]"
|
||||
>
|
||||
<Component
|
||||
:is="item.to ? 'RouterLink' : 'a'"
|
||||
v-bind="getComputedNavLinkToProp(item)"
|
||||
:class="{ 'router-link-active router-link-exact-active': isNavLinkActive(item, $router) }"
|
||||
>
|
||||
<Component
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
class="nav-item-icon"
|
||||
v-bind="item.icon || layoutConfig.verticalNav.defaultNavItemIconProps"
|
||||
/>
|
||||
<Component
|
||||
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
|
||||
class="nav-item-title"
|
||||
v-bind="getDynamicI18nProps(item.title, 'span')"
|
||||
>
|
||||
{{ item.title }}
|
||||
</Component>
|
||||
</Component>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-horizontal-nav {
|
||||
.nav-link a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
208
resources/js/@layouts/components/HorizontalNavPopper.vue
Normal file
208
resources/js/@layouts/components/HorizontalNavPopper.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<script setup>
|
||||
import {
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from '@floating-ui/dom'
|
||||
import { useLayoutConfigStore } from '@layouts/stores/config'
|
||||
import { themeConfig } from '@themeConfig'
|
||||
|
||||
const props = defineProps({
|
||||
popperInlineEnd: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
tag: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'div',
|
||||
},
|
||||
contentContainerTag: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'div',
|
||||
},
|
||||
isRtl: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const configStore = useLayoutConfigStore()
|
||||
const refPopperContainer = ref()
|
||||
const refPopper = ref()
|
||||
|
||||
const popperContentStyles = ref({
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
|
||||
/*ℹ️ Why we are not using fixed positioning?
|
||||
|
||||
`position: fixed` doesn't work as expected when some CSS properties like `transform` is applied on its parent element.
|
||||
Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/position#values <= See `fixed` value description
|
||||
|
||||
Hence, when we use transitions where transition apply `transform` on its parent element, fixed positioning will not work.
|
||||
(Popper content moves away from the element when parent element transition)
|
||||
|
||||
To avoid this, we use `position: absolute` instead of `position: fixed`.
|
||||
|
||||
NOTE: This issue starts from third level children (Top Level > Sub item > Sub item).
|
||||
*/
|
||||
|
||||
// strategy: 'fixed',
|
||||
})
|
||||
|
||||
const updatePopper = async () => {
|
||||
if (refPopperContainer.value !== undefined && refPopper.value !== undefined) {
|
||||
const { x, y } = await computePosition(refPopperContainer.value, refPopper.value, {
|
||||
placement: props.popperInlineEnd ? props.isRtl ? 'left-start' : 'right-start' : 'bottom-start',
|
||||
middleware: [
|
||||
...configStore.horizontalNavPopoverOffset ? [offset(configStore.horizontalNavPopoverOffset)] : [],
|
||||
flip({ boundary: document.querySelector('body') }),
|
||||
shift({ boundary: document.querySelector('body') }),
|
||||
],
|
||||
|
||||
/*ℹ️ Why we are not using fixed positioning?
|
||||
|
||||
`position: fixed` doesn't work as expected when some CSS properties like `transform` is applied on its parent element.
|
||||
Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/position#values <= See `fixed` value description
|
||||
|
||||
Hence, when we use transitions where transition apply `transform` on its parent element, fixed positioning will not work.
|
||||
(Popper content moves away from the element when parent element transition)
|
||||
|
||||
To avoid this, we use `position: absolute` instead of `position: fixed`.
|
||||
|
||||
NOTE: This issue starts from third level children (Top Level > Sub item > Sub item).
|
||||
*/
|
||||
|
||||
// strategy: 'fixed',
|
||||
})
|
||||
|
||||
popperContentStyles.value.left = `${ x }px`
|
||||
popperContentStyles.value.top = `${ y }px`
|
||||
}
|
||||
}
|
||||
|
||||
until(() => configStore.horizontalNavType).toMatch(type => type === 'static').then(() => {
|
||||
useEventListener('scroll', updatePopper)
|
||||
|
||||
/*ℹ️ Why we are not using fixed positioning?
|
||||
|
||||
`position: fixed` doesn't work as expected when some CSS properties like `transform` is applied on its parent element.
|
||||
Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/position#values <= See `fixed` value description
|
||||
|
||||
Hence, when we use transitions where transition apply `transform` on its parent element, fixed positioning will not work.
|
||||
(Popper content moves away from the element when parent element transition)
|
||||
|
||||
To avoid this, we use `position: absolute` instead of `position: fixed`.
|
||||
|
||||
NOTE: This issue starts from third level children (Top Level > Sub item > Sub item).
|
||||
*/
|
||||
|
||||
// strategy: 'fixed',
|
||||
})
|
||||
|
||||
const isContentShown = ref(false)
|
||||
|
||||
const showContent = () => {
|
||||
isContentShown.value = true
|
||||
updatePopper()
|
||||
}
|
||||
|
||||
const hideContent = () => {
|
||||
isContentShown.value = false
|
||||
}
|
||||
|
||||
onMounted(updatePopper)
|
||||
|
||||
// ℹ️ Recalculate popper position when it's triggerer changes its position
|
||||
watch([
|
||||
() => configStore.isAppRTL,
|
||||
() => configStore.appContentWidth,
|
||||
], updatePopper)
|
||||
|
||||
// Watch for route changes and close popper content if route is changed
|
||||
const route = useRoute()
|
||||
|
||||
watch(() => route.fullPath, hideContent)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="nav-popper"
|
||||
:class="[{
|
||||
'popper-inline-end': popperInlineEnd,
|
||||
'show-content': isContentShown,
|
||||
}]"
|
||||
>
|
||||
<div
|
||||
ref="refPopperContainer"
|
||||
class="popper-triggerer"
|
||||
@mouseenter="showContent"
|
||||
@mouseleave="hideContent"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- SECTION Popper Content -->
|
||||
<!-- 👉 Without transition -->
|
||||
<template v-if="!themeConfig.horizontalNav.transition">
|
||||
<div
|
||||
ref="refPopper"
|
||||
class="popper-content"
|
||||
:style="popperContentStyles"
|
||||
@mouseenter="showContent"
|
||||
@mouseleave="hideContent"
|
||||
>
|
||||
<div>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 👉 CSS Transition -->
|
||||
<template v-else-if="typeof themeConfig.horizontalNav.transition === 'string'">
|
||||
<Transition :name="themeConfig.horizontalNav.transition">
|
||||
<div
|
||||
v-show="isContentShown"
|
||||
ref="refPopper"
|
||||
class="popper-content"
|
||||
:style="popperContentStyles"
|
||||
@mouseenter="showContent"
|
||||
@mouseleave="hideContent"
|
||||
>
|
||||
<div>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- 👉 Transition Component -->
|
||||
<template v-else>
|
||||
<Component :is="themeConfig.horizontalNav.transition">
|
||||
<div
|
||||
v-show="isContentShown"
|
||||
ref="refPopper"
|
||||
class="popper-content"
|
||||
:style="popperContentStyles"
|
||||
@mouseenter="showContent"
|
||||
@mouseleave="hideContent"
|
||||
>
|
||||
<div>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
</template>
|
||||
<!-- !SECTION -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.popper-content {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
87
resources/js/@layouts/components/TransitionExpand.vue
Normal file
87
resources/js/@layouts/components/TransitionExpand.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<!-- Thanks: https://markus.oberlehner.net/blog/transition-to-height-auto-with-vue/ -->
|
||||
|
||||
<script>
|
||||
import { Transition } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TransitionExpand',
|
||||
setup(_, { slots }) {
|
||||
const onEnter = element => {
|
||||
const width = getComputedStyle(element).width
|
||||
|
||||
element.style.width = width
|
||||
element.style.position = 'absolute'
|
||||
element.style.visibility = 'hidden'
|
||||
element.style.height = 'auto'
|
||||
|
||||
const height = getComputedStyle(element).height
|
||||
|
||||
element.style.width = ''
|
||||
element.style.position = ''
|
||||
element.style.visibility = ''
|
||||
element.style.height = '0px'
|
||||
|
||||
// Force repaint to make sure the
|
||||
// animation is triggered correctly.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
getComputedStyle(element).height
|
||||
|
||||
// Trigger the animation.
|
||||
// We use `requestAnimationFrame` because we need
|
||||
// to make sure the browser has finished
|
||||
// painting after setting the `height`
|
||||
// to `0` in the line above.
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = height
|
||||
})
|
||||
}
|
||||
|
||||
const onAfterEnter = element => {
|
||||
element.style.height = 'auto'
|
||||
}
|
||||
|
||||
const onLeave = element => {
|
||||
const height = getComputedStyle(element).height
|
||||
|
||||
element.style.height = height
|
||||
|
||||
// Force repaint to make sure the
|
||||
// animation is triggered correctly.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
getComputedStyle(element).height
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = '0px'
|
||||
})
|
||||
}
|
||||
|
||||
return () => h(h(Transition), {
|
||||
name: 'expand',
|
||||
onEnter,
|
||||
onAfterEnter,
|
||||
onLeave,
|
||||
}, () => slots.default?.())
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
overflow: hidden;
|
||||
transition: block-size var(--expand-transition-duration, 0.25s) ease;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
block-size: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
transform: translateZ(0);
|
||||
will-change: block-size;
|
||||
}
|
||||
</style>
|
12
resources/js/@layouts/components/VNodeRenderer.jsx
Normal file
12
resources/js/@layouts/components/VNodeRenderer.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export const VNodeRenderer = defineComponent({
|
||||
name: 'VNodeRenderer',
|
||||
props: {
|
||||
nodes: {
|
||||
type: [Array, Object],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => props.nodes
|
||||
},
|
||||
})
|
246
resources/js/@layouts/components/VerticalNav.vue
Normal file
246
resources/js/@layouts/components/VerticalNav.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
|
||||
import { VNodeRenderer } from './VNodeRenderer'
|
||||
import { layoutConfig } from '@layouts'
|
||||
import {
|
||||
VerticalNavGroup,
|
||||
VerticalNavLink,
|
||||
VerticalNavSectionTitle,
|
||||
} from '@layouts/components'
|
||||
import { useLayoutConfigStore } from '@layouts/stores/config'
|
||||
import { injectionKeyIsVerticalNavHovered } from '@layouts/symbols'
|
||||
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
type: null,
|
||||
required: false,
|
||||
default: 'aside',
|
||||
},
|
||||
navItems: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
isOverlayNavActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
toggleIsOverlayNavActive: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const refNav = ref()
|
||||
const isHovered = useElementHover(refNav)
|
||||
|
||||
provide(injectionKeyIsVerticalNavHovered, isHovered)
|
||||
|
||||
const configStore = useLayoutConfigStore()
|
||||
|
||||
const resolveNavItemComponent = item => {
|
||||
if ('heading' in item)
|
||||
return VerticalNavSectionTitle
|
||||
if ('children' in item)
|
||||
return VerticalNavGroup
|
||||
|
||||
return VerticalNavLink
|
||||
}
|
||||
|
||||
/*ℹ️ Close overlay side when route is changed
|
||||
Close overlay vertical nav when link is clicked
|
||||
*/
|
||||
const route = useRoute()
|
||||
|
||||
watch(() => route.name, () => {
|
||||
props.toggleIsOverlayNavActive(false)
|
||||
})
|
||||
|
||||
const isVerticalNavScrolled = ref(false)
|
||||
const updateIsVerticalNavScrolled = val => isVerticalNavScrolled.value = val
|
||||
|
||||
const handleNavScroll = evt => {
|
||||
isVerticalNavScrolled.value = evt.target.scrollTop > 0
|
||||
}
|
||||
|
||||
const hideTitleAndIcon = configStore.isVerticalNavMini(isHovered)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="props.tag"
|
||||
ref="refNav"
|
||||
class="layout-vertical-nav"
|
||||
:class="[
|
||||
{
|
||||
'overlay-nav': configStore.isLessThanOverlayNavBreakpoint,
|
||||
'hovered': isHovered,
|
||||
'visible': isOverlayNavActive,
|
||||
'scrolled': isVerticalNavScrolled,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<!-- 👉 Header -->
|
||||
<div class="nav-header">
|
||||
<slot name="nav-header">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="app-logo app-title-wrapper"
|
||||
>
|
||||
<VNodeRenderer :nodes="layoutConfig.app.logo" />
|
||||
|
||||
<Transition name="vertical-nav-app-title">
|
||||
<h1
|
||||
v-show="!hideTitleAndIcon"
|
||||
class="app-logo-title leading-normal"
|
||||
>
|
||||
{{ layoutConfig.app.title }}
|
||||
</h1>
|
||||
</Transition>
|
||||
</RouterLink>
|
||||
<!-- 👉 Vertical nav actions -->
|
||||
<!-- Show toggle collapsible in >md and close button in <md -->
|
||||
<Component
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
v-show="configStore.isVerticalNavCollapsed"
|
||||
class="header-action d-none nav-unpin"
|
||||
:class="configStore.isVerticalNavCollapsed && 'd-lg-block'"
|
||||
v-bind="layoutConfig.icons.verticalNavUnPinned"
|
||||
@click="configStore.isVerticalNavCollapsed = !configStore.isVerticalNavCollapsed"
|
||||
/>
|
||||
<Component
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
v-show="!configStore.isVerticalNavCollapsed"
|
||||
class="header-action d-none nav-pin"
|
||||
:class="!configStore.isVerticalNavCollapsed && 'd-lg-block'"
|
||||
v-bind="layoutConfig.icons.verticalNavPinned"
|
||||
@click="configStore.isVerticalNavCollapsed = !configStore.isVerticalNavCollapsed"
|
||||
/>
|
||||
<Component
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
class="header-action d-lg-none"
|
||||
v-bind="layoutConfig.icons.close"
|
||||
@click="toggleIsOverlayNavActive(false)"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="before-nav-items">
|
||||
<div class="vertical-nav-items-shadow" />
|
||||
</slot>
|
||||
<slot
|
||||
name="nav-items"
|
||||
:update-is-vertical-nav-scrolled="updateIsVerticalNavScrolled"
|
||||
>
|
||||
<PerfectScrollbar
|
||||
:key="configStore.isAppRTL"
|
||||
tag="ul"
|
||||
class="nav-items"
|
||||
:options="{ wheelPropagation: false }"
|
||||
@ps-scroll-y="handleNavScroll"
|
||||
>
|
||||
<Component
|
||||
:is="resolveNavItemComponent(item)"
|
||||
v-for="(item, index) in navItems"
|
||||
:key="index"
|
||||
:item="item"
|
||||
/>
|
||||
</PerfectScrollbar>
|
||||
</slot>
|
||||
|
||||
<slot name="after-nav-items" />
|
||||
</Component>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.75rem;
|
||||
|
||||
.app-logo-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@configured-variables" as variables;
|
||||
@use "@layouts/styles/mixins";
|
||||
|
||||
// 👉 Vertical Nav
|
||||
.layout-vertical-nav {
|
||||
position: fixed;
|
||||
z-index: variables.$layout-vertical-nav-z-index;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
inline-size: variables.$layout-vertical-nav-width;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
transition: inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
|
||||
will-change: transform, inline-size;
|
||||
|
||||
.nav-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.header-action {
|
||||
cursor: pointer;
|
||||
|
||||
@at-root {
|
||||
#{variables.$selector-vertical-nav-mini} .nav-header .header-action {
|
||||
&.nav-pin,
|
||||
&.nav-unpin {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-title-wrapper {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
block-size: 100%;
|
||||
|
||||
// ℹ️ We no loner needs this overflow styles as perfect scrollbar applies it
|
||||
// overflow-x: hidden;
|
||||
|
||||
// // ℹ️ We used `overflow-y` instead of `overflow` to mitigate overflow x. Revert back if any issue found.
|
||||
// overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item-title {
|
||||
overflow: hidden;
|
||||
margin-inline-end: auto;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 👉 Collapsed
|
||||
.layout-vertical-nav-collapsed & {
|
||||
&:not(.hovered) {
|
||||
inline-size: variables.$layout-vertical-nav-collapsed-width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small screen vertical nav transition
|
||||
@media (max-width:1279px) {
|
||||
.layout-vertical-nav {
|
||||
&:not(.visible) {
|
||||
transform: translateX(-#{variables.$layout-vertical-nav-width});
|
||||
|
||||
@include mixins.rtl {
|
||||
transform: translateX(variables.$layout-vertical-nav-width);
|
||||
}
|
||||
}
|
||||
|
||||
transition: transform 0.25s ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
218
resources/js/@layouts/components/VerticalNavGroup.vue
Normal file
218
resources/js/@layouts/components/VerticalNavGroup.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup>
|
||||
import { TransitionGroup } from 'vue'
|
||||
import { layoutConfig } from '@layouts'
|
||||
import {
|
||||
TransitionExpand,
|
||||
VerticalNavLink,
|
||||
} from '@layouts/components'
|
||||
import { canViewNavMenuGroup } from '@layouts/plugins/casl'
|
||||
import { useLayoutConfigStore } from '@layouts/stores/config'
|
||||
import { injectionKeyIsVerticalNavHovered } from '@layouts/symbols'
|
||||
import {
|
||||
getDynamicI18nProps,
|
||||
isNavGroupActive,
|
||||
openGroups,
|
||||
} from '@layouts/utils'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
name: 'VerticalNavGroup',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const configStore = useLayoutConfigStore()
|
||||
const hideTitleAndBadge = configStore.isVerticalNavMini()
|
||||
|
||||
/*ℹ️ We provided default value `ref(false)` because inject will return `T | undefined`
|
||||
Docs: https://vuejs.org/api/composition-api-dependency-injection.html#inject
|
||||
*/
|
||||
const isVerticalNavHovered = inject(injectionKeyIsVerticalNavHovered, ref(false))
|
||||
|
||||
// isGroupOpen.value = value ? false : isGroupActive.value
|
||||
|
||||
// })
|
||||
const isGroupActive = ref(false)
|
||||
const isGroupOpen = ref(false)
|
||||
|
||||
const isAnyChildOpen = children => {
|
||||
return children.some(child => {
|
||||
let result = openGroups.value.includes(child.title)
|
||||
if ('children' in child)
|
||||
result = isAnyChildOpen(child.children) || result
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
const collapseChildren = children => {
|
||||
children.forEach(child => {
|
||||
if ('children' in child)
|
||||
collapseChildren(child.children)
|
||||
openGroups.value = openGroups.value.filter(group => group !== child.title)
|
||||
})
|
||||
}
|
||||
|
||||
/*Watch for route changes, more specifically route path. Do note that this won't trigger if route's query is updated.
|
||||
|
||||
updates isActive & isOpen based on active state of group.
|
||||
*/
|
||||
watch(() => route.path, () => {
|
||||
const isActive = isNavGroupActive(props.item.children, router)
|
||||
|
||||
// Don't open group if vertical nav is collapsed and window size is more than overlay nav breakpoint
|
||||
isGroupOpen.value = isActive && !configStore.isVerticalNavMini(isVerticalNavHovered).value
|
||||
isGroupActive.value = isActive
|
||||
}, { immediate: true })
|
||||
watch(isGroupOpen, val => {
|
||||
|
||||
// Find group index for adding/removing group from openGroups array
|
||||
const grpIndex = openGroups.value.indexOf(props.item.title)
|
||||
|
||||
// update openGroups array for addition/removal of current group
|
||||
|
||||
// If group is opened => Add it to `openGroups` array
|
||||
if (val && grpIndex === -1) {
|
||||
openGroups.value.push(props.item.title)
|
||||
} else if (!val && grpIndex !== -1) {
|
||||
openGroups.value.splice(grpIndex, 1)
|
||||
collapseChildren(props.item.children)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
/*Watch for openGroups
|
||||
|
||||
It will help in making vertical nav adapting the behavior of accordion.
|
||||
If we open multiple groups without navigating to any route we must close the inactive or temporarily opened groups.
|
||||
|
||||
😵💫 Gotchas:
|
||||
* If we open inactive group then it will auto close that group because we close groups based on active state.
|
||||
Goal of this watcher is auto close groups which are not active when openGroups array is updated.
|
||||
So, we have to find a way to do not close recently opened inactive group.
|
||||
For this we will fetch recently added group in openGroups array and won't perform closing operation if recently added group is current group
|
||||
*/
|
||||
watch(openGroups, val => {
|
||||
|
||||
// Prevent closing recently opened inactive group.
|
||||
const lastOpenedGroup = val.at(-1)
|
||||
if (lastOpenedGroup === props.item.title)
|
||||
return
|
||||
const isActive = isNavGroupActive(props.item.children, router)
|
||||
|
||||
// Goal of this watcher is to close inactive groups. So don't do anything for active groups.
|
||||
if (isActive)
|
||||
return
|
||||
|
||||
// We won't close group if any of child group is open in current group
|
||||
if (isAnyChildOpen(props.item.children))
|
||||
return
|
||||
isGroupOpen.value = isActive
|
||||
isGroupActive.value = isActive
|
||||
}, { deep: true })
|
||||
|
||||
// ℹ️ Previously instead of below watcher we were using two individual watcher for `isVerticalNavHovered`, `isVerticalNavCollapsed` & `isLessThanOverlayNavBreakpoint`
|
||||
watch(configStore.isVerticalNavMini(isVerticalNavHovered), val => {
|
||||
isGroupOpen.value = val ? false : isGroupActive.value
|
||||
})
|
||||
|
||||
// isGroupOpen.value = value ? false : isGroupActive.value
|
||||
|
||||
// })
|
||||
const isMounted = useMounted()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
v-if="canViewNavMenuGroup(item)"
|
||||
class="nav-group"
|
||||
:class="[
|
||||
{
|
||||
active: isGroupActive,
|
||||
open: isGroupOpen,
|
||||
disabled: item.disable,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="nav-group-label"
|
||||
@click="isGroupOpen = !isGroupOpen"
|
||||
>
|
||||
<Component
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
v-bind="item.icon || layoutConfig.verticalNav.defaultNavItemIconProps"
|
||||
class="nav-item-icon"
|
||||
/>
|
||||
<!--
|
||||
ℹ️ isMounted is workaround of nuxt's hydration issue:
|
||||
https://github.com/vuejs/core/issues/6715
|
||||
-->
|
||||
<Component
|
||||
:is="isMounted ? TransitionGroup : 'div'"
|
||||
name="transition-slide-x"
|
||||
v-bind="!isMounted ? { class: 'd-flex align-center flex-grow-1' } : undefined"
|
||||
>
|
||||
<!-- 👉 Title -->
|
||||
<Component
|
||||
:is=" layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
|
||||
v-bind="getDynamicI18nProps(item.title, 'span')"
|
||||
v-show="!hideTitleAndBadge"
|
||||
key="title"
|
||||
class="nav-item-title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</Component>
|
||||
|
||||
<!-- 👉 Badge -->
|
||||
<Component
|
||||
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
|
||||
v-bind="getDynamicI18nProps(item.badgeContent, 'span')"
|
||||
v-show="!hideTitleAndBadge"
|
||||
v-if="item.badgeContent"
|
||||
key="badge"
|
||||
class="nav-item-badge"
|
||||
:class="item.badgeClass"
|
||||
>
|
||||
{{ item.badgeContent }}
|
||||
</Component>
|
||||
<Component
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
v-show="!hideTitleAndBadge"
|
||||
v-bind="layoutConfig.icons.chevronRight"
|
||||
key="arrow"
|
||||
class="nav-group-arrow"
|
||||
/>
|
||||
</Component>
|
||||
</div>
|
||||
<TransitionExpand>
|
||||
<ul
|
||||
v-show="isGroupOpen"
|
||||
class="nav-group-children"
|
||||
>
|
||||
<Component
|
||||
:is="'children' in child ? 'VerticalNavGroup' : VerticalNavLink"
|
||||
v-for="child in item.children"
|
||||
:key="child.title"
|
||||
:item="child"
|
||||
/>
|
||||
</ul>
|
||||
</TransitionExpand>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-vertical-nav {
|
||||
.nav-group {
|
||||
&-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
193
resources/js/@layouts/components/VerticalNavLayout.vue
Normal file
193
resources/js/@layouts/components/VerticalNavLayout.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script>
|
||||
import { VerticalNav } from '@layouts/components'
|
||||
import { useLayoutConfigStore } from '@layouts/stores/config'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
navItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
verticalNavAttrs: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
const configStore = useLayoutConfigStore()
|
||||
const isOverlayNavActive = ref(false)
|
||||
const isLayoutOverlayVisible = ref(false)
|
||||
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
|
||||
|
||||
|
||||
// ℹ️ This is alternative to below two commented watcher
|
||||
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
|
||||
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
|
||||
|
||||
// watch(isOverlayNavActive, value => {
|
||||
// // Sync layout overlay with overlay nav
|
||||
// isLayoutOverlayVisible.value = value
|
||||
// })
|
||||
// watch(isLayoutOverlayVisible, value => {
|
||||
// // If overlay is closed via click, close hide overlay nav
|
||||
// if (!value) isOverlayNavActive.value = false
|
||||
// })
|
||||
// ℹ️ Hide overlay if user open overlay nav in <md and increase the window width without closing overlay nav
|
||||
watch(windowWidth, () => {
|
||||
if (!configStore.isLessThanOverlayNavBreakpoint && isLayoutOverlayVisible.value)
|
||||
isLayoutOverlayVisible.value = false
|
||||
})
|
||||
|
||||
return () => {
|
||||
const verticalNavAttrs = toRef(props, 'verticalNavAttrs')
|
||||
const { wrapper: verticalNavWrapper, wrapperProps: verticalNavWrapperProps, ...additionalVerticalNavAttrs } = verticalNavAttrs.value
|
||||
|
||||
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(VerticalNav, { isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive, navItems: props.navItems, ...additionalVerticalNavAttrs }, {
|
||||
'nav-header': () => slots['vertical-nav-header']?.(),
|
||||
'before-nav-items': () => slots['before-vertical-nav-items']?.(),
|
||||
})
|
||||
|
||||
|
||||
// 👉 Navbar
|
||||
const navbar = h('header', { class: ['layout-navbar', { 'navbar-blur': configStore.isNavbarBlurEnabled }] }, [
|
||||
h('div', { class: 'navbar-content-container' }, slots.navbar?.({
|
||||
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
|
||||
})),
|
||||
])
|
||||
|
||||
|
||||
// 👉 Content area
|
||||
const main = h('main', { class: 'layout-page-content' }, h('div', { class: 'page-content-container' }, slots.default?.()))
|
||||
|
||||
|
||||
// 👉 Footer
|
||||
const footer = h('footer', { class: 'layout-footer' }, [
|
||||
h('div', { class: 'footer-content-container' }, slots.footer?.()),
|
||||
])
|
||||
|
||||
|
||||
// 👉 Overlay
|
||||
const layoutOverlay = h('div', {
|
||||
class: ['layout-overlay', { visible: isLayoutOverlayVisible.value }],
|
||||
onClick: () => { isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value },
|
||||
})
|
||||
|
||||
return h('div', { class: ['layout-wrapper', ...configStore._layoutClasses] }, [
|
||||
verticalNavWrapper ? h(verticalNavWrapper, verticalNavWrapperProps, { default: () => verticalNav }) : verticalNav,
|
||||
h('div', { class: 'layout-content-wrapper' }, [
|
||||
navbar,
|
||||
main,
|
||||
footer,
|
||||
]),
|
||||
layoutOverlay,
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@configured-variables" as variables;
|
||||
@use "@layouts/styles/placeholders";
|
||||
@use "@layouts/styles/mixins";
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
block-size: 100%;
|
||||
|
||||
.layout-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-block-size: 100dvh;
|
||||
transition: padding-inline-start 0.2s ease-in-out;
|
||||
will-change: padding-inline-start;
|
||||
|
||||
@media screen and (min-width: 1280px) {
|
||||
padding-inline-start: variables.$layout-vertical-nav-width;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: variables.$layout-vertical-nav-navbar-height;
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
.layout-navbar {
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
@include mixins.boxed-content;
|
||||
} @else {
|
||||
.navbar-content-container {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-navbar-sticky .layout-navbar {
|
||||
@extend %layout-navbar-sticky;
|
||||
}
|
||||
|
||||
&.layout-navbar-hidden .layout-navbar {
|
||||
@extend %layout-navbar-hidden;
|
||||
}
|
||||
|
||||
// 👉 Footer
|
||||
.layout-footer {
|
||||
@include mixins.boxed-content;
|
||||
}
|
||||
|
||||
// 👉 Layout overlay
|
||||
.layout-overlay {
|
||||
position: fixed;
|
||||
z-index: variables.$layout-overlay-z-index;
|
||||
background-color: rgb(0 0 0 / 60%);
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease-in-out;
|
||||
will-change: transform;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust right column pl when vertical nav is collapsed
|
||||
&.layout-vertical-nav-collapsed .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
|
||||
}
|
||||
|
||||
// 👉 Content height fixed
|
||||
&.layout-content-height-fixed {
|
||||
.layout-content-wrapper {
|
||||
max-block-size: 100dvh;
|
||||
}
|
||||
|
||||
.layout-page-content {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
> :first-child {
|
||||
max-block-size: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
74
resources/js/@layouts/components/VerticalNavLink.vue
Normal file
74
resources/js/@layouts/components/VerticalNavLink.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { layoutConfig } from '@layouts'
|
||||
import { can } from '@layouts/plugins/casl'
|
||||
import { useLayoutConfigStore } from '@layouts/stores/config'
|
||||
import {
|
||||
getComputedNavLinkToProp,
|
||||
getDynamicI18nProps,
|
||||
isNavLinkActive,
|
||||
} from '@layouts/utils'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const configStore = useLayoutConfigStore()
|
||||
const hideTitleAndBadge = configStore.isVerticalNavMini()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
v-if="can(item.action, item.subject)"
|
||||
class="nav-link"
|
||||
:class="{ disabled: item.disable }"
|
||||
>
|
||||
<Component
|
||||
:is="item.to ? 'RouterLink' : 'a'"
|
||||
v-bind="getComputedNavLinkToProp(item)"
|
||||
:class="{ 'router-link-active router-link-exact-active': isNavLinkActive(item, $router) }"
|
||||
>
|
||||
<Component
|
||||
:is="layoutConfig.app.iconRenderer || 'div'"
|
||||
v-bind="item.icon || layoutConfig.verticalNav.defaultNavItemIconProps"
|
||||
class="nav-item-icon"
|
||||
/>
|
||||
<TransitionGroup name="transition-slide-x">
|
||||
<!-- 👉 Title -->
|
||||
<Component
|
||||
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
|
||||
v-show="!hideTitleAndBadge"
|
||||
key="title"
|
||||
class="nav-item-title"
|
||||
v-bind="getDynamicI18nProps(item.title, 'span')"
|
||||
>
|
||||
{{ item.title }}
|
||||
</Component>
|
||||
|
||||
<!-- 👉 Badge -->
|
||||
<Component
|
||||
:is="layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
|
||||
v-if="item.badgeContent"
|
||||
v-show="!hideTitleAndBadge"
|
||||
key="badge"
|
||||
class="nav-item-badge"
|
||||
:class="item.badgeClass"
|
||||
v-bind="getDynamicI18nProps(item.badgeContent, 'span')"
|
||||
>
|
||||
{{ item.badgeContent }}
|
||||
</Component>
|
||||
</TransitionGroup>
|
||||
</Component>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-vertical-nav {
|
||||
.nav-link a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
39
resources/js/@layouts/components/VerticalNavSectionTitle.vue
Normal file
39
resources/js/@layouts/components/VerticalNavSectionTitle.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { layoutConfig } from '@layouts'
|
||||
import { can } from '@layouts/plugins/casl'
|
||||
import { useLayoutConfigStore } from '@layouts/stores/config'
|
||||
import { getDynamicI18nProps } from '@layouts/utils'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const configStore = useLayoutConfigStore()
|
||||
const shallRenderIcon = configStore.isVerticalNavMini()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
v-if="can(item.action, item.subject)"
|
||||
class="nav-section-title"
|
||||
>
|
||||
<div class="title-wrapper">
|
||||
<Transition
|
||||
name="vertical-nav-section-title"
|
||||
mode="out-in"
|
||||
>
|
||||
<Component
|
||||
:is="shallRenderIcon ? layoutConfig.app.iconRenderer : layoutConfig.app.i18n.enable ? 'i18n-t' : 'span'"
|
||||
:key="shallRenderIcon"
|
||||
:class="shallRenderIcon ? 'placeholder-icon' : 'title-text'"
|
||||
v-bind="{ ...layoutConfig.icons.sectionTitlePlaceholder, ...getDynamicI18nProps(item.heading, 'span') }"
|
||||
>
|
||||
{{ !shallRenderIcon ? item.heading : null }}
|
||||
</Component>
|
||||
</Transition>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
Reference in New Issue
Block a user