@quazardous/qdadm 2.7.3 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  **Vue 3 admin framework. PrimeVue. Zero boilerplate.**
4
4
 
5
- Quick start: [../../README.md](../../README.md) (5 min tutorial)
5
+ Quick start: [README](https://github.com/quazardous/qdadm/blob/main/README.md) (5 min tutorial)
6
6
 
7
- Concepts & patterns: [QDADM_CREDO.md](QDADM_CREDO.md)
7
+ Concepts & patterns: [QDADM_CREDO](https://github.com/quazardous/qdadm/blob/main/packages/qdadm/QDADM_CREDO.md)
8
8
 
9
- Changelog: [../../CHANGELOG.md](../../CHANGELOG.md)
9
+ Changelog: [CHANGELOG](https://github.com/quazardous/qdadm/blob/main/CHANGELOG.md)
10
10
 
11
11
  ## Installation
12
12
 
@@ -95,20 +95,20 @@ Options: `transformRequest`, `transformResponse`, `responseFormat`, `clientSideP
95
95
 
96
96
  | Doc | Purpose |
97
97
  |-----|---------|
98
- | [QDADM_CREDO](QDADM_CREDO.md) | Philosophy, patterns, concepts |
99
- | [Page compositions](../../docs/page-compositions.md) | **Start here** — which composition for which need |
100
- | [CRUD Pages](../../docs/crud.md) | List / form / show / child pages reference |
101
- | [Navigation](../../docs/navigation.md) | Breadcrumb, sibling navlinks, View↔Edit toggle |
102
- | [Architecture](../../docs/architecture.md) | PAC pattern, layers |
103
- | [Extension](../../docs/extension.md) | Decorators, bundles, multi-storage |
104
- | [Hooks](../../docs/hooks.md) | Alter / invoke registry |
105
- | [Signals](../../docs/signals.md) | Event bus, SSE bridge |
106
- | [Zones](../../docs/zones.md) | Extensible UI blocks |
107
- | [Security](../../docs/security.md) | Permissions, roles |
108
- | [i18n](../../docs/i18n.md) | Messages, providers, strategies |
109
- | [Codegen](../../docs/gen.md) | Generate managers (OpenAPI / manual) |
110
- | [Deferred](../../docs/deferred.md) | Deferred values & warmup |
111
- | [AGENT_GUIDE](../../docs/AGENT_GUIDE.md) | Quick index for AI agents |
98
+ | [QDADM_CREDO](https://github.com/quazardous/qdadm/blob/main/packages/qdadm/QDADM_CREDO.md) | Philosophy, patterns, concepts |
99
+ | [Page compositions](https://github.com/quazardous/qdadm/blob/main/docs/page-compositions.md) | **Start here** — which composition for which need |
100
+ | [CRUD Pages](https://github.com/quazardous/qdadm/blob/main/docs/crud.md) | List / form / show / child pages reference |
101
+ | [Navigation](https://github.com/quazardous/qdadm/blob/main/docs/navigation.md) | Breadcrumb, sibling navlinks, View↔Edit toggle |
102
+ | [Architecture](https://github.com/quazardous/qdadm/blob/main/docs/architecture.md) | PAC pattern, layers |
103
+ | [Extension](https://github.com/quazardous/qdadm/blob/main/docs/extension.md) | Decorators, bundles, multi-storage |
104
+ | [Hooks](https://github.com/quazardous/qdadm/blob/main/docs/hooks.md) | Alter / invoke registry |
105
+ | [Signals](https://github.com/quazardous/qdadm/blob/main/docs/signals.md) | Event bus, SSE bridge |
106
+ | [Zones](https://github.com/quazardous/qdadm/blob/main/docs/zones.md) | Extensible UI blocks |
107
+ | [Security](https://github.com/quazardous/qdadm/blob/main/docs/security.md) | Permissions, roles |
108
+ | [i18n](https://github.com/quazardous/qdadm/blob/main/docs/i18n.md) | Messages, providers, strategies |
109
+ | [Codegen](https://github.com/quazardous/qdadm/blob/main/docs/gen.md) | Generate managers (OpenAPI / manual) |
110
+ | [Deferred](https://github.com/quazardous/qdadm/blob/main/docs/deferred.md) | Deferred values & warmup |
111
+ | [AGENT_GUIDE](https://github.com/quazardous/qdadm/blob/main/docs/AGENT_GUIDE.md) | Quick index for AI agents |
112
112
 
113
113
  ## Peer Dependencies
114
114
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.7.3",
3
+ "version": "2.9.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/quazardous/qdadm.git"
9
+ "url": "git+https://github.com/quazardous/qdadm.git",
10
+ "directory": "packages/qdadm"
10
11
  },
11
12
  "homepage": "https://github.com/quazardous/qdadm#readme",
12
13
  "bugs": {
@@ -11,6 +11,7 @@ export { default as Breadcrumb } from './layout/Breadcrumb.vue'
11
11
  export { default as PageNav } from './layout/PageNav.vue'
12
12
  export { default as Zone } from './layout/Zone.vue'
13
13
  export { default as SidebarBox } from './layout/SidebarBox.vue'
14
+ export { default as NavlinksGroup } from './layout/NavlinksGroup.vue'
14
15
 
15
16
  // Default zone components
16
17
  export { default as DefaultHeader } from './layout/defaults/DefaultHeader.vue'
@@ -25,6 +25,7 @@ import Breadcrumb from 'primevue/breadcrumb'
25
25
  import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
26
26
  import SidebarBox from './SidebarBox.vue'
27
27
  import Zone from './Zone.vue'
28
+ import NavlinksGroup from './NavlinksGroup.vue'
28
29
  import qdadmLogo from '../../assets/logo.svg'
29
30
  import { version as qdadmVersion } from '../../../package.json'
30
31
 
@@ -241,7 +242,7 @@ watch(() => route.fullPath, () => {
241
242
 
242
243
  // Navigation context (breadcrumb + navlinks from route config)
243
244
  // Entity data comes from activeStack (populated by useEntityItemPage/useEntityItemFormPage)
244
- const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks, modeToggle } = useNavContext()
245
+ const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks, modeLinks } = useNavContext()
245
246
 
246
247
  // Allow child pages to override breadcrumb/navlinks via provide/inject
247
248
  const breadcrumbOverride = ref<BreadcrumbItem[] | null>(null)
@@ -261,11 +262,6 @@ const showBreadcrumb = computed<boolean>(() => {
261
262
  return true
262
263
  })
263
264
 
264
- // View↔Edit toggle on the terminal crumb (#1332/#1341) — same opt-in contract
265
- // as DefaultBreadcrumb; this inline breadcrumb must honor the flag too
266
- const shownModeToggle = computed(() =>
267
- features.breadcrumbModeToggle === true ? modeToggle.value : null
268
- )
269
265
  </script>
270
266
 
271
267
  <template>
@@ -391,29 +387,8 @@ const shownModeToggle = computed(() =>
391
387
  </Breadcrumb>
392
388
 
393
389
  <!-- Navlinks (provided by PageNav for child routes) + View↔Edit mode
394
- toggle (#1332/#1341) — plain navigation; the form page's own
395
- route-leave guard covers unsaved changes on edit→show -->
396
- <div v-if="navlinks.length > 0 || shownModeToggle" class="layout-navlinks">
397
- <template v-for="(link, index) in navlinks" :key="link.to?.name || index">
398
- <span v-if="index > 0" class="layout-navlinks-separator">|</span>
399
- <RouterLink
400
- :to="link.to"
401
- class="layout-navlink"
402
- :class="{ 'layout-navlink--active': link.active }"
403
- >
404
- {{ link.label }}
405
- </RouterLink>
406
- </template>
407
- <template v-if="shownModeToggle">
408
- <span v-if="navlinks.length > 0" class="layout-navlinks-separator">|</span>
409
- <RouterLink
410
- :to="shownModeToggle.to"
411
- class="layout-navlink breadcrumb-mode-toggle"
412
- >
413
- {{ shownModeToggle.label }}
414
- </RouterLink>
415
- </template>
416
- </div>
390
+ links (#1332/#1341/#1353) — shared rendering + dedup (#1357) -->
391
+ <NavlinksGroup :navlinks="navlinks" :mode-links="modeLinks" variant="layout" />
417
392
  </div>
418
393
 
419
394
  <div class="page-content">
@@ -721,33 +696,7 @@ const shownModeToggle = computed(() =>
721
696
  padding-bottom: 0;
722
697
  }
723
698
 
724
- .layout-navlinks {
725
- display: flex;
726
- align-items: center;
727
- gap: 0.375rem;
728
- font-size: var(--fad-font-size-sm);
729
- }
730
-
731
- .layout-navlinks-separator {
732
- color: var(--p-surface-400, #94a3b8);
733
- opacity: 0.5;
734
- }
735
-
736
- .layout-navlink {
737
- color: var(--p-primary-500, #3b82f6);
738
- text-decoration: none;
739
- transition: color var(--fad-transition-fast);
740
- }
741
-
742
- .layout-navlink:hover {
743
- color: var(--p-primary-600, #2563eb);
744
- }
745
-
746
- .layout-navlink--active {
747
- color: var(--p-surface-600, #475569);
748
- font-weight: var(--fad-font-weight-medium);
749
- pointer-events: none;
750
- }
699
+ /* Navlinks styles moved to NavlinksGroup.vue (#1357) */
751
700
 
752
701
  /* Override PrimeVue Breadcrumb styles for flat look */
753
702
  .layout-nav-bar :deep(.p-breadcrumb) {
@@ -0,0 +1,153 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NavlinksGroup - the right-side navigation group of a breadcrumb bar (#1357)
4
+ *
5
+ * Single rendering of the contract previously copy-pasted between AppLayout's
6
+ * inline breadcrumb and DefaultBreadcrumb: View↔Edit mode links LEAD the
7
+ * group (#1332/#1341/#1353, order #j7udkh), sibling navlinks follow, pipe
8
+ * separators between every pair, plain text (no icons).
9
+ *
10
+ * Owns the two behaviors both call sites need:
11
+ * - the `qdadmFeatures.breadcrumbModeToggle` opt-in gate on mode links
12
+ * - dedup (#1357): a navlink whose target route is already covered by a
13
+ * shown mode link is dropped — e.g. the auto "Details" link resolves to
14
+ * the parent's edit route and would render twice ("Details" + "Edit").
15
+ * With the feature off, no mode links are shown and every navlink stays
16
+ * (back-compat).
17
+ *
18
+ * `variant` keeps the historical CSS hooks of each call site so consumer
19
+ * overrides keep working: 'layout' = AppLayout classes, 'breadcrumb' =
20
+ * DefaultBreadcrumb classes.
21
+ */
22
+ import { computed, inject } from 'vue'
23
+ import { RouterLink } from 'vue-router'
24
+ import type { NavLinkItem, BreadcrumbModeToggle } from '../../composables/useNavContext'
25
+
26
+ interface FeaturesConfig {
27
+ breadcrumbModeToggle?: boolean
28
+ [key: string]: unknown
29
+ }
30
+
31
+ const props = withDefaults(
32
+ defineProps<{
33
+ navlinks?: NavLinkItem[]
34
+ modeLinks?: BreadcrumbModeToggle[]
35
+ variant?: 'layout' | 'breadcrumb'
36
+ }>(),
37
+ {
38
+ navlinks: () => [],
39
+ modeLinks: () => [],
40
+ variant: 'breadcrumb',
41
+ }
42
+ )
43
+
44
+ const features = inject<FeaturesConfig>('qdadmFeatures', {})
45
+
46
+ // Opt-in (#1332): mode links render only when the feature flag is on
47
+ const shownModeLinks = computed<BreadcrumbModeToggle[]>(() =>
48
+ features.breadcrumbModeToggle === true ? props.modeLinks : []
49
+ )
50
+
51
+ // Dedup (#1357): drop navlinks targeting a route a mode link already covers
52
+ const shownNavlinks = computed<NavLinkItem[]>(() => {
53
+ if (shownModeLinks.value.length === 0) return props.navlinks
54
+ const covered = new Set(shownModeLinks.value.map((m) => m.to.name))
55
+ return props.navlinks.filter((link) => !link.to?.name || !covered.has(link.to.name))
56
+ })
57
+
58
+ const cls = computed(() =>
59
+ props.variant === 'layout'
60
+ ? {
61
+ container: 'layout-navlinks',
62
+ separator: 'layout-navlinks-separator',
63
+ link: 'layout-navlink',
64
+ active: 'layout-navlink--active',
65
+ }
66
+ : {
67
+ container: 'breadcrumb-navlinks',
68
+ separator: 'navlinks-separator',
69
+ link: 'navlink',
70
+ active: 'navlink--active',
71
+ }
72
+ )
73
+ </script>
74
+
75
+ <template>
76
+ <!-- Plain navigation; the form page's own route-leave guard covers
77
+ unsaved changes on edit→show -->
78
+ <div v-if="shownNavlinks.length > 0 || shownModeLinks.length > 0" :class="cls.container">
79
+ <!-- mode links lead the group (#j7udkh), sibling tabs follow -->
80
+ <template v-for="(mode, index) in shownModeLinks" :key="`mode-${mode.target}`">
81
+ <span v-if="index > 0" :class="cls.separator">|</span>
82
+ <RouterLink :to="mode.to" class="breadcrumb-mode-toggle" :class="cls.link">
83
+ {{ mode.label }}
84
+ </RouterLink>
85
+ </template>
86
+ <template v-for="(link, index) in shownNavlinks" :key="link.to?.name || index">
87
+ <span v-if="shownModeLinks.length > 0 || index > 0" :class="cls.separator">|</span>
88
+ <RouterLink :to="link.to" :class="[cls.link, { [cls.active]: link.active }]">
89
+ {{ link.label }}
90
+ </RouterLink>
91
+ </template>
92
+ </div>
93
+ </template>
94
+
95
+ <style scoped>
96
+ /* Historical styles of each call site, keyed by variant container so the
97
+ look of both bars is preserved exactly (AppLayout was compact). */
98
+ .layout-navlinks {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 0.375rem;
102
+ font-size: var(--fad-font-size-sm);
103
+ }
104
+
105
+ .layout-navlinks-separator {
106
+ color: var(--p-surface-400, #94a3b8);
107
+ opacity: 0.5;
108
+ }
109
+
110
+ .layout-navlink {
111
+ color: var(--p-primary-500, #3b82f6);
112
+ text-decoration: none;
113
+ transition: color var(--fad-transition-fast);
114
+ }
115
+
116
+ .layout-navlink:hover {
117
+ color: var(--p-primary-600, #2563eb);
118
+ }
119
+
120
+ .layout-navlink--active {
121
+ color: var(--p-surface-600, #475569);
122
+ font-weight: var(--fad-font-weight-medium);
123
+ pointer-events: none;
124
+ }
125
+
126
+ .breadcrumb-navlinks {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 0.5rem;
130
+ font-size: 0.875rem;
131
+ }
132
+
133
+ .navlinks-separator {
134
+ color: var(--p-surface-400, #94a3b8);
135
+ }
136
+
137
+ .navlink {
138
+ color: var(--p-primary-500, #3b82f6);
139
+ text-decoration: none;
140
+ transition: color 0.15s;
141
+ }
142
+
143
+ .navlink:hover {
144
+ color: var(--p-primary-700, #1d4ed8);
145
+ text-decoration: underline;
146
+ }
147
+
148
+ .navlink--active {
149
+ color: var(--p-surface-700, #334155);
150
+ font-weight: 500;
151
+ pointer-events: none;
152
+ }
153
+ </style>
@@ -12,11 +12,10 @@ import { computed, inject, ref, type Ref } from 'vue'
12
12
  import { RouterLink, useRoute } from 'vue-router'
13
13
  import { useNavContext, type BreadcrumbItem, type NavLinkItem } from '../../../composables/useNavContext'
14
14
  import Breadcrumb from 'primevue/breadcrumb'
15
+ import NavlinksGroup from '../NavlinksGroup.vue'
15
16
 
16
17
  interface FeaturesConfig {
17
18
  breadcrumb?: boolean
18
- /** Opt-in (#1332): render a View↔Edit toggle next to the terminal crumb */
19
- breadcrumbModeToggle?: boolean
20
19
  [key: string]: unknown
21
20
  }
22
21
 
@@ -24,7 +23,7 @@ const route = useRoute()
24
23
  const features = inject<FeaturesConfig>('qdadmFeatures', { breadcrumb: true })
25
24
 
26
25
  // Navigation context (breadcrumb + navlinks from route config)
27
- const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks, modeToggle } = useNavContext()
26
+ const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks, modeLinks } = useNavContext()
28
27
 
29
28
  // Allow child pages to override breadcrumb/navlinks via provide/inject
30
29
  const breadcrumbOverride = inject<Ref<BreadcrumbItem[] | null>>('qdadmBreadcrumbOverride', ref(null))
@@ -42,11 +41,6 @@ const showBreadcrumb = computed<boolean>(() => {
42
41
  return true
43
42
  })
44
43
 
45
- // View↔Edit toggle on the terminal crumb (#1332) — opt-in, and only when
46
- // useNavContext resolved a reachable, permitted twin-mode route
47
- const shownModeToggle = computed(() =>
48
- features.breadcrumbModeToggle === true ? modeToggle.value : null
49
- )
50
44
  </script>
51
45
 
52
46
  <template>
@@ -65,29 +59,8 @@ const shownModeToggle = computed(() =>
65
59
  </Breadcrumb>
66
60
 
67
61
  <!-- Navlinks (provided by PageNav for child routes) + View↔Edit mode
68
- toggle (#1332/#1341) — plain navigation; the form page's own
69
- route-leave guard covers unsaved changes on edit→show -->
70
- <div v-if="navlinks.length > 0 || shownModeToggle" class="breadcrumb-navlinks">
71
- <template v-for="(link, index) in navlinks" :key="link.to?.name || index">
72
- <span v-if="index > 0" class="navlinks-separator">|</span>
73
- <RouterLink
74
- :to="link.to"
75
- class="navlink"
76
- :class="{ 'navlink--active': link.active }"
77
- >
78
- {{ link.label }}
79
- </RouterLink>
80
- </template>
81
- <template v-if="shownModeToggle">
82
- <span v-if="navlinks.length > 0" class="navlinks-separator">|</span>
83
- <RouterLink
84
- :to="shownModeToggle.to"
85
- class="navlink breadcrumb-mode-toggle"
86
- >
87
- {{ shownModeToggle.label }}
88
- </RouterLink>
89
- </template>
90
- </div>
62
+ links (#1332/#1341/#1353) — shared rendering + dedup (#1357) -->
63
+ <NavlinksGroup :navlinks="navlinks" :mode-links="modeLinks" variant="breadcrumb" />
91
64
  </div>
92
65
  </template>
93
66
 
@@ -104,33 +77,7 @@ const shownModeToggle = computed(() =>
104
77
  padding-bottom: 0;
105
78
  }
106
79
 
107
- .breadcrumb-navlinks {
108
- display: flex;
109
- align-items: center;
110
- gap: 0.5rem;
111
- font-size: 0.875rem;
112
- }
113
-
114
- .navlinks-separator {
115
- color: var(--p-surface-400, #94a3b8);
116
- }
117
-
118
- .navlink {
119
- color: var(--p-primary-500, #3b82f6);
120
- text-decoration: none;
121
- transition: color 0.15s;
122
- }
123
-
124
- .navlink:hover {
125
- color: var(--p-primary-700, #1d4ed8);
126
- text-decoration: underline;
127
- }
128
-
129
- .navlink--active {
130
- color: var(--p-surface-700, #334155);
131
- font-weight: 500;
132
- pointer-events: none;
133
- }
80
+ /* Navlinks styles moved to NavlinksGroup.vue (#1357) */
134
81
 
135
82
  /* Override PrimeVue Breadcrumb styles for flat look */
136
83
  .default-breadcrumb :deep(.p-breadcrumb) {
@@ -76,9 +76,10 @@ export interface BreadcrumbItem {
76
76
  * permissions are honored once the item is loaded.
77
77
  */
78
78
  export interface BreadcrumbModeToggle {
79
- /** Mode of the current route */
80
- current: 'show' | 'edit'
81
- /** Mode the toggle navigates to */
79
+ /** Mode of the current route — absent on child pages (#1353), where the
80
+ * page is in neither mode of the parent item */
81
+ current?: 'show' | 'edit'
82
+ /** Mode the link navigates to */
82
83
  target: 'show' | 'edit'
83
84
  /** Twin-route location — a plain router.push; any dirty-form protection
84
85
  * comes from the form page's own onBeforeRouteLeave guard */
@@ -130,6 +131,7 @@ export interface UseNavContextReturn {
130
131
  breadcrumb: ComputedRef<BreadcrumbItem[]>
131
132
  navlinks: ComputedRef<NavLinkItem[]>
132
133
  modeToggle: ComputedRef<BreadcrumbModeToggle | null>
134
+ modeLinks: ComputedRef<BreadcrumbModeToggle[]>
133
135
 
134
136
  // Helpers
135
137
  parentConfig: ComputedRef<ParentConfig | undefined>
@@ -375,6 +377,8 @@ export function useNavContext(_options: UseNavContextOptions = {}): UseNavContex
375
377
  })
376
378
 
377
379
  const navlinks = computed(() => {
380
+ void i18nLocale.value // "Details" label re-resolves on locale change
381
+
378
382
  if (!parentConfig.value) return []
379
383
 
380
384
  const { entity: parentEntity, param, itemRoute } = parentConfig.value
@@ -389,7 +393,7 @@ export function useNavContext(_options: UseNavContextOptions = {}): UseNavContex
389
393
  // Details link - use manager's idField for param name
390
394
  const links: NavLinkItem[] = [
391
395
  {
392
- label: 'Details',
396
+ label: breadcrumbLabel('details', 'Details'),
393
397
  to: {
394
398
  name: parentRouteName!,
395
399
  params: { [parentManager.idField!]: parentId.value as string },
@@ -443,15 +447,42 @@ export function useNavContext(_options: UseNavContextOptions = {}): UseNavContex
443
447
  })
444
448
 
445
449
  // ============================================================================
446
- // MODE TOGGLE (#1332)
450
+ // MODE TOGGLE / MODE LINKS (#1332, #1353)
447
451
  // ============================================================================
448
452
 
449
- function toggleLabel(target: 'show' | 'edit'): string {
450
- const trace = kernelI18n?.resolve(target === 'edit' ? 'breadcrumb.edit' : 'breadcrumb.view')
453
+ /** Resolve a `breadcrumb.*` label from the i18n catalog, with fallback. */
454
+ function breadcrumbLabel(key: string, fallback: string): string {
455
+ const trace = kernelI18n?.resolve(`breadcrumb.${key}`)
451
456
  if (trace?.hit) return trace.result
452
- return target === 'edit' ? 'Edit' : 'View'
457
+ return fallback
458
+ }
459
+
460
+ function toggleLabel(target: 'show' | 'edit'): string {
461
+ return target === 'edit'
462
+ ? breadcrumbLabel('edit', 'Edit')
463
+ : breadcrumbLabel('view', 'View')
453
464
  }
454
465
 
466
+ /** Build one mode link if its route exists and (for edit) permission allows. */
467
+ function buildModeLink(
468
+ manager: EntityManager,
469
+ target: 'show' | 'edit',
470
+ id: unknown,
471
+ permissionEntity: unknown
472
+ ): BreadcrumbModeToggle | null {
473
+ const routeName = `${manager.routePrefix}-${target}`
474
+ if (!router.hasRoute(routeName)) return null
475
+ if (target === 'edit' && !manager.canUpdate(permissionEntity ?? undefined)) return null
476
+ return {
477
+ target,
478
+ to: { name: routeName, params: { [manager.idField || 'id']: id as string } },
479
+ label: toggleLabel(target),
480
+ }
481
+ }
482
+
483
+ // Item pages (#1332): the terminal is entity-show/entity-edit — offer the
484
+ // OPPOSITE mode (getDefaultItemRoute's edit-preference is a landing
485
+ // default, not a toggle).
455
486
  const modeToggle = computed<BreadcrumbModeToggle | null>(() => {
456
487
  void i18nLocale.value // label re-resolves on locale change
457
488
 
@@ -464,20 +495,29 @@ export function useNavContext(_options: UseNavContextOptions = {}): UseNavContex
464
495
  if (!manager?.routePrefix) return null
465
496
 
466
497
  const current = terminal.kind === 'entity-edit' ? 'edit' : 'show'
467
- // Target is the OPPOSITE of the current mode — getDefaultItemRoute's
468
- // edit-preference is a landing default, not a toggle.
469
498
  const target = current === 'edit' ? 'show' : 'edit'
470
- const twinRoute = `${manager.routePrefix}-${target}`
471
- if (!router.hasRoute(twinRoute)) return null
472
-
473
- if (target === 'edit' && !manager.canUpdate(entityData.value ?? undefined)) return null
499
+ const link = buildModeLink(manager, target, terminal.id, entityData.value)
500
+ return link ? { ...link, current } : null
501
+ })
474
502
 
475
- return {
476
- current,
477
- target,
478
- to: { name: twinRoute, params: { [manager.idField || 'id']: terminal.id } },
479
- label: toggleLabel(target),
480
- }
503
+ // Uniform list for rendering (#1353). Item pages: the single opposite-mode
504
+ // entry (modeToggle). Child pages (route carries meta.parent): the PARENT
505
+ // item's applicable pair — the page is in neither mode, so both View and
506
+ // Edit are offered, each under the same existence/permission rules. On a
507
+ // child page, `entityData` resolves to the parent item (last item segment
508
+ // of the chain), so the canUpdate gate is ownership-aware once hydrated.
509
+ const modeLinks = computed<BreadcrumbModeToggle[]>(() => {
510
+ const itemToggle = modeToggle.value
511
+ if (itemToggle) return [itemToggle]
512
+
513
+ void i18nLocale.value
514
+ if (!parentConfig.value || parentId.value == null) return []
515
+ const manager = getManager(parentConfig.value.entity)
516
+ if (!manager?.routePrefix) return []
517
+
518
+ return (['show', 'edit'] as const)
519
+ .map((target) => buildModeLink(manager, target, parentId.value, entityData.value))
520
+ .filter((link): link is BreadcrumbModeToggle => link !== null)
481
521
  })
482
522
 
483
523
  return {
@@ -494,6 +534,7 @@ export function useNavContext(_options: UseNavContextOptions = {}): UseNavContex
494
534
  breadcrumb,
495
535
  navlinks,
496
536
  modeToggle,
537
+ modeLinks,
497
538
 
498
539
  // Helpers
499
540
  parentConfig,
@@ -89,10 +89,15 @@ export function computeSemanticBreadcrumb(
89
89
  items.push({ kind: actionKind, entity: lastEntity, id: pendingId })
90
90
  pendingId = null
91
91
  } else {
92
- // No pending ID - update last item's kind (e.g., /books/create)
92
+ // No pending ID and the last item is an id-less entity level
93
+ // (e.g. /books/create): append the action as its own item so the
94
+ // entity-list crumb survives — mutating the list item into
95
+ // entity-create ate the "Books" level (#1356). When the last item
96
+ // already carries an id (/books/1/edit) the action was consumed
97
+ // at the id segment; swallow it as before.
93
98
  const lastItem = items[items.length - 1]
94
99
  if (lastItem && lastItem.kind.startsWith('entity-') && !lastItem.id) {
95
- lastItem.kind = actionKind
100
+ items.push({ kind: actionKind, entity: lastEntity })
96
101
  }
97
102
  }
98
103
  continue