UNPKG

joicomponents

Version:

Design patterns to build native web components

393 lines (356 loc) 18.1 kB
/** * https://html.spec.whatwg.org/multipage/links.html#following-hyperlinks * * Browse event is set up using "global event composition". * 1. Listen for * 1. click-to-browse events. click-to-browse events are click events that come from * `<a href="...">` (both in HTML and inline SVG) and `<area href="">` elements. * 2. submit events. `submit` events come from `<form>` elements. * 3. Dispatch browse event. * * click-to-browse and submit events can be triggered by: * a. The user clicking on an element that is inside an `<a href="...">`, `<area href="">`, or `<input type="button">` element. * b. The user pressing enter when the element is in focus or using an accesskey which will trigger a click event on the element. * c. A script simulating user action via APIs such as `click()` and `.dispatchEvent(new MouseEvent("click", ...))`. * * The `browse` event wraps around the original event so * that `.preventDefault()` and `.defaultPrevented` will work as expected. * * The `browse` event: * * .bubbles: true, * * .composed: true, * * The `browse` event has the following custom properties: * * .method: "POST" or "GET" * * .target: the `<a href>` or `<form>` that defines the browse * * .base(): the base for the navigation. * * .url(): the url object of the navigation, ie. the resolved 'href' or 'action'. * * .target(): name of the target frame, download included * (only <a> and <area>) * * .relList: link relationship options, see https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types * (only <form>-submits) * //* .targetDocument(): the target document for the navigation (usually the main document, but it can also be an iframe). * //* .download: the download option * * .encryptionType: (only <form>-submits) the method of encryption of `<form>`-submit "POST" content * * .elements: (only <form>-submits) the DOM elements with "POST" content for `<form>`-submit, both "POST" and "GET" * //* .data: should I change the elements and suffix to become just data?? * // suffix would be {"123,234": undefined} "123,234" is the x and y coordinates. * // which would be added as something.html?123,234 or something.html?query=this&123,234 todo check out this last problem. * .internal getter that returns true if the browse event is within the web page. * .external getter that returns true if the browse event is external to the web page. * * A browse event can contain both POST data and GET query parameters. * This is 'wrong', but who knows what some servers need. * * todo 5. start to see which browser specific problems we are going to encounter. * * ATT!! There is one caveat: * When <form> submissions are triggered by the `HTMLFormElement.submit()` method, * no navigation event will be triggered. `HTMLFormElement.submit()` has a custom * logic that specifically makes bypass any validation and event processing to be * executed no matter what. * * We cannot capture the HTMLFormElement.submit() method. * Triggering this method will bypass the beforeNavigate event. Unfortunately. * https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit * * Problem 1: How to dispatch a navigation event from a custom element? * Answer 1: Make an <a href> or <form> inside the shadow dom of an element and "click()" it. * Drawback: Very verbose, very convoluted. * * Problem 2: How to intercept navigation events in a browser? * Answer 2: Listen for click and submit events on the window/iframe document. * Click events must be filtered by retracing one or more steps in the event path starting from the target. * Go back to the iframe from which it came, to discover the appropriate target frame, * in order to discover the appropriate BASE from where to interpret the link. * Drawback 2: * the event path is traversed twice. * First up with the click, then again when the target and base is recalculated. * Proposal: * 1. add a navigate event with all the details necessary for the browser to interpret a navigation action/task. * 2. instead of having the click event travel all the way up to the window, and then be interpreted, * have this new navigation event travel directly from the element (<a> and <form>) clicked. * 3. As this navigation event hits documents (the top level or iframes), * its targets and base can be altered. This makes the process of interpreting the navigation * action more akin to common conception. * 4. In order to preserve backward functionality, the navigation event does not replace the old "click()" event, * but runs as a second process. This means that the existing click and keypress events first runs to completion, * which can be stopped and altered as today, before a new navigation event is re-triggered from the target. * This should preserve backwards compatibility for as long as needed. * 5. new custom elements that want can implement the new navigate event directly. This relieves them of the task of * wrapping navigation in shadowDOM. * 6. New apps that want to listen to navigate can do so directly, without bothering with the click and keypress listeners. * 7. a. The proposal will support much simpler routing in SPA that use the path segments for within-app navigation. * b. The proposal can support other navigation use cases such as * 1. white- or black-listing links on the navigation level, very useful when the content of the web app is * not controlled by the developer, but either user or machine generated. * c. The proposal will greatly simplify the standard and implementation of both browsers and apps long term. * d. The proposal will be wastly more efficient for all usecases, as there will be no redundant filtering * and processing of the clicks in JS that is also done in the "users follows a link" algorithm already performed by the browser. * * navigate * * Translate the navigate event and dispatch it not on the window, but * on the triggering element (a, area, or form). * Then, if there are documents, the documents can intercept and reroute the navigation to different targets and * find the relevant base elements for that navigation event. * * This can operate "post"-click in the beginning, thus allowing old websites with the clicks listeners that intercept * navigation to be gradually faced out with newer browsers. * */ //This is a good thing, the event.target would then be the appropriate a href. //If the navigation detail is changed, then the receiver would/should be able to alter the data of the request. //The form data are stored as references to the form elements. Updating that would implicitly mean to alter the form elements themselves. //The same is true of a href. An alternative would be to make a hidden copy of the navigation event as a temporary a or form //and then populate that one. That would make the navigation request complete. The limits would be that the navigation event should not be //listened for on the window, as the task is generated at that point. It should be listened for at window.document. //That would make it a polyfill. // //The question being. Do I want to make a polyfill for something that does not yet exist? //The point of the thing would be that I would get a navigation event that I could alter the content of the event, and //not the linked HTMLs. This is much more in line with HTML composeability. The template stays fixed, while the dynamics //of the DOM and elements are realized as DOM events. This is the best behavior. // //To alter an event is to for example wrap it. // //When you have internal navigation control, and then an event is triggered. //If something alters the content of this event, such as ismap, then you don't want to update the href prop of the //original a href element. That would be wrong. What you would like is to have an event with data that would be //alterable. And then if this event completes, then you would like to make it navigate at that point. // //A means to is to make the event return the default if not an overriding value is set. //It is a class with many getters and setters. //if there is no set value, then use the existing one. //once the event goes to action, then the updated values are transferred to the original target, and then executed. //download should be a target, not a property by itself. //if you have the target _self or _blank or. //if you have an external target, you could try to find that _top or _parent or _name browsing context. //from an iframe, you can do this. Or should I just let it pass.. // //rel I don't think the navigate event should process, external norefferer etc is for the router itself. // //todo make a navigate(request) function. It needs all the potential stuff to make a form submit. //todo it needs the href/action, suffix from ismap, target (that eats download), elements which is a key/value set, //todo method (post/get), encryptionStyle, relList. // //if the navigate is triggered without an event.target, then either a <form> or a <a> is created and clicked. // in this case, the new a or form is also added to a skip_processing_this_target property, thus // making sure it is not double processed. //else if the navigate is triggered on <a>, but the event is changed to post, then a <form> is created. //else if the navigate is triggered on <form> or a <a>, then that target values is updated, and //the event is just let pass by. function getParentDocument(current) { return current.parentNode && current.parentNode.ownerDocument ? current.parentNode.ownerDocument : null; } /** * https://html.spec.whatwg.org/multipage/browsers.html#the-rules-for-choosing-a-browsing-context-given-a-browsing-context-name * * This is a simplified version of the choosing-a-browsing-context algorithm. * * 1. <frame> and <frameset> are not supported as they are deprecated. * 2. There is no security checks imposed when the navigate event is created. * Security is performed in the interpretation of the navigation event. * * @param frameName * @param originDocument * @returns {*} */ function findBrowsingContext(frameName, originDocument) { const target = frameName.toLowerCase(); if (target === "_self" || target === "" || target === "_blank") return originDocument; else if (target === "_parent") return getParentDocument(originDocument) || originDocument; else if (target === "_top") return window.document; else { // let parentDocument = getParentDocument(originDocument); for (let pd = originDocument; pd; pd = getParentDocument(pd)) { let nearestFrame = window.document.querySelector("iframe[name='" + frameName + "']"); if (nearestFrame) return nearestFrame.document; } return null; } } function getTargetAttribute(el) { const res = el.getAttribute("target"); if (res) return res; let base = el.ownerDocument.querySelector("base[target]"); return base ? base.getAttribute("target") : ""; } //wrapper pattern for altering an event going in the DOM class BrowseEvent extends Event { constructor(orig, target) { super("beforeNavigate", {target: target, bubbles: orig.bubbles, composed: true}); this.orig = orig; this.suffix = orig.target.nodeName === "IMG" && orig.target.hasAttribute("ismap") ? "?" + orig.offsetX + "," + orig.offsetY : ""; } preventDefault() { return this.orig.preventDefault(); } get defaultPrevented() { return this.orig.defaultPrevented; } get relList() { return this.target.relList || (this.target.rel ? this.target.rel.trim().split(" ") : []); } // https://html.spec.whatwg.org/multipage/semantics.html#get-an-element's-target sourceDocument() { return this.target.ownerDocument; } get download() { return this.target.hasAttribute("download"); } get method() { return this.target.method || "GET"; } targetFrameDocument() { let source = this.target.ownerDocument; let noopener = this.target.relList.contains("noopener") || this.target.relList.contains("noreferrer"); let targetAttribute = getTargetAttribute(this.target); return findBrowsingContext(targetAttribute, source, noopener); } //method to get the target of the link //method to get the target document object if possible. // baseHref() { // const targetDocument = this.targetFrameDocument(); // const base = targetDocument.querySelector("base[href]"); // return (base || window.location).href; // } // baseHref2() { const b = this.target.ownerDocument.querySelector("base[href]"); return b ? b.getAttribute("href") : undefined; } getFormUrl() { const a = new URL(this.target.action); if (this.method.toUpperCase() === "GET") { //2. Test show that: if you have a <form action="index.html?query=already#hash" method="get">, //the query, but not the hash, will be overwritten by the values in the form when Chrome interprets the link. a.search = ""; let elements = this.elements; for (let el of elements) { if (el.hasAttribute("name")) a.searchParams.append(el.name, el.value); } } return a.href; } getLinkUrl() { let a = this.target.href; if (a.animVal) a = a.animVal; //https://www.w3.org/html/wg/spec/text-level-semantics.html#text-level-semantics a += this.suffix; //1. Tests show that the isMap ?x,y value is added at the end of the link **raw**, ie. it is not parsed in as a query. //this means that if you have an <a href="index.html?query=a#hash"> around an <img isMap>, then //when you click on point x=12, y=34 on the <img> you get a link like this: "index.html?query=a#hash?12,34". return a; } get url() { let a = this.target.nodeName === "FORM" ? this.getFormUrl() : this.getLinkUrl(); return new URL(a, this.baseHref2()); } get elements() { return this.target.elements; } encryptionType() { return this.target.encryptionType; } } //https://www.w3.org/html/wg/spec/content-models.html#interactive-content-0 //http://qaru.site/questions/10726/can-i-nest-a-button-element-inside-an-a-using-html5 //todo find above discussion in english function elementCannotBeInALink(el) { return el.nodeName === "BODY" || //FIND MORE LIKE THIS! (el.nodeName === "AUDIO" && el.hasAttribute("controls")) || (el.nodeName === "VIDEO" && el.hasAttribute("controls")) || el.nodeName === "BUTTON" || (el.nodeName === "INPUT" && el.getAttribute("type") !== "hidden") || el.nodeName === "SELECT" || el.nodeName === "TEXTAREA" || el.nodeName === "KEYGEN" || el.nodeName === "DETAILS" || el.nodeName === "LABEL" || el.nodeName === "EMBED" || (el.nodeName === "OBJECT" /*&& el.hasAttribute("usemap")*/) || //todo, this will not trigger a click?? el.nodeName === "IFRAME" || (el.nodeName === "MENU" && el.getAttribute("type") === "toolbar"); //todo check the menu } //https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission function filterBrowseClicks(e) { if (e.metaKey) return; for (let el = e.target; el; el = el.parentNode) { if (elementCannotBeInALink(el)) return; if (el.nodeName === "A" || el.nodeName === "a" || el.nodeName === "AREA") el.dispatchEvent(new BrowseEvent(e, el)); } } function submitListener(e) { e.target.dispatchEvent(new BrowseEvent(e, e.target)); } /** * You can't block the torpedoes. * 1. if the script uses .submit(), it cannot be controlled. * 2. if there are multiple iframes on the page, and one such iframe directs a navigation task to this document, * it cannot be controlled. * 3. if the scripts uses `window.open()`, `location.assign()`, `history.pushState`, `history.replaceState`, * it cannot be controlled. * * Don't use 1 and 2. If you do, it will not be part of the navigation. * * Use 3 as part of the navigation control only. */ function navigateEvent(doc) { doc.addEventListener("submit", submitListener); doc.addEventListener("click", filterBrowseClicks); } navigateEvent(window); function onBeforeNavigate(e){ if (e.target.nodeName === "A" || e.target.nodeName === "a" || e.target.nodeName === "AREA" || e.target.nodeName === "FORM") return; if (e.method === "GET") //todo how do I best open a GET request with the rel and download and target and the rest? e.sourceDocument().open(e.url.href, {download: e.download, rel: e.relList.join(" ")}); if (e.method === "POST"){ const doc = e.sourceDocument(); let form = doc.createElement("form"); form.method = "POST"; //todo I need to implement support for POST data for (let nameValue of e.elements) { if (nameValue.hasAttribute("name")){ let input = doc.createElement("input"); input.name = nameValue.getAttribute("name"); input.value = nameValue.getValue(); form.appendChild(input); } } e.target.parentNode.appendChild(form); form.submit(); } if (e.method === "PUT" || e.method === "DELETE") XMLHttpRequest.open(e.method, e.url, e.async === undefined ? true : e.async, e.user, e.password); } window.addEventListener("beforeNavigate", onBeforeNavigate); //todemo I need to make tests for this function, for both GET, PUT, DELETE //have any type of element div h1 span //div.dispatch(makeArtificialBeforeNavigate("GET", "test.html")); //h1.dispatch(makeArtificialBeforeNavigate("DELETE", "test.html")); //span.dispatch(makeArtificialBeforeNavigate("PUT", "test.html")); //todemo I need to make tests for this function, for both POST, not yet //el.dispatch(makeArtificialBeforeNavigate("POST", "test.html")); //todo I need to make a function that creates a browse event with elements listed below //todo I need to implement support for POST data window.makeArtificialBeforeNavigate = function(method, url) { const res = new CustomEvent("beforeNavigate"); res.url = url; res.method = method; return res; };