initial commit

This commit is contained in:
2026-06-23 13:28:38 +07:00
commit 966ed115b2
590 changed files with 111412 additions and 0 deletions
BIN
View File
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
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 TopNavLayout } from './components/TopNavLayout.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'
@@ -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>
@@ -0,0 +1,109 @@
<script setup>
import { useLayouts } from '@layouts'
import {
HorizontalNavLink,
HorizontalNavPopper,
} from '@layouts/components'
import { config } from '@layouts/config'
import { canViewNavMenuGroup } from '@layouts/plugins/casl'
import { 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 { dynamicI18nProps, isAppRtl } = useLayouts()
const isGroupActive = ref(false)
watch(() => route.path, () => {
const isActive = isNavGroupActive(props.item.children, router)
isGroupActive.value = isActive
}, { immediate: true })
</script>
<template>
<HorizontalNavPopper
v-if="canViewNavMenuGroup(item)"
:is-rtl="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="config.app.iconRenderer || 'div'"
class="nav-item-icon"
v-bind="item.icon || config.verticalNav.defaultNavItemIconProps"
/>
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
v-bind="dynamicI18nProps(item.title, 'span')"
class="nav-item-title"
>
{{ item.title }}
</Component>
<Component
v-bind="config.icons.chevronDown"
:is="config.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-x: hidden;
overflow-y: auto;
}
}
}
}
</style>
@@ -0,0 +1,176 @@
<script setup>
import { HorizontalNav } from '@layouts/components'
// import { useLayouts } from '@layouts'
import { useLayouts } from '@layouts/composable/useLayouts'
const props = defineProps({
navItems: {
type: null,
required: true,
},
})
const { y: windowScrollY } = useWindowScroll()
const { width: windowWidth } = useWindowSize()
const router = useRouter()
const shallShowPageLoading = ref(false)
router.beforeEach(() => {
shallShowPageLoading.value = true
})
router.afterEach(() => {
shallShowPageLoading.value = false
})
const {
_layoutClasses: layoutClasses,
isNavbarBlurEnabled,
} = useLayouts()
</script>
<template>
<div
class="layout-wrapper"
:class="layoutClasses(windowWidth, windowScrollY)"
>
<div
class="layout-navbar-and-nav-container"
:class="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">
<template v-if="$slots['content-loading']">
<template v-if="shallShowPageLoading">
<slot name="content-loading" />
</template>
<template v-else>
<slot />
</template>
</template>
<template v-else>
<slot />
</template>
</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: calc(var(--vh, 1vh) * 100);
.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: calc(var(--vh) * 100);
.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>
@@ -0,0 +1,62 @@
<script setup>
import { useLayouts } from '@layouts'
import { config } from '@layouts/config'
import { can } from '@layouts/plugins/casl'
import {
getComputedNavLinkToProp,
isNavLinkActive,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
isSubItem: {
type: Boolean,
required: false,
default: false,
},
})
const { dynamicI18nProps } = useLayouts()
</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="config.app.iconRenderer || 'div'"
class="nav-item-icon"
v-bind="item.icon || config.verticalNav.defaultNavItemIconProps"
/>
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
class="nav-item-title"
v-bind="dynamicI18nProps(item.title, 'span')"
>
{{ item.title }}
</Component>
</Component>
</li>
</template>
<style lang="scss">
.layout-horizontal-nav {
.nav-link a {
display: flex;
align-items: center;
}
}
</style>
@@ -0,0 +1,166 @@
<script setup>
import {
computePosition,
flip,
shift,
} from '@floating-ui/dom'
import { useLayouts } from '@layouts/composable/useLayouts'
import { config } from '@layouts/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 refPopperContainer = ref()
const refPopper = ref()
const popperContentStyles = ref({
left: '0px',
top: '0px',
// strategy: 'fixed',
})
const updatePopper = async () => {
const { x, y } = await computePosition(refPopperContainer.value, refPopper.value, {
placement: props.popperInlineEnd ? props.isRtl ? 'left-start' : 'right-start' : 'bottom-start',
middleware: [
flip({ boundary: document.querySelector('body') }),
shift({ boundary: document.querySelector('body') }),
],
// strategy: 'fixed',
})
popperContentStyles.value.left = `${ x }px`
popperContentStyles.value.top = `${ y }px`
}
until(config.horizontalNav.type).toMatch(type => type === 'static').then(() => {
useEventListener('scroll', updatePopper)
// strategy: 'fixed',
})
const isContentShown = ref(false)
const showContent = () => {
isContentShown.value = true
updatePopper()
}
const hideContent = () => {
isContentShown.value = false
}
onMounted(updatePopper)
const { isAppRtl, appContentWidth } = useLayouts()
watch([
isAppRtl,
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>
@@ -0,0 +1,148 @@
<script>
import { useLayouts } from '@layouts'
export default defineComponent({
setup(props, { slots }) {
const { y: windowScrollY } = useWindowScroll()
const { width: windowWidth } = useWindowSize()
const { _layoutClasses: layoutClasses, isNavbarBlurEnabled } = useLayouts()
const router = useRouter()
const shallShowPageLoading = ref(false)
return () => {
// 👉 Navbar
const navbar = h('header', { class: ['layout-navbar', { 'navbar-blur': isNavbarBlurEnabled.value }] }, [
h('div', { class: 'navbar-content-container' }, slots.navbar?.()),
])
// 👉 Content area
let mainChildren = slots.default?.()
// 💡 Only show loading and attach `beforeEach` & `afterEach` hooks if `content-loading` slot is used
if (slots['content-loading']) {
router.beforeEach(() => {
console.info('setting to true')
shallShowPageLoading.value = true
})
router.afterEach(() => {
console.info('setting to false')
shallShowPageLoading.value = false
})
mainChildren = shallShowPageLoading.value ? slots['content-loading']?.() : slots.default?.()
}
const main = h('main', { class: 'layout-page-content' }, h('div', { class: 'page-content-container' }, mainChildren))
return h('div', { class: ['layout-wrapper', ...layoutClasses.value(windowWidth.value, windowScrollY.value)] }, [
h('div', {}, [
navbar,
main,
]),
])
}
},
})
</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: calc(var(--vh, 1vh) * 100);
transition: padding-inline-start 0.2s ease-in-out;
will-change: padding-inline-start;
}
.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;
}
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-width;
}
// 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: calc(var(--vh) * 100);
}
.layout-page-content {
display: flex;
overflow: hidden;
.page-content-container {
inline-size: 100%;
> :first-child {
max-block-size: 100%;
overflow-y: auto;
}
}
}
}
}
</style>
@@ -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>
@@ -0,0 +1,12 @@
export const VNodeRenderer = defineComponent({
name: 'VNodeRenderer',
props: {
nodes: {
type: [Array, Object],
required: true,
},
},
setup(props) {
return () => props.nodes
},
})
@@ -0,0 +1,226 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { VNodeRenderer } from './VNodeRenderer'
import {
injectionKeyIsVerticalNavHovered,
useLayouts,
} from '@layouts'
import {
VerticalNavGroup,
VerticalNavLink,
VerticalNavSectionTitle,
} from '@layouts/components'
import { config } from '@layouts/config'
const props = defineProps({
tag: {
type: [
String,
null,
],
required: false,
default: 'aside',
},
navItems: {
type: null,
required: true,
},
isOverlayNavActive: {
type: Boolean,
required: true,
},
toggleIsOverlayNavActive: {
type: Function,
required: true,
},
})
const refNav = ref()
const { width: windowWidth } = useWindowSize()
const isHovered = useElementHover(refNav)
provide(injectionKeyIsVerticalNavHovered, isHovered)
const {
isVerticalNavCollapsed: isCollapsed,
isLessThanOverlayNavBreakpoint,
isVerticalNavMini,
isAppRtl,
} = useLayouts()
const hideTitleAndIcon = isVerticalNavMini(windowWidth, isHovered)
const resolveNavItemComponent = item => {
if ('heading' in item)
return VerticalNavSectionTitle
if ('children' in item)
return VerticalNavGroup
return VerticalNavLink
}
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
}
</script>
<template>
<Component
:is="props.tag"
ref="refNav"
class="layout-vertical-nav"
:class="[
{
'overlay-nav': isLessThanOverlayNavBreakpoint(windowWidth),
'hovered': isHovered,
'visible': isOverlayNavActive,
'scrolled': isVerticalNavScrolled,
},
]"
>
<!-- 👉 Header -->
<div class="nav-header">
<slot name="nav-header">
<a
href="/"
class="app-logo d-flex align-center gap-x-3 app-title-wrapper"
>
<VNodeRenderer :nodes="config.app.logo" />
<Transition name="vertical-nav-app-title">
<h1
v-show="!hideTitleAndIcon"
class="app-title font-weight-bold text-capitalize leading-normal text-xl"
>
{{ config.app.title }}
</h1>
</Transition>
</a>
<!-- 👉 Vertical nav actions -->
<!-- Show toggle collapsible in >md and close button in <md -->
<template v-if="!isLessThanOverlayNavBreakpoint(windowWidth)">
<Component
:is="config.app.iconRenderer || 'div'"
v-show="isCollapsed && !hideTitleAndIcon"
class="header-action"
v-bind="config.icons.verticalNavUnPinned"
@click="isCollapsed = !isCollapsed"
/>
<Component
:is="config.app.iconRenderer || 'div'"
v-show="!isCollapsed && !hideTitleAndIcon"
class="header-action"
v-bind="config.icons.verticalNavPinned"
@click="isCollapsed = !isCollapsed"
/>
</template>
<template v-else>
<Component
:is="config.app.iconRenderer || 'div'"
class="header-action"
v-bind="config.icons.close"
@click="toggleIsOverlayNavActive(false)"
/>
</template>
</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="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>
</Component>
</template>
<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: transform 0.25s ease-in-out, 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;
}
}
.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;
}
}
// 👉 Overlay nav
&.overlay-nav {
&:not(.visible) {
transform: translateX(-#{variables.$layout-vertical-nav-width});
@include mixins.rtl {
transform: translateX(variables.$layout-vertical-nav-width);
}
}
}
}
</style>
@@ -0,0 +1,180 @@
<script setup>
import {
injectionKeyIsVerticalNavHovered,
useLayouts,
} from '@layouts'
import {
TransitionExpand,
VerticalNavLink,
} from '@layouts/components'
import { config } from '@layouts/config'
import { canViewNavMenuGroup } from '@layouts/plugins/casl'
import {
isNavGroupActive,
openGroups,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
})
defineOptions({ name: 'VerticalNavGroup' })
const route = useRoute()
const router = useRouter()
const { width: windowWidth } = useWindowSize()
const { isVerticalNavMini, dynamicI18nProps } = useLayouts()
const hideTitleAndBadge = isVerticalNavMini(windowWidth)
const isVerticalNavHovered = inject(injectionKeyIsVerticalNavHovered, ref(false))
// })
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(() => 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 && !isVerticalNavMini(windowWidth, 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)
// 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(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(isVerticalNavMini(windowWidth, isVerticalNavHovered), val => {
isGroupOpen.value = val ? false : isGroupActive.value
})
</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="config.app.iconRenderer || 'div'"
v-bind="item.icon || config.verticalNav.defaultNavItemIconProps"
class="nav-item-icon"
/>
<TransitionGroup name="transition-slide-x">
<!-- 👉 Title -->
<Component
:is=" config.app.enableI18n ? 'i18n-t' : 'span'"
v-bind="dynamicI18nProps(item.title, 'span')"
v-show="!hideTitleAndBadge"
key="title"
class="nav-item-title"
>
{{ item.title }}
</Component>
<!-- 👉 Badge -->
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
v-bind="dynamicI18nProps(item.badgeContent, 'span')"
v-show="!hideTitleAndBadge"
v-if="item.badgeContent"
key="badge"
class="nav-item-badge"
:class="item.badgeClass"
>
{{ item.badgeContent }}
</Component>
<Component
:is="config.app.iconRenderer || 'div'"
v-show="!hideTitleAndBadge"
v-bind="config.icons.chevronRight"
key="arrow"
class="nav-group-arrow"
/>
</TransitionGroup>
</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>
@@ -0,0 +1,211 @@
<script>
import { useLayouts } from '@layouts'
import { VerticalNav } from '@layouts/components'
export default defineComponent({
props: {
navItems: {
type: Array,
required: true,
},
verticalNavAttrs: {
type: Object,
default: () => ({}),
},
},
setup(props, { slots }) {
const { y: windowScrollY } = useWindowScroll()
const { width: windowWidth } = useWindowSize()
const { _layoutClasses: layoutClasses, isLessThanOverlayNavBreakpoint, isNavbarBlurEnabled } = useLayouts()
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, value => {
if (!isLessThanOverlayNavBreakpoint.value(value) && isLayoutOverlayVisible.value)
isLayoutOverlayVisible.value = false
})
const router = useRouter()
const shallShowPageLoading = ref(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': isNavbarBlurEnabled.value }] }, [
h('div', { class: 'navbar-content-container' }, slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
})),
])
// 👉 Content area
let mainChildren = slots.default?.()
// 💡 Only show loading and attach `beforeEach` & `afterEach` hooks if `content-loading` slot is used
if (slots['content-loading']) {
router.beforeEach(() => {
console.info('setting to true')
shallShowPageLoading.value = true
})
router.afterEach(() => {
console.info('setting to false')
shallShowPageLoading.value = false
})
mainChildren = shallShowPageLoading.value ? slots['content-loading']?.() : slots.default?.()
}
const main = h('main', { class: 'layout-page-content' }, h('div', { class: 'page-content-container' }, mainChildren))
// 👉 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', ...layoutClasses.value(windowWidth.value, windowScrollY.value)] }, [
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: calc(var(--vh, 1vh) * 100);
transition: padding-inline-start 0.2s ease-in-out;
will-change: padding-inline-start;
}
.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;
}
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-width;
}
// 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: calc(var(--vh) * 100);
}
.layout-page-content {
display: flex;
overflow: hidden;
.page-content-container {
inline-size: 100%;
> :first-child {
max-block-size: 100%;
overflow-y: auto;
}
}
}
}
}
</style>
@@ -0,0 +1,74 @@
<script setup>
import { useLayouts } from '@layouts'
import { config } from '@layouts/config'
import { can } from '@layouts/plugins/casl'
import {
getComputedNavLinkToProp,
isNavLinkActive,
} from '@layouts/utils'
const props = defineProps({
item: {
type: null,
required: true,
},
})
const { width: windowWidth } = useWindowSize()
const { isVerticalNavMini, dynamicI18nProps } = useLayouts()
const hideTitleAndBadge = isVerticalNavMini(windowWidth)
</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="config.app.iconRenderer || 'div'"
v-bind="item.icon || config.verticalNav.defaultNavItemIconProps"
class="nav-item-icon"
/>
<TransitionGroup name="transition-slide-x">
<!-- 👉 Title -->
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
v-show="!hideTitleAndBadge"
key="title"
class="nav-item-title"
v-bind="dynamicI18nProps(item.title, 'span')"
>
{{ item.title }}
</Component>
<!-- 👉 Badge -->
<Component
:is="config.app.enableI18n ? 'i18n-t' : 'span'"
v-if="item.badgeContent"
v-show="!hideTitleAndBadge"
key="badge"
class="nav-item-badge"
:class="item.badgeClass"
v-bind="dynamicI18nProps(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>
@@ -0,0 +1,40 @@
<script setup>
import { useLayouts } from '@layouts'
import { config } from '@layouts/config'
import { can } from '@layouts/plugins/casl'
const props = defineProps({
item: {
type: null,
required: true,
},
})
const { isVerticalNavMini, dynamicI18nProps } = useLayouts()
const { width: windowWidth } = useWindowSize()
const shallRenderIcon = isVerticalNavMini(windowWidth)
</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"
>
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<Component
:is="shallRenderIcon ? config.app.iconRenderer : config.app.enableI18n ? 'i18n-t' : 'span'"
:key="shallRenderIcon"
:class="shallRenderIcon ? 'placeholder-icon' : 'title-text'"
v-bind="{ ...config.icons.sectionTitlePlaceholder, ...dynamicI18nProps(item.heading, 'span') }"
v-text="!shallRenderIcon ? item.heading : null"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
</Transition>
</div>
</li>
</template>
@@ -0,0 +1,14 @@
// Thanks: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
export const useDynamicVhCssProperty = () => {
const vh = ref(0)
const updateVh = () => {
vh.value = window.innerHeight * 0.01
document.documentElement.style.setProperty('--vh', `${vh.value}px`)
}
tryOnBeforeMount(() => {
updateVh()
useEventListener('resize', updateVh)
})
}
@@ -0,0 +1,204 @@
import { AppContentLayoutNav, NavbarType } from '../enums'
import { config } from '@layouts/config'
import { injectionKeyIsVerticalNavHovered } from '@layouts'
export const useLayouts = () => {
const navbarType = computed({
get() {
return config.navbar.type.value
},
set(value) {
config.navbar.type.value = value
},
})
const isNavbarBlurEnabled = computed({
get() {
return config.navbar.navbarBlur.value
},
set(value) {
config.navbar.navbarBlur.value = value
localStorage.setItem(`${config.app.title}-navbarBlur`, value.toString())
},
})
const _setAppDir = dir => {
document.documentElement.setAttribute('dir', dir)
}
const footerType = computed({
get() {
return config.footer.type.value
},
set(value) {
config.footer.type.value = value
},
})
const isVerticalNavCollapsed = computed({
get() {
return config.verticalNav.isVerticalNavCollapsed.value
},
set(val) {
config.verticalNav.isVerticalNavCollapsed.value = val
localStorage.setItem(`${config.app.title}-isVerticalNavCollapsed`, val.toString())
},
})
const appContentWidth = computed({
get() {
return config.app.contentWidth.value
},
set(val) {
config.app.contentWidth.value = val
localStorage.setItem(`${config.app.title}-contentWidth`, val.toString())
},
})
const appContentLayoutNav = computed({
get() {
return config.app.contentLayoutNav.value
},
set(val) {
config.app.contentLayoutNav.value = 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
}
},
})
const horizontalNavType = computed({
get() {
return config.horizontalNav.type.value
},
set(value) {
config.horizontalNav.type.value = value
},
})
const isLessThanOverlayNavBreakpoint = computed(() => {
return windowWidth => unref(windowWidth) < config.app.overlayNavFromBreakpoint
})
const _layoutClasses = computed(() => (windowWidth, windowScrollY) => {
const route = useRoute()
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(windowWidth),
},
{ [`horizontal-nav-${horizontalNavType.value}`]: appContentLayoutNav.value === 'horizontal' },
`layout-content-width-${appContentWidth.value}`,
{ 'layout-overlay-nav': isLessThanOverlayNavBreakpoint.value(windowWidth) },
{ 'window-scrolled': unref(windowScrollY) },
route.meta.layoutWrapperClasses ? route.meta.layoutWrapperClasses : null,
]
})
const switchToVerticalNavOnLtOverlayNavBreakpoint = windowWidth => {
/*
️ 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(appContentLayoutNav.value)
/*
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(appContentLayoutNav, value => {
if (!isLessThanOverlayNavBreakpoint.value(windowWidth))
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(() => isLessThanOverlayNavBreakpoint.value(windowWidth), val => {
if (!val)
appContentLayoutNav.value = lgAndUpNav.value
else
appContentLayoutNav.value = AppContentLayoutNav.Vertical
}, { immediate: true })
}
/*
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 = (windowWidth, isVerticalNavHovered = null) => {
const isVerticalNavHoveredLocal = isVerticalNavHovered || inject(injectionKeyIsVerticalNavHovered) || ref(false)
return computed(() => isVerticalNavCollapsed.value && !isVerticalNavHoveredLocal.value && !isLessThanOverlayNavBreakpoint.value(unref(windowWidth)))
}
const dynamicI18nProps = computed(() => (key, tag = 'span') => {
if (config.app.enableI18n) {
return {
keypath: key,
tag,
scope: 'global',
}
}
return {}
})
const isAppRtl = computed({
get() {
return config.app.isRtl.value
},
set(value) {
config.app.isRtl.value = value
localStorage.setItem(`${config.app.title}-isRtl`, value.toString())
_setAppDir(value ? 'rtl' : 'ltr')
},
})
return {
navbarType,
isNavbarBlurEnabled,
footerType,
isVerticalNavCollapsed,
appContentWidth,
appContentLayoutNav,
horizontalNavType,
isLessThanOverlayNavBreakpoint,
_layoutClasses,
switchToVerticalNavOnLtOverlayNavBreakpoint,
isVerticalNavMini,
dynamicI18nProps,
isAppRtl,
_setAppDir,
}
}
+36
View File
@@ -0,0 +1,36 @@
import { breakpointsVuetify } from '@vueuse/core'
import { AppContentLayoutNav, ContentWidth, FooterType, NavbarType } from '@layouts/enums'
export const config = {
app: {
title: 'title',
logo: h('img', { src: '/src/assets/logo.svg' }),
// logo: () => h('img', { src: 'assets/colored-logo.png' }, null),
contentWidth: ref(ContentWidth.Boxed),
contentLayoutNav: ref(AppContentLayoutNav.Vertical),
overlayNavFromBreakpoint: breakpointsVuetify.md,
enableI18n: true,
isRtl: ref(false),
},
navbar: {
type: ref(NavbarType.Sticky),
navbarBlur: ref(true),
},
footer: { type: ref(FooterType.Static) },
verticalNav: {
isVerticalNavCollapsed: ref(false),
defaultNavItemIconProps: { icon: 'tabler-circle' },
},
horizontalNav: {
type: ref('sticky'),
},
icons: {
chevronDown: { icon: 'tabler-chevron-down' },
chevronRight: { icon: 'tabler-chevron-right' },
close: { icon: 'tabler-x' },
verticalNavPinned: { icon: 'tabler-circle-dot' },
verticalNavUnPinned: { icon: 'tabler-circle' },
sectionTitlePlaceholder: { icon: 'tabler-minus' },
},
}
+18
View File
@@ -0,0 +1,18 @@
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',
}
+52
View File
@@ -0,0 +1,52 @@
import { useDynamicVhCssProperty } from './composable/useDynamicVhCssProperty'
import { config } from './config'
import { ContentWidth } from './enums'
import { useLayouts } from '@layouts'
const { _setAppDir } = useLayouts()
// 🔌 Plugin
export const createLayouts = userConfig => {
const localStorageIsRtl = localStorage.getItem(`${userConfig.app.title}-isRtl`)
const localStorageIsVerticalNavCollapsed = localStorage.getItem(`${userConfig.app.title}-isVerticalNavCollapsed`)
const localStorageContentWidth = (() => {
const storageValue = localStorage.getItem(`${userConfig.app.title}-contentWidth`)
return Object.values(ContentWidth).find(v => v === storageValue)
})()
const localStorageNavbarBlur = localStorage.getItem(`${userConfig.app.title}-navbarBlur`)
config.app.title = userConfig.app.title
config.app.logo = userConfig.app.logo
config.app.contentWidth.value = localStorageContentWidth || userConfig.app.contentWidth
config.app.contentLayoutNav.value = userConfig.app.contentLayoutNav
config.app.overlayNavFromBreakpoint = userConfig.app.overlayNavFromBreakpoint
config.app.enableI18n = userConfig.app.enableI18n
config.app.isRtl.value = localStorageIsRtl ? JSON.parse(localStorageIsRtl) : userConfig.app.isRtl
config.app.iconRenderer = userConfig.app.iconRenderer
config.navbar.type.value = userConfig.navbar.type
config.navbar.navbarBlur.value = localStorageNavbarBlur ? JSON.parse(localStorageNavbarBlur) : userConfig.navbar.navbarBlur
config.footer.type.value = userConfig.footer.type
config.verticalNav.isVerticalNavCollapsed.value
= localStorageIsVerticalNavCollapsed
? JSON.parse(localStorageIsVerticalNavCollapsed)
: userConfig.verticalNav.isVerticalNavCollapsed
config.verticalNav.defaultNavItemIconProps = userConfig.verticalNav.defaultNavItemIconProps
config.horizontalNav.type.value = userConfig.horizontalNav.type
config.icons.chevronDown = userConfig.icons.chevronDown
config.icons.chevronRight = userConfig.icons.chevronRight
config.icons.close = userConfig.icons.close
config.icons.verticalNavPinned = userConfig.icons.verticalNavPinned
config.icons.verticalNavUnPinned = userConfig.icons.verticalNavUnPinned
config.icons.sectionTitlePlaceholder = userConfig.icons.sectionTitlePlaceholder
return () => {
useDynamicVhCssProperty()
_setAppDir(config.app.isRtl.value ? 'rtl' : 'ltr')
}
}
export const injectionKeyIsVerticalNavHovered = Symbol('isVerticalNavHovered')
export * from './components'
export { useLayouts } from './composable/useLayouts'
+39
View File
@@ -0,0 +1,39 @@
import ability from '@/plugins/casl/ability'
/**
* 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 => {
return to.matched.some(route => ability.can(route.meta.action, route.meta.subject))
}
@@ -0,0 +1,3 @@
.cursor-pointer {
cursor: pointer;
}
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
*,
::before,
::after {
box-sizing: inherit;
background-repeat: no-repeat;
}
html {
box-sizing: border-box;
}
+28
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;
}
}
}
}
}
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
@use "./mixins";
.layout-vertical-nav .nav-group-arrow {
@include mixins.rtl {
transform: rotate(180deg);
}
}
@@ -0,0 +1,28 @@
// @use "@styles/style.scss";
// 👉 Vertical nav
$layout-vertical-nav-z-index: 12 !default;
$layout-vertical-nav-width: 260px !default;
$layout-vertical-nav-collapsed-width: 84px !default;
// 👉 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;
+3
View File
@@ -0,0 +1,3 @@
@use "_global";
@use "vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.min.css";
@use "_classes";
+100
View File
@@ -0,0 +1,100 @@
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)
})
/**
* 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 ? `${parseInt(result[1], 16)},${parseInt(result[2], 16)},${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 => 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('')}`)
}