From 851bccb047538f9c7c44414522a1fbd192b39105 Mon Sep 17 00:00:00 2001 From: Tommy Groshong Date: Fri, 27 Oct 2023 13:45:50 -0400 Subject: [PATCH] Add JSDoc annotations and documentation to the JS code --- assets/js/phoenix_live_view/aria.js | 47 +- assets/js/phoenix_live_view/browser.js | 63 +++ assets/js/phoenix_live_view/constants.js | 2 +- assets/js/phoenix_live_view/dom.js | 335 ++++++++++- assets/js/phoenix_live_view/dom_patch.js | 106 +++- .../dom_post_morph_restorer.js | 23 +- assets/js/phoenix_live_view/entry_uploader.js | 34 ++ assets/js/phoenix_live_view/hooks.js | 21 +- assets/js/phoenix_live_view/js.js | 165 +++++- assets/js/phoenix_live_view/live_socket.js | 526 +++++++++++++++--- assets/js/phoenix_live_view/live_uploader.js | 77 +++ assets/js/phoenix_live_view/rendered.js | 196 ++++++- assets/js/phoenix_live_view/upload_entry.js | 78 ++- assets/js/phoenix_live_view/utils.js | 81 ++- assets/js/phoenix_live_view/view.js | 445 ++++++++++++++- assets/js/phoenix_live_view/view_hook.js | 122 ++++ 16 files changed, 2202 insertions(+), 119 deletions(-) diff --git a/assets/js/phoenix_live_view/aria.js b/assets/js/phoenix_live_view/aria.js index 963f680181..f099390293 100644 --- a/assets/js/phoenix_live_view/aria.js +++ b/assets/js/phoenix_live_view/aria.js @@ -1,4 +1,10 @@ +/** + * Utilities for accessibility behaviors and affordances + */ let ARIA = { + /** + * Focus a main element of the page + */ focusMain(){ let target = document.querySelector("main h1, main, h1") if(target){ @@ -9,10 +15,22 @@ let ARIA = { } }, + /** + * Find the first class in the collection that the given object is an instance of. + * @param {object} instance + * @param {object[]} classes + * @returns {object | null} + */ anyOf(instance, classes){ return classes.find(name => instance instanceof name) }, + /** + * Can the element be focused? + * @param {Element} el + * @param {boolean} [interactiveOnly] + * @returns {boolean} + */ isFocusable(el, interactiveOnly){ - return( + return ( (el instanceof HTMLAnchorElement && el.rel !== "ignore") || (el instanceof HTMLAreaElement && el.href !== undefined) || (!el.disabled && (this.anyOf(el, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]))) || @@ -21,11 +39,23 @@ let ARIA = { ) }, + /** + * Focus the given element, reporting the result. + * @param {Element} el + * @param {boolean} [interactiveOnly] + * @returns {boolean} Is the given element now focused? + */ attemptFocus(el, interactiveOnly){ - if(this.isFocusable(el, interactiveOnly)){ try{ el.focus() } catch(e){} } + /* eslint-disable-next-line no-empty */ + if(this.isFocusable(el, interactiveOnly)){ try { el.focus() } catch (e){} } return !!document.activeElement && document.activeElement.isSameNode(el) }, + /** + * Focus the first interactive child element; depth-first search + * @param {Element} el + * @returns {boolean} + */ focusFirstInteractive(el){ let child = el.firstElementChild while(child){ @@ -36,6 +66,11 @@ let ARIA = { } }, + /** + * Focus the first child element; depth-first search + * @param {Element} el + * @returns {boolean} Is the given element now focused? + */ focusFirst(el){ let child = el.firstElementChild while(child){ @@ -46,6 +81,11 @@ let ARIA = { } }, + /** + * Focus the last child; depth-first search + * @param {Element} el + * @returns {boolean} Is the given element now focused? + */ focusLast(el){ let child = el.lastElementChild while(child){ @@ -56,4 +96,5 @@ let ARIA = { } } } -export default ARIA \ No newline at end of file + +export default ARIA diff --git a/assets/js/phoenix_live_view/browser.js b/assets/js/phoenix_live_view/browser.js index 6d4aeca14d..a8bae405a5 100644 --- a/assets/js/phoenix_live_view/browser.js +++ b/assets/js/phoenix_live_view/browser.js @@ -1,10 +1,30 @@ let Browser = { + /** + * Does browser support pushState feature of the History API? + * @returns {boolean} + */ canPushState(){ return (typeof (history.pushState) !== "undefined") }, + /** + * Remove item from local storage + * @param {Storage} localStorage + * @param {string} namespace + * @param {string} subkey + */ dropLocal(localStorage, namespace, subkey){ return localStorage.removeItem(this.localKey(namespace, subkey)) }, + /** + * Update item in local storage + * @template {T} + * @param {Storage} localStorage + * @param {string} namespace + * @param {string} subkey + * @param {T} initial - value to set if first save + * @param {function} func - updating function; receives current, JSON-parsed value of store item and saves the JSON-stringified return value; + * @returns {T} + */ updateLocal(localStorage, namespace, subkey, initial, func){ let current = this.getLocal(localStorage, namespace, subkey) let key = this.localKey(namespace, subkey) @@ -13,15 +33,32 @@ let Browser = { return newVal }, + /** + * Read item from local storage. NOTE: will parse as JSON; might throw + * @param {Storage} localStorage + * @param {string} namespace + * @param {string} subkey + * @returns {any} + */ getLocal(localStorage, namespace, subkey){ return JSON.parse(localStorage.getItem(this.localKey(namespace, subkey))) }, + /** + * Replace current history state data without navigating + * @param {function} callback + */ updateCurrentState(callback){ if(!this.canPushState()){ return } history.replaceState(callback(history.state || {}), "", window.location.href) }, + /** + * Perform history state change to URL + * @param {("push"|"replace")} kind + * @param {{type: string, scroll: number|undefined, root: boolean, id: string}} meta + * @param {string} to - URL of destination + */ pushState(kind, meta, to){ if(this.canPushState()){ if(to !== window.location.href){ @@ -47,21 +84,47 @@ let Browser = { } }, + /** + * Set document cookie value + * @param {string} name + * @param {string} value + */ setCookie(name, value){ document.cookie = `${name}=${value}` }, + /** + * Get value from cookie + * @param {string} name + * @returns {string} + */ getCookie(name){ return document.cookie.replace(new RegExp(`(?:(?:^|.*;\s*)${name}\s*\=\s*([^;]*).*$)|^.*$`), "$1") }, + /** + * Redirect to URL and set flash message if given + * @param {string} toURL + * @param {string} [flash] + */ redirect(toURL, flash){ if(flash){ Browser.setCookie("__phoenix_flash__", flash + "; max-age=60000; path=/") } window.location = toURL }, + /** + * Get the namespaced key for use in browser Storage API + * @param {string} namespace + * @param {string} subkey + * @returns {string} + */ localKey(namespace, subkey){ return `${namespace}-${subkey}` }, + /** + * Find element target of the URL hash segment, if it exists + * @param {string} maybeHash + * @returns {HTMLElement|null} + */ getHashTargetEl(maybeHash){ let hash = maybeHash.toString().substring(1) if(hash === ""){ return } diff --git a/assets/js/phoenix_live_view/constants.js b/assets/js/phoenix_live_view/constants.js index 04cca88352..5e0a53d987 100644 --- a/assets/js/phoenix_live_view/constants.js +++ b/assets/js/phoenix_live_view/constants.js @@ -86,4 +86,4 @@ export const EVENTS = "e" export const REPLY = "r" export const TITLE = "t" export const TEMPLATES = "p" -export const STREAM = "stream" \ No newline at end of file +export const STREAM = "stream" diff --git a/assets/js/phoenix_live_view/dom.js b/assets/js/phoenix_live_view/dom.js index 6cdac7fd2d..7e3dc1086a 100644 --- a/assets/js/phoenix_live_view/dom.js +++ b/assets/js/phoenix_live_view/dom.js @@ -26,40 +26,102 @@ import { logError } from "./utils" +/** + * Wrappers around common DOM APIs for traversal and mutation + */ let DOM = { + /** + * Find Element by ID + * @param {string} id + * @returns {HTMLElement|null} + */ byId(id){ return document.getElementById(id) || logError(`no id found for ${id}`) }, + /** + * Remove class from element + * @param {HTMLElement} el + * @param {string} className + */ removeClass(el, className){ el.classList.remove(className) if(el.classList.length === 0){ el.removeAttribute("class") } }, + /** + * @typedef {(value: Element, index: number, array: Element[])=>void} ElementCallback + */ + + /** + * Execute callback for each child of node matching CSS selector + * + * @template {ElementCallback|undefined} T + * @param {ParentNode} node - a DOM node to search underneath + * @param {string} query - a CSS selector + * @param {T} callback - the callback to operate + * @returns {[T] extends [ElementCallback] ? undefined : HTMLElement[] } + */ all(node, query, callback){ if(!node){ return [] } let array = Array.from(node.querySelectorAll(query)) return callback ? array.forEach(callback) : array }, + /** + * Count the child nodes of HTML fragment + * @param {string} html - HTML fragment + * @returns {number} + */ childNodeLength(html){ let template = document.createElement("template") template.innerHTML = html return template.content.childElementCount }, + /** + * Is this input a tracked file upload input? + * @param {HTMLInputElement} el + * @returns {boolean} + */ isUploadInput(el){ return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null }, + /** + * Is this input a phoenix auto-uploading input? + * @param {HTMLInputElement} inputEl + * @returns {boolean} + */ isAutoUpload(inputEl){ return inputEl.hasAttribute("data-phx-auto-upload") }, + /** + * Select all child nodes that are file inputs + * @param {HTMLElement} node + * @returns {HTMLInputElement[]} + */ findUploadInputs(node){ return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`) }, + /** + * Find nodes within component + * @param {HTMLElement} node + * @param {number|string} cid + * @returns {HTMLElement[]} + */ findComponentNodeList(node, cid){ return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node) }, + /** + * Has this node been marked by phoenix as destroyed? + * @param {HTMLElement} node + * @returns {boolean} + */ isPhxDestroyed(node){ return node.id && DOM.private(node, "destroyed") ? true : false }, + /** + * Did the user perform a well-known gesture to open a link in a new tab? + * @param {Event} e + * @returns {boolean} + */ wantsNewTab(e){ let wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1) let isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download")) @@ -67,6 +129,11 @@ let DOM = { return wantsNewTab || isTargetBlank || isDownload }, + /** + * Did the user submit a form that is unloadable? + * @param {Event} e + * @returns {boolean} + */ isUnloadableFormSubmit(e){ // Ignore form submissions intended to close a native element // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#usage_notes @@ -80,6 +147,12 @@ let DOM = { } }, + /** + * Did the user click a link that navigates somewhere else? + * @param {Event} e + * @param {Location} currentLocation + * @returns {boolean} + */ isNewPageClick(e, currentLocation){ let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null let url @@ -90,10 +163,10 @@ let DOM = { try { url = new URL(href) - } catch(e) { + } catch (e){ try { url = new URL(href, currentLocation) - } catch(e) { + } catch (e){ // bad URL, fallback to let browser try it as external return true } @@ -107,31 +180,71 @@ let DOM = { return url.protocol.startsWith("http") }, + /** + * Mark element as phoenix destroyed + * @param {HTMLElement} el + */ markPhxChildDestroyed(el){ if(this.isPhxChild(el)){ el.setAttribute(PHX_SESSION, "") } this.putPrivate(el, "destroyed", true) }, + /** + * Find all children of a specific phoenix view in the given HTML fragment + * @param {string} html + * @param {string} parentId + * @returns + */ findPhxChildrenInFragment(html, parentId){ let template = document.createElement("template") template.innerHTML = html return this.findPhxChildren(template.content, parentId) }, + /** + * Is this node ignored from phoenix updates? + * @param {HTMLElement} el + * @param {string} phxUpdate + * @returns {boolean} + */ isIgnored(el, phxUpdate){ return (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) === "ignore" }, + /** + * Is this node marked for phoenix updates? + * @param {HTMLElement} el + * @param {string} phxUpdate + * @param {string[]} updateTypes + * @returns + */ isPhxUpdate(el, phxUpdate, updateTypes){ return el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0 }, + /** + * Find child elements marked as phx-sticky + * @param {HTMLElement} el + * @returns {HTMLElement[]} + */ findPhxSticky(el){ return this.all(el, `[${PHX_STICKY}]`) }, + /** + * Find all children of a given phoenix view's element + * @param {HTMLElement} el + * @param {string} parentId + * @returns {HTMLElement[]} + */ findPhxChildren(el, parentId){ return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`) }, + /** + * Find all IDs for parent components of this node + * @param {parentNode} node + * @param {(string|number)[]} cids + * @returns {Set} + */ findParentCIDs(node, cids){ let initial = new Set(cids) let parentCids = @@ -148,6 +261,12 @@ let DOM = { return parentCids.size === 0 ? new Set(cids) : parentCids }, + /** + * Filter the given element nodes to only those within the given parent + * @param {HTMLElement[]} nodes + * @param {HTMLElement} parent + * @returns {HTMLElement[]} + */ filterWithinSameLiveView(nodes, parent){ if(parent.querySelector(PHX_VIEW_SELECTOR)){ return nodes.filter(el => this.withinSameLiveView(el, parent)) @@ -156,6 +275,12 @@ let DOM = { } }, + /** + * Is given node within the given parent? + * @param {HTMLElement} node + * @param {HTMLElement} parent + * @returns {boolean} + */ withinSameLiveView(node, parent){ while(node = node.parentNode){ if(node.isSameNode(parent)){ return true } @@ -163,15 +288,40 @@ let DOM = { } }, + /** + * Get the value stored in the phoenix private data on the DOM node + * @param {HTMLElement} el + * @param {string} key + * @returns {any} + */ private(el, key){ return el[PHX_PRIVATE] && el[PHX_PRIVATE][key] }, + /** + * Delete the value stored in the phoenix private data on the DOM node + * @param {HTMLElement} el + * @param {string} key + * @returns {any} + */ deletePrivate(el, key){ el[PHX_PRIVATE] && delete (el[PHX_PRIVATE][key]) }, + /** + * Set the value at key in the phoenix private data on the DOM node + * @param {HTMLElement} el + * @param {string} key + * @param {any} value + */ putPrivate(el, key, value){ if(!el[PHX_PRIVATE]){ el[PHX_PRIVATE] = {} } el[PHX_PRIVATE][key] = value }, + /** + * Update the value at key in the phoenix private data on the DOM node with a function + * @param {HTMLElement} el + * @param {string} key + * @param {any} defaultVal + * @param {function} updateFunc + */ updatePrivate(el, key, defaultVal, updateFunc){ let existing = this.private(el, key) if(existing === undefined){ @@ -181,12 +331,21 @@ let DOM = { } }, + /** + * Copy private phoenix data bag from one element to another + * @param {HTMLElement} target + * @param {HTMLElement} source + */ copyPrivates(target, source){ if(source[PHX_PRIVATE]){ target[PHX_PRIVATE] = source[PHX_PRIVATE] } }, + /** + * Update the document title + * @param {string} str + */ putTitle(str){ let titleEl = document.querySelector("title") if(titleEl){ @@ -197,6 +356,17 @@ let DOM = { } }, + /** + * Apply an event debounce and/or throttle for callback + * @param {HTMLElement} el + * @param {Event} event + * @param {string} phxDebounce + * @param {number} defaultDebounce + * @param {string} phxThrottle + * @param {number} defaultThrottle + * @param {function} asyncFilter + * @param {function} callback + */ debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){ let debounce = el.getAttribute(phxDebounce) let throttle = el.getAttribute(phxThrottle) @@ -257,6 +427,11 @@ let DOM = { } }, + /** + * @param {HTMLElement} el + * @param {string} key + * @param {any} currentCycle + */ triggerCycle(el, key, currentCycle){ let [cycle, trigger] = this.private(el, key) if(!currentCycle){ currentCycle = cycle } @@ -266,12 +441,24 @@ let DOM = { } }, + /** + * Set element private data key to true + * @param {HTMLElement} el + * @param {string} key + * @returns {boolean} - false if was already set; true if key was unset + */ once(el, key){ if(this.private(el, key) === true){ return false } this.putPrivate(el, key, true) return true }, + /** + * @param {HTMLElement} el + * @param {string} key + * @param {function} trigger + * @returns {any} + */ incCycle(el, key, trigger = function (){ }){ let [currentCycle] = this.private(el, key) || [0, trigger] currentCycle++ @@ -279,12 +466,24 @@ let DOM = { return currentCycle }, + /** + * Add private hooks if element attributes dictate + * @param {HTMLElement} el + * @param {string} phxViewportTop + * @param {string} phxViewportBottom + */ maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom){ if(el.hasAttribute && (el.hasAttribute(phxViewportTop) || el.hasAttribute(phxViewportBottom))){ el.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll") } }, + /** + * Hide input feedback nodes if appropriate + * @param {HTMLElement} container + * @param {HTMLInputElement} input + * @param {string} phxFeedbackFor + */ maybeHideFeedback(container, input, phxFeedbackFor){ if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){ let feedbacks = [input.name] @@ -294,6 +493,11 @@ let DOM = { } }, + /** + * Reset phoenix data (focus, submission state, feedback, etc.) of the given form's inputs + * @param {HTMLFormElement} form + * @param {string} phxFeedbackFor + */ resetForm(form, phxFeedbackFor){ Array.from(form.elements).forEach(input => { let query = `[${phxFeedbackFor}="${input.id}"], @@ -308,6 +512,11 @@ let DOM = { }) }, + /** + * Unhide feedback elements for this input + * @param {HTMLElement} inputEl + * @param {string} phxFeedbackFor + */ showError(inputEl, phxFeedbackFor){ if(inputEl.id || inputEl.name){ this.all(inputEl.form, `[${phxFeedbackFor}="${inputEl.id}"], [${phxFeedbackFor}="${inputEl.name}"]`, (el) => { @@ -316,18 +525,41 @@ let DOM = { } }, + /** + * @param {HTMLElement} node + * @returns {boolean} + */ isPhxChild(node){ return node.getAttribute && node.getAttribute(PHX_PARENT_ID) }, + /** + * @param {HTMLElement} node + * @returns {boolean} + */ isPhxSticky(node){ return node.getAttribute && node.getAttribute(PHX_STICKY) !== null }, + /** + * @param {HTMLElement} el + * @returns {HTMLElement|null} + */ firstPhxChild(el){ return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0] }, + /** + * Create and dispatch a custom event to the target element + * + * Options: + * - bubbles: does the event bubble? Defaults to true (NOTE: this is different from standard CustomEvent behavior) + * - detail: a value associated with the event that can be accessed by the handler + * + * @param {HTMLElement} target + * @param {string} name + * @param {{bubbles?: boolean, detail?: object}} [opts] + */ dispatchEvent(target, name, opts = {}){ let bubbles = opts.bubbles === undefined ? true : !!opts.bubbles let eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}} @@ -335,6 +567,12 @@ let DOM = { target.dispatchEvent(event) }, + /** + * Clone node and optionally replace inner HTML with given HTML fragment + * @param {Node} node + * @param {string} [html] + * @returns {Node} + */ cloneNode(node, html){ if(typeof (html) === "undefined"){ return node.cloneNode(true) @@ -345,6 +583,18 @@ let DOM = { } }, + /** + * Copy attributes from source to target, deleting attributes on target not + * found in source. + * + * Options: + * - exclude: List of attribute names on source to skip + * - isIgnored: Only remove extra attributes on target if they start with 'data- + * + * @param {HTMLElement} target + * @param {HTMLElement} source + * @param {{exclude?: string[], isIgnored?: boolean}} [opts] + */ mergeAttrs(target, source, opts = {}){ let exclude = opts.exclude || [] let isIgnored = opts.isIgnored @@ -365,6 +615,11 @@ let DOM = { } }, + /** + * Copy attributes from focusable input source to target + * @param {HTMLElement} target + * @param {HTMLElement} source + */ mergeFocusedInput(target, source){ // skip selects because FF will reset highlighted index for any setAttribute if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: ["value"]}) } @@ -375,10 +630,21 @@ let DOM = { } }, + /** + * Does element have a selection range? + * @param {HTMLElement} el + * @returns {boolean} + */ hasSelectionRange(el){ return el.setSelectionRange && (el.type === "text" || el.type === "textarea") }, + /** + * Restore focus to the element, along with any selection range + * @param {HTMLElement} focused + * @param {number} [selectionStart] + * @param {number} [selectionEnd] + */ restoreFocus(focused, selectionStart, selectionEnd){ if(!DOM.isTextualInput(focused)){ return } let wasFocused = focused.matches(":focus") @@ -389,20 +655,45 @@ let DOM = { } }, + /** + * Is the element a form input? + * @param {HTMLElement} el + * @returns {boolean} + */ isFormInput(el){ return /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button" }, + /** + * Copy checkable input elements "checked" attributes to "checked" property + * @param {HTMLElement} el + */ syncAttrsToProps(el){ if(el instanceof HTMLInputElement && CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0){ el.checked = el.getAttribute("checked") !== null } }, + /** + * Is the element one of the focusable textual input elements? + * @param {HTMLElement} el + * @returns {boolean} + */ isTextualInput(el){ return FOCUSABLE_INPUTS.indexOf(el.type) >= 0 }, + /** + * @param {HTMLElement} el + * @param {string} phxTriggerExternal + * @returns {boolean} + */ isNowTriggerFormExternal(el, phxTriggerExternal){ return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null }, + /** + * @param {HTMLElement} fromEl + * @param {HTMLElement} toEl + * @param {string} disableWith + * @returns {boolean} + */ syncPendingRef(fromEl, toEl, disableWith){ let ref = fromEl.getAttribute(PHX_REF) if(ref === null){ return true } @@ -422,6 +713,11 @@ let DOM = { } }, + /** + * Remove child nodes missing ID's. + * @param {HTMLElement} container + * @param {string} phxUpdate + */ cleanChildNodes(container, phxUpdate){ if(DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend"])){ let toRemove = [] @@ -440,6 +736,14 @@ let DOM = { } }, + /** + * Replace the root container element and replacing attributes with given set + * (while stilll retaining phx tracking attributes). + * @param {HTMLElement} container + * @param {string} tagName + * @param {object} attrs + * @returns {HTMLElement} + */ replaceRootContainer(container, tagName, attrs){ let retainedAttrs = new Set(["id", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID]) if(container.tagName.toLowerCase() === tagName.toLowerCase()){ @@ -463,8 +767,16 @@ let DOM = { } }, + + /** + * Get the stashed result for the element's private "sticky" operation matching given name; if not found, return default value + * @param {HTMLElement} el + * @param {string} name - name of sticky op to search for + * @param {any} defaultVal - if function, it will be called and result returned + * @returns {any} stashed result of op or defaultVal + */ getSticky(el, name, defaultVal){ - let op = (DOM.private(el, "sticky") || []).find(([existingName, ]) => name === existingName) + let op = (DOM.private(el, "sticky") || []).find(([existingName]) => name === existingName) if(op){ let [_name, _op, stashedResult] = op return stashedResult @@ -473,16 +785,27 @@ let DOM = { } }, + /** + * Delete the named "sticky" operation from element's private data + * @param {HTMLElement} el + * @param {string} name + */ deleteSticky(el, name){ this.updatePrivate(el, "sticky", [], ops => { return ops.filter(([existingName, _]) => existingName !== name) }) }, + /** + * Store a named "sticky" operation and it's result to the element's private data + * @param {HTMLElement} el + * @param {string} name + * @param {function} op + */ putSticky(el, name, op){ let stashedResult = op(el) this.updatePrivate(el, "sticky", [], ops => { - let existingIndex = ops.findIndex(([existingName, ]) => name === existingName) + let existingIndex = ops.findIndex(([existingName]) => name === existingName) if(existingIndex >= 0){ ops[existingIndex] = [name, op, stashedResult] } else { @@ -492,6 +815,10 @@ let DOM = { }) }, + /** + * Re-run all sticky operations for this element and store latest stashed results + * @param {HTMLElement} el + */ applyStickyOperations(el){ let ops = DOM.private(el, "sticky") if(!ops){ return } diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index 5adf9afc9c..ecf60d69d3 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -1,3 +1,8 @@ +/** + * Module Dependencies: + * + * @typedef {import('./rendered.js').Stream} Stream + */ import { PHX_COMPONENT, PHX_DISABLE_WITH, @@ -25,6 +30,15 @@ import DOM from "./dom" import DOMPostMorphRestorer from "./dom_post_morph_restorer" import morphdom from "morphdom" +/** + * Definition of a stream insert object + * @typedef {object} StreamInsert + * @property {string} ref + * @property {number} streamAt + * @property {number} limit + * @property {boolean} resetKept + */ + export default class DOMPatch { static patchEl(fromEl, toEl, activeElement){ morphdom(fromEl, toEl, { @@ -38,6 +52,15 @@ export default class DOMPatch { }) } + /** + * Constructor - DOM Patch to be implemented by Morphdom + * @param {import('./view').default} view + * @param {HTMLElement} container + * @param {string} id + * @param {string} html + * @param {Stream[]} streams + * @param {string|number|null} [targetCID] + */ constructor(view, container, id, html, streams, targetCID){ this.view = view this.liveSocket = view.liveSocket @@ -46,6 +69,7 @@ export default class DOMPatch { this.rootID = view.root.id this.html = html this.streams = streams + /** @type {{[key: string]: StreamInsert}} - keyed by element ID */ this.streamInserts = {} this.targetCID = targetCID this.cidPatch = isCid(this.targetCID) @@ -58,17 +82,41 @@ export default class DOMPatch { } } + /** + * Register "before" callback + * @param {string} kind + * @param {(...any) => void} callback + */ before(kind, callback){ this.callbacks[`before${kind}`].push(callback) } + + /** + * Register "after" callback + * @param {string} kind + * @param {(...any) => void} callback + */ after(kind, callback){ this.callbacks[`after${kind}`].push(callback) } + /** + * Run all "before" callbacks matching kind + * @param {string} kind + * @param {...any} [args] - given to each executed callback + */ trackBefore(kind, ...args){ this.callbacks[`before${kind}`].forEach(callback => callback(...args)) } + /** + * Run all "after" callbacks matching kind + * @param {string} kind + * @param {...any} [args] - given to each executed callback + */ trackAfter(kind, ...args){ this.callbacks[`after${kind}`].forEach(callback => callback(...args)) } + /** + * Add the prune attribute to prunable nodes in the container + */ markPrunableContentForRemoval(){ let phxUpdate = this.liveSocket.binding(PHX_UPDATE) DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, el => el.innerHTML = "") @@ -77,6 +125,9 @@ export default class DOMPatch { }) } + /** + * Run the patch via morphdom + */ perform(){ let {view, liveSocket, container, html} = this let targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container @@ -90,9 +141,14 @@ export default class DOMPatch { let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP) let phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM) let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION) + + /** @type {HTMLElement[]} */ let added = [] + /** @type {HTMLElement[]} */ let trackedInputs = [] + /** @type {HTMLElement[]} */ let updates = [] + /** @type {DOMPostMorphRestorer[]} */ let appendPrependUpdates = [] let externalFormTriggered = null @@ -131,7 +187,7 @@ export default class DOMPatch { // tell morphdom how to add a child addChild: (parent, child) => { let {ref, streamAt, limit} = this.getStreamInsert(child) - if(ref === undefined) { return parent.appendChild(child) } + if(ref === undefined){ return parent.appendChild(child) } DOM.putSticky(child, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref)) @@ -168,6 +224,7 @@ export default class DOMPatch { // hack to fix Safari handling of img srcset and video tags if(el instanceof HTMLImageElement && el.srcset){ + /* eslint-disable-next-line no-self-assign */ el.srcset = el.srcset } else if(el instanceof HTMLVideoElement && el.autoplay){ el.play() @@ -314,12 +371,20 @@ export default class DOMPatch { return true } + /** + * Notify liveSocket and callbacks of node discard + * @param {Element} el + */ onNodeDiscarded(el){ // nested view handling if(DOM.isPhxChild(el) || DOM.isPhxSticky(el)){ this.liveSocket.destroyViewByEl(el) } this.trackAfter("discarded", el) } + /** + * @param {Element} node + * @returns {boolean} true if added to pending removals + */ maybePendingRemove(node){ if(node.getAttribute && node.getAttribute(this.phxRemove) !== null){ this.pendingRemoves.push(node) @@ -329,6 +394,10 @@ export default class DOMPatch { } } + /** + * Remove the child immediately or within a transition + * @param {Element} child + */ removeStreamChildElement(child){ if(!this.maybePendingRemove(child)){ child.remove() @@ -336,12 +405,22 @@ export default class DOMPatch { } } + /** + * Get stream insert matching Element ID + * @param {Element} el + * @returns {StreamInsert} + */ getStreamInsert(el){ let insert = el.id ? this.streamInserts[el.id] : {} return insert || {} } + /** + * Insert nodes that are part of the stream, reordering their position if it changed + * @param {Element} el + */ maybeReOrderStream(el){ + // eslint-disable-next-line no-unused-vars let {ref, streamAt, limit} = this.getStreamInsert(el) if(streamAt === undefined){ return } @@ -366,6 +445,9 @@ export default class DOMPatch { } } + /** + * Run pending node removals within a transition + */ transitionPendingRemoves(){ let {pendingRemoves, liveSocket} = this if(pendingRemoves.length > 0){ @@ -381,12 +463,26 @@ export default class DOMPatch { } } + /** + * Is this patch targeting a component? + * @returns {boolean} + */ isCIDPatch(){ return this.cidPatch } + /** + * Is this an element that was indicated to be skipped? + * @param {Node} el + * @returns {boolean} + */ skipCIDSibling(el){ return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP) } + /** + * Get the container of the target CID + * @param {string} html + * @returns {HTMLElement|undefined} undefined if target is not a component + */ targetCIDContainer(html){ if(!this.isCIDPatch()){ return } let [first, ...rest] = DOM.findComponentNodeList(this.container, this.targetCID) @@ -397,5 +493,11 @@ export default class DOMPatch { } } + /** + * Find indexOf child within parent + * @param {Element} parent + * @param {Element} child + * @returns {number} + */ indexOf(parent, child){ return Array.from(parent.children).indexOf(child) } -} \ No newline at end of file +} diff --git a/assets/js/phoenix_live_view/dom_post_morph_restorer.js b/assets/js/phoenix_live_view/dom_post_morph_restorer.js index 2fa2ea429b..4f71af57c6 100644 --- a/assets/js/phoenix_live_view/dom_post_morph_restorer.js +++ b/assets/js/phoenix_live_view/dom_post_morph_restorer.js @@ -5,10 +5,19 @@ import { import DOM from "./dom" export default class DOMPostMorphRestorer { + /** + * Constructor + * @param {Element} containerBefore + * @param {Element} containerAfter + * @param {string} updateType + */ constructor(containerBefore, containerAfter, updateType){ + /** @type {Set} */ let idsBefore = new Set() + /** @type {Set} */ let idsAfter = new Set([...containerAfter.children].map(child => child.id)) + /** @type {{elementId: string, previousElementId: string}[]} */ let elementsToModify = [] Array.from(containerBefore.children).forEach(child => { @@ -27,12 +36,14 @@ export default class DOMPostMorphRestorer { this.elementIdsToAdd = [...idsAfter].filter(id => !idsBefore.has(id)) } - // We do the following to optimize append/prepend operations: - // 1) Track ids of modified elements & of new elements - // 2) All the modified elements are put back in the correct position in the DOM tree - // by storing the id of their previous sibling - // 3) New elements are going to be put in the right place by morphdom during append. - // For prepend, we move them to the first position in the container + /** + * We do the following to optimize append/prepend operations: + * 1) Track ids of modified elements & of new elements + * 2) All the modified elements are put back in the correct position in the DOM tree + * by storing the id of their previous sibling + * 3) New elements are going to be put in the right place by morphdom during append. + * For prepend, we move them to the first position in the container + */ perform(){ let container = DOM.byId(this.containerId) this.elementsToModify.forEach(elementToModify => { diff --git a/assets/js/phoenix_live_view/entry_uploader.js b/assets/js/phoenix_live_view/entry_uploader.js index e442dc854e..39ad473cfb 100644 --- a/assets/js/phoenix_live_view/entry_uploader.js +++ b/assets/js/phoenix_live_view/entry_uploader.js @@ -1,8 +1,19 @@ +/** + * Module Type Dependencies: + * @typedef {import('./live_socket.js').default} LiveSocket + * @typedef {import('./upload_entry').default} UploadEntry + */ import { logError } from "./utils" export default class EntryUploader { + /** + * Constructor + * @param {UploadEntry} entry + * @param {number} chunkSize + * @param {LiveSocket} liveSocket + */ constructor(entry, chunkSize, liveSocket){ this.liveSocket = liveSocket this.entry = entry @@ -13,6 +24,11 @@ export default class EntryUploader { this.uploadChannel = liveSocket.channel(`lvu:${entry.ref}`, {token: entry.metadata()}) } + /** + * Mark this entry upload as an error + * @private + * @param {string} [reason] + */ error(reason){ if(this.errored){ return } this.errored = true @@ -20,6 +36,10 @@ export default class EntryUploader { this.entry.error(reason) } + /** + * Perform upload over channel + * @public + */ upload(){ this.uploadChannel.onError(reason => this.error(reason)) this.uploadChannel.join() @@ -27,8 +47,17 @@ export default class EntryUploader { .receive("error", reason => this.error(reason)) } + /** + * Have all file chunks finished uploading? + * @public + * @returns {boolean} + */ isDone(){ return this.offset >= this.entry.file.size } + /** + * Read and upload next file chunk + * @private + */ readNextChunk(){ let reader = new window.FileReader() let blob = this.entry.file.slice(this.offset, this.chunkSize + this.offset) @@ -43,6 +72,11 @@ export default class EntryUploader { reader.readAsArrayBuffer(blob) } + /** + * Perform file chunk upload over channel + * @private + * @param {string | ArrayBuffer | null} chunk + */ pushChunk(chunk){ if(!this.uploadChannel.isJoined()){ return } this.uploadChannel.push("chunk", chunk) diff --git a/assets/js/phoenix_live_view/hooks.js b/assets/js/phoenix_live_view/hooks.js index 59521baad1..dc4a0b0382 100644 --- a/assets/js/phoenix_live_view/hooks.js +++ b/assets/js/phoenix_live_view/hooks.js @@ -8,7 +8,13 @@ import { import LiveUploader from "./live_uploader" import ARIA from "./aria" +/** @typedef {import('./view_hook').HookCallbacks} HookCallbacks */ + let Hooks = { + /** + * Phoenix hook for handling live file uploads with progress tracking + * @satisfies {HookCallbacks} + */ LiveFileUpload: { activeRefs(){ return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS) }, @@ -30,6 +36,10 @@ let Hooks = { } }, + /** + * Phoenix hook for image previewing + * @satisfies {HookCallbacks} + */ LiveImgPreview: { mounted(){ this.ref = this.el.getAttribute("data-phx-entry-ref") @@ -43,6 +53,11 @@ let Hooks = { URL.revokeObjectURL(this.url) } }, + + /** + * Phoenix hook for wrapping focus on a container element + * @satisfies {HookCallbacks} + */ FocusWrap: { mounted(){ this.focusStart = this.el.firstElementChild @@ -75,6 +90,9 @@ let isWithinViewport = (el) => { return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight() } +/** + * Phoenix hook for inifinitely scrolling a container and loading new data on-demand + */ Hooks.InfiniteScroll = { mounted(){ let scrollBefore = scrollTop() @@ -105,6 +123,7 @@ Hooks.InfiniteScroll = { }) }) + // eslint-disable-next-line no-unused-vars this.onScroll = (e) => { let scrollNow = scrollTop() @@ -148,7 +167,7 @@ Hooks.InfiniteScroll = { let remainingTime = interval - (now - lastCallAt) if(remainingTime <= 0 || remainingTime > interval){ - if(timer) { + if(timer){ clearTimeout(timer) timer = null } diff --git a/assets/js/phoenix_live_view/js.js b/assets/js/phoenix_live_view/js.js index 8535a53237..51a6926d00 100644 --- a/assets/js/phoenix_live_view/js.js +++ b/assets/js/phoenix_live_view/js.js @@ -1,9 +1,21 @@ import DOM from "./dom" import ARIA from "./aria" +/** + * @typedef {import('./view.js').default} View + */ + let focusStack = null let JS = { + /** + * Execute JS command - main entrypoint + * @param {string|null} eventType + * @param {string} phxEvent + * @param {View} view + * @param {HTMLElement} sourceEl + * @param {[defaultKind: string, defaultArgs: any]} [defaults] + */ exec(eventType, phxEvent, view, sourceEl, defaults){ let [defaultKind, defaultArgs] = defaults || [null, {callback: defaults && defaults.callback}] let commands = phxEvent.charAt(0) === "[" ? @@ -22,6 +34,11 @@ let JS = { }) }, + /** + * Is element visible and not scrolled off-screen? + * @param {Element} el + * @returns {boolean} + */ isVisible(el){ return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0) }, @@ -30,6 +47,16 @@ let JS = { // commands + /** + * Exec JS command for all targeted DOM nodes + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + * @param {[attrWithCommand: string, toTargetsSelector: string]} args + */ exec_exec(eventType, phxEvent, view, sourceEl, el, [attr, to]){ let nodes = to ? DOM.all(document, to) : [sourceEl] nodes.forEach(node => { @@ -39,12 +66,47 @@ let JS = { }) }, + /** + * Exec dispatch command for event + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + * @param {object} args + * @param {string} [args.to] + * @param {string} [args.event] + * @param {boolean} [args.bubbles] + * @param {object} [args.detail] + */ + // eslint-disable-next-line no-unused-vars exec_dispatch(eventType, phxEvent, view, sourceEl, el, {to, event, detail, bubbles}){ detail = detail || {} detail.dispatcher = sourceEl DOM.dispatchEvent(el, event, {detail, bubbles}) }, + /** + * Push an event over livesocket + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + * @param {object} args + * @param {string} [args.event] + * @param {any} [args.submitter] + * @param {string|number|Element} [args.target] + * @param {boolean} [args.page_loading] + * @param {any} [args.loading] + * @param {any} [args.value] + * @param {Element} [args.dispatcher] + * @param {function} [args.callback] + * @param {string|number} [args.newCid] + * @param {string} [args._target] + */ exec_push(eventType, phxEvent, view, sourceEl, el, args){ if(!view.isConnected()){ return } @@ -67,26 +129,78 @@ let JS = { }) }, + /** + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + * @param {{href: string, replace?: boolean}} args + */ exec_navigate(eventType, phxEvent, view, sourceEl, el, {href, replace}){ view.liveSocket.historyRedirect(href, replace ? "replace" : "push") }, + /** + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + * @param {{href: string, replace: boolean}} args + */ exec_patch(eventType, phxEvent, view, sourceEl, el, {href, replace}){ view.liveSocket.pushHistoryPatch(href, replace ? "replace" : "push", sourceEl) }, + + /** + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + */ exec_focus(eventType, phxEvent, view, sourceEl, el){ window.requestAnimationFrame(() => ARIA.attemptFocus(el)) }, + /** + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + */ exec_focus_first(eventType, phxEvent, view, sourceEl, el){ window.requestAnimationFrame(() => ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el)) }, + /** + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + */ exec_push_focus(eventType, phxEvent, view, sourceEl, el){ window.requestAnimationFrame(() => focusStack = el || sourceEl) }, + /** + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + */ + // eslint-disable-next-line no-unused-vars exec_pop_focus(eventType, phxEvent, view, sourceEl, el){ window.requestAnimationFrame(() => { if(focusStack){ focusStack.focus() } @@ -94,6 +208,18 @@ let JS = { }) }, + /** + * @private + * @param {string} eventType + * @param {string} phxEvent + * @param {View} view + * @param {Element} sourceEl + * @param {Element} el + * @param {object} args + * @param {object} args.names + * @param {object} args.transition + * @param {object} args.time + */ exec_add_class(eventType, phxEvent, view, sourceEl, el, {names, transition, time}){ this.addOrRemoveClasses(el, names, [], transition, time, view) }, @@ -193,6 +319,15 @@ let JS = { } }, + /** + * @param {Element} el + * @param {string[]} adds + * @param {string[]} removes + * @param {[runs: string[], starts: string[], ends: string[]]} [transition] + * @param {number} time + * @param {View} view + * @returns + */ addOrRemoveClasses(el, adds, removes, transition, time, view){ let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []] if(transitionRun.length > 0){ @@ -208,6 +343,7 @@ let JS = { } window.requestAnimationFrame(() => { + /** @type {[string[], string[]]} */ let [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]) let keepAdds = adds.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name)) let keepRemoves = removes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name)) @@ -222,7 +358,13 @@ let JS = { }) }, + /** + * @param {Element} el + * @param {[attr: string, val: string][]} sets + * @param {string[]} removes + */ setOrRemoveAttrs(el, sets, removes){ + /** @type {[[attr: string, val: string], string[]]} */ let [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []]) let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes); @@ -236,19 +378,40 @@ let JS = { }) }, + /** + * @param {HTMLElement} el + * @param {string[]} classes + * @returns {boolean} + */ hasAllClasses(el, classes){ return classes.every(name => el.classList.contains(name)) }, + /** + * @param {HTMLElement} el + * @param {string[]} outClasses + * @returns {boolean} + */ isToggledOut(el, outClasses){ return !this.isVisible(el) || this.hasAllClasses(el, outClasses) }, + /** + * @private + * @param {HTMLElement} sourceEl + * @param {{to?: string}} args + * @returns + */ filterToEls(sourceEl, {to}){ return to ? DOM.all(document, to) : [sourceEl] }, + /** + * @private + * @param {HTMLElement} el + * @returns {"table-row"|"table-cell"|"block"} + */ defaultDisplay(el){ return {tr: "table-row", td: "table-cell"}[el.tagName.toLowerCase()] || "block" } } -export default JS \ No newline at end of file +export default JS diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index fe830f302c..2281450528 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -1,76 +1,16 @@ -/** Initializes the LiveSocket - * - * - * @param {string} endPoint - The string WebSocket endpoint, ie, `"wss://example.com/live"`, - * `"/live"` (inherited host & protocol) - * @param {Phoenix.Socket} socket - the required Phoenix Socket class imported from "phoenix". For example: - * - * import {Socket} from "phoenix" - * import {LiveSocket} from "phoenix_live_view" - * let liveSocket = new LiveSocket("/live", Socket, {...}) - * - * @param {Object} [opts] - Optional configuration. Outside of keys listed below, all - * configuration is passed directly to the Phoenix Socket constructor. - * @param {Object} [opts.defaults] - The optional defaults to use for various bindings, - * such as `phx-debounce`. Supports the following keys: - * - * - debounce - the millisecond phx-debounce time. Defaults 300 - * - throttle - the millisecond phx-throttle time. Defaults 300 - * - * @param {Function} [opts.params] - The optional function for passing connect params. - * The function receives the element associated with a given LiveView. For example: - * - * (el) => {view: el.getAttribute("data-my-view-name", token: window.myToken} - * - * @param {string} [opts.bindingPrefix] - The optional prefix to use for all phx DOM annotations. - * Defaults to "phx-". - * @param {Object} [opts.hooks] - The optional object for referencing LiveView hook callbacks. - * @param {Object} [opts.uploaders] - The optional object for referencing LiveView uploader callbacks. - * @param {integer} [opts.loaderTimeout] - The optional delay in milliseconds to wait before apply - * loading states. - * @param {integer} [opts.maxReloads] - The maximum reloads before entering failsafe mode. - * @param {integer} [opts.reloadJitterMin] - The minimum time between normal reload attempts. - * @param {integer} [opts.reloadJitterMax] - The maximum time between normal reload attempts. - * @param {integer} [opts.failsafeJitter] - The time between reload attempts in failsafe mode. - * @param {Function} [opts.viewLogger] - The optional function to log debug information. For example: - * - * (view, kind, msg, obj) => console.log(`${view.id} ${kind}: ${msg} - `, obj) - * - * @param {Object} [opts.metadata] - The optional object mapping event names to functions for - * populating event metadata. For example: - * - * metadata: { - * click: (e, el) => { - * return { - * ctrlKey: e.ctrlKey, - * metaKey: e.metaKey, - * detail: e.detail || 1, - * } - * }, - * keydown: (e, el) => { - * return { - * key: e.key, - * ctrlKey: e.ctrlKey, - * metaKey: e.metaKey, - * shiftKey: e.shiftKey - * } - * } - * } - * @param {Object} [opts.sessionStorage] - An optional Storage compatible object - * Useful when LiveView won't have access to `sessionStorage`. For example, This could - * happen if a site loads a cross-domain LiveView in an iframe. Example usage: - * - * class InMemoryStorage { - * constructor() { this.storage = {} } - * getItem(keyName) { return this.storage[keyName] || null } - * removeItem(keyName) { delete this.storage[keyName] } - * setItem(keyName, keyValue) { this.storage[keyName] = keyValue } - * } - * - * @param {Object} [opts.localStorage] - An optional Storage compatible object - * Useful for when LiveView won't have access to `localStorage`. - * See `opts.sessionStorage` for examples. -*/ +/** + * Module dependencies + * + * Phoenix: + * @typedef {import('phoenix').Socket} Socket + * @typedef {typeof import('phoenix').Socket} SocketCls + * @typedef {import('phoenix').Channel} Channel + * @typedef {import('phoenix').Push} Push + * + * Local: + * @typedef {import('./view_hook.js').default} ViewHook + * @typedef {import('./view_hook.js').HookCallbacks} HookCallbacks + */ import { BINDING_PREFIX, @@ -106,7 +46,6 @@ import { closestPhxBinding, closure, debug, - isObject, maybe } from "./utils" @@ -118,6 +57,78 @@ import View from "./view" import JS from "./js" export default class LiveSocket { + /** Constructor - Initializes the LiveSocket + * + * @param {string} url - The WebSocket endpoint, ie, `"wss://example.com/live"`, `"/live"` (inherited host & protocol) + * @param {SocketCls} phxSocket - The required Phoenix Socket class imported from "phoenix". For example: + * + * import {Socket} from "phoenix" + * import {LiveSocket} from "phoenix_live_view" + * let liveSocket = new LiveSocket("/live", Socket, {...}) + * + * @param {Object} opts - Optional configuration. Outside of keys listed below, all + * configuration is passed directly to the Phoenix Socket constructor. + * + * @param {{debounce: number, throttle: number}} [opts.defaults] - The optional defaults to use for various bindings, + * such as `phx-debounce`. Supports the following keys: + * + * - debounce - the millisecond phx-debounce time. Defaults 300 + * - throttle - the millisecond phx-throttle time. Defaults 300 + * + * @param {Function} [opts.params] - The optional function for passing connect params. + * The function receives the element associated with a given LiveView. For example: + * + * (el) => {view: el.getAttribute("data-my-view-name", token: window.myToken} + * + * @param {string} [opts.bindingPrefix] - The optional prefix to use for all phx DOM annotations. + * Defaults to "phx-". + * @param {{[key:string]: HookCallbacks}} [opts.hooks] - The optional object for referencing LiveView hook callbacks. + * @param {{[key:string]: function}} [opts.uploaders] - The optional object for referencing LiveView uploader callbacks. + * @param {integer} [opts.loaderTimeout] - The optional delay in milliseconds to wait before apply + * loading states. + * @param {integer} [opts.maxReloads] - The maximum reloads before entering failsafe mode. + * @param {integer} [opts.reloadJitterMin] - The minimum time between normal reload attempts. + * @param {integer} [opts.reloadJitterMax] - The maximum time between normal reload attempts. + * @param {integer} [opts.failsafeJitter] - The time between reload attempts in failsafe mode. + * @param {(view: View, kind: string, msg: string, obj: any) => void} [opts.viewLogger] - The optional function to log debug information. For example: + * + * (view, kind, msg, obj) => console.log(`${view.id} ${kind}: ${msg} - `, obj) + * + * @param {{[key:string]: (e: Event, targetEl: Element) => any}} [opts.metadata] - The optional object mapping event names to functions for + * populating event metadata. For example: + * + * metadata: { + * click: (e, el) => { + * return { + * ctrlKey: e.ctrlKey, + * metaKey: e.metaKey, + * detail: e.detail || 1, + * } + * }, + * keydown: (e, el) => { + * return { + * key: e.key, + * ctrlKey: e.ctrlKey, + * metaKey: e.metaKey, + * shiftKey: e.shiftKey + * } + * } + * } + * @param {Object} [opts.sessionStorage] - An optional Storage compatible object + * Useful when LiveView won't have access to `sessionStorage`. For example, This could + * happen if a site loads a cross-domain LiveView in an iframe. Example usage: + * + * class InMemoryStorage { + * constructor() { this.storage = {} } + * getItem(keyName) { return this.storage[keyName] || null } + * removeItem(keyName) { delete this.storage[keyName] } + * setItem(keyName, keyValue) { this.storage[keyName] = keyValue } + * } + * + * @param {Object} [opts.localStorage] - An optional Storage compatible object + * Useful for when LiveView won't have access to `localStorage`. + * See `opts.sessionStorage` for examples. + */ constructor(url, phxSocket, opts = {}){ this.unloaded = false if(!phxSocket || phxSocket.constructor.name === "Object"){ @@ -143,10 +154,12 @@ export default class LiveSocket { this.outgoingMainEl = null this.clickStartedAtTarget = null this.linkRef = 1 + /** @type {{[key: string]: View}} */ this.roots = {} this.href = window.location.href this.pendingLink = null this.currentLocation = clone(window.location) + /** @type {{[key: string]: HookCallbacks}} */ this.hooks = opts.hooks || {} this.uploaders = opts.uploaders || {} this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT @@ -173,35 +186,77 @@ export default class LiveSocket { // public + /** + * Is profiling mode enabled? + * @returns {boolean} + */ isProfileEnabled(){ return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true" } + /** + * Is debug mode enabled? + * @returns {boolean} + */ isDebugEnabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true" } + /** + * Is debug mode disabled? + * @returns {boolean} + */ isDebugDisabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false" } + /** + * Enable debug mode + */ enableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "true") } + /** + * Enable profiling mode + */ enableProfiling(){ this.sessionStorage.setItem(PHX_LV_PROFILE, "true") } + /** + * Disable debug mode + */ disableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "false") } + /** + * Disable profiling mode + */ disableProfiling(){ this.sessionStorage.removeItem(PHX_LV_PROFILE) } + + /** + * Enable latency simulation mode + * @param {number} upperBoundMs + */ enableLatencySim(upperBoundMs){ this.enableDebug() console.log("latency simulator enabled for the duration of this browser session. Call disableLatencySim() to disable") this.sessionStorage.setItem(PHX_LV_LATENCY_SIM, upperBoundMs) } + /** + * Disable latency simulation mode + */ disableLatencySim(){ this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM) } + /** + * Get simulated latency value if set + * @returns {number|null} + */ getLatencySim(){ let str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM) return str ? parseInt(str) : null } + /** + * Get the underlying socket object + */ getSocket(){ return this.socket } + /** + * Connect on socket and join views + */ connect(){ // enable debug by default if on localhost and not explicitly disabled if(window.location.hostname === "localhost" && !this.isDebugDisabled()){ this.enableDebug() } @@ -223,29 +278,52 @@ export default class LiveSocket { } } + /** + * Disconnect + * @param {() => void | Promise} callback + */ disconnect(callback){ clearTimeout(this.reloadWithJitterTimer) this.socket.disconnect(callback) } + /** + * Replace socket transport object + * @param {new(endpoint: string) => object} transport - Class/Constructor implementing transport interface + */ replaceTransport(transport){ clearTimeout(this.reloadWithJitterTimer) this.socket.replaceTransport(transport) this.connect() } + /** + * Execute JS against element + * @param {Element} el + * @param {string} encodedJS + * @param {string} [eventType] + */ execJS(el, encodedJS, eventType = null){ this.owner(el, view => JS.exec(eventType, encodedJS, view, el)) } // private + /** + * @param {HTMLElement} el + * @param {string} phxEvent + * @param {object} data + * @param {() => void} callback + */ execJSHookPush(el, phxEvent, data, callback){ this.withinOwners(el, view => { JS.exec("hook", phxEvent, view, el, ["push", {data, callback}]) }) } + /** + * Disconnect socket, unload, and destroy all views + */ unload(){ if(this.unloaded){ return } if(this.main && this.isConnected()){ this.log(this.main, "socket", () => ["disconnect for page nav"]) } @@ -254,8 +332,20 @@ export default class LiveSocket { this.disconnect() } + /** + * Run registered DOM callbacks matching kind + * @param {string} kind + * @param {any[]} args + */ triggerDOM(kind, args){ this.domCallbacks[kind](...args) } + /** + * Execute the given function in a timer and log to the console + * @template T + * @param {string} name + * @param {() => T} func + * @returns {T} + */ time(name, func){ if(!this.isProfileEnabled() || !console.time){ return func() } console.time(name) @@ -264,6 +354,12 @@ export default class LiveSocket { return result } + /** + * Debug log + * @param {View} view + * @param {string} kind + * @param {() => [string, any]} msgCallback - only called if opt.viewLogger given or isDebugEnabled + */ log(view, kind, msgCallback){ if(this.viewLogger){ let [msg, obj] = msgCallback() @@ -274,14 +370,30 @@ export default class LiveSocket { } } + /** + * Execute callback after next DOM update transition finishes + * @param {() => void} callback + */ requestDOMUpdate(callback){ this.transitions.after(callback) } + /** + * Add a managed transition + * @param {number} time + * @param {() => void} onStart + * @param {() => void} [onDone] + */ transition(time, onStart, onDone = function(){}){ this.transitions.addTransition(time, onStart, onDone) } + /** + * Subscribe to event on channel + * @param {Channel} channel - channel to listen on + * @param {string} event - event to listen for + * @param {(response?: object) => void} cb - callback to invoke on each event + */ onChannel(channel, event, cb){ channel.on(event, data => { let latency = this.getLatencySim() @@ -293,6 +405,13 @@ export default class LiveSocket { }) } + /** + * Wrap Channel Push with additional management behavior + * @param {View} view + * @param {{timeout: boolean}} opts + * @param {() => Push} push + * @returns {Push} + */ wrapPush(view, opts, push){ let latency = this.getLatencySim() let oldJoinCount = view.joinCount @@ -321,6 +440,11 @@ export default class LiveSocket { return fakePush } + /** + * Reload page with simulated network jitter + * @param {View} view + * @param {() => void} [log] + */ reloadWithJitter(view, log){ clearTimeout(this.reloadWithJitterTimer) this.disconnect() @@ -347,20 +471,51 @@ export default class LiveSocket { }, afterMs) } + /** + * Lookup hook + * @param {string} [name] + * @returns {HookCallbacks|undefined} + */ getHookCallbacks(name){ return name && name.startsWith("Phoenix.") ? Hooks[name.split(".")[1]] : this.hooks[name] } + /** + * Is the socket unloaded? + * @returns {boolean} + */ isUnloaded(){ return this.unloaded } + /** + * Is the socket connected? + * @returns {boolean} + */ isConnected(){ return this.socket.isConnected() } + /** + * Get the prefix for attribute bindings + * @returns {string} + */ getBindingPrefix(){ return this.bindingPrefix } + /** + * Create prefixed binding name + * @param {string} kind + * @returns {string} + */ binding(kind){ return `${this.getBindingPrefix()}${kind}` } + /** + * Get socket channel for this topic + * @param {string} topic + * @param {object} [params] + * @returns {Channel} + */ channel(topic, params){ return this.socket.channel(topic, params) } + /** + * If no live root views, join the dead views + */ joinDeadView(){ let body = document.body if(body && !this.isPhxView(body) && !this.isPhxView(document.firstElementChild)){ @@ -372,6 +527,10 @@ export default class LiveSocket { } } + /** + * Find all root views and join() + * @returns {boolean} were root views found? + */ joinRootViews(){ let rootsFound = false DOM.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, rootEl => { @@ -386,11 +545,23 @@ export default class LiveSocket { return rootsFound } + /** + * Execute browser redirect + * @param {string} to + * @param {string|null} flash + */ redirect(to, flash){ this.unload() Browser.redirect(to, flash) } + /** + * Replace a new root view and main element + * @param {string} href + * @param {string|null} flash + * @param {(linkRef: number) => void} [callback] + * @param {number} [linkRef] + */ replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)){ let liveReferer = this.currentLocation.href this.outgoingMainEl = this.outgoingMainEl || this.main.el @@ -414,6 +585,10 @@ export default class LiveSocket { }) } + /** + * Dispatch remove JS events for given elements or elements with "remove" binding + * @param {Element[]} [elements] + */ transitionRemoves(elements){ let removeAttr = this.binding("remove") elements = elements || DOM.all(document, `[${removeAttr}]`) @@ -422,30 +597,66 @@ export default class LiveSocket { }) } + /** + * Is element part of a view? + * @param {Element} el + * @returns {boolean} + */ isPhxView(el){ return el.getAttribute && el.getAttribute(PHX_SESSION) !== null } + /** + * Create a root view + * @param {HTMLElement} el + * @param {*} flash + * @param {string} liveReferer - URL of location that initiated the new view + * @returns {View} + */ newRootView(el, flash, liveReferer){ let view = new View(el, this, null, flash, liveReferer) this.roots[view.id] = view return view } + /** + * Run callback with owning View + * @param {HTMLElement} childEl + * @param {(view: View) => void} callback + */ owner(childEl, callback){ let view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main if(view){ callback(view) } } + /** + * Execute callback for view owning given element + * @template {HTMLElement} T + * @param {T} childEl + * @param {(view: View, childEl: T) => void} callback + */ withinOwners(childEl, callback){ this.owner(childEl, view => callback(view, childEl)) } + /** + * Get view owning the element + * @param {Element} el + * @returns {View} + */ getViewByEl(el){ let rootId = el.getAttribute(PHX_ROOT_ID) return maybe(this.getRootById(rootId), root => root.getDescendentByEl(el)) } + /** + * Get root view by ID + * @param {string} id + * @returns {View} + */ getRootById(id){ return this.roots[id] } + /** + * Destroy all root views + */ destroyAllViews(){ for(let id in this.roots){ this.roots[id].destroy() @@ -454,6 +665,10 @@ export default class LiveSocket { this.main = null } + /** + * Destroy root view for element + * @param {Element} el + */ destroyViewByEl(el){ let root = this.getRootById(el.getAttribute(PHX_ROOT_ID)) if(root && root.id === el.id){ @@ -464,6 +679,10 @@ export default class LiveSocket { } } + /** + * Set target as the new active element + * @param {Element} target + */ setActiveElement(target){ if(this.activeElement === target){ return } this.activeElement = target @@ -476,6 +695,10 @@ export default class LiveSocket { target.addEventListener("touchend", cancel) } + /** + * Get document's active element + * @returns {Element} + */ getActiveElement(){ if(document.activeElement === document.body){ return this.activeElement || document.activeElement @@ -485,23 +708,37 @@ export default class LiveSocket { } } + /** + * Unset the existing active element if it's owned by given view + * @param {View} view + */ dropActiveElement(view){ if(this.prevActive && view.ownsElement(this.prevActive)){ this.prevActive = null } } + /** + * Restore focus to the previously active element + */ restorePreviouslyActiveFocus(){ if(this.prevActive && this.prevActive !== document.body){ this.prevActive.focus() } } + /** + * Blue the active element after tracking it for potential future focus + * restore. + */ blurActiveElement(){ this.prevActive = this.getActiveElement() if(this.prevActive !== document.body){ this.prevActive.blur() } } + /** + * @param {{dead?: boolean}} args + */ bindTopLevelEvents({dead} = {}){ if(this.boundTopLevelEvents){ return } @@ -522,7 +759,7 @@ export default class LiveSocket { if(!dead){ this.bindNav() } this.bindClicks() if(!dead){ this.bindForms() } - this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, eventTarget) => { + this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, _eventTarget) => { let matchKey = targetEl.getAttribute(this.binding(PHX_KEY)) let pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key if(matchKey && matchKey.toLowerCase() !== pressedKey){ return } @@ -565,17 +802,31 @@ export default class LiveSocket { }) } + /** + * @param {string} eventName + * @param {Event} e + * @param {Element} targetEl + * @returns {object} + */ eventMeta(eventName, e, targetEl){ let callback = this.metadataCallbacks[eventName] return callback ? callback(e, targetEl) : {} } + /** + * @param {string} href + * @returns {number} + */ setPendingLink(href){ this.linkRef++ this.pendingLink = href return this.linkRef } + /** + * @param {number} linkRef + * @returns {boolean} + */ commitPendingLink(linkRef){ if(this.linkRef !== linkRef){ return false @@ -586,10 +837,21 @@ export default class LiveSocket { } } + /** + * @returns {string} + */ getHref(){ return this.href } + /** + * @returns {boolean} + */ hasPendingLink(){ return !!this.pendingLink } + /** + * Bind handler to multiple browser events + * @param {{[key:string]: string}} events + * @param {(e: Event, eventType: string, view: View, targetEl: Element, phxEvent: string, eventTarget: Element) => void} callback + */ bind(events, callback){ for(let event in events){ let browserEventName = events[event] @@ -618,12 +880,20 @@ export default class LiveSocket { } } + /** + * Bind to all window click events to dispatch internally + */ bindClicks(){ window.addEventListener("click", e => this.clickStartedAtTarget = e.target) this.bindClick("click", "click", false) this.bindClick("mousedown", "capture-click", true) } + /** + * @param {string} eventName + * @param {string} bindingName + * @param {boolean} capture + */ bindClick(eventName, bindingName, capture){ let click = this.binding(bindingName) window.addEventListener(eventName, e => { @@ -655,6 +925,11 @@ export default class LiveSocket { }, capture) } + /** + * Dispatch click-away events + * @param {Event} e + * @param {Element} clickStartedAt + */ dispatchClickAway(e, clickStartedAt){ let phxClickAway = this.binding("click-away") DOM.all(document, `[${phxClickAway}]`, el => { @@ -669,6 +944,11 @@ export default class LiveSocket { }) } + /** + * Bind navigation events to dispatch internally + * + * NOTE: tracking scrolls to restore scroll when necessary + */ bindNav(){ if(!Browser.canPushState()){ return } if(history.scrollRestoration){ history.scrollRestoration = "manual" } @@ -725,7 +1005,11 @@ export default class LiveSocket { }, false) } - maybeScroll(scroll) { + /** + * Scroll if value given + * @param {number} [scroll] + */ + maybeScroll(scroll){ if(typeof(scroll) === "number"){ requestAnimationFrame(() => { window.scrollTo(0, scroll) @@ -733,20 +1017,42 @@ export default class LiveSocket { } } + /** + * Dispatch event on window + * @param {string} event + * @param {{[key:string]: any}} [payload] + */ dispatchEvent(event, payload = {}){ DOM.dispatchEvent(window, `phx:${event}`, {detail: payload}) } + /** + * Dispatch each event on window + * @param {[eventName: string, payload: any][]} events + */ dispatchEvents(events){ events.forEach(([event, payload]) => this.dispatchEvent(event, payload)) } + /** + * Dispatch phx:page-loading-start and return callback to end loading + * @template T + * @param {any} [info] - optional data to provide in event detail + * @param {(stopLoadingCb: ()=>void) => T} [callback] - if given, call with callback that dispatches stop-loading event + * @returns {T|()=>void} either a callback to dispatch stop-loading event, or result of callback if given + */ withPageLoading(info, callback){ DOM.dispatchEvent(window, "phx:page-loading-start", {detail: info}) let done = () => DOM.dispatchEvent(window, "phx:page-loading-stop", {detail: info}) return callback ? callback(done) : done } + /** + * Push a location history patch + * @param {string} href + * @param {("push"|"replace")} linkState + * @param {HTMLElement} targetEl + */ pushHistoryPatch(href, linkState, targetEl){ if(!this.isConnected()){ return Browser.redirect(href) } @@ -758,6 +1064,12 @@ export default class LiveSocket { }) } + /** + * Perform browser history update for patching + * @param {string} href + * @param {("push"|"replace")} linkState + * @param {number} linkRef + */ historyPatch(href, linkState, linkRef = this.setPendingLink(href)){ if(!this.commitPendingLink(linkRef)){ return } @@ -766,6 +1078,12 @@ export default class LiveSocket { this.registerNewLocation(window.location) } + /** + * Perform browser history update for redirect + * @param {string} href + * @param {("push"|"replace")} linkState + * @param {string|null} flash + */ historyRedirect(href, linkState, flash){ // convert to full href if only path prefix if(!this.isConnected()){ return Browser.redirect(href, flash) } @@ -786,10 +1104,18 @@ export default class LiveSocket { }) } + /** + * Replace root in browser history state + */ replaceRootHistory(){ Browser.pushState("replace", {root: true, type: "patch", id: this.main.id}) } + /** + * Set new location as current location + * @param {Location} newLocation + * @returns {boolean} + */ registerNewLocation(newLocation){ let {pathname, search} = this.currentLocation if(pathname + search === newLocation.pathname + newLocation.search){ @@ -800,6 +1126,9 @@ export default class LiveSocket { } } + /** + * Bind Forms + */ bindForms(){ let iterations = 0 let externalFormSubmitted = false @@ -878,6 +1207,13 @@ export default class LiveSocket { }) } + /** + * @private + * @param {Element} el + * @param {Event} event + * @param {string} eventType + * @param {string} callback + */ debounce(el, event, eventType, callback){ if(eventType === "blur" || eventType === "focusout"){ return callback() } @@ -894,12 +1230,21 @@ export default class LiveSocket { }) } + /** + * Disable event listeners, execute callback, enable event listeners + * @param {() => void} callback + */ silenceEvents(callback){ this.silenced = true callback() this.silenced = false } + /** + * Attach handler for event + * @param {string} event + * @param {(e: Event) => void} callback + */ on(event, callback){ window.addEventListener(event, e => { if(!this.silenced){ callback(e) } @@ -909,10 +1254,15 @@ export default class LiveSocket { class TransitionSet { constructor(){ + /** @type {Set} @private */ this.transitions = new Set() + /** @type {function[]} */ this.pendingOps = [] } + /** + * Clear all transitions and flush any pending operations + */ reset(){ this.transitions.forEach(timer => { clearTimeout(timer) @@ -921,6 +1271,11 @@ class TransitionSet { this.flushPendingOps() } + /** + * Register given callback for execution after next transition finishes. + * Executes immediately if no transition is in-flight. + * @param {() => void} callback + */ after(callback){ if(this.size() === 0){ callback() @@ -929,6 +1284,12 @@ class TransitionSet { } } + /** + * Add a transition. + * @param {number} time + * @param {() => void} onStart + * @param {() => void} onDone + */ addTransition(time, onStart, onDone){ onStart() let timer = setTimeout(() => { @@ -939,10 +1300,23 @@ class TransitionSet { this.transitions.add(timer) } + /** + * Register operation for execution after next transition + * @private + * @param {() => void} op + */ pushPendingOp(op){ this.pendingOps.push(op) } + /** + * Get the size of current transition set. + * @returns {number} + */ size(){ return this.transitions.size } + /** + * Execute all registered pending operations + * @private + */ flushPendingOps(){ if(this.size() > 0){ return } let op = this.pendingOps.shift() diff --git a/assets/js/phoenix_live_view/live_uploader.js b/assets/js/phoenix_live_view/live_uploader.js index 64df45a230..3021218aff 100644 --- a/assets/js/phoenix_live_view/live_uploader.js +++ b/assets/js/phoenix_live_view/live_uploader.js @@ -1,3 +1,8 @@ +/** + * Module Type Dependencies: + * @typedef {import('./view.js').default} View + * @typedef {import('./live_socket.js').default} LiveSocket + */ import { PHX_DONE_REFS, PHX_PREFLIGHTED_REFS, @@ -13,6 +18,11 @@ import UploadEntry from "./upload_entry" let liveUploaderFileRef = 0 export default class LiveUploader { + /** + * Generate a unique reference for this file + * @param {File} file + * @returns {string} the file ref + */ static genFileRef(file){ let ref = file._phxRef if(ref !== undefined){ @@ -23,11 +33,22 @@ export default class LiveUploader { } } + /** + * Create URL and pass to the callback + * @param {HTMLInputElement} inputEl + * @param {string} ref + * @param {function} callback + */ static getEntryDataURL(inputEl, ref, callback){ let file = this.activeFiles(inputEl).find(file => this.genFileRef(file) === ref) callback(URL.createObjectURL(file)) } + /** + * Are any file uploads still in-flight for the given form? + * @param {HTMLFormElement} formEl + * @returns {boolean} + */ static hasUploadsInProgress(formEl){ let active = 0 DOM.findUploadInputs(formEl).forEach(input => { @@ -38,6 +59,11 @@ export default class LiveUploader { return active > 0 } + /** + * + * @param {HTMLInputElement} inputEl + * @returns {object} map of file refs to array of entries + */ static serializeUploads(inputEl){ let files = this.activeFiles(inputEl) let fileData = {} @@ -57,16 +83,31 @@ export default class LiveUploader { return fileData } + /** + * Clear upload refs on given file upload input + * @param {HTMLInputElement} inputEl + */ static clearFiles(inputEl){ inputEl.value = null inputEl.removeAttribute(PHX_UPLOAD_REF) DOM.putPrivate(inputEl, "files", []) } + /** + * Untrack file upload for input + * @param {HTMLInputElement} inputEl + * @param {File} file + */ static untrackFile(inputEl, file){ DOM.putPrivate(inputEl, "files", DOM.private(inputEl, "files").filter(f => !Object.is(f, file))) } + /** + * Track file uploads for the given input + * @param {HTMLInputElement} inputEl + * @param {File[]} files + * @param {object=} dataTransfer + */ static trackFiles(inputEl, files, dataTransfer){ if(inputEl.getAttribute("multiple") !== null){ let newFiles = files.filter(file => !this.activeFiles(inputEl).find(f => Object.is(f, file))) @@ -79,24 +120,50 @@ export default class LiveUploader { } } + /** + * Select a list of all file inputs with active uploads + * @param {HTMLFormElement} formEl + * @returns {HTMLInputElement[]} + */ static activeFileInputs(formEl){ let fileInputs = DOM.findUploadInputs(formEl) return Array.from(fileInputs).filter(el => el.files && this.activeFiles(el).length > 0) } + /** + * Select a list of all files from this input being actively uploaded + * @param {HTMLInputElement} input + * @returns {File[]} + */ static activeFiles(input){ return (DOM.private(input, "files") || []).filter(f => UploadEntry.isActive(input, f)) } + /** + * Select a list of all file inputs with files still awaiting preflight + * @param {HTMLFormElement} formEl + * @returns {HTMLInputElement[]} + */ static inputsAwaitingPreflight(formEl){ let fileInputs = DOM.findUploadInputs(formEl) return Array.from(fileInputs).filter(input => this.filesAwaitingPreflight(input).length > 0) } + /** + * Select a list of all files from this input still awaiting preflight + * @param {HTMLInputElement} input + * @returns {File[]} + */ static filesAwaitingPreflight(input){ return this.activeFiles(input).filter(f => !UploadEntry.isPreflighted(input, f)) } + /** + * Constructor + * @param {HTMLInputElement} inputEl + * @param {View} view + * @param {function} onComplete + */ constructor(inputEl, view, onComplete){ this.view = view this.onComplete = onComplete @@ -107,8 +174,18 @@ export default class LiveUploader { this.numEntriesInProgress = this._entries.length } + /** + * Get upload entries list + * @returns {UploadEntry[]} + */ entries(){ return this._entries } + /** + * Initialize the upload process + * @param {any} resp + * @param {function} onError + * @param {LiveSocket} liveSocket + */ initAdapterUpload(resp, onError, liveSocket){ this._entries = this._entries.map(entry => { diff --git a/assets/js/phoenix_live_view/rendered.js b/assets/js/phoenix_live_view/rendered.js index 8ad1e7adaa..437fee2824 100644 --- a/assets/js/phoenix_live_view/rendered.js +++ b/assets/js/phoenix_live_view/rendered.js @@ -19,6 +19,27 @@ import { isCid, } from "./utils" +/** + * @typedef {[number, number]} Insert + * + * @typedef {[ + * ref: string, + * inserts: {[key: string]: Insert}, + * deleteIds: string[], + * reset: boolean|undefined + * ]} Stream + * + * @typedef {{ + * [key: string]: string | number | string[] | RenderedDiffNode, + * }} RenderedDiffNode + * + * @typedef {object} Output + * @property {string} buffer + * @property {Set} streams + * @property {RenderedDiffNode} [components] + * @property {Set} [onlyCids] + */ + const VOID_TAGS = new Set([ "area", "base", @@ -39,6 +60,13 @@ const VOID_TAGS = new Set([ ]) const endingTagNameChars = new Set([">", "/", " ", "\n", "\t", "\r"]) +/** + * Modify root html + * @param {string} html + * @param {{[key: string]: boolean|string}} attrs + * @param {boolean} [clearInnerHTML] - skip + * @returns {[newHTML: string, beforeTag: string, afterTag: string]} + */ export let modifyRoot = (html, attrs, clearInnerHTML) => { let i = 0 let insideComment = false @@ -94,8 +122,8 @@ export let modifyRoot = (html, attrs, clearInnerHTML) => { let attrsStr = Object.keys(attrs) - .map(attr => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`) - .join(" ") + .map(attr => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`) + .join(" ") if(clearInnerHTML){ if(VOID_TAGS.has(tag)){ @@ -111,7 +139,13 @@ export let modifyRoot = (html, attrs, clearInnerHTML) => { return [newHTML, beforeTag, afterTag] } + export default class Rendered { + /** + * Extract the important top-level elements from the diff + * @param {RenderedDiff} diff + * @returns {{diff: RenderedDiffNode, title?: string, reply: number|null, events: any[]}} + */ static extract(diff){ let {[REPLY]: reply, [EVENTS]: events, [TITLE]: title} = diff delete diff[REPLY] @@ -120,20 +154,45 @@ export default class Rendered { return {diff, title, reply: reply || null, events: events || []} } + /** + * Constructor - Handle the LiveView wire protocol render trees with their + * statics, dynamics, components, streams, and templates. + * + * @param {string} viewId + * @param {RenderedDiffNode} rendered + */ constructor(viewId, rendered){ this.viewId = viewId + /** @type {RenderedDiffNode} */ this.rendered = {} this.magicId = 0 this.mergeDiff(rendered) } + /** + * Get ID of view that created + * @returns {string} + */ parentViewId(){ return this.viewId } + /** + * @param {(string|number)[]} [onlyCids] + * @returns {[string, streams: Set]} + */ toString(onlyCids){ let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {}) return [str, streams] } + /** + * @private + * @param {RenderedDiffNode} rendered + * @param {RenderedDiffNode} [components] + * @param {(string|number)[]} onlyCids + * @param {boolean} changeTracking + * @param {{[key: string]: string}} [rootAttrs] + * @returns {[buffer: Output['buffer'], streams: Output['streams']]} + */ recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs){ onlyCids = onlyCids ? new Set(onlyCids) : null let output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()} @@ -141,15 +200,35 @@ export default class Rendered { return [output.buffer, output.streams] } + /** + * Extract component IDs from diff + * @param {{[key: string]: any}} diff + * @returns {number[]} list of all CIDs parsed to ints + */ componentCIDs(diff){ return Object.keys(diff[COMPONENTS] || {}).map(i => parseInt(i)) } + /** + * Does diff only contain COMPONENTS segment? + * @param {RenderedDiffNode} diff + * @returns {boolean} + */ isComponentOnlyDiff(diff){ if(!diff[COMPONENTS]){ return false } return Object.keys(diff).length === 1 } + /** + * Lookup COMPONENT node with CID from diff + * @param {RenderedDiffNode} diff + * @param {number|string} cid + * @returns {RenderedDiffNode} + */ getComponent(diff, cid){ return diff[COMPONENTS][cid] } + /** + * Merge given diff with + * @param {RenderedDiffNode} diff + */ mergeDiff(diff){ let newc = diff[COMPONENTS] let cache = {} @@ -169,6 +248,16 @@ export default class Rendered { } } + /** + * Lookup Component node + * @private + * @param {string|number} cid + * @param {RenderedDiffNode} cdiff + * @param {RenderedDiffNode} oldc + * @param {RenderedDiffNode} newc + * @param {{[key: string]: any}} cache + * @returns {RenderedDiffNode} + */ cachedFindComponent(cid, cdiff, oldc, newc, cache){ if(cache[cid]){ return cache[cid] @@ -197,6 +286,12 @@ export default class Rendered { } } + /** + * @private + * @param {object} target + * @param {object} source + * @returns {object} + */ mutableMerge(target, source){ if(source[STATIC] !== undefined){ return source @@ -206,6 +301,11 @@ export default class Rendered { } } + /** + * @private + * @param {object} target + * @param {object} source + */ doMutableMerge(target, source){ for(let key in source){ let val = source[key] @@ -222,14 +322,19 @@ export default class Rendered { } } - // Merges cid trees together, copying statics from source tree. - // - // The `pruneMagicId` is passed to control pruning the magicId of the - // target. We must always prune the magicId when we are sharing statics - // from another component. If not pruning, we replicate the logic from - // mutableMerge, where we set newRender to true if there is a root - // (effectively forcing the new version to be rendered instead of skipped) - // + /** + * Merges cid trees together, copying statics from source tree. + * + * The `pruneMagicId` is passed to control pruning the magicId of the + * target. We must always prune the magicId when we are sharing statics + * from another component. If not pruning, we replicate the logic from + * mutableMerge, where we set newRender to true if there is a root + * (effectively forcing the new version to be rendered instead of skipped) + * @param {RenderedDiffNode} target + * @param {RenderedDiffNode} source + * @param {boolean} pruneMagicId + * @returns {RenderedDiffNode} merged CID tree + */ cloneMerge(target, source, pruneMagicId){ let merged = {...target, ...source} for(let key in merged){ @@ -248,40 +353,77 @@ export default class Rendered { return merged } + /** + * Get stripped HTML for component with ID + * @param {string} cid + * @returns {[strippedHTML: string, streams: string | Set]} + */ componentToString(cid){ let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null) let [strippedHTML, _before, _after] = modifyRoot(str, {}) return [strippedHTML, streams] } + + pruneCIDs(cids){ cids.forEach(cid => delete this.rendered[COMPONENTS][cid]) } // private + /** + * Get the current diff tree + * @private + * @returns {RenderedDiffNode} + */ get(){ return this.rendered } + /** + * Is the diff a new fingerprint? + * @private + * @param {RenderedDiffNode} [diff] + * @returns {boolean} + */ isNewFingerprint(diff = {}){ return !!diff[STATIC] } + /** + * Template statics into + * @private + * @param {string[]|number} part + * @param {any} templates + * @returns {any} + */ templateStatic(part, templates){ - if(typeof (part) === "number") { + if(typeof (part) === "number"){ return templates[part] } else { return part } } + /** + * @private + * @returns {string} unique magic ID + */ nextMagicID(){ this.magicId++ return `${this.parentViewId()}-${this.magicId}` } - // Converts rendered tree to output buffer. - // - // changeTracking controls if we can apply the PHX_SKIP optimization. - // It is disabled for comprehensions since we must re-render the entire collection - // and no invidial element is tracked inside the comprehension. + /** + * Converts rendered tree to output buffer. + * + * changeTracking controls if we can apply the PHX_SKIP optimization. + * It is disabled for comprehensions since we must re-render the entire collection + * and no invidial element is tracked inside the comprehension. + * @private + * @param {RenderedDiffNode} rendered + * @param {{[key: string]: string[]}} [templates] + * @param {Output} output + * @param {boolean} changeTracking + * @param {{[key: string]: string}} [rootAttrs] + */ toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}){ if(rendered[DYNAMICS]){ return this.comprehensionToBuffer(rendered, templates, output) } let {[STATIC]: statics} = rendered @@ -321,6 +463,12 @@ export default class Rendered { } } + /** + * @private + * @param {RenderedDiffNode} rendered + * @param {{[key: string]: string[]}} [templates] + * @param {Output} output + */ comprehensionToBuffer(rendered, templates, output){ let {[DYNAMICS]: dynamics, [STATIC]: statics, [STREAM]: stream} = rendered let [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null] @@ -347,6 +495,13 @@ export default class Rendered { } } + /** + * @private + * @param {RenderedDiffNode} rendered + * @param {{[key: string]: string[]}} [templates] + * @param {Output} output + * @param {boolean} changeTracking + */ dynamicToBuffer(rendered, templates, output, changeTracking){ if(typeof (rendered) === "number"){ let [str, streams] = this.recursiveCIDToString(output.components, rendered, output.onlyCids) @@ -359,6 +514,13 @@ export default class Rendered { } } + /** + * @private + * @param {RenderedDiffNode} components + * @param {number|string} cid + * @param {Set} [onlyCids] + * @returns {[html: string, streams: Set]} + */ recursiveCIDToString(components, cid, onlyCids){ let component = components[cid] || logError(`no component for CID ${cid}`, components) let attrs = {[PHX_COMPONENT]: cid} @@ -383,4 +545,4 @@ export default class Rendered { return [html, streams] } -} \ No newline at end of file +} diff --git a/assets/js/phoenix_live_view/upload_entry.js b/assets/js/phoenix_live_view/upload_entry.js index c4b31ffe24..54bf12cbe7 100644 --- a/assets/js/phoenix_live_view/upload_entry.js +++ b/assets/js/phoenix_live_view/upload_entry.js @@ -1,3 +1,9 @@ +/** + * Module Type Dependencies: + * @typedef {import('./view.js').default} View + * @typedef {import('./live_socket.js').default} LiveSocket + */ + import { PHX_ACTIVE_ENTRY_REFS, PHX_LIVE_FILE_UPDATED, @@ -13,6 +19,12 @@ import LiveUploader from "./live_uploader" import DOM from "./dom" export default class UploadEntry { + /** + * Is the file for this input still being uploaded? + * @param {HTMLInputElement} fileEl + * @param {File} file + * @returns {boolean} + */ static isActive(fileEl, file){ let isNew = file._phxRef === undefined let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") @@ -20,29 +32,59 @@ export default class UploadEntry { return file.size > 0 && (isNew || isActive) } + /** + * Is the file for this input active and in preflight? + * @param {HTMLInputElement} fileEl + * @param {File} file + * @returns {boolean} + */ static isPreflighted(fileEl, file){ let preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(",") let isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0 return isPreflighted && this.isActive(fileEl, file) } + /** + * Constructor + * @param {HTMLInputElement} fileEl + * @param {File} file + * @param {View} view + */ constructor(fileEl, file, view){ + /** @readonly */ this.ref = LiveUploader.genFileRef(file) + this.fileEl = fileEl this.file = file this.view = view this.meta = null + + /** @private */ this._isCancelled = false + /** @private */ this._isDone = false + /** @private */ this._progress = 0 + /** @private */ this._lastProgressSent = -1 + /** @private */ this._onDone = function (){ } + /** @private */ this._onElUpdated = this.onElUpdated.bind(this) + this.fileEl.addEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated) } + /** + * Get metadata for this upload entry + * @returns {{uploader: LiveUploader}|null} + */ metadata(){ return this.meta } + /** + * Push file progress to the view + * @param {number} progress + */ progress(progress){ this._progress = Math.floor(progress) if(this._progress > this._lastProgressSent){ @@ -61,22 +103,35 @@ export default class UploadEntry { } } + /** + * Cancel the upload for this entry + */ cancel(){ this._isCancelled = true this._isDone = true this._onDone() } + /** + * @returns {boolean} + */ isDone(){ return this._isDone } + /** + * Mark upload as an error and update view file progress + * @param {string} [reason] + */ error(reason = "failed"){ this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated) this.view.pushFileProgress(this.fileEl, this.ref, {error: reason}) if(!DOM.isAutoUpload(this.fileEl)){ LiveUploader.clearFiles(this.fileEl) } } - //private - + /** + * Set a callback for when upload is done + * @private + * @param {() => void} callback + */ onDone(callback){ this._onDone = () => { this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated) @@ -84,11 +139,19 @@ export default class UploadEntry { } } + /** + * @private + */ onElUpdated(){ let activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") if(activeRefs.indexOf(this.ref) === -1){ this.cancel() } } + /** + * Generate the preflight payload data + * @private + * @returns + */ toPreflightPayload(){ return { last_modified: this.file.lastModified, @@ -101,6 +164,12 @@ export default class UploadEntry { } } + /** + * Lookup the uploader for this specific entry + * @private + * @param {{[key: string]: function}} uploaders + * @returns {{name: string, callback: function}} + */ uploader(uploaders){ if(this.meta.uploader){ let callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`) @@ -110,6 +179,11 @@ export default class UploadEntry { } } + /** + * Update metadata from response + * @private + * @param {{entries: {[key: string]: object}}} resp + */ zipPostFlight(resp){ this.meta = resp.entries[this.ref] if(!this.meta){ logError(`no preflight upload response returned with ref ${this.ref}`, {input: this.fileEl, response: resp}) } diff --git a/assets/js/phoenix_live_view/utils.js b/assets/js/phoenix_live_view/utils.js index 97e5faec14..86af14a2d4 100644 --- a/assets/js/phoenix_live_view/utils.js +++ b/assets/js/phoenix_live_view/utils.js @@ -1,16 +1,40 @@ +/** + * General utility functions. + * + * Module Type Dependencies: + * @typedef {import('./view.js').default} View + * @typedef {import('./live_socket.js').default} LiveSocket + * @typedef {import('./upload_entry').default} UploadEntry + */ + import { PHX_VIEW_SELECTOR } from "./constants" import EntryUploader from "./entry_uploader" + +/** + * Write string to browser error console + * @param {string} msg + * @param {object} obj + * @returns + */ export let logError = (msg, obj) => console.error && console.error(msg, obj) +/** + * Is the value a component ID? e.g. "0", "1", 32, "543", 8 + * @param {string | number} cid + * @returns {boolean} + */ export let isCid = (cid) => { let type = typeof(cid) return type === "number" || (type === "string" && /^(0|[1-9]\d*)$/.test(cid)) } +/** + * Check all Element IDs for any duplicates and log a warning if found. + */ export function detectDuplicateIds(){ let ids = new Set() let elems = document.querySelectorAll("*[id]") @@ -23,17 +47,42 @@ export function detectDuplicateIds(){ } } + +/** + * If debug is enabled, log debug messages. + * @param {View} view + * @param {string} kind + * @param {string} msg + * @param {object} obj + */ export let debug = (view, kind, msg, obj) => { if(view.liveSocket.isDebugEnabled()){ console.log(`${view.id} ${kind}: ${msg} - `, obj) } } -// wraps value in closure or returns closure +/** + * Wrap given value in closure or returns closure + * @param {any} val + * @returns {function} + */ export let closure = (val) => typeof val === "function" ? val : function (){ return val } +/** + * Deep-clone a given object. + * @template T + * @param {T} obj + * @returns {T} cloned obj + */ export let clone = (obj) => { return JSON.parse(JSON.stringify(obj)) } +/** + * Lookup the closest element with the given phoenix binding attribute + * @param {HTMLElement} el + * @param {string} binding + * @param {HTMLElement} [borderEl] + * @returns {HTMLElement | null} + */ export let closestPhxBinding = (el, binding, borderEl) => { do { if(el.matches(`[${binding}]`) && !el.disabled){ return el } @@ -42,19 +91,49 @@ export let closestPhxBinding = (el, binding, borderEl) => { return null } +/** + * Is the given value an object (but not an array)? + * @param {any} obj + * @returns {boolean} + */ export let isObject = (obj) => { return obj !== null && typeof obj === "object" && !(obj instanceof Array) } +/** + * Deep equality check + * @param {any} obj1 + * @param {any} obj2 + * @returns {boolean} + */ export let isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2) +/** + * Is object/array empty? + * @param {object|array} obj + * @returns {boolean} + */ export let isEmpty = (obj) => { for(let x in obj){ return false } return true } +/** + * If given value is truthy, call the callback with that value + * @template T, V + * @param {T} el + * @param {(el: T) => V} callback + * @returns {V} + */ export let maybe = (el, callback) => el && callback(el) +/** + * Create and run an uploader for all given upload entries + * @param {UploadEntry[]} entries + * @param {function} onError + * @param {object} resp + * @param {LiveSocket} liveSocket + */ export let channelUploader = function (entries, onError, resp, liveSocket){ entries.forEach(entry => { let entryUploader = new EntryUploader(entry, resp.config.chunk_size, liveSocket) diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index f93c632235..2f63c20b99 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -55,6 +55,15 @@ import Rendered from "./rendered" import ViewHook from "./view_hook" import JS from "./js" +/** @typedef {import('./rendered').RenderedDiff} RenderedDiff */ + +/** + * URL-safe encoding of data in the given form element + * @param {HTMLFormElement} form + * @param {{submitter: HTMLElement | null | undefined, _target: string}} metadata + * @param {string[]} onlyNames + * @returns {string} URL-encoded data + */ let serializeForm = (form, metadata, onlyNames = []) => { let {submitter, ...meta} = metadata @@ -88,11 +97,20 @@ let serializeForm = (form, metadata, onlyNames = []) => { } export default class View { + /** + * Constructor + * @param {HTMLElement} el + * @param {import('./live_socket').default} liveSocket + * @param {View|null} parentView + * @param {string|null} flash + * @param {string} liveReferer + */ constructor(el, liveSocket, parentView, flash, liveReferer){ this.isDead = false this.liveSocket = liveSocket this.flash = flash this.parent = parentView + /** @type {View} */ this.root = parentView ? parentView.root : this this.el = el this.id = this.el.id @@ -111,7 +129,10 @@ export default class View { this.pendingJoinOps = this.parent ? null : [] this.viewHooks = {} this.uploaders = {} + + /** @type {Array<[el: HTMLFormElement, ref: number, opts: object, cb: () => void]>} */ this.formSubmits = [] + /** @type {{[key: string]: View}|null} */ this.children = this.parent ? null : {} this.root.children[this.id] = {} this.channel = this.liveSocket.channel(`lv:${this.id}`, () => { @@ -127,15 +148,32 @@ export default class View { }) } + /** + * Setter for href + * @param {string} href + */ setHref(href){ this.href = href } + /** + * Set href and enable redirect + * @param {string} href + */ setRedirect(href){ this.redirect = true this.href = href } + /** + * Is this view/element the main liveview container + * @returns {boolean} + */ isMain(){ return this.el.hasAttribute(PHX_MAIN) } + /** + * Set socket connection params + * @param {string} liveReferer + * @returns {object} + */ connectParams(liveReferer){ let params = this.liveSocket.params(this.el) let manifest = @@ -149,15 +187,31 @@ export default class View { return params } + /** + * Is the socket channel connected? + * @returns {boolean} + */ isConnected(){ return this.channel.canPush() } + /** + * Lookup the phoenix session + * @returns {string} + */ getSession(){ return this.el.getAttribute(PHX_SESSION) } + /** + * Lookup the phoenix static information + * @returns {string|null} + */ getStatic(){ let val = this.el.getAttribute(PHX_STATIC) return val === "" ? null : val } + /** + * Destroy view, all child views, and hooks + * @param {() => void} [callback] - will execute callback on completion + */ destroy(callback = function (){ }){ this.destroyAllChildren() this.destroyed = true @@ -180,6 +234,11 @@ export default class View { .receive("timeout", onFinished) } + /** + * Add CSS classes to the container element. + * NOTE: Will remove liveview-managed transient classes for errors, loading, etc. + * @param {...string} classes + */ setContainerClasses(...classes){ this.el.classList.remove( PHX_CONNECTED_CLASS, @@ -191,6 +250,10 @@ export default class View { this.el.classList.add(...classes) } + /** + * Set loading classes and call disconnect on hooks + * @param {number} [timeout] + */ showLoader(timeout){ clearTimeout(this.loaderTimer) if(timeout){ @@ -201,28 +264,54 @@ export default class View { } } + /** + * Run execJS for associated binding + * @param {string} binding + */ execAll(binding){ DOM.all(this.el, `[${binding}]`, el => this.liveSocket.execJS(el, el.getAttribute(binding))) } + /** + * Remove the loader and transition to connected + */ hideLoader(){ clearTimeout(this.loaderTimer) this.setContainerClasses(PHX_CONNECTED_CLASS) this.execAll(this.binding("connected")) } + /** + * Call reconnected callback for viewHooks + */ triggerReconnected(){ for(let id in this.viewHooks){ this.viewHooks[id].__reconnected() } } + /** + * Log over the LiveSocket + * @param {string} kind + * @param {() => [msg: string, obj: any]} msgCallback + */ log(kind, msgCallback){ this.liveSocket.log(this, kind, msgCallback) } + /** + * Add a managed transition + * @param {number} time + * @param {() => void} onStart + * @param {() => void} [onDone] + */ transition(time, onStart, onDone = function(){}){ this.liveSocket.transition(time, onStart, onDone) } + /** + * Execute callback within the context of the view owning the target + * @param {Element|string|number} phxTarget + * @param {(view: View, targetCtx: number|Element) => void} callback + */ withinTargets(phxTarget, callback){ if(phxTarget instanceof HTMLElement || phxTarget instanceof SVGElement){ return this.liveSocket.owner(phxTarget, view => callback(view, phxTarget)) @@ -242,6 +331,12 @@ export default class View { } } + /** + * Apply the given raw diff + * @param {"mount"|"update"} type + * @param {RenderedDiff} rawDiff - The LiveView diff wire protocol + * @param {({diff: RenderedDiff, reply: number|null, events: any[]}) => void} callback + */ applyDiff(type, rawDiff, callback){ this.log(type, () => ["", clone(rawDiff)]) let {diff, reply, events, title} = Rendered.extract(rawDiff) @@ -249,6 +344,10 @@ export default class View { if(title){ window.requestAnimationFrame(() => DOM.putTitle(title)) } } + /** + * Handle successful channel join + * @param {{rendered: RenderedDiff, container?: [string, object]}} resp + */ onJoin(resp){ let {rendered, container} = resp if(container){ @@ -268,6 +367,7 @@ export default class View { this.joinCount++ if(forms.length > 0){ + // eslint-disable-next-line no-unused-vars forms.forEach(([form, newForm, newCid], i) => { this.pushFormRecovery(form, newCid, resp => { if(i === forms.length - 1){ @@ -281,6 +381,9 @@ export default class View { }) } + /** + * Remove all pending element ref attrs on page + */ dropPendingRefs(){ DOM.all(document, `[${PHX_REF_SRC}="${this.id}"][${PHX_REF}]`, el => { el.removeAttribute(PHX_REF) @@ -288,6 +391,14 @@ export default class View { }) } + /** + * Handle successful join result + * @param {object} resp + * @param {any} resp.live_patch + * @param {string} html + * @param {any[]} streams + * @param {any[]} events + */ onJoinComplete({live_patch}, html, streams, events){ // In order to provide a better experience, we want to join // all LiveViews first and only then apply their patches. @@ -319,11 +430,17 @@ export default class View { } } + /** + * Lookup element with ID and set on self + */ attachTrueDocEl(){ this.el = DOM.byId(this.id) this.el.setAttribute(PHX_ROOT_ID, this.root.id) } + /** + * Add and mount a hook if appropriate + */ execNewMounted(){ let phxViewportTop = this.binding(PHX_VIEWPORT_TOP) let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) @@ -337,6 +454,13 @@ export default class View { DOM.all(this.el, `[${this.binding(PHX_MOUNTED)}]`, el => this.maybeMounted(el)) } + /** + * Apply initial patch after socket join completes + * @param {{kind: "push"|"replace", to: string}} [live_patch] + * @param {string} html + * @param {Stream[]} streams + * @param {any[]} events + */ applyJoinPatch(live_patch, html, streams, events){ this.attachTrueDocEl() let patch = new DOMPatch(this, this.el, this.id, html, streams, null) @@ -358,6 +482,12 @@ export default class View { this.stopCallback() } + /** + * Trigger beforeUpdate hook lifecycle + * @param {Element} fromEl + * @param {Element} toEl + * @returns {ViewHook|undefined} if hook found for fromEl, return it + */ triggerBeforeUpdateHook(fromEl, toEl){ this.liveSocket.triggerDOM("onBeforeElUpdated", [fromEl, toEl]) let hook = this.getHook(fromEl) @@ -368,6 +498,10 @@ export default class View { } } + /** + * Maybe run mount JS + * @param {Element} el + */ maybeMounted(el){ let phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)) let hasBeenInvoked = phxMounted && DOM.private(el, "mounted") @@ -377,11 +511,22 @@ export default class View { } } + /** + * @param {Element} el + * @param {boolean} [force] + */ + // eslint-disable-next-line no-unused-vars maybeAddNewHook(el, force){ let newHook = this.addHook(el) if(newHook){ newHook.__mounted() } } + /** + * Perform DOM patch + * @param {DOMPatch} patch + * @param {boolean} pruneCids - prune components associated in patch? + * @returns {boolean} were children added? + */ performPatch(patch, pruneCids){ let removedEls = [] let phxChildrenAdded = false @@ -421,6 +566,11 @@ export default class View { return phxChildrenAdded } + /** + * Remove elements and destroy their components and hooks if applicable + * @param {Element[]} elements + * @param {boolean} pruneCids + */ afterElementsRemoved(elements, pruneCids){ let destroyedCIDs = [] elements.forEach(parent => { @@ -443,12 +593,25 @@ export default class View { } } + /** + * Call jioinChild + */ joinNewChildren(){ DOM.findPhxChildren(this.el, this.id).forEach(el => this.joinChild(el)) } + /** + * Get child views by ID + * @param {string} id + * @returns {View|undefined} + */ getChildById(id){ return this.root.children[this.id][id] } + /** + * Lookup a child view (or self) by element + * @param {HTMLElement} el + * @returns {View} + */ getDescendentByEl(el){ if(el.id === this.id){ return this @@ -457,6 +620,10 @@ export default class View { } } + /** + * Destroy child view matching ID + * @param {string} id + */ destroyDescendent(id){ for(let parentId in this.root.children){ for(let childId in this.root.children[parentId]){ @@ -465,6 +632,11 @@ export default class View { } } + /** + * Looks up a child + * @param {HTMLElement} el + * @returns {boolean} true if child did + */ joinChild(el){ let child = this.getChildById(el.id) if(!child){ @@ -529,6 +701,12 @@ export default class View { if(phxChildrenAdded){ this.joinNewChildren() } } + /** + * + * @param {*} diff + * @param {string} kind + * @returns {[string, object]} + */ renderContainer(diff, kind){ return this.liveSocket.time(`toString diff (${kind})`, () => { let tag = this.el.tagName @@ -550,6 +728,11 @@ export default class View { getHook(el){ return this.viewHooks[ViewHook.elementID(el)] } + /** + * Add a ViewHook for this element if one was specified + * @param {Element} el + * @returns {ViewHook|undefind} + */ addHook(el){ if(ViewHook.elementID(el) || !el.getAttribute){ return } let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)) @@ -566,23 +749,39 @@ export default class View { } } + /** + * Call ViewHook teardown functions and delete from view + * @param {ViewHook} hook + */ destroyHook(hook){ hook.__destroyed() hook.__cleanup__() delete this.viewHooks[ViewHook.elementID(hook.el)] } + /** + * Apply pending diff updates for self and each child + */ applyPendingUpdates(){ this.pendingDiffs.forEach(({diff, events}) => this.update(diff, events)) this.pendingDiffs = [] this.eachChild(child => child.applyPendingUpdates()) } + /** + * Run callback for each child view + * @param {(view: View|undefined) => void} callback + */ eachChild(callback){ let children = this.root.children[this.id] || {} for(let id in children){ callback(this.getChildById(id)) } } + /** + * Register callback for events on channel + * @param {string} event - events to listen for on channel + * @param {(resp: any) => void} cb - callback to execute on each channel response + */ onChannel(event, cb){ this.liveSocket.onChannel(this.channel, event, resp => { if(this.isJoinPending()){ @@ -593,6 +792,9 @@ export default class View { }) } + /** + * Bind handlers to important liveview events + */ bindChannel(){ // The diff event should be handled by the regular update operations. // All other operations are queued to be applied only after join. @@ -608,30 +810,61 @@ export default class View { this.channel.onClose(reason => this.onClose(reason)) } + /** + * Destroy all child nodes + */ destroyAllChildren(){ this.eachChild(child => child.destroy()) } + /** + * Handle live redirect navigation + * @param {{to: string, kind: ("push"|"replace"), flash: string|null}} redir + */ onLiveRedirect(redir){ let {to, kind, flash} = redir let url = this.expandURL(to) this.liveSocket.historyRedirect(url, kind, flash) } + /** + * Handle live patch navigation + * @param {{to: string, kind: ("push"|"replace")}} redir + */ onLivePatch(redir){ let {to, kind} = redir this.href = this.expandURL(to) this.liveSocket.historyPatch(to, kind) } + /** + * Ensure relative URLs are expanded to full URLs + * @param {string} to + * @returns {string} + */ expandURL(to){ return to.startsWith("/") ? `${window.location.protocol}//${window.location.host}${to}` : to } + /** + * Handle full browser redirect events (not Live redirects) + * @param {{to: string, flash: string|null}} resp + */ onRedirect({to, flash}){ this.liveSocket.redirect(to, flash) } + /** + * Is this view destroyed? + * @returns {boolean} + */ isDestroyed(){ return this.destroyed } + /** + * Mark this view dead + */ joinDead(){ this.isDead = true } + /** + * Join the websocket channel + * @param {(joinCount: number, onDone: () => void) => void} [callback] + */ join(callback){ this.showLoader(this.liveSocket.loaderTimeout) this.bindChannel() @@ -654,6 +887,10 @@ export default class View { }) } + /** + * Handle failed channel join + * @param {{reason?: string, redirect?: string, live_redirect?: object}} resp + */ onJoinError(resp){ if(resp.reason === "reload"){ this.log("error", () => [`failed mount with ${resp.status}. Falling back to page request`, resp]) @@ -675,6 +912,10 @@ export default class View { if(this.liveSocket.isConnected()){ this.liveSocket.reloadWithJitter(this) } } + /** + * Callback to clean-up resources on close + * @param {string} reason + */ onClose(reason){ if(this.isDestroyed()){ return } if(this.liveSocket.hasPendingLink() && reason !== "leave"){ @@ -689,6 +930,10 @@ export default class View { } } + /** + * Callback to clean-up resources on error and display error message + * @param {string} reason + */ onError(reason){ this.onClose(reason) if(this.liveSocket.isConnected()){ this.log("error", () => ["view crashed", reason]) } @@ -701,6 +946,10 @@ export default class View { } } + /** + * Dispatch events for error state and disconnected bindings and update page styles + * @param {string[]} classes - CSS classes to set on the container + */ displayError(classes){ if(this.isMain()){ DOM.dispatchEvent(window, "phx:page-loading-start", {detail: {to: this.href, kind: "error"}}) } this.showLoader() @@ -708,6 +957,13 @@ export default class View { this.execAll(this.binding("disconnected")) } + /** + * Push event with payload over channel + * @param {() => [number|null, HTMLElement[], object]} refGenerator + * @param {string} event + * @param {object} payload + * @param {(resp: any, hookReply: any) => void} [onReply] + */ pushWithReply(refGenerator, event, payload, onReply = function (){ }){ if(!this.isConnected()){ return } @@ -745,6 +1001,10 @@ export default class View { ) } + /** + * Unset ref attrs from all child elements with matching ref set + * @param {number} ref + */ undoRefs(ref){ if(!this.isConnected()){ return } // exit if external form triggered @@ -780,6 +1040,13 @@ export default class View { }) } + /** + * Put ref as attribute on elements + * @param {HTMLElement[]} elements + * @param {string} event + * @param {{loading?: boolean}} opts + * @returns {[newRef: number, elements: HTMLElement[], opts: object]} + */ putRef(elements, event, opts = {}){ let newRef = this.ref++ let disableWith = this.binding(PHX_DISABLE_WITH) @@ -801,11 +1068,23 @@ export default class View { return [newRef, elements, opts] } + /** + * Get component ID from element if it is set + * @param {HTMLElement} el + * @returns {number|null} + */ componentID(el){ let cid = el.getAttribute && el.getAttribute(PHX_COMPONENT) return cid ? parseInt(cid) : null } + /** + * Find component ID of target + * @param {HTMLElement} target + * @param {string|number|HTMLElement} targetCtx + * @param {{target?: number|string}} opts + * @returns {number|string} + */ targetComponentID(target, targetCtx, opts = {}){ if(isCid(targetCtx)){ return targetCtx } @@ -819,6 +1098,11 @@ export default class View { } } + /** + * Find closest component ID to the target: self or closest parent + * @param {number|string|HTMLElement} targetCtx + * @returns {number|string|null} + */ closestComponentID(targetCtx){ if(isCid(targetCtx)){ return targetCtx @@ -829,6 +1113,15 @@ export default class View { } } + /** + * Push hook event and handle reply + * @param {HTMLElement} el + * @param {number|string|HTMLElement} targetCtx + * @param {string} event + * @param {object} payload + * @param {(reply: any, ref: number) => void} onReply + * @returns {number} ref + */ pushHookEvent(el, targetCtx, event, payload, onReply){ if(!this.isConnected()){ this.log("hook", () => ["unable to push hook event. LiveView not connected", event, payload]) @@ -845,6 +1138,14 @@ export default class View { return ref } + /** + * Extract metadata from element attributes + * @template {{[key: string]: any}} T + * @param {HTMLElement} el - element to check attrs for metadata + * @param {T} [meta] - initial meta object to mutate by adding data from el attrs + * @param {T} [value] - copy value properties to meta object + * @returns {T} meta + */ extractMeta(el, meta, value){ let prefix = this.binding("value-") for(let i = 0; i < el.attributes.length; i++){ @@ -868,6 +1169,16 @@ export default class View { } + /** + * Push event and optionally handle reply + * @param {string} type + * @param {HTMLElement} el + * @param {string|number|HTMLElement} targetCtx + * @param {string} phxEvent + * @param {{[key: string]: anu}} meta + * @param {{loading?: boolean, value?: object, target?: string | number}} opts + * @param {(reply: any) => void} [onReply] + */ pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply){ this.pushWithReply(() => this.putRef([el], type, opts), "event", { type: type, @@ -877,6 +1188,13 @@ export default class View { }, (resp, reply) => onReply && onReply(reply)) } + /** + * Push file progress event and optionally handle reply + * @param {HTMLInputElement} fileEl + * @param {string} entryRef + * @param {number|{error: string}} progress + * @param {(resp: any, reply: any) => void} [onReply] + */ pushFileProgress(fileEl, entryRef, progress, onReply = function (){ }){ this.liveSocket.withinOwners(fileEl.form, (view, targetCtx) => { view.pushWithReply(null, "progress", { @@ -889,6 +1207,19 @@ export default class View { }) } + /** + * Push form input data over channel + * @param {HTMLElement} inputEl + * @param {string|number|HTMLElement} targetCtx + * @param {string|number|null} forceCid + * @param {string} phxEvent + * @param {object} opts + * @param {boolean} [opts.loading] + * @param {HTMLElement | null} [opts.submitter] + * @param {string|number} [opts.target] + * @param {string} [opts._target] + * @param {(resp: any) => void} [callback] + */ pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback){ let uploads let cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts) @@ -928,6 +1259,10 @@ export default class View { }) } + /** + * Lookup and execute any previously scheduled submission callbacks for this form + * @param {HTMLFormElement} formEl + */ triggerAwaitingSubmit(formEl){ let awaitingSubmit = this.getScheduledSubmit(formEl) if(awaitingSubmit){ @@ -937,15 +1272,32 @@ export default class View { } } + /** + * Lookup any previously scheduled form submission + * @param {HTMLFormElement} formEl + * @returns {[el: HTMLElement, ref: number, opts: any, cb: () => void] | undefined} + */ getScheduledSubmit(formEl){ return this.formSubmits.find(([el, _ref, _opts, _callback]) => el.isSameNode(formEl)) } + /** + * Schedule a form submission callback for future execution (only schedules once per element) + * @param {HTMLFormElement} formEl + * @param {number} ref + * @param {object} opts + * @param {() => void} [callback] + * @returns {boolean} True if form already scheduled to submit + */ scheduleSubmit(formEl, ref, opts, callback){ if(this.getScheduledSubmit(formEl)){ return true } this.formSubmits.push([formEl, ref, opts, callback]) } + /** + * Cancel a form's scheduled submission callback + * @param {HTMLFormElement} formEl + */ cancelSubmit(formEl){ this.formSubmits = this.formSubmits.filter(([el, ref, _callback]) => { if(el.isSameNode(formEl)){ @@ -957,6 +1309,13 @@ export default class View { }) } + /** + * Disable form elements + * @param {HTMLFormElement} formEl + * @param {object} opts + * @param {boolean} [opts.loading] + * @returns {[newRef: number, elements: HTMLElement[], opts: any]} + */ disableForm(formEl, opts = {}){ let filterIgnored = el => { let userIgnored = closestPhxBinding(el, `${this.binding(PHX_UPDATE)}=ignore`, el.form) @@ -990,6 +1349,16 @@ export default class View { return this.putRef([formEl].concat(disables).concat(buttons).concat(inputs), "submit", opts) } + /** + * Push for submission data; will wait for any file uploads to finish + * @param {HTMLFormElement} formEl + * @param {string|number|HTMLElement} targetCtx + * @param {string} phxEvent + * @param {HTMLElement | null | undefined} submitter + * @param {object} opts + * @param {function} [onReply] + * @returns {boolean} + */ pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply){ let refGenerator = () => this.disableForm(formEl, opts) let cid = this.targetComponentID(formEl, targetCtx) @@ -1022,6 +1391,14 @@ export default class View { } } + /** + * Perform all file uploads for inputs in this form + * @param {HTMLFormElement} formEl + * @param {string|number|HTMLElement} targetCtx + * @param {number} ref + * @param {string|number} cid + * @param {() => void} onComplete + */ uploadFiles(formEl, targetCtx, ref, cid, onComplete){ let joinCountAtUpload = this.joinCount let inputEls = LiveUploader.activeFileInputs(formEl) @@ -1032,7 +1409,7 @@ export default class View { let uploader = new LiveUploader(inputEl, this, () => { numFileInputsInProgress-- if(numFileInputsInProgress === 0){ onComplete() } - }); + }) this.uploaders[inputEl] = uploader let entries = uploader.entries().map(entry => entry.toPreflightPayload()) @@ -1063,26 +1440,44 @@ export default class View { }) } + /** + * Dispatch custom upload event to file input matching name + * @param {string|number|HTMLElement} targetCtx + * @param {string} name + * @param {(File|Blob)[]} filesOrBlobs + */ dispatchUploads(targetCtx, name, filesOrBlobs){ - let targetElement = this.targetCtxElement(targetCtx) || this.el; + let targetElement = this.targetCtxElement(targetCtx) || this.el let inputs = DOM.findUploadInputs(targetElement).filter(el => el.name === name) if(inputs.length === 0){ logError(`no live file inputs found matching the name "${name}"`) } else if(inputs.length > 1){ logError(`duplicate live file inputs found matching the name "${name}"`) } else { DOM.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, {detail: {files: filesOrBlobs}}) } } - targetCtxElement(targetCtx) { + /** + * Get element for target element + * @param {string|number|HTMLElement} targetCtx + * @returns {HTMLElement|null} + */ + targetCtxElement(targetCtx){ if(isCid(targetCtx)){ let [target] = DOM.findComponentNodeList(this.el, targetCtx) return target - } else if(targetCtx) { + } else if(targetCtx){ return targetCtx } else { return null } } + /** + * Push form recovery + * @param {HTMLFormElement} form + * @param {string|number} newCid + * @param {function} callback + */ pushFormRecovery(form, newCid, callback){ + // eslint-disable-next-line no-unused-vars this.liveSocket.withinOwners(form, (view, targetCtx) => { let phxChange = this.binding("change") let inputs = Array.from(form.elements).filter(el => DOM.isFormInput(el) && el.name && !el.hasAttribute(phxChange)) @@ -1097,6 +1492,12 @@ export default class View { }) } + /** + * Push live patch for link + * @param {string} href + * @param {HTMLElement} targetEl + * @param {(linkRef: number) => void} callback + */ pushLinkPatch(href, targetEl, callback){ let linkRef = this.liveSocket.setPendingLink(href) let refGen = targetEl ? () => this.putRef([targetEl], "click") : null @@ -1124,6 +1525,11 @@ export default class View { } } + /** + * Select forms for recovery within the element bound to this view + * @param {string} html + * @returns {HTMLFormElement[]} + */ formsForRecovery(html){ if(this.joinCount === 0){ return [] } @@ -1139,7 +1545,7 @@ export default class View { .map(form => { // attribute given via JS module needs to be escaped as it contains the symbols []", // which result in an invalid css selector otherwise. - const phxChangeValue = form.getAttribute(phxChange).replaceAll(/([\[\]"])/g, '\\$1') + const phxChangeValue = form.getAttribute(phxChange).replaceAll(/([\[\]"])/g, "\\$1") let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${phxChangeValue}"]`) if(newForm){ return [form, newForm, this.targetComponentID(newForm)] @@ -1147,10 +1553,16 @@ export default class View { return [form, form, this.targetComponentID(form)] } }) + // eslint-disable-next-line no-unused-vars .filter(([form, newForm, newCid]) => newForm) ) } + /** + * Find child components from given component ID list and push the collection of found IDs + * @param {(string|number)[]} destroyedCIDs + * @returns {boolean|undefined} + */ maybePushComponentsDestroyed(destroyedCIDs){ let willDestroyCIDs = destroyedCIDs.filter(cid => { return DOM.findComponentNodeList(this.el, cid).length === 0 @@ -1178,6 +1590,11 @@ export default class View { } } + /** + * Does this view own the given element? + * @param {Element} el + * @returns {boolean} + */ ownsElement(el){ let parentViewEl = el.closest(PHX_VIEW_SELECTOR) return el.getAttribute(PHX_PARENT_ID) === this.id || @@ -1185,6 +1602,19 @@ export default class View { (!parentViewEl && this.isDead) } + /** + * Submit the data for this form element over livesocket + * @param {HTMLFormElement} form + * @param {string|number|Element} targetCtx + * @param {string} phxEvent + * @param {HTMLElement|null|undefined} submitter + * @param {object} [opts] + * @param {boolean} [opts.loading] + * @param {boolean} [opts.page_loading] + * @param {any} [opts.value] + * @param {string|Element} [opts.target] + * @param {string} [opts._target] + */ submitForm(form, targetCtx, phxEvent, submitter, opts = {}){ DOM.putPrivate(form, PHX_HAS_SUBMITTED, true) let phxFeedback = this.liveSocket.binding(PHX_FEEDBACK_FOR) @@ -1197,5 +1627,10 @@ export default class View { }) } + /** + * Get the binding matching string + * @param {string} kind + * @returns {string} + */ binding(kind){ return this.liveSocket.binding(kind) } } diff --git a/assets/js/phoenix_live_view/view_hook.js b/assets/js/phoenix_live_view/view_hook.js index b939b8a007..759cfc81e2 100644 --- a/assets/js/phoenix_live_view/view_hook.js +++ b/assets/js/phoenix_live_view/view_hook.js @@ -1,8 +1,40 @@ +/** + * @typedef {object} HookCallbacks - The type interface of user-defined hook callback objects + * @property {(this: ViewHook) => void} [mounted] - Called when the element has been added to the DOM and its server LiveView has finished mounting. + * @property {(this: ViewHook) => void} [destroyed] - Called when element has been removed from the page (either parent update or parent removal). + * @property {(this: ViewHook) => void} [beforeDestroy] - Called when the element is about to be removed from the DOM. + * @property {(this: ViewHook) => void} [updated] - Called when the element has been updated in the DOM. + * @property {(this: ViewHook) => void} [beforeUpdate] - Called when the element is about to be updated in the DOM. + * @property {(this: ViewHook) => void} [disconnected] - Called when the element's parent view has disconnected from the server. + * @property {(this: ViewHook) => void} [reconnected] - Called when the element's parent view has reconnected to the server. + */ + let viewHookID = 1 + export default class ViewHook { + /** + * Create a hook ID + * @returns {number} ID unique to this browser tab + */ static makeID(){ return viewHookID++ } + + /** + * Get ID for the hook bound to this element + * @param {Element} el + * @returns {number|undefined} + */ static elementID(el){ return el.phxHookId } + /** + * Constructor - Wraps user-defined hook callbacks with a consistent interface. + * + * The callbacks will run with the scope of an instance of this class: thus, + * all methods and attributes on ViewHook will be availble to the user-defined + * callbacks. + * @param {import('./view.js').default} view + * @param {Element} el - attribute referencing the bound DOM node + * @param {HookCallbacks} callbacks + */ constructor(view, el, callbacks){ this.__view = view this.liveSocket = view.liveSocket @@ -14,31 +46,91 @@ export default class ViewHook { for(let key in this.__callbacks){ this[key] = this.__callbacks[key] } } + /** + * Call the user-provided mounted() callback, if defined + * @public + */ __mounted(){ this.mounted && this.mounted() } + + /** + * Call the user-provided updated() callback, if defined + * @public + */ __updated(){ this.updated && this.updated() } + + /** + * Call the user-provided beforeUpdate() callback, if defined + * @public + */ __beforeUpdate(){ this.beforeUpdate && this.beforeUpdate() } + + /** + * Call the user-provided destroyed() callback, if defined + * @public + */ __destroyed(){ this.destroyed && this.destroyed() } + + /** + * Call the user-provided reconnected() callback, if defined + * @public + */ __reconnected(){ if(this.__isDisconnected){ this.__isDisconnected = false this.reconnected && this.reconnected() } } + + /** + * Call the user-provided disconnected() callback, if defined + * @public + */ __disconnected(){ this.__isDisconnected = true this.disconnected && this.disconnected() } + /** + * Push an event to the server + * @public + * @param {string} event + * @param {any} payload + * @param {(reply: any, ref: number) => void} [onReply] + * @returns {number} ref + */ pushEvent(event, payload = {}, onReply = function (){ }){ return this.__view.pushHookEvent(this.el, null, event, payload, onReply) } + /** + * Push targeted events from the client to LiveViews and LiveComponents. It + * sends the event to the LiveComponent or LiveView the phxTarget is defined + * in, where its value can be either a query selector or an actual DOM + * element. + * + * NOTE: If the query selector returns more than one element it will send the + * event to all of them, even if all the elements are in the same + * LiveComponent or LiveView. + * @public + * @param {string|Element} phxTarget + * @param {string} event + * @param {any} payload + * @param {(reply: any, ref: number) => void} [onReply] + * @returns + */ pushEventTo(phxTarget, event, payload = {}, onReply = function (){ }){ return this.__view.withinTargets(phxTarget, (view, targetCtx) => { return view.pushHookEvent(this.el, targetCtx, event, payload, onReply) }) } + /** + * Register callback to handle phoenix events + * @public + * @param {string} event + * @param {(payload: any) => void} callback - receives as payload the CustomEvent's detail + * @returns {(payload: any, bypass?: boolean) => string|void} the registered callback reference; can be used to remove the event + */ handleEvent(event, callback){ let callbackRef = (customEvent, bypass) => bypass ? event : callback(customEvent.detail) window.addEventListener(`phx:${event}`, callbackRef) @@ -46,22 +138,52 @@ export default class ViewHook { return callbackRef } + /** + * Remove a registered phoenix event callback + * @public + * @param {(payload: any, bypass?: boolean) => string|void} callbackRef + */ removeHandleEvent(callbackRef){ let event = callbackRef(null, true) window.removeEventListener(`phx:${event}`, callbackRef) this.__listeners.delete(callbackRef) } + /** + * Inject a list of file-like objects into the uploader. + * @public + * @param {string} name + * @param {File[]} files + */ upload(name, files){ return this.__view.dispatchUploads(null, name, files) } + /** + * Inject a list of file-like objects into an uploader. The hook + * will send the files to the uploader with name defined by allow_upload/3 on + * the server-side. Dispatching new uploads triggers an input change event + * which will be sent to the LiveComponent or LiveView the phxTarget is + * defined in, where its value can be either a query selector or an actual DOM + * element. + * + * NOTE: If the query selector returns more than one live file input, an + * error will be logged. + * @public + * @param {string|Element} phxTarget + * @param {string} name + * @param {File[]} files + */ uploadTo(phxTarget, name, files){ return this.__view.withinTargets(phxTarget, (view, targetCtx) => { view.dispatchUploads(targetCtx, name, files) }) } + /** + * Teardown resources for hook and callbacks + * @public + */ __cleanup__(){ this.__listeners.forEach(callbackRef => this.removeHandleEvent(callbackRef)) }