V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
@@ -16,6 +16,8 @@ const router = useRouter()
|
||||
const preference = useScreenPreferenceStore()
|
||||
const workspace = useWorkspaceStore()
|
||||
const collapsed = ref(false)
|
||||
const mobileNavOpen = ref(false)
|
||||
const globalHeader = ref<InstanceType<typeof KsGlobalHeader> | null>(null)
|
||||
const menuSearchOpen = ref(false)
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION ?? '0.1.0'
|
||||
|
||||
@@ -40,6 +42,10 @@ watch(() => route.fullPath, () => {
|
||||
function goHome() {
|
||||
router.push('/home')
|
||||
}
|
||||
function closeMobileNavigation() {
|
||||
mobileNavOpen.value = false
|
||||
requestAnimationFrame(() => globalHeader.value?.focusMenuButton())
|
||||
}
|
||||
function openMenuSearch() {
|
||||
menuSearchOpen.value = true
|
||||
}
|
||||
@@ -60,6 +66,10 @@ function closeTab(tab: WorkspaceTab) {
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && mobileNavOpen.value) {
|
||||
closeMobileNavigation()
|
||||
return
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
menuSearchOpen.value = true
|
||||
@@ -72,11 +82,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
<template>
|
||||
<div class="ks-app-shell">
|
||||
<a class="ks-skip" href="#ks-main">본문으로 건너뛰기</a>
|
||||
<KsGlobalHeader :product-name="productName" :environment="environment" :automation-status="automationStatus" @home="goHome" @menu-search="openMenuSearch" />
|
||||
<KsGlobalHeader ref="globalHeader" :product-name="productName" :environment="environment" :automation-status="automationStatus" @home="goHome" @menu-search="openMenuSearch" @menu-toggle="mobileNavOpen = true" />
|
||||
<KsWorkspaceTabs :tabs="workspace.visibleTabs" :overflow-count="workspace.overflowCount" :active-key="activeTabKey()" @select="selectTab" @close="closeTab" @toggle-pin="workspace.togglePin" />
|
||||
<div class="ks-app-shell__body">
|
||||
<KsSideNavigation :sections="sections()" :favorites="favorites()" :active-path="route.path" :collapsed="collapsed" @toggle-collapsed="collapsed = !collapsed" />
|
||||
<KsSideNavigation :sections="sections()" :favorites="favorites()" :active-path="route.path" :collapsed="collapsed" :collapsed-sections="preference.collapsedSectionModules" :mobile-open="mobileNavOpen" @toggle-collapsed="collapsed = !collapsed" @toggle-section="preference.toggleSection" @close-mobile="closeMobileNavigation" />
|
||||
<button v-if="mobileNavOpen" type="button" class="ks-mobile-backdrop" aria-label="메뉴 닫기" @click="closeMobileNavigation" />
|
||||
<main id="ks-main" class="ks-app-shell__main" tabindex="-1">
|
||||
<nav class="ks-breadcrumb" aria-label="현재 위치">
|
||||
<RouterLink to="/home">홈</RouterLink>
|
||||
<span aria-hidden="true">›</span>
|
||||
<span aria-current="page">{{ typeof route.meta.title === 'string' ? route.meta.title : route.path }}</span>
|
||||
</nav>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
@@ -90,9 +106,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
.ks-app-shell { min-height: 100vh; display: grid; grid-template-rows: auto auto 1fr auto; background: var(--ks-color-canvas); }
|
||||
.ks-app-shell__body { display: flex; min-height: 0; }
|
||||
.ks-app-shell__main { flex: 1; min-width: 0; padding: var(--ks-space-6); overflow: auto; }
|
||||
.ks-breadcrumb { display: flex; gap: var(--ks-space-2); align-items: center; margin-bottom: var(--ks-space-4); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
.ks-breadcrumb a { color: inherit; text-decoration: none; }
|
||||
.ks-breadcrumb a:hover, .ks-breadcrumb a:focus-visible { color: var(--ks-color-text); text-decoration: underline; }
|
||||
.ks-app-shell__footer { padding: var(--ks-space-2) var(--ks-space-6); border-top: 1px solid var(--ks-color-border); background: var(--ks-color-surface); color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
.ks-app-shell__version { position: fixed; right: var(--ks-space-3); bottom: var(--ks-space-2); z-index: 20; padding: .25rem .5rem; border: 1px solid var(--ks-color-border); border-radius: var(--ks-radius-sm); background: rgb(255 255 255 / 92%); color: var(--ks-color-text-muted); font-size: .7rem; box-shadow: var(--ks-shadow-sm); }
|
||||
.ks-skip { position: fixed; left: var(--ks-space-2); top: -4rem; z-index: 1000; padding: var(--ks-space-2); background: var(--ks-color-surface); }
|
||||
.ks-skip:focus { top: var(--ks-space-2); }
|
||||
@media (max-width: 900px) { .ks-app-shell__body { flex-direction: column; } }
|
||||
.ks-mobile-backdrop { display: none; }
|
||||
@media (max-width: 900px) { .ks-app-shell__body { flex-direction: column; } .ks-mobile-backdrop { display: block; position: fixed; inset: 0; z-index: 40; border: 0; background: rgb(15 23 42 / 45%); } }
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
withDefaults(defineProps<{ productName?: string; environment?: string; automationStatus?: string }>(), {
|
||||
productName: 'K-ArtSell Aegis',
|
||||
environment: 'IMPLEMENTATION_TEMPLATE',
|
||||
automationStatus: '투자자문형 · 자동주문/KIS 제출 OFF · 자동 모델승격 OFF'
|
||||
})
|
||||
const emit = defineEmits<{ home: []; menuSearch: [] }>()
|
||||
const emit = defineEmits<{ home: []; menuSearch: []; menuToggle: [] }>()
|
||||
const menuButton = ref<HTMLButtonElement | null>(null)
|
||||
defineExpose({ focusMenuButton: () => menuButton.value?.focus() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="ks-global-header">
|
||||
<button ref="menuButton" type="button" class="ks-global-header__menu" aria-label="주요 메뉴 열기" @click="emit('menuToggle')">☰</button>
|
||||
<button type="button" class="ks-global-header__brand" @click="emit('home')">
|
||||
<strong>{{ productName }}</strong><small>{{ environment }}</small>
|
||||
</button>
|
||||
@@ -27,4 +31,6 @@ const emit = defineEmits<{ home: []; menuSearch: [] }>()
|
||||
.ks-global-header__search { flex: 1; max-width: 28rem; height: 2rem; display: flex; align-items: center; justify-content: space-between; gap: var(--ks-space-2); padding: 0 var(--ks-space-3); border: 1px solid var(--ks-color-neutral-700); border-radius: var(--ks-radius-sm); background: var(--ks-color-neutral-800); color: var(--ks-color-neutral-300); }
|
||||
.ks-global-header__search kbd { font-size: var(--ks-font-caption); border: 1px solid var(--ks-color-neutral-600); border-radius: var(--ks-radius-sm); padding: 0 var(--ks-space-1); }
|
||||
.ks-global-header__status { margin-left: auto; padding: var(--ks-space-1) var(--ks-space-3); border: 1px solid #fbbf24; border-radius: var(--ks-radius-sm); color: #fef3c7; font-size: var(--ks-font-caption); white-space: nowrap; }
|
||||
.ks-global-header__menu { display: none; border: 1px solid var(--ks-color-neutral-700); border-radius: var(--ks-radius-sm); background: transparent; color: inherit; padding: .25rem .5rem; }
|
||||
@media (max-width: 900px) { .ks-global-header__menu { display: inline-flex; } }
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@ const emit = defineEmits<{ close: []; select: [entry: NavigationEntry] }>()
|
||||
const query = ref('')
|
||||
const activeIndex = ref(0)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const resultId = (index: number) => `ks-menu-search-result-${index}`
|
||||
|
||||
const results = computed<NavigationEntry[]>(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
@@ -40,11 +41,12 @@ function selectActive() {
|
||||
<template>
|
||||
<KsDialog :visible="props.open" title="메뉴 검색" modal closable @update:visible="value => { if (!value) emit('close') }">
|
||||
<div class="ks-menu-search" role="presentation" @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="selectActive" @keydown.esc="emit('close')">
|
||||
<input ref="inputRef" v-model="query" type="text" placeholder="메뉴명 · 화면코드 · 업무명 검색" aria-label="메뉴 검색" />
|
||||
<ul role="listbox" aria-label="검색 결과">
|
||||
<input ref="inputRef" v-model="query" type="text" placeholder="메뉴명 · 화면코드 · 업무명 검색" aria-label="메뉴 검색" :aria-activedescendant="results.length ? resultId(activeIndex) : undefined" aria-controls="ks-menu-search-results" />
|
||||
<ul id="ks-menu-search-results" role="listbox" aria-label="검색 결과">
|
||||
<li
|
||||
v-for="(entry, index) in results"
|
||||
:key="entry.screenId"
|
||||
:id="resultId(index)"
|
||||
role="option"
|
||||
:aria-selected="index === activeIndex"
|
||||
:class="{ active: index === activeIndex }"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { NavigationEntry, NavigationSection } from './navigationCatalog'
|
||||
|
||||
@@ -7,23 +8,51 @@ const props = withDefaults(defineProps<{
|
||||
favorites?: NavigationEntry[]
|
||||
activePath?: string
|
||||
collapsed?: boolean
|
||||
collapsedSections?: readonly string[]
|
||||
mobileOpen?: boolean
|
||||
}>(), { favorites: () => [], activePath: '', collapsed: false })
|
||||
const emit = defineEmits<{ toggleCollapsed: [] }>()
|
||||
const emit = defineEmits<{ toggleCollapsed: []; toggleSection: [module: string]; closeMobile: [] }>()
|
||||
const navigation = ref<HTMLElement | null>(null)
|
||||
|
||||
function isEntryActive(path: string): boolean {
|
||||
return props.activePath === path || props.activePath.startsWith(`${path}/`)
|
||||
}
|
||||
|
||||
function toggleSection(module: string): void {
|
||||
emit('toggleSection', module)
|
||||
}
|
||||
|
||||
function trapFocus(event: KeyboardEvent): void {
|
||||
if (!props.mobileOpen || event.key !== 'Tab' || !navigation.value) return
|
||||
const focusable = [...navigation.value.querySelectorAll<HTMLElement>('button, a')].filter(element => !element.hasAttribute('disabled'))
|
||||
if (!focusable.length) return
|
||||
const first = focusable[0]
|
||||
const last = focusable.at(-1) ?? first
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
|
||||
watch(() => props.mobileOpen, async open => {
|
||||
if (open) { await nextTick(); navigation.value?.querySelector<HTMLElement>('.ks-side-nav__mobile-close')?.focus() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="ks-side-nav" :class="{ 'ks-side-nav--collapsed': props.collapsed }" aria-label="주요 메뉴">
|
||||
<aside ref="navigation" class="ks-side-nav" :class="{ 'ks-side-nav--collapsed': props.collapsed, 'ks-side-nav--mobile-open': props.mobileOpen }" aria-label="주요 메뉴" @keydown="trapFocus">
|
||||
<button type="button" class="ks-side-nav__mobile-close" aria-label="주요 메뉴 닫기" @click="emit('closeMobile')">×</button>
|
||||
<button type="button" class="ks-side-nav__toggle" :aria-expanded="!props.collapsed" @click="emit('toggleCollapsed')">
|
||||
{{ props.collapsed ? '»' : '« 접기' }}
|
||||
</button>
|
||||
<template v-if="!props.collapsed">
|
||||
<section v-if="favorites.length" class="ks-side-nav__section">
|
||||
<h2>즐겨찾기</h2>
|
||||
<RouterLink v-for="entry in favorites" :key="entry.screenId" :to="entry.path" :class="{ active: entry.path === activePath }">★ {{ entry.title }}</RouterLink>
|
||||
<RouterLink v-for="entry in favorites" :key="entry.screenId" :to="entry.path" :class="{ active: isEntryActive(entry.path) }" :aria-current="isEntryActive(entry.path) ? 'page' : undefined">★ {{ entry.title }}</RouterLink>
|
||||
</section>
|
||||
<section v-for="section in sections" :key="section.module" class="ks-side-nav__section">
|
||||
<h2>{{ section.module }}</h2>
|
||||
<RouterLink v-for="entry in section.entries" :key="entry.screenId" :to="entry.path" :class="{ active: entry.path === activePath }">{{ entry.title }}</RouterLink>
|
||||
<button type="button" class="ks-side-nav__section-toggle" :aria-expanded="!props.collapsedSections?.includes(section.module)" @click="toggleSection(section.module)">{{ section.module }}</button>
|
||||
<template v-if="!props.collapsedSections?.includes(section.module)">
|
||||
<RouterLink v-for="entry in section.entries" :key="entry.screenId" :to="entry.path" :class="{ active: isEntryActive(entry.path) }" :aria-current="isEntryActive(entry.path) ? 'page' : undefined">{{ entry.title }}</RouterLink>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</aside>
|
||||
@@ -35,6 +64,13 @@ const emit = defineEmits<{ toggleCollapsed: [] }>()
|
||||
.ks-side-nav__toggle { justify-self: end; border: 0; background: transparent; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); cursor: pointer; }
|
||||
.ks-side-nav__section { display: grid; gap: var(--ks-space-1); }
|
||||
.ks-side-nav__section h2 { margin: 0 var(--ks-space-2); font-size: var(--ks-font-caption); font-weight: 600; color: var(--ks-color-text-muted); text-transform: uppercase; }
|
||||
.ks-side-nav__section-toggle { border: 0; background: transparent; text-align: left; margin: 0 var(--ks-space-2); padding: 0; color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); font-weight: 600; text-transform: uppercase; cursor: pointer; }
|
||||
.ks-side-nav__section a { padding: var(--ks-space-2) var(--ks-space-2); border-radius: var(--ks-radius-sm); text-decoration: none; color: var(--ks-color-text); font-size: var(--ks-font-body); }
|
||||
.ks-side-nav__section a.active, .ks-side-nav__section a.router-link-active { background: var(--ks-color-neutral-100); font-weight: 700; }
|
||||
.ks-side-nav__mobile-close { display: none; }
|
||||
@media (max-width: 900px) {
|
||||
.ks-side-nav { position: fixed; inset: 0 auto 0 0; z-index: 50; width: min(20rem, 88vw); transform: translateX(-105%); transition: transform .18s ease; box-shadow: var(--ks-shadow-lg); }
|
||||
.ks-side-nav--mobile-open { transform: translateX(0); }
|
||||
.ks-side-nav__mobile-close { display: block; justify-self: end; border: 0; background: transparent; font-size: 1.5rem; cursor: pointer; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface NavigationEntry {
|
||||
order: number
|
||||
favoriteAllowed: boolean
|
||||
internalOnly: boolean
|
||||
permissions: readonly string[]
|
||||
}
|
||||
|
||||
export interface NavigationSection {
|
||||
@@ -23,6 +24,7 @@ function toEntry(route: NavigationRouteLike): NavigationEntry | null {
|
||||
const meta = route.meta
|
||||
if (!meta || typeof meta.screenId !== 'string' || typeof meta.module !== 'string') return null
|
||||
if (meta.module === 'Home') return null
|
||||
if (route.path.includes(':')) return null
|
||||
return {
|
||||
screenId: meta.screenId,
|
||||
path: route.path,
|
||||
@@ -31,10 +33,18 @@ function toEntry(route: NavigationRouteLike): NavigationEntry | null {
|
||||
title: typeof meta.title === 'string' ? meta.title : route.path,
|
||||
order: typeof meta.order === 'number' ? meta.order : 0,
|
||||
favoriteAllowed: meta.favoriteAllowed !== false,
|
||||
internalOnly: meta.internalOnly === true
|
||||
internalOnly: meta.internalOnly === true,
|
||||
permissions: Array.isArray(meta.permissions) && meta.permissions.every(permission => typeof permission === 'string')
|
||||
? meta.permissions
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
/** UI visibility hint only; the API remains the authorization authority. */
|
||||
export function filterNavigationEntries(entries: readonly NavigationEntry[], grantedPermissions: ReadonlySet<string>): NavigationEntry[] {
|
||||
return entries.filter(entry => entry.permissions.every(permission => grantedPermissions.has(permission)))
|
||||
}
|
||||
|
||||
/** router.getRoutes()의 flat route 목록에서 즐겨찾기/검색/사이드바가 공유하는 단일 카탈로그를 파생한다. */
|
||||
export function buildNavigationEntries(routes: readonly NavigationRouteLike[]): NavigationEntry[] {
|
||||
const entries = routes.map(toEntry).filter((entry): entry is NavigationEntry => entry !== null)
|
||||
|
||||
@@ -13,20 +13,24 @@ const MAX_FAVORITES = 20
|
||||
interface PersistedShape {
|
||||
favoriteScreenIds: string[]
|
||||
recents: RecentNavigation[]
|
||||
collapsedSectionModules: string[]
|
||||
}
|
||||
|
||||
function readPersisted(): PersistedShape {
|
||||
if (typeof window === 'undefined') return { favoriteScreenIds: [], recents: [] }
|
||||
if (typeof window === 'undefined') return { favoriteScreenIds: [], recents: [], collapsedSectionModules: [] }
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { favoriteScreenIds: [], recents: [] }
|
||||
if (!raw) return { favoriteScreenIds: [], recents: [], collapsedSectionModules: [] }
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedShape>
|
||||
return {
|
||||
favoriteScreenIds: Array.isArray(parsed.favoriteScreenIds) ? parsed.favoriteScreenIds.slice(0, MAX_FAVORITES) : [],
|
||||
recents: Array.isArray(parsed.recents) ? parsed.recents.slice(0, MAX_RECENTS) : []
|
||||
recents: Array.isArray(parsed.recents) ? parsed.recents.slice(0, MAX_RECENTS) : [],
|
||||
collapsedSectionModules: Array.isArray(parsed.collapsedSectionModules)
|
||||
? parsed.collapsedSectionModules.filter((module): module is string => typeof module === 'string')
|
||||
: []
|
||||
}
|
||||
} catch {
|
||||
return { favoriteScreenIds: [], recents: [] }
|
||||
return { favoriteScreenIds: [], recents: [], collapsedSectionModules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +39,7 @@ export const useScreenPreferenceStore = defineStore('ks-screen-preference', {
|
||||
actions: {
|
||||
persist() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ favoriteScreenIds: this.favoriteScreenIds, recents: this.recents }))
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ favoriteScreenIds: this.favoriteScreenIds, recents: this.recents, collapsedSectionModules: this.collapsedSectionModules }))
|
||||
},
|
||||
toggleFavorite(screenId: string) {
|
||||
const index = this.favoriteScreenIds.indexOf(screenId)
|
||||
@@ -50,6 +54,15 @@ export const useScreenPreferenceStore = defineStore('ks-screen-preference', {
|
||||
const withoutCurrent = this.recents.filter(entry => entry.screenId !== screenId)
|
||||
this.recents = [{ screenId, path, visitedAt: new Date().toISOString() }, ...withoutCurrent].slice(0, MAX_RECENTS)
|
||||
this.persist()
|
||||
},
|
||||
toggleSection(module: string) {
|
||||
const index = this.collapsedSectionModules.indexOf(module)
|
||||
if (index >= 0) this.collapsedSectionModules.splice(index, 1)
|
||||
else this.collapsedSectionModules.push(module)
|
||||
this.persist()
|
||||
},
|
||||
isSectionCollapsed(module: string): boolean {
|
||||
return this.collapsedSectionModules.includes(module)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import KsMenuSearch from '../KsMenuSearch.vue'
|
||||
|
||||
const entries = [
|
||||
{ screenId: 'SCR-1', path: '/ops/data', module: 'Operations', section: 'Operations', title: '데이터 품질', order: 1, favoriteAllowed: true, internalOnly: false, permissions: [] },
|
||||
{ screenId: 'SCR-2', path: '/portfolio/risk', module: 'Portfolio', section: 'Portfolio', title: '리스크', order: 1, favoriteAllowed: true, internalOnly: false, permissions: [] },
|
||||
]
|
||||
|
||||
const global = { stubs: { KsDialog: { props: ['visible'], template: '<div><slot /></div>' } } }
|
||||
|
||||
describe('KsMenuSearch contract', () => {
|
||||
it('connects the active result to the search input for assistive technology', async () => {
|
||||
const wrapper = mount(KsMenuSearch, { props: { open: true, entries }, global })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const input = wrapper.get('input')
|
||||
expect(input.attributes('aria-controls')).toBe('ks-menu-search-results')
|
||||
expect(input.attributes('aria-activedescendant')).toBe('ks-menu-search-result-0')
|
||||
expect(wrapper.get('#ks-menu-search-result-0').attributes('aria-selected')).toBe('true')
|
||||
|
||||
await input.trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(input.attributes('aria-activedescendant')).toBe('ks-menu-search-result-1')
|
||||
})
|
||||
|
||||
it('removes the active descendant when filtering returns no results', async () => {
|
||||
const wrapper = mount(KsMenuSearch, { props: { open: true, entries }, global })
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('없는 메뉴')
|
||||
|
||||
expect(input.attributes('aria-activedescendant')).toBeUndefined()
|
||||
expect(wrapper.get('[role="listbox"]').text()).toContain('검색 결과가 없습니다.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import KsSideNavigation from '../KsSideNavigation.vue'
|
||||
|
||||
const sections = [{ module: 'ModelOps', entries: [{ screenId: 'models', path: '/model-ops/models', module: 'ModelOps', section: 'ModelOps', title: 'Models', order: 1, favoriteAllowed: true, internalOnly: false, permissions: [] }] }]
|
||||
const routerLinkStub = { props: ['to'], template: '<a :href="to"><slot /></a>' }
|
||||
const global = { stubs: { RouterLink: routerLinkStub } }
|
||||
|
||||
describe('KsSideNavigation contract', () => {
|
||||
it('marks a parent menu active for a nested detail route', () => {
|
||||
const wrapper = mount(KsSideNavigation, { props: { sections, activePath: '/model-ops/models/model-1' }, global })
|
||||
const link = wrapper.get('a')
|
||||
expect(link.classes()).toContain('active')
|
||||
expect(link.attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('does not mark an unrelated route active', () => {
|
||||
const wrapper = mount(KsSideNavigation, { props: { sections, activePath: '/portfolio/risk' }, global })
|
||||
expect(wrapper.get('a').classes()).not.toContain('active')
|
||||
expect(wrapper.get('a').attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('collapses a module section without changing its navigation entries', async () => {
|
||||
const wrapper = mount(KsSideNavigation, { props: { sections, activePath: '' }, global })
|
||||
const toggle = wrapper.get('.ks-side-nav__section-toggle')
|
||||
expect(toggle.attributes('aria-expanded')).toBe('true')
|
||||
await toggle.trigger('click')
|
||||
expect(wrapper.emitted('toggleSection')).toEqual([['ModelOps']])
|
||||
await wrapper.setProps({ collapsedSections: ['ModelOps'] })
|
||||
expect(wrapper.get('.ks-side-nav__section-toggle').attributes('aria-expanded')).toBe('false')
|
||||
expect(wrapper.find('a').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,8 @@ describe('KsAppShell contract', () => {
|
||||
expect(wrapper.get('aside').attributes('aria-label')).toBe('주요 메뉴')
|
||||
expect(wrapper.get('nav.ks-workspace-tabs').attributes('aria-label')).toBe('열린 업무')
|
||||
expect(wrapper.get('main').attributes('tabindex')).toBe('-1')
|
||||
expect(wrapper.get('.ks-breadcrumb').attributes('aria-label')).toBe('현재 위치')
|
||||
expect(wrapper.get('.ks-breadcrumb').text()).toContain('매도 의사결정')
|
||||
expect(wrapper.text()).toContain('화면 내용')
|
||||
expect(wrapper.text()).toContain('RESEARCH_CANDIDATE_NOT_PRODUCTION')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildNavigationEntries, groupByModule } from '../navigationCatalog'
|
||||
import { buildNavigationEntries, filterNavigationEntries, groupByModule } from '../navigationCatalog'
|
||||
|
||||
describe('navigation catalogue', () => {
|
||||
it('includes the read-only component catalogue in the Design System menu', () => {
|
||||
@@ -27,4 +27,27 @@ describe('navigation catalogue', () => {
|
||||
])
|
||||
expect(entries.find(entry => entry.path === '/internal/wbs')?.internalOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves route permissions and filters navigation as a UI visibility hint', () => {
|
||||
const entries = buildNavigationEntries([
|
||||
{ path: '/models', meta: { screenId: 'models', module: 'ModelOps', title: 'Models', permissions: ['model.read'] } },
|
||||
{ path: '/home', meta: { screenId: 'home', module: 'Home', title: '홈' } },
|
||||
])
|
||||
|
||||
expect(entries[0].permissions).toEqual(['model.read'])
|
||||
expect(filterNavigationEntries(entries, new Set())).toEqual([])
|
||||
expect(filterNavigationEntries(entries, new Set(['model.read']))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails closed for malformed permission metadata', () => {
|
||||
const [entry] = buildNavigationEntries([{ path: '/safe', meta: { screenId: 'safe', module: 'Operations', permissions: ['ops.read', 7] } }])
|
||||
expect(entry.permissions).toEqual([])
|
||||
})
|
||||
|
||||
it('does not expose parameterized detail routes as top-level navigation', () => {
|
||||
expect(buildNavigationEntries([
|
||||
{ path: '/models', meta: { screenId: 'models', module: 'ModelOps', title: 'Models' } },
|
||||
{ path: '/models/:modelId', meta: { screenId: 'model-detail', module: 'ModelOps', title: 'Model Detail' } },
|
||||
])).toEqual([expect.objectContaining({ path: '/models' })])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user