feat(#208): doc workspace breadcrumb (Studio > <doc> > <mode>)
Adds the breadcrumb anchoring the user across modes on the doc workspace. Empty / hidden on routes that don't opt in. Components - shared/breadcrumb/AppBreadcrumb.vue: data-driven, accessible (<nav aria-label>, <ol>, aria-current=page on the leaf). Renders nothing when crumbs.length === 0. - shared/breadcrumb/types.ts: Crumb = LinkCrumb | LeafCrumb discriminated union. - shared/breadcrumb/store.ts: Pinia store + useCrumbs(source) composable that auto-clears on unmount. Accepts a static array OR a reactive ref/computed so pages with async doc fetches can rebuild crumbs as data lands. - shared/breadcrumb/text.ts: truncate(text, max) helper. Wiring - App.vue main outlet now sits below <AppBreadcrumb>; the shell reads from useBreadcrumbStore so pages don't need teleports. - DocWorkspacePage provides Studio > <id-truncated> > <mode-label>; once the doc is fetched (#216 / E4), the id placeholder will swap for the truncated filename. i18n - breadcrumb.aria, breadcrumb.studio, breadcrumb.mode.{ask,inspect, chunks} added in fr + en. Tests - shared/breadcrumb/text.test.ts: 5 cases on truncate. - shared/breadcrumb/store.test.ts: store actions + useCrumbs reactive/ static seeding (the lifecycle-clear path is covered end-to-end by the integration tests landing in #211 / #216 — the project doesn't use @vue/test-utils so a unit test for onBeforeUnmount is overkill). Refs #208
This commit is contained in:
parent
47e0d197b3
commit
ab5de2aac3
9 changed files with 308 additions and 1 deletions
|
|
@ -40,6 +40,7 @@
|
|||
<div class="app-body">
|
||||
<AppSidebar :open="sidebarOpen" />
|
||||
<main class="main">
|
||||
<AppBreadcrumb :crumbs="breadcrumbStore.crumbs" />
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -50,6 +51,8 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { AppSidebar } from '../shared/ui/index'
|
||||
import AppBreadcrumb from '../shared/breadcrumb/AppBreadcrumb.vue'
|
||||
import { useBreadcrumbStore } from '../shared/breadcrumb/store'
|
||||
import { useSettingsStore } from '../features/settings/store'
|
||||
import { useDocumentStore } from '../features/document/store'
|
||||
import { useFeatureFlag } from '../features/feature-flags'
|
||||
|
|
@ -58,6 +61,7 @@ import { useI18n } from '../shared/i18n'
|
|||
|
||||
useSettingsStore()
|
||||
const flagStore = useFeatureFlagStore()
|
||||
const breadcrumbStore = useBreadcrumbStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const documentStore = useDocumentStore()
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import { useCrumbs } from '../shared/breadcrumb/store'
|
||||
import { truncate } from '../shared/breadcrumb/text'
|
||||
import type { Crumb } from '../shared/breadcrumb/types'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { type DocMode } from '../shared/routing/modes'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
/**
|
||||
|
|
@ -24,4 +28,18 @@ const props = defineProps<{ id: string; mode: DocMode }>()
|
|||
const { t } = useI18n()
|
||||
|
||||
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
|
||||
|
||||
// Provide the breadcrumb segments. Once the doc is fetched (E4 / #216),
|
||||
// `truncate(doc.filename, 40)` will replace the id; for now we render
|
||||
// the id itself so the user can still see what they are looking at.
|
||||
const crumbs = computed<Crumb[]>(() => [
|
||||
{ kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } },
|
||||
{
|
||||
kind: 'link',
|
||||
label: truncate(props.id, 40),
|
||||
to: { name: ROUTES.DOC_WORKSPACE, params: { id: props.id } },
|
||||
},
|
||||
{ kind: 'leaf', label: t(`breadcrumb.mode.${props.mode}`) },
|
||||
])
|
||||
useCrumbs(crumbs)
|
||||
</script>
|
||||
|
|
|
|||
93
frontend/src/shared/breadcrumb/AppBreadcrumb.vue
Normal file
93
frontend/src/shared/breadcrumb/AppBreadcrumb.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<nav v-if="crumbs.length > 0" class="breadcrumb" :aria-label="t('breadcrumb.aria')">
|
||||
<ol class="breadcrumb__list">
|
||||
<li
|
||||
v-for="(crumb, index) in crumbs"
|
||||
:key="`${index}-${crumb.label}`"
|
||||
class="breadcrumb__item"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="crumb.kind === 'link'"
|
||||
:to="crumb.to"
|
||||
class="breadcrumb__link"
|
||||
:title="crumb.label"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</RouterLink>
|
||||
<span v-else class="breadcrumb__leaf" aria-current="page" :title="crumb.label">
|
||||
{{ crumb.label }}
|
||||
</span>
|
||||
<span v-if="index < crumbs.length - 1" class="breadcrumb__sep" aria-hidden="true"> › </span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
import type { Crumb } from './types'
|
||||
|
||||
defineProps<{ crumbs: Crumb[] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
border-bottom: 1px solid var(--color-border, #1a1a1d);
|
||||
background: var(--color-surface, #111113);
|
||||
}
|
||||
|
||||
.breadcrumb__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.breadcrumb__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumb__link {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 16rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.breadcrumb__link:hover {
|
||||
color: var(--color-text, #f3f4f6);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb__leaf {
|
||||
color: var(--color-text, #f3f4f6);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 16rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb__sep {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
71
frontend/src/shared/breadcrumb/store.test.ts
Normal file
71
frontend/src/shared/breadcrumb/store.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useBreadcrumbStore, useCrumbs } from './store'
|
||||
import type { Crumb } from './types'
|
||||
|
||||
/**
|
||||
* The composable side of `useCrumbs` registers `onBeforeUnmount` which
|
||||
* requires a component lifecycle context. The project does not have
|
||||
* `@vue/test-utils`, so we test the store API directly. The composable
|
||||
* lifecycle is exercised end-to-end by the integration tests landing
|
||||
* in #211 / #216.
|
||||
*
|
||||
* We DO test the reactive-input behaviour by calling `useCrumbs` with
|
||||
* a ref inside a setup-style scope; the watch fires synchronously
|
||||
* thanks to `immediate: true`.
|
||||
*/
|
||||
describe('useBreadcrumbStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts empty', () => {
|
||||
const store = useBreadcrumbStore()
|
||||
expect(store.crumbs).toEqual([])
|
||||
})
|
||||
|
||||
it('setCrumbs replaces the segments', () => {
|
||||
const store = useBreadcrumbStore()
|
||||
const next: Crumb[] = [{ kind: 'leaf', label: 'a' }]
|
||||
store.setCrumbs(next)
|
||||
expect(store.crumbs).toEqual(next)
|
||||
})
|
||||
|
||||
it('clear empties the segments', () => {
|
||||
const store = useBreadcrumbStore()
|
||||
store.setCrumbs([{ kind: 'leaf', label: 'a' }])
|
||||
store.clear()
|
||||
expect(store.crumbs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCrumbs (reactive source path)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('seeds the store from a ref synchronously', () => {
|
||||
const store = useBreadcrumbStore()
|
||||
const dynamic: Ref<Crumb[]> = ref([{ kind: 'leaf', label: 'first' }])
|
||||
// Note: `useCrumbs` calls `onBeforeUnmount` which is a no-op when
|
||||
// there is no current component — Vue logs a warning but the watch
|
||||
// path still installs.
|
||||
useCrumbs(dynamic)
|
||||
expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'first' }])
|
||||
|
||||
dynamic.value = [{ kind: 'leaf', label: 'second' }]
|
||||
// The watch is `immediate: true`; for subsequent updates it runs
|
||||
// on the next microtask.
|
||||
return Promise.resolve().then(() => {
|
||||
expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'second' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds the store from a static array', () => {
|
||||
const store = useBreadcrumbStore()
|
||||
useCrumbs([{ kind: 'leaf', label: 'static' }])
|
||||
expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'static' }])
|
||||
})
|
||||
})
|
||||
53
frontend/src/shared/breadcrumb/store.ts
Normal file
53
frontend/src/shared/breadcrumb/store.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { onBeforeUnmount, watch, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
import type { Crumb } from './types'
|
||||
|
||||
/**
|
||||
* Store the current breadcrumb segments. Pages set them on mount via
|
||||
* `useCrumbs(crumbs)` and clear them on unmount automatically.
|
||||
*
|
||||
* The shell (`App.vue`) reads `crumbs` and renders `<AppBreadcrumb>` —
|
||||
* empty array → component renders nothing.
|
||||
*/
|
||||
export const useBreadcrumbStore = defineStore('breadcrumb', {
|
||||
state: () => ({
|
||||
crumbs: [] as Crumb[],
|
||||
}),
|
||||
actions: {
|
||||
setCrumbs(crumbs: Crumb[]) {
|
||||
this.crumbs = crumbs
|
||||
},
|
||||
clear() {
|
||||
this.crumbs = []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Composable for pages to declare their breadcrumb. Auto-clears on
|
||||
* unmount so a stale breadcrumb never leaks to the next route.
|
||||
*
|
||||
* Accepts a static array OR a reactive ref / computed so pages with
|
||||
* async data (e.g. doc workspace fetching the doc title) can rebuild
|
||||
* crumbs as the data lands.
|
||||
*/
|
||||
export function useCrumbs(source: Crumb[] | Ref<Crumb[]> | ComputedRef<Crumb[]>) {
|
||||
const store = useBreadcrumbStore()
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
store.setCrumbs(source)
|
||||
} else {
|
||||
watch(
|
||||
source,
|
||||
(next) => {
|
||||
store.setCrumbs(next)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
store.clear()
|
||||
})
|
||||
}
|
||||
27
frontend/src/shared/breadcrumb/text.test.ts
Normal file
27
frontend/src/shared/breadcrumb/text.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { truncate } from './text'
|
||||
|
||||
describe('truncate', () => {
|
||||
it('returns the input unchanged when shorter than the limit', () => {
|
||||
expect(truncate('short', 10)).toBe('short')
|
||||
expect(truncate('exactly10!', 10)).toBe('exactly10!')
|
||||
})
|
||||
|
||||
it('truncates and adds an ellipsis when longer than the limit', () => {
|
||||
expect(truncate('this is a long title', 10)).toBe('this is a…')
|
||||
})
|
||||
|
||||
it('trims trailing whitespace before the ellipsis', () => {
|
||||
expect(truncate('foo bar baz qux', 8)).toBe('foo bar…')
|
||||
})
|
||||
|
||||
it('returns the input untouched for non-positive limits', () => {
|
||||
expect(truncate('hello', 0)).toBe('hello')
|
||||
expect(truncate('hello', -5)).toBe('hello')
|
||||
})
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(truncate('', 10)).toBe('')
|
||||
})
|
||||
})
|
||||
14
frontend/src/shared/breadcrumb/text.ts
Normal file
14
frontend/src/shared/breadcrumb/text.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Truncate `text` to at most `max` characters, replacing the tail with
|
||||
* an ellipsis when shortened. Returns the original string if it
|
||||
* already fits.
|
||||
*
|
||||
* Used by the breadcrumb to keep the topbar tidy when document
|
||||
* filenames are long. The full title stays available via the `title`
|
||||
* attribute on the segment so the user can hover to read it whole.
|
||||
*/
|
||||
export function truncate(text: string, max: number): string {
|
||||
if (max <= 0 || text.length <= max) return text
|
||||
// Reserve one character for the ellipsis so the visible length is `max`.
|
||||
return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…'
|
||||
}
|
||||
13
frontend/src/shared/breadcrumb/types.ts
Normal file
13
frontend/src/shared/breadcrumb/types.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
/**
|
||||
* One segment in a breadcrumb.
|
||||
*
|
||||
* - `LinkCrumb` renders as a `<RouterLink>` to `to`.
|
||||
* - `LeafCrumb` renders as a non-clickable label with `aria-current="page"`.
|
||||
* The leaf is always the last segment; the rendering component refuses
|
||||
* to render a leaf in a non-final position.
|
||||
*/
|
||||
export type LinkCrumb = { kind: 'link'; label: string; to: RouteLocationRaw }
|
||||
export type LeafCrumb = { kind: 'leaf'; label: string }
|
||||
export type Crumb = LinkCrumb | LeafCrumb
|
||||
|
|
@ -19,6 +19,13 @@ const messages: Messages = {
|
|||
// Top bar
|
||||
'topbar.newAnalysis': 'Nouvelle analyse',
|
||||
|
||||
// Breadcrumb (0.6.0 doc workspace — #208)
|
||||
'breadcrumb.aria': "Fil d'Ariane",
|
||||
'breadcrumb.studio': 'Studio',
|
||||
'breadcrumb.mode.ask': 'Ask',
|
||||
'breadcrumb.mode.inspect': 'Inspect',
|
||||
'breadcrumb.mode.chunks': 'Chunks',
|
||||
|
||||
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
||||
'comingSoon.title': 'Bientôt disponible',
|
||||
'comingSoon.subtitle.docsLibrary':
|
||||
|
|
@ -307,6 +314,13 @@ const messages: Messages = {
|
|||
|
||||
'topbar.newAnalysis': 'New analysis',
|
||||
|
||||
// Breadcrumb (0.6.0 doc workspace — #208)
|
||||
'breadcrumb.aria': 'Breadcrumb',
|
||||
'breadcrumb.studio': 'Studio',
|
||||
'breadcrumb.mode.ask': 'Ask',
|
||||
'breadcrumb.mode.inspect': 'Inspect',
|
||||
'breadcrumb.mode.chunks': 'Chunks',
|
||||
|
||||
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
||||
'comingSoon.title': 'Coming soon',
|
||||
'comingSoon.subtitle.docsLibrary':
|
||||
|
|
|
|||
Loading…
Reference in a new issue