Skip to content

Commit 2c4ff35

Browse files
fix(Link): ensure single-root rendering for v-show and $el resolution (#6310)
Co-authored-by: Jakub Michálek <71264422+J-Michalek@users.noreply.github.com>
1 parent fe70945 commit 2c4ff35

7 files changed

Lines changed: 123 additions & 60 deletions

File tree

src/runtime/components/Link.vue

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,10 @@ interface NuxtLinkDefaultSlotProps {
108108
<script setup lang="ts">
109109
import { computed } from 'vue'
110110
import { isEqual } from 'ohash/utils'
111-
import { useForwardProps } from 'reka-ui'
111+
import { useForwardProps, Slot } from 'reka-ui'
112112
import { defu } from 'defu'
113113
import { reactiveOmit } from '@vueuse/core'
114+
import { hasProtocol } from 'ufo'
114115
import { useRoute, useAppConfig } from '#imports'
115116
import { mergeClasses } from '../utils'
116117
import { tv } from '../utils/tv'
@@ -146,11 +147,30 @@ const ui = computed(() => tv({
146147
147148
const to = computed(() => props.to ?? props.href)
148149
149-
function isLinkActive({ route: linkRoute, isActive, isExactActive }: any) {
150+
const isInternalLink = computed(() => {
151+
if (!to.value) return false
152+
if (props.external) return false
153+
if (typeof to.value !== 'string') return true
154+
if (hasProtocol(to.value, { acceptRelative: true })) return false
155+
if (props.target && props.target !== '_self') return false
156+
return true
157+
})
158+
159+
const externalRel = computed(() => {
160+
if (props.noRel) return null
161+
if (props.rel) return props.rel
162+
return 'noopener noreferrer'
163+
})
164+
165+
function isLinkActive({ route: linkRoute, isActive, isExactActive }: any = {}) {
150166
if (props.active !== undefined) {
151167
return props.active
152168
}
153169
170+
if (!to.value) {
171+
return false
172+
}
173+
154174
if (props.exactQuery === 'partial') {
155175
if (!isPartiallyEqual(linkRoute.query, route.query)) return false
156176
} else if (props.exactQuery === true) {
@@ -172,7 +192,7 @@ function isLinkActive({ route: linkRoute, isActive, isExactActive }: any) {
172192
return false
173193
}
174194
175-
function resolveLinkClass({ route, isActive, isExactActive }: any) {
195+
function resolveLinkClass({ route, isActive, isExactActive }: any = {}) {
176196
const active = isLinkActive({ route, isActive, isExactActive })
177197
178198
if (props.raw) {
@@ -184,8 +204,8 @@ function resolveLinkClass({ route, isActive, isExactActive }: any) {
184204
</script>
185205

186206
<template>
187-
<NuxtLink v-slot="{ href, navigate, route: linkRoute, isActive, isExactActive, ...rest }" v-bind="nuxtLinkProps" :to="to" custom>
188-
<template v-if="custom">
207+
<NuxtLink v-if="isInternalLink" v-slot="{ href, navigate, route: linkRoute, isActive, isExactActive, ...rest }" v-bind="nuxtLinkProps" :to="to" custom>
208+
<Slot v-if="custom">
189209
<slot
190210
v-bind="{
191211
...$attrs,
@@ -201,7 +221,7 @@ function resolveLinkClass({ route, isActive, isExactActive }: any) {
201221
active: isLinkActive({ route: linkRoute, isActive, isExactActive })
202222
}"
203223
/>
204-
</template>
224+
</Slot>
205225
<ULinkBase
206226
v-else
207227
v-bind="{
@@ -221,4 +241,30 @@ function resolveLinkClass({ route, isActive, isExactActive }: any) {
221241
<slot :active="isLinkActive({ route: linkRoute, isActive, isExactActive })" />
222242
</ULinkBase>
223243
</NuxtLink>
244+
245+
<Slot v-else-if="custom">
246+
<slot
247+
v-bind="{
248+
...$attrs,
249+
as,
250+
type,
251+
disabled,
252+
...(to ? { href: String(to), target: props.target, rel: externalRel, isExternal: true } : {}),
253+
active: active ?? false
254+
}"
255+
/>
256+
</Slot>
257+
<ULinkBase
258+
v-else
259+
v-bind="{
260+
...$attrs,
261+
as,
262+
type,
263+
disabled,
264+
...(to ? { href: String(to), target: props.target, rel: externalRel, isExternal: true } : {})
265+
}"
266+
:class="resolveLinkClass()"
267+
>
268+
<slot :active="active ?? false" />
269+
</ULinkBase>
224270
</template>

src/runtime/vue/overrides/inertia/Link.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export interface LinkSlots {
7171
<script setup lang="ts">
7272
import { computed } from 'vue'
7373
import { defu } from 'defu'
74-
import { useForwardProps } from 'reka-ui'
74+
import { useForwardProps, Slot } from 'reka-ui'
7575
import { reactiveOmit } from '@vueuse/core'
7676
import { usePage } from '@inertiajs/vue3'
7777
import { hasProtocol } from 'ufo'
@@ -179,7 +179,7 @@ const linkClass = computed(() => {
179179
</script>
180180

181181
<template>
182-
<template v-if="custom">
182+
<Slot v-if="custom">
183183
<slot
184184
v-bind="{
185185
...$attrs,
@@ -194,7 +194,7 @@ const linkClass = computed(() => {
194194
isExternal
195195
}"
196196
/>
197-
</template>
197+
</Slot>
198198
<ULinkBase
199199
v-else
200200
v-bind="{

src/runtime/vue/overrides/none/Link.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export interface LinkSlots {
7373
<script setup lang="ts">
7474
import { computed, inject } from 'vue'
7575
import { defu } from 'defu'
76+
import { Slot } from 'reka-ui'
7677
import { hasProtocol } from 'ufo'
7778
import { useAppConfig } from '#imports'
7879
import { mergeClasses } from '../../../utils'
@@ -169,7 +170,7 @@ const navigate = handleNavigation
169170
</script>
170171

171172
<template>
172-
<template v-if="custom">
173+
<Slot v-if="custom">
173174
<slot
174175
v-bind="{
175176
...$attrs,
@@ -184,7 +185,7 @@ const navigate = handleNavigation
184185
active: isLinkActive
185186
}"
186187
/>
187-
</template>
188+
</Slot>
188189
<ULinkBase
189190
v-else
190191
v-bind="{

src/runtime/vue/overrides/vue-router/Link.vue

Lines changed: 45 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export interface LinkSlots {
6565
import { computed } from 'vue'
6666
import { defu } from 'defu'
6767
import { isEqual } from 'ohash/utils'
68-
import { useForwardProps } from 'reka-ui'
68+
import { useForwardProps, Slot } from 'reka-ui'
6969
import { reactiveOmit } from '@vueuse/core'
7070
import { hasProtocol } from 'ufo'
7171
import { useRoute, RouterLink } from 'vue-router'
@@ -181,27 +181,9 @@ function resolveLinkClass({ route, isActive, isExactActive }: any = {}) {
181181

182182
<!-- eslint-disable vue/no-template-shadow -->
183183
<template>
184-
<template v-if="!isExternal && !!to">
185-
<RouterLink v-slot="{ href, navigate, route: linkRoute, isActive, isExactActive }" v-bind="routerLinkProps" :to="to" custom>
186-
<template v-if="custom">
187-
<slot
188-
v-bind="{
189-
...$attrs,
190-
...(exact && isExactActive ? { 'aria-current': props.ariaCurrentValue } : {}),
191-
as,
192-
type,
193-
disabled,
194-
href,
195-
navigate,
196-
rel,
197-
target,
198-
isExternal,
199-
active: isLinkActive({ route: linkRoute, isActive, isExactActive })
200-
}"
201-
/>
202-
</template>
203-
<ULinkBase
204-
v-else
184+
<RouterLink v-if="!isExternal && !!to" v-slot="{ href, navigate, route: linkRoute, isActive, isExactActive }" v-bind="routerLinkProps" :to="to" custom>
185+
<Slot v-if="custom">
186+
<slot
205187
v-bind="{
206188
...$attrs,
207189
...(exact && isExactActive ? { 'aria-current': props.ariaCurrentValue } : {}),
@@ -212,46 +194,60 @@ function resolveLinkClass({ route, isActive, isExactActive }: any = {}) {
212194
navigate,
213195
rel,
214196
target,
215-
isExternal
216-
}"
217-
:class="resolveLinkClass({ route: linkRoute, isActive, isExactActive })"
218-
>
219-
<slot :active="isLinkActive({ route: linkRoute, isActive, isExactActive })" />
220-
</ULinkBase>
221-
</RouterLink>
222-
</template>
223-
224-
<template v-else>
225-
<template v-if="custom">
226-
<slot
227-
v-bind="{
228-
...$attrs,
229-
as,
230-
type,
231-
disabled,
232-
href: to,
233-
rel,
234-
target,
235-
active: active ?? false,
236-
isExternal
197+
isExternal,
198+
active: isLinkActive({ route: linkRoute, isActive, isExactActive })
237199
}"
238200
/>
239-
</template>
201+
</Slot>
240202
<ULinkBase
241203
v-else
242204
v-bind="{
243205
...$attrs,
206+
...(exact && isExactActive ? { 'aria-current': props.ariaCurrentValue } : {}),
244207
as,
245208
type,
246209
disabled,
247-
href: (to as string),
210+
href,
211+
navigate,
248212
rel,
249213
target,
250214
isExternal
251215
}"
252-
:class="resolveLinkClass()"
216+
:class="resolveLinkClass({ route: linkRoute, isActive, isExactActive })"
253217
>
254-
<slot :active="active ?? false" />
218+
<slot :active="isLinkActive({ route: linkRoute, isActive, isExactActive })" />
255219
</ULinkBase>
256-
</template>
220+
</RouterLink>
221+
222+
<Slot v-else-if="custom">
223+
<slot
224+
v-bind="{
225+
...$attrs,
226+
as,
227+
type,
228+
disabled,
229+
href: to,
230+
rel,
231+
target,
232+
active: active ?? false,
233+
isExternal
234+
}"
235+
/>
236+
</Slot>
237+
<ULinkBase
238+
v-else
239+
v-bind="{
240+
...$attrs,
241+
as,
242+
type,
243+
disabled,
244+
href: (to as string),
245+
rel,
246+
target,
247+
isExternal
248+
}"
249+
:class="resolveLinkClass()"
250+
>
251+
<slot :active="active ?? false" />
252+
</ULinkBase>
257253
</template>

test/components/Link.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ describe('Link', () => {
1717
['with raw activeClass', { props: { raw: true, active: true, activeClass: 'text-highlighted' } }],
1818
['with raw inactiveClass', { props: { raw: true, active: false, inactiveClass: 'hover:text-primary' } }],
1919
['with class', { props: { class: 'font-medium' } }],
20+
['with external to', { props: { to: 'https://example.com' } }],
21+
['with external to and target', { props: { to: 'https://example.com', target: '_blank' } }],
22+
['with internal to and target', { props: { to: '/about', target: '_blank' } }],
23+
['with external prop', { props: { to: '/api/download', external: true } }],
2024
// Slots
2125
['with default slot', { slots: { default: () => 'Default slot' } }]
2226
])

test/components/__snapshots__/Link-vue.spec.ts.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ exports[`Link > renders with default slot correctly 1`] = `"<button type="button
1010

1111
exports[`Link > renders with disabled correctly 1`] = `"<button type="button" disabled="" class="focus-visible:outline-primary text-muted cursor-not-allowed opacity-75"></button>"`;
1212

13+
exports[`Link > renders with external prop correctly 1`] = `"<a href="/api/download" rel="noopener noreferrer" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></a>"`;
14+
15+
exports[`Link > renders with external to and target correctly 1`] = `"<a href="https://example.com" rel="noopener noreferrer" target="_blank" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></a>"`;
16+
17+
exports[`Link > renders with external to correctly 1`] = `"<a href="https://example.com" rel="noopener noreferrer" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></a>"`;
18+
1319
exports[`Link > renders with inactiveClass correctly 1`] = `"<button type="button" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></button>"`;
1420

21+
exports[`Link > renders with internal to and target correctly 1`] = `"<a href="/about" rel="noopener noreferrer" target="_blank" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></a>"`;
22+
1523
exports[`Link > renders with raw activeClass correctly 1`] = `"<button type="button" class="text-highlighted"></button>"`;
1624

1725
exports[`Link > renders with raw correctly 1`] = `"<button type="button" class=""></button>"`;

test/components/__snapshots__/Link.spec.ts.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ exports[`Link > renders with default slot correctly 1`] = `"<button type="button
1010

1111
exports[`Link > renders with disabled correctly 1`] = `"<button type="button" disabled="" class="focus-visible:outline-primary text-muted cursor-not-allowed opacity-75"></button>"`;
1212

13+
exports[`Link > renders with external prop correctly 1`] = `"<a href="/api/download" rel="noopener noreferrer" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></a>"`;
14+
15+
exports[`Link > renders with external to and target correctly 1`] = `"<a href="https://example.com" rel="noopener noreferrer" target="_blank" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></a>"`;
16+
17+
exports[`Link > renders with external to correctly 1`] = `"<a href="https://example.com" rel="noopener noreferrer" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></a>"`;
18+
1319
exports[`Link > renders with inactiveClass correctly 1`] = `"<button type="button" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></button>"`;
1420

21+
exports[`Link > renders with internal to and target correctly 1`] = `"<a href="/about" rel="noopener noreferrer" target="_blank" class="focus-visible:outline-primary text-muted hover:text-default transition-colors"></a>"`;
22+
1523
exports[`Link > renders with raw activeClass correctly 1`] = `"<button type="button" class="text-highlighted"></button>"`;
1624

1725
exports[`Link > renders with raw correctly 1`] = `"<button type="button" class=""></button>"`;

0 commit comments

Comments
 (0)