feat(#209): rework sidebar nav (Home / Docs / Stores / Runs / Settings)
The sidebar was the project's actual nav (the design doc called it 'top nav' but the existing app is sidebar-driven). Five entries reflecting the doc-centric IA replace the seven analysis-centric ones. Sidebar - shared/ui/AppSidebar.vue: data-driven over a NavItem[] table instead of seven hard-coded RouterLinks. Inline SVG icon components keep the visual weight close to the previous version with no new dependency. - Five entries: Home / Docs (★ primary, bolder label) / Stores / Runs / Settings. - Active state via matchesActive(path, prefixes): exact match for Home, prefix match (with segment / query boundary) for the others. Pure helper in shared/ui/navActive.ts, unit tested. Removed from the sidebar (legacy pages still reachable by URL): Studio, Documents, Search, Reasoning, History. i18n - nav.docs / nav.stores / nav.runs added in fr + en, grouped under the 0.6.0 nav block. - Legacy nav labels (nav.studio / nav.documents / nav.history / nav.reasoning / nav.search) kept — the legacy pages still render headings using them. Duplicate nav.search entries removed (the key now lives once per locale, in the nav block). Tests - shared/ui/navActive.test.ts (5 cases): exact-match for Home, prefix boundary semantics, multi-prefix matching, empty list edge. Refs #209
This commit is contained in:
parent
ab5de2aac3
commit
058d718116
4 changed files with 194 additions and 118 deletions
|
|
@ -6,15 +6,22 @@ type Messages = Record<Locale, MessageMap>
|
||||||
|
|
||||||
const messages: Messages = {
|
const messages: Messages = {
|
||||||
fr: {
|
fr: {
|
||||||
// Sidebar
|
// Sidebar — 0.6.0 doc-centric nav (#209)
|
||||||
'nav.home': 'Accueil',
|
'nav.home': 'Accueil',
|
||||||
|
'nav.docs': 'Documents',
|
||||||
|
'nav.stores': 'Stores',
|
||||||
|
'nav.runs': 'Runs',
|
||||||
|
'nav.settings': 'Paramètres',
|
||||||
|
'nav.collapse': 'Réduire la barre latérale',
|
||||||
|
'nav.expand': 'Développer la barre latérale',
|
||||||
|
// Legacy nav labels — kept because the legacy pages (/studio, /documents,
|
||||||
|
// /history, /search, /reasoning) still render headings using them. They
|
||||||
|
// are no longer surfaced in the sidebar.
|
||||||
'nav.studio': 'Studio',
|
'nav.studio': 'Studio',
|
||||||
'nav.documents': 'Documents',
|
'nav.documents': 'Documents',
|
||||||
'nav.history': 'Historique',
|
'nav.history': 'Historique',
|
||||||
'nav.reasoning': 'Raisonnement',
|
'nav.reasoning': 'Raisonnement',
|
||||||
'nav.settings': 'Paramètres',
|
'nav.search': 'Recherche',
|
||||||
'nav.collapse': 'Réduire la barre latérale',
|
|
||||||
'nav.expand': 'Développer la barre latérale',
|
|
||||||
|
|
||||||
// Top bar
|
// Top bar
|
||||||
'topbar.newAnalysis': 'Nouvelle analyse',
|
'topbar.newAnalysis': 'Nouvelle analyse',
|
||||||
|
|
@ -251,8 +258,7 @@ const messages: Messages = {
|
||||||
'chunking.batchNotice':
|
'chunking.batchNotice':
|
||||||
'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage. Coming soon !',
|
'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage. Coming soon !',
|
||||||
|
|
||||||
// Search
|
// Search (legacy nav label moved to the top with the other legacy keys)
|
||||||
'nav.search': 'Recherche',
|
|
||||||
'search.hint': 'Saisissez un terme pour rechercher dans les chunks indexés.',
|
'search.hint': 'Saisissez un terme pour rechercher dans les chunks indexés.',
|
||||||
|
|
||||||
// Ingestion / My Documents
|
// Ingestion / My Documents
|
||||||
|
|
@ -303,14 +309,20 @@ const messages: Messages = {
|
||||||
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
|
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
|
// Sidebar — 0.6.0 doc-centric nav (#209)
|
||||||
'nav.home': 'Home',
|
'nav.home': 'Home',
|
||||||
|
'nav.docs': 'Docs',
|
||||||
|
'nav.stores': 'Stores',
|
||||||
|
'nav.runs': 'Runs',
|
||||||
|
'nav.settings': 'Settings',
|
||||||
|
'nav.collapse': 'Collapse sidebar',
|
||||||
|
'nav.expand': 'Expand sidebar',
|
||||||
|
// Legacy nav labels — kept because legacy pages still use them.
|
||||||
'nav.studio': 'Studio',
|
'nav.studio': 'Studio',
|
||||||
'nav.documents': 'Documents',
|
'nav.documents': 'Documents',
|
||||||
'nav.history': 'History',
|
'nav.history': 'History',
|
||||||
'nav.reasoning': 'Reasoning',
|
'nav.reasoning': 'Reasoning',
|
||||||
'nav.settings': 'Settings',
|
'nav.search': 'Search',
|
||||||
'nav.collapse': 'Collapse sidebar',
|
|
||||||
'nav.expand': 'Expand sidebar',
|
|
||||||
|
|
||||||
'topbar.newAnalysis': 'New analysis',
|
'topbar.newAnalysis': 'New analysis',
|
||||||
|
|
||||||
|
|
@ -537,7 +549,6 @@ const messages: Messages = {
|
||||||
'chunking.batchNotice':
|
'chunking.batchNotice':
|
||||||
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking. Coming soon!',
|
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking. Coming soon!',
|
||||||
|
|
||||||
'nav.search': 'Search',
|
|
||||||
'search.hint': 'Enter a term to search through indexed chunks.',
|
'search.hint': 'Enter a term to search through indexed chunks.',
|
||||||
|
|
||||||
'ingestion.ingest': 'Ingest',
|
'ingestion.ingest': 'Ingest',
|
||||||
|
|
|
||||||
|
|
@ -2,113 +2,15 @@
|
||||||
<aside class="sidebar" data-e2e="sidebar" :class="{ open }">
|
<aside class="sidebar" data-e2e="sidebar" :class="{ open }">
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/"
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
:to="item.to"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
data-e2e="nav-home"
|
:class="{ active: isActive(item), 'nav-item--primary': item.primary }"
|
||||||
:class="{ active: route.name === 'home' }"
|
:data-e2e="`nav-${item.key}`"
|
||||||
>
|
>
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
<component :is="item.icon" class="nav-icon" />
|
||||||
<path
|
<span class="nav-label">{{ t(item.labelKey) }}</span>
|
||||||
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">{{ t('nav.home') }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
to="/studio"
|
|
||||||
class="nav-item"
|
|
||||||
data-e2e="nav-studio"
|
|
||||||
:class="{ active: route.name === 'studio' }"
|
|
||||||
>
|
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">{{ t('nav.studio') }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
to="/documents"
|
|
||||||
class="nav-item"
|
|
||||||
data-e2e="nav-documents"
|
|
||||||
:class="{ active: route.name === 'documents' }"
|
|
||||||
>
|
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">{{ t('nav.documents') }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
v-if="ingestionEnabled"
|
|
||||||
to="/search"
|
|
||||||
class="nav-item"
|
|
||||||
data-e2e="nav-search"
|
|
||||||
:class="{ active: route.name === 'search' }"
|
|
||||||
>
|
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">{{ t('nav.search') }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
v-if="reasoningEnabled"
|
|
||||||
to="/reasoning"
|
|
||||||
class="nav-item"
|
|
||||||
data-e2e="nav-reasoning"
|
|
||||||
:class="{
|
|
||||||
active: route.name === 'reasoning' || route.name === 'reasoning-doc',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M10 2a5 5 0 00-5 5c0 1.72.87 3.24 2.2 4.14.47.32.8.84.8 1.4V14a1 1 0 001 1h2a1 1 0 001-1v-1.46c0-.56.33-1.08.8-1.4A5 5 0 0010 2zM8 17a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">{{ t('nav.reasoning') }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
to="/history"
|
|
||||||
class="nav-item"
|
|
||||||
data-e2e="nav-history"
|
|
||||||
:class="{ active: route.name === 'history' }"
|
|
||||||
>
|
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">{{ t('nav.history') }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
to="/settings"
|
|
||||||
class="nav-item"
|
|
||||||
data-e2e="nav-settings"
|
|
||||||
:class="{ active: route.name === 'settings' }"
|
|
||||||
>
|
|
||||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">{{ t('nav.settings') }}</span>
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -146,16 +48,19 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, h, onMounted, onBeforeUnmount, type Component } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { useI18n } from '../i18n'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
import { useFeatureFlagStore } from '../../features/feature-flags/store'
|
import { useFeatureFlagStore } from '../../features/feature-flags/store'
|
||||||
import { useIngestionStore } from '../../features/ingestion/store'
|
import { useIngestionStore } from '../../features/ingestion/store'
|
||||||
|
import { useI18n } from '../i18n'
|
||||||
|
import { ROUTES } from '../routing/names'
|
||||||
|
import { matchesActive } from './navActive'
|
||||||
|
|
||||||
const featureStore = useFeatureFlagStore()
|
const featureStore = useFeatureFlagStore()
|
||||||
const ingestionStore = useIngestionStore()
|
const ingestionStore = useIngestionStore()
|
||||||
const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion'))
|
const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion'))
|
||||||
const reasoningEnabled = computed(() => featureStore.isEnabled('reasoning'))
|
|
||||||
const version = computed(() => featureStore.appVersion)
|
const version = computed(() => featureStore.appVersion)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
@ -164,6 +69,103 @@ defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Inline SVG icon components — keeping them here avoids dragging in an
|
||||||
|
// icon lib and keeps the visual weight close to the previous sidebar.
|
||||||
|
const HomeIcon: Component = () =>
|
||||||
|
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
|
||||||
|
h('path', {
|
||||||
|
d: 'M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const DocsIcon: Component = () =>
|
||||||
|
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
|
||||||
|
h('path', {
|
||||||
|
'fill-rule': 'evenodd',
|
||||||
|
'clip-rule': 'evenodd',
|
||||||
|
d: 'M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const StoresIcon: Component = () =>
|
||||||
|
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
|
||||||
|
h('path', {
|
||||||
|
'fill-rule': 'evenodd',
|
||||||
|
'clip-rule': 'evenodd',
|
||||||
|
d: 'M4 3a1 1 0 011-1h10a1 1 0 011 1v3a1 1 0 01-.293.707l-1.414 1.414a1 1 0 010 1.414l1.414 1.414A1 1 0 0116 11.5V14a1 1 0 01-1 1H5a1 1 0 01-1-1v-2.5a1 1 0 01.293-.707L5.707 9.5 4.293 8.086A1 1 0 014 7.379V3zm2 1v2.879l1.414 1.414a1 1 0 010 1.414L6 11.121V13h8v-1.879l-1.414-1.414a1 1 0 010-1.414L14 6.879V4H6z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const RunsIcon: Component = () =>
|
||||||
|
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
|
||||||
|
h('path', {
|
||||||
|
'fill-rule': 'evenodd',
|
||||||
|
'clip-rule': 'evenodd',
|
||||||
|
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const SettingsIcon: Component = () =>
|
||||||
|
h('svg', { viewBox: '0 0 20 20', fill: 'currentColor' }, [
|
||||||
|
h('path', {
|
||||||
|
'fill-rule': 'evenodd',
|
||||||
|
'clip-rule': 'evenodd',
|
||||||
|
d: 'M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
key: string
|
||||||
|
to: RouteLocationRaw
|
||||||
|
labelKey: string
|
||||||
|
icon: Component
|
||||||
|
primary?: boolean
|
||||||
|
matchPrefixes: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{
|
||||||
|
key: 'home',
|
||||||
|
to: { name: ROUTES.HOME },
|
||||||
|
labelKey: 'nav.home',
|
||||||
|
icon: HomeIcon,
|
||||||
|
matchPrefixes: ['/'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'docs',
|
||||||
|
to: { name: ROUTES.DOCS_LIBRARY },
|
||||||
|
labelKey: 'nav.docs',
|
||||||
|
icon: DocsIcon,
|
||||||
|
primary: true,
|
||||||
|
matchPrefixes: ['/docs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stores',
|
||||||
|
to: { name: ROUTES.STORES_LIST },
|
||||||
|
labelKey: 'nav.stores',
|
||||||
|
icon: StoresIcon,
|
||||||
|
matchPrefixes: ['/index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'runs',
|
||||||
|
to: { name: ROUTES.RUNS },
|
||||||
|
labelKey: 'nav.runs',
|
||||||
|
icon: RunsIcon,
|
||||||
|
matchPrefixes: ['/runs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
to: { name: ROUTES.SETTINGS },
|
||||||
|
labelKey: 'nav.settings',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
matchPrefixes: ['/settings'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function isActive(item: NavItem): boolean {
|
||||||
|
return matchesActive(route.path, item.matchPrefixes)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (ingestionEnabled.value) {
|
if (ingestionEnabled.value) {
|
||||||
ingestionStore.checkAvailability()
|
ingestionStore.checkAvailability()
|
||||||
|
|
@ -230,6 +232,14 @@ onBeforeUnmount(() => {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-item--primary .nav-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item--primary:not(.active) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|
|
||||||
33
frontend/src/shared/ui/navActive.test.ts
Normal file
33
frontend/src/shared/ui/navActive.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { matchesActive } from './navActive'
|
||||||
|
|
||||||
|
describe('matchesActive', () => {
|
||||||
|
it('matches Home only on the exact / path', () => {
|
||||||
|
expect(matchesActive('/', ['/'])).toBe(true)
|
||||||
|
expect(matchesActive('/docs', ['/'])).toBe(false)
|
||||||
|
expect(matchesActive('/anything', ['/'])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches a non-root prefix on exact, segment, and query boundaries', () => {
|
||||||
|
expect(matchesActive('/docs', ['/docs'])).toBe(true)
|
||||||
|
expect(matchesActive('/docs/abc', ['/docs'])).toBe(true)
|
||||||
|
expect(matchesActive('/docs/abc/123', ['/docs'])).toBe(true)
|
||||||
|
expect(matchesActive('/docs?foo=bar', ['/docs'])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not match prefixes that are part of a longer segment', () => {
|
||||||
|
expect(matchesActive('/documents', ['/docs'])).toBe(false)
|
||||||
|
expect(matchesActive('/docsy', ['/docs'])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true if any prefix in the list matches', () => {
|
||||||
|
expect(matchesActive('/runs/abc', ['/index', '/runs'])).toBe(true)
|
||||||
|
expect(matchesActive('/index/foo/query', ['/index', '/runs'])).toBe(true)
|
||||||
|
expect(matchesActive('/somewhere-else', ['/index', '/runs'])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false on empty prefix list', () => {
|
||||||
|
expect(matchesActive('/docs', [])).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
22
frontend/src/shared/ui/navActive.ts
Normal file
22
frontend/src/shared/ui/navActive.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Active-state matching for the sidebar.
|
||||||
|
*
|
||||||
|
* `Home` is active only on the exact `/` route — otherwise visiting
|
||||||
|
* any sub-page would also light up Home. For every other entry, a
|
||||||
|
* prefix match is used so `/docs/abc?mode=chunks` highlights `Docs`.
|
||||||
|
*
|
||||||
|
* Pure helper so the component file stays tiny and the rule is
|
||||||
|
* unit-testable.
|
||||||
|
*/
|
||||||
|
export function matchesActive(path: string, prefixes: readonly string[]): boolean {
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
if (prefix === '/') {
|
||||||
|
if (path === '/') return true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (path === prefix || path.startsWith(prefix + '/') || path.startsWith(prefix + '?')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue