From 7afc0017d501eea04f04c0c8e5dea8b74d887866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Mon, 5 Apr 2021 10:36:15 +0200 Subject: [PATCH 01/49] feat(router): wip --- .eslintrc | 1 + src/index.js | 1 + src/router.js | 925 ++++++++++++++++++++++++++++++++++++++++++++ test/spec/router.js | 188 +++++++++ types/index.d.ts | 36 ++ 5 files changed, 1151 insertions(+) create mode 100644 src/router.js create mode 100644 test/spec/router.js diff --git a/.eslintrc b/.eslintrc index bf4ae6a5..f40ae2ff 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,7 @@ "no-param-reassign": 0, "no-new-func": 0, "no-loop-func": 0, + "no-restricted-syntax": 0, "new-cap": 0, "prefer-destructuring": 0, "block-scoped-var": 0, diff --git a/src/index.js b/src/index.js index 3e8f8fea..e49ce2d2 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ export { default as parent } from "./parent.js"; export { default as children } from "./children.js"; export { default as render } from "./render.js"; export { default as store } from "./store.js"; +export { default as router } from "./router.js"; export { html, svg } from "./template/index.js"; diff --git a/src/router.js b/src/router.js new file mode 100644 index 00000000..53aafd35 --- /dev/null +++ b/src/router.js @@ -0,0 +1,925 @@ +import { defineElement, callbacksMap } from "./define.js"; +import * as cache from "./cache.js"; +import { dispatch, pascalToDash } from "./utils.js"; + +/* + +# TODO LIST + +* Nested routers: + * navigate push on self (nested parent) + +* is active route helper +* Transition effect + +*/ + +const connect = Symbol("router.connect"); +const routers = new WeakMap(); +const configs = new WeakMap(); +const routerSettings = new WeakMap(); + +function mapDeepElements(target, cb) { + cb(target); + + const walker = document.createTreeWalker( + target, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => + configs.get(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, + }, + false, + ); + + while (walker.nextNode()) { + const el = walker.currentNode; + cb(el); + if (el.shadowRoot) { + mapDeepElements(el.shadowRoot, cb); + } + } +} + +const scrollMap = new WeakMap(); +const focusMap = new WeakMap(); +function saveLayout(target) { + const focusEl = document.activeElement; + focusMap.set(target, target.contains(focusEl) ? focusEl : target); + const map = new Map(); + + if (!configs.get(target).nestedParent) { + map.set(window, { x: window.scrollX, y: window.scrollY }); + } + + mapDeepElements(target, el => { + if (el.scrollHeight > el.clientHeight || el.scrollLeft > el.clientLeft) { + map.set(el, { x: el.scrollLeft, y: el.scrollTop }); + } + }); + + scrollMap.set(target, map); +} + +let focusTarget; +function restoreLayout(target, clear) { + const focusEl = focusMap.get(target) || target; + + if (!focusTarget) { + requestAnimationFrame(() => { + focusTarget.focus(); + focusTarget = null; + }); + } + + focusTarget = focusEl; + + const map = scrollMap.get(target); + if (map) { + map.forEach( + clear + ? (_, el) => el.scrollTo(0, 0) + : ({ x, y }, el) => el.scrollTo(x, y), + ); + scrollMap.delete(target); + } +} + +const placeholder = Date.now(); +function setupBrowserUrl(browserUrl) { + if (!browserUrl) return null; + + const [pathname, search = ""] = browserUrl.split("?"); + + const searchParams = search ? search.split(",") : []; + const pathnameParams = []; + const normalizedPathname = pathname.replace(/:([^/]+)/g, (_, key) => { + if (searchParams.includes(key)) { + throw TypeError(`The '${key}' property already set in search parameters`); + } + pathnameParams.push(key); + return placeholder; + }); + + const parts = normalizedPathname.substr(1).split(placeholder); + + return { + browserUrl, + paramsKeys: [...searchParams, ...pathnameParams], + url(params, suppressErrors) { + let temp = normalizedPathname; + + if (pathnameParams.length) { + temp = temp.split(placeholder).reduce((acc, part, index) => { + if (index === 0) return part; + const key = pathnameParams[index - 1]; + + if (!hasOwnProperty.call(params, key) && !suppressErrors) { + throw Error(`The '${key}' parameter must be defined`); + } + + return `${acc}${params[key]}${part}`; + }); + } + + const url = new URL(temp, window.location.origin); + + if (suppressErrors) { + searchParams.forEach(key => { + url.searchParams.append(key, String(params[key])); + }); + } else { + Object.keys(params).forEach(key => { + if (pathnameParams.includes(key)) return; + + if (searchParams.includes(key)) { + url.searchParams.append(key, params[key]); + } else { + throw TypeError(`The '${key}' parameter is not supported`); + } + }); + } + + return url; + }, + match(url) { + const params = {}; + let temp = url.pathname; + + if (pathnameParams.length) { + for (let i = 0; i < parts.length; i += 1) { + if (temp === parts[i]) break; + if (!temp.length || temp[0] !== "/") return null; + + temp = temp.substr(1); + + if (temp.substr(0, parts[i].length) !== parts[i]) return null; + temp = temp + .substr(parts[i].length) + .replace(/^([^/]+)/, (_, value) => { + params[pathnameParams[i]] = value; + return ""; + }); + } + } else if (temp !== pathname) { + return null; + } + + url.searchParams.forEach((value, key) => { + if (searchParams.includes(key)) params[key] = value; + }); + + return params; + }, + }; +} + +function hasInStack(config, target) { + return config.stack.some(temp => { + if (temp === target) return true; + return hasInStack(temp, target); + }); +} + +function getNestedRouterSettings(name, view, options) { + const nestedRouters = Object.values(view) + .map(desc => routerSettings.get(desc)) + .filter(d => d); + + if (nestedRouters.length) { + if (nestedRouters.length > 1) { + throw TypeError( + `'${name}' view must contain at most one nested router: ${nestedRouters.length}`, + ); + } + + if (options.dialog) { + throw TypeError( + `Nested routers are not supported in dialogs. Remove the router factory from '${name}' view`, + ); + } + + if (options.url) { + throw TypeError( + `Views with nested routers must not have the url option. Remove either the router factory or the url option from '${name}' view`, + ); + } + } + return nestedRouters[0]; +} + +function setupViews(views, parent = null) { + if (typeof views === "function") views = views(); + + const result = Object.entries(views).map(([name, view]) => { + // eslint-disable-next-line no-use-before-define + const config = setupView(name, view, parent); + + if (parent && hasInStack(config, parent)) { + throw Error( + `${parent.name} cannot be in the stack of ${config.name} - ${config.name} already connected to ${parent.name}`, + ); + } + + return config; + }); + + return result; +} + +function setupView(name, view, parent) { + const id = pascalToDash(`${name}-view`); + + if (!view || typeof view !== "object") { + throw TypeError( + `${name} in the stack of ${ + parent.name + } must be an object instance: ${typeof view} - for import/export cycle, wrap stack option in a function`, + ); + } + + let config = configs.get(view); + + if (config && config.name !== name) { + throw Error( + `View definition for ${name} in ${parent.name} already connected to the router as ${config.name}`, + ); + } + + if (!config) { + let browserUrl = null; + + const options = { + dialog: false, + guard: false, + multiple: false, + ...view[connect], + }; + + const Constructor = defineElement(id, view); + callbacksMap.get(Constructor).push(restoreLayout); + + const writableParams = new Set(); + Object.keys(Constructor.prototype).forEach(key => { + const desc = Object.getOwnPropertyDescriptor(Constructor.prototype, key); + if (desc.set) writableParams.add(key); + }); + + const nestedRouterSettings = getNestedRouterSettings(name, view, options); + + if (options.dialog) { + callbacksMap.get(Constructor).push(host => { + const cb = event => { + if (event.key === "Escape") { + event.stopPropagation(); + window.history.go(-1); + } + }; + host.addEventListener("keydown", cb); + return () => { + host.removeEventListener("keydown", cb); + }; + }); + } + + if (options.url) { + if (options.dialog) { + throw Error( + `The 'url' option is not supported for dialogs - remove it from '${name}'`, + ); + } + if (typeof options.url !== "string") { + throw TypeError( + `The 'url' option in '${name}' must be a string: ${typeof options.url}`, + ); + } + browserUrl = setupBrowserUrl(options.url); + + callbacksMap.get(Constructor).unshift(_ => + cache.observe( + _, + connect, + host => browserUrl.url(host, true), + (host, url) => { + const state = window.history.state; + const entry = state[0]; + + if (entry.id === configs.get(host).id) { + entry.params = browserUrl.paramsKeys.reduce((acc, key) => { + acc[key] = String(host[key]); + return acc; + }, {}); + + window.history.replaceState(state, "", url); + } + }, + ), + ); + + browserUrl.paramsKeys.forEach(key => { + const desc = Object.getOwnPropertyDescriptor( + Constructor.prototype, + key, + ); + if (!desc || !desc.set) { + throw Error( + `'${key}' parameter in the url is not supported by the '${name}'`, + ); + } + }); + } + + let guard; + if (options.guard) { + const el = new Constructor(); + guard = () => { + try { + return options.guard(el); + } catch (e) { + console.error(e); + return false; + } + }; + } + + config = { + id, + name, + view, + dialog: options.dialog, + multiple: options.multiple, + guard, + parent: undefined, + parentsWithGuards: undefined, + stack: [], + nestedParent: undefined, + nested: nestedRouterSettings ? nestedRouterSettings.roots : null, + ...(browserUrl || { + url(params) { + const url = new URL(`@${id}`, window.location.origin); + + Object.keys(params).forEach(key => { + if (writableParams.has(key)) { + url.searchParams.append(key, params[key]); + } else { + throw TypeError(`The '${key}' parameter is not supported`); + } + }); + + return url; + }, + match(url) { + const params = {}; + url.searchParams.forEach((value, key) => { + if (writableParams.has(key)) params[key] = value; + }); + + return params; + }, + }), + create() { + const el = new Constructor(); + configs.set(el, config); + + el.style.outline = "none"; + el.tabIndex = 0; + + return el; + }, + getEntry(params = {}, other) { + const entry = { id, params, ...other }; + const guardConfig = config.parentsWithGuards.find(c => !c.guard()); + + if (guardConfig) { + return guardConfig.getEntry(params, { from: entry }); + } + + if (config.guard && config.guard()) { + return { ...config.stack[0].getEntry(params) }; + } + + if (config.nestedParent) { + return config.nestedParent.getEntry(params, { nested: entry }); + } + + return entry; + }, + }; + + configs.set(view, config); + configs.set(Constructor, config); + + if (options.stack) { + if (options.dialog) { + throw Error( + `The 'stack' option is not supported for dialogs - remove it from '${name}'`, + ); + } + config.stack = setupViews(options.stack, config); + } + + if (nestedRouterSettings) { + config.stack = config.stack.concat(nestedRouterSettings.roots); + } + } + + return config; +} + +function getConfigById(id) { + const Constructor = customElements.get(id); + return configs.get(Constructor); +} + +function getUrl(view, params = {}) { + const config = configs.get(view); + if (!config) { + throw Error(`Provided view is not connected to the router`); + } + + return config.url(params); +} + +function getBackUrl(params = {}) { + const state = window.history.state; + if (!state) return ""; + + let config; + + if (state.length > 1) { + const prevEntry = state[1]; + config = getConfigById(prevEntry.id); + + if (!config.guard) { + return config.url({ ...prevEntry.params, ...params }); + } + } else { + const currentConfig = getConfigById(state[0].id); + if (currentConfig.parent) { + config = currentConfig.parent; + } + } + + if (config) { + if (config.guard) { + config = config.parent; + while (config && config.guard) { + config = config.parent; + } + } + + if (config) { + return config.url(params); + } + } + + return ""; +} + +function getGuardUrl(params = {}) { + const state = window.history.state; + if (!state) return ""; + + const entry = state[0]; + + if (entry.from) { + const config = getConfigById(entry.from.id); + return config.url({ ...entry.from.params, ...params }); + } + + const config = getConfigById(entry.id); + return config.stack[0] ? config.stack[0].url(params) : ""; +} + +function getCurrentUrl(params = {}) { + const state = window.history.state; + if (!state) return ""; + + const entry = state[0]; + const config = getConfigById(entry.id); + return config.url({ ...entry.params, ...params }); +} + +function isActive(...views) { + const state = window.history.state; + if (!state) return false; + + return views.some(view => { + const config = configs.get(view); + if (!config) { + throw TypeError("The first argument must be connected view definition"); + } + + let entry = state[0]; + while (entry) { + if (entry.id === config.id) return true; + entry = entry.nested; + } + + return false; + }); +} + +function handleNavigate(event) { + if (event.defaultPrevented) return; + + let url; + + switch (event.type) { + case "click": { + if (event.ctrlKey || event.metaKey) return; + const anchorEl = event + .composedPath() + .find(el => el instanceof HTMLAnchorElement); + + if (anchorEl) { + url = new URL(anchorEl.href, window.location.origin); + } + break; + } + case "submit": { + if (event.target.action) { + url = new URL(event.target.action, window.location.origin); + } + break; + } + default: + return; + } + + if (url && url.origin === window.location.origin) { + const target = event + .composedPath() + .reverse() + .find(el => routers.has(el)); + + dispatch(target, "navigate", { detail: { url, event } }); + } +} + +let activePromise; +function resolveEvent(event, promise) { + event.preventDefault(); + activePromise = promise; + + const path = event.composedPath(); + const pseudoEvent = { + type: event.type, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + target: event.target, + defaultPrevented: false, + preventDefault: () => {}, + composedPath: () => path, + }; + + promise.then(() => { + if (promise === activePromise) { + requestAnimationFrame(() => { + handleNavigate(pseudoEvent); + }); + activePromise = null; + } + }); +} + +function deepEach(stack, cb, parent, set = new WeakSet()) { + stack + .filter(c => { + if (set.has(c)) return false; + + set.add(c); + cb(c, parent); + + return c.stack.length; + }) + .forEach(c => { + deepEach(c.stack, cb, c, set); + }); +} + +function resolveStack(state, settings) { + const reducedState = state.reduce((acc, entry, index) => { + if ( + index === 0 || + state[index - 1].id !== entry.id || + getConfigById(entry.id).multiple + ) { + acc.push(entry); + } + return acc; + }, []); + const offset = settings.stack.length - reducedState.length; + + settings.stack = reducedState.map(({ id }, index) => { + const prevView = settings.stack[index + offset]; + const config = getConfigById(id); + let nextView; + + if (prevView) { + const prevConfig = configs.get(prevView); + if (config.id !== prevConfig.id) { + return config.create(); + } + nextView = prevView; + } else { + nextView = config.create(); + } + + if (index !== 0) { + cache.suspend(nextView); + } else if (nextView === prevView) { + cache.unsuspend(nextView); + } + + return nextView; + }); + + Object.assign(settings.stack[0], state[0].params); + + const nestedFlush = routers.get(settings.stack[0]); + if (nestedFlush) nestedFlush(); +} + +function findSameEntryIndex(state, entry) { + return state.findIndex(e => { + let temp = entry; + while (e) { + if (e.id !== temp.id) return false; + + const config = getConfigById(e.id); + if (!config.multiple && !entry.nested) return true; + + if ( + !Object.entries(e.params).every( + ([key, value]) => entry.params[key] === value, + ) + ) { + return false; + } + + e = e.nested; + temp = entry.nested; + } + return true; + }); +} + +function connectRootRouter(host, invalidate, settings) { + function flush() { + resolveStack(window.history.state, settings); + invalidate(); + } + + function navigateBack(offset, entry, nextUrl) { + const stateLength = window.history.state.length; + const targetEntry = window.history.state[offset]; + const pushOffset = offset < stateLength - 1 && stateLength > 2 ? 1 : 0; + + if (targetEntry && entry.id === targetEntry.id) { + entry = { ...targetEntry, ...entry }; + } + + offset = -(offset + pushOffset); + + const replace = popStateEvent => { + if (popStateEvent) { + window.removeEventListener("popstate", replace); + window.addEventListener("popstate", flush); + } + + const state = window.history.state; + const method = pushOffset ? "pushState" : "replaceState"; + const nextState = [entry, ...state.slice(pushOffset ? 0 : 1)]; + + window.history[method](nextState, "", nextUrl); + flush(); + }; + + if (offset) { + window.removeEventListener("popstate", flush); + window.addEventListener("popstate", replace); + window.history.go(offset); + } else { + replace(); + } + } + + function getEntryFromURL(url) { + const config = getConfigById(url.pathname.substr(2)); + + if (!config) { + for (let i = 0; i < settings.entryPoints.length; i += 1) { + const entryPoint = settings.entryPoints[i]; + const params = entryPoint.match(url); + if (params) return entryPoint.getEntry(params); + } + + return null; + } + + return config.getEntry(config.match(url)); + } + + function navigate(event) { + const nextEntry = getEntryFromURL(event.detail.url); + if (!nextEntry) return; + + event.stopPropagation(); + + if (event.detail.event) { + event.detail.event.preventDefault(); + } + + const state = window.history.state; + const nextConfig = getConfigById(nextEntry.id); + const currentEntry = state[0]; + + let nextUrl = ""; + if (nextConfig.browserUrl) { + nextUrl = nextConfig.url(nextEntry.params); + } + + cache.suspend(settings.stack[0]); + + if (nextEntry.id === currentEntry.id) { + const offset = findSameEntryIndex(state, nextEntry); + if (offset > -1) { + navigateBack(offset, nextEntry, nextUrl || settings.url); + } else { + window.history.pushState([nextEntry, ...state], "", nextUrl); + flush(); + } + } else { + let offset = state.findIndex(({ id }) => nextEntry.id === id); + if (offset > -1) { + navigateBack(offset, nextEntry, nextUrl || settings.url); + } else { + const currentConfig = getConfigById(currentEntry.id); + if ( + nextConfig.dialog || + (hasInStack(currentConfig, nextConfig) && !currentConfig.guard) + ) { + window.history.pushState([nextEntry, ...state], "", nextUrl); + flush(); + } else { + offset = state + .slice(1) + .findIndex(({ id }) => hasInStack(getConfigById(id), nextConfig)); + + navigateBack( + offset > -1 ? offset : state.length - 1, + nextEntry, + nextUrl || settings.url, + ); + } + } + } + } + + routers.set(host, flush); + + if (!window.history.state) { + const entry = + getEntryFromURL(new URL(window.location.href)) || + settings.roots[0].getEntry(settings.params(host)); + + window.history.scrollRestoration = "manual"; + window.history.replaceState([entry], "", settings.url); + flush(); + } else { + const state = window.history.state; + let i; + for (i = state.length - 1; i >= 0; i -= 1) { + const entry = state[i]; + const config = getConfigById(entry.id); + if (!config || (config.dialog && settings.stack.length === 0)) { + if (state.length > 1) { + window.history.go(-(state.length - i - 1)); + } else { + window.history.replaceState( + [settings.roots[0].getEntry()], + "", + settings.url, + ); + flush(); + } + break; + } + } + if (i < 0) flush(); + } + + window.addEventListener("popstate", flush); + + host.addEventListener("click", handleNavigate); + host.addEventListener("submit", handleNavigate); + host.addEventListener("navigate", navigate); + + return () => { + window.removeEventListener("popstate", flush); + + host.removeEventListener("click", handleNavigate); + host.removeEventListener("submit", handleNavigate); + host.removeEventListener("navigate", navigate); + + routers.delete(host); + }; +} + +function connectNestedRouter(host, invalidate, settings) { + const viewConfig = configs.get(host); + if (!viewConfig) return false; + + function getNestedState() { + return window.history.state.map(entry => { + while (entry) { + if (entry.id === viewConfig.id) return entry.nested; + entry = entry.nested; + } + return entry; + }); + } + + function flush() { + resolveStack(getNestedState(), settings); + invalidate(); + } + + if (!getNestedState()[0]) { + window.history.replaceState( + [settings.roots[0].getEntry(), ...window.history.state.slice(1)], + "", + ); + } + + routers.set(host, flush); + flush(); + + return () => { + routers.delete(host); + }; +} + +function router(views, settings = {}) { + settings = { + url: settings.url || "/", + params: settings.params || (() => ({})), + roots: setupViews(views), + stack: [], + entryPoints: [], + }; + + if (!settings.roots.length) { + throw TypeError( + `The first argument must be a non-empty map of views: ${views}`, + ); + } + + deepEach(settings.roots, (c, parent) => { + c.parent = parent; + + if (parent && parent.nested && parent.nested.includes(c)) { + c.nestedParent = parent; + } + + let tempParent = parent; + c.parentsWithGuards = []; + while (tempParent) { + if (tempParent.guard) c.parentsWithGuards.unshift(tempParent); + tempParent = tempParent.parent; + } + + if (c.browserUrl) settings.entryPoints.push(c); + }); + + const desc = { + get: (host, lastValue) => { + if (lastValue && lastValue[0] !== settings.stack[0]) { + saveLayout(lastValue[0]); + } + + return settings.stack + .slice(0, settings.stack.findIndex(el => !configs.get(el).dialog) + 1) + .reverse(); + }, + connect: (host, key, invalidate) => + configs.get(host) + ? connectNestedRouter(host, invalidate, settings) + : connectRootRouter(host, invalidate, settings), + }; + + routerSettings.set(desc, settings); + + return desc; +} + +export default Object.assign(router, { + connect, + url: getUrl, + backUrl: getBackUrl, + currentUrl: getCurrentUrl, + guardUrl: getGuardUrl, + isActive, + resolve: resolveEvent, +}); diff --git a/test/spec/router.js b/test/spec/router.js new file mode 100644 index 00000000..b0bc78b6 --- /dev/null +++ b/test/spec/router.js @@ -0,0 +1,188 @@ +import { dispatch, router } from "../../src/index.js"; + +fdescribe("router:", () => { + let el; + let spy; + let prop; + let disconnect; + let href; + + function navigate(url) { + dispatch(el, "navigate", { detail: { url } }); + } + + beforeEach(() => { + el = document.createElement("div"); + spy = jasmine.createSpy(); + + href = window.location.href; + window.history.replaceState(null, ""); + }); + + afterEach(() => { + if (disconnect) { + disconnect(); + disconnect = null; + } + + window.history.replaceState(null, "", href); + }); + + describe("for root router", () => { + const views = { + Home: {}, + One: { + [router.connect]: { + url: "/one", + }, + }, + Two: {}, + Dialog: { + [router.connect]: { + dialog: true, + }, + }, + }; + + describe("connected root router", () => { + beforeEach(() => { + prop = router(views); + }); + + it("throws for wrong first argument", () => { + expect(() => { + router(); + }).toThrow(); + expect(() => { + router({}); + }).toThrow(); + }); + + it("returns a default view", () => { + disconnect = prop.connect(el, "", spy); + + const list = prop.get(el); + + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Element); + expect(list[0].constructor.hybrids).toBe(views.Home); + }); + + it("returns a view by matching URL", () => { + window.history.replaceState(null, "", "/one"); + + disconnect = prop.connect(el, "", spy); + const list = prop.get(el); + + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Element); + expect(list[0].constructor.hybrids).toBe(views.One); + }); + + it("sets a view to window history", () => { + disconnect = prop.connect(el, "", spy); + expect(window.history.state).toBeInstanceOf(Array); + expect(window.history.state.length).toBe(1); + }); + + it("returns a view saved in window history", () => { + window.history.replaceState([{ id: "two-view", params: {} }], ""); + disconnect = prop.connect(el, "", spy); + const list = prop.get(el); + + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Element); + expect(list[0].constructor.hybrids).toBe(views.Two); + }); + + it("returns a default view when saved view is not found", () => { + window.history.replaceState( + [{ id: "some-other-view", params: {} }], + "", + ); + disconnect = prop.connect(el, "", spy); + const list = prop.get(el); + + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Element); + expect(list[0].constructor.hybrids).toBe(views.Home); + }); + + it("goes back when dialog element is on the top of the stack", done => { + window.history.replaceState([{ id: "two-view", params: {} }], ""); + window.history.pushState( + [ + { id: "dialog-view", params: {} }, + { id: "two-view", params: {} }, + ], + "", + ); + + disconnect = prop.connect(el, "", () => { + const list = prop.get(el); + + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Element); + expect(list[0].constructor.hybrids).toBe(views.Two); + done(); + }); + }); + + it("does not go back for dialog view when reconnecting (HMR)", done => { + disconnect = prop.connect(el, "", spy); + navigate(router.url(views.Dialog)); + + disconnect(); + + disconnect = prop.connect(el, "", () => { + const list = prop.get(el); + expect(list[1]).toBeInstanceOf(Element); + expect(list[1].constructor.hybrids).toBe(views.Dialog); + done(); + }); + }); + }); + }); + + describe("[router.connect] -", () => { + describe("'dialog'", () => { + it("pushes new state for dialog view"); + it("pushes new state for dialog not from the stack ???"); + }); + + describe("'url'", () => {}); + + describe("'multiple'", () => { + describe("when 'true'", () => { + it( + "navigate pushes new state for the same id when other params with multiple option is set", + ); + it("navigate moves back to state where params are equal"); + }); + describe("when 'false'", () => { + it("replaces state for the same view"); + }); + }); + + describe("'guard'", () => { + it("displays guard parent when condition is not met"); + it("displays the first view from own stack when condition is met"); + }); + + describe("'stack'", () => { + describe("for view from own stack", () => { + it("pushes new state for view from stack"); + it("moves back to the view from stack"); + }); + + describe("for view not from own stack", () => { + it("finds a common parent, clears stack, and pushes new state"); + }); + }); + }); + + describe("view layout", () => { + it("saves scroll positions"); + it("saves the latest focused element"); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index fcb67bac..de19db90 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -168,6 +168,42 @@ declare namespace hybrids { ): number; } + /* Router */ + interface RouterOptions { + url?: string; + params?: (host: E) => object; + } + + type View = Hybrids & { + __router__connect__?: { + url?: string; + multiple?: boolean; + guard?: (host: E) => any; + stack?: MapOfViews; + }; + }; + + interface MapOfViews { + [tagName: string]: View; + } + + function router( + views: MapOfViews, + options: RouterOptions, + ): Descriptor; + + namespace router { + const connect = "__router__connect__"; + + function url(view: V, params?: Record): string; + function backUrl(params?: Record): string; + function guardUrl(params?: Record): string; + function currentUrl(params?: Record): string; + function isActive(...views: V[]): boolean; + + function resolve(event: Event, promise: Promise): void; + } + /* Utils */ function dispatch( From a2eac97f8cb4f947d7bb12fd2430f9631c909704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 21 Apr 2021 11:07:25 +0200 Subject: [PATCH 02/49] Fix save & restore layout --- src/router.js | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/router.js b/src/router.js index 53aafd35..dee71316 100644 --- a/src/router.js +++ b/src/router.js @@ -49,12 +49,13 @@ function saveLayout(target) { const map = new Map(); if (!configs.get(target).nestedParent) { - map.set(window, { x: window.scrollX, y: window.scrollY }); + const el = document.scrollingElement; + map.set(el, { left: el.scrollLeft, top: el.scrollTop }); } mapDeepElements(target, el => { - if (el.scrollHeight > el.clientHeight || el.scrollLeft > el.clientLeft) { - map.set(el, { x: el.scrollLeft, y: el.scrollTop }); + if (el.scrollLeft || el.scrollTop) { + map.set(el, { left: el.scrollLeft, top: el.scrollTop }); } }); @@ -67,7 +68,7 @@ function restoreLayout(target, clear) { if (!focusTarget) { requestAnimationFrame(() => { - focusTarget.focus(); + focusTarget.focus({ preventScroll: true }); focusTarget = null; }); } @@ -75,13 +76,20 @@ function restoreLayout(target, clear) { focusTarget = focusEl; const map = scrollMap.get(target); - if (map) { - map.forEach( - clear - ? (_, el) => el.scrollTo(0, 0) - : ({ x, y }, el) => el.scrollTo(x, y), - ); + + if (map && !clear) { + Promise.resolve().then(() => { + map.forEach((pos, el) => { + el.scrollLeft = pos.left; + el.scrollTop = pos.top; + }); + }); + scrollMap.delete(target); + } else if (!configs.get(target).nestedParent) { + const el = document.scrollingElement; + el.scrollLeft = 0; + el.scrollTop = 0; } } @@ -611,6 +619,10 @@ function resolveStack(state, settings) { }, []); const offset = settings.stack.length - reducedState.length; + if (offset < 0 && settings.stack.length) { + saveLayout(settings.stack[0]); + } + settings.stack = reducedState.map(({ id }, index) => { const prevView = settings.stack[index + offset]; const config = getConfigById(id); @@ -894,15 +906,10 @@ function router(views, settings = {}) { }); const desc = { - get: (host, lastValue) => { - if (lastValue && lastValue[0] !== settings.stack[0]) { - saveLayout(lastValue[0]); - } - - return settings.stack + get: () => + settings.stack .slice(0, settings.stack.findIndex(el => !configs.get(el).dialog) + 1) - .reverse(); - }, + .reverse(), connect: (host, key, invalidate) => configs.get(host) ? connectNestedRouter(host, invalidate, settings) From 2e38fa0a00f23b59e1026d0b13d629f6eb35b15c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 21 Apr 2021 12:18:17 +0200 Subject: [PATCH 03/49] Add prefix option --- src/router.js | 14 +++++++------- types/index.d.ts | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/router.js b/src/router.js index dee71316..57273545 100644 --- a/src/router.js +++ b/src/router.js @@ -216,12 +216,12 @@ function getNestedRouterSettings(name, view, options) { return nestedRouters[0]; } -function setupViews(views, parent = null) { +function setupViews(views, prefix = "view", parent = null) { if (typeof views === "function") views = views(); const result = Object.entries(views).map(([name, view]) => { // eslint-disable-next-line no-use-before-define - const config = setupView(name, view, parent); + const config = setupView(name, view, prefix, parent); if (parent && hasInStack(config, parent)) { throw Error( @@ -235,8 +235,8 @@ function setupViews(views, parent = null) { return result; } -function setupView(name, view, parent) { - const id = pascalToDash(`${name}-view`); +function setupView(name, view, prefix, parent) { + const id = `${pascalToDash(prefix)}-${pascalToDash(name)}`; if (!view || typeof view !== "object") { throw TypeError( @@ -423,7 +423,7 @@ function setupView(name, view, parent) { `The 'stack' option is not supported for dialogs - remove it from '${name}'`, ); } - config.stack = setupViews(options.stack, config); + config.stack = setupViews(options.stack, prefix, config); } if (nestedRouterSettings) { @@ -810,7 +810,7 @@ function connectRootRouter(host, invalidate, settings) { window.history.go(-(state.length - i - 1)); } else { window.history.replaceState( - [settings.roots[0].getEntry()], + [settings.roots[0].getEntry(settings.params(host))], "", settings.url, ); @@ -877,7 +877,7 @@ function router(views, settings = {}) { settings = { url: settings.url || "/", params: settings.params || (() => ({})), - roots: setupViews(views), + roots: setupViews(views, settings.prefix), stack: [], entryPoints: [], }; diff --git a/types/index.d.ts b/types/index.d.ts index de19db90..cd31d00f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -171,6 +171,7 @@ declare namespace hybrids { /* Router */ interface RouterOptions { url?: string; + prefix?: string; params?: (host: E) => object; } From 0a9f4fb67b7e5e4d89d81e00f62911332e577a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 21 Apr 2021 15:06:24 +0200 Subject: [PATCH 04/49] Fix false boolean value in URL --- src/router.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/router.js b/src/router.js index 57273545..3bb3109c 100644 --- a/src/router.js +++ b/src/router.js @@ -141,7 +141,7 @@ function setupBrowserUrl(browserUrl) { if (pathnameParams.includes(key)) return; if (searchParams.includes(key)) { - url.searchParams.append(key, params[key]); + url.searchParams.append(key, params[key] || ""); } else { throw TypeError(`The '${key}' parameter is not supported`); } @@ -368,7 +368,7 @@ function setupView(name, view, prefix, parent) { Object.keys(params).forEach(key => { if (writableParams.has(key)) { - url.searchParams.append(key, params[key]); + url.searchParams.append(key, params[key] || ""); } else { throw TypeError(`The '${key}' parameter is not supported`); } From 6848d22ef26c9cbefe417dc275a3e800d5aa966d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 21 Apr 2021 15:29:21 +0200 Subject: [PATCH 05/49] Search in stack for active view --- src/router.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/router.js b/src/router.js index 3bb3109c..3336ea5f 100644 --- a/src/router.js +++ b/src/router.js @@ -520,7 +520,8 @@ function isActive(...views) { let entry = state[0]; while (entry) { - if (entry.id === config.id) return true; + const target = getConfigById(entry.id); + if (target === config || hasInStack(config, target)) return true; entry = entry.nested; } @@ -758,6 +759,7 @@ function connectRootRouter(host, invalidate, settings) { const offset = findSameEntryIndex(state, nextEntry); if (offset > -1) { navigateBack(offset, nextEntry, nextUrl || settings.url); + restoreLayout(settings.stack[0], true); } else { window.history.pushState([nextEntry, ...state], "", nextUrl); flush(); From efac37def3d36f9680c19fc0e4b37d2ab865a228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 21 Apr 2021 15:52:02 +0200 Subject: [PATCH 06/49] Temporary disable focus --- src/router.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/router.js b/src/router.js index 3336ea5f..07f16371 100644 --- a/src/router.js +++ b/src/router.js @@ -62,18 +62,18 @@ function saveLayout(target) { scrollMap.set(target, map); } -let focusTarget; +// let focusTarget; function restoreLayout(target, clear) { - const focusEl = focusMap.get(target) || target; + // const focusEl = focusMap.get(target) || target; - if (!focusTarget) { - requestAnimationFrame(() => { - focusTarget.focus({ preventScroll: true }); - focusTarget = null; - }); - } + // if (!focusTarget) { + // requestAnimationFrame(() => { + // focusTarget.focus({ preventScroll: true }); + // focusTarget = null; + // }); + // } - focusTarget = focusEl; + // focusTarget = focusEl; const map = scrollMap.get(target); From ba4d1aa04b3161d3cf3ffd3a944ee5b8932d2ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Thu, 22 Apr 2021 14:18:45 +0200 Subject: [PATCH 07/49] Better scrolling performance --- src/router.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/router.js b/src/router.js index 07f16371..dadd4d23 100644 --- a/src/router.js +++ b/src/router.js @@ -88,8 +88,10 @@ function restoreLayout(target, clear) { scrollMap.delete(target); } else if (!configs.get(target).nestedParent) { const el = document.scrollingElement; - el.scrollLeft = 0; - el.scrollTop = 0; + Promise.resolve().then(() => { + el.scrollLeft = 0; + el.scrollTop = 0; + }); } } From e96958ff5e310f1a8757f8ca1bbfa28d5f57100d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Sun, 25 Apr 2021 09:33:35 +0200 Subject: [PATCH 08/49] Fix isActive type --- types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index cd31d00f..6de77b0e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -200,7 +200,7 @@ declare namespace hybrids { function backUrl(params?: Record): string; function guardUrl(params?: Record): string; function currentUrl(params?: Record): string; - function isActive(...views: V[]): boolean; + function isActive(...views: View[]): boolean; function resolve(event: Event, promise: Promise): void; } From b86c6de3b96b604b7cffc49b48306a6f986f4eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Sun, 25 Apr 2021 22:13:04 +0200 Subject: [PATCH 09/49] params option --- src/router.js | 20 ++++++++++++++------ test/spec/router.js | 10 +++++----- types/index.d.ts | 2 +- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/router.js b/src/router.js index dadd4d23..697aa2f3 100644 --- a/src/router.js +++ b/src/router.js @@ -798,7 +798,7 @@ function connectRootRouter(host, invalidate, settings) { if (!window.history.state) { const entry = getEntryFromURL(new URL(window.location.href)) || - settings.roots[0].getEntry(settings.params(host)); + settings.roots[0].getEntry(); window.history.scrollRestoration = "manual"; window.history.replaceState([entry], "", settings.url); @@ -814,7 +814,7 @@ function connectRootRouter(host, invalidate, settings) { window.history.go(-(state.length - i - 1)); } else { window.history.replaceState( - [settings.roots[0].getEntry(settings.params(host))], + [settings.roots[0].getEntry()], "", settings.url, ); @@ -880,7 +880,7 @@ function connectNestedRouter(host, invalidate, settings) { function router(views, settings = {}) { settings = { url: settings.url || "/", - params: settings.params || (() => ({})), + params: settings.params || [], roots: setupViews(views, settings.prefix), stack: [], entryPoints: [], @@ -910,10 +910,18 @@ function router(views, settings = {}) { }); const desc = { - get: () => - settings.stack + get: host => { + const stack = settings.stack .slice(0, settings.stack.findIndex(el => !configs.get(el).dialog) + 1) - .reverse(), + .reverse(); + + const el = stack[stack.length - 1]; + settings.params.forEach(key => { + el[key] = host[key]; + }); + + return stack; + }, connect: (host, key, invalidate) => configs.get(host) ? connectNestedRouter(host, invalidate, settings) diff --git a/test/spec/router.js b/test/spec/router.js index b0bc78b6..d5a43926 100644 --- a/test/spec/router.js +++ b/test/spec/router.js @@ -86,7 +86,7 @@ fdescribe("router:", () => { }); it("returns a view saved in window history", () => { - window.history.replaceState([{ id: "two-view", params: {} }], ""); + window.history.replaceState([{ id: "view-two", params: {} }], ""); disconnect = prop.connect(el, "", spy); const list = prop.get(el); @@ -97,7 +97,7 @@ fdescribe("router:", () => { it("returns a default view when saved view is not found", () => { window.history.replaceState( - [{ id: "some-other-view", params: {} }], + [{ id: "view-some-other", params: {} }], "", ); disconnect = prop.connect(el, "", spy); @@ -109,11 +109,11 @@ fdescribe("router:", () => { }); it("goes back when dialog element is on the top of the stack", done => { - window.history.replaceState([{ id: "two-view", params: {} }], ""); + window.history.replaceState([{ id: "view-two", params: {} }], ""); window.history.pushState( [ - { id: "dialog-view", params: {} }, - { id: "two-view", params: {} }, + { id: "view-dialog", params: {} }, + { id: "view-two", params: {} }, ], "", ); diff --git a/types/index.d.ts b/types/index.d.ts index 6de77b0e..1e442fcf 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -172,7 +172,7 @@ declare namespace hybrids { interface RouterOptions { url?: string; prefix?: string; - params?: (host: E) => object; + params?: Array; } type View = Hybrids & { From ea04d3caae3912b9431ef41588507453c449bd47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Sun, 25 Apr 2021 22:14:54 +0200 Subject: [PATCH 10/49] remove currentUrl method --- src/router.js | 10 ---------- types/index.d.ts | 1 - 2 files changed, 11 deletions(-) diff --git a/src/router.js b/src/router.js index 697aa2f3..2a75a5c6 100644 --- a/src/router.js +++ b/src/router.js @@ -501,15 +501,6 @@ function getGuardUrl(params = {}) { return config.stack[0] ? config.stack[0].url(params) : ""; } -function getCurrentUrl(params = {}) { - const state = window.history.state; - if (!state) return ""; - - const entry = state[0]; - const config = getConfigById(entry.id); - return config.url({ ...entry.params, ...params }); -} - function isActive(...views) { const state = window.history.state; if (!state) return false; @@ -937,7 +928,6 @@ export default Object.assign(router, { connect, url: getUrl, backUrl: getBackUrl, - currentUrl: getCurrentUrl, guardUrl: getGuardUrl, isActive, resolve: resolveEvent, diff --git a/types/index.d.ts b/types/index.d.ts index 1e442fcf..eeae697c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -199,7 +199,6 @@ declare namespace hybrids { function url(view: V, params?: Record): string; function backUrl(params?: Record): string; function guardUrl(params?: Record): string; - function currentUrl(params?: Record): string; function isActive(...views: View[]): boolean; function resolve(event: Event, promise: Promise): void; From aba846db5bc79bea5dcd34dc8cf227a68e884aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Mon, 26 Apr 2021 10:17:09 +0200 Subject: [PATCH 11/49] Add replace view optino --- src/router.js | 22 ++++++++++++---------- types/index.d.ts | 1 + 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/router.js b/src/router.js index 2a75a5c6..c7ba56d5 100644 --- a/src/router.js +++ b/src/router.js @@ -6,10 +6,6 @@ import { dispatch, pascalToDash } from "./utils.js"; # TODO LIST -* Nested routers: - * navigate push on self (nested parent) - -* is active route helper * Transition effect */ @@ -77,11 +73,11 @@ function restoreLayout(target, clear) { const map = scrollMap.get(target); - if (map && !clear) { + if (map) { Promise.resolve().then(() => { map.forEach((pos, el) => { - el.scrollLeft = pos.left; - el.scrollTop = pos.top; + el.scrollLeft = clear ? 0 : pos.left; + el.scrollTop = clear ? 0 : pos.top; }); }); @@ -263,6 +259,7 @@ function setupView(name, view, prefix, parent) { dialog: false, guard: false, multiple: false, + replace: false, ...view[connect], }; @@ -358,6 +355,7 @@ function setupView(name, view, prefix, parent) { view, dialog: options.dialog, multiple: options.multiple, + replace: options.replace, guard, parent: undefined, parentsWithGuards: undefined, @@ -612,8 +610,9 @@ function resolveStack(state, settings) { return acc; }, []); const offset = settings.stack.length - reducedState.length; + const lastStackView = settings.stack[0]; - if (offset < 0 && settings.stack.length) { + if (offset <= 0 && settings.stack.length) { saveLayout(settings.stack[0]); } @@ -624,7 +623,7 @@ function resolveStack(state, settings) { if (prevView) { const prevConfig = configs.get(prevView); - if (config.id !== prevConfig.id) { + if (config.id !== prevConfig.id || config.replace) { return config.create(); } nextView = prevView; @@ -641,6 +640,10 @@ function resolveStack(state, settings) { return nextView; }); + if (settings.stack[0] === lastStackView) { + restoreLayout(lastStackView, true); + } + Object.assign(settings.stack[0], state[0].params); const nestedFlush = routers.get(settings.stack[0]); @@ -752,7 +755,6 @@ function connectRootRouter(host, invalidate, settings) { const offset = findSameEntryIndex(state, nextEntry); if (offset > -1) { navigateBack(offset, nextEntry, nextUrl || settings.url); - restoreLayout(settings.stack[0], true); } else { window.history.pushState([nextEntry, ...state], "", nextUrl); flush(); diff --git a/types/index.d.ts b/types/index.d.ts index eeae697c..baefd6f6 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -179,6 +179,7 @@ declare namespace hybrids { __router__connect__?: { url?: string; multiple?: boolean; + replace?: boolean; guard?: (host: E) => any; stack?: MapOfViews; }; From 2f6a590c45aa9bb5d208bf77348a9bb95daa89ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 28 Apr 2021 09:59:25 +0200 Subject: [PATCH 12/49] isActive takes view or array of views --- src/router.js | 8 ++++++-- types/index.d.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/router.js b/src/router.js index c7ba56d5..56cc13e9 100644 --- a/src/router.js +++ b/src/router.js @@ -499,10 +499,12 @@ function getGuardUrl(params = {}) { return config.stack[0] ? config.stack[0].url(params) : ""; } -function isActive(...views) { +function isActive(views, deep = false) { const state = window.history.state; if (!state) return false; + views = [].concat(views); + return views.some(view => { const config = configs.get(view); if (!config) { @@ -512,7 +514,9 @@ function isActive(...views) { let entry = state[0]; while (entry) { const target = getConfigById(entry.id); - if (target === config || hasInStack(config, target)) return true; + if (target === config || (deep && hasInStack(config, target))) { + return true; + } entry = entry.nested; } diff --git a/types/index.d.ts b/types/index.d.ts index baefd6f6..06bc0074 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -200,7 +200,7 @@ declare namespace hybrids { function url(view: V, params?: Record): string; function backUrl(params?: Record): string; function guardUrl(params?: Record): string; - function isActive(...views: View[]): boolean; + function isActive(views: View | View[], deep?: boolean): boolean; function resolve(event: Event, promise: Promise): void; } From ffb2eb48f875248b1d9f4de92ac9acb3932bf995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 28 Apr 2021 10:04:47 +0200 Subject: [PATCH 13/49] Fix connect key --- types/index.d.ts | 50 +++++++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 06bc0074..3aa53e02 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3,11 +3,11 @@ export = hybrids; export as namespace hybrids; declare namespace hybrids { - interface Descriptor { + interface Descriptor { get?(host: E & HTMLElement, lastValue: V): V; set?(host: E & HTMLElement, value: any, lastValue: V): V; - connect?( - host: E & HTMLElement & { [property in K]: V }, + connect?( + host: E & HTMLElement, key: K, invalidate: Function, ): Function | void; @@ -15,14 +15,14 @@ declare namespace hybrids { } type DescriptorValue = D extends (...args: any) => any - ? ReturnType extends Descriptor + ? ReturnType extends Descriptor ? V : never - : D extends Descriptor + : D extends Descriptor ? V : never; - type Property = V | Descriptor | Descriptor["get"]; + type Property = V | Descriptor | Descriptor["get"]; interface UpdateFunction { (host: E & HTMLElement, target: ShadowRoot | Text | E): void; @@ -33,12 +33,18 @@ declare namespace hybrids { } type Hybrids = { - render?: E extends { render: infer V } ? Property : RenderFunction; + render?: E extends { render: infer V } + ? Property + : RenderFunction; content?: E extends { render: infer V } - ? Property + ? Property : RenderFunction; } & { - [property in keyof Omit]: Property; + [property in keyof Omit]: Property< + E, + E[property], + property + >; }; interface MapOfHybrids { @@ -66,21 +72,21 @@ declare namespace hybrids { /* Factories */ - function property( + function property( value: V | null | undefined | ((value: any) => V), - connect?: Descriptor["connect"], - ): Descriptor; - function parent( + connect?: Descriptor["connect"], + ): Descriptor; + function parent( hybridsOrFn: Hybrids | ((hybrids: Hybrids) => boolean), - ): Descriptor; - function children( + ): Descriptor; + function children( hybridsOrFn: Hybrids | ((hybrids: Hybrids) => boolean), options?: { deep?: boolean; nested?: boolean }, - ): Descriptor; - function render( + ): Descriptor; + function render( fn: RenderFunction, customOptions?: { shadowRoot?: boolean | object }, - ): Descriptor; + ): Descriptor, K>; /* Store */ @@ -128,10 +134,10 @@ declare namespace hybrids { | ((host: E) => string) | { id?: keyof E; draft: boolean }; - function store( + function store( Model: Model, options?: StoreOptions, - ): Descriptor; + ): Descriptor; namespace store { const connect = "__store__connect__"; @@ -189,10 +195,10 @@ declare namespace hybrids { [tagName: string]: View; } - function router( + function router( views: MapOfViews, options: RouterOptions, - ): Descriptor; + ): Descriptor; namespace router { const connect = "__router__connect__"; From 96b0fe5bd3d902142e3dbb523963576d5a61f8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 28 Apr 2021 11:14:10 +0200 Subject: [PATCH 14/49] Fix render & content types --- types/index.d.ts | 65 +++++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 3aa53e02..1cf4d756 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3,11 +3,11 @@ export = hybrids; export as namespace hybrids; declare namespace hybrids { - interface Descriptor { + interface Descriptor { get?(host: E & HTMLElement, lastValue: V): V; set?(host: E & HTMLElement, value: any, lastValue: V): V; connect?( - host: E & HTMLElement, + host: E & HTMLElement & { [property in K]: V }, key: K, invalidate: Function, ): Function | void; @@ -15,14 +15,17 @@ declare namespace hybrids { } type DescriptorValue = D extends (...args: any) => any - ? ReturnType extends Descriptor + ? ReturnType extends Descriptor ? V : never - : D extends Descriptor + : D extends Descriptor ? V : never; - type Property = V | Descriptor | Descriptor["get"]; + type Property = + | V + | Descriptor + | Descriptor["get"]; interface UpdateFunction { (host: E & HTMLElement, target: ShadowRoot | Text | E): void; @@ -33,18 +36,15 @@ declare namespace hybrids { } type Hybrids = { - render?: E extends { render: infer V } - ? Property - : RenderFunction; - content?: E extends { render: infer V } - ? Property - : RenderFunction; + [property in Extract< + keyof Omit, + string + >]: property extends "render" | "content" + ? RenderFunction | Property + : Property; } & { - [property in keyof Omit]: Property< - E, - E[property], - property - >; + render?: RenderFunction; + content?: RenderFunction; }; interface MapOfHybrids { @@ -72,21 +72,24 @@ declare namespace hybrids { /* Factories */ - function property( + function property( value: V | null | undefined | ((value: any) => V), - connect?: Descriptor["connect"], - ): Descriptor; - function parent( - hybridsOrFn: Hybrids | ((hybrids: Hybrids) => boolean), - ): Descriptor; - function children( - hybridsOrFn: Hybrids | ((hybrids: Hybrids) => boolean), + connect?: Descriptor["connect"], + ): Descriptor; + + function parent( + hybridsOrFn: Hybrids | ((hybrids: Hybrids) => boolean), + ): Descriptor; + + function children( + hybridsOrFn: Hybrids | ((hybrids: Hybrids) => boolean), options?: { deep?: boolean; nested?: boolean }, - ): Descriptor; - function render( + ): Descriptor; + + function render( fn: RenderFunction, customOptions?: { shadowRoot?: boolean | object }, - ): Descriptor, K>; + ): Descriptor HTMLElement>; /* Store */ @@ -134,10 +137,10 @@ declare namespace hybrids { | ((host: E) => string) | { id?: keyof E; draft: boolean }; - function store( + function store( Model: Model, options?: StoreOptions, - ): Descriptor; + ): Descriptor; namespace store { const connect = "__store__connect__"; @@ -195,10 +198,10 @@ declare namespace hybrids { [tagName: string]: View; } - function router( + function router( views: MapOfViews, options: RouterOptions, - ): Descriptor; + ): Descriptor; namespace router { const connect = "__router__connect__"; From 7da3a6e420548ba2c14063e05aff6ab1d858b698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 28 Apr 2021 11:17:20 +0200 Subject: [PATCH 15/49] rename isActive to active method --- src/router.js | 6 +++--- types/index.d.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/router.js b/src/router.js index 56cc13e9..fb0f73fb 100644 --- a/src/router.js +++ b/src/router.js @@ -499,7 +499,7 @@ function getGuardUrl(params = {}) { return config.stack[0] ? config.stack[0].url(params) : ""; } -function isActive(views, deep = false) { +function active(views, stack = false) { const state = window.history.state; if (!state) return false; @@ -514,7 +514,7 @@ function isActive(views, deep = false) { let entry = state[0]; while (entry) { const target = getConfigById(entry.id); - if (target === config || (deep && hasInStack(config, target))) { + if (target === config || (stack && hasInStack(config, target))) { return true; } entry = entry.nested; @@ -935,6 +935,6 @@ export default Object.assign(router, { url: getUrl, backUrl: getBackUrl, guardUrl: getGuardUrl, - isActive, + active, resolve: resolveEvent, }); diff --git a/types/index.d.ts b/types/index.d.ts index 1cf4d756..96d4694a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -209,7 +209,7 @@ declare namespace hybrids { function url(view: V, params?: Record): string; function backUrl(params?: Record): string; function guardUrl(params?: Record): string; - function isActive(views: View | View[], deep?: boolean): boolean; + function active(views: View | View[], stack?: boolean): boolean; function resolve(event: Event, promise: Promise): void; } From 70f39b8ca0b86fe48c3d6542704f638b38f40f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 28 Apr 2021 12:13:57 +0200 Subject: [PATCH 16/49] Save stack for HMR reload --- src/router.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/router.js b/src/router.js index fb0f73fb..124d3a2c 100644 --- a/src/router.js +++ b/src/router.js @@ -874,6 +874,7 @@ function connectNestedRouter(host, invalidate, settings) { }; } +const stackMap = new WeakMap(); function router(views, settings = {}) { settings = { url: settings.url || "/", @@ -919,10 +920,18 @@ function router(views, settings = {}) { return stack; }, - connect: (host, key, invalidate) => - configs.get(host) + connect: (host, key, invalidate) => { + settings.stack = stackMap.get(host) || settings.stack; + + const disconnect = configs.get(host) ? connectNestedRouter(host, invalidate, settings) - : connectRootRouter(host, invalidate, settings), + : connectRootRouter(host, invalidate, settings); + + return () => { + stackMap.set(host, settings.stack); + disconnect(); + }; + }, }; routerSettings.set(desc, settings); From aa779cd5a4c57dafc7ddf275f3da68884584ff94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Thu, 29 Apr 2021 13:57:49 +0200 Subject: [PATCH 17/49] Minor fixes --- src/router.js | 88 +++++++++++++++++++++++++++++------------------- types/index.d.ts | 18 +++++++--- 2 files changed, 67 insertions(+), 39 deletions(-) diff --git a/src/router.js b/src/router.js index 124d3a2c..b8c2493f 100644 --- a/src/router.js +++ b/src/router.js @@ -14,6 +14,7 @@ const connect = Symbol("router.connect"); const routers = new WeakMap(); const configs = new WeakMap(); const routerSettings = new WeakMap(); +let rootRouter = null; function mapDeepElements(target, cb) { cb(target); @@ -448,14 +449,25 @@ function getUrl(view, params = {}) { return config.url(params); } -function getBackUrl(params = {}) { +function getBackUrl(params = {}, { nested = false } = {}) { const state = window.history.state; if (!state) return ""; let config; if (state.length > 1) { - const prevEntry = state[1]; + let i = 1; + let prevEntry = state[i]; + if (nested) { + while (prevEntry.nested) { + prevEntry = prevEntry.nested; + } + } else + while (prevEntry.nested && i < state.length - 1) { + i += 1; + prevEntry = state[i]; + } + config = getConfigById(prevEntry.id); if (!config.guard) { @@ -477,7 +489,7 @@ function getBackUrl(params = {}) { } if (config) { - return config.url(params); + return config.url(params || {}); } } @@ -499,7 +511,7 @@ function getGuardUrl(params = {}) { return config.stack[0] ? config.stack[0].url(params) : ""; } -function active(views, stack = false) { +function active(views, { stack = false } = {}) { const state = window.history.state; if (!state) return false; @@ -552,12 +564,7 @@ function handleNavigate(event) { } if (url && url.origin === window.location.origin) { - const target = event - .composedPath() - .reverse() - .find(el => routers.has(el)); - - dispatch(target, "navigate", { detail: { url, event } }); + dispatch(rootRouter, "navigate", { detail: { url, event } }); } } @@ -790,7 +797,28 @@ function connectRootRouter(host, invalidate, settings) { } } + deepEach(settings.roots, (c, parent) => { + c.parent = parent; + + if (parent) { + c.nestedParent = + parent.nested && parent.nested.includes(c) + ? parent + : parent.nestedParent; + } + + let tempParent = parent; + c.parentsWithGuards = []; + while (tempParent) { + if (tempParent.guard) c.parentsWithGuards.unshift(tempParent); + tempParent = tempParent.parent; + } + + if (c.browserUrl) settings.entryPoints.push(c); + }); + routers.set(host, flush); + rootRouter = host; if (!window.history.state) { const entry = @@ -837,6 +865,7 @@ function connectRootRouter(host, invalidate, settings) { host.removeEventListener("navigate", navigate); routers.delete(host); + rootRouter = null; }; } @@ -845,13 +874,15 @@ function connectNestedRouter(host, invalidate, settings) { if (!viewConfig) return false; function getNestedState() { - return window.history.state.map(entry => { - while (entry) { - if (entry.id === viewConfig.id) return entry.nested; - entry = entry.nested; - } - return entry; - }); + return window.history.state + .map(entry => { + while (entry) { + if (entry.id === viewConfig.id) return entry.nested; + entry = entry.nested; + } + return entry; + }) + .filter(e => e); } function flush() { @@ -890,23 +921,6 @@ function router(views, settings = {}) { ); } - deepEach(settings.roots, (c, parent) => { - c.parent = parent; - - if (parent && parent.nested && parent.nested.includes(c)) { - c.nestedParent = parent; - } - - let tempParent = parent; - c.parentsWithGuards = []; - while (tempParent) { - if (tempParent.guard) c.parentsWithGuards.unshift(tempParent); - tempParent = tempParent.parent; - } - - if (c.browserUrl) settings.entryPoints.push(c); - }); - const desc = { get: host => { const stack = settings.stack @@ -929,7 +943,13 @@ function router(views, settings = {}) { return () => { stackMap.set(host, settings.stack); + settings.stack = []; + disconnect(); + + Promise.resolve().then(() => { + stackMap.delete(host); + }); }; }, }; diff --git a/types/index.d.ts b/types/index.d.ts index 96d4694a..1193a482 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -200,17 +200,25 @@ declare namespace hybrids { function router( views: MapOfViews, - options: RouterOptions, + options?: RouterOptions, ): Descriptor; namespace router { const connect = "__router__connect__"; - function url(view: V, params?: Record): string; - function backUrl(params?: Record): string; - function guardUrl(params?: Record): string; - function active(views: View | View[], stack?: boolean): boolean; + type UrlParams = Record; + function url(view: V, params?: UrlParams): string; + function backUrl( + params?: UrlParams | null, + options?: { nested?: boolean }, + ): string; + function guardUrl(params?: UrlParams): string; + + function active( + views: View | View[], + options?: { stack?: boolean }, + ): boolean; function resolve(event: Event, promise: Promise): void; } From 8dccd6b91833821cbba41e99f22954ae42485112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Thu, 29 Apr 2021 15:01:21 +0200 Subject: [PATCH 18/49] Turn on replace option only for current view --- src/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router.js b/src/router.js index b8c2493f..38b04cc4 100644 --- a/src/router.js +++ b/src/router.js @@ -634,7 +634,7 @@ function resolveStack(state, settings) { if (prevView) { const prevConfig = configs.get(prevView); - if (config.id !== prevConfig.id || config.replace) { + if (config.id !== prevConfig.id || (index === 0 && config.replace)) { return config.create(); } nextView = prevView; From c844d65382e2d99332c33de38f3c4e914d657c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Thu, 29 Apr 2021 16:35:37 +0200 Subject: [PATCH 19/49] Strict url params --- types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index 1193a482..d363d60c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -206,7 +206,7 @@ declare namespace hybrids { namespace router { const connect = "__router__connect__"; - type UrlParams = Record; + type UrlParams = Record; function url(view: V, params?: UrlParams): string; function backUrl( From 021434510b4a2df55a877ed76b15637812e920c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Fri, 30 Apr 2021 10:15:54 +0200 Subject: [PATCH 20/49] Clear backUrl --- src/router.js | 6 +++--- types/index.d.ts | 9 +++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/router.js b/src/router.js index 38b04cc4..c3d7203e 100644 --- a/src/router.js +++ b/src/router.js @@ -449,7 +449,7 @@ function getUrl(view, params = {}) { return config.url(params); } -function getBackUrl(params = {}, { nested = false } = {}) { +function getBackUrl({ nested = false } = {}) { const state = window.history.state; if (!state) return ""; @@ -471,7 +471,7 @@ function getBackUrl(params = {}, { nested = false } = {}) { config = getConfigById(prevEntry.id); if (!config.guard) { - return config.url({ ...prevEntry.params, ...params }); + return config.url(prevEntry.params); } } else { const currentConfig = getConfigById(state[0].id); @@ -489,7 +489,7 @@ function getBackUrl(params = {}, { nested = false } = {}) { } if (config) { - return config.url(params || {}); + return config.url({}); } } diff --git a/types/index.d.ts b/types/index.d.ts index d363d60c..ba64c959 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -4,8 +4,8 @@ export as namespace hybrids; declare namespace hybrids { interface Descriptor { - get?(host: E & HTMLElement, lastValue: V): V; - set?(host: E & HTMLElement, value: any, lastValue: V): V; + get?(host: E & HTMLElement, lastValue: V | undefined): V; + set?(host: E & HTMLElement, value: any, lastValue: V | undefined): V; connect?( host: E & HTMLElement & { [property in K]: V }, key: K, @@ -209,10 +209,7 @@ declare namespace hybrids { type UrlParams = Record; function url(view: V, params?: UrlParams): string; - function backUrl( - params?: UrlParams | null, - options?: { nested?: boolean }, - ): string; + function backUrl(options?: { nested?: boolean }): string; function guardUrl(params?: UrlParams): string; function active( From 312f634ea136dc1eac6f1aa65ac4e3d28c53f015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Sat, 1 May 2021 09:21:33 +0200 Subject: [PATCH 21/49] Remove stack from settings --- src/router.js | 60 +++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/src/router.js b/src/router.js index c3d7203e..55464eeb 100644 --- a/src/router.js +++ b/src/router.js @@ -13,6 +13,8 @@ import { dispatch, pascalToDash } from "./utils.js"; const connect = Symbol("router.connect"); const routers = new WeakMap(); const configs = new WeakMap(); +const stacks = new WeakMap(); + const routerSettings = new WeakMap(); let rootRouter = null; @@ -609,7 +611,8 @@ function deepEach(stack, cb, parent, set = new WeakSet()) { }); } -function resolveStack(state, settings) { +function resolveStack(host, state) { + let stack = stacks.get(host); const reducedState = state.reduce((acc, entry, index) => { if ( index === 0 || @@ -620,15 +623,15 @@ function resolveStack(state, settings) { } return acc; }, []); - const offset = settings.stack.length - reducedState.length; - const lastStackView = settings.stack[0]; + const offset = stack.length - reducedState.length; + const lastStackView = stack[0]; - if (offset <= 0 && settings.stack.length) { - saveLayout(settings.stack[0]); + if (offset <= 0 && stack.length) { + saveLayout(stack[0]); } - settings.stack = reducedState.map(({ id }, index) => { - const prevView = settings.stack[index + offset]; + stack = reducedState.map(({ id }, index) => { + const prevView = stack[index + offset]; const config = getConfigById(id); let nextView; @@ -651,14 +654,17 @@ function resolveStack(state, settings) { return nextView; }); - if (settings.stack[0] === lastStackView) { + if (stack[0] === lastStackView) { restoreLayout(lastStackView, true); } - Object.assign(settings.stack[0], state[0].params); + Object.assign(stack[0], state[0].params); + stacks.set(host, stack); - const nestedFlush = routers.get(settings.stack[0]); - if (nestedFlush) nestedFlush(); + const flush = routers.get(stack[0]); + if (flush) { + flush(); + } } function findSameEntryIndex(state, entry) { @@ -687,7 +693,7 @@ function findSameEntryIndex(state, entry) { function connectRootRouter(host, invalidate, settings) { function flush() { - resolveStack(window.history.state, settings); + resolveStack(host, window.history.state); invalidate(); } @@ -760,7 +766,8 @@ function connectRootRouter(host, invalidate, settings) { nextUrl = nextConfig.url(nextEntry.params); } - cache.suspend(settings.stack[0]); + const stack = stacks.get(host); + cache.suspend(stack[0]); if (nextEntry.id === currentEntry.id) { const offset = findSameEntryIndex(state, nextEntry); @@ -829,12 +836,13 @@ function connectRootRouter(host, invalidate, settings) { window.history.replaceState([entry], "", settings.url); flush(); } else { + const stack = stacks.get(host); const state = window.history.state; let i; for (i = state.length - 1; i >= 0; i -= 1) { const entry = state[i]; const config = getConfigById(entry.id); - if (!config || (config.dialog && settings.stack.length === 0)) { + if (!config || (config.dialog && stack.length === 0)) { if (state.length > 1) { window.history.go(-(state.length - i - 1)); } else { @@ -886,10 +894,11 @@ function connectNestedRouter(host, invalidate, settings) { } function flush() { - resolveStack(getNestedState(), settings); + resolveStack(host, getNestedState()); invalidate(); } + // TODO: ??? if (!getNestedState()[0]) { window.history.replaceState( [settings.roots[0].getEntry(), ...window.history.state.slice(1)], @@ -905,13 +914,11 @@ function connectNestedRouter(host, invalidate, settings) { }; } -const stackMap = new WeakMap(); function router(views, settings = {}) { settings = { url: settings.url || "/", params: settings.params || [], roots: setupViews(views, settings.prefix), - stack: [], entryPoints: [], }; @@ -923,32 +930,23 @@ function router(views, settings = {}) { const desc = { get: host => { - const stack = settings.stack - .slice(0, settings.stack.findIndex(el => !configs.get(el).dialog) + 1) + const stack = stacks.get(host); + return stack + .slice(0, stack.findIndex(el => !configs.get(el).dialog) + 1) .reverse(); - - const el = stack[stack.length - 1]; - settings.params.forEach(key => { - el[key] = host[key]; - }); - - return stack; }, connect: (host, key, invalidate) => { - settings.stack = stackMap.get(host) || settings.stack; + if (!stacks.has(host)) stacks.set(host, []); const disconnect = configs.get(host) ? connectNestedRouter(host, invalidate, settings) : connectRootRouter(host, invalidate, settings); return () => { - stackMap.set(host, settings.stack); - settings.stack = []; - disconnect(); Promise.resolve().then(() => { - stackMap.delete(host); + stacks.delete(host); }); }; }, From 0058a375a12d94353c6a0848fba316d153b796db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Tue, 4 May 2021 16:37:56 +0200 Subject: [PATCH 22/49] Fix store validate function type --- types/index.d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index ba64c959..9d544b49 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -161,18 +161,18 @@ declare namespace hybrids { function resolve(model: M): Promise; function ref(fn: () => M): M; - interface ValidateFunction { - (value: string | number, key: string, model: M): string | boolean | void; + interface ValidateFunction { + (value: T, key: string, model: M): string | boolean | void; } function value( defaultValue: string, - validate?: ValidateFunction | RegExp, + validate?: ValidateFunction | RegExp, errorMessage?: string, ): string; function value( defaultValue: number, - validate?: ValidateFunction | RegExp, + validate?: ValidateFunction | RegExp, errorMessage?: string, ): number; } From 263fc7f7c38171f25a09bd92dd11fba5039aa581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Tue, 4 May 2021 20:22:18 +0200 Subject: [PATCH 23/49] Clean out params from settings --- src/router.js | 1 - types/index.d.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/router.js b/src/router.js index 55464eeb..a3e32e0d 100644 --- a/src/router.js +++ b/src/router.js @@ -917,7 +917,6 @@ function connectNestedRouter(host, invalidate, settings) { function router(views, settings = {}) { settings = { url: settings.url || "/", - params: settings.params || [], roots: setupViews(views, settings.prefix), entryPoints: [], }; diff --git a/types/index.d.ts b/types/index.d.ts index 9d544b49..e53829ac 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -181,7 +181,6 @@ declare namespace hybrids { interface RouterOptions { url?: string; prefix?: string; - params?: Array; } type View = Hybrids & { From 3bd66130b56b428a4e28921533b8a24ad8c65788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 5 May 2021 13:18:28 +0200 Subject: [PATCH 24/49] Move router setup to connect callback --- src/router.js | 309 +++++++++++++++++++++++------------------------ types/index.d.ts | 8 +- 2 files changed, 153 insertions(+), 164 deletions(-) diff --git a/src/router.js b/src/router.js index a3e32e0d..31a54dca 100644 --- a/src/router.js +++ b/src/router.js @@ -11,12 +11,14 @@ import { dispatch, pascalToDash } from "./utils.js"; */ const connect = Symbol("router.connect"); -const routers = new WeakMap(); const configs = new WeakMap(); + +const flushes = new WeakMap(); const stacks = new WeakMap(); +const routers = new WeakMap(); -const routerSettings = new WeakMap(); let rootRouter = null; +let entryPoints = []; function mapDeepElements(target, cb) { cb(target); @@ -190,9 +192,30 @@ function hasInStack(config, target) { }); } -function getNestedRouterSettings(name, view, options) { +function setupViews(views, options, parent = null, nestedParent = null) { + if (typeof views === "function") views = views(); + + const result = Object.entries(views).map(([name, view]) => { + // eslint-disable-next-line no-use-before-define + const config = setupView(view, name, options, parent, nestedParent); + + if (parent && hasInStack(config, parent)) { + throw Error( + `${parent.name} cannot be in the stack of ${config.name} - ${config.name} already connected to ${parent.name}`, + ); + } + + if (config.browserUrl) entryPoints.push(config); + + return config; + }); + + return result; +} + +function getNestedRouterOptions(view, name, config) { const nestedRouters = Object.values(view) - .map(desc => routerSettings.get(desc)) + .map(desc => routers.get(desc)) .filter(d => d); if (nestedRouters.length) { @@ -202,42 +225,25 @@ function getNestedRouterSettings(name, view, options) { ); } - if (options.dialog) { + if (config.dialog) { throw TypeError( `Nested routers are not supported in dialogs. Remove the router factory from '${name}' view`, ); } - if (options.url) { + if (config.browserUrl) { throw TypeError( - `Views with nested routers must not have the url option. Remove either the router factory or the url option from '${name}' view`, + `A view with nested router must not have the url option. Remove the url option from '${name}' view`, ); } } return nestedRouters[0]; } -function setupViews(views, prefix = "view", parent = null) { - if (typeof views === "function") views = views(); - - const result = Object.entries(views).map(([name, view]) => { - // eslint-disable-next-line no-use-before-define - const config = setupView(name, view, prefix, parent); - - if (parent && hasInStack(config, parent)) { - throw Error( - `${parent.name} cannot be in the stack of ${config.name} - ${config.name} already connected to ${parent.name}`, - ); - } - - return config; - }); - - return result; -} - -function setupView(name, view, prefix, parent) { - const id = `${pascalToDash(prefix)}-${pascalToDash(name)}`; +function setupView(view, name, routerOptions, parent, nestedParent) { + const id = `${pascalToDash(routerOptions.prefix || "view")}-${pascalToDash( + name, + )}`; if (!view || typeof view !== "object") { throw TypeError( @@ -249,10 +255,17 @@ function setupView(name, view, prefix, parent) { let config = configs.get(view); - if (config && config.name !== name) { - throw Error( - `View definition for ${name} in ${parent.name} already connected to the router as ${config.name}`, - ); + if (config) { + if (config.name !== name) { + throw Error( + `View definition for ${name} in ${parent.name} already connected to the router as ${config.name}`, + ); + } + + if (config.id !== id) { + configs.delete(customElements.get(config.id)); + config = null; + } } if (!config) { @@ -275,8 +288,6 @@ function setupView(name, view, prefix, parent) { if (desc.set) writableParams.add(key); }); - const nestedRouterSettings = getNestedRouterSettings(name, view, options); - if (options.dialog) { callbacksMap.get(Constructor).push(host => { const cb = event => { @@ -316,7 +327,7 @@ function setupView(name, view, prefix, parent) { if (entry.id === configs.get(host).id) { entry.params = browserUrl.paramsKeys.reduce((acc, key) => { - acc[key] = String(host[key]); + acc[key] = String(host[key] || ""); return acc; }, {}); @@ -361,23 +372,26 @@ function setupView(name, view, prefix, parent) { replace: options.replace, guard, parent: undefined, + nestedParent: undefined, + nestedRoots: undefined, parentsWithGuards: undefined, stack: [], - nestedParent: undefined, - nested: nestedRouterSettings ? nestedRouterSettings.roots : null, ...(browserUrl || { - url(params) { - const url = new URL(`@${id}`, window.location.origin); + url(params, suppressErrors) { + const url = new URL("", window.location.origin); Object.keys(params).forEach(key => { if (writableParams.has(key)) { url.searchParams.append(key, params[key] || ""); - } else { + } else if (!suppressErrors) { throw TypeError(`The '${key}' parameter is not supported`); } }); - return url; + return new URL( + `${routerOptions.url}#@${id}${url.search}`, + window.location.origin, + ); }, match(url) { const params = {}; @@ -426,12 +440,30 @@ function setupView(name, view, prefix, parent) { `The 'stack' option is not supported for dialogs - remove it from '${name}'`, ); } - config.stack = setupViews(options.stack, prefix, config); + config.stack = setupViews(options.stack, routerOptions, config); } + } - if (nestedRouterSettings) { - config.stack = config.stack.concat(nestedRouterSettings.roots); - } + config.parent = parent; + config.nestedParent = nestedParent; + + config.parentsWithGuards = []; + while (parent) { + if (parent.guard) config.parentsWithGuards.unshift(parent); + parent = parent.parent; + } + + const nestedRouterOptions = getNestedRouterOptions(view, name, config); + + if (nestedRouterOptions) { + config.nestedRoots = setupViews( + nestedRouterOptions.views, + { ...routerOptions, ...nestedRouterOptions }, + config, + config, + ); + + config.stack = config.stack.concat(config.nestedRoots); } return config; @@ -491,7 +523,7 @@ function getBackUrl({ nested = false } = {}) { } if (config) { - return config.url({}); + return config.url(state[0].params, true); } } @@ -565,7 +597,7 @@ function handleNavigate(event) { return; } - if (url && url.origin === window.location.origin) { + if (rootRouter && url && url.origin === window.location.origin) { dispatch(rootRouter, "navigate", { detail: { url, event } }); } } @@ -596,21 +628,6 @@ function resolveEvent(event, promise) { }); } -function deepEach(stack, cb, parent, set = new WeakSet()) { - stack - .filter(c => { - if (set.has(c)) return false; - - set.add(c); - cb(c, parent); - - return c.stack.length; - }) - .forEach(c => { - deepEach(c.stack, cb, c, set); - }); -} - function resolveStack(host, state) { let stack = stacks.get(host); const reducedState = state.reduce((acc, entry, index) => { @@ -662,9 +679,29 @@ function resolveStack(host, state) { stacks.set(host, stack); const flush = routers.get(stack[0]); - if (flush) { - flush(); + if (flush) flush(); +} + +function getEntryFromURL(url) { + let config; + + const [pathname, search] = url.hash.split("?"); + if (pathname) { + config = getConfigById(pathname.split("@")[1]); + url = new URL(`?${search}`, window.location.origin); } + + if (!config) { + for (let i = 0; i < entryPoints.length; i += 1) { + const entryPoint = entryPoints[i]; + const params = entryPoint.match(url); + if (params) return entryPoint.getEntry(params); + } + + return null; + } + + return config.getEntry(config.match(url)); } function findSameEntryIndex(state, entry) { @@ -691,7 +728,7 @@ function findSameEntryIndex(state, entry) { }); } -function connectRootRouter(host, invalidate, settings) { +function connectRootRouter(host, invalidate, options) { function flush() { resolveStack(host, window.history.state); invalidate(); @@ -731,22 +768,6 @@ function connectRootRouter(host, invalidate, settings) { } } - function getEntryFromURL(url) { - const config = getConfigById(url.pathname.substr(2)); - - if (!config) { - for (let i = 0; i < settings.entryPoints.length; i += 1) { - const entryPoint = settings.entryPoints[i]; - const params = entryPoint.match(url); - if (params) return entryPoint.getEntry(params); - } - - return null; - } - - return config.getEntry(config.match(url)); - } - function navigate(event) { const nextEntry = getEntryFromURL(event.detail.url); if (!nextEntry) return; @@ -772,7 +793,7 @@ function connectRootRouter(host, invalidate, settings) { if (nextEntry.id === currentEntry.id) { const offset = findSameEntryIndex(state, nextEntry); if (offset > -1) { - navigateBack(offset, nextEntry, nextUrl || settings.url); + navigateBack(offset, nextEntry, nextUrl || options.url); } else { window.history.pushState([nextEntry, ...state], "", nextUrl); flush(); @@ -780,7 +801,7 @@ function connectRootRouter(host, invalidate, settings) { } else { let offset = state.findIndex(({ id }) => nextEntry.id === id); if (offset > -1) { - navigateBack(offset, nextEntry, nextUrl || settings.url); + navigateBack(offset, nextEntry, nextUrl || options.url); } else { const currentConfig = getConfigById(currentEntry.id); if ( @@ -797,66 +818,53 @@ function connectRootRouter(host, invalidate, settings) { navigateBack( offset > -1 ? offset : state.length - 1, nextEntry, - nextUrl || settings.url, + nextUrl || options.url, ); } } } } - deepEach(settings.roots, (c, parent) => { - c.parent = parent; + entryPoints = []; + const roots = setupViews(options.views, options); - if (parent) { - c.nestedParent = - parent.nested && parent.nested.includes(c) - ? parent - : parent.nestedParent; - } - - let tempParent = parent; - c.parentsWithGuards = []; - while (tempParent) { - if (tempParent.guard) c.parentsWithGuards.unshift(tempParent); - tempParent = tempParent.parent; - } - - if (c.browserUrl) settings.entryPoints.push(c); - }); - - routers.set(host, flush); + flushes.set(host, flush); rootRouter = host; + window.history.scrollRestoration = "manual"; + if (!window.history.state) { const entry = - getEntryFromURL(new URL(window.location.href)) || - settings.roots[0].getEntry(); - - window.history.scrollRestoration = "manual"; - window.history.replaceState([entry], "", settings.url); + getEntryFromURL(new URL(window.location.href)) || roots[0].getEntry(); + window.history.replaceState([entry], "", options.url); flush(); } else { const stack = stacks.get(host); const state = window.history.state; + let i; for (i = state.length - 1; i >= 0; i -= 1) { - const entry = state[i]; - const config = getConfigById(entry.id); - if (!config || (config.dialog && stack.length === 0)) { - if (state.length > 1) { - window.history.go(-(state.length - i - 1)); - } else { - window.history.replaceState( - [settings.roots[0].getEntry()], - "", - settings.url, - ); - flush(); + let entry = state[i]; + while (entry) { + const config = getConfigById(entry.id); + if (!config || (config.dialog && stack.length === 0)) { + break; } - break; + entry = entry.nested; } + if (entry) break; + } + + if (i > -1) { + const lastValidEntry = state[i + 1]; + navigateBack( + state.length - i - 1, + lastValidEntry || roots[0].getEntry(state[0].params), + options.url, + ); + } else { + flush(); } - if (i < 0) flush(); } window.addEventListener("popstate", flush); @@ -872,20 +880,18 @@ function connectRootRouter(host, invalidate, settings) { host.removeEventListener("submit", handleNavigate); host.removeEventListener("navigate", navigate); - routers.delete(host); rootRouter = null; }; } -function connectNestedRouter(host, invalidate, settings) { - const viewConfig = configs.get(host); - if (!viewConfig) return false; +function connectNestedRouter(host, invalidate) { + const config = configs.get(host); function getNestedState() { return window.history.state .map(entry => { while (entry) { - if (entry.id === viewConfig.id) return entry.nested; + if (entry.id === config.id) return entry.nested; entry = entry.nested; } return entry; @@ -898,38 +904,26 @@ function connectNestedRouter(host, invalidate, settings) { invalidate(); } - // TODO: ??? if (!getNestedState()[0]) { + const state = window.history.state; window.history.replaceState( - [settings.roots[0].getEntry(), ...window.history.state.slice(1)], + [config.nestedRoots[0].getEntry(state[0].params), ...state.slice(1)], "", ); } - - routers.set(host, flush); - flush(); - - return () => { - routers.delete(host); - }; + if (!flushes.has(host)) flush(); + flushes.set(host, flush); } -function router(views, settings = {}) { - settings = { - url: settings.url || "/", - roots: setupViews(views, settings.prefix), - entryPoints: [], +function router(views, options = {}) { + options = { + ...options, + views, }; - if (!settings.roots.length) { - throw TypeError( - `The first argument must be a non-empty map of views: ${views}`, - ); - } - const desc = { get: host => { - const stack = stacks.get(host); + const stack = stacks.get(host) || []; return stack .slice(0, stack.findIndex(el => !configs.get(el).dialog) + 1) .reverse(); @@ -937,22 +931,15 @@ function router(views, settings = {}) { connect: (host, key, invalidate) => { if (!stacks.has(host)) stacks.set(host, []); - const disconnect = configs.get(host) - ? connectNestedRouter(host, invalidate, settings) - : connectRootRouter(host, invalidate, settings); - - return () => { - disconnect(); + if (configs.has(host)) { + return connectNestedRouter(host, invalidate); + } - Promise.resolve().then(() => { - stacks.delete(host); - }); - }; + return connectRootRouter(host, invalidate, options); }, }; - routerSettings.set(desc, settings); - + routers.set(desc, options); return desc; } diff --git a/types/index.d.ts b/types/index.d.ts index e53829ac..25664852 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -205,11 +205,13 @@ declare namespace hybrids { namespace router { const connect = "__router__connect__"; - type UrlParams = Record; + type UrlParams = { + [property in keyof V]?: any; + }; - function url(view: V, params?: UrlParams): string; + function url(view: V, params?: UrlParams): string; function backUrl(options?: { nested?: boolean }): string; - function guardUrl(params?: UrlParams): string; + function guardUrl(params?: UrlParams): string; function active( views: View | View[], From 335c7735812bdc8444f8c9fe8944d108d4360224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 5 May 2021 13:33:14 +0200 Subject: [PATCH 25/49] Fix minor bugs --- src/router.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/router.js b/src/router.js index 31a54dca..632cd419 100644 --- a/src/router.js +++ b/src/router.js @@ -440,7 +440,12 @@ function setupView(view, name, routerOptions, parent, nestedParent) { `The 'stack' option is not supported for dialogs - remove it from '${name}'`, ); } - config.stack = setupViews(options.stack, routerOptions, config); + config.stack = setupViews( + options.stack, + routerOptions, + config, + nestedParent, + ); } } @@ -458,7 +463,11 @@ function setupView(view, name, routerOptions, parent, nestedParent) { if (nestedRouterOptions) { config.nestedRoots = setupViews( nestedRouterOptions.views, - { ...routerOptions, ...nestedRouterOptions }, + { + ...routerOptions, + prefix: `${routerOptions.prefix}-${pascalToDash(name)}`, + ...nestedRouterOptions, + }, config, config, ); @@ -678,7 +687,7 @@ function resolveStack(host, state) { Object.assign(stack[0], state[0].params); stacks.set(host, stack); - const flush = routers.get(stack[0]); + const flush = flushes.get(stack[0]); if (flush) flush(); } From 21f472f2a8ed78289b033b538f1963e7492b4071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Fri, 7 May 2021 09:53:36 +0200 Subject: [PATCH 26/49] Fix tests --- test/spec/router.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/test/spec/router.js b/test/spec/router.js index d5a43926..35033432 100644 --- a/test/spec/router.js +++ b/test/spec/router.js @@ -1,6 +1,6 @@ import { dispatch, router } from "../../src/index.js"; -fdescribe("router:", () => { +describe("router:", () => { let el; let spy; let prop; @@ -49,15 +49,6 @@ fdescribe("router:", () => { prop = router(views); }); - it("throws for wrong first argument", () => { - expect(() => { - router(); - }).toThrow(); - expect(() => { - router({}); - }).toThrow(); - }); - it("returns a default view", () => { disconnect = prop.connect(el, "", spy); From 028926b46867618cf4279b1366c8a4b7433de31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Fri, 14 May 2021 09:13:52 +0200 Subject: [PATCH 27/49] Refactor navigate action for better nested router support --- src/router.js | 113 ++++++++++++++++++++++++-------------------- test/spec/router.js | 10 ++-- 2 files changed, 68 insertions(+), 55 deletions(-) diff --git a/src/router.js b/src/router.js index 632cd419..acc76df0 100644 --- a/src/router.js +++ b/src/router.js @@ -6,6 +6,7 @@ import { dispatch, pascalToDash } from "./utils.js"; # TODO LIST +* Check gourd feature after navigate refactor (guard condition) * Transition effect */ @@ -713,28 +714,63 @@ function getEntryFromURL(url) { return config.getEntry(config.match(url)); } -function findSameEntryIndex(state, entry) { - return state.findIndex(e => { - let temp = entry; +function getEntryOffset(entry) { + const state = window.history.state.reduce((acc, e, index) => { + let i = 0; + while (e) { - if (e.id !== temp.id) return false; + acc[i] = acc[i] || []; + acc[i][index] = e; + e = e.nested; + i += 1; + } + + return acc; + }, []); + + let offset = 0; + let i = 0; + while (entry) { + const config = getConfigById(entry.id); + const currentEntry = state[i][offset]; + + if (currentEntry.id !== entry.id) { + if (config.dialog) return -1; - const config = getConfigById(e.id); - if (!config.multiple && !entry.nested) return true; + let j = offset; + for (; j < state[i].length; j += 1) { + const e = state[i][j]; + if (!e || e.id === entry.id) { + offset = j; + break; + } + + const c = getConfigById(e.id); + if (hasInStack(c, config)) { + offset = j - 1; + break; + } + } + if (j === state[i].length) { + offset = state[i].length - 1; + } + } else if (config.multiple) { + // Push from offset if params not the same if ( - !Object.entries(e.params).every( - ([key, value]) => entry.params[key] === value, + !Object.entries(entry.params).every( + ([key, value]) => currentEntry.params[key] === value, ) ) { - return false; + return offset - 1; } - - e = e.nested; - temp = entry.nested; } - return true; - }); + + entry = entry.nested; + i += 1; + } + + return offset; } function connectRootRouter(host, invalidate, options) { @@ -789,48 +825,25 @@ function connectRootRouter(host, invalidate, options) { const state = window.history.state; const nextConfig = getConfigById(nextEntry.id); - const currentEntry = state[0]; - let nextUrl = ""; + let url = options.url; if (nextConfig.browserUrl) { - nextUrl = nextConfig.url(nextEntry.params); + url = nextConfig.url(nextEntry.params); } - const stack = stacks.get(host); - cache.suspend(stack[0]); + let stack = stacks.get(host); + while (stack) { + cache.suspend(stack[0]); + stack = stacks.get(stack[0]); + } - if (nextEntry.id === currentEntry.id) { - const offset = findSameEntryIndex(state, nextEntry); - if (offset > -1) { - navigateBack(offset, nextEntry, nextUrl || options.url); - } else { - window.history.pushState([nextEntry, ...state], "", nextUrl); - flush(); - } + const offset = getEntryOffset(nextEntry); + + if (offset > -1) { + navigateBack(offset, nextEntry, url); } else { - let offset = state.findIndex(({ id }) => nextEntry.id === id); - if (offset > -1) { - navigateBack(offset, nextEntry, nextUrl || options.url); - } else { - const currentConfig = getConfigById(currentEntry.id); - if ( - nextConfig.dialog || - (hasInStack(currentConfig, nextConfig) && !currentConfig.guard) - ) { - window.history.pushState([nextEntry, ...state], "", nextUrl); - flush(); - } else { - offset = state - .slice(1) - .findIndex(({ id }) => hasInStack(getConfigById(id), nextConfig)); - - navigateBack( - offset > -1 ? offset : state.length - 1, - nextEntry, - nextUrl || options.url, - ); - } - } + window.history.pushState([nextEntry, ...state], "", url); + flush(); } } diff --git a/test/spec/router.js b/test/spec/router.js index 35033432..15ada3f8 100644 --- a/test/spec/router.js +++ b/test/spec/router.js @@ -137,8 +137,8 @@ describe("router:", () => { describe("[router.connect] -", () => { describe("'dialog'", () => { - it("pushes new state for dialog view"); - it("pushes new state for dialog not from the stack ???"); + it("pushes new entry for dialog view"); + it("pushes new entry for dialog not from the stack ???"); }); describe("'url'", () => {}); @@ -146,9 +146,9 @@ describe("router:", () => { describe("'multiple'", () => { describe("when 'true'", () => { it( - "navigate pushes new state for the same id when other params with multiple option is set", + "navigate pushes new entry for the same id when other params with multiple option is set", ); - it("navigate moves back to state where params are equal"); + it("navigate moves back to entry where params are equal"); }); describe("when 'false'", () => { it("replaces state for the same view"); @@ -162,7 +162,7 @@ describe("router:", () => { describe("'stack'", () => { describe("for view from own stack", () => { - it("pushes new state for view from stack"); + it("pushes new entry for view from stack"); it("moves back to the view from stack"); }); From 77b8c49cf6030559643dcf69009c1d2c1e3cb21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Fri, 14 May 2021 10:44:00 +0200 Subject: [PATCH 28/49] Fix next entry offset for push and guards --- src/router.js | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/router.js b/src/router.js index acc76df0..725a6d20 100644 --- a/src/router.js +++ b/src/router.js @@ -435,6 +435,9 @@ function setupView(view, name, routerOptions, parent, nestedParent) { configs.set(view, config); configs.set(Constructor, config); + config.parent = parent; + config.nestedParent = nestedParent; + if (options.stack) { if (options.dialog) { throw Error( @@ -448,11 +451,11 @@ function setupView(view, name, routerOptions, parent, nestedParent) { nestedParent, ); } + } else { + config.parent = parent; + config.nestedParent = nestedParent; } - config.parent = parent; - config.nestedParent = nestedParent; - config.parentsWithGuards = []; while (parent) { if (parent.guard) config.parentsWithGuards.unshift(parent); @@ -747,14 +750,20 @@ function getEntryOffset(entry) { const c = getConfigById(e.id); if (hasInStack(c, config)) { - offset = j - 1; - break; + if (j > 0) { + offset = j - 1; + break; + } else { + return c.guard ? 0 : -1; + } } } if (j === state[i].length) { offset = state[i].length - 1; } + + if (offset === -1) return offset; } else if (config.multiple) { // Push from offset if params not the same if ( @@ -937,11 +946,8 @@ function connectNestedRouter(host, invalidate) { flushes.set(host, flush); } -function router(views, options = {}) { - options = { - ...options, - views, - }; +function router(views, options) { + options = { ...options, views }; const desc = { get: host => { From cecc15057117258ff9c7059bc3c91c68c18ee804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Fri, 14 May 2021 14:13:55 +0200 Subject: [PATCH 29/49] Fix store function id return type --- types/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/index.d.ts b/types/index.d.ts index 2b648d26..f55c1f73 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -134,7 +134,7 @@ declare namespace hybrids { type StoreOptions = | keyof E - | ((host: E) => string) + | ((host: E) => ModelIdentifier) | { id?: keyof E; draft: boolean }; function store( From c0abdb8779f714cf1367edaa706668b4bc984ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Sun, 16 May 2021 09:17:22 +0200 Subject: [PATCH 30/49] Better error message for browser url --- src/router.js | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/router.js b/src/router.js index 725a6d20..7db83bb1 100644 --- a/src/router.js +++ b/src/router.js @@ -98,7 +98,7 @@ function restoreLayout(target, clear) { } const placeholder = Date.now(); -function setupBrowserUrl(browserUrl) { +function setupBrowserUrl(browserUrl, name) { if (!browserUrl) return null; const [pathname, search = ""] = browserUrl.split("?"); @@ -127,7 +127,7 @@ function setupBrowserUrl(browserUrl) { const key = pathnameParams[index - 1]; if (!hasOwnProperty.call(params, key) && !suppressErrors) { - throw Error(`The '${key}' parameter must be defined`); + throw Error(`The '${key}' parameter must be defined in '${name}'`); } return `${acc}${params[key]}${part}`; @@ -242,9 +242,8 @@ function getNestedRouterOptions(view, name, config) { } function setupView(view, name, routerOptions, parent, nestedParent) { - const id = `${pascalToDash(routerOptions.prefix || "view")}-${pascalToDash( - name, - )}`; + const prefix = routerOptions.prefix || "view"; + const id = `${pascalToDash(prefix)}-${pascalToDash(name)}`; if (!view || typeof view !== "object") { throw TypeError( @@ -315,7 +314,7 @@ function setupView(view, name, routerOptions, parent, nestedParent) { `The 'url' option in '${name}' must be a string: ${typeof options.url}`, ); } - browserUrl = setupBrowserUrl(options.url); + browserUrl = setupBrowserUrl(options.url, name); callbacksMap.get(Constructor).unshift(_ => cache.observe( @@ -345,7 +344,7 @@ function setupView(view, name, routerOptions, parent, nestedParent) { ); if (!desc || !desc.set) { throw Error( - `'${key}' parameter in the url is not supported by the '${name}'`, + `'${key}' parameter in the url is not supported in '${name}'`, ); } }); @@ -385,12 +384,14 @@ function setupView(view, name, routerOptions, parent, nestedParent) { if (writableParams.has(key)) { url.searchParams.append(key, params[key] || ""); } else if (!suppressErrors) { - throw TypeError(`The '${key}' parameter is not supported`); + throw TypeError( + `The '${key}' parameter for '${name}' is not supported`, + ); } }); return new URL( - `${routerOptions.url}#@${id}${url.search}`, + `${routerOptions.url || ""}#@${id}${url.search}`, window.location.origin, ); }, @@ -469,7 +470,7 @@ function setupView(view, name, routerOptions, parent, nestedParent) { nestedRouterOptions.views, { ...routerOptions, - prefix: `${routerOptions.prefix}-${pascalToDash(name)}`, + prefix: `${prefix}-${pascalToDash(name)}`, ...nestedRouterOptions, }, config, @@ -558,6 +559,16 @@ function getGuardUrl(params = {}) { return config.stack[0] ? config.stack[0].url(params) : ""; } +function getCurrentUrl() { + const state = window.history.state; + if (!state) return ""; + + const entry = state[0]; + const config = getConfigById(entry.id); + + return config.url(entry.params); +} + function active(views, { stack = false } = {}) { const state = window.history.state; if (!state) return false; @@ -835,7 +846,7 @@ function connectRootRouter(host, invalidate, options) { const state = window.history.state; const nextConfig = getConfigById(nextEntry.id); - let url = options.url; + let url = options.url || ""; if (nextConfig.browserUrl) { url = nextConfig.url(nextEntry.params); } @@ -974,8 +985,9 @@ function router(views, options) { export default Object.assign(router, { connect, url: getUrl, + resolve: resolveEvent, backUrl: getBackUrl, guardUrl: getGuardUrl, + currentUrl: getCurrentUrl, active, - resolve: resolveEvent, }); From f8515de3bea0dfc4ac6f3f626a3e64e9f4a195cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Sun, 16 May 2021 09:40:03 +0200 Subject: [PATCH 31/49] Fix types --- types/index.d.ts | 57 ++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index f55c1f73..5c83f02f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3,29 +3,26 @@ export = hybrids; export as namespace hybrids; declare namespace hybrids { - interface Descriptor { + interface Descriptor { get?(host: E & HTMLElement, lastValue: V | undefined): V; set?(host: E & HTMLElement, value: any, lastValue: V | undefined): V; connect?( - host: E & HTMLElement & { [property in K]: V }, - key: K, + host: E & HTMLElement & { [property in keyof E]: V }, + key: keyof E, invalidate: Function, ): Function | void; observe?(host: E & HTMLElement, value: V, lastValue: V): void; } type DescriptorValue = D extends (...args: any) => any - ? ReturnType extends Descriptor + ? ReturnType extends Descriptor ? V : never - : D extends Descriptor + : D extends Descriptor ? V : never; - type Property = - | V - | Descriptor - | Descriptor["get"]; + type Property = V | Descriptor | Descriptor["get"]; interface UpdateFunction { (host: E & HTMLElement, target: ShadowRoot | Text | E): void; @@ -40,8 +37,8 @@ declare namespace hybrids { keyof Omit, string >]: property extends "render" | "content" - ? RenderFunction | Property - : Property; + ? RenderFunction | Property + : Property; } & { render?: RenderFunction; content?: RenderFunction; @@ -72,24 +69,24 @@ declare namespace hybrids { /* Factories */ - function property( + function property( value: V | null | undefined | ((value: any) => V), - connect?: Descriptor["connect"], - ): Descriptor; + connect?: Descriptor["connect"], + ): Descriptor; - function parent( + function parent( hybridsOrFn: Hybrids | ((hybrids: Hybrids) => boolean), - ): Descriptor; + ): Descriptor; - function children( + function children( hybridsOrFn: Hybrids | ((hybrids: Hybrids) => boolean), options?: { deep?: boolean; nested?: boolean }, - ): Descriptor; + ): Descriptor; - function render( + function render( fn: RenderFunction, customOptions?: { shadowRoot?: boolean | object }, - ): Descriptor HTMLElement>; + ): Descriptor HTMLElement>; /* Store */ @@ -98,7 +95,7 @@ declare namespace hybrids { infer T > ? [Model] | ((model: M) => T[]) - : Required[property] extends Object + : Required[property] extends object ? Model[property]> | ((model: M) => M[property]) : Required[property] | ((model: M) => M[property]); } & { @@ -108,13 +105,11 @@ declare namespace hybrids { type ModelIdentifier = | string - | undefined - | { - [property: string]: string | boolean | number | null; - }; + | Record + | undefined; type ModelValues = { - [property in keyof M]?: M[property] extends Object + [property in keyof M]?: M[property] extends object ? ModelValues : M[property]; }; @@ -135,12 +130,12 @@ declare namespace hybrids { type StoreOptions = | keyof E | ((host: E) => ModelIdentifier) - | { id?: keyof E; draft: boolean }; + | { id?: keyof E; draft?: boolean }; - function store( + function store( Model: Model, options?: StoreOptions, - ): Descriptor; + ): Descriptor; namespace store { const connect = "__store__connect__"; @@ -200,7 +195,7 @@ declare namespace hybrids { function router( views: MapOfViews, options?: RouterOptions, - ): Descriptor; + ): Descriptor; namespace router { const connect = "__router__connect__"; @@ -273,7 +268,7 @@ declare namespace hybrids { ): EventHandler; function resolve( - promise: Promise>, + promise: Promise, placeholder?: UpdateFunction, delay?: number, ): UpdateFunction; From 6e5bbf4505b478adaa522a913378fad16693384b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Mon, 17 May 2021 09:54:51 +0200 Subject: [PATCH 32/49] fix(store): support a list of models in ready & promise guards --- docs/misc/api-reference.md | 8 ++++---- docs/store/usage.md | 18 +++++++++--------- src/store.js | 31 +++++++++++++++++++++++-------- test/spec/store.js | 32 ++++++++++++++++++++++++++++++++ types/index.d.ts | 10 ++++++++-- 5 files changed, 76 insertions(+), 23 deletions(-) diff --git a/docs/misc/api-reference.md b/docs/misc/api-reference.md index 5c1729d0..3f328bc8 100644 --- a/docs/misc/api-reference.md +++ b/docs/misc/api-reference.md @@ -146,22 +146,22 @@ store.submit(model: Model): Promise ### guards ```typescript -store.ready(model: object): boolean +store.ready(model, ...): boolean ``` * **arguments**: * `model: object` - a model instance * **returns**: - * `true` for a valid model instance, `false` otherwise + * `true` for valid model instances, `false` otherwise ```typescript -store.pending(model: object): boolean | Promise +store.pending(model, ...): boolean | Promise ``` * **arguments**: * `model: object` - a model instance * **returns**: - * In pending state a promise instance resolving with the next model value, `false` otherwise + * In pending state a promise instance resolving with the next model value or a list of values, `false` otherwise ```typescript store.error(model: object, propertyName?: string): boolean | Error | any diff --git a/docs/store/usage.md b/docs/store/usage.md index aceba803..6f2935e9 100644 --- a/docs/store/usage.md +++ b/docs/store/usage.md @@ -306,17 +306,17 @@ The store provides three guard methods, which indicate the current state of the ### Ready ```typescript -store.ready(model: Model): boolean +store.ready(model, ...): boolean ``` * **arguments**: * `model` - a model instance * **returns**: - * `true` for a valid model instance, `false` otherwise + * `true` for valid model instances, `false` otherwise -The ready guard protects access to the models for async storage before they are fetched for the first time. You can also use it with sync storage, but if you are aware of the connection type, you can omit the guard. +The ready guard protects access to the models with async storage before they are fetched for the first time. You can also use it with sync storage, but if you are aware of the connection type, you can omit the guard. -The guard returns `true` only for a valid model instance. If the model has changed, the previous state of the model is not valid anymore, so for that object, it will return `false`. +The function supports passing one or more model instances. The guard returns `true` only for all resolved model instances (`AND` condition). If one of the models has changed, the previous state of the model is not valid anymore, so for that object, it will return `false`. When the model instance is going to be updated (by setting a new value, or by cache invalidation), the store returns the last valid state of the model until a new version is ready. In that situation `store.ready()` still returns `true`. It is up to you if you want to display a dirty state or not by combining ready and pending guards. It works the same if the update fails (then `store.error()` will be truthy as well). In simple words, the `store.ready()` always return `true` if the model was resolved at least once. @@ -341,19 +341,19 @@ const MyElement = { ### Pending ```typescript -store.pending(model: Model): boolean | Promise +store.pending(model, ...): boolean | Promise ``` * **arguments**: * `model` - a model instance * **returns**: - * In pending state a promise instance resolving with the next model value, `false` otherwise + * In pending state a promise instance resolving with the next model value or a list of values, `false` otherwise -The pending guard returns a promise when a model instance is fetched from async storages, or when the instance is set (`store.set()` method always use Promise API). If the model instance is returned (it is in a stable state), the guard returns `false`. +The function supports passing one or more model instances. It returns a promise when at least one of the model instances (`OR` condition) is being fetched from async storage, or when the instance is set (`store.set()` method always use Promise API). If the model instance is resolved (it is in a stable state), the guard returns `false`. -Both pending and ready guards can be truthy if the already resolved model instance updates. +Both pending and ready guards can be truthy if the already resolved model instance is being updated. -#### Resolve Helper +### Resolve You can use `store.resolve()` helper method to simplify access to pending model instances, which can be updated at the moment. The function returns a promise resolving into the current model instance, regardless of pending state. It also supports multiple chain of set methods, so the result will always be the latest instance. diff --git a/src/store.js b/src/store.js index d5c6daeb..2bf92b1e 100644 --- a/src/store.js +++ b/src/store.js @@ -1044,10 +1044,21 @@ function clear(model, clearValue = true) { } } -function pending(model) { - if (model === null || typeof model !== "object") return false; - const { state, value } = getModelState(model); - return state === "pending" && value; +function pending(...models) { + let isPending = false; + const result = models.map(model => { + try { + const { state, value } = getModelState(model); + if (state === "pending") { + isPending = true; + return value; + } + } catch (e) {} // eslint-disable-line no-empty + + return Promise.resolve(model); + }); + + return isPending && (models.length > 1 ? Promise.all(result) : result[0]); } function resolveToLatest(model) { @@ -1075,10 +1086,14 @@ function error(model, property) { return result; } -function ready(model) { - if (model === null || typeof model !== "object") return false; - const config = definitions.get(model); - return !!(config && config.isInstance(model)); +function ready(...models) { + return ( + models.length > 0 && + models.every(model => { + const config = definitions.get(model); + return !!(config && config.isInstance(model)); + }) + ); } function mapValueWithState(lastValue, nextValue) { diff --git a/test/spec/store.js b/test/spec/store.js index 9945f024..3f1ffa1e 100644 --- a/test/spec/store.js +++ b/test/spec/store.js @@ -993,6 +993,7 @@ describe("store:", () => { expect(store.pending()).toBe(false); expect(store.error()).toBe(false); expect(store.ready()).toBe(false); + expect(store.ready(123)).toBe(false); }); it("ready() returns truth for ready model instance", done => { @@ -1005,6 +1006,37 @@ describe("store:", () => { .then(done); }); + it("ready() returns false if one of the argument is not resolved model instance", done => { + Model = { id: true }; + store + .set(Model) + .then(model => { + expect(store.ready(model, null)).toBe(false); + }) + .then(done); + }); + + it("pending() returns a promise for a list of pending models", done => { + Model = { + id: true, + value: "", + [store.connect]: id => Promise.resolve({ id, value: "test" }), + }; + + store + .pending(store.get(Model, "1"), store.get(Model, "2")) + .then(([a, b]) => { + expect(a).toEqual({ id: "1", value: "test" }); + expect(b).toEqual({ id: "2", value: "test" }); + + return store.pending(a, store.get(Model, "3")).then(([c, d]) => { + expect(c).toBe(a); + expect(d).toEqual({ id: "3", value: "test" }); + }); + }) + .then(done); + }); + it("ready() returns truth for ready a list of models", () => { Model = { id: true }; const list = store.get([Model]); diff --git a/types/index.d.ts b/types/index.d.ts index dde3cbed..7dfa24af 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -148,9 +148,15 @@ declare namespace hybrids { function sync(model: Model | M, values: ModelValues | null): M; function clear(model: Model | M, clearValue?: boolean): void; - function pending(model: M): false | Promise; + function pending(model: Model): false | Promise; + function pending( + ...models: Array> + ): false | Promise; + function error(model: M, propertyName?: keyof M): false | Error | any; - function ready(model: M): boolean; + + function ready(model: Model): boolean; + function ready(...models: Array>): boolean; function submit(draft: M, values?: ModelValues): Promise; function resolve(model: M): Promise; From 686cbb6cbb8b1214437636bb562a2c6aef758a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Tue, 18 May 2021 14:05:57 +0200 Subject: [PATCH 33/49] update types --- types/index.d.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index f4de2bf4..32749e0f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -49,12 +49,14 @@ declare namespace hybrids { } type MapOfConstructors = { - [tagName in keyof T]: typeof HTMLElement; + [tagName in keyof T]: T[tagName] extends Hybrids + ? HybridElement + : typeof HTMLElement; }; - interface HybridElement { - new (): E; - prototype: E; + interface HybridElement extends HTMLElement { + new (): E & HTMLElement; + prototype: E & HTMLElement; } /* Define */ @@ -62,7 +64,7 @@ declare namespace hybrids { function define( tagName: string | null, hybrids: Hybrids, - ): HybridElement; + ): HybridElement; function define( mapOfHybrids: MapOfHybrids, ): MapOfConstructors; @@ -182,6 +184,10 @@ declare namespace hybrids { prefix?: string; } + interface MapOfViews { + [tagName: string]: View; + } + type View = Hybrids & { __router__connect__?: { url?: string; @@ -192,10 +198,6 @@ declare namespace hybrids { }; }; - interface MapOfViews { - [tagName: string]: View; - } - function router( views: MapOfViews, options?: RouterOptions, @@ -205,12 +207,13 @@ declare namespace hybrids { const connect = "__router__connect__"; type UrlParams = { - [property in keyof V]?: any; + [property in keyof V]?: DescriptorValue; }; function url(view: V, params?: UrlParams): string; function backUrl(options?: { nested?: boolean }): string; function guardUrl(params?: UrlParams): string; + function currentUrl(): string; function active( views: View | View[], From 9c1f950686555b9e80d40eb16d2cb8709040fba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Tue, 18 May 2021 14:07:22 +0200 Subject: [PATCH 34/49] fix(types): better result type of define function --- types/index.d.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 33863d83..e5148eba 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -49,12 +49,14 @@ declare namespace hybrids { } type MapOfConstructors = { - [tagName in keyof T]: typeof HTMLElement; + [tagName in keyof T]: T[tagName] extends Hybrids + ? HybridElement + : typeof HTMLElement; }; - interface HybridElement { - new (): E; - prototype: E; + interface HybridElement extends HTMLElement { + new (): E & HTMLElement; + prototype: E & HTMLElement; } /* Define */ @@ -62,7 +64,8 @@ declare namespace hybrids { function define( tagName: string | null, hybrids: Hybrids, - ): HybridElement; + ): HybridElement; + function define( mapOfHybrids: MapOfHybrids, ): MapOfConstructors; From 397730422f2a6decbd7f2cbea89ac13a94a75287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Tue, 18 May 2021 14:08:55 +0200 Subject: [PATCH 35/49] fix merge --- types/index.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types/index.d.ts b/types/index.d.ts index 32749e0f..c3295d85 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -65,6 +65,7 @@ declare namespace hybrids { tagName: string | null, hybrids: Hybrids, ): HybridElement; + function define( mapOfHybrids: MapOfHybrids, ): MapOfConstructors; From 73e436e97322263cb6d88066242aab7ebbe0f407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 19 May 2021 11:17:50 +0200 Subject: [PATCH 36/49] Use element constructor as a view reference --- src/router.js | 139 ++++++++++++++++++------------------- test/spec/router.js | 162 +++++++++++++++++++++----------------------- types/index.d.ts | 31 ++++----- 3 files changed, 155 insertions(+), 177 deletions(-) diff --git a/src/router.js b/src/router.js index 7db83bb1..49530ea4 100644 --- a/src/router.js +++ b/src/router.js @@ -1,6 +1,6 @@ -import { defineElement, callbacksMap } from "./define.js"; +import { callbacksMap } from "./define.js"; import * as cache from "./cache.js"; -import { dispatch, pascalToDash } from "./utils.js"; +import { dispatch } from "./utils.js"; /* @@ -98,7 +98,7 @@ function restoreLayout(target, clear) { } const placeholder = Date.now(); -function setupBrowserUrl(browserUrl, name) { +function setupBrowserUrl(browserUrl, id) { if (!browserUrl) return null; const [pathname, search = ""] = browserUrl.split("?"); @@ -127,7 +127,7 @@ function setupBrowserUrl(browserUrl, name) { const key = pathnameParams[index - 1]; if (!hasOwnProperty.call(params, key) && !suppressErrors) { - throw Error(`The '${key}' parameter must be defined in '${name}'`); + throw Error(`The '${key}' parameter must be defined for <${id}>`); } return `${acc}${params[key]}${part}`; @@ -147,7 +147,9 @@ function setupBrowserUrl(browserUrl, name) { if (searchParams.includes(key)) { url.searchParams.append(key, params[key] || ""); } else { - throw TypeError(`The '${key}' parameter is not supported`); + throw TypeError( + `The '${key}' parameter is not supported for <${id}>`, + ); } }); } @@ -165,7 +167,10 @@ function setupBrowserUrl(browserUrl, name) { temp = temp.substr(1); - if (temp.substr(0, parts[i].length) !== parts[i]) return null; + if (!parts[i] || temp.substr(0, parts[i].length) !== parts[i]) { + return null; + } + temp = temp .substr(parts[i].length) .replace(/^([^/]+)/, (_, value) => { @@ -196,13 +201,13 @@ function hasInStack(config, target) { function setupViews(views, options, parent = null, nestedParent = null) { if (typeof views === "function") views = views(); - const result = Object.entries(views).map(([name, view]) => { + const result = views.map(Constructor => { // eslint-disable-next-line no-use-before-define - const config = setupView(view, name, options, parent, nestedParent); + const config = setupView(Constructor, options, parent, nestedParent); if (parent && hasInStack(config, parent)) { throw Error( - `${parent.name} cannot be in the stack of ${config.name} - ${config.name} already connected to ${parent.name}`, + `<${parent.id}> cannot be in the stack of <${config.id}> - it is already in stack of <${parent.id}>`, ); } @@ -214,55 +219,39 @@ function setupViews(views, options, parent = null, nestedParent = null) { return result; } -function getNestedRouterOptions(view, name, config) { - const nestedRouters = Object.values(view) +function getNestedRouterOptions(hybrids, id, config) { + const nestedRouters = Object.values(hybrids) .map(desc => routers.get(desc)) .filter(d => d); if (nestedRouters.length) { if (nestedRouters.length > 1) { throw TypeError( - `'${name}' view must contain at most one nested router: ${nestedRouters.length}`, + `<${id}> must contain at most one nested router: ${nestedRouters.length}`, ); } if (config.dialog) { throw TypeError( - `Nested routers are not supported in dialogs. Remove the router factory from '${name}' view`, + `Nested routers are not supported in dialogs. Remove the router property definition from <${id}>`, ); } if (config.browserUrl) { throw TypeError( - `A view with nested router must not have the url option. Remove the url option from '${name}' view`, + `A view with nested router must not have the url option. Remove the url option from <${id}>`, ); } } return nestedRouters[0]; } -function setupView(view, name, routerOptions, parent, nestedParent) { - const prefix = routerOptions.prefix || "view"; - const id = `${pascalToDash(prefix)}-${pascalToDash(name)}`; - - if (!view || typeof view !== "object") { - throw TypeError( - `${name} in the stack of ${ - parent.name - } must be an object instance: ${typeof view} - for import/export cycle, wrap stack option in a function`, - ); - } - - let config = configs.get(view); +function setupView(Constructor, routerOptions, parent, nestedParent) { + const id = new Constructor().tagName.toLowerCase(); + let config = configs.get(Constructor); if (config) { - if (config.name !== name) { - throw Error( - `View definition for ${name} in ${parent.name} already connected to the router as ${config.name}`, - ); - } - - if (config.id !== id) { + if (config.hybrids !== Constructor.hybrids) { configs.delete(customElements.get(config.id)); config = null; } @@ -271,16 +260,34 @@ function setupView(view, name, routerOptions, parent, nestedParent) { if (!config) { let browserUrl = null; - const options = { + let options = { dialog: false, guard: false, multiple: false, replace: false, - ...view[connect], }; - const Constructor = defineElement(id, view); - callbacksMap.get(Constructor).push(restoreLayout); + const hybrids = Constructor.hybrids; + if (hybrids) { + options = { ...options, ...hybrids[connect] }; + + callbacksMap.get(Constructor).push(restoreLayout); + + if (options.dialog) { + callbacksMap.get(Constructor).push(host => { + const cb = event => { + if (event.key === "Escape") { + event.stopPropagation(); + window.history.go(-1); + } + }; + host.addEventListener("keydown", cb); + return () => { + host.removeEventListener("keydown", cb); + }; + }); + } + } const writableParams = new Set(); Object.keys(Constructor.prototype).forEach(key => { @@ -288,33 +295,18 @@ function setupView(view, name, routerOptions, parent, nestedParent) { if (desc.set) writableParams.add(key); }); - if (options.dialog) { - callbacksMap.get(Constructor).push(host => { - const cb = event => { - if (event.key === "Escape") { - event.stopPropagation(); - window.history.go(-1); - } - }; - host.addEventListener("keydown", cb); - return () => { - host.removeEventListener("keydown", cb); - }; - }); - } - if (options.url) { if (options.dialog) { throw Error( - `The 'url' option is not supported for dialogs - remove it from '${name}'`, + `The 'url' option is not supported for dialogs - remove it from <${id}>`, ); } if (typeof options.url !== "string") { throw TypeError( - `The 'url' option in '${name}' must be a string: ${typeof options.url}`, + `The 'url' option in <${id}> must be a string: ${typeof options.url}`, ); } - browserUrl = setupBrowserUrl(options.url, name); + browserUrl = setupBrowserUrl(options.url, id); callbacksMap.get(Constructor).unshift(_ => cache.observe( @@ -344,7 +336,7 @@ function setupView(view, name, routerOptions, parent, nestedParent) { ); if (!desc || !desc.set) { throw Error( - `'${key}' parameter in the url is not supported in '${name}'`, + `'${key}' parameter in the url is not supported for <${id}>`, ); } }); @@ -365,8 +357,7 @@ function setupView(view, name, routerOptions, parent, nestedParent) { config = { id, - name, - view, + hybrids, dialog: options.dialog, multiple: options.multiple, replace: options.replace, @@ -385,13 +376,13 @@ function setupView(view, name, routerOptions, parent, nestedParent) { url.searchParams.append(key, params[key] || ""); } else if (!suppressErrors) { throw TypeError( - `The '${key}' parameter for '${name}' is not supported`, + `The '${key}' parameter is not supported for <${id}>`, ); } }); return new URL( - `${routerOptions.url || ""}#@${id}${url.search}`, + `${routerOptions.url}#@${id}${url.search}`, window.location.origin, ); }, @@ -433,7 +424,6 @@ function setupView(view, name, routerOptions, parent, nestedParent) { }, }; - configs.set(view, config); configs.set(Constructor, config); config.parent = parent; @@ -442,7 +432,7 @@ function setupView(view, name, routerOptions, parent, nestedParent) { if (options.stack) { if (options.dialog) { throw Error( - `The 'stack' option is not supported for dialogs - remove it from '${name}'`, + `The 'stack' option is not supported for dialogs - remove it from <${id}>`, ); } config.stack = setupViews( @@ -463,16 +453,13 @@ function setupView(view, name, routerOptions, parent, nestedParent) { parent = parent.parent; } - const nestedRouterOptions = getNestedRouterOptions(view, name, config); + const nestedRouterOptions = + config.hybrids && getNestedRouterOptions(config.hybrids, id, config); if (nestedRouterOptions) { config.nestedRoots = setupViews( nestedRouterOptions.views, - { - ...routerOptions, - prefix: `${prefix}-${pascalToDash(name)}`, - ...nestedRouterOptions, - }, + { ...routerOptions, ...nestedRouterOptions }, config, config, ); @@ -491,7 +478,7 @@ function getConfigById(id) { function getUrl(view, params = {}) { const config = configs.get(view); if (!config) { - throw Error(`Provided view is not connected to the router`); + throw Error(`Provided view is not connected to the router: ${view}`); } return config.url(params); @@ -559,14 +546,14 @@ function getGuardUrl(params = {}) { return config.stack[0] ? config.stack[0].url(params) : ""; } -function getCurrentUrl() { +function getCurrentUrl(params) { const state = window.history.state; if (!state) return ""; const entry = state[0]; const config = getConfigById(entry.id); - return config.url(entry.params); + return config.url({ ...entry.params, ...params }); } function active(views, { stack = false } = {}) { @@ -578,7 +565,7 @@ function active(views, { stack = false } = {}) { return views.some(view => { const config = configs.get(view); if (!config) { - throw TypeError("The first argument must be connected view definition"); + throw TypeError(`Provided view is not connected to the router: ${view}`); } let entry = state[0]; @@ -958,7 +945,11 @@ function connectNestedRouter(host, invalidate) { } function router(views, options) { - options = { ...options, views }; + options = { + url: window.location.pathname, + ...options, + views, + }; const desc = { get: host => { diff --git a/test/spec/router.js b/test/spec/router.js index 15ada3f8..22a35508 100644 --- a/test/spec/router.js +++ b/test/spec/router.js @@ -1,6 +1,6 @@ -import { dispatch, router } from "../../src/index.js"; +import { define, dispatch, router } from "../../src/index.js"; -describe("router:", () => { +fdescribe("router:", () => { let el; let spy; let prop; @@ -28,109 +28,101 @@ describe("router:", () => { window.history.replaceState(null, "", href); }); - describe("for root router", () => { - const views = { - Home: {}, - One: { - [router.connect]: { - url: "/one", - }, + describe("root router", () => { + const Home = define("test-router-home", {}); + const One = define("test-router-one", { + [router.connect]: { + url: "/one", }, - Two: {}, - Dialog: { - [router.connect]: { - dialog: true, - }, + }); + const Two = define("test-router-two", {}); + const Dialog = define("test-router-dialog", { + [router.connect]: { + dialog: true, }, - }; - - describe("connected root router", () => { - beforeEach(() => { - prop = router(views); - }); + }); + const views = [Home, One, Two, Dialog]; - it("returns a default view", () => { - disconnect = prop.connect(el, "", spy); + beforeEach(() => { + prop = router(views); + }); - const list = prop.get(el); + it("returns a default view", () => { + disconnect = prop.connect(el, "", spy); - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Element); - expect(list[0].constructor.hybrids).toBe(views.Home); - }); + const list = prop.get(el); - it("returns a view by matching URL", () => { - window.history.replaceState(null, "", "/one"); + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Home); + }); - disconnect = prop.connect(el, "", spy); - const list = prop.get(el); + it("returns a view by matching URL", () => { + window.history.replaceState(null, "", "/one"); - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Element); - expect(list[0].constructor.hybrids).toBe(views.One); - }); + disconnect = prop.connect(el, "", spy); + const list = prop.get(el); - it("sets a view to window history", () => { - disconnect = prop.connect(el, "", spy); - expect(window.history.state).toBeInstanceOf(Array); - expect(window.history.state.length).toBe(1); - }); + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(One); + }); - it("returns a view saved in window history", () => { - window.history.replaceState([{ id: "view-two", params: {} }], ""); - disconnect = prop.connect(el, "", spy); - const list = prop.get(el); + it("sets a view to window history", () => { + disconnect = prop.connect(el, "", spy); + expect(window.history.state).toBeInstanceOf(Array); + expect(window.history.state.length).toBe(1); + }); - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Element); - expect(list[0].constructor.hybrids).toBe(views.Two); - }); + it("returns a view saved in window history", () => { + window.history.replaceState([{ id: "test-router-two", params: {} }], ""); + disconnect = prop.connect(el, "", spy); + const list = prop.get(el); - it("returns a default view when saved view is not found", () => { - window.history.replaceState( - [{ id: "view-some-other", params: {} }], - "", - ); - disconnect = prop.connect(el, "", spy); - const list = prop.get(el); + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Two); + }); - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Element); - expect(list[0].constructor.hybrids).toBe(views.Home); - }); + it("returns a default view when saved view is not found", () => { + window.history.replaceState( + [{ id: "test-router-some-other", params: {} }], + "", + ); + disconnect = prop.connect(el, "", spy); + const list = prop.get(el); - it("goes back when dialog element is on the top of the stack", done => { - window.history.replaceState([{ id: "view-two", params: {} }], ""); - window.history.pushState( - [ - { id: "view-dialog", params: {} }, - { id: "view-two", params: {} }, - ], - "", - ); + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Home); + }); - disconnect = prop.connect(el, "", () => { - const list = prop.get(el); + it("goes back when dialog element is on the top of the stack", done => { + window.history.replaceState([{ id: "test-router-two", params: {} }], ""); + window.history.pushState( + [ + { id: "test-router-dialog", params: {} }, + { id: "test-router-two", params: {} }, + ], + "", + ); + + disconnect = prop.connect(el, "", () => { + const list = prop.get(el); - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Element); - expect(list[0].constructor.hybrids).toBe(views.Two); - done(); - }); + expect(list).toBeInstanceOf(Array); + expect(list[0]).toBeInstanceOf(Two); + done(); }); + }); - it("does not go back for dialog view when reconnecting (HMR)", done => { - disconnect = prop.connect(el, "", spy); - navigate(router.url(views.Dialog)); + it("does not go back for dialog view when reconnecting (HMR)", done => { + disconnect = prop.connect(el, "", spy); + navigate(router.url(Dialog)); - disconnect(); + disconnect(); - disconnect = prop.connect(el, "", () => { - const list = prop.get(el); - expect(list[1]).toBeInstanceOf(Element); - expect(list[1].constructor.hybrids).toBe(views.Dialog); - done(); - }); + disconnect = prop.connect(el, "", () => { + const list = prop.get(el); + expect(list[1]).toBeInstanceOf(Element); + expect(list[1]).toBeInstanceOf(Dialog); + done(); }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index c3295d85..b16b2647 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -54,7 +54,7 @@ declare namespace hybrids { : typeof HTMLElement; }; - interface HybridElement extends HTMLElement { + interface HybridElement { new (): E & HTMLElement; prototype: E & HTMLElement; } @@ -180,46 +180,41 @@ declare namespace hybrids { } /* Router */ - interface RouterOptions { - url?: string; - prefix?: string; - } - - interface MapOfViews { - [tagName: string]: View; - } - type View = Hybrids & { __router__connect__?: { url?: string; multiple?: boolean; replace?: boolean; guard?: (host: E) => any; - stack?: MapOfViews; + stack?: HybridElement[]; }; }; - function router( - views: MapOfViews, - options?: RouterOptions, + function router( + views: HybridElement[], + options?: { + url?: string; + }, ): Descriptor; namespace router { const connect = "__router__connect__"; type UrlParams = { - [property in keyof V]?: DescriptorValue; + [property in keyof V]?: V[property]; }; - function url(view: V, params?: UrlParams): string; + function url(view: HybridElement, params?: UrlParams): string; + function backUrl(options?: { nested?: boolean }): string; function guardUrl(params?: UrlParams): string; - function currentUrl(): string; + function currentUrl(params?: UrlParams): string; function active( - views: View | View[], + views: HybridElement | HybridElement[], options?: { stack?: boolean }, ): boolean; + function resolve(event: Event, promise: Promise): void; } From e302b8b5296119492ce6a0f844b9d0616eae4415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 19 May 2021 11:25:54 +0200 Subject: [PATCH 37/49] Minor fixes --- src/router.js | 6 +++--- test/spec/router.js | 2 +- types/index.d.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/router.js b/src/router.js index 49530ea4..deb33079 100644 --- a/src/router.js +++ b/src/router.js @@ -270,11 +270,11 @@ function setupView(Constructor, routerOptions, parent, nestedParent) { const hybrids = Constructor.hybrids; if (hybrids) { options = { ...options, ...hybrids[connect] }; - - callbacksMap.get(Constructor).push(restoreLayout); + const callbacks = callbacksMap.get(Constructor); + callbacks.push(restoreLayout); if (options.dialog) { - callbacksMap.get(Constructor).push(host => { + callbacks.push(host => { const cb = event => { if (event.key === "Escape") { event.stopPropagation(); diff --git a/test/spec/router.js b/test/spec/router.js index 22a35508..9a96591e 100644 --- a/test/spec/router.js +++ b/test/spec/router.js @@ -1,6 +1,6 @@ import { define, dispatch, router } from "../../src/index.js"; -fdescribe("router:", () => { +describe("router:", () => { let el; let spy; let prop; diff --git a/types/index.d.ts b/types/index.d.ts index b16b2647..609b8a71 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -186,12 +186,12 @@ declare namespace hybrids { multiple?: boolean; replace?: boolean; guard?: (host: E) => any; - stack?: HybridElement[]; + stack?: typeof HTMLElement[]; }; }; function router( - views: HybridElement[], + views: typeof HTMLElement[], options?: { url?: string; }, From e48f838de259e70d63a101c4e5754b40179dbe8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 19 May 2021 14:14:25 +0200 Subject: [PATCH 38/49] Fix focus on navigate --- src/router.js | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/router.js b/src/router.js index deb33079..fb039ff9 100644 --- a/src/router.js +++ b/src/router.js @@ -2,15 +2,6 @@ import { callbacksMap } from "./define.js"; import * as cache from "./cache.js"; import { dispatch } from "./utils.js"; -/* - -# TODO LIST - -* Check gourd feature after navigate refactor (guard condition) -* Transition effect - -*/ - const connect = Symbol("router.connect"); const configs = new WeakMap(); @@ -48,6 +39,7 @@ const focusMap = new WeakMap(); function saveLayout(target) { const focusEl = document.activeElement; focusMap.set(target, target.contains(focusEl) ? focusEl : target); + const map = new Map(); if (!configs.get(target).nestedParent) { @@ -64,18 +56,15 @@ function saveLayout(target) { scrollMap.set(target, map); } -// let focusTarget; function restoreLayout(target, clear) { - // const focusEl = focusMap.get(target) || target; - - // if (!focusTarget) { - // requestAnimationFrame(() => { - // focusTarget.focus({ preventScroll: true }); - // focusTarget = null; - // }); - // } - - // focusTarget = focusEl; + const focusTarget = focusMap.get(target); + if (focusTarget) { + focusTarget.setAttribute("tabindex", "0"); + focusTarget.focus({ preventScroll: true }); + focusTarget.removeAttribute("tabindex"); + } else { + target.focus({ preventScroll: true }); + } const map = scrollMap.get(target); From 7f9823ee0b90139213dea905f44336a3cfea60dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Thu, 20 May 2021 09:36:43 +0200 Subject: [PATCH 39/49] Fix setting focus target --- src/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router.js b/src/router.js index fb039ff9..2dd2d8c7 100644 --- a/src/router.js +++ b/src/router.js @@ -38,7 +38,7 @@ const scrollMap = new WeakMap(); const focusMap = new WeakMap(); function saveLayout(target) { const focusEl = document.activeElement; - focusMap.set(target, target.contains(focusEl) ? focusEl : target); + focusMap.set(target, target.contains(focusEl) && focusEl); const map = new Map(); From 3760b2af66b956f463b87e7a3241b26c5b9f7d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Thu, 20 May 2021 10:33:39 +0200 Subject: [PATCH 40/49] fix(types): render & content type for direct factory usage --- types/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index f94ca51b..752a8f2a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -40,8 +40,8 @@ declare namespace hybrids { ? RenderFunction | Property : Property; } & { - render?: RenderFunction; - content?: RenderFunction; + render?: RenderFunction | Descriptor HTMLElement>; + content?: RenderFunction | Descriptor HTMLElement>; }; interface MapOfHybrids { From 67682fc94b991d74de4269074c99ebcb50af3254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Thu, 20 May 2021 11:16:11 +0200 Subject: [PATCH 41/49] Check guard when router connects with preloaded state --- src/router.js | 52 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/router.js b/src/router.js index 2dd2d8c7..8f871459 100644 --- a/src/router.js +++ b/src/router.js @@ -809,40 +809,47 @@ function connectRootRouter(host, invalidate, options) { } } - function navigate(event) { - const nextEntry = getEntryFromURL(event.detail.url); - if (!nextEntry) return; - - event.stopPropagation(); - - if (event.detail.event) { - event.detail.event.preventDefault(); - } - + function navigate(entry) { const state = window.history.state; - const nextConfig = getConfigById(nextEntry.id); + + let nestedEntry = entry; + while (nestedEntry.nested) nestedEntry = nestedEntry.nested; + const nestedConfig = getConfigById(nestedEntry.id); let url = options.url || ""; - if (nextConfig.browserUrl) { - url = nextConfig.url(nextEntry.params); + if (nestedConfig.browserUrl) { + url = nestedConfig.url(entry.params); } let stack = stacks.get(host); - while (stack) { + while (stack && stack[0]) { cache.suspend(stack[0]); stack = stacks.get(stack[0]); } - const offset = getEntryOffset(nextEntry); + const offset = getEntryOffset(entry); if (offset > -1) { - navigateBack(offset, nextEntry, url); + navigateBack(offset, entry, url); } else { - window.history.pushState([nextEntry, ...state], "", url); + window.history.pushState([entry, ...state], "", url); flush(); } } + function onNavigate(event) { + const nextEntry = getEntryFromURL(event.detail.url); + if (!nextEntry) return; + + event.stopPropagation(); + + if (event.detail.event) { + event.detail.event.preventDefault(); + } + + navigate(nextEntry); + } + entryPoints = []; const roots = setupViews(options.views, options); @@ -881,7 +888,12 @@ function connectRootRouter(host, invalidate, options) { options.url, ); } else { - flush(); + let entry = state[0]; + while (entry.nested) entry = entry.nested; + + const nestedConfig = getConfigById(entry.id); + const resultEntry = nestedConfig.getEntry(entry.params); + navigate(resultEntry); } } @@ -889,14 +901,14 @@ function connectRootRouter(host, invalidate, options) { host.addEventListener("click", handleNavigate); host.addEventListener("submit", handleNavigate); - host.addEventListener("navigate", navigate); + host.addEventListener("navigate", onNavigate); return () => { window.removeEventListener("popstate", flush); host.removeEventListener("click", handleNavigate); host.removeEventListener("submit", handleNavigate); - host.removeEventListener("navigate", navigate); + host.removeEventListener("navigate", onNavigate); rootRouter = null; }; From 4caf5efe3ee67f2f7a7061df7971c80e4c507ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Fri, 21 May 2021 09:39:16 +0200 Subject: [PATCH 42/49] Optimize focus on navigate --- src/router.js | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/router.js b/src/router.js index 8f871459..f5fa1ea7 100644 --- a/src/router.js +++ b/src/router.js @@ -56,16 +56,26 @@ function saveLayout(target) { scrollMap.set(target, map); } +let focusTarget = null; function restoreLayout(target, clear) { - const focusTarget = focusMap.get(target); - if (focusTarget) { - focusTarget.setAttribute("tabindex", "0"); - focusTarget.focus({ preventScroll: true }); - focusTarget.removeAttribute("tabindex"); - } else { - target.focus({ preventScroll: true }); + if (!focusTarget) { + Promise.resolve().then(() => { + const el = focusTarget; + focusTarget = null; + + if (!el.hasAttribute("tabindex")) { + el.setAttribute("tabindex", "0"); + Promise.resolve().then(() => { + el.removeAttribute("tabindex"); + }); + } + + el.focus({ preventScroll: true }); + }); } + focusTarget = focusMap.get(target) || target; + const map = scrollMap.get(target); if (map) { From 7b917c7a03ab3bc964cbeffbeb9428cce4e550fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Fri, 21 May 2021 10:13:30 +0200 Subject: [PATCH 43/49] Make navigate event bubbling for global listening --- src/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router.js b/src/router.js index f5fa1ea7..7ece88ef 100644 --- a/src/router.js +++ b/src/router.js @@ -608,7 +608,7 @@ function handleNavigate(event) { } if (rootRouter && url && url.origin === window.location.origin) { - dispatch(rootRouter, "navigate", { detail: { url, event } }); + dispatch(rootRouter, "navigate", { bubbles: true, detail: { url, event } }); } } From aee399e85111e29c0ce8883f38d144a66294e14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Mon, 24 May 2021 10:26:22 +0200 Subject: [PATCH 44/49] Prevent from focus when already focused element within the view --- src/router.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/router.js b/src/router.js index 7ece88ef..44bd6491 100644 --- a/src/router.js +++ b/src/router.js @@ -59,22 +59,23 @@ function saveLayout(target) { let focusTarget = null; function restoreLayout(target, clear) { if (!focusTarget) { - Promise.resolve().then(() => { - const el = focusTarget; - focusTarget = null; - - if (!el.hasAttribute("tabindex")) { - el.setAttribute("tabindex", "0"); - Promise.resolve().then(() => { - el.removeAttribute("tabindex"); - }); + requestAnimationFrame(() => { + const activeEl = document.activeElement; + if (!focusTarget.contains(activeEl)) { + const el = scrollMap.get(focusTarget) || focusTarget; + if (!el.hasAttribute("tabindex")) { + el.setAttribute("tabindex", "0"); + Promise.resolve().then(() => { + el.removeAttribute("tabindex"); + }); + } + el.focus({ preventScroll: true }); } - - el.focus({ preventScroll: true }); + focusTarget = null; }); } - focusTarget = focusMap.get(target) || target; + focusTarget = target; const map = scrollMap.get(target); @@ -398,9 +399,6 @@ function setupView(Constructor, routerOptions, parent, nestedParent) { const el = new Constructor(); configs.set(el, config); - el.style.outline = "none"; - el.tabIndex = 0; - return el; }, getEntry(params = {}, other) { From 1438256145d93ec8b72c30f5b7a9695a6e61dfe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Tue, 25 May 2021 14:19:58 +0200 Subject: [PATCH 45/49] Minor code optimizations --- src/router.js | 92 +++++++-------- test/spec/router.js | 277 ++++++++++++++++++++++---------------------- 2 files changed, 181 insertions(+), 188 deletions(-) diff --git a/src/router.js b/src/router.js index 44bd6491..96b9c034 100644 --- a/src/router.js +++ b/src/router.js @@ -474,11 +474,7 @@ function getConfigById(id) { function getUrl(view, params = {}) { const config = configs.get(view); - if (!config) { - throw Error(`Provided view is not connected to the router: ${view}`); - } - - return config.url(params); + return config ? config.url(params) : ""; } function getBackUrl({ nested = false } = {}) { @@ -578,6 +574,28 @@ function active(views, { stack = false } = {}) { }); } +function getEntryFromURL(url) { + let config; + + const [pathname, search] = url.hash.split("?"); + if (pathname) { + config = getConfigById(pathname.split("@")[1]); + url = new URL(`?${search}`, window.location.origin); + } + + if (!config) { + for (let i = 0; i < entryPoints.length; i += 1) { + const entryPoint = entryPoints[i]; + const params = entryPoint.match(url); + if (params) return entryPoint.getEntry(params); + } + + return null; + } + + return config.getEntry(config.match(url)); +} + function handleNavigate(event) { if (event.defaultPrevented) return; @@ -606,7 +624,15 @@ function handleNavigate(event) { } if (rootRouter && url && url.origin === window.location.origin) { - dispatch(rootRouter, "navigate", { bubbles: true, detail: { url, event } }); + const entry = getEntryFromURL(url); + if (entry) { + event.preventDefault(); + + dispatch(rootRouter, "navigate", { + bubbles: true, + detail: { entry, url }, + }); + } } } @@ -690,28 +716,6 @@ function resolveStack(host, state) { if (flush) flush(); } -function getEntryFromURL(url) { - let config; - - const [pathname, search] = url.hash.split("?"); - if (pathname) { - config = getConfigById(pathname.split("@")[1]); - url = new URL(`?${search}`, window.location.origin); - } - - if (!config) { - for (let i = 0; i < entryPoints.length; i += 1) { - const entryPoint = entryPoints[i]; - const params = entryPoint.match(url); - if (params) return entryPoint.getEntry(params); - } - - return null; - } - - return config.getEntry(config.match(url)); -} - function getEntryOffset(entry) { const state = window.history.state.reduce((acc, e, index) => { let i = 0; @@ -845,17 +849,8 @@ function connectRootRouter(host, invalidate, options) { } } - function onNavigate(event) { - const nextEntry = getEntryFromURL(event.detail.url); - if (!nextEntry) return; - - event.stopPropagation(); - - if (event.detail.event) { - event.detail.event.preventDefault(); - } - - navigate(nextEntry); + function executeNavigate(event) { + navigate(event.detail.entry); } entryPoints = []; @@ -880,7 +875,11 @@ function connectRootRouter(host, invalidate, options) { let entry = state[i]; while (entry) { const config = getConfigById(entry.id); - if (!config || (config.dialog && stack.length === 0)) { + if ( + !config || + (config.dialog && stack.length === 0) || + (!roots.includes(config) && !roots.some(c => hasInStack(c, config))) + ) { break; } entry = entry.nested; @@ -909,14 +908,14 @@ function connectRootRouter(host, invalidate, options) { host.addEventListener("click", handleNavigate); host.addEventListener("submit", handleNavigate); - host.addEventListener("navigate", onNavigate); + host.addEventListener("navigate", executeNavigate); return () => { window.removeEventListener("popstate", flush); host.removeEventListener("click", handleNavigate); host.removeEventListener("submit", handleNavigate); - host.removeEventListener("navigate", onNavigate); + host.removeEventListener("navigate", executeNavigate); rootRouter = null; }; @@ -949,16 +948,13 @@ function connectNestedRouter(host, invalidate) { "", ); } - if (!flushes.has(host)) flush(); + + flush(); flushes.set(host, flush); } function router(views, options) { - options = { - url: window.location.pathname, - ...options, - views, - }; + options = { ...options, views }; const desc = { get: host => { diff --git a/test/spec/router.js b/test/spec/router.js index 9a96591e..45a6818f 100644 --- a/test/spec/router.js +++ b/test/spec/router.js @@ -1,171 +1,168 @@ -import { define, dispatch, router } from "../../src/index.js"; +import { define, router, html } from "../../src/index.js"; +import { resolveRaf } from "../helpers.js"; describe("router:", () => { - let el; - let spy; - let prop; - let disconnect; - let href; - - function navigate(url) { - dispatch(el, "navigate", { detail: { url } }); - } + let Nested; + let Child; + let OtherChild; + let Home; + let App; + let host; beforeEach(() => { - el = document.createElement("div"); - spy = jasmine.createSpy(); - - href = window.location.href; - window.history.replaceState(null, ""); - }); - - afterEach(() => { - if (disconnect) { - disconnect(); - disconnect = null; - } - - window.history.replaceState(null, "", href); - }); - - describe("root router", () => { - const Home = define("test-router-home", {}); - const One = define("test-router-one", { - [router.connect]: { - url: "/one", - }, + OtherChild = define("test-router-child", { + content: () => html` + Back + `, }); - const Two = define("test-router-two", {}); - const Dialog = define("test-router-dialog", { - [router.connect]: { - dialog: true, - }, - }); - const views = [Home, One, Two, Dialog]; - beforeEach(() => { - prop = router(views); - }); + Nested = define("test-router-child-nested", {}); - it("returns a default view", () => { - disconnect = prop.connect(el, "", spy); - - const list = prop.get(el); - - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Home); + Child = define("test-router-child", { + nested: router([Nested]), + content: ({ nested }) => html` + Back + OtherChild + ${nested} + `, }); - it("returns a view by matching URL", () => { - window.history.replaceState(null, "", "/one"); - - disconnect = prop.connect(el, "", spy); - const list = prop.get(el); - - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(One); + Home = define("test-router-home", { + [router.connect]: { stack: [Child, OtherChild] }, + content: () => html` + Child + Child + `, }); - it("sets a view to window history", () => { - disconnect = prop.connect(el, "", spy); - expect(window.history.state).toBeInstanceOf(Array); - expect(window.history.state.length).toBe(1); + App = define("test-router-app", { + views: router([Home]), + content: ({ views }) => html`${views}` // prettier-ignore }); - it("returns a view saved in window history", () => { - window.history.replaceState([{ id: "test-router-two", params: {} }], ""); - disconnect = prop.connect(el, "", spy); - const list = prop.get(el); - - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Two); - }); + window.history.replaceState(null, ""); + host = new App(); + document.body.appendChild(host); + }); - it("returns a default view when saved view is not found", () => { - window.history.replaceState( - [{ id: "test-router-some-other", params: {} }], - "", - ); - disconnect = prop.connect(el, "", spy); - const list = prop.get(el); + afterEach(() => { + host.parentElement.removeChild(host); + }); - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Home); + describe("connect root router -", () => { + it("displays root view", done => { + resolveRaf(() => { + expect(host.views[0]).toBeInstanceOf(Home); + expect(host.children[0]).toBeInstanceOf(Home); + }).then(done); }); - it("goes back when dialog element is on the top of the stack", done => { - window.history.replaceState([{ id: "test-router-two", params: {} }], ""); - window.history.pushState( - [ - { id: "test-router-dialog", params: {} }, - { id: "test-router-two", params: {} }, - ], - "", - ); - - disconnect = prop.connect(el, "", () => { - const list = prop.get(el); - - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(Two); - done(); - }); + it("uses view from the history state", done => { + resolveRaf(() => { + const el = host.children[0].children[0]; + el.click(); + return resolveRaf(() => { + host.parentElement.removeChild(host); + host = new App(); + document.body.appendChild(host); + return resolveRaf(() => { + expect(host.children[0]).toBeInstanceOf(Child); + }); + }); + }).then(done); }); - it("does not go back for dialog view when reconnecting (HMR)", done => { - disconnect = prop.connect(el, "", spy); - navigate(router.url(Dialog)); - - disconnect(); - - disconnect = prop.connect(el, "", () => { - const list = prop.get(el); - expect(list[1]).toBeInstanceOf(Element); - expect(list[1]).toBeInstanceOf(Dialog); - done(); - }); + it("resets state to default root view", done => { + resolveRaf(() => { + const el = host.children[0].children[0]; + el.click(); + return resolveRaf(() => { + host.parentElement.removeChild(host); + const Another = define("test-router-another", {}); + App = define("test-router-app", { + views: router([Another]), + content: ({ views }) => html`${views}` // prettier-ignore + }); + host = new App(); + document.body.appendChild(host); + return resolveRaf(() => { + expect(host.children[0]).toBeInstanceOf(Another); + }); + }); + }).then(done); }); - }); - describe("[router.connect] -", () => { - describe("'dialog'", () => { - it("pushes new entry for dialog view"); - it("pushes new entry for dialog not from the stack ???"); + it("resets state to previously connected view", done => { + resolveRaf(() => { + const el = host.children[0].children[0]; + el.click(); + return resolveRaf(() => { + host.parentElement.removeChild(host); + Home = define("test-router-home", {}); + App = define("test-router-app", { + views: router([Home]), + content: ({ views }) => html`${views}` // prettier-ignore + }); + host = new App(); + document.body.appendChild(host); + return resolveRaf(() => { + expect(host.children[0]).toBeInstanceOf(Home); + }); + }); + }).then(done); }); + }); - describe("'url'", () => {}); - - describe("'multiple'", () => { - describe("when 'true'", () => { - it( - "navigate pushes new entry for the same id when other params with multiple option is set", - ); - it("navigate moves back to entry where params are equal"); - }); - describe("when 'false'", () => { - it("replaces state for the same view"); - }); + describe("navigate -", () => { + it("navigates to Child and go back to Home", done => { + resolveRaf(() => { + let el = host.children[0].children[0]; + el.click(); + return resolveRaf(() => { + expect(host.views[0]).toBeInstanceOf(Child); + expect(host.children[0]).toBeInstanceOf(Child); + expect(window.history.state.length).toBe(2); + + el = host.children[0].children[0]; + el.click(); + + return resolveRaf(() => { + expect(host.views[0]).toBeInstanceOf(Home); + expect(host.children[0]).toBeInstanceOf(Home); + expect(window.history.state.length).toBe(1); + }); + }); + }).then(done); }); - describe("'guard'", () => { - it("displays guard parent when condition is not met"); - it("displays the first view from own stack when condition is met"); + it("navigates to Child and replace state with OtherChild", done => { + resolveRaf(() => { + let el = host.children[0].children[0]; + el.click(); + return resolveRaf(() => { + el = host.children[0].children[1]; + el.click(); + + return resolveRaf(() => { + expect(host.views[0]).toBeInstanceOf(OtherChild); + expect(host.children[0]).toBeInstanceOf(OtherChild); + expect(window.history.state.length).toBe(2); + }); + }); + }).then(done); }); + }); - describe("'stack'", () => { - describe("for view from own stack", () => { - it("pushes new entry for view from stack"); - it("moves back to the view from stack"); - }); - - describe("for view not from own stack", () => { - it("finds a common parent, clears stack, and pushes new state"); - }); + describe("url() -", () => { + it("returns empty string for not connected view", () => { + const MyElement = define("test-router-my-element", {}); + expect(router.url(MyElement)).toBe(""); }); }); - describe("view layout", () => { - it("saves scroll positions"); - it("saves the latest focused element"); - }); + describe("resolve() -", () => {}); + describe("backUrl() -", () => {}); + describe("guardUrl() -", () => {}); + describe("currentUrl() -", () => {}); + describe("active() -", () => {}); }); From 399481ac5726a5a7996cd6b5481e3eae94b39545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 26 May 2021 14:34:07 +0200 Subject: [PATCH 46/49] Fix restore scroll when focus on Safari --- src/router.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/router.js b/src/router.js index 96b9c034..1fb7349a 100644 --- a/src/router.js +++ b/src/router.js @@ -57,17 +57,16 @@ function saveLayout(target) { } let focusTarget = null; +const deffer = Promise.resolve(); function restoreLayout(target, clear) { if (!focusTarget) { - requestAnimationFrame(() => { + deffer.then(() => { const activeEl = document.activeElement; if (!focusTarget.contains(activeEl)) { const el = scrollMap.get(focusTarget) || focusTarget; - if (!el.hasAttribute("tabindex")) { - el.setAttribute("tabindex", "0"); - Promise.resolve().then(() => { - el.removeAttribute("tabindex"); - }); + if (el.tabIndex === -1) { + el.tabIndex = 0; + deffer.then(() => el.removeAttribute("tabindex")); } el.focus({ preventScroll: true }); } @@ -80,7 +79,7 @@ function restoreLayout(target, clear) { const map = scrollMap.get(target); if (map) { - Promise.resolve().then(() => { + deffer.then(() => { map.forEach((pos, el) => { el.scrollLeft = clear ? 0 : pos.left; el.scrollTop = clear ? 0 : pos.top; @@ -90,7 +89,7 @@ function restoreLayout(target, clear) { scrollMap.delete(target); } else if (!configs.get(target).nestedParent) { const el = document.scrollingElement; - Promise.resolve().then(() => { + deffer.then(() => { el.scrollLeft = 0; el.scrollTop = 0; }); @@ -399,6 +398,8 @@ function setupView(Constructor, routerOptions, parent, nestedParent) { const el = new Constructor(); configs.set(el, config); + el.style.outline = "none"; + return el; }, getEntry(params = {}, other) { From b7257c78aa33cd8e7808d2c5d4e3fb2892b680f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 26 May 2021 15:27:09 +0200 Subject: [PATCH 47/49] Fix generating url for hash --- src/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router.js b/src/router.js index 1fb7349a..04ad6396 100644 --- a/src/router.js +++ b/src/router.js @@ -381,7 +381,7 @@ function setupView(Constructor, routerOptions, parent, nestedParent) { }); return new URL( - `${routerOptions.url}#@${id}${url.search}`, + `${routerOptions.url || ""}#@${id}${url.search}`, window.location.origin, ); }, From 5e992cb22c81482ac8fc7bd16f599a475673cd1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Wed, 26 May 2021 15:43:53 +0200 Subject: [PATCH 48/49] Add default setting for url --- src/router.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/router.js b/src/router.js index 04ad6396..e9f945f4 100644 --- a/src/router.js +++ b/src/router.js @@ -381,7 +381,7 @@ function setupView(Constructor, routerOptions, parent, nestedParent) { }); return new URL( - `${routerOptions.url || ""}#@${id}${url.search}`, + `${routerOptions.url}#@${id}${url.search}`, window.location.origin, ); }, @@ -955,7 +955,7 @@ function connectNestedRouter(host, invalidate) { } function router(views, options) { - options = { ...options, views }; + options = { url: window.location.pathname, ...options, views }; const desc = { get: host => { From 4dce53ca8d81a3bf692729a2916aff46ed155bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Luba=C5=84ski?= Date: Mon, 31 May 2021 21:35:01 +0200 Subject: [PATCH 49/49] Fix cache suspend usage --- src/router.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/router.js b/src/router.js index e9f945f4..4e7c5492 100644 --- a/src/router.js +++ b/src/router.js @@ -697,9 +697,7 @@ function resolveStack(host, state) { nextView = config.create(); } - if (index !== 0) { - cache.suspend(nextView); - } else if (nextView === prevView) { + if (index === 0 && nextView === prevView) { cache.unsuspend(nextView); } @@ -834,17 +832,17 @@ function connectRootRouter(host, invalidate, options) { url = nestedConfig.url(entry.params); } - let stack = stacks.get(host); - while (stack && stack[0]) { - cache.suspend(stack[0]); - stack = stacks.get(stack[0]); - } - const offset = getEntryOffset(entry); if (offset > -1) { navigateBack(offset, entry, url); } else { + let stack = stacks.get(host); + while (stack && stack[0]) { + cache.suspend(stack[0]); + stack = stacks.get(stack[0]); + } + window.history.pushState([entry, ...state], "", url); flush(); }