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..4e7c5492 --- /dev/null +++ b/src/router.js @@ -0,0 +1,988 @@ +import { callbacksMap } from "./define.js"; +import * as cache from "./cache.js"; +import { dispatch } from "./utils.js"; + +const connect = Symbol("router.connect"); +const configs = new WeakMap(); + +const flushes = new WeakMap(); +const stacks = new WeakMap(); +const routers = new WeakMap(); + +let rootRouter = null; +let entryPoints = []; + +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); + + const map = new Map(); + + if (!configs.get(target).nestedParent) { + const el = document.scrollingElement; + map.set(el, { left: el.scrollLeft, top: el.scrollTop }); + } + + mapDeepElements(target, el => { + if (el.scrollLeft || el.scrollTop) { + map.set(el, { left: el.scrollLeft, top: el.scrollTop }); + } + }); + + scrollMap.set(target, map); +} + +let focusTarget = null; +const deffer = Promise.resolve(); +function restoreLayout(target, clear) { + if (!focusTarget) { + deffer.then(() => { + const activeEl = document.activeElement; + if (!focusTarget.contains(activeEl)) { + const el = scrollMap.get(focusTarget) || focusTarget; + if (el.tabIndex === -1) { + el.tabIndex = 0; + deffer.then(() => el.removeAttribute("tabindex")); + } + el.focus({ preventScroll: true }); + } + focusTarget = null; + }); + } + + focusTarget = target; + + const map = scrollMap.get(target); + + if (map) { + deffer.then(() => { + map.forEach((pos, el) => { + el.scrollLeft = clear ? 0 : pos.left; + el.scrollTop = clear ? 0 : pos.top; + }); + }); + + scrollMap.delete(target); + } else if (!configs.get(target).nestedParent) { + const el = document.scrollingElement; + deffer.then(() => { + el.scrollLeft = 0; + el.scrollTop = 0; + }); + } +} + +const placeholder = Date.now(); +function setupBrowserUrl(browserUrl, id) { + 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 for <${id}>`); + } + + 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 for <${id}>`, + ); + } + }); + } + + 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 (!parts[i] || 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 setupViews(views, options, parent = null, nestedParent = null) { + if (typeof views === "function") views = views(); + + const result = views.map(Constructor => { + // eslint-disable-next-line no-use-before-define + const config = setupView(Constructor, options, parent, nestedParent); + + if (parent && hasInStack(config, parent)) { + throw Error( + `<${parent.id}> cannot be in the stack of <${config.id}> - it is already in stack of <${parent.id}>`, + ); + } + + if (config.browserUrl) entryPoints.push(config); + + return config; + }); + + return result; +} + +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( + `<${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 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 <${id}>`, + ); + } + } + return nestedRouters[0]; +} + +function setupView(Constructor, routerOptions, parent, nestedParent) { + const id = new Constructor().tagName.toLowerCase(); + let config = configs.get(Constructor); + + if (config) { + if (config.hybrids !== Constructor.hybrids) { + configs.delete(customElements.get(config.id)); + config = null; + } + } + + if (!config) { + let browserUrl = null; + + let options = { + dialog: false, + guard: false, + multiple: false, + replace: false, + }; + + const hybrids = Constructor.hybrids; + if (hybrids) { + options = { ...options, ...hybrids[connect] }; + const callbacks = callbacksMap.get(Constructor); + callbacks.push(restoreLayout); + + if (options.dialog) { + callbacks.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 => { + const desc = Object.getOwnPropertyDescriptor(Constructor.prototype, key); + if (desc.set) writableParams.add(key); + }); + + if (options.url) { + if (options.dialog) { + throw Error( + `The 'url' option is not supported for dialogs - remove it from <${id}>`, + ); + } + if (typeof options.url !== "string") { + throw TypeError( + `The 'url' option in <${id}> must be a string: ${typeof options.url}`, + ); + } + browserUrl = setupBrowserUrl(options.url, id); + + 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 for <${id}>`, + ); + } + }); + } + + let guard; + if (options.guard) { + const el = new Constructor(); + guard = () => { + try { + return options.guard(el); + } catch (e) { + console.error(e); + return false; + } + }; + } + + config = { + id, + hybrids, + dialog: options.dialog, + multiple: options.multiple, + replace: options.replace, + guard, + parent: undefined, + nestedParent: undefined, + nestedRoots: undefined, + parentsWithGuards: undefined, + stack: [], + ...(browserUrl || { + 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 if (!suppressErrors) { + throw TypeError( + `The '${key}' parameter is not supported for <${id}>`, + ); + } + }); + + return new URL( + `${routerOptions.url}#@${id}${url.search}`, + window.location.origin, + ); + }, + 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"; + + 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(Constructor, config); + + config.parent = parent; + config.nestedParent = nestedParent; + + if (options.stack) { + if (options.dialog) { + throw Error( + `The 'stack' option is not supported for dialogs - remove it from <${id}>`, + ); + } + config.stack = setupViews( + options.stack, + routerOptions, + config, + nestedParent, + ); + } + } else { + config.parent = parent; + config.nestedParent = nestedParent; + } + + config.parentsWithGuards = []; + while (parent) { + if (parent.guard) config.parentsWithGuards.unshift(parent); + parent = parent.parent; + } + + const nestedRouterOptions = + config.hybrids && getNestedRouterOptions(config.hybrids, id, config); + + if (nestedRouterOptions) { + config.nestedRoots = setupViews( + nestedRouterOptions.views, + { ...routerOptions, ...nestedRouterOptions }, + config, + config, + ); + + config.stack = config.stack.concat(config.nestedRoots); + } + + return config; +} + +function getConfigById(id) { + const Constructor = customElements.get(id); + return configs.get(Constructor); +} + +function getUrl(view, params = {}) { + const config = configs.get(view); + return config ? config.url(params) : ""; +} + +function getBackUrl({ nested = false } = {}) { + const state = window.history.state; + if (!state) return ""; + + let config; + + if (state.length > 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) { + return config.url(prevEntry.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(state[0].params, true); + } + } + + 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 active(views, { stack = false } = {}) { + const state = window.history.state; + if (!state) return false; + + views = [].concat(views); + + return views.some(view => { + const config = configs.get(view); + if (!config) { + throw TypeError(`Provided view is not connected to the router: ${view}`); + } + + let entry = state[0]; + while (entry) { + const target = getConfigById(entry.id); + if (target === config || (stack && hasInStack(config, target))) { + return true; + } + entry = entry.nested; + } + + return 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; + + 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 (rootRouter && url && url.origin === window.location.origin) { + const entry = getEntryFromURL(url); + if (entry) { + event.preventDefault(); + + dispatch(rootRouter, "navigate", { + bubbles: true, + detail: { entry, url }, + }); + } + } +} + +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 resolveStack(host, state) { + let stack = stacks.get(host); + 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 = stack.length - reducedState.length; + const lastStackView = stack[0]; + + if (offset <= 0 && stack.length) { + saveLayout(stack[0]); + } + + stack = reducedState.map(({ id }, index) => { + const prevView = stack[index + offset]; + const config = getConfigById(id); + let nextView; + + if (prevView) { + const prevConfig = configs.get(prevView); + if (config.id !== prevConfig.id || (index === 0 && config.replace)) { + return config.create(); + } + nextView = prevView; + } else { + nextView = config.create(); + } + + if (index === 0 && nextView === prevView) { + cache.unsuspend(nextView); + } + + return nextView; + }); + + if (stack[0] === lastStackView) { + restoreLayout(lastStackView, true); + } + + Object.assign(stack[0], state[0].params); + stacks.set(host, stack); + + const flush = flushes.get(stack[0]); + if (flush) flush(); +} + +function getEntryOffset(entry) { + const state = window.history.state.reduce((acc, e, index) => { + let i = 0; + + while (e) { + 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; + + 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)) { + 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 ( + !Object.entries(entry.params).every( + ([key, value]) => currentEntry.params[key] === value, + ) + ) { + return offset - 1; + } + } + + entry = entry.nested; + i += 1; + } + + return offset; +} + +function connectRootRouter(host, invalidate, options) { + function flush() { + resolveStack(host, window.history.state); + 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 navigate(entry) { + const state = window.history.state; + + let nestedEntry = entry; + while (nestedEntry.nested) nestedEntry = nestedEntry.nested; + const nestedConfig = getConfigById(nestedEntry.id); + + let url = options.url || ""; + if (nestedConfig.browserUrl) { + url = nestedConfig.url(entry.params); + } + + 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(); + } + } + + function executeNavigate(event) { + navigate(event.detail.entry); + } + + entryPoints = []; + const roots = setupViews(options.views, options); + + flushes.set(host, flush); + rootRouter = host; + + window.history.scrollRestoration = "manual"; + + if (!window.history.state) { + const entry = + 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) { + let entry = state[i]; + while (entry) { + const config = getConfigById(entry.id); + if ( + !config || + (config.dialog && stack.length === 0) || + (!roots.includes(config) && !roots.some(c => hasInStack(c, config))) + ) { + 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 { + let entry = state[0]; + while (entry.nested) entry = entry.nested; + + const nestedConfig = getConfigById(entry.id); + const resultEntry = nestedConfig.getEntry(entry.params); + navigate(resultEntry); + } + } + + window.addEventListener("popstate", flush); + + host.addEventListener("click", handleNavigate); + host.addEventListener("submit", handleNavigate); + host.addEventListener("navigate", executeNavigate); + + return () => { + window.removeEventListener("popstate", flush); + + host.removeEventListener("click", handleNavigate); + host.removeEventListener("submit", handleNavigate); + host.removeEventListener("navigate", executeNavigate); + + rootRouter = null; + }; +} + +function connectNestedRouter(host, invalidate) { + const config = configs.get(host); + + function getNestedState() { + return window.history.state + .map(entry => { + while (entry) { + if (entry.id === config.id) return entry.nested; + entry = entry.nested; + } + return entry; + }) + .filter(e => e); + } + + function flush() { + resolveStack(host, getNestedState()); + invalidate(); + } + + if (!getNestedState()[0]) { + const state = window.history.state; + window.history.replaceState( + [config.nestedRoots[0].getEntry(state[0].params), ...state.slice(1)], + "", + ); + } + + flush(); + flushes.set(host, flush); +} + +function router(views, options) { + options = { url: window.location.pathname, ...options, views }; + + const desc = { + get: host => { + const stack = stacks.get(host) || []; + return stack + .slice(0, stack.findIndex(el => !configs.get(el).dialog) + 1) + .reverse(); + }, + connect: (host, key, invalidate) => { + if (!stacks.has(host)) stacks.set(host, []); + + if (configs.has(host)) { + return connectNestedRouter(host, invalidate); + } + + return connectRootRouter(host, invalidate, options); + }, + }; + + routers.set(desc, options); + return desc; +} + +export default Object.assign(router, { + connect, + url: getUrl, + resolve: resolveEvent, + backUrl: getBackUrl, + guardUrl: getGuardUrl, + currentUrl: getCurrentUrl, + active, +}); diff --git a/test/spec/router.js b/test/spec/router.js new file mode 100644 index 00000000..45a6818f --- /dev/null +++ b/test/spec/router.js @@ -0,0 +1,168 @@ +import { define, router, html } from "../../src/index.js"; +import { resolveRaf } from "../helpers.js"; + +describe("router:", () => { + let Nested; + let Child; + let OtherChild; + let Home; + let App; + let host; + + beforeEach(() => { + OtherChild = define("test-router-child", { + content: () => html` + Back + `, + }); + + Nested = define("test-router-child-nested", {}); + + Child = define("test-router-child", { + nested: router([Nested]), + content: ({ nested }) => html` + Back + OtherChild + ${nested} + `, + }); + + Home = define("test-router-home", { + [router.connect]: { stack: [Child, OtherChild] }, + content: () => html` + Child + Child + `, + }); + + App = define("test-router-app", { + views: router([Home]), + content: ({ views }) => html`${views}` // prettier-ignore + }); + + window.history.replaceState(null, ""); + host = new App(); + document.body.appendChild(host); + }); + + afterEach(() => { + host.parentElement.removeChild(host); + }); + + 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("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("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); + }); + + 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("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); + }); + + 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("url() -", () => { + it("returns empty string for not connected view", () => { + const MyElement = define("test-router-my-element", {}); + expect(router.url(MyElement)).toBe(""); + }); + }); + + describe("resolve() -", () => {}); + describe("backUrl() -", () => {}); + describe("guardUrl() -", () => {}); + describe("currentUrl() -", () => {}); + describe("active() -", () => {}); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 7cf1ab5f..711c5c99 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -185,6 +185,45 @@ declare namespace hybrids { ): number; } + /* Router */ + type View = Hybrids & { + __router__connect__?: { + url?: string; + multiple?: boolean; + replace?: boolean; + guard?: (host: E) => any; + stack?: typeof HTMLElement[]; + }; + }; + + function router( + views: typeof HTMLElement[], + options?: { + url?: string; + }, + ): Descriptor; + + namespace router { + const connect = "__router__connect__"; + + type UrlParams = { + [property in keyof V]?: V[property]; + }; + + function url(view: HybridElement, params?: UrlParams): string; + + function backUrl(options?: { nested?: boolean }): string; + function guardUrl(params?: UrlParams): string; + function currentUrl(params?: UrlParams): string; + + function active( + views: HybridElement | HybridElement[], + options?: { stack?: boolean }, + ): boolean; + + function resolve(event: Event, promise: Promise): void; + } + /* Utils */ function dispatch(