@vuecs/navigation
Version:
A package for multi level navigations.
1 lines • 101 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","names":["inject","inject","inject"],"sources":["../src/registry/module.ts","../src/registry/singleton.ts","../src/helpers/match.ts","../src/helpers/normalize.ts","../src/helpers/trace.ts","../src/helpers/reset.ts","../src/helpers/submenu.ts","../src/helpers/trail.ts","../src/helpers/url.ts","../src/components/items/theme.ts","../src/components/select-context.ts","../src/components/item/module.ts","../src/components/items/module.ts","../src/components/stepper/context.ts","../src/components/stepper/theme.ts","../src/components/stepper/Stepper.vue","../src/components/stepper/Stepper.vue","../src/components/stepper/StepperItem.vue","../src/components/stepper/StepperItem.vue","../src/components/stepper/StepperTrigger.vue","../src/components/stepper/StepperTrigger.vue","../src/components/stepper/StepperIndicator.vue","../src/components/stepper/StepperIndicator.vue","../src/components/stepper/StepperTitle.vue","../src/components/stepper/StepperTitle.vue","../src/components/stepper/StepperDescription.vue","../src/components/stepper/StepperDescription.vue","../src/components/stepper/StepperSeparator.vue","../src/components/stepper/StepperSeparator.vue","../src/index.ts"],"sourcesContent":["/*\n * Copyright (c) 2024-2024.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nimport { computed, ref, shallowReactive } from 'vue';\nimport type { NavigationItemNormalized } from '../types';\nimport type { NavigationRegistryEntry } from './types';\n\ntype Occupant = {\n token: symbol;\n entry: NavigationRegistryEntry;\n};\n\nfunction createEmptyEntry(): NavigationRegistryEntry {\n const items = ref<NavigationItemNormalized[]>([]);\n return {\n items,\n active: computed(() => []),\n activeTrail: computed(() => []),\n };\n}\n\ntype NavigationRegistryUnregisterFn = () => void;\n\n/**\n * Reactive, app-wide navigation registry. `<VCNavItems registry>`\n * publishes its resolved output here under a `registry-id`; other navs\n * read it reactively + empty-safe via the resolver context's\n * `registry(id)`.\n *\n * The backing map is `shallowReactive`, so membership changes\n * (register + the returned unregister closure) are tracked dependencies\n * — a consumer reading `get(id)` inside a `computed` / `watchEffect`\n * re-runs when the id's occupancy flips.\n */\nexport class NavigationRegistry {\n protected map = shallowReactive(\n new Map<string, Occupant>(),\n );\n\n /**\n * Stable empty entries handed out for absent ids, memoized per id so\n * the SAME reactive handle is returned every call — a consumer\n * subscribed to an absent id keeps its dependency and lights up the\n * moment an occupant registers.\n */\n protected empties = new Map<string, NavigationRegistryEntry>();\n\n /**\n * Claim `id`. Last-wins: a newer occupant replaces the current one\n * (dev warning on collision). Returns a token-guarded unregister\n * closure: it releases `id` ONLY if this registration is still the\n * occupant. During a route handoff (Vue mounts the new page before\n * unmounting the old) the departing nav's closure holds a stale token\n * and cannot evict the incoming occupant.\n */\n register(id: string, entry: NavigationRegistryEntry): NavigationRegistryUnregisterFn {\n if (this.map.has(id)) {\n // eslint-disable-next-line no-console\n console.warn(`[vuecs] navigation registry id \"${id}\" reassigned to a new occupant.`);\n }\n\n const token = Symbol('vc-nav-registry-token');\n\n this.map.set(id, { token, entry });\n\n return () => {\n const occupant = this.map.get(id);\n if (occupant && occupant.token === token) {\n this.map.delete(id);\n }\n };\n }\n\n /** Reactive, empty-safe read. Never returns `undefined`. */\n get<META = any>(id: string): NavigationRegistryEntry<META> {\n const occupant = this.map.get(id);\n if (occupant) {\n return occupant.entry as NavigationRegistryEntry<META>;\n }\n\n let empty = this.empties.get(id);\n if (!empty) {\n empty = createEmptyEntry();\n this.empties.set(id, empty);\n }\n\n return empty as NavigationRegistryEntry<META>;\n }\n\n /** True when an occupant currently holds `id`. */\n has(id: string): boolean {\n return this.map.has(id);\n }\n}\n","/*\n * Copyright (c) 2024-2024.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nimport { inject, provide } from '@vuecs/core';\nimport type { App } from 'vue';\nimport { NavigationRegistry } from './module';\n\nconst sym = Symbol.for('VCNavigationRegistry');\n\nexport function tryInjectNavigationRegistry(app?: App): NavigationRegistry | undefined {\n return inject<NavigationRegistry>(sym, app);\n}\n\nexport function injectNavigationRegistry(app?: App): NavigationRegistry {\n const instance = tryInjectNavigationRegistry(app);\n if (!instance) {\n throw new Error('A navigation registry has not been provided.');\n }\n\n return instance;\n}\n\nexport function provideNavigationRegistry(\n registry: NavigationRegistry = new NavigationRegistry(),\n app?: App,\n): NavigationRegistry {\n provide(sym, registry, app);\n return registry;\n}\n","import type { NavigationItemNormalized } from '../types';\n\ntype ParentMatch = {\n score: number\n};\n\ntype ItemMatchesFindOptions = {\n path?: string\n};\n\nfunction calculateItemScoreForPath(\n item: NavigationItemNormalized,\n currentPath: string,\n) {\n if (item.url === '/') {\n return 1;\n }\n\n if (item.activeMatch) {\n if (item.activeMatch === currentPath) {\n return 6;\n } if (currentPath.startsWith(item.activeMatch)) {\n return 4;\n }\n }\n\n if (item.url) {\n if (item.url === currentPath) {\n return 3;\n } if (currentPath.startsWith(item.url)) {\n return 2;\n }\n }\n\n return 0;\n}\n\nfunction findItemMatchesIF(\n items: NavigationItemNormalized[],\n options: ItemMatchesFindOptions,\n parent: ParentMatch,\n) {\n const output : {\n data: NavigationItemNormalized,\n score: number\n }[] = [];\n\n for (const item of items) {\n let { score } = parent;\n\n if (options.path) {\n score += calculateItemScoreForPath(item, options.path);\n }\n\n if (item.default) {\n score += 1;\n }\n\n if (item.children) {\n const childMatches = findItemMatchesIF(item.children, options, { score });\n\n output.push(...childMatches);\n }\n\n output.push({ data: item, score });\n }\n\n return output.sort((a, b) => b.score - a.score);\n}\n\nexport function findBestItemMatches(\n items: NavigationItemNormalized[],\n options: ItemMatchesFindOptions = {},\n) : NavigationItemNormalized[] {\n const result = findItemMatchesIF(items, options, { score: 0 });\n const [first] = result;\n if (!first) {\n return [];\n }\n\n return result\n .filter((match) => match.score === first.score)\n .map((match) => match.data);\n}\n","/*\n * Copyright (c) 2024.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nimport type { NavigationItem, NavigationItemNormalized } from '../types';\n\nfunction normalizeItemIF(\n item: NavigationItem,\n trace: string[],\n) : NavigationItemNormalized {\n const output : NavigationItemNormalized = {\n ...item,\n children: [],\n trace: [\n ...trace,\n item.name,\n ],\n meta: item.meta || {},\n };\n\n if (!item.children) {\n return output;\n }\n\n for (let i = 0; i < item.children.length; i++) {\n output.children.push(normalizeItemIF(item.children[i], output.trace));\n }\n\n return output;\n}\n\nexport function normalizeItem(\n item: NavigationItem,\n) : NavigationItemNormalized {\n return normalizeItemIF(item, []);\n}\n\nexport function normalizeItems(\n items: NavigationItem[],\n) : NavigationItemNormalized[] {\n return items.map((item) => normalizeItem(item));\n}\n","/*\n * Copyright (c) 2024.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nexport function isTraceEqual(\n a: string[],\n b: string[],\n): boolean {\n if (a.length !== b.length) {\n return false;\n }\n\n for (const [i, element] of a.entries()) {\n if (element !== b[i]) {\n return false;\n }\n }\n\n return true;\n}\n\nexport function isTracePartOf(item: string[], parent: string[]) {\n for (const [i, element] of item.entries()) {\n if (parent[i] !== element) {\n return false;\n }\n }\n\n return true;\n}\n","import type { NavigationItemNormalized } from '../types';\nimport { isTraceEqual, isTracePartOf } from './trace';\n\nfunction resetItemsByTraceIF(\n items: NavigationItemNormalized[],\n trace: string[],\n) {\n for (const item of items) {\n const isEqual = isTraceEqual(item.trace, trace);\n item.active = isEqual;\n item.display = true;\n\n if (isEqual) {\n item.activeWithin = false;\n item.displayChildren = true;\n } else {\n const isAncestor = isTracePartOf(item.trace, trace);\n item.activeWithin = isAncestor;\n item.displayChildren = isAncestor;\n }\n\n item.children = resetItemsByTraceIF(item.children, trace);\n }\n\n return items;\n}\n\nexport function resetItemsByTrace(\n items: NavigationItemNormalized[],\n trace: string[],\n) {\n return resetItemsByTraceIF(items, trace);\n}\n","/*\n * Copyright (c) 2024-2024.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nimport type {\n NavigationOrientation,\n NavigationSubmenu,\n NavigationSubmenuMode,\n} from '../types';\n\n/**\n * Resolve the effective submenu presentation. An explicit `collapse` /\n * `dropdown` wins; `auto` derives from orientation — only an explicit\n * `horizontal` opts into the dropdown (NavigationMenu) path, everything\n * else collapses (Collapsible).\n */\nexport function resolveSubmenuMode(\n submenu: NavigationSubmenu | undefined,\n orientation: NavigationOrientation | undefined,\n): NavigationSubmenuMode {\n if (submenu === 'collapse' || submenu === 'dropdown') {\n return submenu;\n }\n\n return orientation === 'horizontal' ? 'dropdown' : 'collapse';\n}\n","/*\n * Copyright (c) 2024-2024.\n * Author Peter Placzek (tada5hi)\n * For the full copyright and license information,\n * view the LICENSE file that was distributed with this source code.\n */\n\nimport type { NavigationItemNormalized } from '../types';\n\n/**\n * Walk a normalized tree along `trace` (an ordered list of item names,\n * root → leaf) and collect the item at each depth. Returns the ordered\n * active trail: `[0]` is the top-level section, `.at(-1)` is the leaf.\n */\nexport function collectTrail(\n items: NavigationItemNormalized[],\n trace: string[],\n): NavigationItemNormalized[] {\n const output: NavigationItemNormalized[] = [];\n\n let level = items;\n for (const name of trace) {\n const found = level.find((item) => item.name === name);\n if (!found) {\n break;\n }\n\n output.push(found);\n level = found.children;\n }\n\n return output;\n}\n\n/**\n * Depth-first collect of every item in the tree matching `predicate`.\n */\nexport function flattenWhere(\n items: NavigationItemNormalized[],\n predicate: (item: NavigationItemNormalized) => boolean,\n): NavigationItemNormalized[] {\n const output: NavigationItemNormalized[] = [];\n\n for (const item of items) {\n if (predicate(item)) {\n output.push(item);\n }\n\n if (item.children.length > 0) {\n output.push(...flattenWhere(item.children, predicate));\n }\n }\n\n return output;\n}\n","export function isAbsoluteURL(str: string): boolean {\n return str.substring(0, 7) === 'http://' ||\n str.substring(0, 8) === 'https://';\n}\n","import type { ComponentThemeDefinition } from '@vuecs/core';\nimport type { NavigationThemeClasses } from '../../helpers/component/types';\n\n/**\n * Default classes for the `navigation` theme entry. Shared between\n * `<VCNavItems>` (the container) and `<VCNavItem>` (the per-row\n * component) — both call `useComponentTheme('navigation', …)` with\n * the same slot defaults, so the source of truth lives here.\n */\nexport const navigationThemeDefaults: ComponentThemeDefinition<NavigationThemeClasses> = {\n classes: {\n group: 'vc-nav-items',\n item: 'vc-nav-item',\n itemNested: 'vc-nav-item-nested',\n separator: 'vc-nav-separator',\n link: 'vc-nav-link',\n linkRoot: 'vc-nav-link-root',\n linkIcon: 'vc-nav-link-icon',\n linkText: 'vc-nav-link-text',\n trigger: 'vc-nav-trigger',\n content: 'vc-nav-content',\n viewport: 'vc-nav-viewport',\n },\n variants: {\n // `list` is the default vertical/stacked look; `pills` renders the\n // items as a joined pill group (the nav-pills style). Structural CSS\n // for these markers ships in the package `assets/index.css`; palette\n // colors stay the theme's responsibility.\n variant: {\n list: {},\n pills: {\n group: 'vc-nav-items--pills',\n item: 'vc-nav-item--pills',\n link: 'vc-nav-link--pills',\n },\n },\n orientation: {\n horizontal: {},\n vertical: { group: 'vc-nav-items--vertical' },\n },\n },\n defaultVariants: {\n variant: 'list',\n orientation: 'horizontal',\n },\n};\n","import type { ComputedRef, InjectionKey } from 'vue';\nimport type { NavigationItemNormalized } from '../types';\n\n/**\n * Channels a `<VCNavItem>`'s already-normalized + scored `children` down\n * to the nested `<VCNavItems>` that renders its submenu.\n *\n * The top-level nav scores the WHOLE tree once; nested lists must render\n * those results as-is rather than re-resolving / re-scoring a subtree\n * (which would clobber traces and lose whole-tree active context). The\n * nested `<VCNavItems>` reads this when it has no own `data` prop —\n * presence of the injected nodes is what marks it as a nested renderer\n * rather than a resolving root. Each `<VCNavItem>` re-provides its own\n * children, so the value is correctly scoped per nesting level.\n */\nexport const NAVIGATION_NODES_KEY: InjectionKey<ComputedRef<NavigationItemNormalized[]>> = Symbol('vc-navigation-nodes');\n\n/**\n * Bridges a clicked `<VCNavItem>` up to the owning root `<VCNavItems>`.\n *\n * A url-less item can't navigate, so a click instead \"selects\" it: the\n * root nav records the item's trace and folds it into its active-state\n * derivation, publishing it through the registry's `active` / `activeTrail`.\n * Dependent navs then react with zero app wiring — exactly as they would\n * for a route-driven active change.\n */\nexport type NavigationSelectContext = {\n /** Invoke to mark `item` as the selected (active) item of the root nav. */\n select: (item: NavigationItemNormalized) => void;\n};\n\nexport const NAVIGATION_SELECT_KEY: InjectionKey<NavigationSelectContext> = Symbol('vc-navigation-select');\n","import { hasNormalizedSlot, normalizeSlot, useComponentTheme } from '@vuecs/core';\nimport type { ThemeClassesOverride, UseComponentThemeProps, VariantValues } from '@vuecs/core';\nimport type { LinkProperties } from '@vuecs/link';\nimport { VCLink } from '@vuecs/link';\nimport type {\n Component,\n ExtractPublicPropTypes,\n PropType,\n SlotsType,\n VNodeChild,\n} from 'vue';\nimport {\n computed,\n defineComponent,\n h,\n inject,\n provide,\n ref,\n resolveComponent,\n toRef,\n watch,\n} from 'vue';\nimport {\n CollapsibleContent,\n CollapsibleRoot,\n CollapsibleTrigger,\n NavigationMenuContent,\n NavigationMenuItem,\n NavigationMenuLink,\n NavigationMenuTrigger,\n} from 'reka-ui';\nimport type {\n NavigationItemNormalized,\n NavigationOrientation,\n NavigationSubmenuMode,\n} from '../../types';\nimport type { NavigationThemeClasses } from '../../helpers/component/types';\nimport { isAbsoluteURL } from '../../helpers';\nimport { ElementType, SlotName } from '../../constants';\nimport type {\n NavItemLinkSlotProps,\n NavItemSeparatorSlotProps,\n NavItemSubItemsSlotProps,\n NavItemSubSlotProps,\n NavItemSubTitleSlotProps,\n} from '../type';\nimport { navigationThemeDefaults } from '../items/theme';\nimport { NAVIGATION_NODES_KEY, NAVIGATION_SELECT_KEY } from '../select-context';\n\nconst navItemProps = {\n data: {\n type: Object as PropType<NavigationItemNormalized>,\n required: true,\n },\n variant: {\n type: String,\n default: undefined,\n },\n orientation: {\n type: String as PropType<NavigationOrientation>,\n default: undefined,\n },\n /**\n * Resolved submenu presentation handed down by the parent\n * `<VCNavItems>`. `collapse` renders groups as an inline\n * Reka `Collapsible`; `dropdown` renders them as Reka\n * `NavigationMenu` flyouts.\n */\n submenu: {\n type: String as PropType<NavigationSubmenuMode>,\n default: 'collapse',\n },\n /**\n * The tag (or component) this item renders as — its own wrapper\n * (`<li>` by default). Receives `<VCNavItems>`' `itemAs`. Honored in\n * collapse mode only.\n */\n as: {\n type: [String, Object] as PropType<string | Component>,\n default: 'li',\n },\n /**\n * The list-container tag for this item's nested submenu\n * `<VCNavItems>` (`<ul>` by default). Receives `<VCNavItems>`' `as`.\n * Honored in collapse mode only.\n */\n itemsAs: {\n type: [String, Object] as PropType<string | Component>,\n default: 'ul',\n },\n themeClass: {\n type: Object as PropType<ThemeClassesOverride<NavigationThemeClasses>>,\n default: undefined,\n },\n themeVariant: {\n type: Object as PropType<VariantValues>,\n default: undefined,\n },\n};\n\nexport type NavItemProps = ExtractPublicPropTypes<typeof navItemProps>;\n\nexport const VCNavItem = defineComponent({\n name: 'VCNavItem',\n props: navItemProps,\n slots: Object as SlotsType<{\n separator: NavItemSeparatorSlotProps;\n link: NavItemLinkSlotProps;\n sub: NavItemSubSlotProps;\n 'sub-title': NavItemSubTitleSlotProps;\n 'sub-items': NavItemSubItemsSlotProps;\n }>,\n setup(props, { slots }) {\n const itemsNode = resolveComponent('VCNavItems');\n\n const themeProps: UseComponentThemeProps<NavigationThemeClasses> = {\n get themeClass() {\n return props.themeClass;\n },\n get themeVariant() {\n return {\n ...(props.themeVariant ?? {}),\n ...(props.variant !== undefined ? { variant: props.variant } : {}),\n };\n },\n };\n\n const theme = useComponentTheme('navigation', themeProps, navigationThemeDefaults);\n\n const data = toRef(props, 'data');\n const hasChildren = computed(() => data.value.children &&\n data.value.children.length > 0);\n\n // Channel this item's already-scored children down to the nested\n // `<VCNavItems>` that renders its submenu, so the child list renders\n // them as-is instead of re-resolving / re-scoring a subtree. Scoped\n // per item — each `<VCNavItem>` re-provides its own children.\n provide(NAVIGATION_NODES_KEY, computed(() => data.value.children));\n\n // Local expand state — seeded from the resolved `displayChildren`\n // (driven by active-trail matching upstream) and resynced whenever\n // that recomputes, so a path change auto-opens the active branch.\n const open = ref(!!data.value.displayChildren);\n watch(() => data.value.displayChildren, (value) => {\n open.value = !!value;\n });\n\n // Selection bubbles up to the owning root `<VCNavItems>`, which folds\n // this item's trace into its active state and republishes through the\n // registry. The primary use is url-less section switchers (a top-nav\n // tab that swaps a dependent sidebar without navigating). Items with\n // a real url also navigate; the route change then supersedes the\n // selection upstream.\n const selectContext = inject(NAVIGATION_SELECT_KEY, null);\n const select = () => {\n selectContext?.select(data.value);\n };\n\n const toggle = () => {\n open.value = !open.value;\n };\n\n // Iconify-style icon strings (e.g. `fa6-solid:home`, `lucide:plus`)\n // contain a colon. Render via the globally-registered <VCIcon> so\n // they resolve through the Iconify pipeline rather than landing as\n // raw CSS classes on a literal <i>. Legacy class-string icons\n // (`fa fa-home`, `material-icons home`) keep their <i class> rendering.\n const renderIcon = (icon: string): VNodeChild => {\n if (icon.includes(':')) {\n return h(resolveComponent('VCIcon'), { name: icon });\n }\n return h('i', { class: icon });\n };\n\n const renderTitleInner = (resolved: NavigationThemeClasses): VNodeChild[] => [\n ...(data.value.icon ?\n [h('div', { class: resolved.linkIcon || undefined }, [\n renderIcon(data.value.icon),\n ])] :\n []),\n h('div', { class: resolved.linkText || undefined }, [\n data.value.name,\n ]),\n ];\n\n const renderLeaf = (resolved: NavigationThemeClasses): VNodeChild => {\n if (hasNormalizedSlot(SlotName.LINK, slots)) {\n return normalizeSlot(SlotName.LINK, {\n data: data.value,\n select,\n isActive: data.value.active,\n }, slots);\n }\n\n const linkProps: LinkProperties = {\n active: data.value.active,\n disabled: false,\n prefetch: true,\n };\n\n if (data.value.url) {\n if (\n isAbsoluteURL(data.value.url) ||\n data.value.url.startsWith('#')\n ) {\n linkProps.href = data.value.url;\n if (data.value.urlTarget) {\n linkProps.target = data.value.urlTarget;\n }\n } else {\n linkProps.to = data.value.url;\n }\n }\n\n return h(VCLink, {\n class: [resolved.link],\n 'data-vc-collection-item': '',\n ...linkProps,\n onClicked: select,\n }, { default: () => renderTitleInner(resolved) });\n };\n\n const renderChildren = (): VNodeChild => {\n if (hasNormalizedSlot(SlotName.SUB_ITEMS, slots)) {\n return normalizeSlot(SlotName.SUB_ITEMS, {\n data: data.value,\n select,\n toggle,\n });\n }\n\n // A dropdown group's flyout panel is plain content — a list of\n // links — NOT another menu bar. Recursing with `submenu=\"dropdown\"`\n // would nest a second `NavigationMenuRoot` inside this root's\n // `NavigationMenuContent`; Reka's NavigationMenu is built around a\n // SINGLE root per bar, and nesting roots breaks the hover state\n // machine (the panel only opens on the first hover and never\n // reopens). Rendering the panel contents in `collapse` mode keeps\n // them a plain `<ul>` of links, so deeper groups degrade to inline\n // collapsibles within the flyout instead of buggy sub-roots.\n // No `data`: the nested list reads this item's children via the\n // NAVIGATION_NODES_KEY inject provided in setup above.\n return h(itemsNode, {\n variant: props.variant,\n orientation: props.orientation,\n submenu: props.submenu === 'dropdown' ? 'collapse' : props.submenu,\n as: props.itemsAs,\n itemAs: props.as,\n themeClass: props.themeClass,\n themeVariant: props.themeVariant,\n });\n };\n\n return () => {\n const resolved = theme.value;\n const isDropdown = props.submenu === 'dropdown';\n const isActive = data.value.active || data.value.activeWithin;\n\n // type: separator\n if (data.value.type === ElementType.SEPARATOR) {\n const body = hasNormalizedSlot(SlotName.SEPARATOR, slots) ?\n normalizeSlot(SlotName.SEPARATOR, { data: data.value }, slots) :\n h('div', { class: resolved.separator || undefined }, data.value.name);\n\n if (isDropdown) {\n return h(NavigationMenuItem, { class: [resolved.item] }, { default: () => body });\n }\n return h(props.as, { class: [resolved.item] }, [body]);\n }\n\n // type: link (no children)\n if (!hasChildren.value) {\n const leaf = renderLeaf(resolved);\n\n if (isDropdown) {\n return h(NavigationMenuItem, {\n class: [resolved.item],\n 'data-active': data.value.active ? '' : undefined,\n }, {\n default: () => h(NavigationMenuLink, {\n active: data.value.active,\n asChild: true,\n }, { default: () => leaf }),\n });\n }\n\n return h(props.as, {\n class: [resolved.item, { active: data.value.active }],\n 'data-active': data.value.active ? '' : undefined,\n }, [leaf]);\n }\n\n // type: group with children — full-override slot bypasses the\n // Collapsible / NavigationMenu machinery entirely.\n if (hasNormalizedSlot(SlotName.SUB, slots)) {\n const body = normalizeSlot(SlotName.SUB, {\n data: data.value,\n select,\n toggle,\n }, slots);\n\n if (isDropdown) {\n return h(NavigationMenuItem, {\n class: [resolved.item, resolved.itemNested],\n 'data-active': isActive ? '' : undefined,\n }, { default: () => body });\n }\n return h(props.as, {\n class: [resolved.item, resolved.itemNested, { active: isActive }],\n 'data-active': isActive ? '' : undefined,\n }, [body]);\n }\n\n const title = hasNormalizedSlot(SlotName.SUB_TITLE, slots) ?\n normalizeSlot(SlotName.SUB_TITLE, {\n data: data.value,\n select,\n toggle,\n }) :\n renderTitleInner(resolved);\n\n // dropdown: Reka NavigationMenu flyout\n if (isDropdown) {\n return h(NavigationMenuItem, {\n class: [resolved.item, resolved.itemNested],\n 'data-active': isActive ? '' : undefined,\n }, {\n default: () => [\n h(NavigationMenuTrigger, {\n class: resolved.trigger || undefined,\n 'data-vc-collection-item': '',\n 'data-active': isActive ? '' : undefined,\n }, { default: () => title }),\n // Re-invoke `renderChildren()` per mount: Reka's\n // NavigationMenuContent unmounts on close and remounts on\n // reopen (unmountOnHide). A VNode can only be rendered\n // once, so handing back a pre-computed tree renders an\n // EMPTY flyout on the second open. The thunk produces a\n // fresh subtree each time the content mounts.\n h(NavigationMenuContent, { class: resolved.content || undefined }, { default: () => renderChildren() }),\n ],\n });\n }\n\n // collapse: inline Reka Collapsible\n return h(CollapsibleRoot, {\n as: props.as,\n class: [\n resolved.item,\n resolved.itemNested,\n { active: data.value.active || open.value },\n ],\n 'data-active': isActive ? '' : undefined,\n open: open.value,\n 'onUpdate:open': (value: boolean) => {\n open.value = value;\n },\n }, {\n default: () => [\n h(CollapsibleTrigger, {\n class: resolved.trigger || undefined,\n 'data-vc-collection-item': '',\n 'data-active': isActive ? '' : undefined,\n }, { default: () => title }),\n h(CollapsibleContent, { class: resolved.content || undefined }, { default: () => renderChildren() }),\n ],\n });\n };\n },\n});\n","import {\n hasNormalizedSlot,\n isPromise,\n normalizeSlot,\n useArrowNavigation,\n useComponentTheme,\n} from '@vuecs/core';\nimport type { ThemeClassesOverride, UseComponentThemeProps, VariantValues } from '@vuecs/core';\nimport type {\n Component,\n ExtractPublicPropTypes,\n PropType,\n SlotsType,\n VNodeArrayChildren,\n VNodeChild,\n WatchSource,\n} from 'vue';\nimport {\n computed,\n defineComponent,\n getCurrentInstance,\n h,\n inject,\n onMounted,\n onUnmounted,\n provide,\n ref,\n watch,\n watchEffect,\n} from 'vue';\nimport { NavigationMenuList, NavigationMenuRoot } from 'reka-ui';\nimport { SlotName } from '../../constants';\nimport type {\n NavigationItem,\n NavigationItemNormalized,\n NavigationOrientation,\n NavigationResolver,\n NavigationSubmenu,\n} from '../../types';\nimport type { NavigationThemeClasses } from '../../helpers/component/types';\nimport {\n collectTrail,\n findBestItemMatches,\n flattenWhere,\n normalizeItems,\n resetItemsByTrace,\n resolveSubmenuMode,\n} from '../../helpers';\nimport { NavigationRegistry, tryInjectNavigationRegistry } from '../../registry';\nimport { VCNavItem } from '../item';\nimport { NAVIGATION_NODES_KEY, NAVIGATION_SELECT_KEY } from '../select-context';\nimport type { NavItemsItemSlotProps } from '../type';\nimport { navigationThemeDefaults } from './theme';\n\nconst navItemsProps = {\n /**\n * The source of this nav's items. Plain array, sync fn, or async fn.\n * A fn receives a NavigationResolverContext and may read reactive\n * state freely — the nav re-runs it automatically when that state\n * changes.\n *\n * When omitted, the nav checks whether it is a nested submenu of a\n * parent `<VCNavItem>` (via the {@link NAVIGATION_NODES_KEY} inject)\n * and, if so, renders that parent's already-scored children as-is.\n */\n data: {\n type: [Array, Function] as PropType<NavigationItem[] | NavigationResolver>,\n default: undefined,\n },\n /** Opt in to publishing this nav's resolved output into the registry. */\n registry: { type: Boolean, default: false },\n /** The key under which to publish. Required when `registry` is true. */\n registryId: { type: String, default: undefined },\n /**\n * Current path for active-state matching. When omitted, the nav softly\n * reads vue-router's current route (via the `$route` global property)\n * if a router is installed; router-free apps simply get `undefined`.\n */\n path: { type: String, default: undefined },\n /**\n * Extra reactive deps that should retrigger the resolver — for state\n * read only AFTER the first `await` in an async resolver (auto-track\n * can't see past an await).\n */\n watch: { type: Array as PropType<WatchSource[]>, default: undefined },\n variant: { type: String, default: undefined },\n orientation: { type: String as PropType<NavigationOrientation>, default: undefined },\n /**\n * How items with children render their submenu. `auto` derives from\n * orientation (horizontal → dropdown, otherwise collapse).\n */\n submenu: { type: String as PropType<NavigationSubmenu>, default: 'auto' },\n /**\n * The tag (or component) for this nav's list container. Defaults to\n * `'ul'`. Forwarded unchanged to every nesting level so the whole tree\n * renders the same container tag. Honored in collapse mode only —\n * dropdown mode keeps Reka's NavigationMenu primitives.\n */\n as: { type: [String, Object] as PropType<string | Component>, default: 'ul' },\n /**\n * The tag (or component) for each item wrapper. Defaults to `'li'`.\n * Forwarded unchanged to every nesting level. Honored in collapse mode\n * only — dropdown mode keeps Reka's NavigationMenu primitives.\n */\n itemAs: { type: [String, Object] as PropType<string | Component>, default: 'li' },\n themeClass: { type: Object as PropType<ThemeClassesOverride<NavigationThemeClasses>>, default: undefined },\n themeVariant: { type: Object as PropType<VariantValues>, default: undefined },\n};\n\nexport type NavItemsProps = ExtractPublicPropTypes<typeof navItemsProps>;\n\nexport const VCNavItems = defineComponent({\n name: 'VCNavItems',\n props: navItemsProps,\n slots: Object as SlotsType<{\n item: NavItemsItemSlotProps;\n }>,\n setup(props, { slots, expose }) {\n // Merge the convenience `variant` prop into themeVariant before\n // resolution so themes can drive slot classes off it. Getter keeps\n // it reactive (computed() inside useComponentTheme tracks the read).\n const themeProps: UseComponentThemeProps<NavigationThemeClasses> = {\n get themeClass() {\n return props.themeClass;\n },\n get themeVariant() {\n return {\n ...(props.themeVariant ?? {}),\n ...(props.variant !== undefined ? { variant: props.variant } : {}),\n ...(props.orientation !== undefined ? { orientation: props.orientation } : {}),\n };\n },\n };\n\n const theme = useComponentTheme('navigation', themeProps, navigationThemeDefaults);\n\n const rootRef = ref<HTMLUListElement | null>(null);\n\n const onKeyDown = (event: KeyboardEvent) => {\n useArrowNavigation(\n event,\n event.target as HTMLElement | null,\n rootRef.value,\n {\n arrowKeyOptions: 'vertical',\n focus: true,\n loop: true,\n },\n );\n };\n\n // Registry is empty-safe: a standalone nav (no plugin installed)\n // falls back to a local empty registry so `registry(id)` still works.\n const registry = tryInjectNavigationRegistry() ?? new NavigationRegistry();\n\n // Soft vue-router lookup: vue-router installs a reactive `$route`\n // getter on `globalProperties`. Reading it inside a computed tracks\n // route changes without a static `vue-router` import, so router-free\n // apps degrade to `undefined` instead of failing to resolve the\n // module. An explicit `:path` prop always wins.\n const globals = getCurrentInstance()?.appContext.config.globalProperties;\n const currentPath = computed<string | undefined>(() => {\n if (typeof props.path !== 'undefined') {\n return props.path;\n }\n const route = globals?.$route as { path?: string } | undefined;\n return route?.path;\n });\n\n // Nested submenu detection: a parent `<VCNavItem>` provides its\n // already-scored children via NAVIGATION_NODES_KEY. When this nav\n // has no own `data` and such nodes are present, it is a nested\n // renderer — it skips resolving / scoring / select / registry and\n // just renders the provided subtree. An explicit `data` always\n // wins (treated as a resolving root even when nested in markup).\n const injectedNodes = inject(NAVIGATION_NODES_KEY, null);\n const isNested = computed(() => typeof props.data === 'undefined' && injectedNodes !== null);\n\n // --- click-driven selection (url-less section switchers) ---\n // A url-less item can't navigate, so a click \"selects\" it instead:\n // we record its trace and fold it into active-state derivation\n // below, publishing it through the registry like a route change.\n // Only a root nav holds this state and provides the bridge —\n // nested `<VCNavItems>` let the click bubble up to their owning root.\n const selectedTrace = ref<string[] | null>(null);\n if (!isNested.value) {\n provide(NAVIGATION_SELECT_KEY, {\n select: (item) => {\n selectedTrace.value = item.trace;\n },\n });\n // A real navigation supersedes a prior selection: once the\n // route changes, hand active-state back to path matching.\n watch(currentPath, () => {\n selectedTrace.value = null;\n });\n }\n\n // --- resolver + reactivity (root mode only; nested bypasses) ---\n const raw = ref<NavigationItem[]>([]);\n // Monotonic run token: overlapping async resolvers can settle out of\n // order, so only the latest invocation is allowed to write `raw.value`\n // — a slower earlier run must not clobber a fresher result.\n let runToken = 0;\n\n async function run() {\n const token = ++runToken;\n const value = typeof props.data === 'function' ?\n props.data({\n path: currentPath.value,\n registry: (id: string) => registry.get(id),\n }) :\n (props.data ?? []);\n\n if (!isPromise(value)) {\n raw.value = value ?? [];\n return;\n }\n\n try {\n const awaited = (await value) ?? [];\n if (token === runToken) {\n raw.value = awaited;\n }\n } catch (error) {\n // Without this, a rejected resolver surfaces as an unhandled\n // rejection from the watcher effect. Only the latest run logs.\n if (token === runToken) {\n // eslint-disable-next-line no-console\n console.error('[vuecs] <VCNavItems> resolver rejected:', error);\n }\n }\n }\n\n if (!isNested.value) {\n // Auto-track: reactive reads in `run` BEFORE the first await retrigger it.\n watchEffect(run);\n // Escape hatch for state read AFTER an await:\n if (props.watch) {\n watch(props.watch, run);\n }\n }\n\n // Imperative escape hatch:\n expose({ refresh: run });\n\n // --- normalized + tree-wide scored derivation ---\n const resolved = computed<{ items: NavigationItemNormalized[]; trace: string[] }>(() => {\n if (isNested.value && injectedNodes) {\n return { items: injectedNodes.value, trace: [] };\n }\n\n const normalized = normalizeItems(raw.value);\n const [match] = findBestItemMatches(normalized, { path: currentPath.value });\n // A click-driven selection (url-less switcher) overrides the\n // path match until the next real navigation clears it.\n const trace = selectedTrace.value ?? (match ? match.trace : []);\n // sets per item: .active (exact) AND .activeWithin (ancestor)\n resetItemsByTrace(normalized, trace);\n return { items: normalized, trace };\n });\n\n const tree = computed(() => resolved.value.items);\n const active = computed(() => flattenWhere(tree.value, (item) => !!item.active));\n const activeTrail = computed(() => collectTrail(tree.value, resolved.value.trace));\n\n // --- registry publish (opt-in, lifecycle-bound) ---\n if (props.registry) {\n let unsubscribeFn : (() => void) | undefined;\n\n const entry = {\n items: tree,\n active,\n activeTrail,\n };\n\n onMounted(() => {\n if (!props.registryId) {\n // eslint-disable-next-line no-console\n console.warn('[vuecs] <VCNavItems registry> requires a `registry-id`.');\n return;\n }\n unsubscribeFn = registry.register(props.registryId, entry);\n });\n onUnmounted(() => {\n if (!props.registryId || !unsubscribeFn) {\n return;\n }\n\n unsubscribeFn();\n });\n }\n\n const submenuMode = computed(() => resolveSubmenuMode(props.submenu, props.orientation));\n\n return () => {\n const resolvedTheme = theme.value;\n const vNodes: VNodeArrayChildren = [];\n\n for (let i = 0; i < tree.value.length; i++) {\n const item = tree.value[i];\n if (!item.display && !item.displayChildren) {\n continue;\n }\n\n let vNode: VNodeChild;\n if (hasNormalizedSlot(SlotName.ITEM, slots)) {\n vNode = normalizeSlot(SlotName.ITEM, { data: item }, slots);\n } else {\n vNode = h(\n VCNavItem,\n {\n key: item.trace.join('/') || i,\n data: item,\n variant: props.variant,\n orientation: props.orientation,\n submenu: submenuMode.value,\n as: props.itemAs,\n itemsAs: props.as,\n themeClass: props.themeClass,\n themeVariant: props.themeVariant,\n },\n );\n }\n\n vNodes.push(vNode);\n }\n\n // Dropdown mode wraps the list in Reka's NavigationMenu so group\n // triggers get flyout machinery (hover-grace, edge-aware content,\n // arrow-key nav). Collapse mode stays a plain <ul> and wires our\n // own arrow-navigation on the root.\n if (submenuMode.value === 'dropdown') {\n return h(\n NavigationMenuRoot,\n { orientation: 'horizontal' },\n {\n default: () => h(\n NavigationMenuList,\n { class: resolvedTheme.group || undefined },\n { default: () => vNodes },\n ),\n },\n );\n }\n\n const isRoot = !isNested.value;\n\n return h(\n props.as,\n {\n class: resolvedTheme.group || undefined,\n ...(isRoot ?\n { ref: rootRef, onKeydown: onKeyDown } :\n {}),\n },\n vNodes,\n );\n };\n },\n});\n","import type { InjectionKey } from 'vue';\nimport { inject, provide } from 'vue';\nimport type { ThemeClassesOverride, VariantValues } from '@vuecs/core';\nimport type { StepperThemeClasses } from './types';\n\n/**\n * Context shared from `<VCStepper>` to its descendant parts so that\n * theme-class and theme-variant values applied to the root propagate\n * automatically to indicator / title / description / separator / item /\n * trigger without the consumer having to repeat the props on every\n * child. Per-instance values on a child still win over inherited ones.\n *\n * Optional — children render bare (without inherited theme values) when\n * mounted outside `<VCStepper>` for unit tests / Storybook.\n */\nexport type StepperContext = {\n themeClass: () => ThemeClassesOverride<StepperThemeClasses> | undefined;\n themeVariant: () => VariantValues | undefined;\n};\n\nconst STEPPER_CONTEXT_KEY: InjectionKey<StepperContext> = Symbol('vcStepperContext');\n\nexport function provideStepperContext(ctx: StepperContext): void {\n provide(STEPPER_CONTEXT_KEY, ctx);\n}\n\nexport function useStepperContext(): StepperContext | null {\n return inject(STEPPER_CONTEXT_KEY, null);\n}\n","import type { ComponentThemeDefinition } from '@vuecs/core';\nimport type { StepperThemeClasses } from './types';\n\nexport const stepperThemeDefaults: ComponentThemeDefinition<StepperThemeClasses> = {\n classes: {\n root: 'vc-stepper',\n item: 'vc-stepper-item',\n trigger: 'vc-stepper-trigger',\n indicator: 'vc-stepper-indicator',\n title: 'vc-stepper-title',\n description: 'vc-stepper-description',\n separator: 'vc-stepper-separator',\n },\n};\n","<script lang=\"ts\">\nimport { defineComponent, h, mergeProps } from 'vue';\nimport type { ExtractPublicPropTypes, PropType } from 'vue';\nimport { StepperRoot } from 'reka-ui';\nimport { useComponentTheme } from '@vuecs/core';\nimport type { ThemeClassesOverride, VariantValues } from '@vuecs/core';\nimport { provideStepperContext } from './context';\nimport { stepperThemeDefaults } from './theme';\nimport type { StepperThemeClasses } from './types';\n\nconst stepperProps = {\n /** Active step (1-based). v-modeled. */\n modelValue: { type: Number, default: undefined },\n /** Initial active step for uncontrolled usage. */\n defaultValue: { type: Number, default: 1 },\n /** Layout direction. */\n orientation: { type: String as PropType<'horizontal' | 'vertical'>, default: 'horizontal' },\n /** Reading direction. Falls back to the ConfigManager's `dir` value when omitted. */\n dir: { type: String as PropType<'ltr' | 'rtl'>, default: undefined },\n /** When `true`, steps must be completed in order — Reka blocks navigation past the next incomplete step. */\n linear: { type: Boolean, default: true },\n /** Theme-class overrides for this component instance. */\n themeClass: { type: Object as PropType<ThemeClassesOverride<StepperThemeClasses>>, default: undefined },\n /** Theme-variant values for this component instance. */\n themeVariant: { type: Object as PropType<VariantValues>, default: undefined },\n};\n\nexport type StepperProps = ExtractPublicPropTypes<typeof stepperProps>;\n\nexport default defineComponent({\n name: 'VCStepper',\n inheritAttrs: false,\n props: stepperProps,\n emits: ['update:modelValue'],\n setup(props, {\n attrs,\n slots,\n emit,\n }) {\n // Propagate theme-class + theme-variant to descendant indicator /\n // title / description / separator / item / trigger parts so a single\n // `<VCStepper :theme-variant=\"{ size: 'sm' }\">` resizes the whole\n // stepper, and `:theme-class=\"{ indicator: 'ring-2' }\">` skins every\n // indicator. Children fall back to their own per-instance values\n // when the consumer wants to override them.\n provideStepperContext({\n themeClass: () => props.themeClass,\n themeVariant: () => props.themeVariant,\n });\n const theme = useComponentTheme('stepper', props, stepperThemeDefaults);\n return () => h(\n StepperRoot,\n mergeProps(attrs, {\n modelValue: props.modelValue,\n defaultValue: props.defaultValue,\n orientation: props.orientation,\n