From 20818ffe840bdbebe2e6d77b480d4b450480584c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 13 Sep 2023 14:33:32 +0200 Subject: [PATCH] copy over router-slot code --- src/Umbraco.Web.UI.Client/.eslintignore | 1 + src/Umbraco.Web.UI.Client/package-lock.json | 7 - src/Umbraco.Web.UI.Client/package.json | 4 +- .../src/external/router-slot/LICENSE.md | 9 + .../src/external/router-slot/config.ts | 9 + .../src/external/router-slot/index.ts | 8 +- .../src/external/router-slot/model.ts | 178 ++++++++ .../src/external/router-slot/router-slot.ts | 419 ++++++++++++++++++ .../src/external/router-slot/util.ts | 1 + .../src/external/router-slot/util/anchor.ts | 38 ++ .../src/external/router-slot/util/events.ts | 49 ++ .../src/external/router-slot/util/history.ts | 110 +++++ .../src/external/router-slot/util/index.ts | 6 + .../src/external/router-slot/util/router.ts | 284 ++++++++++++ .../src/external/router-slot/util/shadow.ts | 43 ++ .../src/external/router-slot/util/url.ts | 135 ++++++ 16 files changed, 1288 insertions(+), 13 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/LICENSE.md create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/config.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/router-slot.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/util.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/util/anchor.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/util/events.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/util/history.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/util/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/util/router.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/util/shadow.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/router-slot/util/url.ts diff --git a/src/Umbraco.Web.UI.Client/.eslintignore b/src/Umbraco.Web.UI.Client/.eslintignore index 2b14f644c9..973eb4106a 100644 --- a/src/Umbraco.Web.UI.Client/.eslintignore +++ b/src/Umbraco.Web.UI.Client/.eslintignore @@ -5,3 +5,4 @@ dist-cms schemas temp-schema-generator APP_PLUGINS +/src/external/router-slot diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index a730a5ba69..25177c0562 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -16,7 +16,6 @@ "lit": "^2.8.0", "lodash-es": "4.17.21", "monaco-editor": "^0.41.0", - "router-slot": "file:router-slot-2.3.0.tgz", "rxjs": "^7.8.1", "tinymce": "^6.6.1", "tinymce-i18n": "^23.8.7", @@ -18803,12 +18802,6 @@ "rollup": "^1.9.2 || ^2.0.0" } }, - "node_modules/router-slot": { - "version": "2.3.0", - "resolved": "file:router-slot-2.3.0.tgz", - "integrity": "sha512-bH3g1/xOwbkuwE4iQ0tLwp1/r+dqttQr/ezO0tzp+KCttsCcxxgSCbEYy8+ePuSCLsiS3WhuTfFSED74d+tMjg==", - "license": "MIT" - }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index b1c92ed03d..03d64b8394 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -73,8 +73,7 @@ }, "files": [ "dist-cms", - "README.md", - "router-slot-*.*.*.tgz" + "README.md" ], "repository": { "url": "https://github.com/umbraco/Umbraco.CMS.Backoffice", @@ -133,7 +132,6 @@ "lit": "^2.8.0", "lodash-es": "4.17.21", "monaco-editor": "^0.41.0", - "router-slot": "file:router-slot-2.3.0.tgz", "rxjs": "^7.8.1", "tinymce": "^6.6.1", "tinymce-i18n": "^23.8.7", diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/LICENSE.md b/src/Umbraco.Web.UI.Client/src/external/router-slot/LICENSE.md new file mode 100644 index 0000000000..ba42248464 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright © 2018 Andreas Mehlsen andmehlsen@gmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/config.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/config.ts new file mode 100644 index 0000000000..29c9f48751 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/config.ts @@ -0,0 +1,9 @@ +import { PathMatch } from "./model.js"; + +export const CATCH_ALL_WILDCARD: string = "**"; +export const TRAVERSE_FLAG: string = "\\.\\.\\/"; +export const PARAM_IDENTIFIER: RegExp = /:([^\\/]+)/g; +export const ROUTER_SLOT_TAG_NAME: string = "router-slot"; +export const GLOBAL_ROUTER_EVENTS_TARGET = window; +export const HISTORY_PATCH_NATIVE_KEY: string = `native`; +export const DEFAULT_PATH_MATCH: PathMatch = "prefix"; diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/index.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/index.ts index 226ba1ba27..8dca803178 100644 --- a/src/Umbraco.Web.UI.Client/src/external/router-slot/index.ts +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/index.ts @@ -1,4 +1,7 @@ -export * from 'router-slot'; +export * from './router-slot.js'; +export * from './config.js'; +export * from './util/index.js'; + export type { Guard, IRoute, @@ -10,5 +13,4 @@ export type { PageComponent, Component, Params, -} from 'router-slot/model'; -export * from 'router-slot/util'; +} from './model.js'; diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts new file mode 100644 index 0000000000..e101e43160 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/model.ts @@ -0,0 +1,178 @@ +export interface IRouterSlot extends HTMLElement { + readonly route: IRoute | null; + readonly isRoot: boolean; + readonly fragments: IPathFragments | null; + readonly params: Params | null; + readonly match: IRouteMatch | null; + routes: IRoute[]; + add: ((routes: IRoute[], navigate?: boolean) => void); + clear: (() => void); + render: (() => Promise); + constructAbsolutePath: ((path: PathFragment) => string); + parent: IRouterSlot

| null | undefined; + queryParentRouterSlot: (() => IRouterSlot

| null); +} + +export type IRoutingInfo = { + slot: IRouterSlot, + match: IRouteMatch +}; + +export type CustomResolver = ((info: IRoutingInfo) => boolean | void | Promise | Promise); +export type Guard = ((info: IRoutingInfo) => boolean | Promise); +export type Cancel = (() => boolean); + +export type PageComponent = HTMLElement | undefined; +export type ModuleResolver = Promise<{default: any; /*PageComponent*/}>; +export type Class = {new (...args: any[]): T;}; +export type Component = Class | ModuleResolver | PageComponent | (() => Class) | (() => PromiseLike) | (() => PageComponent) | (() => PromiseLike) | (() => ModuleResolver) | (() => PromiseLike); +export type Setup = ((component: PageComponent, info: IRoutingInfo) => void); + +export type RouterTree = {slot: IRouterSlot} & {child?: RouterTree} | null | undefined; +export type PathMatch = "prefix" | "suffix" | "full" | "fuzzy"; + +/** + * The base route interface. + * D = the data type of the data + */ +export interface IRouteBase { + + // The path for the route fragment + path: PathFragment; + + // Optional metadata + data?: D; + + // If guard returns false, the navigation is not allowed + guards?: Guard[]; + + // The type of match. + // - If "prefix" router-slot will try to match the first part of the path. + // - If "suffix" router-slot will try to match the last part of the path. + // - If "full" router-slot will try to match the entire path. + // - If "fuzzy" router-slot will try to match an arbitrary part of the path. + pathMatch?: PathMatch; +} + +/** + * Route type used for redirection. + */ +export interface IRedirectRoute extends IRouteBase { + + // The paths the route should redirect to. Can either be relative or absolute. + redirectTo: string; + + // Whether the query should be preserved when redirecting. + preserveQuery?: boolean; +} + +/** + * Route type used to resolve and stamp components. + */ +export interface IComponentRoute extends IRouteBase { + + // The component loader (should return a module with a default export) + component: Component | PromiseLike; + + // A custom setup function for the instance of the component. + setup?: Setup; +} + +/** + * Route type used to take control of how the route should resolve. + */ +export interface IResolverRoute extends IRouteBase { + + // A custom resolver that handles the route change + resolve: CustomResolver; +} + +export type IRoute = IRedirectRoute | IComponentRoute | IResolverRoute; +export type PathFragment = string; +export type IPathFragments = { + consumed: PathFragment, + rest: PathFragment +} + +export interface IRouteMatch { + route: IRoute; + params: Params, + fragments: IPathFragments; + match: RegExpMatchArray; +} + +export type PushStateEvent = CustomEvent; +export type ReplaceStateEvent = CustomEvent; +export type ChangeStateEvent = CustomEvent; +export type WillChangeStateEvent = CustomEvent<{ url?: string | null, eventName: GlobalRouterEvent}>; +export type NavigationStartEvent = CustomEvent>; +export type NavigationSuccessEvent = CustomEvent>; +export type NavigationCancelEvent = CustomEvent>; +export type NavigationErrorEvent = CustomEvent>; +export type NavigationEndEvent = CustomEvent>; + +export type Params = {[key: string]: string}; +export type Query = {[key: string]: string}; + +export type EventListenerSubscription = (() => void); + +/** + * RouterSlot related events. + */ +export type RouterSlotEvent = "changestate"; + +/** + * History related events. + */ +export type GlobalRouterEvent = + +// An event triggered when a new state is added to the history. + "pushstate" + + // An event triggered when the current state is replaced in the history. + | "replacestate" + + // An event triggered when a state in the history is popped from the history. + | "popstate" + + // An event triggered when the state changes (eg. pop, push and replace) + | "changestate" + + // A cancellable event triggered before the history state changes. + | "willchangestate" + + // An event triggered when navigation starts. + | "navigationstart" + + // An event triggered when navigation is canceled. This is due to a route guard returning false during navigation. + | "navigationcancel" + + // An event triggered when navigation fails due to an unexpected error. + | "navigationerror" + + // An event triggered when navigation successfully completes. + | "navigationsuccess" + + // An event triggered when navigation ends. + | "navigationend"; + +export interface ISlashOptions { + start: boolean; + end: boolean; +} + +/* Extend the global event handlers map with the router related events */ +declare global { + interface GlobalEventHandlersEventMap { + "pushstate": PushStateEvent, + "replacestate": ReplaceStateEvent, + "popstate": PopStateEvent, + "changestate": ChangeStateEvent, + "navigationstart": NavigationStartEvent, + "navigationend": NavigationEndEvent, + "navigationsuccess": NavigationSuccessEvent, + "navigationcancel": NavigationCancelEvent, + "navigationerror": NavigationErrorEvent, + "willchangestate": WillChangeStateEvent + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/router-slot.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/router-slot.ts new file mode 100644 index 0000000000..4e936275ab --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/router-slot.ts @@ -0,0 +1,419 @@ +import { GLOBAL_ROUTER_EVENTS_TARGET, ROUTER_SLOT_TAG_NAME } from "./config.js"; +import { + Cancel, + EventListenerSubscription, + GlobalRouterEvent, + IPathFragments, + IRoute, + IRouteMatch, + IRouterSlot, + IRoutingInfo, + Params, + PathFragment, + RouterSlotEvent, +} from "./model.js"; +import { + addListener, + constructAbsolutePath, + dispatchGlobalRouterEvent, + dispatchRouteChangeEvent, + ensureAnchorHistory, + ensureHistoryEvents, + handleRedirect, + isRedirectRoute, + isResolverRoute, + matchRoutes, + pathWithoutBasePath, + queryParentRouterSlot, + removeListeners, + resolvePageComponent, + shouldNavigate, +} from "./util.js"; + +const template = document.createElement("template"); +template.innerHTML = ``; + +// Patches the history object and ensures the correct events. +ensureHistoryEvents(); + +// Ensure the anchor tags uses the history API +ensureAnchorHistory(); + +/** + * Slot for a node in the router tree. + * @slot - Default content. + * @event changestate - Dispatched when the router slot state changes. + */ +export class RouterSlot + extends HTMLElement + implements IRouterSlot +{ + /** + * Listeners on the router. + */ + private listeners: EventListenerSubscription[] = []; + + /** + * The available routes. + */ + private _routes: IRoute[] = []; + get routes(): IRoute[] { + return this._routes; + } + + set routes(routes: IRoute[]) { + this.clear(); + this.add(routes); + } + + /** + * The parent router. + * Is REQUIRED if this router is a child. + * When set, the relevant listeners are added or teared down because they depend on the parent. + */ + _parent: IRouterSlot

| null | undefined; + get parent(): IRouterSlot

| null | undefined { + return this._parent; + } + set parent(router: IRouterSlot

| null | undefined) { + this._lockParent = true; + this._setParent(router); + } + + private _lockParent = false; + private _setParent(router: IRouterSlot

| null | undefined) { + this.detachListeners(); + this._parent = router; + this.attachListeners(); + } + + /** + * Whether the router is a root router. + */ + get isRoot(): boolean { + return this.parent == null; + } + + /** + * The current route match. + */ + private _routeMatch: IRouteMatch | null = null; + + get match(): IRouteMatch | null { + return this._routeMatch; + } + + /** + * The current route of the match. + */ + get route(): IRoute | null { + return this.match != null ? this.match.route : null; + } + + /** + * The current path fragment of the match + */ + get fragments(): IPathFragments | null { + return this.match != null ? this.match.fragments : null; + } + + /** + * The current params of the match. + */ + get params(): Params | null { + return this.match != null ? this.match.params : null; + } + + /** + * Hooks up the element. + */ + constructor() { + super(); + + this.render = this.render.bind(this); + + // Attach the template + const shadow = this.attachShadow({ mode: "open" }); + shadow.appendChild(template.content.cloneNode(true)); + } + + /** + * Query the parent router slot when the router slot is connected. + */ + connectedCallback() { + // Do not query a parent if the parent has been set from the outside. + if (!this._lockParent) { + this._setParent(this.queryParentRouterSlot()); + } + if (this.parent && this.parent.match !== null && this.match === null) { + requestAnimationFrame(() => { + this.render(); + }); + } + } + + /** + * Tears down the element. + */ + disconnectedCallback() { + this.detachListeners(); + } + + /** + * Queries the parent router. + */ + queryParentRouterSlot(): IRouterSlot

| null { + return queryParentRouterSlot

(this); + } + + /** + * Returns an absolute path relative to the router slot. + * @param path + */ + constructAbsolutePath(path: PathFragment): string { + return constructAbsolutePath(this, path); + } + + /** + * Adds routes to the router. + * Navigates automatically if the router slot is the root and is connected. + * @param routes + * @param navigate + */ + add(routes: IRoute[], navigate?: boolean): void { + // Add the routes to the array + this._routes.push(...routes); + + if (navigate === undefined) { + // If navigate is not determined, then we will check if we have a route match. If not then we will re-render. + navigate = this._routeMatch === null; + } + + // Navigate fallback: + navigate ??= this.isRoot && this.isConnected; + + // Register that the path has changed so the correct route can be loaded. + if (navigate) { + this.render().then(); + } + } + + /** + * Removes all routes. + */ + clear(): void { + this._routes.length = 0; + } + + /** + * Each time the path changes, load the new path. + */ + async render(): Promise { + // When using ShadyDOM the disconnectedCallback in the child router slot is called async + // in a microtask. This means that when using the ShadyDOM polyfill, sometimes child router slots + // would not clear event listeners from the parent router slots and therefore route even though + // it was no longer in the DOM. The solution is to check whether the isConnected flag is false + // before rendering the path. + if (!this.isConnected) { + return; + } + + // Either choose the parent fragment or the current path if no parent exists. + // The root router slot will always use the entire path. + const pathFragment = + this.parent != null && this.parent.fragments != null + ? this.parent.fragments.rest + : pathWithoutBasePath(); + + // Route to the path + await this.renderPath(pathFragment); + } + + /** + * Attaches listeners, either globally or on the parent router. + */ + protected attachListeners(): void { + // Add listeners that updates the route + this.listeners.push( + this.parent != null + ? // Attach child router listeners + addListener( + this.parent, + "changestate", + this.render + ) + : // Add global listeners. + addListener( + GLOBAL_ROUTER_EVENTS_TARGET, + "changestate", + this.render + ) + ); + } + + /** + * Clears the children in the DOM. + */ + protected clearChildren() { + while (this.firstChild != null) { + this.firstChild.parentNode!.removeChild(this.firstChild); + } + } + + /** + * Detaches the listeners. + */ + protected detachListeners(): void { + removeListeners(this.listeners); + } + + /** + * Notify the listeners. + */ + notifyChildRouters(info: IRoutingInfo) { + // This method should be called before routeMatch is being set! + // This only work cause we are using a requestAnimationFrame to dispatch the event, + // in other words, the routeMatch will be set when the child router receives the event. + // Scenario: + // When this router came from a route(routeMatch !== null), then: + // Dispatch the route change event to notify the children that something happened. + // This is because the child routes might have to change routes further down the tree. + // The event is dispatched in an animation frame to allow route children to make the initial render first + // and hook up the new router slot. + if (this._routeMatch !== null) { + requestAnimationFrame(() => { + dispatchRouteChangeEvent(this, info); + }); + } + } + + /** + * Loads a new path based on the routes. + * Returns true if a navigation was made to a new page. + */ + protected async renderPath(path: string | PathFragment): Promise { + // Find the corresponding route. + const match = matchRoutes(this._routes, path); + + // Ensure that a route was found, otherwise we just clear the current state of the route. + if (match == null) { + this._routeMatch = null; + return false; + } + + const { route } = match; + const info: IRoutingInfo = { match, slot: this }; + + try { + // Only change route if its a new route. + const navigate = shouldNavigate(this.match, match); + if (navigate) { + // Listen for another push state event. If another push state event happens + // while we are about to navigate we have to cancel. + let navigationInvalidated = false; + const cancelNavigation = () => (navigationInvalidated = true); + const removeChangeListener: EventListenerSubscription = addListener< + Event, + GlobalRouterEvent + >(GLOBAL_ROUTER_EVENTS_TARGET, "changestate", cancelNavigation, { + once: true, + }); + + // Cleans up the routing by removing listeners and restoring the match from before + const cleanup = () => { + removeChangeListener(); + }; + + // Cleans up and dispatches a global event that a navigation was cancelled. + const cancel: Cancel = () => { + cleanup(); + dispatchGlobalRouterEvent("navigationcancel", info); + dispatchGlobalRouterEvent("navigationend", info); + return false; + }; + + // Dispatch globally that a navigation has started + dispatchGlobalRouterEvent("navigationstart", info); + + // Check whether the guards allow us to go to the new route. + if (route.guards != null) { + for (const guard of route.guards) { + if (!(await guard(info))) { + return cancel(); + } + } + } + + // We are going to navigate, so we want to notify the child routers: + this.notifyChildRouters(info); + + // Redirect if necessary + if (isRedirectRoute(route)) { + cleanup(); + handleRedirect(this, route); + return false; + } + + // Handle custom resolving if necessary + else if (isResolverRoute(route)) { + // The resolve will handle the rest of the navigation. This includes whether or not the navigation + // should be cancelled. If the resolve function returns false we cancel the navigation. + if ((await route.resolve(info)) === false) { + return cancel(); + } + } + + // If the component provided is a function (and not a class) call the function to get the promise. + else { + const page = await resolvePageComponent(route, info); + + // Cancel the navigation if another navigation event was sent while this one was loading + if (navigationInvalidated) { + return cancel(); + } + + // Remove the old page by clearing the slot + this.clearChildren(); + + // Store the new route match before we append the new page to the DOM. + // We do this to ensure that we can find the match in the connectedCallback of the page. + this._routeMatch = match; + + if(page) { + // Append the new page + this.appendChild(page); + } + } + + // Remember to cleanup after the navigation + cleanup(); + } else { + // We did not make a new navigation this time, but we want to notify children here: + this.notifyChildRouters(info); + } + + // Store the new route match + this._routeMatch = match; + + // Dispatch globally that a navigation has ended. + if (navigate) { + dispatchGlobalRouterEvent("navigationsuccess", info); + dispatchGlobalRouterEvent("navigationend", info); + } + + return navigate; + } catch (e) { + dispatchGlobalRouterEvent("navigationerror", info); + dispatchGlobalRouterEvent("navigationend", info); + throw e; + } + } +} + +window.customElements.define(ROUTER_SLOT_TAG_NAME, RouterSlot); + +declare global { + interface HTMLElementTagNameMap { + "router-slot": RouterSlot; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util.ts new file mode 100644 index 0000000000..d1222d076a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util.ts @@ -0,0 +1 @@ +export * from './util/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/anchor.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/anchor.ts new file mode 100644 index 0000000000..1337caf22e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/anchor.ts @@ -0,0 +1,38 @@ +/** + * Hook up a click listener to the window that, for all anchor tags + * that has a relative HREF, uses the history API instead. + */ +export function ensureAnchorHistory () { + window.addEventListener("click", (e: MouseEvent) => { + + // Find the target by using the composed path to get the element through the shadow boundaries. + const $anchor = ("composedPath" in e as any) ? e.composedPath().find($elem => $elem instanceof HTMLAnchorElement) : e.target; + + // Abort if the event is not about the anchor tag + if ($anchor == null || !($anchor instanceof HTMLAnchorElement)) { + return; + } + + // Get the HREF value from the anchor tag + const href = $anchor.href; + + // Only handle the anchor tag if the follow holds true: + // - The HREF is relative to the origin of the current location. + // - The target is targeting the current frame. + // - The anchor doesn't have the attribute [data-router-slot]="disabled" + if (!href.startsWith(location.origin) || + ($anchor.target !== "" && $anchor.target !== "_self") || + $anchor.dataset["routerSlot"] === "disabled") { + return; + } + + // Remove the origin from the start of the HREF to get the path + const path = $anchor.pathname + $anchor.search + $anchor.hash; + + // Prevent the default behavior + e.preventDefault(); + + // Change the history! + history.pushState(null, "", path); + }); +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/events.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/events.ts new file mode 100644 index 0000000000..85bf127954 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/events.ts @@ -0,0 +1,49 @@ +import { GLOBAL_ROUTER_EVENTS_TARGET } from "../config"; +import { EventListenerSubscription, GlobalRouterEvent, IRoute, IRoutingInfo } from "../model"; + +/** + * Dispatches a did change route event. + * @param $elem + * @param {IRoute} detail + */ +export function dispatchRouteChangeEvent ($elem: HTMLElement, detail: IRoutingInfo) { + $elem.dispatchEvent(new CustomEvent("changestate", {detail})); +} + +/** + * Dispatches an event on the window object. + * @param name + * @param detail + */ +export function dispatchGlobalRouterEvent (name: GlobalRouterEvent, detail?: IRoutingInfo) { + GLOBAL_ROUTER_EVENTS_TARGET.dispatchEvent(new CustomEvent(name, {detail})); + // if ("debugRouterSlot" in window) { + // console.log(`%c [router-slot]: ${name}`, `color: #286ee0`, detail); + // } +} + +/** + * Adds an event listener (or more) to an element and returns a function to unsubscribe. + * @param $elem + * @param type + * @param listener + * @param options + */ +export function addListener ($elem: EventTarget, + type: eventType[] | eventType, + listener: ((e: T) => void), + options?: boolean | AddEventListenerOptions): EventListenerSubscription { + const types = Array.isArray(type) ? type : [type]; + types.forEach(t => $elem.addEventListener(t, listener as EventListenerOrEventListenerObject, options)); + return () => types.forEach( + t => $elem.removeEventListener(t, listener as EventListenerOrEventListenerObject, options)); +} + + +/** + * Removes the event listeners in the array. + * @param listeners + */ +export function removeListeners (listeners: EventListenerSubscription[]) { + listeners.forEach(unsub => unsub()); +} diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/history.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/history.ts new file mode 100644 index 0000000000..74c7bdb59e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/history.ts @@ -0,0 +1,110 @@ +import { GLOBAL_ROUTER_EVENTS_TARGET, HISTORY_PATCH_NATIVE_KEY } from "../config"; +import { GlobalRouterEvent } from "../model"; +import { dispatchGlobalRouterEvent } from "./events"; + +// Mapping a history functions to the events they are going to dispatch. +export const historyPatches: [string, GlobalRouterEvent[]][] = [ + ["pushState", ["pushstate", "changestate"]], + ["replaceState", ["replacestate", "changestate"]], + ["forward", ["pushstate", "changestate"]], + ["go", ["pushstate", "changestate"]], + + // We need to handle the popstate a little differently when it comes to the change state event. + ["back", ["popstate"]], +]; + + +/** + * Patches the history object by ensuring correct events are dispatches when the history changes. + */ +export function ensureHistoryEvents() { + for (const [name, events] of historyPatches) { + for (const event of events) { + attachCallback(history, name, event); + } + } + + // The popstate is the only event natively dispatched when using the hardware buttons. + // Therefore we need to handle this case a little different. To ensure the changestate event + // is fired also when the hardware back button is used, we make sure to listen for the popstate + // event and dispatch a change state event right after. The reason for the setTimeout is because we + // want the popstate event to bubble up before the changestate event is dispatched. + window.addEventListener("popstate", (e: PopStateEvent) => { + + // Check if the state should be allowed to change + if (shouldCancelChangeState({eventName: "popstate"})) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + // Dispatch the global router event to change the routes after the popstate has bubbled up + setTimeout(() => dispatchGlobalRouterEvent("changestate"), 0); + } + ); +} + +/** + * Attaches a global router event after the native function on the object has been invoked. + * Stores the original function at the _name. + * @param obj + * @param functionName + * @param eventName + */ +export function attachCallback(obj: any, functionName: string, eventName: GlobalRouterEvent) { + const func = obj[functionName]; + saveNativeFunction(obj, functionName, func); + obj[functionName] = (...args: any[]) => { + + // If its push/replace state we want to send the url to the should cancel change state event + const url = args.length > 2 ? args[2] : null; + + // Check if the state should be allowed to change + if (shouldCancelChangeState({url, eventName})) return; + + // Navigate + func.apply(obj, args); + dispatchGlobalRouterEvent(eventName) + }; +} + +/** + * Saves the native function on the history object. + * @param obj + * @param name + * @param func + */ +export function saveNativeFunction(obj: any, name: string, func: (() => void)) { + + // Ensure that the native object exists. + if (obj[HISTORY_PATCH_NATIVE_KEY] == null) { + obj[HISTORY_PATCH_NATIVE_KEY] = {}; + } + + // Save the native function. + obj[HISTORY_PATCH_NATIVE_KEY][`${name}`] = func.bind(obj); +} + +/** + * Dispatches and event and returns whether the state change should be cancelled. + * The state will be considered as cancelled if the "willChangeState" event was cancelled. + */ +function shouldCancelChangeState(data: { url?: string | null, eventName: GlobalRouterEvent }): boolean { + return !GLOBAL_ROUTER_EVENTS_TARGET.dispatchEvent(new CustomEvent("willchangestate", { + cancelable: true, + detail: data + })); +} + +// Expose the native history functions. +declare global { + interface History { + "native": { + "back": ((distance?: any) => void); + "forward": ((distance?: any) => void); + "go": ((delta?: any) => void); + "pushState": ((data: any, title?: string, url?: string | null) => void); + "replaceState": ((data: any, title?: string, url?: string | null) => void); + } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/index.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/index.ts new file mode 100644 index 0000000000..2551554cdd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/index.ts @@ -0,0 +1,6 @@ +export * from './events.js'; +export * from './history.js'; +export * from './router.js'; +export * from './shadow.js'; +export * from './url.js'; +export * from './anchor.js'; diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/router.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/router.ts new file mode 100644 index 0000000000..033767f3d9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/router.ts @@ -0,0 +1,284 @@ +import { CATCH_ALL_WILDCARD, DEFAULT_PATH_MATCH, PARAM_IDENTIFIER, TRAVERSE_FLAG } from "../config"; +import { IComponentRoute, IRedirectRoute, IResolverRoute, IRoute, IRouteMatch, IRouterSlot, ModuleResolver, PageComponent, Params, PathFragment, RouterTree, IRoutingInfo } from "../model"; +import { constructPathWithBasePath, path as getPath, queryString, stripSlash } from "./url"; + +/** + * Determines whether the path is active. + * If the full path starts with the path and is followed by the end of the string or a "/" the path is considered active. + * @param path + * @param fullPath + */ +export function isPathActive (path: string | PathFragment, fullPath: string = getPath()): boolean { + return new RegExp(`^${stripSlash(path)}(\/|$)`, "gm").test(stripSlash(fullPath)); +} + +/** + * Matches a route. + * @param route + * @param path + */ +export function matchRoute (route: IRoute, path: string | PathFragment): IRouteMatch | null { + + // We start by preparing the route path by replacing the param names with a regex that matches everything + // until either the end of the path or the next "/". While replacing the param placeholders we make sure + // to store the names of the param placeholders. + const paramNames: string[] = []; + const routePath = stripSlash(route.path.replace(PARAM_IDENTIFIER, (substring: string, ...args: string[]) => { + paramNames.push(args[0]); + return `([^\/]+)`; + })); + + // Construct the regex to match with the path or fragment + // If path is wildcard: + // - We match with /^/ to not consume any characters. + // If path is empty and pathmatch is not full + // - We match with /^/ to not consume any characters. + // If pathmatch is prefix + // - We start the match with [/]? to allow a slash in front of the path. + // - We end the match with (?:/|$) to make sure the match ends at either the end of the fragment or end of the path. + // If pathmatch is suffix: + // - We start the match with .*? to allow anything to be in front of what we are trying to match. + // - We end the match with $ to make sure the match ends at the end of the path. + // If pathmatch is full: + // - We end the match with $ to make sure the match ends at the end of the path. + // If pathmatch is fuzzy + // - We start the match with .*? to allow anything to be in front of what we are trying to match. + // - We end the match with .*? to allow anything to be after what we are trying to match. + // All matches starts with ^ to make sure the match is done from the beginning of the path. + const regex = route.path === CATCH_ALL_WILDCARD || (route.path.length === 0 && route.pathMatch != "full" ) ? /^/ : (() => { + switch (route.pathMatch || DEFAULT_PATH_MATCH) { + case "full": return new RegExp(`^${routePath}\/?$`); + case "suffix": return new RegExp(`^.*?${routePath}\/?$`); + case "fuzzy": return new RegExp(`^.*?${routePath}.*?$`); + case "prefix": default: return new RegExp(`^[\/]?${routePath}(?:\/|$)`); + } + })(); + + // Check if there's a match + const match = path.match(regex); + if (match != null) { + + // Match the param names with the matches. The matches starts from index 1 which is the + // reason why we add 1. match[0] is the entire string. + const params = paramNames.reduce((acc: Params, name: string, i: number) => { + acc[name] = match[i + 1]; + return acc; + }, {}); + + // Split up the path into two fragments: the one consumed and the rest. + const consumed = stripSlash(path.slice(0, match[0].length)); + const rest = stripSlash(path.slice(match[0].length, path.length)); + + return { + route, + match, + params, + fragments: { + consumed, + rest + } + }; + } + + + return null; +} + +/** + * Matches the first route that matches the given path. + * @param routes + * @param path + */ +export function matchRoutes (routes: IRoute[], path: string | PathFragment): IRouteMatch | null { + for (const route of routes) { + const match = matchRoute(route, path); + if (match != null) { + return match; + } + } + + return null; +} + +/** + * Returns the page from the route. + * If the component provided is a function (and not a class) call the function to get the promise. + * @param route + * @param info + */ +export async function resolvePageComponent (route: IComponentRoute, info: IRoutingInfo): Promise { + + // Figure out if the component were given as an import or class. + let cmp = route.component; + if (cmp instanceof Function) { + try { + cmp = (cmp as Function)(); + } catch (err) { + + // The invocation most likely failed because the function is a class. + // If it failed due to the "new" keyword not being used, the error will be of type "TypeError". + // This is the most reliable way to check whether the provided function is a class or a function. + if (!(err instanceof TypeError)) { + throw err; + } + } + } + + // Load the module or component. + const moduleClassOrPage = await Promise.resolve(cmp); + + // Instantiate the component + let component!: PageComponent; + if (!(moduleClassOrPage instanceof HTMLElement)) { + component = new (moduleClassOrPage.default ? moduleClassOrPage.default : moduleClassOrPage)() as PageComponent; + } else { + component = moduleClassOrPage as PageComponent; + } + + // Setup the component using the callback. + if (route.setup != null) { + route.setup(component, info); + } + + return component; +} + +/** + * Determines if a route is a redirect route. + * @param route + */ +export function isRedirectRoute (route: IRoute): route is IRedirectRoute { + return "redirectTo" in route; +} + +/** + * Determines if a route is a resolver route. + * @param route + */ +export function isResolverRoute (route: IRoute): route is IResolverRoute { + return "resolve" in route; +} + +/** + * Traverses the router tree up to the root route. + * @param slot + */ +export function traverseRouterTree (slot: IRouterSlot): {tree: RouterTree, depth: number} { + + // Find the nodes from the route up to the root route + let routes: IRouterSlot[] = [slot]; + while (slot.parent != null) { + slot = slot.parent; + routes.push(slot); + } + + // Create the tree + const tree: RouterTree = routes.reduce((acc: RouterTree, slot: IRouterSlot) => { + return {slot, child: acc}; + }, undefined); + + const depth = routes.length; + + return {tree, depth}; +} + +/** + * Generates a path based on the router tree. + * @param tree + * @param depth + */ +export function getFragments (tree: RouterTree, depth: number): PathFragment[] { + let child = tree; + const fragments: PathFragment[] = []; + + // Look through all of the path fragments + while (child != null && child.slot.match != null && depth > 0) { + fragments.push(child.slot.match.fragments.consumed); + child = child.child; + depth--; + } + + return fragments; +} + +/** + * Constructs the correct absolute path based on a router. + * - Handles relative paths: "mypath" + * - Handles absolute paths: "/mypath" + * - Handles traversing paths: "../../mypath" + * @param slot + * @param path + */ +export function constructAbsolutePath (slot: IRouterSlot, + path: string | PathFragment = ""): string { + + // Grab the router tree + const {tree, depth} = traverseRouterTree(slot); + + // If the path starts with "/" we treat it as an absolute path + // and therefore don't continue because it is already absolute. + if (!path.startsWith("/")) { + let traverseDepth = 0; + + // If the path starts with "./" we can remove that part + // because we know the path is relative to its route. + if (path.startsWith("./")) { + path = path.slice(2); + } + + // Match with the traverse flag. + const match = path.match(new RegExp(TRAVERSE_FLAG, "g")); + if (match != null) { + + // If the path matched with the traverse flag we know that we have to construct + // a route until a certain depth. The traverse depth is the amount of "../" in the path + // and the depth is the part of the path we a slicing away. + traverseDepth = match.length; + + // Count the amount of characters that the matches add up to and remove it from the path. + const length = match.reduce((acc: number, m: string) => acc + m.length, 0); + path = path.slice(length); + } + + // Grab the fragments and construct the new path, taking the traverse depth into account. + // Always subtract at least 1 because we the path is relative to its parent. + // Filter away the empty fragments from the path. + const fragments = getFragments(tree, depth - 1 - traverseDepth).filter(fragment => fragment.length > 0); + path = `${fragments.join("/")}${fragments.length > 0 ? "/" : ""}${path}`; + } + + // Add the base path in front of the path. If the path is already absolute, the path wont get the base path added. + return constructPathWithBasePath(path, {end: false}); +} + +/** + * Handles a redirect. + * @param slot + * @param route + */ +export function handleRedirect (slot: IRouterSlot, route: IRedirectRoute) { + history.replaceState(history.state, "", `${constructAbsolutePath(slot, route.redirectTo)}${route.preserveQuery ? queryString() : ""}`); +} + +/** + * Determines whether the navigation should start based on the current match and the new match. + * @param currentMatch + * @param newMatch + */ +export function shouldNavigate (currentMatch: IRouteMatch | null, newMatch: IRouteMatch) { + + // If the current match is not defined we should always route. + if (currentMatch == null) { + return true; + } + + // Extract information about the matches + const {route: currentRoute, fragments: currentFragments} = currentMatch; + const {route: newRoute, fragments: newFragments} = newMatch; + + const isSameRoute = currentRoute == newRoute; + const isSameFragments = currentFragments.consumed == newFragments.consumed; + + // Only navigate if the URL consumption is new or if the two routes are no longer the same. + return !isSameFragments || !isSameRoute; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/shadow.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/shadow.ts new file mode 100644 index 0000000000..0ee04a3b8f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/shadow.ts @@ -0,0 +1,43 @@ +import { ROUTER_SLOT_TAG_NAME } from "../config"; +import { IRouterSlot } from "../model"; + +/** + * Queries the parent router. + * @param $elem + */ +export function queryParentRouterSlot ($elem: Element): IRouterSlot | null { + return queryParentRoots>($elem, ROUTER_SLOT_TAG_NAME); +} + +/** + * Traverses the roots and returns the first match. + * The minRoots parameter indicates how many roots should be traversed before we started matching with the query. + * @param $elem + * @param query + * @param minRoots + * @param roots + */ +export function queryParentRoots ($elem: Element, query: string, minRoots: number = 0, roots: number = 0): T | null { + + // Grab the rood node and query it + const $root = ($elem).getRootNode(); + + // If we are at the right level or above we can query! + if (roots >= minRoots) { + + // See if there's a match + const match = $root.querySelector(query); + if (match != null && match != $elem) { + return match; + } + } + + // If a parent root with a host doesn't exist we don't continue the traversal + const $rootRootNode = $root.getRootNode(); + if ($rootRootNode.host == null) { + return null; + } + + // We continue the traversal if there was not matches + return queryParentRoots($rootRootNode.host, query, minRoots, ++roots); +} diff --git a/src/Umbraco.Web.UI.Client/src/external/router-slot/util/url.ts b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/url.ts new file mode 100644 index 0000000000..13f5d830b1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/router-slot/util/url.ts @@ -0,0 +1,135 @@ +import { ISlashOptions, Params, Query } from "../model"; + +const $anchor = document.createElement("a"); + +/** + * The current path of the location. + * As default slashes are included at the start and end. + * @param options + */ +export function path (options: Partial = {}): string { + return slashify(window.location.pathname, options); +} + +/** + * Returns the path without the base path. + * @param options + */ +export function pathWithoutBasePath (options: Partial = {}): string { + return slashify(stripStart(path(), basePath()), options); +} + +/** + * Returns the base path as defined in the tag in the head in a reliable way. + * If eg. is defined this function will return "/router-slot/". + * + * An alternative would be to use regex on document.baseURI, + * but that will be unreliable in some cases because it + * doesn't use the built in HTMLHyperlinkElementUtils. + * + * To make this method more performant we could cache the anchor element. + * As default it will return the base path with slashes in front and at the end. + */ +export function basePath (options: Partial = {}): string { + return constructPathWithBasePath(".", options); +} + +/** + * Creates an URL using the built in HTMLHyperlinkElementUtils. + * An alternative would be to use regex on document.baseURI, + * but that will be unreliable in some cases because it + * doesn't use the built in HTMLHyperlinkElementUtils. + * + * As default it will return the base path with slashes in front and at the end. + * @param path + * @param options + */ +export function constructPathWithBasePath (path: string, options: Partial = {}) { + $anchor.href = path; + return slashify($anchor.pathname, options); +} + +/** + * Removes the start of the path that matches the part. + * @param path + * @param part + */ +export function stripStart (path: string, part: string) { + return path.replace(new RegExp(`^${part}`), ""); +} + +/** + * Returns the query string. + */ +export function queryString (): string { + return window.location.search; +} + +/** + * Returns the params for the current path. + * @returns Params + */ +export function query (): Query { + return toQuery(queryString().substr(1)); +} + +/** + * Strips the slash from the start and end of a path. + * @param path + */ +export function stripSlash (path: string): string { + return slashify(path, {start: false, end: false}); +} + +/** + * Ensures the path starts and ends with a slash + * @param path + */ +export function ensureSlash (path: string): string { + return slashify(path, {start: true, end: true}); +} + +/** + * Makes sure that the start and end slashes are present or not depending on the options. + * @param path + * @param start + * @param end + */ +export function slashify (path: string, {start = true, end = true}: Partial = {}): string { + path = start && !path.startsWith("/") ? `/${path}` : (!start && path.startsWith("/") ? path.slice(1) : path); + return end && !path.endsWith("/") ? `${path}/` : (!end && path.endsWith("/") ? path.slice(0, path.length - 1) : path); +} + +/** + * Turns a query string into an object. + * @param {string} queryString (example: ("test=123&hejsa=LOL&wuhuu")) + * @returns {Query} + */ +export function toQuery (queryString: string): Query { + + // If the query does not contain anything, return an empty object. + if (queryString.length === 0) { + return {}; + } + + // Grab the atoms (["test=123", "hejsa=LOL", "wuhuu"]) + const atoms = queryString.split("&"); + + // Split by the values ([["test", "123"], ["hejsa", "LOL"], ["wuhuu"]]) + const arrayMap = atoms.map(atom => atom.split("=")); + + // Assign the values to an object ({ test: "123", hejsa: "LOL", wuhuu: "" }) + return Object.assign({}, ...arrayMap.map(arr => ({ + [decodeURIComponent(arr[0])]: (arr.length > 1 ? decodeURIComponent(arr[1]) : "") + }))); +} + +/** + * Turns a query object into a string query. + * @param query + */ +export function toQueryString (query: Query): string { + return Object.entries(query) + .map(([key, value]) => `${key}${value != "" ? `=${encodeURIComponent(value)}` : ""}`) + .join("&"); +}