feat(nav): add collapsible sidebar and nested navigation groups
Refactor sidebar to support nested navigation groups for related entities. Implement collapsible desktop sidebar with icon tooltips.
This commit is contained in:
@@ -3,24 +3,54 @@ import { Icon } from '@iconify/vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { iconClose, iconLogin, iconLogout, iconMenu, iconProfile, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
||||
import {
|
||||
iconChevronDown,
|
||||
iconChevronRight,
|
||||
iconClose,
|
||||
iconLogin,
|
||||
iconLogout,
|
||||
iconMenu,
|
||||
iconProfile,
|
||||
iconRegister,
|
||||
iconTranslate,
|
||||
type AppIcon
|
||||
} from '../icons';
|
||||
import type { AuthUser, Language } from '../services/api';
|
||||
import PokeBallMark from './PokeBallMark.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
|
||||
type NavBadge = {
|
||||
label: string;
|
||||
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||
};
|
||||
|
||||
type NavLinkItem = {
|
||||
label: string;
|
||||
to: string;
|
||||
icon?: AppIcon;
|
||||
badge?: NavBadge;
|
||||
};
|
||||
|
||||
type NavGroupItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: AppIcon;
|
||||
children: NavLinkItem[];
|
||||
};
|
||||
|
||||
type NavItem = NavLinkItem | NavGroupItem;
|
||||
|
||||
type SidebarTooltip = {
|
||||
label: string;
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
currentUser: AuthUser | null;
|
||||
languages: Language[];
|
||||
locale: string;
|
||||
navItems: Array<{
|
||||
label: string;
|
||||
to: string;
|
||||
icon?: AppIcon;
|
||||
badge?: {
|
||||
label: string;
|
||||
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||
};
|
||||
}>;
|
||||
navItems: NavItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -33,25 +63,61 @@ const route = useRoute();
|
||||
const copyrightYear = new Date().getFullYear();
|
||||
const languageMenu = ref<HTMLElement | null>(null);
|
||||
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
||||
const sideNav = ref<HTMLElement | null>(null);
|
||||
const languageMenuOpen = ref(false);
|
||||
const sidebarOpen = ref(false);
|
||||
const sidebarCollapsed = ref(false);
|
||||
const expandedNavGroups = ref<Set<string>>(new Set());
|
||||
const sidebarTooltip = ref<SidebarTooltip | null>(null);
|
||||
const sidebarTooltipTarget = ref<HTMLElement | null>(null);
|
||||
|
||||
function closeLanguageMenu() {
|
||||
languageMenuOpen.value = false;
|
||||
}
|
||||
|
||||
function clearSidebarTooltipTarget() {
|
||||
sidebarTooltipTarget.value?.removeAttribute('aria-describedby');
|
||||
}
|
||||
|
||||
function hideSidebarTooltip() {
|
||||
clearSidebarTooltipTarget();
|
||||
sidebarTooltipTarget.value = null;
|
||||
sidebarTooltip.value = null;
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false;
|
||||
closeLanguageMenu();
|
||||
hideSidebarTooltip();
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value;
|
||||
closeLanguageMenu();
|
||||
hideSidebarTooltip();
|
||||
}
|
||||
|
||||
function toggleSidebarCollapsed() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
closeLanguageMenu();
|
||||
hideSidebarTooltip();
|
||||
}
|
||||
|
||||
function toggleNavGroup(key: string) {
|
||||
const nextGroups = new Set(expandedNavGroups.value);
|
||||
if (nextGroups.has(key)) {
|
||||
nextGroups.delete(key);
|
||||
} else {
|
||||
nextGroups.add(key);
|
||||
}
|
||||
expandedNavGroups.value = nextGroups;
|
||||
closeLanguageMenu();
|
||||
hideSidebarTooltip();
|
||||
}
|
||||
|
||||
function toggleLanguageMenu() {
|
||||
languageMenuOpen.value = !languageMenuOpen.value;
|
||||
hideSidebarTooltip();
|
||||
}
|
||||
|
||||
function selectLocale(value: string) {
|
||||
@@ -79,26 +145,108 @@ function requestLogout() {
|
||||
emit('logout');
|
||||
}
|
||||
|
||||
function isDesktopSidebar() {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches;
|
||||
}
|
||||
|
||||
function canShowSidebarTooltip(collapsedOnly = true) {
|
||||
return isDesktopSidebar() && (!collapsedOnly || sidebarCollapsed.value);
|
||||
}
|
||||
|
||||
function setSidebarTooltip(label: string, target: HTMLElement) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
clearSidebarTooltipTarget();
|
||||
sidebarTooltipTarget.value = target;
|
||||
target.setAttribute('aria-describedby', 'sidebar-tooltip');
|
||||
sidebarTooltip.value = {
|
||||
label,
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.right + 10
|
||||
};
|
||||
}
|
||||
|
||||
function showSidebarTooltip(label: string, event: MouseEvent | FocusEvent, collapsedOnly = true) {
|
||||
if (!canShowSidebarTooltip(collapsedOnly) || languageMenuOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.currentTarget;
|
||||
if (target instanceof HTMLElement) {
|
||||
setSidebarTooltip(label, target);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSidebarTooltipPosition() {
|
||||
const target = sidebarTooltipTarget.value;
|
||||
const currentTooltip = sidebarTooltip.value;
|
||||
if (!target || !currentTooltip || !canShowSidebarTooltip()) {
|
||||
hideSidebarTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sideNav.value?.contains(target)) {
|
||||
const navRect = sideNav.value.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
if (targetRect.bottom < navRect.top || targetRect.top > navRect.bottom) {
|
||||
hideSidebarTooltip();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSidebarTooltip(currentTooltip.label, target);
|
||||
}
|
||||
|
||||
function isNavActive(path: string) {
|
||||
return route.path === path || route.path.startsWith(`${path}/`);
|
||||
}
|
||||
|
||||
function isNavGroup(item: NavItem): item is NavGroupItem {
|
||||
return 'children' in item;
|
||||
}
|
||||
|
||||
function isNavGroupActive(item: NavGroupItem) {
|
||||
return item.children.some((child) => isNavActive(child.to));
|
||||
}
|
||||
|
||||
function isNavGroupExpanded(item: NavGroupItem) {
|
||||
return expandedNavGroups.value.has(item.key) || isNavGroupActive(item);
|
||||
}
|
||||
|
||||
function navItemKey(item: NavItem) {
|
||||
return isNavGroup(item) ? item.key : item.to;
|
||||
}
|
||||
|
||||
watch(sidebarOpen, (open) => {
|
||||
document.body.classList.toggle('lock-scroll', open);
|
||||
});
|
||||
|
||||
watch(sidebarCollapsed, (collapsed) => {
|
||||
if (!collapsed) {
|
||||
hideSidebarTooltip();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||
window.addEventListener('resize', updateSidebarTooltipPosition);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
window.removeEventListener('resize', updateSidebarTooltipPosition);
|
||||
document.body.classList.remove('lock-scroll');
|
||||
hideSidebarTooltip();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'app-shell--sidebar-open': sidebarOpen }">
|
||||
<div
|
||||
class="app-shell"
|
||||
:class="{
|
||||
'app-shell--sidebar-open': sidebarOpen,
|
||||
'app-shell--sidebar-collapsed': sidebarCollapsed
|
||||
}"
|
||||
>
|
||||
<header class="mobile-topbar">
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
@@ -124,33 +272,110 @@ onBeforeUnmount(() => {
|
||||
|
||||
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
|
||||
<div class="site-sidebar__inner">
|
||||
<RouterLink class="brand-lockup" to="/" aria-label="Pokopia Wiki" @click="closeSidebar">
|
||||
<PokeBallMark size="42px" />
|
||||
<span>
|
||||
<span class="pokemon-word">Pokopia</span>
|
||||
<span class="brand-subtitle">Community Wiki</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="side-nav" :aria-label="t('nav.main')">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
class="side-nav__link"
|
||||
:class="{ 'router-link-active': isNavActive(item.to) }"
|
||||
:to="item.to"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||
<span class="side-nav__label">{{ item.label }}</span>
|
||||
<StatusBadge
|
||||
v-if="item.badge"
|
||||
class="side-nav__badge"
|
||||
:label="item.badge.label"
|
||||
:tone="item.badge.tone"
|
||||
compact
|
||||
/>
|
||||
<div class="site-sidebar__header">
|
||||
<RouterLink class="brand-lockup" to="/" aria-label="Pokopia Wiki" @click="closeSidebar">
|
||||
<PokeBallMark size="42px" />
|
||||
<span>
|
||||
<span class="pokemon-word">Pokopia</span>
|
||||
<span class="brand-subtitle">Community Wiki</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
|
||||
<button
|
||||
class="sidebar-collapse-toggle"
|
||||
type="button"
|
||||
:aria-label="sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')"
|
||||
:aria-expanded="!sidebarCollapsed"
|
||||
aria-controls="app-sidebar"
|
||||
@focus="showSidebarTooltip(sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar'), $event, false)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar'), $event, false)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="toggleSidebarCollapsed"
|
||||
>
|
||||
<Icon
|
||||
:icon="iconChevronRight"
|
||||
class="ui-icon sidebar-collapse-toggle__icon"
|
||||
:class="{ 'sidebar-collapse-toggle__icon--expanded': !sidebarCollapsed }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav ref="sideNav" class="side-nav" :aria-label="t('nav.main')" @scroll="updateSidebarTooltipPosition">
|
||||
<template v-for="item in navItems" :key="navItemKey(item)">
|
||||
<div v-if="isNavGroup(item)" class="side-nav__group" :class="{ 'side-nav__group--active': isNavGroupActive(item) }">
|
||||
<button
|
||||
class="side-nav__link side-nav__group-trigger"
|
||||
:class="{ 'router-link-active': isNavGroupActive(item) }"
|
||||
type="button"
|
||||
:aria-expanded="isNavGroupExpanded(item)"
|
||||
:aria-controls="`side-nav-group-${item.key}`"
|
||||
:aria-label="item.label"
|
||||
@focus="showSidebarTooltip(item.label, $event)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(item.label, $event)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="toggleNavGroup(item.key)"
|
||||
>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||
<span class="side-nav__label">{{ item.label }}</span>
|
||||
<Icon
|
||||
:icon="isNavGroupExpanded(item) ? iconChevronDown : iconChevronRight"
|
||||
class="ui-icon side-nav__chevron"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="isNavGroupExpanded(item)" :id="`side-nav-group-${item.key}`" class="side-nav__children">
|
||||
<RouterLink
|
||||
v-for="child in item.children"
|
||||
:key="child.to"
|
||||
class="side-nav__link side-nav__link--child"
|
||||
:class="{ 'router-link-active': isNavActive(child.to) }"
|
||||
:to="child.to"
|
||||
:aria-label="child.label"
|
||||
@focus="showSidebarTooltip(child.label, $event)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(child.label, $event)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
<Icon v-if="child.icon" :icon="child.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||
<span class="side-nav__label">{{ child.label }}</span>
|
||||
<StatusBadge
|
||||
v-if="child.badge"
|
||||
class="side-nav__badge"
|
||||
:label="child.badge.label"
|
||||
:tone="child.badge.tone"
|
||||
compact
|
||||
/>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
v-else
|
||||
class="side-nav__link"
|
||||
:class="{ 'router-link-active': isNavActive(item.to) }"
|
||||
:to="item.to"
|
||||
:aria-label="item.label"
|
||||
@focus="showSidebarTooltip(item.label, $event)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(item.label, $event)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||
<span class="side-nav__label">{{ item.label }}</span>
|
||||
<StatusBadge
|
||||
v-if="item.badge"
|
||||
class="side-nav__badge"
|
||||
:label="item.badge.label"
|
||||
:tone="item.badge.tone"
|
||||
compact
|
||||
/>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="auth-actions">
|
||||
@@ -162,6 +387,10 @@ onBeforeUnmount(() => {
|
||||
:aria-label="t('nav.language')"
|
||||
:aria-expanded="languageMenuOpen"
|
||||
aria-haspopup="menu"
|
||||
@focus="showSidebarTooltip(t('nav.language'), $event)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(t('nav.language'), $event)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="toggleLanguageMenu"
|
||||
>
|
||||
<Icon :icon="iconTranslate" class="language-menu__icon" aria-hidden="true" />
|
||||
@@ -185,29 +414,75 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="currentUser">
|
||||
<RouterLink class="auth-user" to="/profile" :aria-label="t('nav.profile')" @click="closeSidebar">
|
||||
<RouterLink
|
||||
class="auth-user"
|
||||
to="/profile"
|
||||
:aria-label="t('nav.profile')"
|
||||
@focus="showSidebarTooltip(t('nav.profile'), $event)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(t('nav.profile'), $event)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
<Icon :icon="iconProfile" class="ui-icon auth-user__icon" aria-hidden="true" />
|
||||
<span class="auth-user__name">{{ currentUser.displayName || currentUser.email }}</span>
|
||||
</RouterLink>
|
||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
|
||||
<button
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:aria-label="t('nav.logout')"
|
||||
@focus="showSidebarTooltip(t('nav.logout'), $event)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(t('nav.logout'), $event)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="requestLogout"
|
||||
>
|
||||
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('nav.logout') }}
|
||||
<span class="auth-actions__label">{{ t('nav.logout') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login" @click="closeSidebar">
|
||||
<RouterLink
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
to="/login"
|
||||
:aria-label="t('nav.login')"
|
||||
@focus="showSidebarTooltip(t('nav.login'), $event)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(t('nav.login'), $event)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('nav.login') }}
|
||||
<span class="auth-actions__label">{{ t('nav.login') }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register" @click="closeSidebar">
|
||||
<RouterLink
|
||||
class="ui-button ui-button--primary ui-button--small"
|
||||
to="/register"
|
||||
:aria-label="t('nav.register')"
|
||||
@focus="showSidebarTooltip(t('nav.register'), $event)"
|
||||
@blur="hideSidebarTooltip"
|
||||
@pointerenter="showSidebarTooltip(t('nav.register'), $event)"
|
||||
@pointerleave="hideSidebarTooltip"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
<Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('nav.register') }}
|
||||
<span class="auth-actions__label">{{ t('nav.register') }}</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-if="sidebarTooltip"
|
||||
id="sidebar-tooltip"
|
||||
class="sidebar-tooltip"
|
||||
role="tooltip"
|
||||
:style="{ top: `${sidebarTooltip.top}px`, left: `${sidebarTooltip.left}px` }"
|
||||
>
|
||||
{{ sidebarTooltip.label }}
|
||||
</div>
|
||||
|
||||
<main class="page">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user