Sidebar improvements

- Now uses soft nesting
- Fixed width when expanded, dynamic when reduced
- Also added a border to selected pillbar variant
This commit is contained in:
adro 2023-11-03 17:04:03 +01:00
parent 5a3a0c8f32
commit 7e7cff371f
4 changed files with 81 additions and 33 deletions

View file

@ -25,6 +25,7 @@ import INetwork from '~icons/mdi/lan';
enum NavState { Open, Reduced, Collapsed }; enum NavState { Open, Reduced, Collapsed };
const NavStateCount = 3; const NavStateCount = 3;
let navState = $ref(NavState.Open); let navState = $ref(NavState.Open);
let reducedDynamicWidth = $ref(2.5);
const navRoutesNew = [ const navRoutesNew = [
{ caption: 'Dashboard', icon: IDashboard, href: '/' }, { caption: 'Dashboard', icon: IDashboard, href: '/' },
@ -131,7 +132,7 @@ onMounted(async() => {
</script> </script>
<template> <template>
<div v-if="authState === AuthState.Authenticated" :class="{ <div v-if="authState === AuthState.Authenticated" :style="`--reduced-dynamic-width: ${reducedDynamicWidth}rem;`" :class="{
'layout': 1, 'layout': 1,
'nav-state-open': navState === NavState.Open, 'nav-state-open': navState === NavState.Open,
'nav-state-collapsed': navState === NavState.Collapsed, 'nav-state-collapsed': navState === NavState.Collapsed,
@ -146,7 +147,9 @@ onMounted(async() => {
<div class="nav-body cl-secondary cl-force-dark"> <div class="nav-body cl-secondary cl-force-dark">
<div> <div>
<NavElements :routes="navRoutesNew" :click-handler="collapseNavIfMobile"/> <div>
<NavElements :routes="navRoutesNew" :click-handler="collapseNavIfMobile" @update:expanded-depth="(val) => reducedDynamicWidth = 2.5 + 0.5 * val"/>
</div>
</div> </div>
<div class="flex-row"> <div class="flex-row">
<router-link class="button" to="/help"><i-material-symbols-help-outline/></router-link> <router-link class="button" to="/help"><i-material-symbols-help-outline/></router-link>
@ -194,6 +197,8 @@ onMounted(async() => {
"NH PH" "NH PH"
"NB PC"; "NB PC";
} }
.layout:not(.nav-state-open) { --reduced-width: var(--reduced-dynamic-width); }
.nav-state-open { --reduced-width: 3.5rem; }
.login { place-items: center; } .login { place-items: center; }
.nav-head { grid-area: NH; } .nav-head { grid-area: NH; }
@ -202,6 +207,8 @@ onMounted(async() => {
.page-content { grid-area: PC; } .page-content { grid-area: PC; }
.nav-head { font-weight: bold; text-align: center; } .nav-head { font-weight: bold; text-align: center; }
.nav-head:focus { background: var(--cl-bg); }
.nav-head:hover { background: var(--cl-bg-el); }
.nav-head > svg { display: none; } .nav-head > svg { display: none; }
.nav-head > h1 { flex-grow: 1; } .nav-head > h1 { flex-grow: 1; }
@ -228,6 +235,22 @@ onMounted(async() => {
background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px; background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
background-attachment: local, local, scroll, scroll; background-attachment: local, local, scroll, scroll;
} }
.nav-body > :first-child > * {
display: grid;
grid-template-columns: calc(var(--reduced-width) - 0.25rem) 1fr; /* -0.25rem adjustment is for halved 0.5rem padding */
place-self: start;
transition: grid-template-columns 0.2s ease-out;
width: 100%;
}
.nav-body > :first-child > * > *, .nav-dropdown > *, .nav-dropdown > :first-child, .nav-dropdown-body > * {
grid-column: 1 / 3;
display: grid;
grid-template-columns: subgrid;
}
:is(.nav-body > :first-child > * > *, .nav-dropdown > *, .nav-dropdown > :first-child, .nav-dropdown-body > *) > svg {
place-self: center;
}
/* Page */ /* Page */
.page-header { .page-header {
@ -242,7 +265,6 @@ onMounted(async() => {
left: 0%; left: 0%;
width: 100%; width: 100%;
transition: left 0.2s ease-out, width 0.2s ease-out; transition: left 0.2s ease-out, width 0.2s ease-out;
--reduced-width: 2.5rem;
} }
.nav-state-reduced .nav-body { width: calc(0% + var(--reduced-width)); } .nav-state-reduced .nav-body { width: calc(0% + var(--reduced-width)); }
.nav-state-reduced .page-content { .nav-state-reduced .page-content {
@ -259,6 +281,8 @@ onMounted(async() => {
align-items: start; align-items: start;
} }
.nav-state-reduced > .nav-body > .flex-row > * { width: var(--reduced-width); }
/* Mobile Layout */ /* Mobile Layout */
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {
.layout { .layout {
@ -270,9 +294,8 @@ onMounted(async() => {
"NB PC"; "NB PC";
} }
.nav-head > svg { .nav-head > svg { display: initial; }
display: initial; .nav-head > h1 { text-align: left; }
}
.nav-state-collapsed .page-header { .nav-state-collapsed .page-header {
left: calc(-100vw + 100%); left: calc(-100vw + 100%);

View file

@ -83,5 +83,9 @@ watchEffect(() => {
gap: 0.25rem; gap: 0.25rem;
} }
.pillbar > button { padding: 0.25rem; gap: 0.25rem; } .pillbar > button { padding: 0.25rem; gap: 0.25rem; }
.pillbar > .selected { background-color: var(--cl-bg-sl); } .pillbar > .selected {
background-color: var(--cl-bg-sl);
border: 1px solid var(--cl-fg);
padding: calc(0.25rem - 1px);
}
</style> </style>

View file

@ -1,19 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { NavRoute } from './NavElements.vue'; import { NavRoute } from './NavElements.vue';
withDefaults(defineProps<{ let props = withDefaults(defineProps<{
// Two-Way Bindings
expanded?: boolean,
// One-Way Bindings
icon?: string | Component, icon?: string | Component,
caption?: string, caption?: string,
children: NavRoute[], children: NavRoute[],
clickHandler?: () => void, clickHandler?: () => void,
}>(), { }>(), {
// Two-Way Bindings
expanded: false,
// One-Way Bindings
icon: '', icon: '',
caption: '', caption: '',
children: () => [], children: () => [],
clickHandler: () => {}, clickHandler: () => {},
}); });
let expanded = $ref(false); const emit = defineEmits<{ (e: 'update:expandedDepth', value: number): void }>();
// Local Variables for Two-Way Binding
let expanded = $ref(props.expanded ?? false);
let lowerDepth = $ref(0);
// Sync to v-model
watchEffect(() => emit('update:expandedDepth', expanded ? (lowerDepth || 0) + 1 : 0));
function tallyChildren(routes: NavRoute[]) { function tallyChildren(routes: NavRoute[]) {
let count = routes.length; let count = routes.length;
@ -25,39 +40,38 @@ function tallyChildren(routes: NavRoute[]) {
</script> </script>
<template> <template>
<div :class="{'nav-dropdown-expanded': expanded}" :style="`--predicted-height: ${2.5 * tallyChildren(children)}rem;`"> <div :class="{'nav-dropdown': 1, 'nav-dropdown-expanded': expanded}" :style="`--predicted-height: ${2.5 * tallyChildren(children)}rem;`">
<div class="button" @click="expanded = !expanded"> <div class="button" @click="expanded = !expanded">
<component v-if="(typeof icon !== 'string')" :is="icon"/> <component v-if="(typeof icon !== 'string')" :is="icon"/>
<template v-else>{{ icon }}</template> <template v-else>{{ icon }}</template>
<span v-text="caption"/> <span>
{{ caption }}
<i-material-symbols-expand-more class="nav-dropdown-expand-icon" width="1em" height="1em"/> <i-material-symbols-expand-more class="nav-dropdown-expand-icon" width="1em" height="1em"/>
</span>
</div> </div>
<div class="nav-dropdown-body"> <div class="nav-dropdown-body">
<NavElements :routes="children" :click-handler="clickHandler"/> <NavElements :routes="children" :click-handler="clickHandler" @update:expanded-depth="(val) => lowerDepth = val"/>
</div> </div>
</div> </div>
</template> </template>
<style> <style scoped>
span { .nav-dropdown-body {
flex-grow: 1; max-height: 0px;
} border-left: 1px solid white;
.nav-dropdown-body > :is(button, .button) {
padding-left: calc(0.5rem - 1px); padding-left: calc(0.5rem - 1px);
} }
.nav-dropdown-body { span {
transition: all 0.1s ease-out; display: flex;
max-height: 0px; flex-flow: row nowrap;
border-left: 1px solid white; align-items: center;
} justify-content: space-between;
.nav-dropdown-expanded > .nav-dropdown-body {
max-height: var(--predicted-height);
} }
.nav-dropdown-expand-icon { .nav-dropdown-expand-icon { min-width: 1.5rem; min-height: 1.5rem; }
transition: all 0.1s ease-out;
} /* Expanded State with Transitions */
.nav-dropdown-expanded > div > .nav-dropdown-expand-icon { .nav-dropdown-expand-icon, .nav-dropdown-body { transition: all 0.1s ease-out; }
transform: rotate(180deg); .nav-dropdown-expanded > div > span > .nav-dropdown-expand-icon { transform: rotate(180deg); }
} .nav-dropdown-expanded > .nav-dropdown-body { max-height: var(--predicted-height); }
</style> </style>

View file

@ -14,15 +14,22 @@ withDefaults(defineProps<{
routes: () => [], routes: () => [],
clickHandler: () => {}, clickHandler: () => {},
}); });
const emit = defineEmits<{
(e: 'update:expandedDepth', value: number): void,
}>();
const lowerDepths: {[index: number]: number} = $ref({});
watch($$(lowerDepths), () => emit('update:expandedDepth', Math.max(...Object.entries(lowerDepths).map(x => x[1])) ?? 0), { deep: true });
</script> </script>
<template> <template>
<template v-if="routes"> <template v-if="routes">
<template v-for="route of routes" :key="route.href"> <template v-for="[index, route] of routes.entries()" :key="route.href">
<router-link v-if="route.href" :to="route.href" class="button" @click="clickHandler" :title="route.caption"> <router-link v-if="route.href" :to="route.href" class="button" @click="clickHandler" :title="route.caption">
<component :is="route.icon"/> <component :is="route.icon"/>
{{ route.caption }} {{ route.caption }}
</router-link> </router-link>
<NavDropdown v-else v-bind="route"/> <NavDropdown v-else v-bind="route" @update:expanded-depth="(val) => lowerDepths[index] = val" :click-handler="clickHandler"/>
</template> </template>
</template> </template>
</template> </template>