first commit

This commit is contained in:
Inshal
2024-05-29 22:34:28 +05:00
commit e63fc41a20
1470 changed files with 174828 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
export { default as HorizontalNav } from './components/HorizontalNav.vue'
export { default as HorizontalNavGroup } from './components/HorizontalNavGroup.vue'
export { default as HorizontalNavLayout } from './components/HorizontalNavLayout.vue'
export { default as HorizontalNavLink } from './components/HorizontalNavLink.vue'
export { default as HorizontalNavPopper } from './components/HorizontalNavPopper.vue'
export { default as TransitionExpand } from './components/TransitionExpand.vue'
export { default as VerticalNav } from './components/VerticalNav.vue'
export { default as VerticalNavGroup } from './components/VerticalNavGroup.vue'
export { default as VerticalNavLayout } from './components/VerticalNavLayout.vue'
export { default as VerticalNavLink } from './components/VerticalNavLink.vue'
export { default as VerticalNavSectionTitle } from './components/VerticalNavSectionTitle.vue'

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,12 @@
export const VNodeRenderer = defineComponent({
name: 'VNodeRenderer',
props: {
nodes: {
type: [Array, Object],
required: true,
},
},
setup(props) {
return () => props.nodes
},
})

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,42 @@
import { breakpointsVuetify } from '@vueuse/core'
import { AppContentLayoutNav, ContentWidth, FooterType, HorizontalNavType, NavbarType } from '@layouts/enums'
export const layoutConfig = {
app: {
title: 'my-layout',
logo: h('img', { src: '/src/assets/logo.svg' }),
contentWidth: ContentWidth.Boxed,
contentLayoutNav: AppContentLayoutNav.Vertical,
overlayNavFromBreakpoint: breakpointsVuetify.md,
// isRTL: false,
i18n: {
enable: true,
},
iconRenderer: h('div'),
},
navbar: {
type: NavbarType.Sticky,
navbarBlur: true,
},
footer: {
type: FooterType.Static,
},
verticalNav: {
isVerticalNavCollapsed: false,
defaultNavItemIconProps: { icon: 'ri-circle-line' },
},
horizontalNav: {
type: HorizontalNavType.Sticky,
transition: 'none',
popoverOffset: 0,
},
icons: {
chevronDown: { icon: 'ri-arrow-down-line' },
chevronRight: { icon: 'ri-arrow-right-line' },
close: { icon: 'ri-close-line' },
verticalNavPinned: { icon: 'ri-record-circle-line' },
verticalNavUnPinned: { icon: 'ri-circle-line' },
sectionTitlePlaceholder: { icon: 'ri-subtract-line' },
},
}

View File

@@ -0,0 +1,23 @@
export const ContentWidth = {
Fluid: 'fluid',
Boxed: 'boxed',
}
export const NavbarType = {
Sticky: 'sticky',
Static: 'static',
Hidden: 'hidden',
}
export const FooterType = {
Sticky: 'sticky',
Static: 'static',
Hidden: 'hidden',
}
export const AppContentLayoutNav = {
Vertical: 'vertical',
Horizontal: 'horizontal',
}
export const HorizontalNavType = {
Sticky: 'sticky',
Static: 'static',
Hidden: 'hidden',
}

View File

@@ -0,0 +1,44 @@
import { layoutConfig } from '@layouts/config'
import { cookieRef, useLayoutConfigStore } from '@layouts/stores/config'
import { _setDirAttr } from '@layouts/utils'
// 🔌 Plugin
export const createLayouts = userConfig => {
return () => {
const configStore = useLayoutConfigStore()
// Non reactive Values
layoutConfig.app.title = userConfig.app?.title ?? layoutConfig.app.title
layoutConfig.app.logo = userConfig.app?.logo ?? layoutConfig.app.logo
layoutConfig.app.overlayNavFromBreakpoint = userConfig.app?.overlayNavFromBreakpoint ?? layoutConfig.app.overlayNavFromBreakpoint
layoutConfig.app.i18n.enable = userConfig.app?.i18n?.enable ?? layoutConfig.app.i18n.enable
layoutConfig.app.iconRenderer = userConfig.app?.iconRenderer ?? layoutConfig.app.iconRenderer
layoutConfig.verticalNav.defaultNavItemIconProps = userConfig.verticalNav?.defaultNavItemIconProps ?? layoutConfig.verticalNav.defaultNavItemIconProps
layoutConfig.icons.chevronDown = userConfig.icons?.chevronDown ?? layoutConfig.icons.chevronDown
layoutConfig.icons.chevronRight = userConfig.icons?.chevronRight ?? layoutConfig.icons.chevronRight
layoutConfig.icons.close = userConfig.icons?.close ?? layoutConfig.icons.close
layoutConfig.icons.verticalNavPinned = userConfig.icons?.verticalNavPinned ?? layoutConfig.icons.verticalNavPinned
layoutConfig.icons.verticalNavUnPinned = userConfig.icons?.verticalNavUnPinned ?? layoutConfig.icons.verticalNavUnPinned
layoutConfig.icons.sectionTitlePlaceholder = userConfig.icons?.sectionTitlePlaceholder ?? layoutConfig.icons.sectionTitlePlaceholder
// Reactive Values (Store)
configStore.$patch({
appContentLayoutNav: cookieRef('appContentLayoutNav', userConfig.app?.contentLayoutNav ?? layoutConfig.app.contentLayoutNav).value,
appContentWidth: cookieRef('appContentWidth', userConfig.app?.contentWidth ?? layoutConfig.app.contentWidth).value,
footerType: cookieRef('footerType', userConfig.footer?.type ?? layoutConfig.footer.type).value,
navbarType: cookieRef('navbarType', userConfig.navbar?.type ?? layoutConfig.navbar.type).value,
isNavbarBlurEnabled: cookieRef('isNavbarBlurEnabled', userConfig.navbar?.navbarBlur ?? layoutConfig.navbar.navbarBlur).value,
isVerticalNavCollapsed: cookieRef('isVerticalNavCollapsed', userConfig.verticalNav?.isVerticalNavCollapsed ?? layoutConfig.verticalNav.isVerticalNavCollapsed).value,
// isAppRTL: userConfig.app?.isRTL ?? config.app.isRTL,
// isLessThanOverlayNavBreakpoint: false,
horizontalNavType: cookieRef('horizontalNavType', userConfig.horizontalNav?.type ?? layoutConfig.horizontalNav.type).value,
})
// _setDirAttr(config.app.isRTL ? 'rtl' : 'ltr')
_setDirAttr(configStore.isAppRTL ? 'rtl' : 'ltr')
}
}
export * from './components'
export { layoutConfig }

View File

@@ -0,0 +1,41 @@
import { useAbility } from '@casl/vue'
/**
* Returns ability result if ACL is configured or else just return true
* We should allow passing string | undefined to can because for admin ability we omit defining action & subject
*
* Useful if you don't know if ACL is configured or not
* Used in @core files to handle absence of ACL without errors
*
* @param {string} action CASL Actions // https://casl.js.org/v4/en/guide/intro#basics
* @param {string} subject CASL Subject // https://casl.js.org/v4/en/guide/intro#basics
*/
export const can = (action, subject) => {
const vm = getCurrentInstance()
if (!vm)
return false
const localCan = vm.proxy && '$can' in vm.proxy
return localCan ? vm.proxy?.$can(action, subject) : true
}
/**
* Check if user can view item based on it's ability
* Based on item's action and subject & Hide group if all of it's children are hidden
* @param {object} item navigation object item
*/
export const canViewNavMenuGroup = item => {
const hasAnyVisibleChild = item.children.some(i => can(i.action, i.subject))
// If subject and action is defined in item => Return based on children visibility (Hide group if no child is visible)
// Else check for ability using provided subject and action along with checking if has any visible child
if (!(item.action && item.subject))
return hasAnyVisibleChild
return can(item.action, item.subject) && hasAnyVisibleChild
}
export const canNavigate = to => {
const ability = useAbility()
return to.matched.some(route => ability.can(route.meta.action, route.meta.subject))
}

View File

@@ -0,0 +1,115 @@
import { AppContentLayoutNav, NavbarType } from '@layouts/enums'
import { injectionKeyIsVerticalNavHovered } from '@layouts/symbols'
import { _setDirAttr } from '@layouts/utils'
// We should not import themeConfig here but in urgency we are doing it for now
import { layoutConfig } from '@themeConfig'
export const namespaceConfig = str => `${layoutConfig.app.title}-${str}`
export const cookieRef = (key, defaultValue) => {
return useCookie(namespaceConfig(key), { default: () => defaultValue })
}
export const useLayoutConfigStore = defineStore('layoutConfig', () => {
const route = useRoute()
// 👉 Navbar Type
const navbarType = ref(layoutConfig.navbar.type)
// 👉 Navbar Type
const isNavbarBlurEnabled = cookieRef('isNavbarBlurEnabled', layoutConfig.navbar.navbarBlur)
// 👉 Vertical Nav Collapsed
const isVerticalNavCollapsed = cookieRef('isVerticalNavCollapsed', layoutConfig.verticalNav.isVerticalNavCollapsed)
// 👉 App Content Width
const appContentWidth = cookieRef('appContentWidth', layoutConfig.app.contentWidth)
// 👉 App Content Layout Nav
const appContentLayoutNav = ref(layoutConfig.app.contentLayoutNav)
watch(appContentLayoutNav, val => {
// If Navbar type is hidden while switching to horizontal nav => Reset it to sticky
if (val === AppContentLayoutNav.Horizontal) {
if (navbarType.value === NavbarType.Hidden)
navbarType.value = NavbarType.Sticky
isVerticalNavCollapsed.value = false
}
})
// 👉 Horizontal Nav Type
const horizontalNavType = ref(layoutConfig.horizontalNav.type)
// 👉 Horizontal Nav Popover Offset
const horizontalNavPopoverOffset = ref(layoutConfig.horizontalNav.popoverOffset)
// 👉 Footer Type
const footerType = ref(layoutConfig.footer.type)
// 👉 Misc
const isLessThanOverlayNavBreakpoint = computed(() => useMediaQuery(`(max-width: ${layoutConfig.app.overlayNavFromBreakpoint}px)`).value)
// 👉 Layout Classes
const _layoutClasses = computed(() => {
const { y: windowScrollY } = useWindowScroll()
return [
`layout-nav-type-${appContentLayoutNav.value}`,
`layout-navbar-${navbarType.value}`,
`layout-footer-${footerType.value}`,
{
'layout-vertical-nav-collapsed': isVerticalNavCollapsed.value
&& appContentLayoutNav.value === 'vertical'
&& !isLessThanOverlayNavBreakpoint.value,
},
{ [`horizontal-nav-${horizontalNavType.value}`]: appContentLayoutNav.value === 'horizontal' },
`layout-content-width-${appContentWidth.value}`,
{ 'layout-overlay-nav': isLessThanOverlayNavBreakpoint.value },
{ 'window-scrolled': unref(windowScrollY) },
route.meta.layoutWrapperClasses ? route.meta.layoutWrapperClasses : null,
]
})
// 👉 RTL
// const isAppRTL = ref(layoutConfig.app.isRTL)
const isAppRTL = ref(false)
watch(isAppRTL, val => {
_setDirAttr(val ? 'rtl' : 'ltr')
})
// 👉 Is Vertical Nav Mini
/*
This function will return true if current state is mini. Mini state means vertical nav is:
- Collapsed
- Isn't hovered by mouse
- nav is not less than overlay breakpoint (hence, isn't overlay menu)
We are getting `isVerticalNavHovered` as param instead of via `inject` because
we are using this in `VerticalNav.vue` component which provide it and I guess because
same component is providing & injecting we are getting undefined error
*/
const isVerticalNavMini = (isVerticalNavHovered = null) => {
const isVerticalNavHoveredLocal = isVerticalNavHovered || inject(injectionKeyIsVerticalNavHovered) || ref(false)
return computed(() => isVerticalNavCollapsed.value && !isVerticalNavHoveredLocal.value && !isLessThanOverlayNavBreakpoint.value)
}
return {
appContentWidth,
appContentLayoutNav,
navbarType,
isNavbarBlurEnabled,
isVerticalNavCollapsed,
horizontalNavType,
horizontalNavPopoverOffset,
footerType,
isLessThanOverlayNavBreakpoint,
isAppRTL,
_layoutClasses,
isVerticalNavMini,
}
})

View File

@@ -0,0 +1,3 @@
.cursor-pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,35 @@
// These are styles which are both common in layout w/ vertical nav & horizontal nav
@use "@layouts/styles/rtl";
@use "@layouts/styles/placeholders";
@use "@layouts/styles/mixins";
@use "@configured-variables" as variables;
html,
body {
min-block-size: 100%;
}
.layout-page-content {
@include mixins.boxed-content(true);
flex-grow: 1;
// TODO: Use grid gutter variable here
padding-block: 1.5rem;
}
.layout-footer {
.footer-content-container {
block-size: variables.$layout-vertical-nav-footer-height;
}
.layout-footer-sticky & {
position: sticky;
inset-block-end: 0;
will-change: transform;
}
.layout-footer-hidden & {
display: none;
}
}

View File

@@ -0,0 +1,10 @@
*,
::before,
::after {
box-sizing: inherit;
background-repeat: no-repeat;
}
html {
box-sizing: border-box;
}

View File

@@ -0,0 +1,28 @@
@use "placeholders";
@use "@configured-variables" as variables;
@mixin rtl {
@if variables.$enable-rtl-styles {
[dir="rtl"] & {
@content;
}
}
}
@mixin boxed-content($nest-selector: false) {
& {
@extend %boxed-content-spacing;
@at-root {
@if $nest-selector == false {
.layout-content-width-boxed#{&} {
@extend %boxed-content;
}
} @else {
.layout-content-width-boxed & {
@extend %boxed-content;
}
}
}
}
}

View File

@@ -0,0 +1,53 @@
// placeholders
@use "@configured-variables" as variables;
%boxed-content {
@at-root #{&}-spacing {
// TODO: Use grid gutter variable here
padding-inline: 1.5rem;
}
inline-size: 100%;
margin-inline: auto;
max-inline-size: variables.$layout-boxed-content-width;
}
%layout-navbar-hidden {
display: none;
}
// We created this placeholder even it is being used in just layout w/ vertical nav because in future we might apply style to both navbar & horizontal nav separately
%layout-navbar-sticky {
position: sticky;
inset-block-start: 0;
// will-change: transform;
// inline-size: 100%;
}
%style-scroll-bar {
/* width */
&::-webkit-scrollbar {
background: rgb(var(--v-theme-surface));
block-size: 8px;
border-end-end-radius: 14px;
border-start-end-radius: 14px;
inline-size: 4px;
}
/* Track */
&::-webkit-scrollbar-track {
background: transparent;
}
/* Handle */
&::-webkit-scrollbar-thumb {
border-radius: 0.5rem;
background: rgb(var(--v-theme-perfect-scrollbar-thumb));
}
&::-webkit-scrollbar-corner {
display: none;
}
}

View File

@@ -0,0 +1,7 @@
@use "./mixins";
.layout-vertical-nav .nav-group-arrow {
@include mixins.rtl {
transform: rotate(180deg);
}
}

View File

@@ -0,0 +1,29 @@
// @use "@styles/style.scss";
// 👉 Vertical nav
$layout-vertical-nav-z-index: 12 !default;
$layout-vertical-nav-width: 260px !default;
$layout-vertical-nav-collapsed-width: 80px !default;
$selector-vertical-nav-mini: '.layout-vertical-nav-collapsed .layout-vertical-nav:not(:hover)';
// 👉 Horizontal nav
$layout-horizontal-nav-z-index: 11 !default;
$layout-horizontal-nav-navbar-height: 64px !default;
// 👉 Navbar
$layout-vertical-nav-navbar-height: 64px !default;
$layout-vertical-nav-navbar-is-contained: true !default;
$layout-vertical-nav-layout-navbar-z-index: 11 !default;
$layout-horizontal-nav-layout-navbar-z-index: 11 !default;
// 👉 Main content
$layout-boxed-content-width: 1440px !default;
// 👉Footer
$layout-vertical-nav-footer-height: 56px !default;
// 👉 Layout overlay
$layout-overlay-z-index: 11 !default;
// 👉 RTL
$enable-rtl-styles: true !default;

View File

@@ -0,0 +1,3 @@
@use "_global";
@use "vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.min.css";
@use "_classes";

View File

@@ -0,0 +1 @@
export const injectionKeyIsVerticalNavHovered = Symbol('isVerticalNavHovered')

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,169 @@
import { layoutConfig } from '@layouts/config'
import { AppContentLayoutNav } from '@layouts/enums'
import { useLayoutConfigStore } from '@layouts/stores/config'
export const openGroups = ref([])
/**
* Return nav link props to use
// @param {Object, String} item navigation routeName or route Object provided in navigation data
*/
export const getComputedNavLinkToProp = computed(() => link => {
const props = {
target: link.target,
rel: link.rel,
}
// If route is string => it assumes string is route name => Create route object from route name
// If route is not string => It assumes it's route object => returns passed route object
if (link.to)
props.to = typeof link.to === 'string' ? { name: link.to } : link.to
else
props.href = link.href
return props
})
/**
* Return route name for navigation link
* If link is string then it will assume it is route-name
* IF link is object it will resolve the object and will return the link
// @param {Object, String} link navigation link object/string
*/
export const resolveNavLinkRouteName = (link, router) => {
if (!link.to)
return null
if (typeof link.to === 'string')
return link.to
return router.resolve(link.to).name
}
/**
* Check if nav-link is active
* @param {object} link nav-link object
*/
export const isNavLinkActive = (link, router) => {
// Matched routes array of current route
const matchedRoutes = router.currentRoute.value.matched
// Check if provided route matches route's matched route
const resolveRoutedName = resolveNavLinkRouteName(link, router)
if (!resolveRoutedName)
return false
return matchedRoutes.some(route => {
return route.name === resolveRoutedName || route.meta.navActiveLink === resolveRoutedName
})
}
/**
* Check if nav group is active
* @param {Array} children Group children
*/
export const isNavGroupActive = (children, router) => children.some(child => {
// If child have children => It's group => Go deeper(recursive)
if ('children' in child)
return isNavGroupActive(child.children, router)
// else it's link => Check for matched Route
return isNavLinkActive(child, router)
})
/**
* Change `dir` attribute based on direction
* @param dir 'ltr' | 'rtl'
*/
export const _setDirAttr = dir => {
// Check if document exists for SSR
if (typeof document !== 'undefined')
document.documentElement.setAttribute('dir', dir)
}
/**
* Return dynamic i18n props based on i18n plugin is enabled or not
* @param key i18n translation key
* @param tag tag to wrap the translation with
*/
export const getDynamicI18nProps = (key, tag = 'span') => {
if (!layoutConfig.app.i18n.enable)
return {}
return {
keypath: key,
tag,
scope: 'global',
}
}
export const switchToVerticalNavOnLtOverlayNavBreakpoint = () => {
const configStore = useLayoutConfigStore()
/*
This is flag will hold nav type need to render when switching between lgAndUp from mdAndDown window width
Requirement: When we nav is set to `horizontal` and we hit the `mdAndDown` breakpoint nav type shall change to `vertical` nav
Now if we go back to `lgAndUp` breakpoint from `mdAndDown` how we will know which was previous nav type in large device?
Let's assign value of `appContentLayoutNav` as default value of lgAndUpNav. Why 🤔?
If template is viewed in lgAndUp
We will assign `appContentLayoutNav` value to `lgAndUpNav` because at this point both constant is same
Hence, for `lgAndUpNav` it will take value from theme config file
else
It will always show vertical nav and if user increase the window width it will fallback to `appContentLayoutNav` value
But `appContentLayoutNav` will be value set in theme config file
*/
const lgAndUpNav = ref(configStore.appContentLayoutNav)
/*
There might be case where we manually switch from vertical to horizontal nav and vice versa in `lgAndUp` screen
So when user comes back from `mdAndDown` to `lgAndUp` we can set updated nav type
For this we need to update the `lgAndUpNav` value if screen is `lgAndUp`
*/
watch(() => configStore.appContentLayoutNav, value => {
if (!configStore.isLessThanOverlayNavBreakpoint)
lgAndUpNav.value = value
})
/*
This is layout switching part
If it's `mdAndDown` => We will use vertical nav no matter what previous nav type was
Or if it's `lgAndUp` we need to switch back to `lgAndUp` nav type. For this we will tracker property `lgAndUpNav`
*/
watch(() => configStore.isLessThanOverlayNavBreakpoint, val => {
configStore.appContentLayoutNav = val ? AppContentLayoutNav.Vertical : lgAndUpNav.value
}, { immediate: true })
}
/**
* Convert Hex color to rgb
* @param hex
*/
export const hexToRgb = hex => {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b
})
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? `${Number.parseInt(result[1], 16)},${Number.parseInt(result[2], 16)},${Number.parseInt(result[3], 16)}` : null
}
/**
*RGBA color to Hex color with / without opacity
*/
export const rgbaToHex = (rgba, forceRemoveAlpha = false) => {
return (`#${rgba
.replace(/^rgba?\(|\s+|\)$/g, '') // Get's rgba / rgb string values
.split(',') // splits them at ","
.filter((string, index) => !forceRemoveAlpha || index !== 3)
.map(string => Number.parseFloat(string)) // Converts them to numbers
.map((number, index) => (index === 3 ? Math.round(number * 255) : number)) // Converts alpha to 255 number
.map(number => number.toString(16)) // Converts numbers to hex
.map(string => (string.length === 1 ? `0${string}` : string)) // Adds 0 when length of one number is 1
.join('')}`)
}