Skip to content

Commit

Permalink
feat: handle active/exact in Link
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Feb 28, 2020
1 parent 2541b98 commit 6f49dce
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 78 deletions.
29 changes: 22 additions & 7 deletions __tests__/RouterLink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import {
import { createMemoryHistory } from '../src'
import { mount, tick } from './mount'
import { ref, markNonReactive } from 'vue'
import { RouteRecordNormalized } from '../src/matcher/types'

const records = {
home: {} as RouteRecordNormalized,
foo: {} as RouteRecordNormalized,
}

const locations: Record<
string,
Expand All @@ -30,7 +36,7 @@ const locations: Record<
meta: {},
query: {},
hash: '',
matched: [],
matched: [records.home],
redirectedFrom: undefined,
name: undefined,
},
Expand All @@ -45,7 +51,7 @@ const locations: Record<
meta: {},
query: {},
hash: '',
matched: [],
matched: [records.foo],
redirectedFrom: undefined,
name: undefined,
},
Expand All @@ -60,7 +66,7 @@ const locations: Record<
meta: {},
query: { foo: 'a', bar: 'b' },
hash: '',
matched: [],
matched: [records.home],
redirectedFrom: undefined,
name: undefined,
},
Expand Down Expand Up @@ -102,7 +108,7 @@ describe('RouterLink', () => {
{ to: locations.basic.string },
locations.basic.normalized
)
expect(el.innerHTML).toBe('<a class="" href="/home">a link</a>')
expect(el.querySelector('a')!.getAttribute('href')).toBe('/home')
})

// TODO: not sure why this breaks. We could take a look at @vue/test-runtime
Expand All @@ -126,7 +132,7 @@ describe('RouterLink', () => {
{ to: { path: locations.basic.string } },
locations.basic.normalized
)
expect(el.innerHTML).toBe('<a class="" href="/home">a link</a>')
expect(el.querySelector('a')!.getAttribute('href')).toBe('/home')
})

it('can be active', () => {
Expand All @@ -135,8 +141,17 @@ describe('RouterLink', () => {
{ to: locations.basic.string },
locations.basic.normalized
)
expect(el.innerHTML).toBe(
'<a class="router-link-active" href="/home">a link</a>'
expect(el.querySelector('a')!.className).toContain('router-link-active')
})

it('can be exact-active', () => {
const { el } = factory(
locations.basic.normalized,
{ to: locations.basic.string },
locations.basic.normalized
)
expect(el.querySelector('a')!.className).toContain(
'router-link-exact-active'
)
})

Expand Down
2 changes: 1 addition & 1 deletion __tests__/__snapshots__/RouterLink.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`RouterLink v-slot provides information on v-slot 1`] = `"<a class=\\"router-link-active\\" href=\\"/home\\"> route: {\\"fullPath\\":\\"/home\\",\\"path\\":\\"/home\\",\\"params\\":{},\\"meta\\":{},\\"query\\":{},\\"hash\\":\\"\\",\\"matched\\":[]} href: \\"/home\\" isActive: \\"true\\" </a>"`;
exports[`RouterLink v-slot provides information on v-slot 1`] = `"<a class=\\"router-link-active router-link-exact-active\\" href=\\"/home\\"> route: {\\"fullPath\\":\\"/home\\",\\"path\\":\\"/home\\",\\"params\\":{},\\"meta\\":{},\\"query\\":{},\\"hash\\":\\"\\",\\"matched\\":[{}]} href: \\"/home\\" isActive: \\"true\\" </a>"`;
13 changes: 11 additions & 2 deletions playground/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,16 @@
<a href="/documents/€">/documents/€ (force reload): not valid tho</a>
</li>
<li>
<router-link to="/">Home</router-link>
<router-link to="/">Home (redirects)</router-link>
</li>
<li>
<router-link to="/home">Home</router-link>
</li>
<li>
<router-link to="/nested">/nested</router-link>
</li>
<li>
<router-link to="/nested/nested">/nested/nested</router-link>
</li>
<li>
<router-link to="/nested/nested/nested"
Expand All @@ -78,7 +87,7 @@
<router-link
:to="{
name: 'user',
params: { id: Number(currentLocation.params.id || 0) + 1 },
params: { id: '' + (Number(currentLocation.params.id || 0) + 1) },
}"
>/users/{{ Number(currentLocation.params.id || 0) + 1 }}</router-link
>
Expand Down
9 changes: 8 additions & 1 deletion playground/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,15 @@ export const router = createRouter({
children: [
{
path: 'nested',
name: 'NestedNested',
component: Nested,
children: [{ path: 'nested', component: Nested }],
children: [
{
name: 'NestedNestedNested',
path: 'nested',
component: Nested,
},
],
},
],
},
Expand Down
77 changes: 66 additions & 11 deletions src/components/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,83 @@ import {
inject,
computed,
reactive,
isRef,
Ref,
unref,
} from 'vue'
import { RouteLocation } from '../types'
import { RouteLocation, RouteLocationNormalized, Immutable } from '../types'
import { isSameLocationObject } from '../utils'
import { routerKey } from '../injectKeys'
import { RouteRecordNormalized } from '../matcher/types'

interface UseLinkProps {
to: Ref<RouteLocation> | RouteLocation
replace?: Ref<boolean> | boolean
type VueUseOptions<T> = {
[k in keyof T]: Ref<T[k]> | T[k]
}

interface LinkProps {
to: RouteLocation
// TODO: refactor using extra options allowed in router.push
replace?: boolean
}

type UseLinkOptions = VueUseOptions<LinkProps>

function isSameRouteRecord(
a: Immutable<RouteRecordNormalized>,
b: Immutable<RouteRecordNormalized>
): boolean {
// TODO: handle aliases
return a === b
}

function includesParams(
outter: Immutable<RouteLocationNormalized['params']>,
inner: Immutable<RouteLocationNormalized['params']>
): boolean {
for (let key in inner) {
let innerValue = inner[key]
let outterValue = outter[key]
if (typeof innerValue === 'string') {
if (innerValue !== outterValue) return false
} else {
if (
!Array.isArray(outterValue) ||
innerValue.some((value, i) => value !== outterValue[i])
)
return false
}
}

return true
}

// TODO: what should be accepted as arguments?
export function useLink(props: UseLinkProps) {
export function useLink(props: UseLinkOptions) {
const router = inject(routerKey)!

const route = computed(() =>
router.resolve(isRef(props.to) ? props.to.value : props.to)
)
const route = computed(() => router.resolve(unref(props.to)))
const href = computed(() => router.createHref(route.value))

const activeRecordIndex = computed<number>(() => {
const currentMatched = route.value.matched[route.value.matched.length - 1]
return router.currentRoute.value.matched.findIndex(
isSameRouteRecord.bind(null, currentMatched)
)
})

const isActive = computed<boolean>(
() => router.currentRoute.value.path.indexOf(route.value.path) === 0
() =>
activeRecordIndex.value > -1 &&
includesParams(router.currentRoute.value.params, route.value.params)
)
const isExactActive = computed<boolean>(
() =>
activeRecordIndex.value ===
router.currentRoute.value.matched.length - 1 &&
isSameLocationObject(router.currentRoute.value.params, route.value.params)
)

// TODO: handle replace prop
// const method = unref(rep)

function navigate(e: MouseEvent = {} as MouseEvent) {
// TODO: handle navigate with empty parameters for scoped slot and composition api
Expand All @@ -39,6 +92,7 @@ export function useLink(props: UseLinkProps) {
route,
href,
isActive,
isExactActive,
navigate,
}
}
Expand All @@ -53,10 +107,11 @@ export const Link = defineComponent({
},

setup(props, { slots, attrs }) {
const { route, isActive, href, navigate } = useLink(props)
const { route, isActive, isExactActive, href, navigate } = useLink(props)

const elClass = computed(() => ({
'router-link-active': isActive.value,
'router-link-exact-active': isExactActive.value,
}))

// TODO: exact active classes
Expand Down
72 changes: 19 additions & 53 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Lazy,
TODO,
Immutable,
MatcherLocationNormalized,
} from './types'
import { RouterHistory, parseURL, stringifyURL } from './history/common'
import {
Expand All @@ -25,16 +26,12 @@ import {
import {
extractComponentsGuards,
guardToPromiseFn,
isSameLocationObject,
applyToParams,
} from './utils'
import { useCallbacks } from './utils/callbacks'
import { encodeParam, decode } from './utils/encoding'
import {
normalizeQuery,
parseQuery,
stringifyQuery,
LocationQueryValue,
} from './utils/query'
import { normalizeQuery, parseQuery, stringifyQuery } from './utils/query'
import { ref, Ref, markNonReactive, nextTick, App, warn } from 'vue'
import { RouteRecordNormalized } from './matcher/types'
import { Link } from './components/Link'
Expand Down Expand Up @@ -158,23 +155,21 @@ export function createRouter({
}
}

const hasParams = 'params' in location

// relative or named location, path is ignored
// for same reason TS thinks location.params can be undefined
let matchedRoute = matcher.resolve(
hasParams
? // we know we have the params attribute
{ ...location, params: encodeParams((location as any).params) }
: location,
currentLocation
)
let matchedRoute: MatcherLocationNormalized = // relative or named location, path is ignored
// for same reason TS thinks location.params can be undefined
matcher.resolve(
'params' in location
? { ...location, params: encodeParams(location.params) }
: location,
currentLocation
)

// put back the unencoded params as given by the user (avoid the cost of decoding them)
matchedRoute.params = hasParams
? // we know we have the params attribute
(location as any).params!
: decodeParams(matchedRoute.params)
// TODO: normalize params if we accept numbers as raw values
matchedRoute.params =
'params' in location
? location.params!
: decodeParams(matchedRoute.params)

return {
fullPath: stringifyURL(stringifyQuery, {
Expand Down Expand Up @@ -541,42 +536,13 @@ function extractChangingRecords(
}

function isSameLocation(
a: RouteLocationNormalized,
b: RouteLocationNormalized
a: Immutable<RouteLocationNormalized>,
b: Immutable<RouteLocationNormalized>
): boolean {
return (
a.name === b.name &&
a.path === b.path &&
a.hash === b.hash &&
isSameLocationQuery(a.query, b.query)
isSameLocationObject(a.query, b.query)
)
}

function isSameLocationQuery(
a: RouteLocationNormalized['query'],
b: RouteLocationNormalized['query']
): boolean {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) return false
let i = 0
let key: string
while (i < aKeys.length) {
key = aKeys[i]
if (key !== bKeys[i]) return false
if (!isSameLocationQueryValue(a[key], b[key])) return false
i++
}

return true
}

function isSameLocationQueryValue(
a: LocationQueryValue | LocationQueryValue[],
b: LocationQueryValue | LocationQueryValue[]
): boolean {
if (typeof a !== typeof b) return false
if (Array.isArray(a))
return a.every((value, i) => value === (b as LocationQueryValue[])[i])
return a === b
}
2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type TODO = any

export type ListenerRemover = () => void

type RouteParamValue = string
export type RouteParamValue = string
// TODO: should we allow more values like numbers and normalize them to strings?
// type RouteParamValueRaw = RouteParamValue | number
export type RouteParams = Record<string, RouteParamValue | RouteParamValue[]>
Expand Down
Loading

0 comments on commit 6f49dce

Please sign in to comment.