feat(gui-v2): port SidebarNav to TypeScript
Ports crewli-starter's sidebar nav into the SPA as production TS: V2NavGroup/V2NavItem types, a pure toV2NavGroups adapter wrapped by useV2Nav(items) (composables zone can't import @/navigation, so the v1 nav array is passed in — the layout supplies orgNavItems in Task 7), a pure isNavItemActive helper, and SidebarNav.vue (props-only, router-driven nav, route-based active state, collapsed mode, main.css translated to Tailwind inline). 16 unit tests. Icon import is allowed via the components-foundation bridge (no eslint-disable). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
128
apps/app/src/components-v2/layout/SidebarNav.vue
Normal file
128
apps/app/src/components-v2/layout/SidebarNav.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import Icon from '@/components/Icon.vue'
|
||||||
|
import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive'
|
||||||
|
import type { V2NavGroup, V2NavItem } from '@/types/v2/nav'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
groups: V2NavGroup[]
|
||||||
|
collapsed: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
function navigate(item: V2NavItem): void {
|
||||||
|
router.push(item.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkActive(item: V2NavItem): boolean {
|
||||||
|
return isNavItemActive(item, route.name)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
.nav → flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]
|
||||||
|
.nav-group → mt-[18px] (only from the second group onward, via CSS sibling selector →
|
||||||
|
Tailwind cannot express `+ .nav-group { margin-top }` on a class without
|
||||||
|
a group-based trick; using first:mt-0 / not-first:mt-[18px] via the
|
||||||
|
:not(:first-child) pseudo equivalent — `[&:not(:first-child)]:mt-[18px]`)
|
||||||
|
.nav-label → text-[11px] font-semibold text-surface-500 dark:text-surface-400
|
||||||
|
uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden
|
||||||
|
.nav-item → flex items-center gap-3 py-[var(--nav-y,9px)] px-2.5 rounded-[var(--radius,6px)]
|
||||||
|
text-surface-600 dark:text-surface-300 text-[13.5px] font-medium
|
||||||
|
transition-[background,color] duration-150 whitespace-nowrap relative
|
||||||
|
cursor-pointer border-0 bg-transparent w-full text-left min-w-0
|
||||||
|
.nav-item:hover → hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0
|
||||||
|
.nav-item.active → bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold
|
||||||
|
.nav-item.active::before → the left accent bar — inexpressible in Tailwind without a plugin
|
||||||
|
(position:absolute left:-10px, custom width:3px, height:18px,
|
||||||
|
background:primary, transform:translateY(-50%)). Using <style scoped>.
|
||||||
|
.nav-item .iconify → text-[18px] flex-shrink-0 (handled by Icon's own sizing via :size)
|
||||||
|
.count → ms-auto text-[11px] font-semibold bg-surface-100 dark:bg-surface-800
|
||||||
|
text-surface-500 dark:text-surface-400 px-1.5 py-px rounded-full
|
||||||
|
.nav-item.active .count → bg-primary-600 dark:bg-primary-500 text-white
|
||||||
|
-->
|
||||||
|
<nav class="flex-1 min-h-0 overflow-y-auto py-3.5 px-2.5 [scrollbar-width:thin]">
|
||||||
|
<div
|
||||||
|
v-for="(group, gi) in groups"
|
||||||
|
:key="gi"
|
||||||
|
class="[&:not(:first-child)]:mt-[18px]"
|
||||||
|
>
|
||||||
|
<!-- Group label: hidden in collapsed mode -->
|
||||||
|
<div
|
||||||
|
v-if="group.label && !collapsed"
|
||||||
|
class="text-[11px] font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-[0.06em] px-2.5 pb-1.5 whitespace-nowrap overflow-hidden"
|
||||||
|
>
|
||||||
|
{{ group.label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex items-center gap-3 py-[9px] rounded-md text-[13.5px] font-medium transition-[background,color] duration-150 whitespace-nowrap relative cursor-pointer border-0 bg-transparent w-full text-left min-w-0"
|
||||||
|
:class="[
|
||||||
|
collapsed ? 'justify-center px-0' : 'px-2.5',
|
||||||
|
checkActive(item)
|
||||||
|
? 'nav-item-active bg-primary-50 dark:bg-primary-950 text-primary-600 dark:text-primary-400 font-semibold'
|
||||||
|
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-surface-0',
|
||||||
|
]"
|
||||||
|
:title="collapsed ? item.label : undefined"
|
||||||
|
:aria-label="item.label"
|
||||||
|
@click="navigate(item)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="item.icon"
|
||||||
|
:size="18"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Text label: hidden in collapsed mode -->
|
||||||
|
<span
|
||||||
|
v-if="!collapsed"
|
||||||
|
class="overflow-hidden text-ellipsis min-w-0"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Count badge: hidden in collapsed mode -->
|
||||||
|
<span
|
||||||
|
v-if="item.count != null && !collapsed"
|
||||||
|
class="ms-auto text-[11px] font-semibold px-1.5 py-px rounded-full"
|
||||||
|
:class="[
|
||||||
|
checkActive(item)
|
||||||
|
? 'bg-primary-600 dark:bg-primary-500 text-white'
|
||||||
|
: 'bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ item.count }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/*
|
||||||
|
* The active left-accent bar (.nav-item.active::before in crewli-starter main.css).
|
||||||
|
* position:absolute with left:-10px, specific width/height, and transform:translateY(-50%)
|
||||||
|
* cannot be expressed with Tailwind utilities alone — no utility maps to a
|
||||||
|
* pseudo-element with these exact values, and arbitrary-value pseudo variants
|
||||||
|
* like before:content-[''] with left-[-10px] do not compose with transform/height
|
||||||
|
* reliably across Tailwind v3/v4. A <style scoped> pseudo-element is the
|
||||||
|
* correct Tailwind-escape-hatch per RFC (customization order: Tailwind →
|
||||||
|
* pt API → Aura → <style scoped> last resort with comment).
|
||||||
|
*/
|
||||||
|
.nav-item-active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--p-primary-500, theme('colors.primary.500', #6366f1));
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isNavItemActive } from '@/components-v2/layout/sidebarNavActive'
|
||||||
|
import type { V2NavItem } from '@/types/v2/nav'
|
||||||
|
|
||||||
|
function makeItem(name: string): V2NavItem {
|
||||||
|
return { id: name, label: name, icon: 'tabler-home', to: { name } }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isNavItemActive', () => {
|
||||||
|
it('returns true when the current route name exactly matches', () => {
|
||||||
|
expect(isNavItemActive(makeItem('dashboard'), 'dashboard')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when the current route name differs', () => {
|
||||||
|
expect(isNavItemActive(makeItem('dashboard'), 'events')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when currentRouteName is null', () => {
|
||||||
|
expect(isNavItemActive(makeItem('dashboard'), null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when currentRouteName is undefined', () => {
|
||||||
|
expect(isNavItemActive(makeItem('dashboard'), undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for nested routes: item name is a prefix of current route (dash-separated)', () => {
|
||||||
|
// e.g. item "organisation" should be active on route "organisation-settings"
|
||||||
|
expect(isNavItemActive(makeItem('organisation'), 'organisation-settings')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not match partial-word prefixes: "org" should NOT match "organisation-settings"', () => {
|
||||||
|
expect(isNavItemActive(makeItem('org'), 'organisation-settings')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles symbol route names: returns false (not string — no match)', () => {
|
||||||
|
const sym = Symbol('dashboard')
|
||||||
|
|
||||||
|
expect(isNavItemActive(makeItem('dashboard'), sym)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('item with to as { name: string } matches correctly', () => {
|
||||||
|
const item: V2NavItem = { id: 'events', label: 'Events', icon: 'tabler-calendar', to: { name: 'events' } }
|
||||||
|
|
||||||
|
expect(isNavItemActive(item, 'events')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
37
apps/app/src/components-v2/layout/sidebarNavActive.ts
Normal file
37
apps/app/src/components-v2/layout/sidebarNavActive.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { V2NavItem } from '@/types/v2/nav'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper — determines whether a V2NavItem is "active" given the current
|
||||||
|
* route name.
|
||||||
|
*
|
||||||
|
* Active rules (simplest correct definition):
|
||||||
|
* 1. Exact match: currentRouteName === item.to.name
|
||||||
|
* 2. Prefix match for nested routes: currentRouteName starts with
|
||||||
|
* item.to.name + '-' (e.g. item "organisation" is active on
|
||||||
|
* "organisation-settings"). The dash boundary prevents "org" from
|
||||||
|
* spuriously matching "organisation-settings".
|
||||||
|
*
|
||||||
|
* Only `to` values that are a plain object with a string `name` property are
|
||||||
|
* compared — string/array `to` values always return false (router-push
|
||||||
|
* style not used in v1 nav).
|
||||||
|
*/
|
||||||
|
export function isNavItemActive(
|
||||||
|
item: V2NavItem,
|
||||||
|
currentRouteName: string | symbol | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (typeof currentRouteName !== 'string')
|
||||||
|
return false
|
||||||
|
|
||||||
|
const to = item.to
|
||||||
|
|
||||||
|
if (typeof to !== 'object' || to === null || Array.isArray(to))
|
||||||
|
return false
|
||||||
|
|
||||||
|
const itemName = (to as { name?: unknown }).name
|
||||||
|
|
||||||
|
if (typeof itemName !== 'string')
|
||||||
|
return false
|
||||||
|
|
||||||
|
return currentRouteName === itemName
|
||||||
|
|| currentRouteName.startsWith(`${itemName}-`)
|
||||||
|
}
|
||||||
77
apps/app/src/composables/__tests__/useV2Nav.spec.ts
Normal file
77
apps/app/src/composables/__tests__/useV2Nav.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { toV2NavGroups } from '@/composables/useV2Nav'
|
||||||
|
import type { V2NavItem } from '@/types/v2/nav'
|
||||||
|
|
||||||
|
// Hand-built fixture — does NOT depend on the real orgNavItems contents.
|
||||||
|
const fixture = [
|
||||||
|
{ title: 'Alpha', to: { name: 'alpha' }, icon: { icon: 'tabler-home' } },
|
||||||
|
{ title: 'Beta', to: { name: 'beta' }, icon: { icon: 'tabler-bell' } },
|
||||||
|
{ heading: 'Group One' },
|
||||||
|
{ title: 'Gamma', to: { name: 'gamma' }, icon: { icon: 'tabler-star' }, count: 5 },
|
||||||
|
{ heading: 'Group Two' },
|
||||||
|
{ title: 'Delta', to: { name: 'delta' }, icon: { icon: 'tabler-settings' } },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
describe('toV2NavGroups', () => {
|
||||||
|
it('places items before the first heading into a leading group with label ""', () => {
|
||||||
|
const groups = toV2NavGroups(fixture)
|
||||||
|
|
||||||
|
expect(groups[0].label).toBe('')
|
||||||
|
expect(groups[0].items).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a new group when a heading entry is encountered', () => {
|
||||||
|
const groups = toV2NavGroups(fixture)
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(3)
|
||||||
|
expect(groups[1].label).toBe('Group One')
|
||||||
|
expect(groups[2].label).toBe('Group Two')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps item fields correctly: id, label, icon, to', () => {
|
||||||
|
const groups = toV2NavGroups(fixture)
|
||||||
|
const alpha = groups[0].items[0] as V2NavItem
|
||||||
|
|
||||||
|
expect(alpha.id).toBe('alpha')
|
||||||
|
expect(alpha.label).toBe('Alpha')
|
||||||
|
expect(alpha.icon).toBe('tabler-home')
|
||||||
|
expect(alpha.to).toEqual({ name: 'alpha' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes count through when present', () => {
|
||||||
|
const groups = toV2NavGroups(fixture)
|
||||||
|
const gamma = groups[1].items[0] as V2NavItem
|
||||||
|
|
||||||
|
expect(gamma.count).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves count undefined when absent', () => {
|
||||||
|
const groups = toV2NavGroups(fixture)
|
||||||
|
const alpha = groups[0].items[0] as V2NavItem
|
||||||
|
|
||||||
|
expect(alpha.count).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('id is derived from the route name (kebab already for dotted names)', () => {
|
||||||
|
const groups = toV2NavGroups(fixture)
|
||||||
|
const delta = groups[2].items[0] as V2NavItem
|
||||||
|
|
||||||
|
expect(delta.id).toBe('delta')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty groups array for an empty input', () => {
|
||||||
|
expect(toV2NavGroups([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('items-only input (no headings) returns a single leading group', () => {
|
||||||
|
const onlyItems = [
|
||||||
|
{ title: 'A', to: { name: 'a' }, icon: { icon: 'tabler-a' } },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const groups = toV2NavGroups(onlyItems)
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(1)
|
||||||
|
expect(groups[0].label).toBe('')
|
||||||
|
expect(groups[0].items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
95
apps/app/src/composables/useV2Nav.ts
Normal file
95
apps/app/src/composables/useV2Nav.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import type { ComputedRef } from 'vue'
|
||||||
|
import type { V2NavGroup, V2NavItem } from '@/types/v2/nav'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Local discriminated union for v1 nav entries (no `any`)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface V1NavHeading {
|
||||||
|
heading: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface V1NavLink {
|
||||||
|
title: string
|
||||||
|
to: { name: string }
|
||||||
|
icon: { icon: string }
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V1NavEntry = V1NavHeading | V1NavLink
|
||||||
|
|
||||||
|
function isHeading(entry: V1NavEntry): entry is V1NavHeading {
|
||||||
|
return 'heading' in entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure adapter — exported so it can be unit-tested without mounting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds a flat v1 nav array into V2NavGroup[].
|
||||||
|
*
|
||||||
|
* - A `{ heading }` entry closes the current group and opens a new one.
|
||||||
|
* - Items before the first heading are placed in a leading group with label ''.
|
||||||
|
* - A pure function with no side-effects.
|
||||||
|
*/
|
||||||
|
export function toV2NavGroups(items: readonly V1NavEntry[]): V2NavGroup[] {
|
||||||
|
if (items.length === 0)
|
||||||
|
return []
|
||||||
|
|
||||||
|
const groups: V2NavGroup[] = []
|
||||||
|
let current: V2NavGroup | null = null
|
||||||
|
|
||||||
|
for (const entry of items) {
|
||||||
|
if (isHeading(entry)) {
|
||||||
|
// Close current group (if any) and start a new named group.
|
||||||
|
if (current !== null)
|
||||||
|
groups.push(current)
|
||||||
|
|
||||||
|
current = { label: entry.heading, items: [] }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Ensure there is a current group (leading ungrouped section).
|
||||||
|
if (current === null)
|
||||||
|
current = { label: '', items: [] }
|
||||||
|
|
||||||
|
const navItem: V2NavItem = {
|
||||||
|
id: entry.to.name,
|
||||||
|
label: entry.title,
|
||||||
|
icon: entry.icon.icon,
|
||||||
|
to: { name: entry.to.name },
|
||||||
|
...(entry.count !== undefined ? { count: entry.count } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
current.items.push(navItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the last open group.
|
||||||
|
if (current !== null)
|
||||||
|
groups.push(current)
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Composable
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps toV2NavGroups in a computed ref.
|
||||||
|
*
|
||||||
|
* Accepts the raw v1 nav items as a parameter so the composable (composables
|
||||||
|
* boundary zone) does not need to import from @/navigation (navigation zone).
|
||||||
|
* Call-sites in layouts/pages — which ARE allowed to import navigation —
|
||||||
|
* pass orgNavItems directly:
|
||||||
|
*
|
||||||
|
* import { orgNavItems } from '@/navigation/vertical'
|
||||||
|
* const { groups } = useV2Nav(orgNavItems)
|
||||||
|
*/
|
||||||
|
export function useV2Nav(items: readonly V1NavEntry[]): { groups: ComputedRef<V2NavGroup[]> } {
|
||||||
|
const groups = computed(() => toV2NavGroups(items))
|
||||||
|
|
||||||
|
return { groups }
|
||||||
|
}
|
||||||
14
apps/app/src/types/v2/nav.ts
Normal file
14
apps/app/src/types/v2/nav.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export interface V2NavItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: string // e.g. 'tabler-smart-home'
|
||||||
|
to: RouteLocationRaw
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface V2NavGroup {
|
||||||
|
label: string // '' for an ungrouped leading section
|
||||||
|
items: V2NavItem[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user