UNPKG

infusion

Version:

Infusion is an application framework for developing flexible stuff with JavaScript

702 lines (636 loc) 29.8 kB
/* 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/master/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/master/Infusion-LICENSE.txt */ /** This file contains functions which depend on the presence of a DOM document * and which depend on the contents of Fluid.js **/ var fluid_3_0_0 = fluid_3_0_0 || {}; (function ($, fluid) { "use strict"; fluid.defaults("fluid.viewComponent", { gradeNames: ["fluid.modelComponent"], initFunction: "fluid.initView", argumentMap: { container: 0, options: 1 }, members: { // Used to allow early access to DOM binder via IoC, but to also avoid triggering evaluation of selectors dom: "@expand:fluid.initDomBinder({that}, {that}.options.selectors)" } }); // unsupported, NON-API function fluid.dumpSelector = function (selectable) { return typeof (selectable) === "string" ? selectable : selectable.selector ? selectable.selector : ""; }; // unsupported, NON-API function // NOTE: this function represents a temporary strategy until we have more integrated IoC debugging. // It preserves the 1.3 and previous framework behaviour for the 1.x releases, but provides a more informative // diagnostic - in fact, it is perfectly acceptable for a component's creator to return no value and // the failure is really in assumptions in fluid.initLittleComponent. Revisit this issue for 2.0 fluid.diagnoseFailedView = function (componentName, that, options, args) { if (!that && fluid.hasGrade(options, "fluid.viewComponent")) { var container = fluid.wrap(args[1]); var message1 = "Instantiation of view component with type " + componentName + " failed, since "; if (!container) { fluid.fail(message1 + " container argument is empty"); } else if (container.length === 0) { fluid.fail(message1 + "selector \"", fluid.dumpSelector(args[1]), "\" did not match any markup in the document"); } else { fluid.fail(message1 + " component creator function did not return a value"); } } }; 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 id string, 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) { 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) { if (typeof (containerSpec) !== "string") { containerSpec = container.selector; } var count = container.length !== undefined ? container.length : 0; fluid.fail((count > 1 ? "More than one (" + count + ") container elements were" : "No container element was") + " found for selector " + containerSpec); } 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) { // don't put on a typename to avoid confusing primitive visitComponentChildren var that = { 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) { return undefined; } thisContainer = localContainer ? $(localContainer) : container; if (!thisContainer) { fluid.fail("DOM binder invoked for selector " + name + " without container"); } if (selector === "") { togo = thisContainer; } else if (!selector) { togo = userJQuery(); } 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 : container; var key = cacheKey(name, thisContainer); var togo = that.cache[key]; return togo ? togo : that.locate(name, localContainer); }; that.clear = function () { that.cache = {}; }; that.refresh = function (names, localContainer) { var thisContainer = localContainer ? localContainer : 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)); } }; /** * The central initialiation method called as the first act of every Fluid * component. This function automatically merges user options with defaults, * attaches a DOM Binder to the instance, and configures events. * * @param {String} componentName - The unique "name" of the component, which will be used * to fetch the default options from store. By recommendation, this should be the global * name of the component's creator function. * @param {jQueryable} containerSpec - A specifier for the single root "container node" in the * DOM which will house all the markup for this component. * @param {Object} userOptions - The user configuration options for this component. * @param {Object} localOptions - The local configuration options for this component. Unsupported, see comments for initLittleComponent. * @return {Object|null} - The newly created component, or `null` id the container does not exist. */ fluid.initView = function (componentName, containerSpec, userOptions, localOptions) { var container = fluid.container(containerSpec, true); fluid.expectFilledSelector(container, "Error instantiating component with name \"" + componentName); if (!container) { return null; } // Need to ensure container is set early, without relying on an IoC mechanism - rethink this with asynchrony var receiver = function (that) { that.container = container; }; var that = fluid.initLittleComponent(componentName, userOptions, localOptions || {gradeNames: ["fluid.viewComponent"]}, receiver); if (!that.dom) { fluid.initDomBinder(that); } // TODO: cannot afford a mutable container - put this into proper workflow var userJQuery = that.options.jQuery; // Do it a second time to correct for jQuery injection // if (userJQuery) { // container = fluid.container(containerSpec, true, userJQuery); // } fluid.log("Constructing view component " + componentName + " with container " + container.constructor.expando + (userJQuery ? " user jQuery " + userJQuery.expando : "") + " env: " + $.expando); return that; }; /** * Creates a new DOM Binder instance for the specified component and mixes it in. * * @param {Object} that - The component instance to attach the new DOM Binder to. * @param {Object} selectors - a collection of named jQuery selectors * @return {Object} - The DOM for the component. */ fluid.initDomBinder = function (that, selectors) { if (!that.container) { fluid.fail("fluid.initDomBinder called for component with typeName " + that.typeName + " without an initialised container - this has probably resulted from placing \"fluid.viewComponent\" in incorrect position in grade merging order. " + " Make sure to place it to the right of any non-view grades in the gradeNames list to ensure that it overrides properly: resolved gradeNames is ", that.options.gradeNames, " for component ", that); } that.dom = fluid.createDomBinder(that.container, selectors || that.options.selectors || {}); that.locate = that.dom.locate; return that.dom; }; // DOM Utilities. /** * Finds the nearest ancestor of the element that matches a predicate * @param {Element} element - DOM element * @param {Function} test - A function (predicate) accepting a DOM element, returning a truthy value representing a match * @return {Element|undefined} - The first element parent for which the predicate returns truthy - or undefined if no parent matches */ fluid.findAncestor = function (element, test) { element = fluid.unwrap(element); while (element) { if (test(element)) { return element; } element = element.parentNode; } }; fluid.findForm = function (node) { return fluid.findAncestor(node, function (element) { return element.nodeName.toLowerCase() === "form"; }); }; /* A utility with the same signature as jQuery.text and jQuery.html, but without the API irregularity * that treats a single argument of undefined as different to no arguments */ // in jQuery 1.7.1, jQuery pulled the same dumb trick with $.text() that they did with $.val() previously, // see comment in fluid.value below fluid.each(["text", "html"], function (method) { fluid[method] = function (node, newValue) { node = $(node); return newValue === undefined ? node[method]() : node[method](newValue); }; }); /* 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. */ fluid.value = function (nodeIn, newValue) { var node = fluid.unwrap(nodeIn); var multiple = false; if (node.nodeType === undefined && node.length > 1) { node = node[0]; multiple = 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; if (name === undefined) { fluid.fail("Cannot acquire value from node " + fluid.dumpEl(node) + " which does not have name attribute set"); } var elements; if (multiple) { 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 || fluid.dom.isContainer(scope, element); }); } 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 { // this part jQuery will not do - extracting value from <input> array var checked = $.map(elements, function (element) { return element.checked ? element.value : null; }); return node.type === "radio" ? checked[0] : checked; } }; fluid.BINDING_ROOT_KEY = "fluid-binding-root"; /* Recursively find any data stored under a given name from a node upwards * in its DOM hierarchy **/ fluid.findData = function (elem, name) { while (elem) { var data = $.data(elem, name); if (data) { return data; } elem = elem.parentNode; } }; fluid.bindFossils = function (node, data, fossils) { $.data(node, fluid.BINDING_ROOT_KEY, {data: data, fossils: fossils}); }; fluid.boundPathForNode = function (node, fossils) { node = fluid.unwrap(node); var key = node.name || node.id; var record = fossils[key]; return record ? record.EL : null; }; /* relevant, the changed value received at the given DOM node */ fluid.applyBoundChange = function (node, newValue, applier) { node = fluid.unwrap(node); if (newValue === undefined) { newValue = fluid.value(node); } if (node.nodeType === undefined && node.length > 0) { node = node[0]; } // assume here that they share name and parent var root = fluid.findData(node, fluid.BINDING_ROOT_KEY); if (!root) { fluid.fail("Bound data could not be discovered in any node above " + fluid.dumpEl(node)); } var name = node.name; var fossil = root.fossils[name]; if (!fossil) { fluid.fail("No fossil discovered for name " + name + " in fossil record above " + fluid.dumpEl(node)); } if (typeof(fossil.oldvalue) === "boolean") { // deal with the case of an "isolated checkbox" newValue = newValue[0] ? true : false; } var EL = root.fossils[name].EL; if (applier) { applier.fireChangeRequest({path: EL, value: newValue, source: "DOM:" + node.id}); } else { fluid.set(root.data, EL, newValue); } }; /* * Returns a jQuery object given the id of a DOM node. In the case the element * is not found, will return an empty list. */ fluid.jById = function (id, dokkument) { dokkument = dokkument && dokkument.nodeType === 9 ? dokkument : document; var element = fluid.byId(id, dokkument); var togo = element ? $(element) : []; togo.selector = "#" + id; togo.context = dokkument; return togo; }; /** * Returns an DOM element quickly, given an id * * @param {Object} id - the id of the DOM node to find * @param {Document} dokkument - the document in which it is to be found (if left empty, use the current document) * @return {Object} - The DOM element with this id, or null, if none exists in the document. */ fluid.byId = function (id, dokkument) { dokkument = dokkument && dokkument.nodeType === 9 ? dokkument : document; var el = dokkument.getElementById(id); if (el) { // Use element id property here rather than attribute, to work around FLUID-3953 if (el.id !== id) { fluid.fail("Problem in document structure - picked up element " + fluid.dumpEl(el) + " for id " + id + " without this id - most likely the element has a name which conflicts with this id"); } return el; } else { return null; } }; /** * 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; }; /** * Returns the document to which an element belongs, or the element itself if it is already a document * * @param {jQuery|Element} element - The element to return the document for * @return {Document} - The document in which it is to be found */ fluid.getDocument = function (element) { var node = fluid.unwrap(element); // DOCUMENT_NODE - guide to node types at https://developer.mozilla.org/en/docs/Web/API/Node/nodeType return node.nodeType === 9 ? node : node.ownerDocument; }; fluid.defaults("fluid.ariaLabeller", { gradeNames: ["fluid.viewComponent"], labelAttribute: "aria-label", liveRegionMarkup: "<div class=\"liveRegion fl-hidden-accessible\" aria-live=\"polite\"></div>", liveRegionId: "fluid-ariaLabeller-liveRegion", invokers: { generateLiveElement: { funcName: "fluid.ariaLabeller.generateLiveElement", args: "{that}" }, update: { funcName: "fluid.ariaLabeller.update", args: ["{that}", "{arguments}.0"] } }, listeners: { onCreate: { func: "{that}.update", args: [null] } } }); fluid.ariaLabeller.update = function (that, newOptions) { newOptions = newOptions || that.options; that.container.attr(that.options.labelAttribute, newOptions.text); if (newOptions.dynamicLabel) { var live = fluid.jById(that.options.liveRegionId); if (live.length === 0) { live = that.generateLiveElement(); } live.text(newOptions.text); } }; fluid.ariaLabeller.generateLiveElement = function (that) { var liveEl = $(that.options.liveRegionMarkup); liveEl.prop("id", that.options.liveRegionId); $("body").append(liveEl); return liveEl; }; var LABEL_KEY = "aria-labelling"; fluid.getAriaLabeller = function (element) { element = $(element); var that = fluid.getScopedData(element, LABEL_KEY); return that; }; /* Manages an ARIA-mediated label attached to a given DOM element. An * aria-labelledby attribute and target node is fabricated in the document * if they do not exist already, and a "little component" is returned exposing a method * "update" that allows the text to be updated. */ fluid.updateAriaLabel = function (element, text, options) { options = $.extend({}, options || {}, {text: text}); var that = fluid.getAriaLabeller(element); if (!that) { that = fluid.ariaLabeller(element, options); fluid.setScopedData(element, LABEL_KEY, that); } else { that.update(options); } return that; }; /* "Global Dismissal Handler" for the entire page. Attaches a click handler to the * document root that will cause dismissal of any elements (typically dialogs) which * have registered themselves. Dismissal through this route will automatically clean up * the record - however, the dismisser themselves must take care to deregister in the case * dismissal is triggered through the dialog interface itself. This component can also be * automatically configured by fluid.deadMansBlur by means of the "cancelByDefault" option */ var dismissList = {}; $(document).click(function (event) { var target = fluid.resolveEventTarget(event); while (target) { if (dismissList[target.id]) { return; } target = target.parentNode; } fluid.each(dismissList, function (dismissFunc, key) { dismissFunc(event); delete dismissList[key]; }); }); // TODO: extend a configurable equivalent of the above dealing with "focusin" events /* Accepts a free hash of nodes and an optional "dismissal function". * If dismissFunc is set, this "arms" the dismissal system, such that when a click * is received OUTSIDE any of the hierarchy covered by "nodes", the dismissal function * will be executed. */ fluid.globalDismissal = function (nodes, dismissFunc) { fluid.each(nodes, function (node) { // Don't bother to use the real id if it is from a foreign document - we will never receive events // from it directly in any case - and foreign documents may be under the control of malign fiends // such as tinyMCE who allocate the same id to everything var id = fluid.unwrap(node).ownerDocument === document ? fluid.allocateSimpleId(node) : fluid.allocateGuid(); if (dismissFunc) { dismissList[id] = dismissFunc; } else { delete dismissList[id]; } }); }; /* Provides an abstraction for determing the current time. * This is to provide a fix for FLUID-4762, where IE6 - IE8 * do not support Date.now(). */ fluid.now = function () { return Date.now ? Date.now() : (new Date()).getTime(); }; /* Sets an interation on a target control, which morally manages a "blur" for * a possibly composite region. * A timed blur listener is set on the control, which waits for a short period of * time (options.delay, defaults to 150ms) to discover whether the reason for the * blur interaction is that either a focus or click is being serviced on a nominated * set of "exclusions" (options.exclusions, a free hash of elements or jQueries). * If no such event is received within the window, options.handler will be called * with the argument "control", to service whatever interaction is required of the * blur. */ fluid.deadMansBlur = function (control, options) { // TODO: This should be rewritten as a proper component var that = {options: $.extend(true, {}, fluid.defaults("fluid.deadMansBlur"), options)}; that.blurPending = false; that.lastCancel = 0; that.canceller = function (event) { fluid.log("Cancellation through " + event.type + " on " + fluid.dumpEl(event.target)); that.lastCancel = fluid.now(); that.blurPending = false; }; that.noteProceeded = function () { fluid.globalDismissal(that.options.exclusions); }; that.reArm = function () { fluid.globalDismissal(that.options.exclusions, that.proceed); }; that.addExclusion = function (exclusions) { fluid.globalDismissal(exclusions, that.proceed); }; that.proceed = function (event) { fluid.log("Direct proceed through " + event.type + " on " + fluid.dumpEl(event.target)); that.blurPending = false; that.options.handler(control); }; fluid.each(that.options.exclusions, function (exclusion) { exclusion = $(exclusion); fluid.each(exclusion, function (excludeEl) { $(excludeEl).on("focusin", that.canceller). on("fluid-focus", that.canceller). click(that.canceller).mousedown(that.canceller); // Mousedown is added for FLUID-4212, as a result of Chrome bug 6759, 14204 }); }); if (!that.options.cancelByDefault) { $(control).on("focusout", function (event) { fluid.log("Starting blur timer for element " + fluid.dumpEl(event.target)); var now = fluid.now(); fluid.log("back delay: " + (now - that.lastCancel)); if (now - that.lastCancel > that.options.backDelay) { that.blurPending = true; } setTimeout(function () { if (that.blurPending) { that.options.handler(control); } }, that.options.delay); }); } else { that.reArm(); } return that; }; fluid.defaults("fluid.deadMansBlur", { gradeNames: "fluid.function", delay: 150, backDelay: 100 }); })(jQuery, fluid_3_0_0);