Skip to content

Commit 67da90a

Browse files
fix(Link): consistent behavior between nuxt, vue and inertia (#4134)
1 parent 894e8a6 commit 67da90a

File tree

10 files changed

+210
-139
lines changed

10 files changed

+210
-139
lines changed

src/runtime/components/Button.vue

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
<script lang="ts">
22
import type { AppConfig } from '@nuxt/schema'
33
import theme from '#build/ui/button'
4-
import type { LinkProps } from './Link.vue'
54
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
6-
import type { AvatarProps } from '../types'
5+
import type { LinkProps, AvatarProps } from '../types'
76
import type { ComponentConfig } from '../types/utils'
87
98
type Button = ComponentConfig<typeof theme, AppConfig, 'button'>
@@ -123,14 +122,13 @@ const ui = computed(() => tv({
123122
v-slot="{ active, ...slotProps }"
124123
:type="type"
125124
:disabled="disabled || isLoading"
126-
:class="ui.base({ class: [props.ui?.base, props.class] })"
127125
v-bind="omit(linkProps, ['type', 'disabled', 'onClick'])"
128126
custom
129127
>
130128
<ULinkBase
131129
v-bind="slotProps"
132130
:class="ui.base({
133-
class: [props.class, props.ui?.base],
131+
class: [props.ui?.base, props.class],
134132
active,
135133
...(active && activeVariant ? { variant: activeVariant } : {}),
136134
...(active && activeColor ? { color: activeColor } : {})

src/runtime/components/Link.vue

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,12 @@ export interface LinkSlots {
8989
<script setup lang="ts">
9090
import { computed } from 'vue'
9191
import { defu } from 'defu'
92-
import { isEqual, diff } from 'ohash/utils'
92+
import { isEqual } from 'ohash/utils'
9393
import { useForwardProps } from 'reka-ui'
9494
import { reactiveOmit } from '@vueuse/core'
9595
import { useRoute, useAppConfig } from '#imports'
9696
import { tv } from '../utils/tv'
97+
import { isPartiallyEqual } from '../utils/link'
9798
import ULinkBase from './LinkBase.vue'
9899
99100
defineOptions({ inheritAttrs: false })
@@ -111,7 +112,7 @@ defineSlots<LinkSlots>()
111112
const route = useRoute()
112113
const appConfig = useAppConfig() as Link['AppConfig']
113114
114-
const nuxtLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'exactQuery', 'exactHash', 'activeClass', 'inactiveClass', 'raw', 'class'))
115+
const nuxtLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'exactQuery', 'exactHash', 'activeClass', 'inactiveClass', 'to', 'href', 'raw', 'custom', 'class'))
115116
116117
const ui = computed(() => tv({
117118
extend: tv(theme),
@@ -125,19 +126,7 @@ const ui = computed(() => tv({
125126
}, appConfig.ui?.link || {})
126127
}))
127128
128-
function isPartiallyEqual(item1: any, item2: any) {
129-
const diffedKeys = diff(item1, item2).reduce((filtered, q) => {
130-
if (q.type === 'added') {
131-
filtered.add(q.key)
132-
}
133-
return filtered
134-
}, new Set<string>())
135-
136-
const item1Filtered = Object.fromEntries(Object.entries(item1).filter(([key]) => !diffedKeys.has(key)))
137-
const item2Filtered = Object.fromEntries(Object.entries(item2).filter(([key]) => !diffedKeys.has(key)))
138-
139-
return isEqual(item1Filtered, item2Filtered)
140-
}
129+
const to = computed(() => props.to ?? props.href)
141130
142131
function isLinkActive({ route: linkRoute, isActive, isExactActive }: any) {
143132
if (props.active !== undefined) {
@@ -177,7 +166,7 @@ function resolveLinkClass({ route, isActive, isExactActive }: any) {
177166
</script>
178167

179168
<template>
180-
<NuxtLink v-slot="{ href, navigate, route: linkRoute, rel, target, isExternal, isActive, isExactActive }" v-bind="nuxtLinkProps" custom>
169+
<NuxtLink v-slot="{ href, navigate, route: linkRoute, rel, target, isExternal, isActive, isExactActive }" v-bind="nuxtLinkProps" :to="to" custom>
181170
<template v-if="custom">
182171
<slot
183172
v-bind="{

src/runtime/inertia/components/Link.vue

Lines changed: 60 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { ComponentConfig } from '../../types/utils'
77
88
type Link = ComponentConfig<typeof theme, AppConfig, 'link'>
99
10-
interface NuxtLinkProps extends Omit<InertiaLinkProps, 'href'> {
10+
interface NuxtLinkProps extends Omit<InertiaLinkProps, 'href' | 'onClick'> {
1111
activeClass?: string
1212
/**
1313
* Route Location the link should navigate to when clicked on.
@@ -62,10 +62,11 @@ import { computed } from 'vue'
6262
import { defu } from 'defu'
6363
import { useForwardProps } from 'reka-ui'
6464
import { reactiveOmit } from '@vueuse/core'
65-
import { usePage, Link as InertiaLink } from '@inertiajs/vue3'
65+
import { usePage } from '@inertiajs/vue3'
6666
import { hasProtocol } from 'ufo'
6767
import { useAppConfig } from '#imports'
6868
import { tv } from '../../utils/tv'
69+
import ULinkBase from '../../components/LinkBase.vue'
6970
7071
defineOptions({ inheritAttrs: false })
7172
@@ -78,9 +79,11 @@ const props = withDefaults(defineProps<LinkProps>(), {
7879
})
7980
defineSlots<LinkSlots>()
8081
82+
const page = usePage()
83+
8184
const appConfig = useAppConfig() as Link['AppConfig']
8285
83-
const routerLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'activeClass', 'inactiveClass', 'to', 'raw', 'class'))
86+
const routerLinkProps = useForwardProps(reactiveOmit(props, 'as', 'type', 'disabled', 'active', 'exact', 'activeClass', 'inactiveClass', 'to', 'href', 'raw', 'custom', 'class'))
8487
8588
const ui = computed(() => tv({
8689
extend: tv(theme),
@@ -94,89 +97,79 @@ const ui = computed(() => tv({
9497
}, appConfig.ui?.link || {})
9598
}))
9699
100+
const href = computed(() => props.to ?? props.href)
101+
97102
const isExternal = computed(() => {
98-
if (props.external) return true
99-
if (!props.to) return false
100-
return typeof props.to === 'string' && hasProtocol(props.to, { acceptRelative: true })
103+
if (props.external) {
104+
return true
105+
}
106+
107+
if (!href.value) {
108+
return false
109+
}
110+
111+
return typeof href.value === 'string' && hasProtocol(href.value, { acceptRelative: true })
112+
})
113+
114+
const isLinkActive = computed(() => {
115+
if (props.active !== undefined) {
116+
return props.active
117+
}
118+
119+
if (!href.value) {
120+
return false
121+
}
122+
123+
if (props.exact && page.url === href.value) {
124+
return true
125+
}
126+
127+
if (!props.exact && page.url.startsWith(href.value)) {
128+
return true
129+
}
130+
131+
return false
101132
})
102133
103134
const linkClass = computed(() => {
104-
const active = isActive.value
135+
const active = isLinkActive.value
105136
106137
if (props.raw) {
107138
return [props.class, active ? props.activeClass : props.inactiveClass]
108139
}
109140
110141
return ui.value({ class: props.class, active, disabled: props.disabled })
111142
})
112-
113-
const page = usePage()
114-
const url = computed(() => props.to ?? props.href ?? '')
115-
116-
const isActive = computed(() => props.active || (!!url.value && (props.exact ? url.value === props.href : page?.url.startsWith(url.value))))
117143
</script>
118144

119145
<template>
120-
<template v-if="!isExternal && !!url">
121-
<InertiaLink v-bind="routerLinkProps" :href="url">
122-
<template v-if="custom">
123-
<slot
124-
v-bind="{
125-
...$attrs,
126-
as,
127-
type,
128-
disabled,
129-
href: url,
130-
active: isActive
131-
}"
132-
/>
133-
</template>
134-
<ULinkBase
135-
v-else
136-
v-bind="{
137-
...$attrs,
138-
as,
139-
type,
140-
disabled,
141-
href: url,
142-
active: isActive
143-
}"
144-
:class="linkClass"
145-
>
146-
<slot :active="isActive" />
147-
</ULinkBase>
148-
</InertiaLink>
149-
</template>
150-
151-
<template v-else>
152-
<template v-if="custom">
153-
<slot
154-
v-bind="{
155-
...$attrs,
156-
as,
157-
type,
158-
disabled,
159-
href: to,
160-
target: isExternal ? '_blank' : undefined,
161-
active: isActive
162-
}"
163-
/>
164-
</template>
165-
<ULinkBase
166-
v-else
146+
<template v-if="custom">
147+
<slot
167148
v-bind="{
168149
...$attrs,
150+
...routerLinkProps,
169151
as,
170152
type,
171153
disabled,
172-
href: url,
173-
target: isExternal ? '_blank' : undefined,
174-
active: isActive
154+
href,
155+
active: isLinkActive,
156+
isExternal
175157
}"
176-
:is-external="isExternal"
177-
:class="linkClass"
178-
>
179-
<slot :active="isActive" />
180-
</ULinkBase>
158+
/>
181159
</template>
160+
<ULinkBase
161+
v-else
162+
v-bind="{
163+
...$attrs,
164+
...routerLinkProps,
165+
as,
166+
type,
167+
disabled,
168+
href,
169+
isExternal
170+
}"
171+
:class="linkClass"
172+
>
173+
<slot :active="isLinkActive" />
174+
</ULinkBase>
182175
</template>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<script lang="ts">
2+
import type { LinkProps } from '../../types'
3+
4+
export interface LinkBaseProps {
5+
as?: string
6+
type?: string
7+
disabled?: boolean
8+
onClick?: ((e: MouseEvent) => void | Promise<void>) | Array<((e: MouseEvent) => void | Promise<void>)>
9+
href?: string
10+
target?: LinkProps['target']
11+
active?: boolean
12+
isExternal?: boolean
13+
}
14+
</script>
15+
16+
<script setup lang="ts">
17+
import { Primitive } from 'reka-ui'
18+
import { Link as InertiaLink } from '@inertiajs/vue3'
19+
20+
defineOptions({ inheritAttrs: false })
21+
22+
const props = withDefaults(defineProps<LinkBaseProps>(), {
23+
as: 'button',
24+
type: 'button'
25+
})
26+
27+
function onClickWrapper(e: MouseEvent) {
28+
if (props.disabled) {
29+
e.stopPropagation()
30+
e.preventDefault()
31+
return
32+
}
33+
34+
if (props.onClick) {
35+
for (const onClick of Array.isArray(props.onClick) ? props.onClick : [props.onClick]) {
36+
onClick(e)
37+
}
38+
}
39+
}
40+
</script>
41+
42+
<template>
43+
<InertiaLink
44+
v-if="!!href && !isExternal && !disabled"
45+
:href="href"
46+
v-bind="{
47+
target: target || (isExternal ? '_blank' : undefined),
48+
...$attrs
49+
}"
50+
@click="onClickWrapper"
51+
>
52+
<slot />
53+
</InertiaLink>
54+
<Primitive
55+
v-else
56+
v-bind="href ? {
57+
'as': 'a',
58+
'href': disabled ? undefined : href,
59+
'aria-disabled': disabled ? 'true' : undefined,
60+
'role': disabled ? 'link' : undefined,
61+
'tabindex': disabled ? -1 : undefined,
62+
'target': target || (isExternal ? '_blank' : undefined),
63+
...$attrs
64+
} : as === 'button' ? {
65+
as,
66+
type,
67+
disabled,
68+
...$attrs
69+
} : {
70+
as,
71+
...$attrs
72+
}"
73+
@click="onClickWrapper"
74+
>
75+
<slot />
76+
</Primitive>
77+
</template>

src/runtime/utils/link.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { reactivePick } from '@vueuse/core'
2+
import { isEqual, diff } from 'ohash/utils'
23
import type { LinkProps } from '../types'
34

45
export function pickLinkProps(link: LinkProps & { [key: string]: any }) {
@@ -19,3 +20,17 @@ export function pickLinkProps(link: LinkProps & { [key: string]: any }) {
1920

2021
return reactivePick(link, ...propsToInclude)
2122
}
23+
24+
export function isPartiallyEqual(item1: any, item2: any) {
25+
const diffedKeys = diff(item1, item2).reduce((filtered, q) => {
26+
if (q.type === 'added') {
27+
filtered.add(q.key)
28+
}
29+
return filtered
30+
}, new Set<string>())
31+
32+
const item1Filtered = Object.fromEntries(Object.entries(item1).filter(([key]) => !diffedKeys.has(key)))
33+
const item2Filtered = Object.fromEntries(Object.entries(item2).filter(([key]) => !diffedKeys.has(key)))
34+
35+
return isEqual(item1Filtered, item2Filtered)
36+
}

0 commit comments

Comments
 (0)