infusion
Version:
Infusion is an application framework for developing flexible stuff with JavaScript
631 lines (571 loc) • 25.1 kB
JavaScript
/*
Copyright The Infusion copyright holders
See the AUTHORS.md file at the top-level directory of this distribution and at
https://github.com/fluid-project/infusion/raw/main/AUTHORS.md.
Licensed under the Educational Community License (ECL), Version 2.0 or the New
BSD license. You may not use this file except in compliance with one these
Licenses.
You may obtain a copy of the ECL 2.0 License and BSD License at
https://github.com/fluid-project/infusion/raw/main/Infusion-LICENSE.txt
*/
/* This file contains functions which depend on the presence of a markup document
* somehow represented and which depend on the contents of Fluid.js
*/
;
/** Invoke the supplied function on the supplied arguments
* @param {Object} options - A structure encoding a function invocation
* @param {Function} options.func - The function to be invoked
* @param {Array} options.args - The arguments on which the function is to be invoked
* @return {Any} The return value from the function invocation
*/
fluid.apply = function (options) {
return options.func.apply(null, options.args);
};
// A "proto-viewComponent" which simply defines a DOM binder and is agnostic as to how its container is defined
// Temporary factoring artefact which will most likely go away/be improved once new renderer has stabilised
fluid.defaults("fluid.baseViewComponent", {
gradeNames: "fluid.component",
argumentMap: {
container: 0,
options: 1
},
events: {
onDomBind: "promise"
},
// Note that we apply the same timing as the pref's framework's panels for old-style components, to avoid
// confusing its timing before it is rewritten, which has a whole bunch of createOnEvent: "onDomBind" subcomponents.
// The renderer and all modern components will fire this in a timely way when the DOM binder is actually constructed
listeners: {
"onCreate.onDomBind": "{that}.events.onDomBind"
},
selectors: {
},
members: {
dom: "@expand:fluid.createDomBinder({that}.container, {that}.options.selectors)",
locate: "{that}.dom.locate"
},
// mergePolicy allows these members to be cleanly overridden, avoiding FLUID-5668
mergePolicy: {
"members.dom": "replace",
"members.container": "replace"
}
});
fluid.defaults("fluid.viewComponent", {
gradeNames: ["fluid.modelComponent", "fluid.baseViewComponent"],
members: {
container: "@expand:fluid.containerForViewComponent({that}, {that}.options.container)"
}
});
// unsupported, NON-API function
fluid.dumpSelector = function (selectable) {
return typeof(selectable) === "string" ? selectable :
selectable.selector ? selectable.selector : "";
};
fluid.checkTryCatchParameter = function () {
var location = window.location || { search: "", protocol: "file:" };
var GETparams = location.search.slice(1).split("&");
return fluid.find(GETparams, function (param) {
if (param.indexOf("notrycatch") === 0) {
return true;
}
}) === true;
};
fluid.notrycatch = fluid.checkTryCatchParameter();
/**
* Wraps an object in a jQuery if it isn't already one. This function is useful since
* it ensures to wrap a null or otherwise falsy argument to itself, rather than the
* often unhelpful jQuery default of returning the overall document node.
*
* @param {Object} obj - the object to wrap in a jQuery
* @param {jQuery} [userJQuery] - the jQuery object to use for the wrapping, optional - use the current jQuery if absent
* @return {jQuery} - The wrapped object.
*/
fluid.wrap = function (obj, userJQuery) {
userJQuery = userJQuery || $;
return ((!obj || obj.jquery) ? obj : userJQuery(obj));
};
/**
* If obj is a jQuery, this function will return the first DOM element within it. Otherwise, the object will be returned unchanged.
*
* @param {jQuery} obj - The jQuery instance to unwrap into a pure DOM element.
* @return {Object} - The unwrapped object.
*/
fluid.unwrap = function (obj) {
return obj && obj.jquery ? obj[0] : obj;
};
/**
* Fetches a single container element and returns it as a jQuery.
*
* @param {String|jQuery|Element} containerSpec - an selector, a single-element jQuery, or a DOM element specifying a unique container
* @param {Boolean} fallible - <code>true</code> if an empty container is to be reported as a valid condition
* @param {jQuery} [userJQuery] - the jQuery object to use for the wrapping, optional - use the current jQuery if absent
* @return {jQuery} - A single-element jQuery container.
*/
fluid.container = function (containerSpec, fallible, userJQuery) {
if (!containerSpec) {
fluid.fail("fluid.container argument is empty");
}
var selector = containerSpec.selector || containerSpec;
if (userJQuery) {
containerSpec = fluid.unwrap(containerSpec);
}
var container = fluid.wrap(containerSpec, userJQuery);
if (fallible && (!container || container.length === 0)) {
return null;
}
if (!container || !container.jquery || container.length !== 1) {
// TODO: This boneheaded overwriting of our arguments prevents plain view components appearing as children of renderer components -
// unless of course it has already overwritten the DOM binder's cache value - doesn't seem possible. A renderer component could
// of course broadcast an addon grade to all its viewComponent children overriding their container resolver
if (typeof(containerSpec) !== "string") {
containerSpec = container.selector;
}
var count = container.length !== undefined ? container.length : 0;
// TODO: "in context" confusingly reports "undefined" in the usual case that containerSpec is just a selector
var extraMessage = container.selectorName ? " with selector name " + container.selectorName +
" in context " + fluid.dumpEl(containerSpec.context) : "";
fluid.fail((count > 1 ? "More than one (" + count + ") container elements were" :
"No container element was") + " found for selector " + containerSpec + extraMessage );
}
if (!fluid.isDOMNode(container[0])) {
fluid.fail("fluid.container was supplied a non-jQueryable element");
}
// To address FLUID-5966, manually adding back the selector and context properties that were removed from jQuery v3.0.
// ( see: https://jquery.com/upgrade-guide/3.0/#breaking-change-deprecated-context-and-selector-properties-removed )
// In most cases the "selector" property will already be restored through the DOM binder;
// however, when a selector or pure jQuery element is supplied directly as a component's container, we need to add them
// if it is possible to infer them. This feature is rarely used but is crucial for the prefs framework infrastructure
// in Panels.js fluid.prefs.subPanel.resetDomBinder
container.selector = selector;
container.context = container.context || containerSpec.ownerDocument || document;
return container;
};
/**
* Creates a new DOM Binder instance, used to locate elements in the DOM by name.
*
* @param {Object} container - the root element in which to locate named elements
* @param {Object} selectors - a collection of named jQuery selectors
* @return {Object} - The new DOM binder.
*/
fluid.createDomBinder = function (container, selectors) {
var userJQuery = container.constructor;
var that = {
container: container,
id: fluid.allocateGuid(),
doQuery: function (selector) {
return userJQuery(selector, that.container);
},
cache: {}
};
that.locate = function (selectorName) {
var selector = selectorName === "container" ? "" : selectors[selectorName];
if (selector === undefined) {
fluid.fail("DOM binder request for selector " + selectorName + " which is not registered");
}
var togo;
if (selector === "") {
togo = that.container;
} else {
togo = that.doQuery(selector, selectorName);
}
// These hacks are still required since fluid.prefs.subPanel.resetDomBinder egregiously reads them off the panel container
togo.selector = selector;
togo.context = that.container;
togo.selectorName = selectorName;
that.cache[selectorName] = togo;
return togo;
};
that.fastLocate = function (selectorName) {
return that.cache[selectorName] || that.locate(selectorName);
};
that.resetContainer = function (container) {
that.container = container;
that.clear();
};
that.clear = function () {
that.cache = {};
};
that.resolvePathSegment = that.locate;
return that;
};
/**
* Creates a new "local container"-capable DOM Binder instance, used to locate elements in the DOM by name. This
* is a historical contract for the DOM binder which was used by two components, the FileQueueView and the Reorderer.
* A simpler contract has been extracted in order to be compatible with future notions of the DOM binder used by
* the "new" FLUID-6580 renderer.
*
* @param {Object} container - the root element in which to locate named elements
* @param {Object} selectors - a collection of named jQuery selectors
* @return {Object} - The new DOM binder.
*/
fluid.createLocalContainerDomBinder = function (container, selectors) {
var that = {
container: container,
id: fluid.allocateGuid(),
cache: {}
};
var userJQuery = container.constructor;
function cacheKey(name, thisContainer) {
return fluid.allocateSimpleId(thisContainer) + "-" + name;
}
function record(name, thisContainer, result) {
that.cache[cacheKey(name, thisContainer)] = result;
}
that.locate = function (name, localContainer) {
var selector, thisContainer, togo;
selector = selectors[name];
if (selector === undefined) {
if (name === "container") {
selector = "";
} else {
fluid.fail("DOM binder request for selector " + name + " which is not registered");
}
}
thisContainer = localContainer || that.container;
if (!thisContainer) {
fluid.fail("DOM binder invoked for selector " + name + " without container");
}
if (selector === "") {
togo = userJQuery(thisContainer);
}
else {
if (typeof(selector) === "function") {
togo = userJQuery(selector.call(null, fluid.unwrap(thisContainer)));
} else {
togo = userJQuery(selector, thisContainer);
}
}
if (!togo.selector) {
togo.selector = selector;
togo.context = thisContainer;
}
togo.selectorName = name;
record(name, thisContainer, togo);
return togo;
};
that.fastLocate = function (name, localContainer) {
var thisContainer = localContainer ? localContainer : that.container;
var key = cacheKey(name, thisContainer);
var togo = that.cache[key];
return togo ? togo : that.locate(name, localContainer);
};
that.resetContainer = function (container) {
that.container = container;
that.clear();
};
that.clear = function () {
that.cache = {};
};
that.refresh = function (names, localContainer) {
var thisContainer = localContainer ? localContainer : that.container;
if (typeof names === "string") {
names = [names];
}
if (thisContainer.length === undefined) {
thisContainer = [thisContainer];
}
for (var i = 0; i < names.length; ++i) {
for (var j = 0; j < thisContainer.length; ++j) {
that.locate(names[i], thisContainer[j]);
}
}
};
that.resolvePathSegment = that.locate;
return that;
};
/* Expect that jQuery selector query has resulted in a non-empty set of
* results. If none are found, this function will fail with a diagnostic message,
* with the supplied message prepended.
*/
fluid.expectFilledSelector = function (result, message) {
if (result && result.length === 0 && result.jquery) {
fluid.fail(message + ": selector \"" + result.selector + "\" with name " + result.selectorName +
" returned no results in context " + fluid.dumpEl(result.context));
}
};
fluid.containerForViewComponent = function (that, containerSpec) {
var container = fluid.container(containerSpec);
fluid.expectFilledSelector(container, "Error instantiating viewComponent at path \"" + fluid.pathForComponent(that));
return container;
};
/**
* Returns the id attribute from a jQuery or pure DOM element.
*
* @param {jQuery|Element} element - the element to return the id attribute for.
* @return {String} - The id attribute of the element.
*/
fluid.getId = function (element) {
return fluid.unwrap(element).id;
};
/*
* Allocate an id to the supplied element if it has none already, by a simple
* scheme resulting in ids "fluid-id-nnnn" where nnnn is an increasing integer.
*/
fluid.allocateSimpleId = function (element) {
element = fluid.unwrap(element);
if (!element || fluid.isPrimitive(element)) {
return null;
}
if (!element.id) {
var simpleId = "fluid-id-" + fluid.allocateGuid();
element.id = simpleId;
}
return element.id;
};
fluid.registerNamespace("fluid.materialisers");
fluid.makeDomMaterialiserManager = function () {
// In future we will want to track listeners and other elements in order to invalidate them, but currently
// there are no use cases
var that = {
idToModelListeners: {}
};
return that;
};
fluid.checkMaterialisedElement = function (element, selectorName, that) {
if (!element || !element.length) {
fluid.fail("Could not locate element for selector " + selectorName + " for component " + fluid.dumpComponentAndPath(that));
}
};
fluid.domMaterialiserManager = fluid.makeDomMaterialiserManager();
// Passive - pushes out to single-arg jQuery method, active in acquiring initial markup value for booleanAttr
fluid.materialisers.domOutput = function (that, segs, type, options) {
fluid.freezeRecursive(segs);
var selectorName = segs[1];
var listener = function (value) {
if (that.dom) {
var element = that.dom.locate(selectorName);
fluid.checkMaterialisedElement(element, selectorName, that);
if (type === "jQuery") {
var model = {
value: value,
segs: segs
};
var args = options.makeArgs ? options.makeArgs(model) : [model.value];
element[options.method].apply(element, args);
} else if (type === "booleanAttr") {
if (value === undefined) {
var markupValue = !!element.attr(options.attr);
that.applier.change(segs, options.negate ? !markupValue : markupValue);
} else {
var attrValue = options.negate ? !value : value;
if (attrValue) {
element.attr(options.attr, options.attr);
} else {
element.removeAttr(options.attr);
}
}
}
}
};
// fluid.pushArray(fluid.domMaterialiserManager.idToModelListeners, that.id, listener);
that.applier.modelChanged.addListener({segs: segs}, listener);
that.events.onDomBind.addListener(function () {
var modelValue = fluid.getImmediate(that.model, segs);
listener(modelValue);
});
// For "read" materialisers, if the DOM has shorter lifetime than the component, the binder will still function
};
fluid.incrementModel = function (that, segs) {
var oldValue = fluid.getImmediate(that.model, segs) || 0;
that.applier.change(segs, oldValue + 1);
};
// Active - count of received clicks
fluid.materialisers.domClick = function (that, segs) {
// Note that we no longer supply an initial value to avoid confusing non-integral modelListeners
// that.applier.change(segs, 0);
var listener = function () {
fluid.incrementModel(that, segs);
// TODO: Add a change source, and stick "event" on the stack somehow
};
// TODO: ensure that we don't miss the initial DOM bind event - unlikely, since models are resolved first
// We assume that an outdated DOM will cease to generate events and be GCed
that.events.onDomBind.addListener(function () {
that.dom.locate(segs[1]).click(listener);
});
};
// Active - hover state
fluid.materialisers.hover = function (that, segs) {
var makeListener = function (state) {
return function () {
// TODO: Add a change source, and stick "event" on the stack somehow
that.applier.change(segs, state);
};
};
// TODO: For this, click and focusin, integralise over the entire document and add just one single listener - also, preferably eliminate jQuery
// Perhaps a giant WeakMap of all DOM binder cache contents?
that.events.onDomBind.addListener(function () {
that.dom.locate(segs[1]).hover(makeListener(true), makeListener(false));
});
};
// Active - focusin state
fluid.materialisers.focusin = function (that, segs) {
var makeListener = function (state) {
return function () {
// TODO: Add a change source, and stick "event" on the stack somehow
that.applier.change(segs, state);
};
};
that.events.onDomBind.addListener(function () {
that.dom.locate(segs[1]).focusin(makeListener(true)).focusout(makeListener(false));
});
};
// Bidirectional - reads existing id or allocates simple if not present, and also allows it to be rewritten from the model
fluid.materialisers.id = function (that, segs) {
that.events.onDomBind.addListener(function () {
var element = that.dom.locate(segs[1])[0];
var modelValue = fluid.getImmediate(that.model, segs);
if (modelValue === undefined) {
var id = fluid.allocateSimpleId(element);
that.applier.change(segs, id, "ADD", "DOM");
} else {
element.id = modelValue;
}
var modelListener = function (value) {
if (value !== undefined) {
element.id = value;
}
};
that.applier.modelChanged.addListener({segs: segs}, modelListener);
});
};
// Bidirectional - pushes and receives values
fluid.materialisers.domValue = function (that, segs) {
that.events.onDomBind.addListener(function () {
var element = that.dom.locate(segs[1]);
var domListener = function () {
var val = fluid.value(element);
that.applier.change(segs, val, "ADD", "DOM");
};
var modelListener = function (value) {
if (value !== undefined) {
fluid.value(element, value);
}
};
that.applier.modelChanged.addListener({segs: segs}, modelListener);
var options = fluid.getImmediate(that, ["options", "bindingOptions", fluid.model.composeSegments.apply(null, segs)]);
var changeEvent = options && options.changeEvent || "change";
element.on(changeEvent, domListener);
// Pull the initial value from the model
modelListener(fluid.getImmediate(that.model, segs));
// TODO: How do we know not to pull the value from the DOM on startup? Are we expected to introspect into
// the relay rule connecting it? Surely not. In practice this should use the same rule as "outlying init
// values" in the main ChangeApplier which we have done, but so far there is no mechanism to override it.
});
};
// Passive
fluid.materialisers.style = function (that, segs) {
var selectorName = segs[1];
that.events.onDomBind.addListener(function () {
var element = that.dom.locate(selectorName);
fluid.checkMaterialisedElement(element, selectorName, that);
var modelValue = fluid.getImmediate(that.model, segs);
element[0].style[segs[3]] = modelValue;
});
};
// Remember, naturally all this stuff will go into defaults when we can afford it
fluid.registerNamespace("fluid.materialiserRegistry");
fluid.materialiserRegistry["fluid.viewComponent"] = {
"dom": {
"*": {
"text": {
materialiser: "fluid.materialisers.domOutput",
args: ["jQuery", {method: "text"}]
},
"attr": {
materialiser: "fluid.materialisers.domOutput",
args: ["jQuery", {method: "attr", makeArgs: function (model) {
return [ model.segs[3], model.value];
}}]
},
"visible": {
materialiser: "fluid.materialisers.domOutput",
args: ["jQuery", {method: "toggle"}]
},
"enabled": {
materialiser: "fluid.materialisers.domOutput",
args: ["booleanAttr", {attr: "disabled", negate: true}]
},
"click": {
materialiser: "fluid.materialisers.domClick"
},
"hover": {
materialiser: "fluid.materialisers.hover"
},
"focusin": {
materialiser: "fluid.materialisers.focusin"
},
"value": {
materialiser: "fluid.materialisers.domValue"
},
"class": {
materialiser: "fluid.materialisers.domOutput",
args: ["jQuery", {method: "toggleClass", makeArgs: function (model) {
return [ model.segs[3], !!model.value];
}}]
},
"style": {
materialiser: "fluid.materialisers.style"
},
"id": {
materialiser: "fluid.materialisers.id"
}
}
}
};
/** A generalisation of jQuery.val to correctly handle the case of acquiring and setting the value of clustered
* radio button/checkbox sets, potentially, given a node corresponding to just one element.
* @param {jQuery} nodeIn - The node whose value is to be read or written
* @param {Any} newValue - If `undefined`, the value will be read, otherwise, the supplied value will be applied to the node
* @return {Any|undefined} The queried value, if `newValue` was undefined, otherwise `undefined`.
*/
fluid.value = function (nodeIn, newValue) {
var node = fluid.unwrap(nodeIn);
var isMultiple = false;
if (node.nodeType === undefined && node.length > 1) {
node = node[0];
isMultiple = true;
}
if ("input" !== node.nodeName.toLowerCase() || !/radio|checkbox/.test(node.type)) {
// resist changes to contract of jQuery.val() in jQuery 1.5.1 (see FLUID-4113)
return newValue === undefined ? $(node).val() : $(node).val(newValue);
}
var name = node.name;
var elements;
if (isMultiple || name === "") {
elements = nodeIn;
} else {
elements = node.ownerDocument.getElementsByName(name);
var scope = fluid.findForm(node);
elements = $.grep(elements, function (element) {
if (element.name !== name) {
return false;
}
return !scope || scope.contains(element);
});
// TODO: "Code to the test" for old renderer - remove all HTML 1.0 behaviour when old renderer is abolished
isMultiple = elements.length > 1;
}
if (newValue !== undefined) {
if (typeof(newValue) === "boolean") {
newValue = (newValue ? "true" : "false");
}
// jQuery gets this partially right, but when dealing with radio button array will
// set all of their values to "newValue" rather than setting the checked property
// of the corresponding control.
$.each(elements, function () {
this.checked = (newValue instanceof Array ?
newValue.indexOf(this.value) !== -1 : newValue === this.value);
});
} else { // it is a checkbox - jQuery fails on this - see https://stackoverflow.com/questions/5621324/jquery-val-and-checkboxes
var checked = $.map(elements, function (element) {
return element.checked ? element.value : null;
});
return node.type === "radio" ? checked[0] :
isMultiple ? checked : !!checked[0]; // else it's a checkbox
}
};
// The current implementation of fluid.ariaLabeller consists 100% of side-effects on the document and so
// this grade is supplied only so that instantiation on the server is not an error
fluid.defaults("fluid.ariaLabeller", {
gradeNames: "fluid.viewComponent"
});