UNPKG

@sbrew.com/atv

Version:

Functional JS framework for Rails

695 lines (630 loc) 21.2 kB
/*global console, document, MutationObserver */ /*jslint white*/ // // ATV.js: Actions, Targets, Values // // Super vanilla JS / Lightweight alternative to stimulus without "class" // overhead and binding nonsense, just the actions, targets, and values // Also more forgiving with hyphens and underscores. // The MIT License (MIT) // Copyright (c) 2024 Timothy Breitkreutz // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. const version = "0.1.10"; // To dynamically load up the ATV javascripts if needed function importMap() { return JSON.parse( document.querySelector("script[type='importmap']").innerText ).imports; } /* ----------------- HELPER FUNCTIONS ------------------ */ /* Variant in the context of ATV means either dash-case or snake_case */ const variantPattern = /[\-_]/; function pascalize(string) { return string .split(variantPattern) .map((str) => str.charAt(0).toUpperCase() + str.slice(1)) .join(""); } function dasherize(string) { return string.replace(/_/g, "-"); } const deCommaPattern = /,[\s+]/; /* The following methods returns combinations of the given list * connected with dashes and underscores. For many examples see * test/system/unit_test.rb */ function allVariants(...words) { const parts = words.filter((arg) => Boolean(arg)); if (parts.length === 0) { return []; } return dashVariants(...parts).flatMap((string) => [string, `${string}s`]); } function dashVariants(firstWord, ...words) { function dashOrUnderscore(input) { const string = input || ""; if (variantPattern.test(string)) { return [dasherize(string), string.replace(/-/g, "_")]; } return [string]; } const first = dashOrUnderscore(firstWord); if (words.length < 1) { return first; } return dashVariants(...words).flatMap((str2) => first.flatMap((str1) => [`${str1}-${str2}`, `${str1}_${str2}`]) ); } /* Returns a list of selectors to use for given name list */ function variantSelectors(container, ...words) { return allVariants("data", ...words).flatMap((variant) => Array.from(container.querySelectorAll(`[${variant}]`)).map((element) => [ element, variant ]) ); } /* JSON is parsed aggressively and relies on "try" */ function errorReport(ex) { if (ex?.message?.includes("JSON")) { return; } console.error(`ATV: ${ex}`); } /* * Gets all action declarations for an element, returns an array of structures. */ function actionsFor(prefix, element, onlyEvent = null) { let result = []; const paramSeparator = /[()]/; // Parse a single action part, controller is passed in // if derived from the attribute key function parseAction(action, controller) { let method; let parameters = []; let [event, rightSide] = action.split(/[\s]*[\-=]>[\s]*/); // Figure out what to do with the part on the right of the "=> blah#blah" if (rightSide) { let [innerController, methodCall] = rightSide.split("#"); if (methodCall) { controller = innerController; } else { methodCall = innerController; } method = methodCall; } else { method = event; } const [methodName, params] = method.split(paramSeparator); if (params) { method = methodName; parameters = params.split(deCommaPattern); try { parameters = JSON.parse(`[${params}]`); } catch (ex) { errorReport(ex); } event = event.split("(")[0]; } // Sometimes we only care about a given event name passed in. if (onlyEvent && event !== onlyEvent) { return; } if (!controller) { controller = method; method = event; } result.push({ controller: dasherize(controller), event, method, parameters }); } // Split on commas as long as they are not inside parameter lists. function actionSplit(string, callback) { let paramDepth = 0; let accumulator = []; Array.from(string).forEach(function (char) { if (char === "," && paramDepth === 0) { callback(accumulator.join("").trim()); accumulator = []; return; } accumulator.push(char); if (char === "(") { paramDepth += 1; } else if (char === ")") { paramDepth -= 1; } }); const last = accumulator.join("").trim(); if (paramDepth !== 0) { console.error(`badly formed parameters: ${string}`); } if (last) { callback(last); } } element.getAttributeNames().forEach(function (name) { const unqualified = new RegExp(`^data[_-]${prefix}[_-]?action[s]?`); const qualified = new RegExp( `^data[_-]${prefix}[_-]?(.*[^-_])[_-]?action[s]?` ); let controller; let matched = name.match(qualified); if (matched) { controller = matched[1]; } else { controller = null; matched = name.match(unqualified); } if (!matched) { return; } let list = element.getAttribute(name); if (list) { try { list = JSON.parse(list).join(","); } catch (ex) { errorReport(ex); } actionSplit(list, (action) => parseAction(action, controller)); } }); return result; } function attributeKeysFor(element, type) { if (!element || !element.getAttributeNames) { return []; } const regex = new RegExp(`${type}s?$`, "i"); return element.getAttributeNames().filter((name) => regex.test(name)); } // Returns a list of attributes for the type (controller, target, action, etc.) function attributesFor(element, type) { return attributeKeysFor(element, type).map((name) => element.getAttribute(name) ); } const allControllerNames = new Set(); const allTargets = new Map(); let allControllers = new Map(); let allHandlers = new Map(); let allEvents = new Set(); // The three maps above all have the same structure: prefix // first (atv, etc.), then element, then a Map of those things. function findOrInitalize(map, prefix, element, initial = null) { if (!map.has(prefix)) { map.set(prefix, new Map()); } if (!map.get(prefix).has(element)) { map.get(prefix).set(element, initial ?? new Map()); } return map.get(prefix).get(element); } /* Look for the controllers in the importmap or just try to load them */ function withModule(name, callback) { let importmapName = `${name}_atv`; const map = importMap(); Object.keys(map).forEach(function (source) { if (dasherize(source).includes(`/${name}-atv`)) { importmapName = map[source]; // There should only be one } }); import(importmapName) .then(function (module) { callback(module); }) .catch(function (ex) { console.error(`Loading ${importmapName} failed:`, ex); }); } /* ----------------- Main Activation Function ------------------ */ function activate(prefix = "atv") { if (allControllers.has(prefix)) { return; } const root = document.body; // Provide selector for any controllers given a prefix const controllersSelector = allVariants("data", prefix, "controller") .map((selector) => `[${selector}]`) .join(","); // To allow for nesting controllers: // skip if it's not the nearest enclosing controller function outOfScope(element, root, name) { if (!element || element.nodeType === "BODY") { return true; } const closestRoot = element.closest(controllersSelector); if (!closestRoot) { return true; } let out = false; attributesFor(closestRoot, "controller").forEach(function (attr) { const list = attr.split(deCommaPattern).map((str) => dasherize(str)); if (list.includes(name)) { out = !(closestRoot === root); } else { out = outOfScope(closestRoot.parentNode, root, name); } }); return out; } // Optional console output mainly for development const quiet = !document.querySelector(`[data-${prefix}-report="true"]`); function report(count, type, action) { if (quiet || count < 1) { return; } console.log( [ [ "ATV:", `(${prefix})`, type, `[${Number(allControllers.get(prefix)?.size)}]` ].join(" "), `${action}: ${count}`, `v${version}` ].join(" / ") ); } function registerHandler(element, eventName, controllerName, handler) { allEvents.add(eventName); if (!allHandlers.get(element)) { allHandlers.set(element, new Map()); } const identifier = `${eventName}/${controllerName}`; let elementHandlers = allHandlers.get(element); elementHandlers.set(identifier, handler); } function unregisterHandler(element, controllerName) { let elementHandlers = allHandlers?.get(element); if (elementHandlers) { allEvents.forEach(function (eventName) { const identifier = `${eventName}/${controllerName}`; elementHandlers.delete(identifier); }); } } function handlerFor(element, eventName, controllerName) { const identifier = `${eventName}/${controllerName}`; return allHandlers.get(element)?.get(identifier); } /* ----------------- Controller Factory ------------------ */ function createController(root, name) { let actions; let targets = {}; let values = {}; allControllerNames.add(name); function getActions() { return actions; } function registerActions(root) { const controllers = findOrInitalize(allControllers, prefix, root); let elements = new Set(); function collectElements(item) { const [element] = item; elements.add(element); } variantSelectors(root, prefix, name, "action").forEach(collectElements); variantSelectors(root, prefix, "action").forEach(collectElements); // Find each element that has this type of controller Array.from(elements).forEach(function (element) { // Get action definitions const list = actionsFor(prefix, element); // Collect the events const eventNames = new Set(list.map((action) => action.event)); // Make one handler for each event type eventNames.forEach(function (eventName) { const firstForEvent = list.find( (action) => action.event === eventName ); if (firstForEvent?.controller !== name) { return; } function invokeNext(event, actions) { function invoke(callback) { if (callback) { try { return callback(event.target, event, action.parameters); } catch (error) { console.error(`ATV ${prefix}: ${eventName}->${name}`, error); return false; } } return true; } if (actions.length < 1) { return; } const action = actions[0]; if ( action.event === eventName && !outOfScope(element, root, action.controller) ) { const callbacks = controllers.get(action.controller).getActions(); if (invoke(callbacks[action.method]) === false) { return; } } return invokeNext(event, actions.slice(1)); } if (!handlerFor(element, eventName, name)) { const handler = (event) => invokeNext(event, list); element.addEventListener(eventName, handler); registerHandler(element, eventName, name, handler); } }); }); } // Update the in-memory controller from the DOM function refresh() { function refreshTargets(root, middle) { const addedTargets = {}; function collectionKeys(key) { return [`all${pascalize(key)}`, `${key}s`]; } variantSelectors(root.parentNode, prefix, name, "target").forEach( function (item) { const [element, variant] = item; element .getAttribute(variant) .split(deCommaPattern) .forEach(function (key) { const [allKey, pluralKey] = collectionKeys(key); if ( targets[key] === element || (targets[allKey] && targets[allKey].includes(element)) || outOfScope(element, root, name) ) { return; } if (targets[allKey]) { targets[allKey].push(element); targets[pluralKey].push(element); } else if (targets[key]) { targets[allKey] = [targets[key], element]; targets[pluralKey] = [targets[key], element]; // delete targets[key]; } else { targets[key] = element; } if (!addedTargets[key]) { addedTargets[key] = []; } addedTargets[key].push(element); }); } ); middle(); // This part needs to happen after the controller "activate". Object.keys(addedTargets).forEach(function (key) { const connectedCallback = actions[`${key}TargetConnected`]; if (connectedCallback) { addedTargets[key].forEach(connectedCallback); } const disconnectedCallback = actions[`${key}TargetDisconnected`]; if (disconnectedCallback) { addedTargets[key].forEach(function (element) { findOrInitalize(allTargets, prefix, element, []).push( function () { const [allKey, pluralKey] = collectionKeys(key); let index = targets[allKey]?.indexOf(element); if (index) { targets[allKey].splice(index, 1); } index = targets[pluralKey]?.indexOf(element); if (index) { targets[pluralKey].splice(index, 1); } if (targets[key] === element) { if (targets[allKey]) { targets[key] = targets[allKey][0]; } else { delete targets[allKey]; } } disconnectedCallback(element); } ); }); } }); } function refreshValues(element) { allVariants("data", prefix, name, "value").forEach(function (variant) { const data = element.getAttribute(variant); if (!data) { return; } [data, `{${data}}`].forEach(function (json) { try { Object.assign(values, JSON.parse(json)); } catch (ex) { errorReport(ex); } }); }); } // Note that with module includes a promise return so this part finishes // asynchronously. withModule(name, function (module) { refreshTargets(root, function () { refreshValues(root); const invoked = module.connect( targets, values, root, controllerBySelectorAndName ); // Allow for returning an collection of actions or // a function returning a collection of actions if (typeof invoked === "function") { actions = invoked(); } else { actions = invoked; } registerActions(root); }); }); } /** The public controller object */ const controller = Object.freeze({ getActions, refresh }); return controller; } function registerControllers(root) { findOrInitalize(allControllers, prefix, root); attributesFor(root, "controller").forEach(function (attribute) { attribute.split(deCommaPattern).forEach(function (controllerName) { const name = dasherize(controllerName); const controller = allControllers.get(prefix).get(root).get(name) || createController(root, name); controller.refresh(); allControllers.get(prefix).get(root).set(name, controller); }); }); } function updateControllers(root) { let initialCount = 0; if (allControllers?.has(prefix)) { initialCount = Number(allControllers.get(prefix).size); } const elements = new Set(); if (root.matches(controllersSelector)) { elements.add(root); } root .querySelectorAll(controllersSelector) .forEach((element) => elements.add(element)); elements.forEach(registerControllers); if (allControllers.has(prefix)) { report( allControllers.get(prefix).size - initialCount, "controllers", "found" ); } } updateControllers(root); const observer = new MutationObserver(domWatcher); /* --- Provided to client code to talk to other controllers --- */ function controllerBySelectorAndName(selector, name, callback) { document.querySelectorAll(selector).forEach(function (element) { let controller = findOrInitalize(allControllers, prefix, element)?.get( name ); if (controller) { callback({ actions: controller.getActions() }); } }); } /* ------------ React to DOM changes for this prefix --------------- */ function domWatcher(records, observer) { function cleanup(node) { // Hard reset if ( node.nodeName === "BODY" || node.nodeName === "HTML" || node.nodeName === "#document" ) { observer.disconnect(); allControllers = new Map(); return; } // Inner DOM reset function cleanTargets(element) { if (element && element.children.length > 0) { Array.from(element.children).forEach(cleanTargets); } const disconnectors = allTargets.get(prefix)?.get(element); if (disconnectors) { disconnectors.forEach((callback) => callback()); disconnectors.splice(0, disconnectors.length); } } function cleanActions(element) { const controllers = findOrInitalize(allControllers, prefix, element); if (controllers) { controllers.forEach(function (controller) { const disconnect = controller.getActions().disconnect; if (disconnect) { disconnect(); } unregisterHandler(element, controller.name); }); allControllers.get(prefix).delete(element); } } cleanTargets(node); node.querySelectorAll(controllersSelector).forEach(cleanActions); cleanActions(node); } function controllerFor(element, name) { if (!element || element === document.body) { return; } return ( findOrInitalize(allControllers, prefix, element)?.get(name) || controllerFor(element.parentNode, name) ); } function updateTargets(element) { Array.from(allControllerNames).forEach(function (name) { controllerFor(element, name)?.refresh(); variantSelectors(element, prefix, name, "target").forEach( function (item) { controllerFor(item[0], name)?.refresh(); } ); }); } function HTMLElements(node) { return Boolean(node.classList); } records.forEach(function (mutation) { if (mutation.type === "childList") { Array.from(mutation.removedNodes) .filter(HTMLElements) .forEach((node) => cleanup(node)); } }); records.forEach(function (mutation) { if (mutation.type === "childList") { Array.from(mutation.addedNodes) .filter(HTMLElements) .forEach(function (node) { updateTargets(node); updateControllers(node); }); } }); } const config = { childList: true, subtree: true }; observer.observe(document, config); } export { activate, version };