@sbrew.com/atv
Version:
Functional JS framework for Rails
927 lines (816 loc) • 25 kB
JavaScript
/*global document, console, 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) 2025 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.2.2";
// ----------- atv/action-parser.js -----------
//
// A recursive-descent parser for ATV actions
const actionMap = stateMap();
const tokenizer = new RegExp("(=>|->|[\\w]+[\\-\\w]+[\\w]|\\S)", "g");
function parseActions(input, defaultController) {
if (actionMap.get(input, defaultController)) {
return actionMap.get(input, defaultController);
}
const tokens = input.match(tokenizer);
let ii = 0;
function parse() {
const actions = [];
while (ii < tokens.length) {
actions.push(parseAction());
if (peek() === ",") {
ii += 1;
}
}
return actions;
}
function parseAction() {
let eventName = consume();
let method;
let theController = defaultController;
const nextToken = peek();
if (
nextToken === "(" ||
nextToken === "," ||
isWord.test(nextToken) ||
!nextToken
) {
method = eventName;
theController = defaultController;
} else if (nextToken === "->" || nextToken === "=>") {
consume(); // ->
if (peek(1) === "#") {
theController = consume();
consume(); // #
method = consume();
} else if (defaultController) {
theController = defaultController;
method = consume();
} else {
theController = consume();
method = eventName;
}
}
if (!theController) {
console.error("No controller specified");
return;
}
return {
controllerName: theController.replace(underscore, "-"),
eventName: eventName || method,
method,
parameters: parseParameters()
};
}
function parseParameters() {
const params = [];
if (peek() !== "(") {
return params;
}
consume("(");
let param = "";
let braceCount = 0;
let inString = false;
let stringChar = "";
while (ii < tokens.length) {
const token = consume();
if (isQuote.test(token) && !inString) {
inString = true;
stringChar = token;
} else if (token === stringChar && inString) {
inString = false;
stringChar = "";
}
if (!inString) {
if ((token === "," || token === ")") && braceCount === 0) {
params.push(sanitize(param));
param = "";
if (token === ")") {
break;
}
} else {
if (token === "(") {
braceCount += 1;
}
if (token === ")") {
braceCount -= 1;
}
param += token;
}
} else {
param += token;
}
}
if (param) {
params.push(sanitize(param.trim()));
}
return params;
}
function sanitize(param) {
param = param.trim();
if (isNumber.test(param)) {
return Number(param);
}
const length = param.length;
const first = param.charAt[0];
if (isQuote.test(first) && !param.endsWith(first)) {
return param;
}
return param.substring(1, length - 1);
}
function consume(expected) {
const token = peek();
ii += 1;
if (expected && token !== expected) {
console.error(`Expected ${expected} but found ${token}`);
}
return token;
}
function peek(offset = 0) {
return tokens[ii + offset];
}
const result = parse();
actionMap.set(input, defaultController, result);
return result;
}
// ----------- atv/action.js -----------
//
// Responsible for processing ATV actions
function actionSequence(prefix, element) {
const matcher = new RegExp(`${dataPrefix(prefix)}[-_](.*)[_-]?actions?$`);
let parsed = [];
element.getAttributeNames().forEach(function (attributeName) {
const match = attributeName.match(matcher);
if (match) {
const defaultController = match[1].replace(/[\-_]$/, "");
const concatenate = function (action) {
parsed = parsed.concat(parseActions(action, defaultController));
};
const attribute = element.getAttribute(attributeName);
if (attribute.startsWith("[")) {
JSON.parse(attribute).forEach(concatenate);
} else {
concatenate(attribute);
}
}
});
return parsed;
}
function distinctEventNamesFor(prefix, element) {
let result = new Set();
actionSequence(prefix, element).forEach(function (action) {
if (action?.eventName) {
result.add(action.eventName);
}
});
return Array.from(result);
}
// Respond to an event
function act(prefix, element, incomingEventName, event) {
let finished = false;
actionSequence(prefix, element).forEach(function ({
controllerName,
eventName,
method,
parameters
}) {
if (finished || eventName !== incomingEventName) {
return;
}
const controller = controllerFor(prefix, element, controllerName);
if (controller && controller.actions[method]) {
if (!controller.actions[method](event.target, event, parameters)) {
finished = true;
}
}
});
}
// ----------- atv/application.js -----------
//
// Maintain list of applications in this top-level module
//
// An application is identified by its "prefix"-- default "atv"
// This allows for avoiding name collisions *and* independent apps on
// the same page.
function createApplication(prefix) {
const application = {};
const manager = createControllerManager(prefix);
application.activate = function () {
loadImportmap(manager.refresh);
};
application.manager = manager;
return application;
}
const applications = stateMap();
function activate(prefix = "atv") {
prefix = `${prefix}`;
applications.initialize(prefix, function () {
const application = createApplication(prefix);
application.activate();
return application;
});
}
// ----------- atv/controller-manager.js -----------
//
// Reponsible for managing all the instances of a given controller type,
// E.g. <div data-atv-controller="my-controller">:
// There will be one "manager" for "my" with the prefix "atv", responsible
// for care and feeding of the controllers (worker bees).
const allControllers = stateMap();
function createControllerManager(prefix) {
const selector = controllerSelector(prefix);
const managers = {};
let watchingDom = false;
let log = () => undefined;
if (document.querySelector(friendlySelector(`data-${prefix}-report`))) {
log = (message) => console.log(`ATV (${prefix}): ${message}`);
}
// Find outlets (provided to atv controllers as fourth connect parameter)
function outlets(selector, controllerName, callback) {
document.querySelectorAll(selector).forEach(function (element) {
const controller = allControllers.get(prefix, element, controllerName);
if (controller) {
callback(controller);
}
});
}
// This public function receives a set of ES6 modules from
// the importmap loader
function refresh(moduleDefinitions) {
let controllerCount = 0;
function refreshModule({ controllerName, module, version }) {
const manager = managers[controllerName];
if (!manager || manager.version !== version) {
const staleControllers = manager?.controllers;
if (staleControllers) {
staleControllers.forEach(function (controller) {
controller.disconnect();
});
}
// Manager structure
managers[controllerName] = {
controllerName,
controllers: new Map(),
module,
outlets,
prefix,
selector,
version
};
}
}
function addOrUpdateControllers() {
const liveList = stateMap();
allControllerElements(prefix).forEach(function ([
controllerName,
element
]) {
const manager = managers[controllerName];
if (!manager) {
console.error(`ATV: Missing module: ${prefix}/${controllerName}`);
return;
}
let controller = manager.controllers.get(element);
if (controller?.disconnect) {
controller.disconnect();
controller = undefined;
}
if (!controller) {
const newController = createController(manager, element);
manager.controllers.set(element, newController);
allControllers.set(prefix, element, controllerName, newController);
}
liveList.set(element, controllerName, true);
controllerCount += 1;
});
allControllers
.get(prefix)
?.keys()
?.forEach(function (element) {
allControllers
.get(prefix, element)
?.keys()
?.forEach(function (controllerName) {
if (liveList.get(element, controllerName)) {
return;
}
const controller = allControllers.destroy(
prefix,
element,
controllerName
);
const disconnector = controller?.actions?.disconnect;
if (disconnector) {
disconnector();
}
});
});
}
function refreshApplication() {
addOrUpdateControllers();
refreshEvents(prefix);
}
function setupDomWatcher() {
if (watchingDom) {
return;
}
watchingDom = true;
const observer = new MutationObserver(refreshApplication);
observer.observe(document.body, { childList: true, subtree: true });
document.documentElement.addEventListener("turbo:load", function () {
refreshApplication();
watchingDom = false;
setupDomWatcher();
});
}
moduleDefinitions.forEach(refreshModule);
refreshApplication();
setupDomWatcher();
const managerCount = Object.keys(managers).length;
log(`Activated: Managers: ${managerCount} Controllers: ${controllerCount}`);
}
return { refresh };
}
// Find the container controller by prefix and name for element
function controllerFor(prefix, element, controllerName) {
if (element === undefined) {
return;
}
const controller = allControllers.get(prefix, element, controllerName);
if (controller) {
return controller;
}
return controllerFor(prefix, element.parentNode, controllerName);
}
// ----------- atv/controller.js -----------
//
// This is the worker bee, a controller specifically attached
// to a given dom element.
function createController(controllerManager, element) {
const targets = {}; // Exposed to ATV controller code
const values = {}; // Exposed to ATV controller code
const prefix = controllerManager.prefix;
// Used to update controllers if importmap is changed to a new cache name
const controller = {
element,
moduleVersion: controllerManager.version,
prefix,
targets,
values
};
// Update or find associated targets and values
function refresh() {
refreshValues(controllerManager, element, values);
refreshTargets(controllerManager, element, targets);
}
controller.refresh = refresh;
refresh();
// This is where the actual connection to the controller instance happens
const result = controllerManager.module.connect(
targets,
values,
element,
controllerManager.outlets
);
controller.actions = functionify(result);
return controller;
}
// ----------- atv/element-finder.js -----------
//
// Helpers to deal with finding things in the DOM
function rawSelector(prefix, type) {
let dashPrefix = "";
if (prefix) {
dashPrefix = `${prefix}-`;
}
return ["data-", dashPrefix, type].join("");
}
function allControllerElements(prefix) {
const result = [];
const selector = rawSelector(prefix, "controller");
const elements = Array.from(
document.body.querySelectorAll(friendlySelector(selector))
);
elements.forEach(function (element) {
allVariants(selector).forEach(function (variant) {
const value = element.getAttribute(variant);
if (value) {
value.split(/[\s,]+/).forEach(function (controllerName) {
result.push([controllerName.replace(/_/g, "-"), element]);
});
}
});
});
return result;
}
function allActionElements(prefix) {
const result = new Set();
function collectActions(selector) {
Array.from(
document.body.querySelectorAll(friendlySelector(selector))
).forEach(function (element) {
element.getAttributeNames().forEach(function (attributeName) {
if (/^data.*actions?$/.test(attributeName)) {
result.add(element);
}
});
});
}
collectActions(rawSelector(prefix, "action"));
allControllerNames.forEach(function (controllerName) {
collectActions(rawSelector(prefix, `${controllerName}-action`));
});
return Array.from(result);
}
function controllerSelector(prefix) {
return `[${rawSelector(prefix, "controller")}]`;
}
function outOfScope(element, rootElement, controllerName, prefix) {
if (!element || element.nodeType === "BODY") {
return true;
}
const closestRoot = element.closest(controllerSelector(prefix));
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(controllerName)) {
out = !(closestRoot === rootElement);
} else {
out = outOfScope(
closestRoot.parentNode,
rootElement,
controllerName,
prefix
);
}
});
return out;
}
const variantRegex = new RegExp("([^_-]+)[-_]?(.*)");
const startRegex = new RegExp("^[-_]");
function allVariants(selector) {
if (!selector) {
return [];
}
const variants = new Set();
function addVariants(prefix, remainder) {
if (remainder) {
const match = remainder.match(variantRegex);
if (match) {
const first = match[1];
const rest = match[2];
addVariants(`${prefix}-${first}`, rest);
addVariants(`${prefix}_${first}`, rest);
return;
}
}
const result = prefix.replace(startRegex, "");
variants.add(result);
variants.add(`${result}s`);
}
addVariants("", selector);
return Array.from(variants);
}
function friendlySelector(selector) {
return allVariants(selector)
.map((item) => `[${item}]`)
.join(",");
}
// ----------- atv/event.js -----------
const allHandlers = stateMap();
function refreshHandler(prefix, element, eventName) {
if (allHandlers.get(prefix, element, eventName)) {
return;
}
const handler = function (event) {
act(prefix, element, eventName, event);
};
allHandlers.set(prefix, element, eventName, () => handler);
element.addEventListener(eventName, handler);
}
function refreshEvents(prefix) {
allActionElements(prefix).forEach(function (element) {
distinctEventNamesFor(prefix, element).forEach(function (eventName) {
refreshHandler(prefix, element, eventName);
});
});
}
// ----------- atv/importmap.js -----------
//
// Responsible for finding all the available ATV controllers
// in the importmaps. Also provides a global list of all
// controller type names
const importMapSelector = "script[type='importmap']";
const allControllerNames = new Set();
function importMap() {
return JSON.parse(document.querySelector(importMapSelector).innerText)
.imports;
}
// This method is called first upon activation (booting) of ATV
// for a given prefix. The callback receives a set of fully
// instantiated ES6 modules and will complete initialization.
function loadImportmap(complete) {
const importMapper = new RegExp(`^controllers\/(.*)[-_]atv$`);
const moduleDefinitions = [];
function reloadModules() {
const map = importMap();
const moduleLoaderPromises = [];
Object.keys(map).forEach(function (source) {
const matched = source.match(importMapper);
if (!matched) {
return;
}
const controllerName = matched[1]
.split("/")
.join("--")
.replace(underscore, "-");
allControllerNames.add(controllerName);
moduleLoaderPromises.push(
new Promise(function (resolve) {
import(source).then(function (module) {
const version = `${map[source]}`;
moduleDefinitions.push({
controllerName,
module,
version
});
resolve(module);
});
})
);
});
Promise.allSettled(moduleLoaderPromises).then(() =>
complete(moduleDefinitions)
);
}
// This watcher is just for the importmap header script itself.
function setupDomWatcher() {
const observer = new MutationObserver(reloadModules);
observer.observe(document.querySelector(importMapSelector), {
childList: true,
subtree: true
});
}
reloadModules();
setupDomWatcher();
}
// ----------- atv/pluralize.js -----------
// Inspired by Rails Inflector
// Used for target lists, assuming all targets will be lower case
const irregularMap = {
child: "children",
index: "indices",
louse: "lice",
man: "men",
matrix: "matrices",
mouse: "mice",
ox: "oxen",
person: "people",
potato: "potatoes",
quiz: "quizzes",
tomato: "tomatoes",
vertex: "vertices"
};
const replacements = [
[/quiz$/, "quizzes"],
[/x$/, "xes"],
[/ch$/, "ches"],
[/ss$/, "sses"],
[/sh$/, "shes"],
[/s$/, "ses"]
];
function pluralize(word) {
if (Object.values(irregularMap).includes(word)) {
return word;
}
if (irregularMap[word]) {
return irregularMap[word];
}
let ii;
for (ii = 0; ii < replacements.length; ii += 1) {
const [pattern, replacement] = replacements[ii];
if (pattern.test(word)) {
return word.replace(pattern, replacement);
}
}
const m1 = word.match(/([^aeiouy]|qu)y$/i);
if (m1) {
return word.replace(/y$/, "ies");
}
const m2 = word.match(/(?:([^f])fe|([lr])f)$/i);
if (m2) {
return word.replace(/f$/, "ves");
}
return `${word}s`;
}
// ----------- atv/state-map.js -----------
// Responsible for storing state by nested keys of any type
function stateMap() {
// A la Picard
function engage(map, params, action, value = undefined) {
if (params.length === 0) {
return undefined;
}
const firstKey = params[0];
let result = map.get(firstKey);
if (params.length === 1) {
switch (action) {
case "destroy":
result = map.get(firstKey);
map.delete(firstKey);
return result;
case "initialize":
if (!map.has(firstKey)) {
map.set(firstKey, functionify(value));
}
break;
case "set":
map.set(firstKey, functionify(value));
break;
}
return map.get(firstKey);
}
if (!map.has(firstKey)) {
map.set(firstKey, new Map());
}
return engage(map.get(firstKey), params.slice(1), action, value);
}
const map = new Map();
function initialize(...params) {
return engage(map, params, "initialize", params.pop());
}
function set(...params) {
return engage(map, params, "set", params.pop());
}
function get(...params) {
return engage(map, params, "get");
}
function destroy(...params) {
return engage(map, params, "destroy");
}
return {
destroy,
get,
initialize,
map,
set
};
}
// ----------- atv/target.js -----------
//
// Responsible for keeping track of all the targets on the page
const targetMatchers = {};
const targetSelectors = {};
function refreshTargets(controllerManager, rootElement, targets) {
const controllerName = controllerManager.controllerName;
const prefix = controllerManager.prefix;
let matcherKey = controllerName;
if (prefix) {
matcherKey = `${prefix}-${controllerName}`;
}
function dataMatcher() {
if (!targetMatchers[matcherKey]) {
const friendlyName = controllerName.replace(/-/g, "[-_]");
let parts = ["data[-_]", prefix];
if (prefix) {
parts.push(`[-_]${friendlyName}[-_]target[s]?`);
}
targetMatchers[matcherKey] = new RegExp(parts.join(""));
}
return targetMatchers[matcherKey];
}
function dataSelector() {
if (!targetSelectors[matcherKey]) {
targetSelectors[matcherKey] = friendlySelector(
`data-${matcherKey}-target`
);
}
return targetSelectors[matcherKey];
}
Object.keys(targets).forEach(function (key) {
delete targets[key];
});
function updateTargets(element) {
if (outOfScope(element, rootElement, controllerName, prefix)) {
return;
}
element.getAttributeNames().forEach(function (attributeName) {
if (dataMatcher().test(attributeName)) {
const parsed = element.getAttribute(attributeName);
parsed.split(/[\s]*,[\s]*/).forEach(function (key) {
const pluralKey = pluralize(key);
if (targets[pluralKey]?.includes(element)) {
return;
}
targets[key] = element;
if (!targets[pluralKey]) {
targets[pluralKey] = [];
}
targets[pluralKey].push(element);
});
}
});
}
updateTargets(rootElement);
rootElement.querySelectorAll(dataSelector()).forEach(updateTargets);
}
// ----------- atv/utilities.js -----------
const deCommaPattern = /,[\s+]/;
const isNumber = new RegExp("^-?\\d*[.]?\\d+$");
const isQuote = new RegExp("[\"']");
const isWord = new RegExp("\\w+");
const underscore = new RegExp("[_]", "g");
function attributesFor(element, type) {
return attributeKeysFor(element, type).map(function (name) {
return element.getAttribute(name);
});
}
function attributeKeysFor(element, type) {
if (!element || !element.getAttributeNames) {
return [];
}
const regex = new RegExp(`${type}s?$`, "i");
return element.getAttributeNames().filter((name) => regex.test(name));
}
function dasherize(string) {
return string.replace(/_/g, "-");
}
function dataPrefix(prefix) {
if (prefix) {
return `data-${prefix}`;
}
return "data";
}
function functionify(value) {
if (typeof value === "function") {
return value();
}
return value;
}
// ----------- atv/value.js -----------
//
// Represents a value for a given controller.
const valueMatchers = {};
const valueRegex = /values?/;
function refreshValues(controllerManager, element, values) {
const controllerName = controllerManager.controllerName;
const prefix = controllerManager.prefix;
const matcherKey = `${prefix}-${controllerName}`;
function dataMatcher() {
const friendlyName = controllerName.replace(/-/g, "[-_]");
if (!valueMatchers[matcherKey]) {
let parts = ["data[-_]", prefix];
if (prefix) {
parts.push(`[-_]${friendlyName}[-_]value[s]?`);
}
valueMatchers[matcherKey] = new RegExp(parts.join(""));
}
return valueMatchers[matcherKey];
}
// Must preserve the original "values" object here.
Object.keys(values).forEach(function (key) {
delete values[key];
});
element.getAttributeNames().forEach(function (attributeName) {
if (dataMatcher().test(attributeName)) {
if (!valueRegex.test(attributeName)) {
return;
}
const value = element.getAttribute(attributeName);
let parsed;
if (value.startsWith("{")) {
parsed = JSON.parse(value);
} else {
parsed = JSON.parse(`{${value}}`);
}
Object.keys(parsed).forEach(function (key) {
values[key] = parsed[key];
});
}
});
}
export { activate, version };