From 4dbe748cfc3775aecb8fccc3bb2a4136241aa120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20S=C3=A1nchez?= Date: Fri, 19 Apr 2024 11:48:49 +0200 Subject: [PATCH] feat(nuxt): expose `useLink` from `NuxtLink` (#26522) --- packages/nuxt/src/app/components/nuxt-link.ts | 132 +++++++++++------- test/basic.test.ts | 37 +++++ .../basic/pages/nuxt-link/use-link.vue | 53 +++++++ .../basic/plugins/add-nuxt-link-alias.ts | 5 + 4 files changed, 177 insertions(+), 50 deletions(-) create mode 100644 test/fixtures/basic/pages/nuxt-link/use-link.vue create mode 100644 test/fixtures/basic/plugins/add-nuxt-link-alias.ts diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index 62446eb563..6efcab60b3 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -7,7 +7,7 @@ import type { VNodeProps, } from 'vue' import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, resolveComponent } from 'vue' -import type { RouteLocation, RouteLocationRaw, Router, RouterLinkProps } from '#vue-router' +import type { RouteLocation, RouteLocationRaw, Router, RouterLink, RouterLinkProps, useLink } from '#vue-router' import { hasProtocol, joinURL, parseQuery, parseURL, withTrailingSlash, withoutTrailingSlash } from 'ufo' import { preloadRouteComponents } from '../composables/preload' import { onNuxtReady } from '../composables/ready' @@ -120,6 +120,79 @@ export function defineNuxtLink (options: NuxtLinkOptions) { return resolvedPath } + function useNuxtLink (props: NuxtLinkProps) { + const router = useRouter() + const config = useRuntimeConfig() + + // Resolving `to` value from `to` and `href` props + const to: ComputedRef = computed(() => { + checkPropConflicts(props, 'to', 'href') + const path = props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute) + return resolveTrailingSlashBehavior(path, router.resolve) + }) + + // Lazily check whether to.value has a protocol + const isAbsoluteUrl = computed(() => typeof to.value === 'string' && hasProtocol(to.value, { acceptRelative: true })) + + // Resolves `to` value if it's a route location object + const href = computed(() => (typeof to.value === 'object' + ? router.resolve(to.value)?.href ?? null + : (to.value && !props.external && !isAbsoluteUrl.value) + ? resolveTrailingSlashBehavior(joinURL(config.app.baseURL, to.value), router.resolve) as string + : to.value + )) + + const builtinRouterLink = resolveComponent('RouterLink') as string | typeof RouterLink + const useBuiltinLink = builtinRouterLink && typeof builtinRouterLink !== 'string' ? builtinRouterLink.useLink : undefined + + const link = useBuiltinLink?.({ + ...props, + to: to.value, + }) + + const hasTarget = computed(() => props.target && props.target !== '_self') + + // Resolving link type + const isExternal = computed(() => { + // External prop is explicitly set + if (props.external) { + return true + } + + // When `target` prop is set, link is external + if (hasTarget.value) { + return true + } + + // When `to` is a route object then it's an internal link + if (typeof to.value === 'object') { + return false + } + + return to.value === '' || isAbsoluteUrl.value + }) + + return { + to, + hasTarget, + isAbsoluteUrl, + isExternal, + // + href, + isActive: link?.isActive ?? computed(() => to.value === router.currentRoute.value.path), + isExactActive: link?.isExactActive ?? computed(() => to.value === router.currentRoute.value.path), + route: link?.route ?? computed(() => router.resolve(to.value)), + async navigate () { + await navigateTo(href.value, { replace: props.replace, external: props.external }) + }, + } satisfies ReturnType & { + to: ComputedRef + hasTarget: ComputedRef + isAbsoluteUrl: ComputedRef + isExternal: ComputedRef + } + } + return defineComponent({ name: componentName, props: { @@ -207,43 +280,11 @@ export function defineNuxtLink (options: NuxtLinkOptions) { required: false, }, }, + useLink: useNuxtLink, setup (props, { slots }) { const router = useRouter() - const config = useRuntimeConfig() - // Resolving `to` value from `to` and `href` props - const to: ComputedRef = computed(() => { - checkPropConflicts(props, 'to', 'href') - - const path = props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute) - - return resolveTrailingSlashBehavior(path, router.resolve) - }) - - // Lazily check whether to.value has a protocol - const isAbsoluteUrl = computed(() => typeof to.value === 'string' && hasProtocol(to.value, { acceptRelative: true })) - - const hasTarget = computed(() => props.target && props.target !== '_self') - - // Resolving link type - const isExternal = computed(() => { - // External prop is explicitly set - if (props.external) { - return true - } - - // When `target` prop is set, link is external - if (hasTarget.value) { - return true - } - - // When `to` is a route object then it's an internal link - if (typeof to.value === 'object') { - return false - } - - return to.value === '' || isAbsoluteUrl.value - }) + const { to, href, navigate, isExternal, hasTarget, isAbsoluteUrl } = useNuxtLink(props) // Prefetching const prefetched = ref(false) @@ -323,14 +364,6 @@ export function defineNuxtLink (options: NuxtLinkOptions) { ) } - // Resolves `to` value if it's a route location object - // converts `""` to `null` to prevent the attribute from being added as empty (`href=""`) - const href = typeof to.value === 'object' - ? router.resolve(to.value)?.href ?? null - : (to.value && !props.external && !isAbsoluteUrl.value) - ? resolveTrailingSlashBehavior(joinURL(config.app.baseURL, to.value), router.resolve) as string - : to.value || null - // Resolves `target` value const target = props.target || null @@ -353,15 +386,13 @@ export function defineNuxtLink (options: NuxtLinkOptions) { return null } - const navigate = () => navigateTo(href, { replace: props.replace, external: props.external }) - return slots.default({ - href, + href: href.value, navigate, get route () { - if (!href) { return undefined } + if (!href.value) { return undefined } - const url = parseURL(href) + const url = parseURL(href.value) return { path: url.pathname, fullPath: url.pathname, @@ -372,7 +403,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { matched: [], redirectedFrom: undefined, meta: {}, - href, + href: href.value, } satisfies RouteLocation & { href: string } }, rel, @@ -383,7 +414,8 @@ export function defineNuxtLink (options: NuxtLinkOptions) { }) } - return h('a', { ref: el, href, rel, target }, slots.default?.()) + // converts `""` to `null` to prevent the attribute from being added as empty (`href=""`) + return h('a', { ref: el, href: href.value || null, rel, target }, slots.default?.()) } }, }) as unknown as DefineComponent diff --git a/test/basic.test.ts b/test/basic.test.ts index b6e7b168e0..338e2f83b2 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -771,6 +771,43 @@ describe('nuxt links', () => { await page.waitForFunction(() => window.scrollY === 0) await page.close() }) + + it('useLink works', async () => { + const html = await $fetch('/nuxt-link/use-link') + expect(html).toContain('
useLink in NuxtLink: true
') + expect(html).toContain('
route using useLink: /nuxt-link/trailing-slash
') + expect(html).toContain('
href using useLink: /nuxt-link/trailing-slash
') + expect(html).toContain('
useLink2 in NuxtLink: true
') + expect(html).toContain('
route2 using useLink: /nuxt-link/trailing-slash
') + expect(html).toContain('
href2 using useLink: /nuxt-link/trailing-slash
') + expect(html).toContain('
useLink3 in NuxtLink: true
') + expect(html).toContain('
route3 using useLink: /nuxt-link/trailing-slash
') + expect(html).toContain('
href3 using useLink: /nuxt-link/trailing-slash
') + }) + it('useLink navigate importing NuxtLink works', async () => { + const page = await createPage('/nuxt-link/use-link') + await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/nuxt-link/use-link') + + await page.locator('#button1').click() + await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nuxt-link/trailing-slash') + await page.close() + }) + it('useLink navigate using resolveComponent works', async () => { + const page = await createPage('/nuxt-link/use-link') + await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/nuxt-link/use-link') + + await page.locator('#button2').click() + await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nuxt-link/trailing-slash') + await page.close() + }) + it('useLink navigate using resolveDynamicComponent works', async () => { + const page = await createPage('/nuxt-link/use-link') + await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath === '/nuxt-link/use-link') + + await page.locator('#button3').click() + await page.waitForFunction(path => window.useNuxtApp?.()._route.fullPath === path, '/nuxt-link/trailing-slash') + await page.close() + }) }) describe('head tags', () => { diff --git a/test/fixtures/basic/pages/nuxt-link/use-link.vue b/test/fixtures/basic/pages/nuxt-link/use-link.vue new file mode 100644 index 0000000000..21f0ac5592 --- /dev/null +++ b/test/fixtures/basic/pages/nuxt-link/use-link.vue @@ -0,0 +1,53 @@ + + + diff --git a/test/fixtures/basic/plugins/add-nuxt-link-alias.ts b/test/fixtures/basic/plugins/add-nuxt-link-alias.ts new file mode 100644 index 0000000000..6d0bfeadfb --- /dev/null +++ b/test/fixtures/basic/plugins/add-nuxt-link-alias.ts @@ -0,0 +1,5 @@ +import { NuxtLink } from '#components' + +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.vueApp.component('NuxtLinkAlias', NuxtLink) +})