Files
veridian/app/components/Sidenav/index.vue
T

192 lines
7.0 KiB
Vue

<script setup lang="ts">
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
const route = useRoute();
const isResizing = ref(false);
const startX = ref(0);
const initialWidth = ref(0);
const isMouseOver = ref(false);
const isFocused = ref(false);
const lastInteraction = ref<'mouse' | 'keyboard' | null>(null);
const isHovering = computed(() =>
isMouseOver.value || (isFocused.value && lastInteraction.value === 'keyboard')
);
const sidenavRef = ref<HTMLElement | null>(null);
provideSidenavContext({
isHovered: isHovering,
sidebarWidth: sidebarWidth,
isOpen: open,
close: () => {
closeSidebar();
},
});
const trackInteraction = (interaction: 'mouse' | 'keyboard') => {
lastInteraction.value = interaction;
};
const onFocusOut = (e: FocusEvent) => {
const isMovingOutside = sidenavRef.value && !sidenavRef.value.contains(e.relatedTarget as Node);
if (isMovingOutside) {
isFocused.value = false;
}
};
const { toggle: toggleSettings } = useSettings();
const onResizeStart = (event: MouseEvent) => {
isResizing.value = true;
startX.value = event.clientX;
initialWidth.value = sidebarWidth.value;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const onResizeMove = (event: MouseEvent) => {
if (!isResizing.value) return;
if (resizeAnimationFrame) return;
resizeAnimationFrame = requestAnimationFrame(() => {
const deltaX = event.clientX - startX.value;
const newWidth = initialWidth.value + deltaX;
resize(newWidth);
resizeAnimationFrame = null;
});
};
const onResizeEnd = () => {
if (!isResizing.value) return;
isResizing.value = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
saveWidth();
};
let resizeAnimationFrame: number | null = null;
onMounted(() => {
document.addEventListener('mousemove', onResizeMove);
document.addEventListener('mouseup', onResizeEnd);
// NEW: Track global interactions
document.addEventListener('mousedown', () => trackInteraction('mouse'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') trackInteraction('keyboard');
});
});
onUnmounted(() => {
document.removeEventListener('mousemove', onResizeMove);
document.removeEventListener('mouseup', onResizeEnd);
// NEW: Cleanup listeners
document.removeEventListener('mousedown', () => trackInteraction('mouse'));
document.removeEventListener('keydown', (e) => {
if (e.key === 'Tab') trackInteraction('keyboard');
});
});
const navKind = computed(() => {
if (route.path === '/') return 'home';
if (route.path.startsWith('/agent/')) return 'agent';
return null;
});
</script>
<template>
<div class="relative">
<aside ref="sidenavRef" :class="[
'sidenav',
open ? 'sidenav--open' : 'sidenav--closed',
isResizing ? 'sidenav--resizing' : ''
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
<div class="flex flex-col h-full max-h-full overflow-y-hidden">
<!-- Header -->
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5">
<SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
<div class="flex items-center justify-end text-[var(--color-muted)] gap-0.5">
<div :style="isHovering ? 'width: 32px;' : 'width: 0px;'"
:class="['flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right']">
<button aria-label="close sidebar" @click="closeSidebar" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent transition-inherit',
]">
<Icon name="mynaui:panel-left-close"
:class="['transition-inherit transform-origin-right-center', isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
</button>
</div>
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent text-inherit',
]">
<Icon name="mynaui:book-plus" />
</NuxtLink>
</div>
</div>
</div>
<!-- Main Menu -->
<div class="max-h-full h-full overflow-auto" style="scrollbar-width: thin;">
<SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" />
</div>
</div>
<div class="flex justify-between pt-2">
<div class="flex">
<button @click="toggleSettings()"
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg transition-colors text-[var(--color-muted)] active:text-[var(--color-text)]">
<Icon name="mynaui:cog-four" class="text-4" />
</button>
</div>
<!-- Theme Switcher -->
<div class="flex justify-end gap-1">
<ThemeSwitcher />
</div>
</div>
</div>
</aside>
<!-- resize handle -->
<div @mousedown="onResizeStart" class="absolute top-0 right-0 bottom-0 p-1 cursor-ew-resize">
</div>
</div>
</template>
<style scoped>
.sidenav {
height: 100%;
max-width: fit-content;
background: var(--color-base);
overflow: hidden;
will-change: width;
color: var(--color-muted);
user-select: none;
transition: width 250ms cubic-bezier(0, 0.55, 0.45, 1),
margin 250ms cubic-bezier(0, 0.55, 0.45, 1);
}
.sidenav--open {
width: 100%;
margin-right: 0.5rem;
}
.sidenav--closed {
width: 0;
margin-right: 0;
}
.sidenav--resizing {
transition: none;
}
</style>