Skip to content

Inertia Link override: no aria-current, invalid ariacurrentvalue attribute, and prefix-based active matching #6803

Description

@kodjosama

Environment

  • @nuxt/ui 4.10.0
  • Vue + Vite (no Nuxt), Inertia.js v2, plugin configured as ui({ router: 'inertia' })
  • File under discussion: dist/runtime/vue/overrides/inertia/Link.vue
  • Comparison point: dist/runtime/vue/overrides/vue-router/Link.vue

Description

The router: 'inertia' Link override diverges from the vue-router one in three ways. Because every Nuxt UI component that renders a link (UButton, UBreadcrumb, UNavigationMenu, UDropdownMenu, …) goes through this component, all three affect the whole library in Inertia mode.

1. aria-current is never emitted

isLinkActive (inertia/Link.vue:109-123) only feeds linkClass (:124-130) and the active slot prop. The rendered anchor never receives aria-current.

The vue-router variant does emit it (vue-router/Link.vue:119 and :136):

...exact && isExactActive ? { 'aria-current': props.ariaCurrentValue } : {},

The Inertia variant has no equivalent, so an Inertia app that migrates its navigation onto Nuxt UI silently loses the current-page announcement for screen-reader users.

2. ariaCurrentValue leaks onto the DOM as an invalid attribute

ariaCurrentValue is declared with a default (inertia/Link.vue:26):

ariaCurrentValue: { type: String, required: false, default: "page" },

but it is not in the reactiveOmit list at :71:

const routerLinkProps = useForwardProps(reactiveOmit(props, "as", "type", "disabled", "active", "exact", "activeClass", "inactiveClass", "to", "href", "raw", "custom", "class", "target", "rel", "noRel"));

so useForwardProps passes it to ULinkBase (:150-166), which does not declare it either, so it lands in $attrs and reaches the DOM as ariacurrentvalue="page".

vue-router/Link.vue omits it from its reactiveOmit list too, but there it is harmless: RouterLink declares ariaCurrentValue as a real prop and consumes it. inertia/LinkBase.vue (and @inertiajs/vue3's Link) declare no such prop.

Because the prop carries a default, the attribute appears even on components that render no link at all. Measured on a page with three Nuxt UI components — one <UButton to> and two <UButton> with no to — all three rendered ariacurrentvalue="page", client-side and in SSR output.

Minimal repro:

<UButton>no link at all</UButton>
<!-- renders: <button ... ariacurrentvalue="page"> -->

3. Active matching uses a raw prefix against a query-carrying URL

inertia/Link.vue:119:

if (!props.exact && page.url.startsWith(href.value)) {
  return true;
}

usePage().url in Inertia includes the query string, and startsWith is a raw prefix test. Three consequences:

  • A query string defeats the match. page.url = '/inventory?search=x' with to="/inventory" is active (prefix holds), but to="/inventory" on page.url = '/inventory?search=x' where the link itself carries a query — or any link whose href is longer than the bare path — is not. More importantly the reverse case, to="/inventory/adjust" while on /inventory?tab=1, behaves unpredictably.
  • to="/" is active on every page, since every URL starts with /.
  • Segment boundaries are ignored: to="/inventory" reports active on /inventory-adjustments.

The vue-router variant delegates this to RouterLink's isActive/isExactActive, which are path- and segment-aware, so the Inertia variant is the odd one out.

Suggested fix

  1. Add ...isLinkActive ? { 'aria-current': props.ariaCurrentValue } : {} to the two v-bind objects in inertia/Link.vue's template (absent, not "false", when inactive).
  2. Add "ariaCurrentValue" to the reactiveOmit list at :71.
  3. Compare on the path only, with segment boundaries:
const pathOf = (url) => {
  const path = url.split('#')[0].split('?')[0]
  return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path || '/'
}

// non-exact
const current = pathOf(page.url)
const target = pathOf(href.value)
return target === '/' ? current === '/' : current === target || current.startsWith(`${target}/`)

Happy to open a PR for this if the approach looks right.

Additional context

We are carrying this as a local build-time patch on the vendored module while migrating a production Inertia app onto Nuxt UI, and would rather drop it than maintain it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions