-import History from '../src/history/html5'
+import { HTML5History } from '../src/history/html5'
import { JSDOM } from 'jsdom'
describe('History HTMl5', () => {
})
it('can be instantiated', () => {
- const history = new History()
+ const history = new HTML5History()
expect(history.location).toBe('/')
})
})
--- /dev/null
+import { HTML5History } from '../src/history/html5'
+import { Router } from '../src/router'
+import { JSDOM } from 'jsdom'
+
+describe('Router', () => {
+ beforeAll(() => {
+ // TODO: move to utils for tests that need DOM
+ const dom = new JSDOM(
+ `<!DOCTYPE html><html><head></head><body></body></html>`,
+ {
+ url: 'https://example.org/',
+ referrer: 'https://example.com/',
+ contentType: 'text/html',
+ }
+ )
+
+ // @ts-ignore
+ global.window = dom.window
+ })
+
+ it('can be instantiated', () => {
+ const history = new HTML5History()
+ const router = new Router({ history, routes: [] })
+ expect(router.currentRoute).toEqual({
+ fullPath: '/',
+ hash: '',
+ params: {},
+ path: '/',
+ query: {},
+ })
+ })
+})
-import HTML5History from '../src/history/html5'
-import { Router } from '../src'
+import { Router, HTML5History } from '../src'
const component = null
"license": "MIT",
"scripts": {
"test:unit": "jest --coverage",
+ "test:unit:watch": "yarn run test:unit --watchAll",
"dev": "webpack-dev-server --mode=development"
},
"devDependencies": {
--- /dev/null
+import consola from 'consola'
+import { BaseHistory } from './base'
+import {
+ HistoryLocation,
+ NavigationCallback,
+ HistoryState,
+ NavigationType,
+ HistoryURL,
+} from './base'
+
+const cs = consola.withTag('html5')
+
+type PopStateListener = (this: Window, ev: PopStateEvent) => any
+
+export class AbstractHistory extends BaseHistory {
+ private history = window.history
+ private _popStateListeners: PopStateListener[] = []
+ private _listeners: NavigationCallback[] = []
+ private _teardowns: Array<() => void> = []
+
+ constructor() {
+ super()
+ }
+
+ // TODO: is this necessary
+ ensureLocation() {
+ const to = buildFullPath()
+ cs.log('ensureLocation', to)
+ this.history.replaceState(
+ {
+ _back: null,
+ _current: to,
+ _forward: null,
+ },
+ '',
+ to
+ )
+ this.location = to
+ }
+
+ replace(to: HistoryLocation) {
+ if (to === this.location) return
+ cs.info('replace', this.location, to)
+ this.history.replaceState(
+ {
+ // TODO: this should be user's responsibility
+ // _replacedState: this.history.state || null,
+ _back: this.location,
+ _current: to,
+ _forward: null,
+ _replaced: true,
+ },
+ '',
+ to
+ )
+ this.location = to
+ }
+
+ push(to: HistoryLocation, data?: HistoryState) {
+ // replace current entry state to add the forward value
+ this.history.replaceState(
+ {
+ ...this.history.state,
+ _forward: to,
+ },
+ ''
+ )
+ // TODO: compare current location to prevent navigation
+ // NEW NOTE: I think it shouldn't be history responsibility to check that
+ // if (to === this.location) return
+ const state = {
+ _back: this.location,
+ _current: to,
+ _forward: null,
+ ...data,
+ }
+ cs.info('push', this.location, '->', to, 'with state', state)
+ this.history.pushState(state, '', to)
+ this.location = to
+ }
+
+ listen(callback: NavigationCallback) {
+ // state is the same as history.state
+ const handler: PopStateListener = ({ state }) => {
+ cs.log(this)
+ cs.info('popstate fired', {
+ state,
+ location: this.location,
+ })
+ const from = this.location
+ // we have the state from the old entry, not the current one being removed
+ // TODO: correctly parse pathname
+ this.location = state ? state._current : buildFullPath
+ callback(this.location, from, {
+ type:
+ from === state._forward
+ ? NavigationType.back
+ : NavigationType.forward,
+ })
+ }
+
+ // settup the listener and prepare teardown callbacks
+ this._popStateListeners.push(handler)
+ this._listeners.push(callback)
+ window.addEventListener('popstate', handler)
+
+ const teardown = () => {
+ this._popStateListeners.splice(
+ this._popStateListeners.indexOf(handler),
+ 1
+ )
+ this._listeners.splice(this._listeners.indexOf(callback), 1)
+ window.removeEventListener('popstate', handler)
+ }
+
+ this._teardowns.push(teardown)
+ return teardown
+ }
+
+ parseURL(location: string): HistoryURL {
+ let path = '',
+ search: HistoryURL['search'] = {},
+ searchString = '',
+ hash = ''
+
+ // Could use URL and URLSearchParams but IE 11 doesn't support it
+ const searchPos = location.indexOf('?')
+ const hashPos = location.indexOf(location, searchPos > -1 ? searchPos : 0)
+ if (searchPos > -1) {
+ path = location.slice(0, searchPos)
+ searchString = location.slice(
+ searchPos + 1,
+ hashPos > -1 ? hashPos : location.length - 1
+ )
+
+ // TODO: properly do this in a util function
+ search = searchString.split('&').reduce((search, entry) => {
+ const [key, value] = entry.split('=')
+ search[key] = value
+ return search
+ }, search)
+ }
+
+ if (hashPos > -1) {
+ path = path || location.slice(0, hashPos)
+ hash = location.slice(hashPos, location.length - 1)
+ }
+
+ path = path || location
+
+ return {
+ path,
+ // TODO: transform searchString
+ search,
+ hash,
+ }
+ }
+
+ destroy() {
+ for (const teardown of this._teardowns) teardown()
+ this._teardowns = []
+ }
+}
+
+const buildFullPath = () =>
+ window.location.pathname + window.location.search + window.location.hash
export type RemoveListener = () => void
-export default abstract class BaseHistory {
+export abstract class BaseHistory {
// previousState: object
location: HistoryLocation = START
base: string = ''
import consola from 'consola'
-import BaseHistory from './base'
+import { BaseHistory } from './base'
import {
HistoryLocation,
NavigationCallback,
type PopStateListener = (this: Window, ev: PopStateEvent) => any
-export default class HTML5History extends BaseHistory {
+export class HTML5History extends BaseHistory {
private history = window.history
private _popStateListeners: PopStateListener[] = []
private _listeners: NavigationCallback[] = []
-import BaseHistory from './history/base'
-import { RouterMatcher } from './matcher'
-import {
- RouterLocation,
- RouteRecord,
- START_LOCATION_NORMALIZED,
- RouterLocationNormalized,
-} from './types/index'
+import { Router } from './router'
+import { HTML5History } from './history/html5'
-interface RouterOptions {
- history: BaseHistory
- routes: RouteRecord[]
-}
-
-export class Router {
- protected history: BaseHistory
- private matcher: RouterMatcher
- currentRoute: RouterLocationNormalized = START_LOCATION_NORMALIZED
-
- constructor(options: RouterOptions) {
- this.history = options.history
- this.history.ensureLocation()
-
- this.matcher = new RouterMatcher(options.routes)
- }
-
- /**
- * Trigger a navigation, should resolve all guards first
- * @param to Where to go
- */
- push(to: RouterLocation) {
- // TODO: resolve URL
- const location = this.matcher.resolve(to, this.currentRoute)
- // TODO: call hooks, guards
- this.history.push(location.fullPath)
- this.currentRoute = location
- }
-
- getRouteRecord(location: RouterLocation) {}
-}
+export { Router, HTML5History }
--- /dev/null
+import { BaseHistory } from './history/base'
+import { RouterMatcher } from './matcher'
+import {
+ RouterLocation,
+ RouteRecord,
+ START_LOCATION_NORMALIZED,
+ RouterLocationNormalized,
+} from './types/index'
+
+interface RouterOptions {
+ history: BaseHistory
+ routes: RouteRecord[]
+}
+
+export class Router {
+ protected history: BaseHistory
+ private matcher: RouterMatcher
+ currentRoute: RouterLocationNormalized = START_LOCATION_NORMALIZED
+
+ constructor(options: RouterOptions) {
+ this.history = options.history
+ this.history.ensureLocation()
+
+ this.matcher = new RouterMatcher(options.routes)
+ }
+
+ /**
+ * Trigger a navigation, should resolve all guards first
+ * @param to Where to go
+ */
+ push(to: RouterLocation) {
+ // TODO: resolve URL
+ const location = this.matcher.resolve(to, this.currentRoute)
+ // TODO: call hooks, guards
+ this.history.push(location.fullPath)
+ this.currentRoute = location
+ }
+
+ getRouteRecord(location: RouterLocation) {}
+}