]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: handle active/exact in Link
authorEduardo San Martin Morote <posva13@gmail.com>
Fri, 28 Feb 2020 14:22:41 +0000 (15:22 +0100)
committerEduardo San Martin Morote <posva13@gmail.com>
Fri, 28 Feb 2020 14:22:41 +0000 (15:22 +0100)
__tests__/RouterLink.spec.ts
__tests__/__snapshots__/RouterLink.spec.ts.snap
playground/App.vue
playground/router.ts
src/components/Link.ts
src/router.ts
src/types/index.ts
src/utils/index.ts

index 0e3aacbcf2c29bd48d448b594be558aea1bfe1fc..79e8637ac50fdda77b9617bfdc54a5f19970b66e 100644 (file)
@@ -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,
@@ -30,7 +36,7 @@ const locations: Record<
       meta: {},
       query: {},
       hash: '',
-      matched: [],
+      matched: [records.home],
       redirectedFrom: undefined,
       name: undefined,
     },
@@ -45,7 +51,7 @@ const locations: Record<
       meta: {},
       query: {},
       hash: '',
-      matched: [],
+      matched: [records.foo],
       redirectedFrom: undefined,
       name: undefined,
     },
@@ -60,7 +66,7 @@ const locations: Record<
       meta: {},
       query: { foo: 'a', bar: 'b' },
       hash: '',
-      matched: [],
+      matched: [records.home],
       redirectedFrom: undefined,
       name: undefined,
     },
@@ -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
@@ -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', () => {
@@ -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'
     )
   })
 
index 61bd8ed95d19f021e42504bbf5f4a132a102fd3e..6de7f7e233da4a6d2f449cf78b6ae5c554fc79c8 100644 (file)
@@ -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>"`;
index 99b8e242aa55258c77aab9e0fa75df5fec40cb75..a09d59907fcfe00eef033f5d3e4679cbfcaa2bc4 100644 (file)
         <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"
@@ -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
         >
index 78a9e0821c13c69ffa65f6fb827bd05f9c3141e6..ee30c59524cb1b5ac2a6453221586a26354815b5 100644 (file)
@@ -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,
+            },
+          ],
         },
       ],
     },
index f22670e7c45e5cce762e3e8048fdb3b50b2a1de1..e4b9a88a263741140c0d9e2d36d9746983dd4f2e 100644 (file)
@@ -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
@@ -39,6 +92,7 @@ export function useLink(props: UseLinkProps) {
     route,
     href,
     isActive,
+    isExactActive,
     navigate,
   }
 }
@@ -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
index e7451b6c2c8f1deecf6dd5c81cd1db796232bb22..b41a46c5e70d538ef8591a63d59ef0ff57c8c16b 100644 (file)
@@ -9,6 +9,7 @@ import {
   Lazy,
   TODO,
   Immutable,
+  MatcherLocationNormalized,
 } from './types'
 import { RouterHistory, parseURL, stringifyURL } from './history/common'
 import {
@@ -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'
@@ -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, {
@@ -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
-}
index e231633a6d741b48c9735571bbdb20d43d21dfe7..4c46b8881d8cf8a10e35265e1a0748151168810a 100644 (file)
@@ -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[]>
index da97587f2bbe0eada4558723708f4e56b7eaf98a..342cd69248b4f391689d1fb5a1e992dd0552f0aa 100644 (file)
@@ -1,6 +1,7 @@
-import { RouteLocationNormalized, RouteParams } from '../types'
+import { RouteLocationNormalized, RouteParams, Immutable } from '../types'
 import { guardToPromiseFn } from './guardToPromiseFn'
 import { RouteRecordNormalized } from '../matcher/types'
+import { LocationQueryValue } from './query'
 
 export * from './guardToPromiseFn'
 
@@ -38,7 +39,7 @@ export async function extractComponentsGuards(
 
 export function applyToParams(
   fn: (v: string) => string,
-  params: RouteParams
+  params: RouteParams | undefined
 ): RouteParams {
   const newParams: RouteParams = {}
 
@@ -49,3 +50,53 @@ export function applyToParams(
 
   return newParams
 }
+
+export function isSameLocationObject(
+  a: Immutable<RouteLocationNormalized['query']>,
+  b: Immutable<RouteLocationNormalized['query']>
+): boolean
+export function isSameLocationObject(
+  a: Immutable<RouteLocationNormalized['params']>,
+  b: Immutable<RouteLocationNormalized['params']>
+): boolean
+export function isSameLocationObject(
+  a: Immutable<RouteLocationNormalized['query' | 'params']>,
+  b: Immutable<RouteLocationNormalized['query' | 'params']>
+): 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 (!isSameLocationObjectValue(a[key], b[key])) return false
+    i++
+  }
+
+  return true
+}
+
+function isSameLocationObjectValue(
+  a: Immutable<LocationQueryValue | LocationQueryValue[]>,
+  b: Immutable<LocationQueryValue | LocationQueryValue[]>
+): boolean
+function isSameLocationObjectValue(
+  a: Immutable<RouteParams | RouteParams[]>,
+  b: Immutable<RouteParams | RouteParams[]>
+): boolean
+function isSameLocationObjectValue(
+  a: Immutable<
+    LocationQueryValue | LocationQueryValue[] | RouteParams | RouteParams[]
+  >,
+  b: Immutable<
+    LocationQueryValue | LocationQueryValue[] | RouteParams | RouteParams[]
+  >
+): boolean {
+  if (typeof a !== typeof b) return false
+  // both a and b are arrays
+  if (Array.isArray(a))
+    return a.every((value, i) => value === (b as LocationQueryValue[])[i])
+  return a === b
+}