Skip to content
John Horback edited this page Sep 16, 2021 · 1 revision

The router will set up a global dom click handler to test for links. If a link is one that matches a root route (one without a parentRoute), then route handling will occur and .preventDefault() will be called so the browser does not navigate to the page.

Connected/disconnected callbacks will be used to keep track of all active routes.

Router.js

// triggers window.location-changed event
// routes get registered with the router
// when the body is clicked inspection will see if a root route matches
window.addEventListner("click", (event) => {
    // test if the source is a valid option

    // run through root routes (ones without a parentRoute)
    // to see if they match (routes have a matches method)
    // if no matches - return
    // otherwise
    // preventDefault()
    // Router.pushUrl(url)
    // dispatch location-changed - add sourceElement to event.detail
});


const routes = [];
class Router {
    addRoute(route) {
        routes[route.routeId] = route;
    }

    removeRoute(route) {
        delete routes[route.routeId];
    }

    getNextRouteId(pattern) {
        // returns nextId++:pattern
    }

    // URL HELPER METHODS
    // ALL push/replace state and trigger location-changed

    pushUrl(url) {
        //- pushUrl(url) - uses push state to push the URL on the history stack
    }
    replaceUrl(url) {
        // - replace(url) - uses replace state to replace the current history item (for inner page navigation such as tabs)
    }
    replaceUrlParams(url) {
        // - replaceUrlParams(params) - adds/removes parameters from the URL to match the ones provided.
    }
}

/** Used for parent and tail routes */
interface Route {
    prefix: string,
    path: string
}

interface StringKeyIndex {
    [key:string]:string
}

/** Contains the parsed route segments */
interface RouteData extends StringKeyIndex {}

/** Parsed URL by DomxLocation */
interface RouteLocation {
    href:string,
    pathname: string,
    queryParams: QueryParams
}

/** DomxRouteData element state property */
interface RouteState {
    url: string,
    matches: boolean,
    tail:Route,
    routeData:RouteData,
    queryParams:QueryParams,
    // informational
    routeId:string,
    parentRoute:Route,
    pattern:string,
    element:string,
    appendTo: string
}

/** Parsed query parameters */
interface QueryParams extends StringKeyIndex {}

domx-location.js

/**
 * Used by all domx-route elements to provide
 * a parsed window location and sets the last source element
 */
@customDataElement("domx-location")
class DomxLocation extends DataElement {

    @dataProperty()
    location:RouteLocation = {
        href: "",
        pathname: "",
        queryParms: {}
    };

    sourcElement:HTMLElement = null;

    @event("location-changed", {listenAt: "window"})
    locationChanged({detail:{sourceElement}}) {
        // parse window location and update this.location
        // set sourceElement
        StateChange.of("location")
            .next(updateLocation)
            .dispatch();
    }
}

domx-route-data.js

@customDataElement("domx-route-data", {stateIdProperty: "routeId"})
class DomxRouteData extends DataElement {
    routeId:string = null;
    parentRoute:Route = null;
    pattern:string = null;

    constructor() {
        super();
        this.routeId = Router.getNextRouteId(pattern)
    }

    // linked in domx-route and comes from
    // DomxLocation -> window.location-changed
    private __location:RouteLocation = {};
    set location(location:RouteLocation) { 
        this.__location = location;
        this.locationChanged();
    }

    state:RouteState {
        url: string,
        matches: boolean,
        tail:Route,
        routeData:RouteData,
        queryParams:QueryParams,
        // informational
        routeId:string,
        parentRoute:Route,
        pattern:string,
        element:string,
        appendTo: string
    };

    /** Called on window@location-changed */
    locationChanged() {
        // determine if this pattern / parentRoute / location matches
        // update the matches, tail, and routeData properties
        // dispatch change
    }

    /**
     * Returns true of this is a root route
     * and the pattern matches.
     * Used by the Router to determine
     * if a route link has been clicked.
     */
    testMatch(pathname:string) {
        if (parentRoute) {
            return false;
        }
        // else return true if pathname matches pattern!!!
    }

    connectedCallback() {
        Router.addRoute(this);
    }

    disconnectedCallback() {
        Router.removeRoute(this);
    }
}

Example rootState data

const rootState = {
    "domx-location": {
        href: "",
        pathname: "",
        queryParams: {}
    },
    "domx-route-data": {
        // routeId:pattern
        "1:/route/:pattern": {
            "state": {
                matches:boolean,
                tail:Route,
                routeData:RouteData,
                queryParams:QueryParams,
                routeId:string,
                parentRoute:Route,
                pattern:string,
                element:string,
                appendTo: string
            }
        }
    }
}

domx-route.js

class DomxRoute extends LitElement {
    @property({attribute:false}) 
    parentRoute:Route|null = null;

    @property({attribute:false})
    get tail():Route = { return _tail; };
    _tail = null;

    @property({type:String})
    pattern:string = "";

    @property({type:String})
    element:string|null = null;

    @property({attribute: "append-to"})
    appendTo:string = "parent"; // parent, body, or shadow query

    // this is used to declaratively set the parent route
    @property(attribute: "route-from")
    routeFrom:string|null = null;

    @property({type:Boolean})
    cache: false;

    @property({type:Number, attribute:"cache-count"})
    cacheCount: 10;

    navigate({replaceState:false, routeData, queryParams}) {
        if (!this.parentRoute) {
            // pushState using "/" + pattern
        } else {
            // pushState using parentRoute prefix + pattern
        }
    }

    cachedElements: {}, // key is stringified routedata, value is element

    isActive: false;
    activeElement:HTMLElement|null = null;
    activeRouteData:RouteData|null = null;
    activeSourceElement:HTMLElement|null = null;
    lastSourceElement:HTMLElement|null = null;

    @query("domx-route-data")
    $routeData = null;

    @query("domx-location")
    $location = null;

    connectedCallback() {
        // if there is a route-from, set that as the parentRoute
        // use this.getRootNode().querySelector(this.rootFrom)
    }

    render {
        return html`
            <domx-route-data
                .parentRoute="${this.parentRoute}"
                .pattern="${this.pattern}"
                .element="${this.element}"
                .appendTo="${this.appendTo}"
                @state-changed="${this.routeStateChanged}"
            ></domx-route-data>
            <domx-location
                @location-changed="${this.locationChanged}"
            ></domx-location>
        `;
    }

    locationChanged(event) {
        this.lastSourceElement = $location.sourceElement;
        $routeData.location = $location.location;
    }

    routeStateChanged() {
        // if active is changing from false to true (or if active
        //     and routeData has changed or
        //     if route tail has changed)
        // see if element already exists if so jump to trigger
        // if not create the element
        //    remove an element from cache if needed
        //    add the each routeData as attributes        
        //    add queryParams as a property
        //    update the activeRouteData, activeElement,
        //        and activeSourceElement
        //    add element to cache if needed
        // trigger route-active with element and sourceElement
        //    add the parentRoute as a property (use route tail)
        //    if not event.preventDefault called then
        //    append to the DOM according to append-to

        // if active is changing from true to false
        // trigger route-inactive with element and sourceElement
        //    if not event.preventDefault
        //    remove from the DOM and add to cache if needed
    }
}

Element Caching

Elements are hidden when inactive, when a route re-activates it will compare the routeParams to see if the element should be destroyed and re-created or just added back to the DOM.

If a route is removed from the DOM, any active/inactive element will also be removed

Route Data

When creating an element all route data (I.e. /path/:data1) will be added as attributes to the element, as well as the "routePath" attribute which will come from the tail of the route

Sub Routing

If a subroute matches, the parent element will not be hidden.

Example:

  • Parent - /users
  • Child - /users/:userId

The parent route is still active so nothing should happen with its element. There may be use cases where something else is desired that need to be identified.

Example:
location.pathname = /users/123/games/234
pattern = /users/:userId
tail would be \

prefix: /users/1234
path: /games/234
parentRoute = {prefix, path}
pattern = /games/:gameId \

Two ways to control sub-routing

  1. Within the same DOM tree use a route-from attribute which querys the shadow dom to map the two together; both parent and child are accessible so keeping the parentRoute synced should be easy.

  2. A parentRoute property is added to all created DOM nodes so they can use it directly

Drawbacks

There are other solutions available such as navigo so this is not necessary. But this would provide a better pattern using Custom Elements.

Rationale and alternatives

  • Why is this design the best in the space of possible designs?
  • What other designs have been considered and what is the rationale for not choosing them?
  • What is the impact of not doing this?

Unresolved questions

  • What questions need to be answered before implementation?
  • What dependencies need to be resolved before implementation?

Future possibilities

  • What features are dependent on this feature?
    • Demo App dx-league-app

Clone this wiki locally