chore: merge executor worktree (10-03 BreadcrumbBar)
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<nav aria-label="Navigation">
|
||||
<ol class="flex items-center gap-1 text-sm flex-wrap">
|
||||
<li v-if="showRoot" class="flex items-center gap-1">
|
||||
<button
|
||||
@click="$emit('navigate', null)"
|
||||
class="text-indigo-600 hover:underline font-medium"
|
||||
>
|
||||
{{ rootLabel }}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<template v-for="(segment, idx) in visibleSegments" :key="segment.id ?? 'static-' + idx">
|
||||
<li
|
||||
v-if="showRoot || idx > 0"
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<AppIcon name="chevronRight" class="w-3 h-3 text-gray-400" />
|
||||
</li>
|
||||
|
||||
<li v-if="segment.id === 'ellipsis'" class="flex items-center">
|
||||
<span class="px-2 py-1 text-gray-400">…</span>
|
||||
</li>
|
||||
|
||||
<li v-else-if="idx === visibleSegments.length - 1" class="flex items-center">
|
||||
<span class="text-gray-900 font-medium">{{ segment.label }}</span>
|
||||
</li>
|
||||
|
||||
<li v-else-if="segment.id" class="flex items-center">
|
||||
<button
|
||||
@click="$emit('navigate', segment.id)"
|
||||
class="text-indigo-600 hover:underline font-medium"
|
||||
>
|
||||
{{ segment.label }}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li v-else class="flex items-center">
|
||||
<span class="text-gray-500">{{ segment.label }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppIcon from './AppIcon.vue'
|
||||
|
||||
export default {
|
||||
name: 'BreadcrumbBar',
|
||||
components: { AppIcon },
|
||||
props: {
|
||||
segments: { type: Array, default: () => [] },
|
||||
rootLabel: { type: String, default: 'Home' },
|
||||
showRoot: { type: Boolean, default: true },
|
||||
},
|
||||
emits: ['navigate'],
|
||||
computed: {
|
||||
visibleSegments() {
|
||||
if (this.segments.length > 4) {
|
||||
return [
|
||||
this.segments[0],
|
||||
{ id: 'ellipsis', label: '…' },
|
||||
...this.segments.slice(-2),
|
||||
]
|
||||
}
|
||||
return this.segments
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import BreadcrumbBar from '../BreadcrumbBar.vue'
|
||||
|
||||
const STUBS = { global: { stubs: { AppIcon: true } } }
|
||||
|
||||
function seg(id, label) { return { id, label } }
|
||||
function staticSeg(label) { return { label } }
|
||||
|
||||
describe('BreadcrumbBar', () => {
|
||||
it('renders rootLabel button when showRoot=true', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { rootLabel: 'Home', segments: [] },
|
||||
...STUBS,
|
||||
})
|
||||
expect(w.find('button').text()).toBe('Home')
|
||||
})
|
||||
|
||||
it('does NOT render root button when showRoot=false', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { showRoot: false, segments: [staticSeg('Users')] },
|
||||
...STUBS,
|
||||
})
|
||||
expect(w.findAll('button').length).toBe(0)
|
||||
})
|
||||
|
||||
it('clicking root button emits navigate(null)', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [] },
|
||||
...STUBS,
|
||||
})
|
||||
await w.find('button').trigger('click')
|
||||
expect(w.emitted('navigate')).toBeTruthy()
|
||||
expect(w.emitted('navigate')[0]).toEqual([null])
|
||||
})
|
||||
|
||||
it('last segment renders as a non-clickable <span>', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [seg('a', 'A'), seg('b', 'B')] },
|
||||
...STUBS,
|
||||
})
|
||||
const text = w.text()
|
||||
expect(text).toContain('B')
|
||||
const buttonWithB = w.findAll('button').filter(b => b.text() === 'B')
|
||||
expect(buttonWithB.length).toBe(0)
|
||||
})
|
||||
|
||||
it('clicking intermediate segment emits navigate(segment.id)', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
|
||||
...STUBS,
|
||||
})
|
||||
const buttons = w.findAll('button')
|
||||
const rootBtn = buttons.find(b => b.text() === 'Root')
|
||||
await rootBtn.trigger('click')
|
||||
expect(w.emitted('navigate')).toBeTruthy()
|
||||
expect(w.emitted('navigate')[0]).toEqual(['r1'])
|
||||
})
|
||||
|
||||
it('segments without id render as plain text even when intermediate', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: {
|
||||
showRoot: false,
|
||||
segments: [staticSeg('Admin'), staticSeg('Users')],
|
||||
},
|
||||
...STUBS,
|
||||
})
|
||||
expect(w.findAll('button').length).toBe(0)
|
||||
})
|
||||
|
||||
it('>4 segments collapse to first + ellipsis + last two', () => {
|
||||
const segments = [
|
||||
seg('a', 'Alpha'),
|
||||
seg('b', 'Beta'),
|
||||
seg('c', 'Gamma'),
|
||||
seg('d', 'Delta'),
|
||||
seg('e', 'Epsilon'),
|
||||
]
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments },
|
||||
...STUBS,
|
||||
})
|
||||
const text = w.text()
|
||||
expect(text).toContain('Alpha')
|
||||
expect(text).toContain('…')
|
||||
expect(text).toContain('Delta')
|
||||
expect(text).toContain('Epsilon')
|
||||
expect(text).not.toContain('Beta')
|
||||
expect(text).not.toContain('Gamma')
|
||||
})
|
||||
|
||||
it('rootLabel defaults to "Home"', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [] },
|
||||
...STUBS,
|
||||
})
|
||||
expect(w.find('button').text()).toBe('Home')
|
||||
})
|
||||
|
||||
it('custom rootLabel "Cloud" renders correctly', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { rootLabel: 'Cloud', segments: [] },
|
||||
...STUBS,
|
||||
})
|
||||
expect(w.find('button').text()).toBe('Cloud')
|
||||
})
|
||||
|
||||
it('renders chevronRight separator between segments', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [seg('a', 'A'), seg('b', 'B')] },
|
||||
...STUBS,
|
||||
})
|
||||
const appIcons = w.findAll('app-icon-stub')
|
||||
expect(appIcons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user