This commit is contained in:
2025-05-24 01:49:48 +09:00
commit 62abbcf4eb
2376 changed files with 325522 additions and 0 deletions

View File

@@ -0,0 +1,366 @@
<template>
<div class="auth-wrapper">
<slot />
</div>
</template>
<style lang="scss">
.auth-wrapper-inner {
overflow: hidden !important;
height: 100%;
padding: 0;
margin: 0;
&.is-gapless:not(:last-child) {
margin-bottom: 0 !important;
}
&.is-single {
background: var(--widget-grey);
min-height: 100vh;
}
.hero-banner {
background: var(--widget-grey);
img {
max-width: 550px;
margin: 0 auto;
}
}
.hero-heading {
position: relative;
max-width: 360px;
width: 100%;
margin: 0 auto;
padding: 20px 0 0;
.auth-logo {
display: flex;
align-items: center;
justify-content: space-between;
.dark-mode {
transform: scale(0.6);
z-index: 2;
margin-inline-start: 0 !important;
}
.iconify {
height: 42px;
width: 42px;
}
.top-logo {
height: 42px;
}
}
}
.hero {
&.is-white {
background: var(--white);
}
.hero-body {
.login {
padding: 10px 0;
}
.auth-content {
max-width: 320px;
width: 100%;
margin: 0 auto;
margin-top: -40px;
margin-bottom: 40px;
h2 {
font-size: 2rem;
font-family: var(--font);
line-height: 1;
}
p {
font-size: 1rem;
margin-bottom: 8px;
color: var(--muted-grey);
}
a {
font-size: 0.9rem;
font-family: var(--font-alt);
font-weight: 500;
color: var(--primary);
}
}
.auth-form-wrapper {
max-width: 320px;
width: 100%;
margin: 0 auto;
}
}
}
.forgot-link {
margin-top: 10px;
a {
font-family: var(--font-alt);
font-size: 0.9rem;
color: var(--light-text);
transition: color 0.3s;
&:hover,
&:focus {
color: var(--primary);
}
}
}
.setting-item {
display: flex;
align-items: center;
padding: 10px 0;
.setting-meta {
font-family: var(--font);
color: var(--light-text);
margin-inline-start: 8px;
}
}
.v-button {
min-height: 44px;
}
}
.is-dark {
.auth-wrapper-inner {
.hero-banner {
background: color-mix(in oklab, var(--dark-sidebar), white 4%);
}
.hero {
&.is-white {
background: color-mix(in oklab, var(--dark-sidebar), black 4%);
}
.hero-body {
.auth-content {
h2 {
color: var(--dark-dark-text);
}
a {
color: var(--primary);
}
}
}
}
.forgot-link {
a:hover {
color: var(--primary);
}
}
}
}
.auth-nav {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 80px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
z-index: 1;
.left,
.right {
display: flex;
align-items: center;
width: 20%;
}
.right {
justify-content: flex-end;
.dark-mode {
transform: scale(0.7);
}
}
.center {
flex-grow: 2;
a {
display: flex;
justify-content: center;
align-items: center;
img {
display: block;
width: 100%;
max-width: 50px;
}
}
}
}
.auth-wrapper-inner {
.single-form-wrap {
min-height: 690px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
.inner-wrap {
width: 100%;
max-width: 400px;
margin: 40px auto 0;
.auth-head {
max-width: 320px;
width: 100%;
margin: 0 auto;
margin-bottom: 20px;
text-align: center;
h2 {
font-size: 2rem;
font-family: var(--font);
line-height: 1;
}
p {
font-size: 1rem;
margin-bottom: 8px;
color: var(--muted-grey);
}
a {
font-size: 0.9rem;
font-family: var(--font-alt);
font-weight: 500;
color: var(--primary);
}
}
.form-card {
background: var(--white);
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
border-radius: 10px;
padding: 50px;
margin-bottom: 16px;
.v-button {
margin-top: 10px;
}
}
}
}
}
.is-dark {
.auth-wrapper-inner {
&.is-single {
background: color-mix(in oklab, var(--dark-sidebar), white 4%);
.single-form-wrap {
.inner-wrap {
.auth-head {
h2 {
color: var(--dark-dark-text);
}
a {
color: var(--primary);
}
}
.form-card {
background: color-mix(in oklab, var(--dark-sidebar), black 4%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
}
}
}
}
}
}
@media only screen and (width <= 767px) {
.avatar-carousel {
&.resized-mobile {
max-width: 300px;
}
.slick-custom {
display: none !important;
}
.image-wrapper img {
height: auto;
}
}
.auth-wrapper-inner {
.hero {
.hero-body {
.auth-content {
text-align: center !important;
}
}
}
.single-form-wrap {
.inner-wrap {
.form-card {
padding: 40px;
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.modern-login {
.top-logo {
.iconify {
height: 60px;
width: 60px;
}
}
.dark-mode {
top: -58px;
inset-inline-end: 30%;
}
.columns {
display: flex;
height: 100vh;
}
}
.auth-wrapper-inner {
.hero {
.hero-body {
.auth-content {
text-align: center !important;
}
}
}
}
.signup-columns {
max-width: 460px;
margin: 0 auto;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import type { LandingFooterColumn, LandingSocialItem } from './landing.types'
const props = defineProps<{
title?: string
subtitle?: string
links?: LandingFooterColumn[]
social?: LandingSocialItem[]
}>()
</script>
<template>
<footer class="vuero-footer is-relative">
<div class="container">
<div v-if="'action' in $slots || props.title || props.subtitle" class="footer-head">
<div v-if="props.title || props.subtitle" class="head-text">
<h3 v-if="props.title">
{{ props.title }}
</h3>
<p v-if="props.subtitle">
{{ props.subtitle }}
</p>
</div>
<div class="head-action">
<div class="is-flex is-align-items-center">
<slot name="action" />
</div>
</div>
</div>
<div v-if="'default' in $slots || props.social?.length || props.links?.length" class="columns footer-body">
<!-- Column -->
<div v-if="'default' in $slots || props.social?.length" class="column is-4">
<slot />
<div v-if="props.social">
<div class="social-links p-b-10">
<VLink
v-for="item in social"
:key="item.icon"
:to="item.link"
>
<VIcon :icon="item.icon" class="icon" />
</VLink>
</div>
</div>
</div>
<!-- Column -->
<div
class="column"
:class="'default' in $slots || props.social?.length ? 'is-6 is-offset-2' : 'is-12 has-text-centered'"
>
<div class="columns is-flex-tablet-p">
<!-- Column -->
<div
v-for="column in props.links"
:key="column.label"
class="column"
>
<ul class="footer-column">
<li class="column-header">
{{ column.label }}
</li>
<li
v-for="link in column.children"
:key="link.label"
class="column-item"
>
<VLink :to="link.to">
{{ link.label }}
</VLink>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="footer-copyright has-text-centered">
<slot name="copyright" />
</div>
</div>
</footer>
<!-- /Simple light footer -->
</template>
<style lang="scss">
.vuero-footer {
padding-bottom: 0 !important;
padding-top: 4rem !important;
background: var(--body-color);
.footer-head {
padding-bottom: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid color-mix(in oklab, var(--fade-grey), black 4%);
.head-text {
h3 {
font-family: var(--font);
font-size: 1.8rem;
color: var(--dark-text);
}
p {
font-size: 1.1rem;
color: var(--light-text);
}
}
.head-action {
.buttons {
.button {
&.action-button {
height: 36px;
min-width: 140px;
}
&.chat-button {
background: transparent;
border: none;
box-shadow: none;
color: var(--primary);
font-weight: 500;
}
}
}
}
}
.footer-body {
padding-top: 3rem;
.footer-column {
padding-top: 20px;
.column-header {
font-family: var(--font-alt);
text-transform: uppercase;
color: var(--dark-text);
font-size: 1rem;
font-weight: 700;
margin: 10px 0;
}
.column-item {
padding-bottom: 10px;
a {
font-family: var(--font);
color: var(--light-text);
&:hover,
&:focus {
color: var(--primary);
}
}
}
}
.social-links {
display: flex;
justify-content: flex-start;
align-items: center;
.icon {
color: var(--light-text);
font-size: 16px;
margin-inline-end: 0.6rem;
&:hover,
&:focus {
color: var(--primary);
}
}
}
.footer-description {
color: var(--light-text);
}
.small-footer-logo {
height: 36px;
}
}
.footer-copyright {
font-family: var(--font);
color: var(--light-text);
padding: 4rem 0 2rem;
a {
color: var(--light-text);
&:hover,
&:focus {
color: var(--primary);
}
}
}
}
.is-dark {
.landing-page-wrapper {
.vuero-footer {
background: color-mix(in oklab, var(--landing-xxx), white 8%);
.footer-head {
border-color: color-mix(in oklab, var(--landing-xxx), white 18%);
.head-text {
h3 {
color: var(--dark-dark-text);
}
p {
font-size: 1.1rem;
color: var(--light-text);
}
}
.head-action {
.buttons {
.button {
&.action-button {
background: var(--primary);
border-color: var(--primary);
}
&.chat-button {
color: var(--primary);
background: none !important;
}
}
}
}
}
.footer-body {
.footer-column {
.column-header {
color: var(--dark-dark-text);
}
.column-item {
a:hover {
color: var(--primary);
}
}
}
.social-links {
a:hover {
color: var(--primary);
}
}
}
.footer-copyright {
a {
&:hover {
color: var(--primary);
}
}
}
}
}
}
@media (width <= 767px) {
.vuero-footer {
.footer-head {
flex-direction: column;
text-align: center;
.head-text {
padding-bottom: 20px;
}
}
.footer-body {
padding-inline-start: 20px;
padding-inline-end: 20px;
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.vuero-footer {
.footer-head,
.footer-body {
padding-inline-start: 20px;
padding-inline-end: 20px;
}
.footer-description {
max-width: 400px;
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: landscape) {
.vuero-footer {
.footer-head,
.footer-body {
padding-inline-start: 20px;
padding-inline-end: 20px;
}
}
}
</style>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
const { x, y } = useMouse()
const maskPosition = computed(() => `${Math.round(x.value - 220)}px ${Math.round(y.value - 220)}px`)
</script>
<template>
<div class="grids gridlines" />
</template>
<style scoped lang="scss">
.grids {
position: absolute;
inset: 0;
z-index: 0;
will-change: mask-position;
mask-image: radial-gradient(circle 220px at 220px 220px, black 0%, transparent 100%);
mask-position: v-bind(maskPosition);
mask-repeat: no-repeat;
pointer-events: none;
}
.gridlines {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PGcgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBmaWxsPSIjZWJlYmViIj48cGF0aCBvcGFjaXR5PSIuNSIgZD0iTTk2IDk1aDR2MWgtNHY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNGgtOXY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNGgtOXY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNEgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNXYtOUgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNXYtOUgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djl6bS0xIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6IiAvPjxwYXRoIGQ9Ik02IDVWMEg1djVIMHYxaDV2OTRoMVY2aDk0VjVINnoiIC8+PC9nPjwvZz48L3N2Zz4K');
}
.is-dark .gridlines:not(.is-contrasted) {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PGcgZmlsbC1ydWxlPSJldmVub2RkIj48ZyBmaWxsPSIjMjcyZTNlIj48cGF0aCBvcGFjaXR5PSIuNSIgZD0iTTk2IDk1aDR2MWgtNHY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNGgtOXY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNGgtOXY0aC0xdi00aC05djRoLTF2LTRoLTl2NGgtMXYtNEgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNXYtOUgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNXYtOUgwdi0xaDE1di05SDB2LTFoMTV2LTlIMHYtMWgxNVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoOVYwaDF2MTVoNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djloNHYxaC00djl6bS0xIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTktMTB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS0xMCAwdi05aC05djloOXptLTEwIDB2LTloLTl2OWg5em0tMTAgMHYtOWgtOXY5aDl6bS05LTEwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6bTEwIDBoOXYtOWgtOXY5em0xMCAwaDl2LTloLTl2OXptMTAgMGg5di05aC05djl6IiBmaWxsPSIjMjcyZTNlIi8+PHBhdGggZD0iTTYgNVYwSDV2NUgwdjFoNXY5NGgxVjZoOTRWNUg2eiIgZmlsbD0iIzI3MmUzZSIvPjwvZz48L2c+PC9zdmc+');
}
.is-dark .gridlines.is-contrasted {
filter: brightness(0.30);
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { LandingNavItem } from './landing.types'
const props = withDefaults(defineProps<{
links: LandingNavItem[]
}>(), {
links: () => [],
})
</script>
<template>
<MinimalLayout theme="light">
<main class="landing-page-wrapper">
<LandingGrids />
<div class="hero">
<LandingNavigation>
<template #logo>
<slot name="logo" />
</template>
<template #end>
<slot name="nav-end" />
</template>
<slot name="nav-links">
<div
v-for="link in props.links"
:key="link.label"
class="navbar-item"
>
<VLink
:to="link.to"
class="nav-link"
:class="[link.active && 'is-active']"
>
{{ link.label }}
</VLink>
</div>
</slot>
</LandingNavigation>
</div>
<slot />
</main>
</MinimalLayout>
</template>
<style lang="scss">
@import '/@src/scss/abstracts/all';
@import '/@src/scss/layout/landing';
</style>

View File

@@ -0,0 +1,360 @@
<script setup lang="ts">
const isMobileNavOpen = ref(false)
const { y } = useWindowScroll()
const { isLargeScreen } = useScreenSize()
const isScrolling = computed(() => {
return y.value > 30
})
watchEffect(() => {
if (isLargeScreen.value) {
isMobileNavOpen.value = false
}
})
</script>
<template>
<div class="hero">
<nav
class="navbar is-fixed-top"
:class="[!isScrolling && 'is-docked', isMobileNavOpen && 'is-solid']"
aria-label="main navigation"
>
<div class="navbar-brand">
<slot name="logo" />
<MobileBurger v-model="isMobileNavOpen" />
</div>
<div
class="navbar-menu"
:class="[isMobileNavOpen && 'is-active']"
>
<div class="navbar-start">
<slot />
</div>
<div class="navbar-end">
<slot name="end" />
</div>
</div>
</nav>
</div>
</template>
<style lang="scss">
.hero {
.navbar {
top: 15px;
height: 65px;
max-width: 1140px;
margin: 0 auto;
background-color: var(--white);
box-shadow: var(--light-box-shadow);
border: 1px solid var(--fade-grey);
border-radius: 500px;
font-family: var(--font);
z-index: 99;
transition: all 0.3s; // transition-all test
&.is-docked {
&:not(.is-solid) {
top: 0;
border-color: transparent;
height: 110px;
box-shadow: none;
background: transparent;
.navbar-brand {
.brand-icon {
height: 64px;
width: 64px;
background: var(--white);
border-color: color-mix(in oklab, var(--fade-grey), black 3%);
}
}
}
&.is-solid {
height: 65px !important;
}
}
&.is-solid {
background: var(--white) !important;
border-radius: 10px 10px 0 0;
}
.navbar-brand {
img,
.iconify {
position: relative;
display: block;
width: 100%;
max-width: 34px;
max-height: 34px;
margin-inline-start: 10px;
}
.brand-icon {
height: 50px;
width: 50px;
border-radius: var(--radius-rounded);
display: flex;
justify-content: center;
align-items: center;
border: 1px solid transparent;
transition: all 0.3s; // transition-all test
img,
svg {
position: relative;
top: -2px;
margin-inline-start: 0;
}
}
}
.navbar-menu {
.navbar-item {
.dark-mode {
transform: scale(0.6);
}
.nav-link {
position: relative;
font-family: var(--font-alt);
font-weight: 500;
font-size: 0.9rem;
color: var(--light-text);
text-transform: capitalize;
&::before {
content: '';
position: absolute;
top: -4px;
inset-inline-start: 2px;
width: 50%;
transform-origin: right center;
height: 3px;
border-radius: 50px;
background: var(--primary);
transform: scale(0, 1);
transition: -webkit-transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition:
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),
-webkit-transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
// Hover state
&:hover,
&.is-active {
color: var(--dark-text);
&::before {
transform-origin: left center;
transform: scale(1, 1);
}
}
&.active {
&::before {
background: var(--primary);
}
}
}
.button {
font-weight: 400 !important;
height: 44px;
min-width: 110px;
}
}
}
}
}
.is-dark {
.navbar {
&:not(.is-docked) {
background: color-mix(in oklab, var(--landing-xxx), white 8%);
border-color: color-mix(in oklab, var(--landing-xxx), white 14%);
}
&.is-docked {
.navbar-brand {
.brand-icon {
background: color-mix(in oklab, var(--landing-yyy), white 8%) !important;
border-color: color-mix(in oklab, var(--landing-yyy), white 18%) !important;
}
}
}
&.is-solid {
background: color-mix(in oklab, var(--landing-xxx), white 8%) !important;
border-color: color-mix(in oklab, var(--landing-xxx), white 14%) !important;
.navbar-brand {
.brand-icon {
border-color: transparent;
background-color: transparent;
}
}
.navbar-menu {
&.is-active {
background: color-mix(in oklab, var(--landing-xxx), white 12%);
border-color: color-mix(in oklab, var(--landing-xxx), white 14%);
}
}
}
.navbar-item {
.nav-link {
&:hover,
&.is-active {
color: var(--white) !important;
}
&::before {
background: var(--primary);
}
}
.button {
&.is-primary {
background: var(--primary);
border-color: var(--primary);
&.is-raised:hover {
box-shadow: var(--primary-box-shadow);
}
}
}
}
}
}
@media (width <= 767px) {
.navbar {
display: flex;
align-items: center;
width: calc(100% - 32px);
margin: 0 16px;
&.is-docked {
height: 80px;
}
&.is-solid {
top: 10px;
box-shadow: var(--light-box-shadow) !important;
.navbar-brand {
.brand-icon {
border-color: transparent;
}
}
.navbar-menu {
box-shadow: var(--light-box-shadow) !important;
top: 73px !important;
}
}
.navbar-brand {
width: 100%;
}
.navbar-menu {
width: calc(100% - 32px);
position: fixed;
top: 78px;
inset-inline-start: 0;
inset-inline-end: 0;
margin: 0 auto;
border-radius: 0 0 10px 10px;
padding: 30px;
text-align: center;
border: 1px solid var(--fade-grey);
box-shadow: none;
.navbar-item {
.button {
width: 100%;
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.navbar {
display: flex;
align-items: center;
width: calc(100% - 32px);
margin: 0 16px;
&.is-docked {
height: 80px;
}
&:not(.is-docked) {
&.is-solid {
.navbar-menu {
top: 73px !important;
}
}
}
&.is-solid {
top: 10px;
box-shadow: var(--light-box-shadow) !important;
.navbar-brand {
.brand-icon {
border-color: transparent;
}
}
.navbar-menu {
box-shadow: var(--light-box-shadow) !important;
}
}
.navbar-brand {
width: 100%;
}
.navbar-menu {
width: calc(100% - 32px);
position: fixed;
top: 78px;
inset-inline-start: 0;
inset-inline-end: 0;
margin: 0 auto;
border-radius: 0 0 10px 10px;
padding: 30px;
text-align: center;
border: 1px solid var(--fade-grey);
box-shadow: none;
.navbar-item {
.button {
width: 100%;
}
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: landscape) {
.navbar {
width: calc(100% - 40px);
margin: 0 20px;
}
}
</style>

View File

@@ -0,0 +1,18 @@
export interface LandingNavItem {
label: string
to: string
active?: boolean
}
export interface LandingFooterColumn {
label: string
children: {
label: string
to: string
}[]
}
export interface LandingSocialItem {
icon: string
link: string
}

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
export type MinimalTheme = 'darker' | 'light'
const props = withDefaults(
defineProps<{
theme?: MinimalTheme
}>(),
{
theme: 'darker',
},
)
</script>
<template>
<div
class="minimal-wrapper"
:class="[props.theme]"
>
<slot />
</div>
</template>
<style lang="scss">
.minimal-wrapper {
position: relative;
width: 100%;
min-height: 100vh;
background: var(--lighter-grey);
&.light {
background: var(--white);
}
&.lighter {
background: var(--smoke-white);
}
&.darker {
background: var(--background-grey);
}
.minimal-wrap {
min-height: calc(100vh - 60px);
}
}
.is-dark {
.minimal-wrapper {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%);
}
}
</style>

View File

@@ -0,0 +1,656 @@
<script setup lang="ts">
import { useNavbarLayoutContext } from './navbar.context'
const { theme } = useNavbarLayoutContext()
const { y } = useWindowScroll()
const isScrolling = computed(() => {
return y.value > 30
})
</script>
<template>
<div
class="navbar-navbar"
:class="[
isScrolling && 'is-scrolled',
theme === 'fade' && 'is-transparent',
theme === 'colored' && 'is-colored',
]"
>
<div class="navbar-navbar-inner">
<div class="left">
<slot name="title" />
</div>
<div class="center">
<slot name="links" />
</div>
<div class="right">
<slot name="toolbar" />
</div>
</div>
</div>
</template>
<style lang="scss">
.navbar-navbar {
position: fixed;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 65px;
background: var(--white);
transition: all 0.3s; // transition-all test
border-bottom: 1px solid var(--fade-grey);
z-index: 100;
&.is-transparent {
background: transparent;
box-shadow: none;
border-bottom-color: transparent;
&.is-solid,
&.is-scrolled {
background: var(--white);
border-bottom-color: var(--fade-grey);
}
&.is-solid {
box-shadow: none !important;
}
&.is-scrolled {
box-shadow: 0 0 8px 0 rgb(0 0 0 / 12%);
}
}
&.is-colored {
background: var(--landing-yyy);
border-bottom-color: var(--landing-yyy);
.navbar-navbar-inner {
.left {
.separator {
border-color: color-mix(in oklab, var(--landing-yyy), white 18%);
}
.title {
color: var(--smoke-white);
}
}
.center {
.centered-links {
.centered-link {
&:hover {
background: color-mix(in oklab, var(--landing-yyy), black 6%);
.iconify {
color: var(--smoke-white);
}
span {
color: var(--smoke-white);
}
}
&.is-active {
// background: color-mix(in oklab, var(--landing-yyy), black 12%);
// border-color: color-mix(in oklab, var(--landing-yyy), white 6%);
&:hover,
&:focus {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
}
.iconify {
color: var(--smoke-white);
}
span {
color: var(--smoke-white);
}
}
.iconify {
color: var(--light-text);
}
span {
color: var(--light-text);
}
}
}
.centered-drops {
.centered-drop {
.dropdown {
&:hover {
.is-trigger {
.button {
background: color-mix(in oklab, var(--landing-yyy), black 6%);
color: var(--smoke-white);
}
}
}
&.is-active {
.is-trigger {
.button {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
border-color: color-mix(in oklab, var(--landing-yyy), white 6%);
}
}
}
.is-trigger {
.button {
background: var(--landing-yyy);
color: var(--light-text);
.caret {
margin-inline-start: 0;
}
}
}
}
}
.centered-button {
.button {
background: var(--landing-yyy);
color: var(--light-text);
&:hover,
&:focus {
background: color-mix(in oklab, var(--landing-yyy), black 6%);
color: var(--smoke-white);
}
}
}
}
.centered-search {
.field {
.control {
.input {
background: color-mix(in oklab, var(--primary), black 10%);
border-color: color-mix(in oklab, var(--primary), black 6%);
color: var(--smoke-white);
&::placeholder {
color: color-mix(in oklab, var(--primary), white 2%);
}
&:focus ~ .form-icon.iconify {
color: var(--smoke-white);
}
}
.form-icon.iconify {
color: color-mix(in oklab, var(--primary), white 6%);
}
}
}
}
}
.right {
.toolbar {
.toolbar-link {
&:hover {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
border-color: color-mix(in oklab, var(--landing-yyy), black 12%);
}
> .iconify {
color: var(--smoke-white);
}
}
.dropdown {
&:hover {
.is-trigger {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
border-color: color-mix(in oklab, var(--landing-yyy), black 12%);
}
}
&.is-dots {
&.is-active {
.is-trigger {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
border-color: color-mix(in oklab, var(--landing-yyy), black 12%);
}
}
}
.is-trigger .iconify {
color: var(--smoke-white);
}
}
}
.icon-link {
background: var(--landing-yyy);
&:hover,
&:focus {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
}
> .iconify {
color: var(--smoke-white);
}
}
}
}
}
.navbar-navbar-inner {
position: relative;
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
.left {
display: flex;
align-items: center;
width: 17%;
.brand {
display: flex;
align-items: center;
img {
display: block;
min-width: 38px;
height: 38px;
}
span {
font-family: var(--font);
font-size: 0.95rem;
color: var(--muted-grey);
letter-spacing: 1px;
max-width: 50px;
line-height: 1.2;
margin-inline-start: 8px;
}
}
.separator {
height: 38px;
width: 2px;
border-inline-end: 1px solid color-mix(in oklab, var(--fade-grey), black 4%);
margin: 0 20px 0 16px;
}
}
.center {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 2;
width: 50%;
.left-links {
padding-left: 20px;
justify-content: left !important;
}
.centered-links, .left-links {
display: flex;
justify-content: center;
width: 100%;
gap: 1rem;
// max-width: 580px;
.centered-link {
// flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.25rem;
text-align: center;
padding: 12px 24px;
//border-radius: 8px;
border: 1px solid transparent;
margin: 0 4px;
transition: all 0.3s; // transition-all test
white-space: nowrap;
&:hover {
background-color: transparent !important;
background: color-mix(in oklab, var(--fade-grey), white 4%);
}
&.is-active {
.iconify {
color: var(--primary);
}
span {
color: color-mix(in oklab, var(--primary), black 8%);
}
}
.iconify {
font-size: 20px;
color: color-mix(in oklab, var(--light-text), white 6%);
stroke-width: 1.6px;
transition: stroke 0.3s;
}
span {
display: block;
font-family: var(--font);
font-size: 1.1rem;
letter-spacing: 0.6px;
font-weight: 500;
//color: var(--muted-grey);
//color: var(--modal-text);
color: black;
text-transform: uppercase;
transition: all 0.3s;
cursor: pointer;
}
&.router-link-exact-active {
border-bottom: 3px solid var(--primary);
span{
color: black;
//color: var(--modal-text) ;
font-weight: bold;
font-size: 1.2rem;
}
}
}
}
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
width: 25%;
.icon-link {
display: flex;
justify-content: center;
align-items: center;
height: 34px;
width: 34px;
font-size: 18px;
border-radius: var(--radius-rounded);
margin: 0 4px;
transition: all 0.3s; // transition-all test
&:hover {
background: var(--white);
border-color: var(--fade-grey);
box-shadow: var(--light-box-shadow);
}
.iconify {
height: 18px;
width: 18px;
stroke-width: 1.6px;
color: var(--light-text);
transition: stroke 0.3s;
vertical-align: 0;
transform: none;
}
}
}
}
}
/* ==========================================================================
4. Webapp Navbar Dark mode
========================================================================== */
.is-dark {
.navbar-navbar:not(.is-colored) {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
&.is-transparent {
background: transparent;
box-shadow: none;
border-bottom-color: transparent;
&.is-solid,
&.is-scrolled {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
}
}
.navbar-navbar-inner {
.left {
a {
&:hover {
background-color: transparent !important;
}
}
.separator {
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
.center {
.centered-links {
.centered-link {
&:hover {
background: color-mix(in oklab, var(--dark-sidebar), white 2%);
}
&.is-active {
// background: color-mix(in oklab, var(--dark-sidebar), white 2%);
// border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
// &:hover,
// &:focus {
// background: color-mix(in oklab, var(--dark-sidebar), white 2%);
// }
span {
color: var(--primary);
}
.iconify {
color: var(--primary);
}
}
}
}
}
.right {
.icon-link {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark-sidebar), white 2%);
}
}
}
}
}
.navbar-navbar {
&.is-colored {
.navbar-navbar-inner {
.left {
.title {
color: var(--smoke-white) !important;
}
}
.center {
.centered-links {
.centered-link {
&:hover {
.iconify {
color: var(--smoke-white);
}
span {
color: var(--smoke-white);
}
}
&.is-active {
.iconify {
color: var(--smoke-white);
}
span {
color: var(--smoke-white);
}
}
}
}
.centered-drops {
.centered-drop {
.dropdown {
&:hover {
.is-trigger {
.button {
color: var(--smoke-white);
}
}
}
&.is-active {
.is-trigger {
.button {
color: var(--smoke-white);
}
}
}
&.has-mega-dropdown {
.dropdown-menu {
.dropdown-content {
.category-selector {
.title-wrap {
h4 {
color: var(--dark-dark-text);
}
}
.category-selector-inner {
.category-item {
background: color-mix(in oklab, var(--dark-sidebar), white 4%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
&:hover,
&:focus {
i,
span {
color: var(--primary);
}
}
span {
color: var(--dark-dark-text);
}
}
}
}
.mega-menus {
.dropdown-item-group {
.column-heading {
color: var(--dark-dark-text);
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
.column-content {
.is-media {
&:hover {
.meta {
span:first-child {
color: var(--smoke-white);
}
}
}
}
}
}
}
}
}
}
}
}
.centered-button {
.button {
&:hover {
color: var(--smoke-white) !important;
}
}
}
}
.centered-search {
.field {
.control {
.input {
color: var(--smoke-white);
&:focus ~ .form-icon .iconify {
color: var(--smoke-white) !important;
}
}
}
}
}
}
.right {
.toolbar {
.toolbar-link {
&:hover {
background: color-mix(in oklab, var(--landing-yyy), black 12%) !important;
border-color: color-mix(in oklab, var(--landing-yyy), black 12%) !important;
}
> .iconify {
color: var(--smoke-white);
}
}
.dropdown {
.is-trigger {
&:hover {
background: color-mix(in oklab, var(--landing-yyy), black 12%) !important;
border-color: color-mix(in oklab, var(--landing-yyy), black 12%) !important;
}
.iconify {
color: var(--smoke-white);
}
}
}
}
}
}
}
}
}
a {
&:hover {
background-color:white !important;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import type { NavbarItemDropdown } from './navbar.types'
import { useNavbarLayoutContext } from './navbar.context'
const props = defineProps<{
link: NavbarItemDropdown
}>()
const tippyRef = ref<any>()
const {
activeSubnavId,
toggleSubnav,
} = useNavbarLayoutContext()
watch(activeSubnavId, () => {
if (activeSubnavId.value === props.link.id) {
tippyRef.value?.show()
}
else {
tippyRef.value?.hide()
}
})
function onHidden() {
if (activeSubnavId.value === props.link.id) {
activeSubnavId.value = undefined
}
}
</script>
<template>
<div class="is-flex">
<Tippy
ref="tippyRef"
:key="`dropdown-${props.link.id}`"
trigger="manual"
class="is-flex mx-1"
content-class="content-tet"
interactive
:offset="[0, 10]"
:duration="[150, 100]"
@hidden="onHidden"
>
<a
:class="[
activeSubnavId === props.link.id && 'is-active',
]"
class="centered-link centered-link-toggle"
tabindex="0"
role="button"
@keydown.enter.prevent="toggleSubnav(props.link.id)"
@click="toggleSubnav(props.link.id)"
>
<VIcon
v-if="props.link.icon"
:icon="props.link.icon"
/>
<span>{{ props.link.label }}</span>
</a>
<template #content>
<ul class="centered-link-dropdown has-slimscroll">
<li v-for="item of props.link.children" :key="item.to">
<VLink :to="item.to">
<VIcon
v-if="item.icon"
:icon="item.icon"
/>
<span>{{ item.label }}</span>
</VLink>
</li>
</ul>
</template>
</Tippy>
</div>
</template>
<style scoped lang="scss">
.centered-link-dropdown {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 300px;
padding: 1rem 0;
a {
display: flex;
padding: 0 4rem 0 1rem;
align-items: center;
gap: 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
font-family: var(--font);
font-size: 0.9rem;
color: color-mix(in oklab, var(--light-text), black 5%);
&:focus,
&:hover {
color: var(--primary);
}
.iconify {
font-size: 1.2rem;
opacity: 0.5;
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { NavbarItem, NavbarItemMegamenu, NavbarItemAction } from './navbar.types'
import { useNavbarLayoutContext } from './navbar.context'
const props = defineProps<{
link: NavbarItem
}>()
const { activeSubnavId, toggleSubnav } = useNavbarLayoutContext()
</script>
<template>
<NavbarDropdown
v-if="props.link.type === 'dropdown'"
:key="`dropdown-${props.link.id}`"
:link="props.link"
/>
<a
v-if="props.link.type === 'megamenu'"
:key="`megamenu-${props.link.id}`"
:class="[
activeSubnavId === props.link.id && 'is-active',
]"
class="centered-link centered-link-toggle"
tabindex="0"
role="button"
@keydown.enter.prevent="toggleSubnav((props.link as NavbarItemMegamenu).id)"
@click="toggleSubnav((props.link as NavbarItemMegamenu).id)"
>
<VIcon
v-if="props.link.icon"
:icon="props.link.icon"
/>
<span v-if="props.link.label">{{ props.link.label }}</span>
</a>
<a
v-else-if="props.link.type === 'action'"
:key="`action-${props.link.label}`"
class="centered-link centered-link-search"
tabindex="0"
role="button"
@keydown.enter.prevent="(props.link as NavbarItemAction).onClick"
@click="(props.link as NavbarItemAction).onClick"
>
<VIcon
v-if="props.link.icon"
:icon="props.link.icon"
/>
<span v-if="props.link.label">{{ props.link.label }}</span>
</a>
<VLink
v-else-if="props.link.type === 'link'"
:key="`link-${props.link.label}`"
class="centered-link centered-link-search"
:to="props.link.to"
>
<VIcon
v-if="props.link.icon"
:icon="props.link.icon"
/>
<span v-if="props.link.label">{{ props.link.label }}</span>
</VLink>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { NavbarItem, NavbarItemDropdown, NavbarItemMegamenu, NavbarItemAction } from './navbar.types'
import { useNavbarLayoutContext } from './navbar.context'
const props = defineProps<{
link: NavbarItem
}>()
const { activeMobileSubsidebarId, toggleMobileSubnav } = useNavbarLayoutContext()
</script>
<template>
<li>
<a
v-if="props.link.type === 'dropdown'"
:class="[activeMobileSubsidebarId === props.link.id && 'is-active']"
tabindex="0"
role="button"
@keydown.enter.prevent="toggleMobileSubnav((props.link as NavbarItemDropdown).id)"
@click="toggleMobileSubnav((props.link as NavbarItemDropdown).id)"
>
<VIcon
:icon="props.link.icon"
/>
</a>
<a
v-if="props.link.type === 'megamenu'"
:class="[activeMobileSubsidebarId === props.link.id && 'is-active']"
tabindex="0"
role="button"
@keydown.enter.prevent="toggleMobileSubnav((props.link as NavbarItemMegamenu).id)"
@click="toggleMobileSubnav((props.link as NavbarItemMegamenu).id)"
>
<VIcon
:icon="props.link.icon"
/>
</a>
<a
v-else-if="props.link.type === 'action'"
tabindex="0"
role="button"
@keydown.enter.prevent="(props.link as NavbarItemAction).onClick"
@click="(props.link as NavbarItemAction).onClick"
>
<VIcon
:icon="props.link.icon"
/>
</a>
<VLink
v-else-if="props.link.type === 'link'"
:to="props.link.to"
>
<VIcon
:icon="props.link.icon"
/>
</VLink>
</li>
</template>

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import type {
NavbarTheme,
NavbarItem,
NavbarItemMegamenu,
NavbarItemDropdown,
NavbarLayoutContext,
} from './navbar.types'
import { injectionKey } from './navbar.context'
const props = withDefaults(
defineProps<{
links?: NavbarItem[]
theme?: NavbarTheme
size?: 'default' | 'large' | 'wide' | 'full'
}>(),
{
links: () => [],
theme: 'default',
size: 'default',
},
)
const pageTitle = useVueroContext<string>('page-title')
const route = useRoute()
const linksWithChildren = computed(() => {
return props.links.filter(link => link.type === 'megamenu' || link.type === 'dropdown') as (NavbarItemMegamenu | NavbarItemDropdown)[]
})
const isMobileSidebarOpen = ref(false)
const activeMobileSubsidebarId = ref<string>(linksWithChildren.value?.[0]?.id)
const activeSubnavId = ref<string | undefined>()
const activeSubnav = computed(() => {
return linksWithChildren.value.find(link => link.id === activeSubnavId.value)
})
const activeMobileSubsidebar = computed(() => {
return linksWithChildren.value.find(link => link.id === activeMobileSubsidebarId.value)
})
function toggleSubnav(id: string) {
if (activeSubnavId.value === id) {
activeSubnavId.value = undefined
}
else {
activeSubnavId.value = id
}
}
function toggleMobileSubnav(id: string) {
if (activeMobileSubsidebarId.value === id) {
isMobileSidebarOpen.value = false
}
else {
activeMobileSubsidebarId.value = id
isMobileSidebarOpen.value = true
}
}
// provide context to children
const context: NavbarLayoutContext = {
links: computed(() => props.links),
theme: computed(() => props.theme),
isMobileSidebarOpen,
activeMobileSubsidebarId,
activeSubnavId,
activeSubnav,
activeMobileSubsidebar,
toggleSubnav,
toggleMobileSubnav,
}
provide(injectionKey, context)
// using reactive context for slots, has better dev experience
const contextRx = reactive(context)
watch(
() => route.fullPath,
() => {
activeSubnavId.value = undefined
isMobileSidebarOpen.value = false
},
)
watch(() => Boolean(activeSubnav.value?.type === 'megamenu' || isMobileSidebarOpen.value), (value) => {
if (value) {
document.documentElement.classList.add('no-scroll')
}
else {
document.documentElement.classList.remove('no-scroll')
}
})
</script>
<template>
<div class="navbar-layout">
<!-- Mobile navigation -->
<MobileNavbar v-model="isMobileSidebarOpen">
<template #logo>
<slot name="logo" v-bind="contextRx" />
<div class="brand-end">
<slot name="toolbar-mobile" v-bind="contextRx" />
</div>
</template>
</MobileNavbar>
<MobileSidebar
:class="[isMobileSidebarOpen && 'is-active']"
>
<template #links>
<slot name="navbar-links-mobile" v-bind="contextRx">
<NavbarItemMobile
v-for="link in props.links"
:key="link.label"
:link
/>
</slot>
</template>
</MobileSidebar>
<Transition name="fade">
<MobileOverlay
v-if="isMobileSidebarOpen"
@click="isMobileSidebarOpen = false"
/>
</Transition>
<Transition name="slide-x">
<KeepAlive>
<NavbarSubsidebarMobile
v-if="isMobileSidebarOpen && activeMobileSubsidebar?.children"
:key="activeMobileSubsidebarId"
:label="activeMobileSubsidebar.label"
:items="activeMobileSubsidebar.children"
/>
</KeepAlive>
</Transition>
<!-- /Mobile navigation -->
<!-- Desktop navigation -->
<Navbar>
<template #title>
<slot name="logo" v-bind="contextRx" />
<div v-if="'logo' in $slots" class="separator" />
<!-- <slot name="navbar-title" v-bind="contextRx">-->
<!-- <h1 class="title is-5">-->
<!-- {{ pageTitle }}-->
<!-- </h1>-->
<!-- </slot>-->
</template>
<template #toolbar>
<div class="toolbar desktop-toolbar">
<slot name="toolbar" v-bind="contextRx" />
</div>
</template>
<template #links>
<slot name="navbar-links" v-bind="contextRx">
<div class="left-links">
<NavbarItem
v-for="link in props.links"
:key="link.label"
:link="link"
/>
</div>
</slot>
</template>
</Navbar>
<div
class="navbar-subnavbar is-hidden-mobile"
:class="[activeSubnav?.type === 'megamenu' && 'is-active']"
>
<NavbarMegamenu
v-if="activeSubnav?.type === 'megamenu'"
:key="activeSubnavId"
:children="activeSubnav.children"
class="is-active"
>
<template v-if="'megamenu-start' in $slots" #start>
<slot name="megamenu-start" v-bind="contextRx" />
</template>
<template v-if="'megamenu-end' in $slots" #end>
<slot name="megamenu-end" v-bind="contextRx" />
</template>
<template v-if="'megamenu-top' in $slots" #top>
<slot name="megamenu-top" v-bind="contextRx" />
</template>
<template v-if="'megamenu-bottom' in $slots" #bottom>
<slot name="megamenu-bottom" v-bind="contextRx" />
</template>
</NavbarMegamenu>
</div>
<!-- /Desktop navigation -->
<ViewWrapper full top-nav>
<template v-if="props.size === 'full'">
<div class="is-navbar-md">
<slot name="page-heading" v-bind="contextRx">
<NavbarPageTitleMobile>
<slot name="navbar-title-mobile" v-bind="contextRx">
<h1 class="title is-4">
{{ pageTitle }}
</h1>
</slot>
<template #toolbar>
<slot name="toolbar" v-bind="contextRx" />
</template>
</NavbarPageTitleMobile>
</slot>
<slot v-bind="contextRx" />
</div>
</template>
<PageContentWrapper v-else :size="props.size">
<PageContent class="is-relative">
<div class="is-navbar-md">
<slot name="page-heading" v-bind="contextRx">
<NavbarPageTitleMobile>
<slot name="navbar-title-mobile" v-bind="contextRx">
<h1 class="title is-4">
{{ pageTitle }}
</h1>
</slot>
<template #toolbar>
<slot name="toolbar" v-bind="contextRx" />
</template>
</NavbarPageTitleMobile>
</slot>
<slot v-bind="contextRx" />
</div>
</PageContent>
</PageContentWrapper>
</ViewWrapper>
<slot name="extra" v-bind="contextRx" />
</div>
</template>
<style lang="scss">
@import '/@src/scss/abstracts/all';
@import '/@src/scss/layout/navbar';
</style>

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import type { NavbarMegamenu } from './navbar.types'
const props = withDefaults(defineProps<{
children: NavbarMegamenu[]
}>(), {
children: () => [],
})
</script>
<template>
<div class="navbar-subnavbar-inner">
<div v-if="'start' in $slots" class="menu-grid-start">
<slot name="start" />
</div>
<div class="menu-grid-wrapper">
<div v-if="'top' in $slots" class="menu-grid-top">
<slot name="top" />
</div>
<div class="menu-grid-container has-slimscroll">
<div class="menu-grid">
<div
v-for="group in props.children"
:key="group.id"
class="menu-block"
>
<h4 class="block-heading">
<VIcon
:icon="group.icon"
/>
<span>{{ group.label }}</span>
</h4>
<ul class="block-links">
<li v-for="link in group.children" :key="link.to">
<VLink :to="link.to">
<span>{{ link.label }}</span>
<VTag
v-if="link.tag"
color="primary"
size="tiny"
outlined
>
{{ link.tag }}
</VTag>
</VLink>
</li>
</ul>
</div>
</div>
</div>
<div v-if="'bottom' in $slots" class="menu-grid-bottom">
<slot name="bottom" />
</div>
</div>
<div v-if="'end' in $slots" class="menu-grid-end">
<slot name="end" />
</div>
</div>
</template>
<style lang="scss">
.navbar-subnavbar-inner {
justify-content: space-between;
&.is-active {
display: flex !important;
}
}
.menu-grid-start,
.menu-grid-end {
min-width: 260px;
}
// .menu-grid-wrapper-wrapper {
// border-bottom: 4px solid green;
// display: flex;
// flex-direction: column;
// flex-grow: 2;
// // flex: 1 1 0;
// > div {
// width: 100%;
// }
// }
.menu-grid-top,
.menu-grid-bottom {
width: 100%;
}
.menu-grid-wrapper {
display: flex;
flex-direction: column;
// flex-wrap: wrap;
width: 100%;
height: 100%;
padding: 0 1.5rem;
}
.menu-grid-container {
height: 100%;
}
.menu-grid {
width: 100%;
max-height: 100%;
display: flex;
// flex-direction: column;
flex-wrap: wrap;
gap: 0 4rem;
padding: 1.5rem 0;
// display: grid;
// grid-template-rows: 1fr 1fr 1fr;
// grid-auto-flow: column dense;
&.is-horizontal {
flex-direction: row;
gap: 1rem 8rem;
}
}
.menu-block {
min-width: 170px;
padding-bottom: 2rem;
.block-heading {
font-family: var(--font-alt);
font-size: 0.8rem;
font-weight: 600;
color: var(--dark-text);
text-transform: uppercase;
margin-bottom: 12px;
display: flex;
align-items: top;
gap: 0.5rem;
.iconify {
font-size: 18px;
opacity: 0.3;
}
}
.block-links {
li {
padding-inline-start: 26px;
transition:
color 0.3s,
background-color 0.3s,
border-color 0.3s,
height 0.3s,
width 0.3s;
margin-bottom: 6px;
&:hover,
&:focus {
a {
color: var(--primary);
}
}
&.is-active {
border-radius: 0;
a {
color: color-mix(in oklab, var(--primary), black 14%);
.iconify {
opacity: 1;
fill: var(--primary);
}
}
}
.router-link-exact-active {
color: color-mix(in oklab, var(--primary), black 14%);
.iconify {
opacity: 1;
fill: var(--primary);
}
}
a {
display: flex;
align-items: center;
color: color-mix(in oklab, var(--light-text), white 5%);
gap: 0.225rem;
span {
font-family: var(--font);
font-size: 0.9rem;
}
.lnil,
.lnir,
.fas,
.fal,
.fab,
.far {
margin-inline-end: 10px;
}
.tag {
line-height: 1.6;
height: 1.7em;
font-size: 0.65rem;
margin-inline-start: 0.25rem;
}
.iconify {
opacity: 0;
position: relative;
top: 0;
margin-inline-start: 12px;
height: 6px;
width: 6px;
stroke-width: 2px;
fill: var(--primary);
transition:
color 0.3s,
background-color 0.3s,
border-color 0.3s,
height 0.3s,
width 0.3s;
}
}
&:hover {
a {
opacity: 1;
color: var(--primary);
}
}
}
}
}
.is-dark .navbar-subnavbar-inner {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
}
</style>

View File

@@ -0,0 +1,11 @@
<template>
<div class="page-title has-text-centered">
<div class="title-wrap">
<slot />
</div>
<div class="toolbar mobile-toolbar">
<slot name="toolbar" />
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { NavbarDropdown, NavbarMegamenu } from './navbar.types'
const props = defineProps<{
label?: string
items: (NavbarDropdown | NavbarMegamenu)[]
}>()
</script>
<template>
<div class="mobile-subsidebar">
<div class="inner">
<div class="sidebar-title">
<slot>
<h3>{{ props.label }}</h3>
</slot>
</div>
<ul
class="submenu has-slimscroll"
>
<template v-for="item of props.items">
<VCollapseLinks
v-if="'children' in item"
:key="item.id"
:links="item.children"
>
{{ item.label }}
</VCollapseLinks>
<li v-else-if="'to' in item" :key="item.label">
<VLink :to="item.to">
{{ item.label }}
</VLink>
</li>
</template>
</ul>
</div>
</div>
</template>
<style lang="scss">
@import '/@src/scss/layout/mobile-subsidebar';
</style>

View File

@@ -0,0 +1,14 @@
import type { InjectionKey } from 'vue'
import type { NavbarLayoutContext } from './navbar.types'
export const injectionKey = Symbol('navbar-layout') as InjectionKey<NavbarLayoutContext>
export function useNavbarLayoutContext() {
const context = inject(injectionKey)
if (!context) {
throw new Error('useNavbarLayoutContext() is called outside of <NavbarLayout> tree.')
}
return context
}

View File

@@ -0,0 +1,73 @@
export type NavbarTheme = 'default' | 'colored' | 'fade'
// -- Item
export interface NavbarItemMegamenu {
type: 'megamenu'
label: string
id: string
icon: string
children: NavbarMegamenu[]
}
export interface NavbarItemDropdown {
type: 'dropdown'
label: string
id: string
icon: string
children: NavbarDropdown[]
}
export interface NavbarItemAction {
type: 'action'
label: string
icon: string
onClick: (event: Event) => void
}
export interface NavbarItemLink {
type: 'link'
label: string
icon: string
to: string
}
export type NavbarItem =
| NavbarItemMegamenu
| NavbarItemDropdown
| NavbarItemAction
| NavbarItemLink
// -- Item Megamenu
export interface NavbarMegamenu {
id: string
label: string
icon: string
children: NavbarMegamenuLink[]
}
export interface NavbarMegamenuLink {
label: string
to: string
tag?: string | number
}
// -- Item Dropdown
export interface NavbarDropdown {
label: string
to: string
icon: string
}
// -- Context
export interface NavbarLayoutContext {
theme: ComputedRef<NavbarTheme>
links: ComputedRef<NavbarItem[]>
isMobileSidebarOpen: Ref<boolean>
activeMobileSubsidebarId: Ref<string>
activeSubnavId: Ref<string | undefined>
activeSubnav: ComputedRef<NavbarItem | undefined>
activeMobileSubsidebar: ComputedRef<NavbarItem | undefined>
toggleSubnav: (id: string) => void
toggleMobileSubnav: (id: string) => void
}

View File

@@ -0,0 +1,338 @@
<template>
<div
class="navbar-navbar-clean"
>
<div class="navbar-navbar-inner">
<div class="left">
<slot name="title" />
</div>
<div class="center">
<slot name="search" />
</div>
<div class="right">
<slot name="toolbar" />
</div>
</div>
<div
class="navbar-navbar-lower"
:class="[
'subtitle' in $slots && 'is-between',
!('subtitle' in $slots) && 'is-centered',
]"
>
<div
v-if="'subtitle' in $slots"
class="left"
>
<slot name="subtitle" />
</div>
<div
:class="[
!('subtitle' in $slots) && 'left',
'subtitle' in $slots && 'center',
]"
>
<slot name="links" />
</div>
<div
v-if="'toolbar-bottom' in $slots"
class="right"
>
<slot name="toolbar-bottom" />
</div>
</div>
</div>
</template>
<style lang="scss">
.navbar-navbar-clean {
position: fixed;
top: 0;
inset-inline-start: 0;
width: 100%;
background: var(--white);
z-index: 15;
transition: all 0.3s; // transition-all test
&.is-transparent {
background: transparent;
box-shadow: none;
border-bottom-color: transparent;
&.is-solid,
&.is-scrolled {
background: var(--white);
border-bottom-color: var(--fade-grey);
}
&.is-solid {
box-shadow: none !important;
}
&.is-scrolled {
box-shadow: 0 0 8px 0 rgb(0 0 0 / 12%);
}
&:not(.is-scrolled) {
.navbar-navbar-lower {
&.is-between,
&.is-centered {
.left,
.center {
.button:not(:hover) {
background: transparent;
border-color: transparent;
}
.button:hover {
background: var(--white);
border-color: var(--white);
}
}
}
}
}
}
.navbar-navbar-inner {
display: flex;
height: 50px;
padding: 0 20px;
.left {
display: flex;
align-items: center;
width: 25%;
.brand {
display: flex;
align-items: center;
img {
display: block;
min-width: 38px;
height: 38px;
}
span {
font-family: var(--font);
font-size: 0.95rem;
color: var(--muted-grey);
letter-spacing: 1px;
max-width: 50px;
line-height: 1.2;
margin-inline-start: 8px;
}
}
.separator {
height: 38px;
width: 2px;
border-inline-end: 1px solid color-mix(in oklab, var(--fade-grey), black 4%);
margin: 0 20px 0 16px;
}
}
.center {
display: flex;
align-items: center;
flex-grow: 2;
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
width: 25%;
margin-inline-start: auto;
.icon-link {
display: flex;
justify-content: center;
align-items: center;
height: 34px;
width: 34px;
border-radius: var(--radius-rounded);
margin: 0 4px;
transition: all 0.3s; // transition-all test
&:hover {
background: var(--white);
border-color: var(--fade-grey);
box-shadow: var(--light-box-shadow);
}
.iconify {
height: 18px;
width: 18px;
font-size: 18px;
stroke-width: 1.6px;
color: var(--light-text);
transition: stroke 0.3s;
vertical-align: 0;
transform: none;
}
}
}
}
.navbar-navbar-lower {
display: flex;
align-items: center;
height: 50px;
padding: 0 20px;
&.is-between,
&.is-centered {
justify-content: space-between;
.left,
.right, {
display: flex;
align-items: center;
font-size: 0.9rem;
font-weight: 500;
font-family: var(--font);
color: var(--light-text);
}
.left,
.center {
display: flex;
align-items: center;
.button {
font-size: 0.9rem;
font-weight: 500;
border-radius: 0.5rem;
border: none;
color: var(--light-text);
&:hover,
&:focus,
&.router-link-exact-active {
background: color-mix(in oklab, var(--widget-grey), black 2%);
color: var(--dark-text);
box-shadow: none;
}
}
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
.avatar-stack {
margin-inline-end: 1rem;
}
.dropdown {
.button {
&.is-circle {
min-width: 35px;
}
}
}
}
}
&.is-centered {
.left,
.right {
width: 25%;
}
.center {
justify-content: center;
flex-grow: 2;
}
}
}
}
.is-dark {
.navbar-navbar-clean {
&:not(.is-colored) {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
&.is-transparent {
background: transparent;
box-shadow: none;
border-bottom-color: transparent;
&.is-solid,
&.is-scrolled {
background: color-mix(in oklab, var(--dark-sidebar), black 2%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%);
}
&:not(.is-scrolled) {
.navbar-navbar-lower {
&.is-between,
&.is-centered {
.left,
.center {
.button:not(:hover) {
background: transparent !important;
border-color: transparent !important;
}
.button:hover {
background: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
}
}
}
}
}
}
}
.navbar-navbar-inner {
.left {
.separator {
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
}
}
.right {
.icon-link {
background: var(--landing-yyy);
&:hover,
&:focus {
background: color-mix(in oklab, var(--landing-yyy), black 12%);
}
> .iconify {
color: var(--smoke-white);
stroke: var(--smoke-white);
}
}
}
}
.navbar-navbar-lower {
&.is-between,
&.is-centered {
.left,
.center {
.button {
background: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), black 2%) !important;
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 4%) !important;
color: var(--white) !important;
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts" generic="T">
const props = withDefaults(defineProps<{
suggestions?: T[]
}>(), {
suggestions: () => [],
})
const emits = defineEmits<{
select: [item: T]
}>()
const modelValue = defineModel<string>()
</script>
<template>
<div class="centered-search">
<div class="field">
<div class="control has-icon">
<input
v-model="modelValue"
type="text"
class="input search-input"
placeholder="Search records..."
>
<div class="form-icon">
<VIcon
icon="lucide:search"
/>
</div>
<div
v-if="modelValue"
class="form-icon is-right"
tabindex="0"
role="button"
@keydown.enter.prevent="modelValue = ''"
@click="modelValue = ''"
>
<VIcon
icon="lucide:x"
/>
</div>
<div
v-if="props.suggestions.length"
class="search-results has-slimscroll is-active"
>
<a
v-for="(item, key) in props.suggestions"
:key="key"
role="button"
tabindex="0"
class="search-result"
@click="() => emits('select', item)"
@keydown.enter.prevent="() => emits('select', item)"
>
<slot v-bind="{ item }" />
</a>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.centered-search {
width: 100%;
.field {
margin-bottom: 0;
.control {
.input {
border-radius: 0.5rem;
}
.form-icon {
&.is-right {
inset-inline-start: unset !important;
inset-inline-end: 6px;
cursor: pointer;
}
}
.search-results {
top: 48px;
}
}
}
}
</style>

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import type {
NavsearchTheme,
NavsearchItem,
NavsearchScrollBehavior,
NavsearchLayoutContext,
} from './navsearch.types'
import { injectionKey } from './navsearch.context'
const props = withDefaults(
defineProps<{
links?: NavsearchItem[]
theme?: NavsearchTheme
size?: 'default' | 'large' | 'wide' | 'full'
scrollBehavior?: NavsearchScrollBehavior
}>(),
{
links: () => [],
scrollBehavior: 'fixed',
theme: 'default',
size: 'default',
},
)
const pageTitle = useVueroContext<string>('page-title')
const route = useRoute()
const isMobileSidebarOpen = ref(false)
const { y } = useWindowScroll()
const threshold = 25 // half of the navbar height
let lastScrollY = 0
const direction = ref<'up' | 'down'>('down')
const isScrolled = computed(() => {
return y.value > threshold
})
// provide context to children
const context: NavsearchLayoutContext = {
links: computed(() => props.links),
theme: computed(() => props.theme),
scrollBehavior: computed(() => props.scrollBehavior),
isMobileSidebarOpen,
}
provide(injectionKey, context)
// using reactive context for slots, has better dev experience
const contextRx = reactive(context)
watch(y, (value) => {
if (lastScrollY < value) {
direction.value = 'down'
}
else {
direction.value = 'up'
}
lastScrollY = value
})
watch(
() => route.fullPath,
() => {
isMobileSidebarOpen.value = false
},
)
</script>
<template>
<div
class="navbar-layout navbar-layout-search"
:class="[
...(props.scrollBehavior === 'shrink' ? [
'is-shrink',
isScrolled ? 'is-scrolled' : '',
] : []),
...(props.scrollBehavior === 'reveal' ? [
'is-reveal',
isScrolled && direction === 'down' ? 'is-scrolled' : '',
] : []),
]"
>
<!-- Mobile navigation -->
<MobileNavbar v-model="isMobileSidebarOpen">
<template #logo>
<slot name="logo" v-bind="contextRx" />
<div class="brand-end">
<slot name="toolbar-mobile" v-bind="contextRx" />
</div>
</template>
</MobileNavbar>
<Transition name="slide-x">
<NavsearchSubsidebarMobile
v-if="isMobileSidebarOpen"
:items="props.links"
>
<slot name="navbar-content" v-bind="contextRx" />
<template #links>
<slot name="navbar-links-mobile" />
</template>
</NavsearchSubsidebarMobile>
</Transition>
<Transition name="fade">
<MobileOverlay
v-if="isMobileSidebarOpen"
@click="isMobileSidebarOpen = false"
/>
</Transition>
<!-- /Mobile navigation -->
<!-- Desktop navigation -->
<Navsearch
:class="[
props.theme === 'fade' && 'is-transparent',
props.theme === 'fade' && isScrolled && 'is-scrolled',
]"
>
<template #title>
<slot name="logo" v-bind="contextRx" />
<div v-if="'logo' in $slots" class="separator" />
<slot name="navbar-title" v-bind="contextRx">
<h1 class="title is-6">
{{ pageTitle }}
</h1>
</slot>
</template>
<template #search>
<slot name="navbar-content" v-bind="contextRx" />
</template>
<template #toolbar>
<div class="toolbar desktop-toolbar">
<slot name="toolbar" v-bind="contextRx" />
</div>
</template>
<template v-if="'subnav-start' in $slots" #subtitle>
<slot name="subnav-start" v-bind="contextRx" />
</template>
<template #links>
<slot name="subnav-links" v-bind="contextRx">
<div class="buttons">
<VLink
v-for="link in props.links"
:key="link.to"
:to="link.to"
class="button"
>
{{ link.label }}
</VLink>
</div>
</slot>
</template>
<template v-if="'subnav-end' in $slots" #toolbar-bottom>
<slot name="subnav-end" v-bind="contextRx" />
</template>
</Navsearch>
<!-- /Desktop navigation -->
<ViewWrapper top-nav>
<template v-if="props.size === 'full'">
<div class="is-navbar-lg">
<slot name="page-heading">
<NavsearchPageTitleMobile>
<slot v-bind="contextRx" name="title-mobile">
<h1 class="title is-4">
{{ pageTitle }}
</h1>
</slot>
<template #toolbar>
<slot v-bind="contextRx" name="toolbar" />
</template>
</NavsearchPageTitleMobile>
</slot>
<slot v-bind="contextRx" />
</div>
</template>
<PageContentWrapper v-else :size="props.size">
<PageContent
class="is-relative"
>
<div class="is-navbar-lg">
<slot name="page-heading">
<NavsearchPageTitleMobile>
<slot v-bind="contextRx" name="title-mobile">
<h1 class="title is-4">
{{ pageTitle }}
</h1>
</slot>
<template #toolbar>
<slot v-bind="contextRx" name="toolbar" />
</template>
</NavsearchPageTitleMobile>
</slot>
<slot v-bind="contextRx" />
</div>
</PageContent>
</PageContentWrapper>
</ViewWrapper>
<slot v-bind="contextRx" name="extra" />
</div>
</template>
<style lang="scss" scoped>
:deep(.view-wrapper.has-top-nav) {
.is-stuck {
top: 100px;
}
}
.is-shrink,
.is-reveal {
.navbar-navbar-clean {
transition: transform 0.3s;
}
}
.is-shrink {
&.is-scrolled {
.navbar-navbar-clean:not(:has(.navbar-navbar-inner:focus-within)) {
transform: translateY(-50px);
}
}
}
.is-reveal {
&.is-scrolled {
.navbar-navbar-clean:not(:focus-within) {
transform: translateY(-100%);
}
}
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div class="page-title has-text-centered">
<!-- Mobile Page Title -->
<div class="title-wrap">
<slot />
</div>
<div class="toolbar mobile-toolbar">
<slot name="toolbar" />
</div>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { NavsearchItem } from './navsearch.types'
const props = defineProps<{
items?: NavsearchItem[]
}>()
</script>
<template>
<div class="mobile-subsidebar">
<div class="inner">
<div v-if="'default' in $slots" class="sidebar-title">
<slot />
</div>
<div v-if="'default' in $slots" class="navbar-divider" />
<ul
class="submenu has-slimscroll"
>
<slot name="links">
<li v-for="item of props.items" :key="item.to">
<VLink :to="item.to">
{{ item.label }}
</VLink>
</li>
</slot>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
.mobile-subsidebar .inner {
margin-inline-start: 0;
width: 100%;
}
</style>
<style lang="scss">
@import '/@src/scss/layout/mobile-subsidebar';
</style>

View File

@@ -0,0 +1,14 @@
import type { InjectionKey } from 'vue'
import type { NavsearchLayoutContext } from './navsearch.types'
export const injectionKey = Symbol('navsearch-layout') as InjectionKey<NavsearchLayoutContext>
export function useNavsearchLayoutContext() {
const context = inject(injectionKey)
if (!context) {
throw new Error('useNavsearchLayoutContext() is called outside of <NavsearchLayout> tree.')
}
return context
}

View File

@@ -0,0 +1,17 @@
// -- Navsearch
export type NavsearchTheme = 'default' | 'fade'
export type NavsearchScrollBehavior = 'reveal' | 'shrink' | 'fixed'
export interface NavsearchItem {
to: string
label: string
}
// -- Context
export interface NavsearchLayoutContext {
theme: ComputedRef<NavsearchTheme>
scrollBehavior: ComputedRef<NavsearchScrollBehavior>
links: ComputedRef<NavsearchItem[]>
isMobileSidebarOpen: Ref<boolean>
}

View File

@@ -0,0 +1,58 @@
<template>
<div class="page-content">
<slot />
</div>
</template>
<style lang="scss">
/* ==========================================================================
3. Page Content
========================================================================== */
.page-content {
padding: 0 40px;
&.is-relative {
position: relative;
}
&.kanban-content {
padding: 0 20px;
}
&.chat-content {
padding: 0 40px;
}
&.card-content {
padding: 0 20px;
}
&.waterfall-content {
overflow: hidden;
}
&.projects-content {
padding: 0 40px;
}
}
@media only screen and (device-width >= 768px) and (device-width <= 1024px) and (orientation: portrait) {
.page-content {
padding: 0 10px;
&.is-explore {
padding: 0 !important;
}
}
}
@media (width <= 767px) {
.page-content {
padding: 0 10px !important;
&.projects-content {
padding: 0 16px;
}
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
size?: 'default' | 'large' | 'wide'
}>(), {
size: 'default',
})
</script>
<template>
<div class="page-content-wrapper" :class="[`is-size-${props.size}`]">
<slot />
</div>
</template>
<style lang="scss">
/* ==========================================================================
3. Page Content
========================================================================== */
.page-content-wrapper {
width: 100%;
max-width: 1200px;
margin: 0 auto;
&.is-size-default {
padding-bottom: 100px;
}
&.is-size-large {
padding-bottom: 100px;
}
&.is-size-wide {
padding-bottom: 32px;
}
.page-content {
padding: 0 40px;
&.is-relative {
position: relative;
}
&.kanban-content {
padding: 0 20px;
}
&.chat-content {
padding: 0 40px;
}
&.card-content {
padding: 0 20px;
}
&.waterfall-content {
overflow: hidden;
}
&.projects-content {
padding: 0 40px;
}
}
}
// Enable this setting if you want the dashboard to be larger
@media only screen and (width >= 1408px) {
.page-content-wrapper {
&.is-size-large {
max-width: 1380px;
}
&.is-size-wide {
max-width: none;
}
}
}
@media only screen and (device-width >= 768px) and (device-width <= 1024px) and (orientation: portrait) {
.page-content-wrapper {
.page-content {
padding: 0;
}
}
}
</style>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
const props = defineProps<{
topNav?: boolean
full?: boolean
}>()
</script>
<template>
<div
class="view-wrapper"
:class="[
props.topNav && 'has-top-nav',
props.full && 'view-wrapper-full',
]"
>
<slot />
</div>
</template>
<style lang="scss">
.view-wrapper {
&.has-top-nav {
margin-inline-start: 0 !important;
width: 100% !important;
padding-top: 1px;
.is-stuck {
position: fixed;
top: 78px;
inset-inline-start: 0;
margin-inline-start: 0;
border-inline-start: 0 !important;
width: 100%;
z-index: 14;
&.stuck-header {
padding-inline-end: 20px !important;
.form-head-inner,
.form-header-inner {
max-width: 1240px;
margin: 0 auto;
}
}
}
.page-title {
display: none;
}
.has-navbar-spacing {
margin-top: -20px;
}
.is-navbar-md {
margin-top: 64px;
}
.is-navbar-lg {
margin-top: 30px;
}
.is-navbar-xl {
margin-top: 130px;
}
.page-content-wrapper {
padding-top: 30px;
}
}
&.view-wrapper-full {
width: 100%;
margin-inline-start: 0;
&.is-pushed-block {
margin-inline-start: 280px;
width: calc(100% - 280px);
.is-stuck {
margin-inline-start: 280px;
width: calc(100% - 280px);
}
}
}
}
.view-wrapper {
position: relative;
height: 100%;
min-height: 100vh;
width: calc(100% - 80px);
// padding: 0 0 60px;
margin-inline-start: 80px;
background: var(--background-grey);
transition: all 0.3s; // transition-all test
// 25.05.07 수정 및 주석
//&.is-pushed-full {
// margin-inline-start: 320px;
// width: calc(100% - 320px);
//
// .is-stuck {
// margin-inline-start: 320px;
// width: calc(100% - 320px);
// }
//}
.is-stuck {
position: fixed;
top: 0;
inset-inline-start: 0;
margin-inline-start: 80px;
width: calc(100% - 80px);
z-index: 14;
}
}
.is-dark {
.view-wrapper {
background: color-mix(in oklab, var(--dark-sidebar), white 10%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%);
}
}
@media (width <= 767px) {
.view-wrapper {
&.has-top-nav {
.is-navbar-md,
.is-navbar-lg,
.is-navbar-xl {
margin-top: 0;
}
.page-title {
display: flex !important;
}
.page-content-wrapper {
padding-top: 0;
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.view-wrapper {
&.has-top-nav {
.is-navbar-md,
.is-navbar-lg,
.is-navbar-xl {
margin-top: 0;
}
.page-title {
display: flex !important;
}
}
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: landscape) {
.view-wrapper {
width: calc(100% - 60px) !important;
margin-inline-start: 60px !important;
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
// Layout
.view-wrapper {
width: 100% !important;
margin-inline-start: 0 !important;
margin-top: 60px !important;
padding-inline-start: 40px;
padding-inline-end: 40px;
&.is-explore {
padding-inline-start: 0 !important;
padding-inline-end: 0 !important;
}
}
}
@media (width <= 767px) {
// Layout
.view-wrapper {
width: 100% !important;
margin-inline-start: 0 !important;
margin-top: 60px !important;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
const modelValue = defineModel<boolean>({
default: false,
})
</script>
<template>
<a
class="navbar-burger burger is-hidden-desktop"
:class="[modelValue && 'is-active']"
role="button"
aria-label="menu"
aria-expanded="false"
tabindex="0"
@keydown.enter.prevent="modelValue = !modelValue"
@click="modelValue = !modelValue"
>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</a>
</template>
<style scoped lang="scss">
.navbar-burger {
height: 60px;
width: 60px;
margin-inline-start: 0 !important;
background: transparent !important;
display: flex;
.is-active,
&:hover,
&:focus {
background-color: rgb(0 0 0 / 2%);
}
span {
height: 2px;
background-color: var(--muted-grey);
}
}
</style>

View File

@@ -0,0 +1,438 @@
<script setup lang="ts">
const modelValue = defineModel<boolean>({
default: false,
})
</script>
<template>
<nav class="navbar mobile-navbar is-hidden-desktop is-hidden-tablet">
<div class="container">
<!-- Brand -->
<div class="navbar-brand">
<!-- Mobile menu toggler icon -->
<div class="brand-start">
<MobileBurger v-model="modelValue" />
</div>
<slot name="logo" />
</div>
</div>
</nav>
</template>
<style lang="scss">
.mobile-navbar {
position: fixed;
top: 0;
inset-inline-start: 0;
display: none;
width: 100%;
z-index: 100;
box-shadow: 0 0 8px 0 rgb(0 0 0 / 12%);
transition: all 0.3s; // transition-all test
margin: 0;
&.no-shadow {
box-shadow: none !important;
}
.navbar-brand {
.is-brand {
img {
position: relative;
height: 32px !important;
max-height: 32px !important;
}
}
.profile-dropdown {
.dropdown-menu {
margin-top: 20px;
}
}
}
.navbar-item {
&.has-icon {
padding: 0.75rem !important;
border-bottom: 1px solid var(--fade-grey);
&:last-child {
border-bottom: none !important;
}
.iconify {
color: var(--primary);
}
.sidebar-icon {
.path {
fill: var(--primary);
}
}
}
&.is-sidebar-toggler {
.iconify {
color: var(--muted-grey) !important;
}
}
&.is-flex {
display: flex;
justify-content: space-between;
align-items: center;
span {
display: block;
&.menu-badge {
color: var(--primary);
width: 20px;
height: 20px;
border: 1px solid var(--primary);
border-radius: var(--radius-rounded);
display: flex;
justify-content: center;
align-items: center;
font-size: 90%;
font-weight: 500;
}
}
}
&.is-notification {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 38px;
width: 38px;
transition: all 0.3s; // transition-all test
border-radius: var(--radius-rounded);
margin-inline-end: 12px;
.navbar-link {
padding: 0;
display: block;
width: 18px;
height: 18px;
}
.iconify {
font-size: 18px;
color: var(--muted-grey);
transition: all 0.3s; // transition-all test
}
.new-indicator {
position: absolute;
top: -9px;
inset-inline-end: -9px;
display: block;
width: 8px;
height: 8px;
border-radius: var(--radius-rounded);
background: var(--danger);
}
&:hover,
&.is-active {
box-shadow: 0 3px 10px 4px rgb(0 0 0 / 7%);
.iconify {
color: var(--primary);
}
}
.navbar-dropdown {
position: fixed;
padding-bottom: 15px;
top: 68px;
inset-inline-start: 0;
inset-inline-end: 0;
margin: 0 auto;
width: 96%;
.title {
display: flex;
justify-content: space-between;
align-items: center;
.count,
.view-all {
font-size: 0.8rem;
color: var(--danger);
text-transform: uppercase;
font-weight: 500;
}
}
.heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1rem;
border-bottom: 0.01rem solid var(--light-grey);
.heading-left {
h6 {
font-size: 0.9rem;
font-weight: 500;
color: var(--light-text);
line-height: 1.1;
}
}
.heading-right {
.notification-link {
margin: 0.4rem 0;
color: var(--primary);
font-weight: 500;
}
}
}
.inner {
position: relative;
width: 100%;
height: 264px;
overflow: auto;
.notification-list {
list-style-type: none;
padding: 0.5rem 1rem;
margin: 0 0 0.5rem;
.notification-item {
display: flex;
align-items: center;
padding: 0.65rem 0;
.img-left {
img {
display: inline-block;
vertical-align: middle;
height: 3rem;
max-height: 3rem;
width: 3rem;
margin-inline-end: 0.75rem;
border-radius: var(--radius-rounded);
max-width: 100%;
}
}
.user-content {
text-align: inset-inline-start;
.user-info {
color: var(--dark-text);
margin: 0.15rem 0 0;
span {
font-weight: 500;
}
}
.time {
margin: 0;
color: var(--light-text);
}
}
}
}
}
}
}
}
.navbar-menu {
background: var(--white);
.navbar-item,
.navbar-link {
color: var(--dark);
}
.navbar-link {
display: flex;
justify-content: flex-start;
align-items: center;
padding-inline-end: 10px !important;
&.is-active {
.link-chevron {
transform: rotate(calc(var(--transform-direction) * 90deg)) !important;
}
}
img {
height: 36px;
width: 36px;
max-height: 36px !important;
border-radius: var(--radius-rounded);
}
.iconify {
height: 16px;
width: 16px;
color: var(--primary);
}
span {
margin: 0 10px;
&.is-heading {
font-size: 12px;
font-weight: 500;
color: var(--dark);
letter-spacing: 1px;
text-transform: uppercase;
}
&.is-subheading {
font-size: 10px;
font-weight: 400;
color: var(--muted-grey);
letter-spacing: 1px;
text-transform: uppercase;
}
&.is-block span {
display: block;
}
&.link-chevron {
margin-inline-start: auto;
height: 30px;
width: 30px;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s; // transition-all test
transform: rotate(calc(var(--transform-direction) * 0));
.iconify {
height: 18px;
width: 18px;
color: var(--muted-grey);
}
}
}
&:hover {
background: transparent !important;
}
}
.navbar-dropdown .navbar-item {
font-size: 95%;
padding: 0.75rem 1.5rem !important;
color: var(--muted-grey);
&.is-active,
&:hover {
color: var(--primary);
background: color-mix(in oklab, var(--placeholder), white 16%);
}
}
}
}
/* ==========================================================================
2. Mobile Navbar Dark mode
========================================================================== */
.is-dark {
.mobile-navbar {
background: var(--dark-sidebar);
.navbar-menu.is-active {
background: color-mix(in oklab, var(--dark-sidebar), white 3%);
.navbar-link {
.is-heading {
color: color-mix(in oklab, var(--primary-grey), white 10%);
}
.iconify {
color: var(--primary);
}
}
.navbar-item.has-icon {
border-bottom-color: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
}
.navbar-dropdown .navbar-item {
color: color-mix(in oklab, var(--primary-grey), black 5%) !important;
}
.is-search .control {
input {
background: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
border-color: color-mix(in oklab, var(--dark-sidebar), white 10%) !important;
color: var(--primary-grey);
&:focus {
~ span .iconify {
color: var(--primary);
}
}
}
}
}
}
.is-notification {
&.is-active {
.navbar-link {
.iconify {
color: var(--primary) !important;
}
}
}
.navbar-dropdown {
background: var(--dark-sidebar) !important;
border-color: var(--dark-sidebar) !important;
.heading {
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%) !important;
.heading-right {
.notification-link {
color: var(--primary) !important;
}
}
}
.inner {
.notification-list {
li {
.notification-item {
.user-content {
p {
color: var(--dark-dark-text) !important;
}
}
}
}
}
}
}
}
}
@media only screen and (device-width >= 768px) and (device-width <= 1024px) and (orientation: portrait) {
.mobile-navbar {
display: flex;
}
}
@media (width <= 767px) {
.mobile-navbar {
display: flex;
}
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<div class="mobile-overlay" />
</template>
<style lang="scss">
.mobile-overlay {
background: rgb(0 0 0 / 30%);
position: fixed;
top: 0;
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
z-index: 20;
backdrop-filter: blur(1px);
}
</style>

View File

@@ -0,0 +1,150 @@
<script setup lang="ts">
</script>
<template>
<div class="mobile-main-sidebar">
<div class="inner">
<ul class="icon-side-menu">
<slot name="links" />
</ul>
<ul class="bottom-icon-side-menu">
<slot name="links-bottom" />
</ul>
</div>
</div>
</template>
<style lang="scss">
.mobile-main-sidebar {
position: fixed;
top: 60px;
inset-inline-start: 0;
height: calc(100% - 60px);
width: 60px;
background: var(--white);
border-top: 1px solid var(--fade-grey);
border-inline-end: 1px solid var(--fade-grey);
z-index: 100;
transform: translateX(calc(var(--transform-direction) * -100%));
transition: all 0.3s; // transition-all test
&.is-active {
transform: translateX(calc(var(--transform-direction) * 0));
}
.inner {
height: 100%;
width: 100%;
position: relative;
.icon-side-menu,
.bottom-icon-side-menu {
li {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
a {
display: block;
position: relative;
transform: rotate(calc(var(--transform-direction) * 0));
opacity: 1;
transition: all 0.3s; // transition-all test
&:hover,
&.is-active {
> .iconify {
color: var(--primary);
}
}
> .iconify {
color: var(--title-grey);
font-size: 18px;
}
.sidebar-icon {
display: flex;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
transition: all 0.3s; // transition-all test
}
&:hover .iconify,
&.is-active .iconify {
color: var(--primary);
}
&.is-opened {
transform: rotate(calc(var(--transform-direction) * 360deg));
opacity: 0;
}
&.is-inactive {
transform: rotate(calc(var(--transform-direction) * -360deg));
opacity: 0;
}
&.is-selected,
&.router-link-exact-active {
.iconify {
color: var(--primary);
}
}
}
#open-filters {
.iconify {
transform: rotate(calc(var(--transform-direction) * 0));
transition: all 0.3s; // transition-all test
}
&:hover {
.iconify {
transform: rotate(calc(var(--transform-direction) * 145deg));
}
}
}
&.is-active {
a .iconify {
color: var(--primary);
}
}
}
}
.bottom-icon-side-menu {
position: absolute;
bottom: 0;
inset-inline-start: 0;
}
}
}
.is-dark {
.mobile-main-sidebar {
background: color-mix(in oklab, var(--dark-sidebar), black 6%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 1%) !important;
.inner {
.icon-side-menu {
li {
a {
&.is-active {
.iconify {
color: var(--primary);
}
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,525 @@
<script setup lang="ts">
import { useSidebarLayoutContext } from './sidebar.context'
const {
theme,
isOpen,
} = useSidebarLayoutContext()
const themeClasses = computed(() => {
switch (theme.value) {
case 'color':
return 'is-colored'
case 'labels':
return 'has-labels'
case 'labels-hover':
return 'has-labels has-hover-labels'
case 'float':
return !isOpen.value ? 'is-float' : 'is-float is-bordered'
case 'curved':
return !isOpen.value ? 'is-curved' : ''
case 'color-curved':
return !isOpen.value ? 'is-colored is-curved' : 'is-colored'
default:
return ''
}
})
</script>
<template>
<div
class="main-sidebar"
:class="[themeClasses]"
>
<div v-if="'logo' in $slots" class="sidebar-brand">
<slot name="logo" />
</div>
<div class="sidebar-inner">
<div class="naver" />
<ul class="icon-menu has-slimscroll">
<slot name="links" />
</ul>
<!-- User account -->
<ul class="bottom-menu">
<slot name="links-bottom" />
</ul>
</div>
</div>
</template>
<style lang="scss">
.main-sidebar {
position: fixed;
top: 0;
inset-inline-start: 0;
margin-inline-start: 0;
height: 100vh;
width: 80px;
background-color: var(--body-color);
box-shadow: none;
z-index: 35;
transition:
border-radius 0.3s ease-in,
background-color 0.3s ease-in,
top 0.3s ease-in,
margin-inline-start 0.3s ease-in,
height 0.3s ease-in;
&.is-bordered {
border-inline-end: 1px solid var(--fade-grey) !important;
}
&.is-open {
box-shadow: 2px 0 2px 0 rgb(0 0 0 / 2%);
}
&.is-curved {
&:not(.is-bordered) {
border-start-end-radius: 20px;
border-end-end-radius: 20px;
border-inline-end: 1px solid color-mix(in oklab, var(--fade-grey), black 4%) !important;
.sidebar-brand {
border-start-end-radius: 20px;
}
}
}
&.is-colored {
border-color: color-mix(in oklab, var(--landing-yyy), white 2%);
background: var(--landing-yyy);
.sidebar-inner {
.naver {
background: var(--white);
}
.icon-menu,
.bottom-menu {
li {
a {
&:hover,
&.is-active,
&.router-link-active {
.sidebar-svg {
color: var(--smoke-white);
}
}
.sidebar-svg {
color: var(--light-text);
}
}
}
}
}
}
&.has-labels {
&.has-hover-labels {
.sidebar-inner {
.icon-menu,
.bottom-menu {
li {
&:hover {
a {
&::after {
opacity: 1 !important;
}
}
}
a {
&.is-active,
&.router-link-active {
&::after {
opacity: 1 !important;
}
}
&::after {
opacity: 0;
}
}
}
}
}
}
.sidebar-inner {
.icon-menu,
.bottom-menu {
overflow-x: hidden;
li {
a {
&.router-link-active {
&::after {
color: var(--primary);
}
}
&::after {
content: attr(data-content);
position: absolute;
bottom: -14px;
inset-inline-start: -29px;
inset-inline-end: 0;
margin: 0 auto;
font-family: var(--font);
font-size: 0.65rem;
font-weight: 500;
color: var(--light-text);
text-transform: uppercase;
text-align: center;
width: 80px;
transition: opacity 0.3s;
}
.iconify {
position: relative;
top: -4px;
}
}
}
}
}
}
&.is-float {
border-radius: 1000px;
overflow: hidden;
width: 74px;
margin-inline-start: 6px;
top: 6px;
height: calc(100vh - 12px);
border: none !important;
&:not(.is-bordered) {
box-shadow: var(--light-box-shadow);
}
&.is-bordered {
width: 80px;
margin-inline-start: 0;
top: 0;
height: 100vh;
border-radius: 0;
.sidebar-brand {
width: 80px;
}
.sidebar-inner {
.icon-menu,
.bottom-menu {
li {
width: 80px;
}
}
}
}
.sidebar-brand {
width: 74px;
}
.sidebar-inner {
.icon-menu,
.bottom-menu {
li {
width: 74px;
}
}
.bottom-menu {
padding-bottom: 4px;
}
}
}
.sidebar-brand {
width: 80px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
img {
margin-top: 5px;
width: 36px;
height: auto;
}
}
.sidebar-inner {
height: calc(100% - 60px);
width: 100%;
position: relative;
.naver {
position: absolute;
top: -150px;
inset-inline-start: 0;
height: 64px;
width: 4px;
border-radius: 100px;
background: var(--primary);
transition: all 0.3s; // transition-all test
&.is-search-results {
margin-top: 240px;
}
&.from-bottom {
top: unset !important;
bottom: -64px;
margin-top: 0 !important;
}
}
.icon-menu {
overflow-y: auto;
overflow-x: hidden;
max-height: 400px;
}
.icon-menu,
.bottom-menu {
li {
position: relative;
width: 80px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
&.is-active,
&.router-link-active {
.iconify {
color: var(--primary);
}
}
.is-messages-counter {
position: absolute;
top: 6px;
inset-inline-end: 16px;
display: block;
line-height: 17px;
background: var(--danger);
color: var(--white);
font-weight: 500;
font-size: 0.6rem;
border-radius: 100px;
border: 1px solid var(--white);
transform: scale(0.8);
}
a {
display: flex;
position: relative;
transform: rotate(calc(var(--transform-direction) * 0));
opacity: 1;
transition: all 0.3s; // transition-all test
&:hover,
&.is-selected,
&.router-link-active {
.sidebar-svg {
color: var(--primary);
}
}
.sidebar-svg {
font-size: 20px;
color: var(--title-grey);
stroke-width: 1.6px;
transition: all 0.3s; // transition-all test
}
&:hover .iconify,
&.is-active .iconify,
&.router-link-exact-active .iconify {
color: var(--primary);
}
&.is-opened {
transform: rotate(calc(var(--transform-direction) * 360deg));
opacity: 0;
}
&.is-inactive {
transform: rotate(calc(var(--transform-direction) * -360deg));
opacity: 0;
}
}
}
}
.bottom-menu {
position: absolute;
bottom: 0;
padding: 0;
li {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
&.is-rotate {
a:hover {
animation: rotating 1s linear infinite;
}
}
a:not(.dropdown-item) {
display: flex;
justify-content: center;
align-items: center;
}
.dropdown {
position: relative;
display: block;
height: 48px;
width: 48px !important;
> img {
height: 48px;
width: 48px;
border-radius: var(--radius-rounded);
position: relative;
z-index: 1;
}
.status-indicator {
display: block;
position: absolute;
top: 0;
inset-inline-end: 0;
width: 14px;
height: 14px;
border-radius: var(--radius-rounded);
border: 2px solid var(--white);
background: var(--success);
z-index: 2;
}
}
}
}
}
}
.is-dark {
.main-sidebar:not(.is-colored) {
background: var(--dark-sidebar);
&.is-bordered {
border-inline-end: 1px solid var(--dark-sidebar) !important;
}
&.is-curved {
&:not(.is-bordered) {
border-color: color-mix(in oklab, var(--dark-sidebar), white 16%) !important;
}
}
.naver {
background: var(--primary);
}
.icon-menu,
.bottom-menu {
li {
a {
&.is-selected,
&.router-link-active {
.iconify {
color: var(--primary) !important;
}
.sidebar-icon .path {
fill: var(--primary) !important;
}
}
&:hover {
.iconify {
color: var(--primary) !important;
}
.sidebar-icon .path {
fill: var(--primary) !important;
}
}
.sidebar-icon.is-active .path {
fill: var(--primary) !important;
}
}
.iconify {
color: color-mix(in oklab, var(--primary-grey), white 3%);
}
.status-indicator {
border-color: var(--dark-sidebar) !important;
}
}
}
.sidebar-inner {
.icon-menu,
.bottom-menu {
li {
a {
&.is-active {
.iconify {
color: var(--primary) !important;
}
}
}
}
}
}
}
.main-sidebar {
&.is-colored {
// background: color-mix(in oklab, var(--primary), black 2%);
// border-color: color-mix(in oklab, var(--primary), black 2%) !important;
.sidebar-inner {
.naver {
opacity: 0.8;
}
.icon-menu,
.bottom-menu {
li {
a {
&:hover,
&.is-active {
.sidebar-svg {
color: var(--smoke-white);
stroke: var(--smoke-white);
opacity: 1;
}
}
.sidebar-svg {
color: color-mix(in oklab, var(--smoke-white), white 2%);
opacity: 0.5;
}
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { SidebarItem, SidebarItemAction } from './sidebar.types'
import { useSidebarLayoutContext } from './sidebar.context'
const props = defineProps<{
link: SidebarItem
}>()
const {
activeSubsidebarId,
toggleSubsidebar,
} = useSidebarLayoutContext()
</script>
<template>
<a
v-if="props.link.type === 'subsidebar'"
:class="[activeSubsidebarId === props.link.id && 'is-active']"
tabindex="0"
role="button"
:data-content="props.link.label"
@keydown.enter.prevent="() => toggleSubsidebar(props.link.id)"
@click.prevent="() => toggleSubsidebar(props.link.id)"
>
<VIcon
class="sidebar-svg"
:icon="props.link.icon"
/>
</a>
<component
:is="props.link.component"
v-else-if="props.link.type === 'component'"
:title="props.link.label"
/>
<VLink
v-else-if="props.link.type === 'link'"
:title="props.link.label"
:to="props.link.to"
:data-content="props.link.label"
>
<VIcon
class="sidebar-svg"
:icon="props.link.icon"
/>
</VLink>
<a
v-else-if="props.link.type === 'action'"
role="button"
tabindex="0"
:title="props.link.label"
:data-content="props.link.label"
@click="(props.link as SidebarItemAction).onClick"
@keydown.enter="(props.link as SidebarItemAction).onClick"
>
<VIcon
class="sidebar-svg"
:icon="props.link.icon"
/>
</a>
</template>

View File

@@ -0,0 +1,256 @@
<script setup lang="ts">
import type { SidebarLayoutContext, SidebarItem, SidebarItemSubsidebar, SidebarTheme } from './sidebar.types'
import { injectionKey } from './sidebar.context'
const props = withDefaults(
defineProps<{
links?: SidebarItem[]
linksBottom?: SidebarItem[]
theme?: SidebarTheme
size?: 'default' | 'large' | 'wide' | 'full'
defaultSidebar?: string
closeOnChange?: boolean
openOnMounted?: boolean
}>(),
{
links: () => [],
linksBottom: () => [],
defaultSidebar: '',
theme: 'default',
size: 'default',
},
)
const pageTitle = useVueroContext<string>('page-title')
const route = useRoute()
const isMobileSidebarOpen = ref(false)
const isDesktopSidebarOpen = ref(props.openOnMounted)
const activeSubsidebarId = ref(props.defaultSidebar)
const subsidebars = computed(() =>
props.links.filter(item => item.type === 'subsidebar') as SidebarItemSubsidebar[],
)
const activeSubsidebar = computed(() => {
if (!activeSubsidebarId.value || subsidebars.value.length === 0) {
return undefined
}
return subsidebars.value?.find(item => item.id === activeSubsidebarId.value)
})
const isOpen = computed(() => {
return Boolean(activeSubsidebar.value && (isMobileSidebarOpen.value || isDesktopSidebarOpen.value))
})
function toggleSubsidebar(id: string) {
if (id === activeSubsidebarId.value) {
isDesktopSidebarOpen.value = !isDesktopSidebarOpen.value
}
else {
isDesktopSidebarOpen.value = true
activeSubsidebarId.value = id
}
}
// provide context to children
const context: SidebarLayoutContext = {
links: computed(() => props.links),
linksBottom: computed(() => props.linksBottom),
theme: computed(() => props.theme),
defaultSidebar: computed(() => props.defaultSidebar),
closeOnChange: computed(() => props.closeOnChange),
openOnMounted: computed(() => props.openOnMounted),
isMobileSidebarOpen,
isDesktopSidebarOpen,
activeSubsidebarId,
subsidebars,
activeSubsidebar,
isOpen,
toggleSubsidebar,
}
provide(injectionKey, context)
// using reactive context for slots, has better dev experience
const contextRx = reactive(context)
// close subsidebar when route changes
watch(
() => route.fullPath,
() => {
isMobileSidebarOpen.value = false
if (props.closeOnChange) {
isDesktopSidebarOpen.value = false
}
},
)
</script>
<template>
<div class="sidebar-layout">
<!-- Mobile navigation -->
<MobileNavbar v-model="isMobileSidebarOpen">
<template #logo>
<slot name="logo" v-bind="contextRx" />
<div class="brand-end">
<slot name="toolbar-mobile" v-bind="contextRx" />
</div>
</template>
</MobileNavbar>
<MobileSidebar
:class="[activeSubsidebar && isMobileSidebarOpen && 'is-active']"
>
<template v-if="props.links.length" #links>
<slot name="sidebar-links-mobile" v-bind="contextRx">
<li
v-for="link in props.links"
:key="link.id"
:class="[link.hideMobile ? 'is-hidden-mobile' : '']"
>
<SidebarItem :link />
</li>
</slot>
</template>
<template v-if="props.linksBottom.length" #links-bottom>
<slot name="sidebar-links-bottom-mobile" v-bind="contextRx">
<li
v-for="link in props.linksBottom"
:key="link.id"
:class="[link.hideMobile ? 'is-hidden-mobile' : '']"
>
<SidebarItem :link />
</li>
</slot>
</template>
</MobileSidebar>
<Transition name="fade">
<MobileOverlay
v-if="isMobileSidebarOpen"
@click="isMobileSidebarOpen = false"
/>
</Transition>
<Transition name="slide-x">
<KeepAlive>
<SidebarSubsidebarMobile
v-if="activeSubsidebar && isMobileSidebarOpen"
:key="activeSubsidebar.id"
:items="activeSubsidebar.subsidebar.items"
:label="activeSubsidebar.subsidebar.label"
>
<slot name="subsidebar-title-mobile" v-bind="contextRx" />
</SidebarSubsidebarMobile>
</KeepAlive>
</Transition>
<!-- /Mobile navigation -->
<!-- Desktop navigation -->
<Sidebar
:theme="props.theme"
:is-open="activeSubsidebar && isDesktopSidebarOpen"
>
<template
v-if="'logo' in $slots"
#logo
>
<slot name="logo" v-bind="contextRx" />
</template>
<template v-if="props.links.length" #links>
<slot name="sidebar-links" v-bind="contextRx">
<li
v-for="link in props.links"
:key="link.id"
>
<SidebarItem :link />
</li>
</slot>
</template>
<template v-if="props.linksBottom.length" #links-bottom>
<slot name="sidebar-links-bottom" v-bind="contextRx">
<li
v-for="link in props.linksBottom"
:key="link.id"
>
<SidebarItem :link />
</li>
</slot>
</template>
</Sidebar>
<Transition name="slide-x">
<KeepAlive>
<SidebarSubsidebar
v-if="activeSubsidebar && isDesktopSidebarOpen"
:key="activeSubsidebar.id"
:items="activeSubsidebar.subsidebar.items"
:label="activeSubsidebar.subsidebar.label"
@close="isDesktopSidebarOpen = false"
>
<slot name="subsidebar-title" v-bind="contextRx" />
</SidebarSubsidebar>
</KeepAlive>
</Transition>
<!-- /Desktop navigation -->
<ViewWrapper
:class="[
activeSubsidebar && isDesktopSidebarOpen && 'is-pushed-full',
]"
>
<template v-if="props.size === 'full'">
<slot name="page-heading" v-bind="contextRx">
<SidebarPageHeading
:open="activeSubsidebar && isDesktopSidebarOpen"
@toggle="isDesktopSidebarOpen = !isDesktopSidebarOpen"
>
<span>{{ pageTitle }}</span>
<template #toolbar>
<slot
name="toolbar"
v-bind="contextRx"
/>
</template>
</SidebarPageHeading>
</slot>
<slot v-bind="contextRx" />
</template>
<PageContentWrapper v-else :size="props.size">
<PageContent
class="is-relative"
>
<slot name="page-heading" v-bind="contextRx">
<SidebarPageHeading
:open="activeSubsidebar && isDesktopSidebarOpen"
@toggle="isDesktopSidebarOpen = !isDesktopSidebarOpen"
>
<span>{{ pageTitle }}</span>
<template #toolbar>
<slot
name="toolbar"
v-bind="contextRx"
/>
</template>
</SidebarPageHeading>
</slot>
<slot v-bind="contextRx" />
</PageContent>
</PageContentWrapper>
</ViewWrapper>
<slot
name="extra"
v-bind="contextRx"
/>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
const props = defineProps<{
open?: boolean
}>()
const emits = defineEmits<{
toggle: []
}>()
</script>
<template>
<div class="page-title has-text-centered">
<div
class="vuero-hamburger nav-trigger push-resize"
tabindex="0"
role="button"
@keydown.enter.prevent="() => emits('toggle')"
@click="() => emits('toggle')"
>
<span class="menu-toggle has-chevron">
<span
:class="[props.open && 'active']"
class="icon-box-toggle"
>
<span class="rotate">
<i
aria-hidden="true"
class="icon-line-top"
/>
<i
aria-hidden="true"
class="icon-line-center"
/>
<i
aria-hidden="true"
class="icon-line-bottom"
/>
</span>
</span>
</span>
</div>
<div class="title-wrap">
<h1 class="title is-4">
<slot />
</h1>
</div>
<div v-if="'toolbar' in $slots" class="toolbar desktop-toolbar">
<slot name="toolbar" />
</div>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { SubsidebarItem } from './sidebar.types'
const props = defineProps<{
label?: string
items: SubsidebarItem[]
}>()
const emits = defineEmits<{
close: []
}>()
</script>
<template>
<div class="sidebar-panel is-generic">
<div class="subpanel-header">
<slot>
<h3>{{ props.label }}</h3>
</slot>
<div
class="panel-close"
tabindex="0"
role="button"
@keydown.enter.prevent="emits('close')"
@click="emits('close')"
>
<VIcon
icon="lucide:x"
/>
</div>
</div>
<div
class="inner has-slimscroll"
>
<ul>
<template v-for="(item, idx) of props.items">
<li
v-if="item.type === 'divider'"
:key="`divider-${idx}`"
class="divider"
:class="[item.label ? 'with-label' : '']"
>
<span v-if="item.label" class="divider-label">{{ item.label }}</span>
</li>
<li v-else-if="item.type === 'link'" :key="`link-${idx}`">
<VLink :to="item.to">
{{ item.label }}
</VLink>
</li>
<VCollapseLinks
v-else-if="item.type === 'collapse'"
:key="`collapse-${item.id}`"
:links="item.children"
>
{{ item.label }}
</VCollapseLinks>
</template>
</ul>
</div>
</div>
</template>
<style lang="scss">
@import '/@src/scss/layout/sidebar-panel';
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { SubsidebarItem } from './sidebar.types'
const props = defineProps<{
label?: string
items: SubsidebarItem[]
}>()
</script>
<template>
<div class="mobile-subsidebar">
<div class="inner">
<div class="sidebar-title">
<slot>
<h3>{{ props.label }}</h3>
</slot>
</div>
<ul
class="submenu has-slimscroll"
>
<template v-for="(item, idx) of props.items">
<li
v-if="item.type === 'divider'"
:key="`divider-${idx}`"
class="divider"
:class="[item.label ? 'with-label' : '']"
>
<span v-if="item.label" class="divider-label">{{ item.label }}</span>
</li>
<li v-else-if="item.type === 'link'" :key="`link-${idx}`">
<VLink :to="item.to">
{{ item.label }}
</VLink>
</li>
<VCollapseLinks
v-else-if="item.type === 'collapse'"
:key="`collapse-${item.id}`"
:links="item.children"
>
{{ item.label }}
</VCollapseLinks>
</template>
</ul>
</div>
</div>
</template>
<style lang="scss">
@import '/@src/scss/layout/mobile-subsidebar';
</style>

View File

@@ -0,0 +1,14 @@
import type { InjectionKey } from 'vue'
import type { SidebarLayoutContext } from './sidebar.types'
export const injectionKey = Symbol('sidebar-layout') as InjectionKey<SidebarLayoutContext>
export function useSidebarLayoutContext() {
const context = inject(injectionKey)
if (!context) {
throw new Error('useSidebarLayoutContext() is called outside of <SidebarLayout> tree.')
}
return context
}

View File

@@ -0,0 +1,105 @@
import type { VNode, Component } from 'vue'
// -- Sidebar
export interface SidebarItemSubsidebar {
type: 'subsidebar'
id: string
icon: string
hideMobile?: boolean
label?: string
subsidebar: {
label: string
items: SubsidebarItem[]
}
}
export interface SidebarItemLink {
type: 'link'
id: string
icon: string
hideMobile?: boolean
label?: string
to: string
}
export interface SidebarItemAction {
type: 'action'
id: string
icon: string
hideMobile?: boolean
label?: string
onClick: (event: Event) => void
}
export interface SidebarItemComponent {
type: 'component'
id: string
hideMobile?: boolean
label?: string
component: string | Component | (() => VNode)
}
export type SidebarItem =
| SidebarItemSubsidebar
| SidebarItemLink
| SidebarItemAction
| SidebarItemComponent
// -- Subsidebar
export interface SubsidebarItemCollapse {
type: 'collapse'
id: string
label: string
children: {
label: string
to: string
icon?: string
tag?: string | number
}[]
}
export interface SubsidebarItemLink {
type: 'link'
label: string
to: string
tag?: string | number
}
export interface SubsidebarItemDivider {
type: 'divider'
label?: string
}
export type SubsidebarItem =
| SubsidebarItemCollapse
| SubsidebarItemLink
| SubsidebarItemDivider
// -- Context
export type SidebarTheme =
| 'default'
| 'color'
| 'color-curved'
| 'curved'
| 'float'
| 'labels'
| 'labels-hover'
export interface SidebarLayoutContext {
links: ComputedRef<SidebarItem[]>
linksBottom: ComputedRef<SidebarItem[]>
theme: ComputedRef<SidebarTheme>
defaultSidebar: ComputedRef<string>
closeOnChange: ComputedRef<boolean>
openOnMounted: ComputedRef<boolean>
isMobileSidebarOpen: Ref<boolean>
isDesktopSidebarOpen: Ref<boolean>
activeSubsidebarId: Ref<string>
isOpen: ComputedRef<boolean>
subsidebars: ComputedRef<SidebarItemSubsidebar[]>
activeSubsidebar: ComputedRef<SidebarItemSubsidebar | undefined>
toggleSubsidebar: (id: string) => void
}

View File

@@ -0,0 +1,656 @@
<script setup lang="ts">
import { useSideblockLayoutContext } from './sideblock.context'
const { theme } = useSideblockLayoutContext()
const themeClasses = computed(() => {
switch (theme.value) {
case 'color':
return 'is-colored'
case 'curved':
return 'is-curved'
case 'color-curved':
return 'is-colored is-curved'
default:
return ''
}
})
</script>
<template>
<div
class="sidebar-block is-active"
:class="[themeClasses]"
>
<div class="sidebar-block-header">
<slot name="header" />
</div>
<div class="sidebar-block-inner">
<ul>
<slot name="links" />
</ul>
</div>
<div class="sidebar-block-footer">
<slot name="links-bottom" />
</div>
</div>
</template>
<style lang="scss">
.sidebar-block {
position: fixed;
top: 0;
inset-inline-start: 0;
height: 100vh;
width: 280px;
background-color: var(--white);
box-shadow: none;
z-index: 35;
transition:
border-radius 0.3s,
transform 0.3s;
&.is-curved {
border-start-end-radius: 2rem;
border-end-end-radius: 2rem;
border-inline-end: 1px solid var(--border) !important;
box-shadow: none;
}
.sidebar-block-header {
display: flex;
align-items: center;
height: 60px;
width: 100%;
padding: 0 2.5rem;
.sidebar-block-logo {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 40px;
margin-inline-end: 0.5rem;
img {
height: 38px;
}
}
h3 {
font-family: var(--font-alt);
color: var(--dark-text);
font-size: 1.3rem;
}
.sidebar-block-close {
margin-inline-start: auto;
display: block;
width: 18px;
height: 18px;
.iconify {
width: 18px;
height: 18px;
}
}
}
.sidebar-block-inner {
position: relative;
height: calc(100vh - 160px);
width: 100%;
overflow-y: auto;
overflow-x: hidden;
background: var(--white);
margin-top: 40px;
&::-webkit-scrollbar {
width: 3px;
}
&::-webkit-scrollbar-thumb {
border-radius: 5px;
background: rgba(0 0 0 / 20%);
}
ul {
padding: 10px 0;
}
li {
display: flex;
justify-content: flex-start;
align-items: center;
border-inline-start: 2px solid transparent;
cursor: pointer;
&.is-active {
a {
font-weight: 500;
color: var(--primary);
}
> a {
font-weight: 600;
}
}
a.router-link-exact-active {
font-weight: 500;
color: var(--primary);
> a {
font-weight: 600;
}
}
&.divider {
cursor: default;
pointer-events: none;
height: 10px;
margin: 5px 20px 10px;
border-bottom: 1px solid rgba(0 0 0 / 15%);
}
&.has-children {
display: block;
height: unset;
min-height: 36px;
margin-bottom: 4px;
&.active {
.collapse-wrap {
margin-bottom: 0.5rem;
> a {
color: var(--dark-text);
font-weight: 600;
background: var(--widget-grey);
margin-bottom: 0.25rem;
.icon {
color: var(--primary);
}
> .iconify {
transform: rotate(calc(var(--transform-direction) * 90deg));
}
}
}
}
&:hover {
.collapse-wrap > a {
color: var(--dark-text);
.icon {
color: var(--primary);
}
}
}
&.is-flex {
display: flex;
justify-content: space-between;
align-items: center;
.tag {
margin-inline-start: auto;
margin-inline-end: 20px;
border-radius: 100px;
background: var(--danger);
color: var(--white);
font-size: 0.8rem;
font-weight: 600;
}
}
.collapse-wrap {
display: flex;
align-items: center;
height: 100%;
> a {
font-family: var(--font-alt);
display: flex;
align-items: center;
font-size: 0.9rem;
font-weight: 500;
color: var(--light-text);
padding: 0.85rem;
margin: 0 2rem;
border-radius: 0.75rem;
.icon {
color: var(--light-text);
font-size: 1.25rem;
margin-inline-end: 1rem;
.iconify {
font-size: 1.25rem;
}
.iconify {
stroke-width: 1.5px;
}
}
> .iconify {
position: relative;
top: 1px;
height: 18px;
width: 18px;
margin-inline-start: auto;
transform: rotate(calc(var(--transform-direction) * 0));
stroke: var(--light-text);
transition: all 0.3s;
}
}
}
ul {
li {
height: 32px;
font-family: var(--font);
&.is-active {
.is-submenu {
font-weight: 500;
.iconify {
display: block;
}
}
}
.is-submenu {
display: flex;
font-weight: 400;
font-family: var(--font);
align-items: center;
padding: 0 2rem 0 3.5rem;
font-size: 0.9rem;
&.is-active,
&.router-link-exact-active {
font-weight: 500;
color: var(--primary);
}
.lnil,
.lnir {
font-size: 1.2rem;
margin-inline-end: 10px;
}
> span {
font-family: var(--font);
}
.iconify {
display: none;
height: 8px;
width: 8px;
max-width: 8px;
min-width: 8px;
stroke-width: 2px;
margin-inline-end: 8px;
&.is-auto {
margin-inline-start: auto;
height: 15px;
width: 15px;
max-width: 15px;
min-width: 15px;
stroke-width: 2px;
margin-inline-end: 4px;
}
}
}
}
}
}
a:not(.single-link) {
font-family: var(--font);
display: block;
width: 100%;
padding: 0 20px;
font-size: 0.95rem;
color: var(--light-text);
&:hover,
&:focus {
color: var(--dark-text);
}
}
> a {
font-family: var(--font-alt);
font-size: 0.9rem;
font-weight: 500;
color: var(--light-text);
}
}
.single-link {
font-family: var(--font-alt);
display: flex;
align-items: center;
font-size: 0.9rem;
font-weight: 500;
color: var(--light-text);
padding: 0.85rem;
margin: 0 2rem 0.25rem;
width: 100%;
border-radius: 0.65rem;
transition: background-color 0.3s;
&:hover,
&:focus,
&.active {
background: var(--widget-grey);
color: var(--dark-text);
.icon {
.iconify {
color: var(--primary);
}
.iconify {
stroke: var(--primary);
}
}
}
.icon {
font-size: 1.25rem;
margin-inline-end: 1rem;
.iconify {
font-size: 1.25rem;
}
.iconify {
stroke-width: 1.5px;
}
}
.badge {
margin-inline-start: auto;
color: var(--white);
background: var(--primary);
height: 1.5rem;
padding: 0.5rem;
font-size: 0.8rem;
font-weight: 500;
line-height: 0.6;
border-radius: 10rem;
}
}
}
.sidebar-block-footer {
height: 60px;
width: 100%;
padding: 0 2.5rem;
display: flex;
align-items: center;
justify-content: space-between;
a:not(.dropdown-item) {
display: flex;
justify-content: center;
align-items: center;
}
.search-link,
.icon-link {
height: 48px;
width: 48px;
color: var(--light-text);
border-radius: 50%;
transition: background-color 0.3s;
.iconify {
font-size: 18px;
height: 20px;
width: 20px;
transition: stroke 0.3s;
}
&:hover {
background: var(--widget-grey);
color: var(--primary);
}
}
.dropdown {
position: relative;
display: block;
height: 42px;
width: 42px !important;
> img {
height: 42px;
width: 42px;
border-radius: 50%;
position: relative;
z-index: 1;
}
.status-indicator {
display: block;
position: absolute;
top: 0;
inset-inline-end: 0;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--white);
background: var(--success);
z-index: 2;
}
}
}
}
/* ==========================================================================
2. Sidebar Block Dark mode
========================================================================== */
.is-dark {
.sidebar-block {
background: color-mix(in oklab, var(--dark-sidebar), white 5%);
border-color: color-mix(in oklab, var(--dark-sidebar), white 5%) !important;
.panel-close {
.iconify {
stroke: var(--muted-grey) !important;
}
}
.sidebar-block-header {
h3 {
color: var(--smoke-white);
}
}
.sidebar-block-inner {
background: color-mix(in oklab, var(--dark-sidebar), white 5%);
li {
&.has-children {
&.active {
.collapse-wrap > a {
color: var(--smoke-white);
background: color-mix(in oklab, var(--dark-sidebar), white 8%);
.icon {
color: var(--accent);
}
}
}
&:hover {
.collapse-wrap > a {
color: var(--smoke-white);
.icon {
color: var(--accent);
}
}
}
}
}
a:not(.single-link) {
&:hover {
color: var(--smoke-white);
}
}
.single-link {
&:hover,
&.active {
background: color-mix(in oklab, var(--dark-sidebar), white 8%);
color: var(--smoke-white);
.icon {
.iconify {
color: var(--accent);
}
.iconify {
stroke: var(--accent);
}
}
}
.badge {
background: var(--accent);
}
}
}
.sidebar-block-footer {
.search-link {
color: var(--light-text);
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark-sidebar), white 8%);
color: var(--accent);
}
}
}
}
}
@media only screen and (width <= 767px) {
.sidebar-block {
display: none;
}
}
@media only screen and (width >= 768px) and (width <= 1024px) and (orientation: portrait) {
.sidebar-block {
display: none;
}
}
/* ==========================================================================
3. Sidebar Block Colored
========================================================================== */
html:not(.is-dark) {
.sidebar-block {
&.is-colored {
background: color-mix(in oklab, var(--dark), black 12%) !important;
border-color: color-mix(in oklab, var(--dark), white 5%) !important;
.panel-close {
.iconify {
stroke: var(--muted-grey) !important;
}
}
.sidebar-block-header {
h3 {
color: var(--smoke-white) !important;
}
}
.sidebar-block-inner {
background: color-mix(in oklab, var(--dark), black 12%) !important;
li {
&.has-children {
&.active {
.collapse-wrap > a {
color: var(--smoke-white) !important;
background: color-mix(in oklab, var(--dark), black 7%) !important;
.icon {
color: var(--primary) !important;
}
}
}
&:hover {
.collapse-wrap > a {
color: var(--smoke-white) !important;
.icon {
color: var(--primary) !important;
}
}
}
}
}
a:not(.single-link) {
&:hover {
color: var(--smoke-white) !important;
}
}
.single-link {
&:hover,
&.active {
background: color-mix(in oklab, var(--dark), black 7%) !important;
color: var(--smoke-white) !important;
.icon {
.iconify {
color: var(--primary) !important;
}
.iconify {
stroke: var(--primary) !important;
}
}
}
.badge {
background: var(--primary) !important;
}
}
}
.sidebar-block-footer {
.search-link {
color: var(--light-text) !important;
&:hover,
&:focus {
background: color-mix(in oklab, var(--dark), black 7%) !important;
color: var(--primary) !important;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { SideblockItem, SideblockItemAction } from './sideblock.types'
const props = defineProps<{
link: SideblockItem
}>()
</script>
<template>
<li v-if="props.link.type === 'link'">
<VLink
:to="props.link.to"
class="single-link"
>
<span class="icon">
<VIcon
:icon="props.link.icon"
/>
</span>
{{ props.link.label }}
<span v-if="props.link.badge !== undefined" class="badge">{{ props.link.badge }}</span>
</VLink>
</li>
<component
:is="props.link.component"
v-else-if="props.link.type === 'component'"
:title="props.link.label"
/>
<li v-else-if="props.link.type === 'action'">
<a
role="button"
tabindex="0"
class="single-link"
@click="(props.link as SideblockItemAction).onClick"
@keydown.enter.prevent="(props.link as SideblockItemAction).onClick"
>
<span class="icon">
<VIcon
:icon="props.link.icon"
/>
</span>
{{ props.link.label }}
<span v-if="props.link.badge !== undefined" class="badge">{{ props.link.badge }}</span>
</a>
</li>
<VCollapseLinks
v-else-if="props.link.type === 'collapse'"
:links="props.link.children"
>
<div class="icon">
<VIcon
:icon="props.link.icon"
/>
</div>
{{ props.link.label }}
</VCollapseLinks>
<li
v-else-if="props.link.type === 'divider'"
class="divider"
/>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { SideblockItem, SideblockItemAction } from './sideblock.types'
const props = defineProps<{
link: SideblockItem
}>()
</script>
<template>
<li
v-if="props.link.type === 'divider'"
class="divider"
:class="[props.link.label ? 'with-label' : '']"
>
<span v-if="props.link.label" class="divider-label">{{ props.link.label }}</span>
</li>
<li v-else-if="props.link.type === 'link'">
<VLink :to="props.link.to">
{{ props.link.label }}
</VLink>
</li>
<li v-else-if="props.link.type === 'action'">
<a
role="button"
tabindex="0"
@click="(props.link as SideblockItemAction).onClick"
@keydown.enter.prevent="(props.link as SideblockItemAction).onClick"
>
{{ props.link.label }}
</a>
</li>
<VCollapseLinks
v-else-if="props.link.type === 'collapse'"
:links="props.link.children"
>
{{ props.link.label }}
</VCollapseLinks>
</template>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { SideblockLayoutContext, SideblockItem, SideblockTheme } from './sideblock.types'
import { injectionKey } from './sideblock.context'
import SideblockItemMobile from './SideblockItemMobile.vue'
const props = withDefaults(
defineProps<{
links?: SideblockItem[]
theme?: SideblockTheme
size?: 'default' | 'large' | 'wide' | 'full'
closeOnChange?: boolean
openOnMounted?: boolean
}>(),
{
links: () => [],
theme: 'default',
size: 'default',
},
)
const pageTitle = useVueroContext<string>('page-title')
const route = useRoute()
const isMobileSideblockOpen = ref(false)
const isDesktopSideblockOpen = ref(props.openOnMounted)
// provide context to children
const context: SideblockLayoutContext = {
links: computed(() => props.links),
theme: computed(() => props.theme),
closeOnChange: computed(() => props.closeOnChange),
openOnMounted: computed(() => props.openOnMounted),
isMobileSideblockOpen,
isDesktopSideblockOpen,
}
provide(injectionKey, context)
// using reactive context for slots, has better dev experience
const contextRx = reactive(context)
watch(
() => route.fullPath,
() => {
isMobileSideblockOpen.value = false
if (props.closeOnChange && isDesktopSideblockOpen.value) {
isDesktopSideblockOpen.value = false
}
},
)
</script>
<template>
<div class="sidebar-layout">
<!-- Mobile navigation -->
<MobileNavbar v-model="isMobileSideblockOpen">
<template #logo>
<slot name="logo" v-bind="contextRx" />
<div class="brand-end">
<slot name="toolbar-mobile" v-bind="contextRx" />
</div>
</template>
</MobileNavbar>
<Transition name="slide-x">
<SideblockSubsidebarMobile
v-if="isMobileSideblockOpen"
:items="props.links"
>
<template #default>
<slot name="sideblock-title-mobile" />
</template>
<template #links>
<slot name="sideblock-links-mobile" v-bind="contextRx">
<SideblockItemMobile
v-for="(link, key) in props.links"
:key
:link
/>
</slot>
</template>
</SideblockSubsidebarMobile>
</Transition>
<Transition name="fade">
<MobileOverlay
v-if="isMobileSideblockOpen"
@click="isMobileSideblockOpen = false"
/>
</Transition>
<!-- /Mobile navigation -->
<!-- Desktop navigation -->
<Transition name="slide-x">
<Sideblock
v-if="isDesktopSideblockOpen"
:theme="props.theme"
>
<template #header>
<slot name="logo" v-bind="contextRx" />
</template>
<template #links>
<slot name="sideblock-links" v-bind="contextRx">
<SideblockItem
v-for="(link, key) in props.links"
:key
:link
/>
</slot>
</template>
<template #links-bottom>
<slot name="sideblock-end" v-bind="contextRx" />
</template>
</Sideblock>
</Transition>
<!-- /Desktop navigation -->
<ViewWrapper
full
:class="[
isDesktopSideblockOpen && 'is-pushed-block',
]"
>
<template v-if="props.size === 'full'">
<slot name="page-heading" v-bind="contextRx">
<SideblockPageHeading
:open="isDesktopSideblockOpen"
@toggle="isDesktopSideblockOpen = !isDesktopSideblockOpen"
>
{{ pageTitle }}
<template #toolbar>
<slot
name="toolbar"
v-bind="contextRx"
/>
</template>
</SideblockPageHeading>
</slot>
<slot v-bind="contextRx" />
</template>
<PageContentWrapper v-else :size="props.size">
<PageContent
class="is-relative"
>
<slot name="page-heading" v-bind="contextRx">
<SideblockPageHeading
:open="isDesktopSideblockOpen"
@toggle="isDesktopSideblockOpen = !isDesktopSideblockOpen"
>
{{ pageTitle }}
<template #toolbar>
<slot
name="toolbar"
v-bind="contextRx"
/>
</template>
</SideblockPageHeading>
</slot>
<slot v-bind="contextRx" />
</PageContent>
</PageContentWrapper>
</ViewWrapper>
<slot name="extra" />
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
const props = defineProps<{
open?: boolean
}>()
const emits = defineEmits<{
toggle: []
}>()
</script>
<template>
<div class="page-title has-text-centered">
<div
class="vuero-hamburger nav-trigger push-resize"
tabindex="0"
role="button"
@keydown.enter.prevent="() => emits('toggle')"
@click="() => emits('toggle')"
>
<span class="menu-toggle has-chevron">
<span
:class="[props.open && 'active']"
class="icon-box-toggle"
>
<span class="rotate">
<i
aria-hidden="true"
class="icon-line-top"
/>
<i
aria-hidden="true"
class="icon-line-center"
/>
<i
aria-hidden="true"
class="icon-line-bottom"
/>
</span>
</span>
</span>
</div>
<div class="title-wrap">
<h1 class="title is-4">
<slot />
</h1>
</div>
<div v-if="'toolbar' in $slots" class="toolbar desktop-toolbar">
<slot name="toolbar" />
</div>
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { SideblockItem } from './sideblock.types'
const props = defineProps<{
label?: string
items: SideblockItem[]
}>()
</script>
<template>
<div class="mobile-subsidebar">
<div class="inner">
<div v-if="props.label" class="sidebar-title">
<slot>
<h3>{{ props.label }}</h3>
</slot>
</div>
<ul
class="submenu has-slimscroll"
>
<slot name="links" />
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
.mobile-subsidebar .inner {
margin-inline-start: 0;
width: 100%;
}
</style>
<style lang="scss">
@import '/@src/scss/layout/mobile-subsidebar';
</style>

View File

@@ -0,0 +1,14 @@
import type { InjectionKey } from 'vue'
import type { SideblockLayoutContext } from './sideblock.types'
export const injectionKey = Symbol('sideblock-layout') as InjectionKey<SideblockLayoutContext>
export function useSideblockLayoutContext() {
const context = inject(injectionKey)
if (!context) {
throw new Error('useSideblockLayoutContext() is called outside of <SideblockLayout> tree.')
}
return context
}

View File

@@ -0,0 +1,73 @@
import type { VNode, Component } from 'vue'
// -- Sideblock
export interface SideblockItemCollapse {
type: 'collapse'
id: string
icon: string
hideMobile?: boolean
label?: string
children: {
label: string
to: string
icon?: string
tag?: string
}[]
}
export interface SideblockItemLink {
type: 'link'
icon: string
hideMobile?: boolean
label?: string
badge?: string | number
to: string
}
export interface SideblockItemAction {
type: 'action'
id: string
icon: string
hideMobile?: boolean
label?: string
badge?: string | number
onClick: (event: Event) => void
}
export interface SideblockItemComponent {
type: 'component'
id: string
hideMobile?: boolean
label?: string
component: string | Component | (() => VNode)
}
export interface SideblockItemDivider {
type: 'divider'
label?: string
}
export type SideblockItem =
| SideblockItemCollapse
| SideblockItemLink
| SideblockItemAction
| SideblockItemComponent
| SideblockItemDivider
// -- Context
export type SideblockTheme =
| 'default'
| 'curved'
| 'color'
| 'color-curved'
export interface SideblockLayoutContext {
links: ComputedRef<SideblockItem[]>
theme: ComputedRef<SideblockTheme>
closeOnChange: ComputedRef<boolean>
openOnMounted: ComputedRef<boolean>
isMobileSideblockOpen: Ref<boolean>
isDesktopSideblockOpen: Ref<boolean>
}