UNPKG

@v4fire/client

Version:

V4Fire client core library

915 lines (772 loc) • 23.2 kB
/*! * V4Fire Client Core * https://github.com/V4Fire/Client * * Released under the MIT license * https://github.com/V4Fire/Client/blob/master/LICENSE */ /** * [[include:base/b-router/README.md]] * @packageDocumentation */ import symbolGenerator from 'core/symbol'; import { deprecated } from 'core/functools/deprecation'; import globalRoutes from 'routes'; import type Async from 'core/async'; import iData, { component, prop, system, computed, hook, wait, watch, UnsafeGetter } from 'super/i-data/i-data'; import engine, * as router from 'core/router'; import { fillRouteParams } from 'base/b-router/modules/normalizers'; import { urlsToIgnore } from 'base/b-router/modules/const'; import { HrefTransitionEvent } from 'base/b-router/modules/transition/event'; import type { StaticRoutes, RouteOption, TransitionMethod, UnsafeBRouter } from 'base/b-router/interface'; export * from 'super/i-data/i-data'; export * from 'core/router/const'; export * from 'base/b-router/interface'; export * from 'base/b-router/modules/transition/event'; export const $$ = symbolGenerator(); /** * Component to route application pages */ @component({ deprecatedProps: { pageProp: 'activeRoute', pagesProp: 'routesProp' } }) export default class bRouter extends iData { /** * Type: page parameters */ readonly PageParams!: RouteOption; /** * Type: page query */ readonly PageQuery!: RouteOption; /** * Type: page meta */ readonly PageMeta!: RouteOption; public override async!: Async<this>; /** * The static schema of application routes. * By default, this value is taken from `routes/index.ts`. * * @example * ``` * < b-router :routes = { & * main: { * path: '/' * }, * * notFound: { * default: true * } * } . * ``` */ @prop<bRouter>({ type: Object, required: false, watch: (ctx, val, old) => { if (!Object.fastCompare(val, old)) { ctx.updateCurrentRoute(); } } }) readonly routesProp?: StaticRoutes; /** * Compiled schema of application routes * @see [[bRouter.routesProp]] */ @system<bRouter>({ after: 'engine', init: (o) => o.sync.link(o.compileStaticRoutes) }) routes!: router.RouteBlueprints; /** * An initial route value. * Usually, you don't need to provide this value manually, * because it is inferring automatically, but sometimes it can be useful. * * @example * ``` * < b-router :initialRoute = 'main' | :routes = { & * main: { * path: '/' * }, * * notFound: { * default: true * } * } . * ``` */ @prop<bRouter>({ type: [String, Object], required: false, watch: 'updateCurrentRoute' }) readonly initialRoute?: router.InitialRoute; /** * Base route path: all route paths are concatenated with this path * * @example * ``` * < b-router :basePath = '/demo' | :routes = { & * user: { * /// '/demo/user' * path: '/user' * } * } . * ``` */ @prop({watch: 'updateCurrentRoute'}) readonly basePathProp: string = '/'; /** @see [[bRouter.basePathProp]] */ @system<bRouter>((o) => o.sync.link()) basePath!: string; /** * If true, the router will intercept all click events on elements with a `href` attribute to emit a transition. * An element with `href` can have additional attributes: * * * `data-router-method` - type of the used router method to emit the transition; * * `data-router-go` - value for the router `go` method; * * `data-router-params`, `data-router-query`, `data-router-meta` - additional parameters for the used router method * (to provide an object use JSON). */ @prop(Boolean) readonly interceptLinks: boolean = true; /** * A factory to create router engine. * By default, this value is taken from `core/router/engines`. * * @example * ``` * < b-router :engine = myCustomEngine * ``` */ @prop<bRouter>({ type: Function, watch: 'updateCurrentRoute', default: engine }) readonly engineProp!: () => router.Router; /** * An internal router engine. * For example, it can be the HTML5 history router or a router based on URL hash values. * * @see [[bRouter.engine]] */ @system((o) => o.sync.link((v) => (<(v: unknown) => router.Router>v)(o))) protected engine!: router.Router; /** * Raw value of the active route */ @system() protected routeStore?: router.Route; override get unsafe(): UnsafeGetter<UnsafeBRouter<this>> { return Object.cast(this); } /** * Value of the active route * @see [[bRouter.routeStore]] * * @example * ```js * console.log(route?.query) * ``` */ override get route(): CanUndef<this['r']['CurrentPage']> { return this.field.get('routeStore'); } /** * @deprecated * @see [[bRouter.route]] */ @deprecated({renamedTo: 'route'}) get page(): CanUndef<this['r']['CurrentPage']> { return this.route; } /** * Default route value * * @example * ``` * < b-router :initialRoute = 'main' | :routes = { & * main: { * path: '/' * }, * * notFound: { * default: true * } * } . * ``` * * ```js * router.defaultRoute.name === 'notFound' * ``` */ @computed({cache: true, dependencies: ['routes']}) get defaultRoute(): CanUndef<router.RouteBlueprint> { let route; for (let keys = Object.keys(this.routes), i = 0; i < keys.length; i++) { const el = this.routes[keys[i]]; if (el?.meta.default) { route = el; break; } } return route; } /** * Pushes a new route to the history stack. * The method returns a promise that is resolved when the transition will be completed. * * @param route - route name or URL * @param [opts] - additional options * * @example * ```js * router.push('main', {query: {foo: 1}}); * router.push('/user/:id', {params: {id: 1}}); * router.push('https://google.com'); * ``` */ async push(route: Nullable<string>, opts?: router.TransitionOptions): Promise<void> { await this.emitTransition(route, opts, 'push'); } /** * Replaces the current route. * The method returns a promise that will be resolved when the transition is completed. * * @param route - route name or URL * @param [opts] - additional options * * @example * ```js * router.replace('main', {query: {foo: 1}}); * router.replace('/user/:id', {params: {id: 1}}); * router.replace('https://google.com'); * ``` */ async replace(route: Nullable<string>, opts?: router.TransitionOptions): Promise<void> { await this.emitTransition(route, opts, 'replace'); } /** * Switches to a route from the history, * identified by its relative position to the current route (with the current route being relative index 0). * The method returns a promise that will be resolved when the transition is completed. * * @param pos * * @example * ````js * this.go(-1) // this.back(); * this.go(1) // this.forward(); * this.go(-2) // this.back(); this.back(); * ``` */ async go(pos: number): Promise<void> { const res = this.promisifyOnce('transition'); this.engine.go(pos); await res; } /** * Switches to the next route from the history. * The method returns a promise that will be resolved when the transition is completed. */ async forward(): Promise<void> { const res = this.promisifyOnce('transition'); this.engine.forward(); await res; } /** * Switches to the previous route from the history. * The method returns a promise that will be resolved when the transition is completed. */ async back(): Promise<void> { const res = this.promisifyOnce('transition'); this.engine.back(); await res; } /** * Clears the routes' history. * Mind, this method can't work properly with `HistoryAPI` based engines. * * @param [filter] - filter predicate */ clear(filter?: router.HistoryClearFilter): Promise<void> { return this.engine.clear(filter); } /** * Clears all temporary routes from the history. * The temporary route is a route that has `tmp` flag within its own properties, like, `params`, `query` or `meta`. * Mind, this method can't work properly with `HistoryAPI` based engines. * * @example * ```js * this.push('redeem-money', { * meta: { * tmp: true * } * }); * * this.clearTmp(); * ``` */ clearTmp(): Promise<void> { return this.engine.clearTmp(); } /** @see [[router.getRoutePath]] */ getRoutePath(ref: string, opts: router.TransitionOptions = {}): CanUndef<string> { return router.getRoutePath(ref, this.routes, opts); } /** @see [[router.getRoute]] */ getRoute(ref: string): CanUndef<router.RouteAPI> { const {routes, basePath, defaultRoute} = this; return router.getRoute(ref, routes, {basePath, defaultRoute}); } /** * @deprecated * @see [[bRouter.getRoute]] */ @deprecated({renamedTo: 'getRoute'}) getPageOpts(ref: string): CanUndef<router.RouteBlueprint> { return this.getRoute(ref); } /** * Emits a new transition to the specified route * * @param ref - route name or URL or `null`, if the route is equal to the previous * @param [opts] - additional transition options * @param [method] - transition method * * @emits `beforeChange(route: Nullable<string>, params:` [[TransitionOptions]]`, method:` [[TransitionMethod]]`)` * * @emits `change(route:` [[Route]]`)` * @emits `hardChange(route:` [[Route]]`)` * @emits `softChange(route:` [[Route]]`)` * * @emits `transition(route:` [[Route]]`, type:` [[TransitionType]]`)` * @emits `$root.transition(route:` [[Route]]`, type:` [[TransitionType]]`)` */ async emitTransition( ref: Nullable<string>, opts?: router.TransitionOptions, method: TransitionMethod = 'push' ): Promise<CanUndef<router.Route>> { opts = router.getBlankRouteFrom(router.normalizeTransitionOpts(opts)); const {r, engine} = this, originRef = ref; const currentEngineRoute = engine.route ?? engine.page; this.emit('beforeChange', ref, opts, method); let newRouteInfo: CanUndef<router.RouteAPI>; const getEngineRoute = () => currentEngineRoute ? currentEngineRoute.url ?? router.getRouteName(currentEngineRoute) : undefined; // Get information about the specified route if (ref != null) { newRouteInfo = this.getRoute(engine.id(ref)); // In this case, we don't have the specified ref to a transition, // so we try to get information from the current route and use it as a blueprint to the new } else if (currentEngineRoute) { ref = getEngineRoute()!; const route = this.getRoute(ref); if (route) { newRouteInfo = Object.mixin(true, route, router.purifyRoute(currentEngineRoute)); } } const scroll = { meta: { scroll: { x: pageXOffset, y: pageYOffset } } }; // To save scroll position before change to a new route // we need to emit system "replace" transition with padding information about the scroll if (currentEngineRoute && method !== 'replace') { const currentRouteWithScroll = Object.mixin(true, undefined, currentEngineRoute, scroll); if (!Object.fastCompare(currentEngineRoute, currentRouteWithScroll)) { await engine.replace(getEngineRoute()!, currentRouteWithScroll); } } // We haven't found any routes that match to the specified ref if (newRouteInfo == null) { // The transition was emitted by a user, then we need to save the scroll if (method !== 'event' && ref != null) { await engine[method](ref, scroll); } return; } if ((<router.PurifiedRoute<router.RouteAPI>>newRouteInfo).name == null) { const nm = router.getRouteName(currentEngineRoute); if (nm != null) { newRouteInfo.name = nm; } } const currentRoute = this.field.get<router.Route>('routeStore'); const deepMixin = (...args) => Object.mixin( { deep: true, skipUndefs: false, extendFilter: (el) => !Object.isArray(el) }, ...args ); // If the target ref is null it means we're navigating to the current route, // so we need to mix the new state with the current state if (originRef == null) { deepMixin(newRouteInfo, router.getBlankRouteFrom(currentRoute), opts); // Simple normalizing of a route state } else { deepMixin(newRouteInfo, opts); } const {meta} = newRouteInfo; // If a route support filling from the root object or query parameters fillRouteParams(newRouteInfo, this); // We have two variants of transitions: // "soft" - between routes were changed only query or meta parameters // "hard" - first and second routes aren't equal by a name // Mutations of query and meta parameters of a route shouldn't force re-render of components, // that why we placed it to a prototype object by using `Object.create` const nonWatchRouteValues = { url: newRouteInfo.resolvePath(newRouteInfo.params), query: newRouteInfo.query, meta }; const newRoute = Object.assign( Object.create(nonWatchRouteValues), Object.reject(router.convertRouteToPlainObject(newRouteInfo), Object.keys(nonWatchRouteValues)) ); let hardChange = false; // Emits the route transition event const emitTransition = (onlyOwnTransition?: boolean) => { const type = hardChange ? 'hard' : 'soft'; if (onlyOwnTransition) { this.emit('transition', newRoute, type); } else { this.emit('change', newRoute); this.emit('transition', newRoute, type); r.emit('transition', newRoute, type); } }; // Checking that a new route is really needed, i.e., it isn't equal to the previous let newRouteIsReallyNeeded = !Object.fastCompare( router.getComparableRouteParams(currentRoute), router.getComparableRouteParams(newRoute) ); // Nothing changes between routes, but there are provided some meta object if (!newRouteIsReallyNeeded && currentRoute != null && opts.meta != null) { newRouteIsReallyNeeded = !Object.fastCompare( Object.select(currentRoute.meta, opts.meta), opts.meta ); } // The transition is necessary, but now we need to understand should we emit a "soft" or "hard" transition if (newRouteIsReallyNeeded) { this.field.set('routeStore', newRoute); const plainInfo = router.convertRouteToPlainObject(newRouteInfo); const canRouteTransformToReplace = currentRoute && method !== 'replace' && Object.fastCompare(router.convertRouteToPlainObject(currentRoute), plainInfo); if (canRouteTransformToReplace) { method = 'replace'; } // If the used engine does not support the requested transition method, // we should use `replace` if (!Object.isFunction(engine[method])) { method = 'replace'; } // This transition is marked as `external`, // i.e. it refers to another site if (newRouteInfo.meta.external) { const u = newRoute.url; location.href = u !== '' ? u : '/'; return; } await engine[method](newRoute.url, plainInfo); const isSoftTransition = Boolean(r.route && Object.fastCompare( router.convertRouteToPlainObjectWithoutProto(currentRoute), router.convertRouteToPlainObjectWithoutProto(newRoute) )); // In this transition were changed only properties from a prototype, // that why it can be emitted as a soft transition, i.e. without forcing of the re-rendering of components if (isSoftTransition) { this.emit('softChange', newRoute); // We get a prototype by using `__proto__` link, // because `Object.getPrototypeOf` returns a non-watchable object. // This behavior is based on a strategy that every touch to an object property of the watched object // will create a child watch object. const proto = r.route?.__proto__; if (Object.isDictionary(proto)) { // Correct values from the root route object for (let keys = Object.keys(nonWatchRouteValues), i = 0; i < keys.length; i++) { const key = keys[i]; proto[key] = nonWatchRouteValues[key]; } } } else { hardChange = true; this.emit('hardChange', newRoute); r.route = newRoute; } emitTransition(); // This route is equal to the previous, and we don't actually do transition, // but for a "push" request we need to emit a "fake" transition event anyway } else if (method === 'push') { emitTransition(); // In this case, we don't do transition, but still, // we should emit the special event, because some methods, like, `back` or `forward` can wait for it } else { emitTransition(true); } // Restoring the scroll position if (meta.autoScroll !== false) { (async () => { const label = { label: $$.autoScroll }; const setScroll = () => { const s = meta.scroll; if (s != null) { this.r.scrollTo(s.x, s.y); } else if (hardChange) { this.r.scrollTo(0, 0); } }; // Restoring of scroll for static height components await this.nextTick(label); setScroll(); // Restoring of scroll for dynamic height components await this.async.sleep(10, label); setScroll(); })().catch(stderr); } return newRoute; } /** * @deprecated * @see [[bRouter.emitTransition]] */ @deprecated({renamedTo: 'emitTransition'}) setPage( ref: Nullable<string>, opts?: router.TransitionOptions, method?: TransitionMethod ): Promise<CanUndef<router.Route>> { return this.emitTransition(ref, opts, method); } /** * Updates the schema of routes * * @param basePath - base route path * @param [routes] - static schema of application routes * @param [activeRoute] */ updateRoutes( basePath: string, routes?: StaticRoutes, activeRoute?: Nullable<router.InitialRoute> ): Promise<router.RouteBlueprints>; /** * Updates the schema of routes * * @param basePath - base route path * @param activeRoute * @param [routes] - static schema of application routes */ updateRoutes( basePath: string, activeRoute: router.InitialRoute, routes?: StaticRoutes ): Promise<router.RouteBlueprints>; /** * Updates the schema of routes * * @param routes - static schema of application routes * @param [activeRoute] */ updateRoutes( routes: StaticRoutes, activeRoute?: Nullable<router.InitialRoute> ): Promise<router.RouteBlueprints>; /** * @param basePathOrRoutes * @param [routesOrActiveRoute] * @param [activeRouteOrRoutes] */ @wait('beforeReady') async updateRoutes( basePathOrRoutes: string | StaticRoutes, routesOrActiveRoute?: StaticRoutes | Nullable<router.InitialRoute>, activeRouteOrRoutes?: Nullable<router.InitialRoute> | StaticRoutes ): Promise<router.RouteBlueprints> { let basePath, routes, activeRoute; if (Object.isString(basePathOrRoutes)) { basePath = basePathOrRoutes; if (Object.isString(routesOrActiveRoute)) { routes = <StaticRoutes>activeRouteOrRoutes; activeRoute = routesOrActiveRoute; } else { routes = routesOrActiveRoute; activeRoute = <Nullable<router.InitialRoute>>activeRouteOrRoutes; } } else { routes = basePathOrRoutes; activeRoute = <Nullable<router.InitialRoute>>routesOrActiveRoute; } if (basePath != null) { this.basePath = basePath; } if (routes != null) { this.routes = this.compileStaticRoutes(routes); } this.routeStore = undefined; await this.initRoute(activeRoute ?? this.initialRoute ?? this.defaultRoute); return this.routes; } protected override initRemoteData(): CanUndef<CanPromise<router.RouteBlueprints | Dictionary>> { if (!this.db) { return; } const val = this.convertDBToComponent<StaticRoutes>(this.db); if (Object.isDictionary(val)) { return Promise.all(this.state.set(val)).then(() => val); } if (Object.isArray(val)) { // eslint-disable-next-line prefer-spread return this.updateRoutes.apply(this, val); } return this.routes; } /** * Initializes the router within an application * @emits `$root.initRouter(router:` [[bRouter]]`)` */ @hook('created') protected init(): void { this.field.set('routerStore', this, this.$root); this.r.emit('initRouter', this); } /** * Initializes the specified route * @param [route] - route */ @hook('beforeDataCreate') protected initRoute(route: Nullable<router.InitialRoute> = this.initialRoute): Promise<void> { if (route != null) { if (Object.isString(route)) { return this.replace(route); } return this.replace(router.getRouteName(route), Object.reject(route, router.routeNames)); } return this.replace(null); } /** * Updates the current route value */ @wait({defer: true, label: $$.updateCurrentRoute}) protected updateCurrentRoute(): Promise<void> { return this.initRoute(); } /** * Compiles the specified static routes with the current base path and returns a new object * @param [routes] */ protected compileStaticRoutes(routes: StaticRoutes = this.engine.routes ?? globalRoutes): router.RouteBlueprints { return router.compileStaticRoutes(routes, {basePath: this.basePath}); } protected override initBaseAPI(): void { super.initBaseAPI(); const i = this.instance; this.compileStaticRoutes = i.compileStaticRoutes.bind(this); this.emitTransition = i.emitTransition.bind(this); } /** * Handler: click on an element with the `href` attribute * @param e * @emits `hrefTransition(event:` [[HrefTransitionEvent]]`)` - contains the `HTMLElement` onto which the event * was dispatched and its `href` attribute value */ @watch({ field: 'document:click', wrapper: (o, cb) => o.dom.delegate('[href]', cb) }) protected async onLink(e: MouseEvent): Promise<void> { const a = <HTMLElement>e.delegateTarget, href = a.getAttribute('href')?.trim(); const cantIntercept = !this.interceptLinks || href == null || href === '' || urlsToIgnore.some((scheme) => scheme.test(href)) || router.isExternal.test(href); if (cantIntercept) { return; } const hrefTransitionEvent = new HrefTransitionEvent(a); this.emit('hrefTransition', hrefTransitionEvent); if (hrefTransitionEvent.transitionPrevented) { return; } e.preventDefault(); if (hrefTransitionEvent.defaultPrevented || Object.parse(a.getAttribute('data-router-prevent-transition'))) { return; } const l = Object.assign(document.createElement('a'), {href}); if (a.getAttribute('target') === '_blank' || e.ctrlKey || e.metaKey) { globalThis.open(l.href, '_blank'); return; } const method = a.getAttribute('data-router-method'); switch (method) { case 'back': this.back().catch(stderr); break; case 'forward': this.back().catch(stderr); break; case 'go': { const go = Object.parse(a.getAttribute('data-router-go')); this.go(Object.isNumber(go) ? go : -1).catch(stderr); break; } default: { const params = Object.parse(a.getAttribute('data-router-params')), query = Object.parse(a.getAttribute('data-router-query')), meta = Object.parse(a.getAttribute('data-router-meta')); await this[method === 'replace' ? 'replace' : 'push'](href, { params: Object.isDictionary(params) ? params : {}, query: Object.isDictionary(query) ? query : {}, meta: Object.isDictionary(meta) ? meta : {} }); } } } }