From: Eduardo San Martin Morote Date: Fri, 28 Feb 2020 14:22:41 +0000 (+0100) Subject: feat: handle active/exact in Link X-Git-Tag: v4.0.0-alpha.2~29 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6f49dcea35a63785ae08d08787913ab8391cae67;p=thirdparty%2Fvuejs%2Frouter.git feat: handle active/exact in Link --- diff --git a/__tests__/RouterLink.spec.ts b/__tests__/RouterLink.spec.ts index 0e3aacbc..79e8637a 100644 --- a/__tests__/RouterLink.spec.ts +++ b/__tests__/RouterLink.spec.ts @@ -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 link') + 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 link') + 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 link' + 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' ) }) diff --git a/__tests__/__snapshots__/RouterLink.spec.ts.snap b/__tests__/__snapshots__/RouterLink.spec.ts.snap index 61bd8ed9..6de7f7e2 100644 --- a/__tests__/__snapshots__/RouterLink.spec.ts.snap +++ b/__tests__/__snapshots__/RouterLink.spec.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RouterLink v-slot provides information on v-slot 1`] = `" route: {\\"fullPath\\":\\"/home\\",\\"path\\":\\"/home\\",\\"params\\":{},\\"meta\\":{},\\"query\\":{},\\"hash\\":\\"\\",\\"matched\\":[]} href: \\"/home\\" isActive: \\"true\\" "`; +exports[`RouterLink v-slot provides information on v-slot 1`] = `" route: {\\"fullPath\\":\\"/home\\",\\"path\\":\\"/home\\",\\"params\\":{},\\"meta\\":{},\\"query\\":{},\\"hash\\":\\"\\",\\"matched\\":[{}]} href: \\"/home\\" isActive: \\"true\\" "`; diff --git a/playground/App.vue b/playground/App.vue index 99b8e242..a09d5990 100644 --- a/playground/App.vue +++ b/playground/App.vue @@ -61,7 +61,16 @@ /documents/€ (force reload): not valid tho
  • - Home + Home (redirects) +
  • +
  • + Home +
  • +
  • + /nested +
  • +
  • + /nested/nested
  • /users/{{ Number(currentLocation.params.id || 0) + 1 }} diff --git a/playground/router.ts b/playground/router.ts index 78a9e082..ee30c595 100644 --- a/playground/router.ts +++ b/playground/router.ts @@ -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, + }, + ], }, ], }, diff --git a/src/components/Link.ts b/src/components/Link.ts index f22670e7..e4b9a88a 100644 --- a/src/components/Link.ts +++ b/src/components/Link.ts @@ -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 - replace?: Ref | boolean +type VueUseOptions = { + [k in keyof T]: Ref | T[k] +} + +interface LinkProps { + to: RouteLocation + // TODO: refactor using extra options allowed in router.push + replace?: boolean +} + +type UseLinkOptions = VueUseOptions + +function isSameRouteRecord( + a: Immutable, + b: Immutable +): boolean { + // TODO: handle aliases + return a === b +} + +function includesParams( + outter: Immutable, + inner: Immutable +): 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(() => { + const currentMatched = route.value.matched[route.value.matched.length - 1] + return router.currentRoute.value.matched.findIndex( + isSameRouteRecord.bind(null, currentMatched) + ) + }) + const isActive = computed( - () => router.currentRoute.value.path.indexOf(route.value.path) === 0 + () => + activeRecordIndex.value > -1 && + includesParams(router.currentRoute.value.params, route.value.params) + ) + const isExactActive = computed( + () => + 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 diff --git a/src/router.ts b/src/router.ts index e7451b6c..b41a46c5 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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, + b: Immutable ): 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 -} diff --git a/src/types/index.ts b/src/types/index.ts index e231633a..4c46b888 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 diff --git a/src/utils/index.ts b/src/utils/index.ts index da97587f..342cd692 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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, + b: Immutable +): boolean +export function isSameLocationObject( + a: Immutable, + b: Immutable +): boolean +export function isSameLocationObject( + a: Immutable, + b: Immutable +): 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, + b: Immutable +): boolean +function isSameLocationObjectValue( + a: Immutable, + b: Immutable +): 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 +}