diff --git a/README.md b/README.md index 737b711..8e0b430 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,47 @@ If your component generates or regenerates markup, you will need to call `gpii.b whenever it changes. The `gpii.binder.bindOnDomChange` grade included in this package will reapply the bindings whenever an `onDomChange` event is fired. +## Markup Event Bindings + +In addition to bi-directional binding of model variables to DOM elements denoted by selectors, another feature is +convenient binding of DOM events by selectors. This allows a streamlined syntax taking a view component selector as +a key, and specifying a jQuery event type and arguments to be bound to that selector. These arguments can contain +fluid IOC references, making it easy to attach an invoker on a component to an event. + +### `gpii.binder.bindMarkupEvents` + +Inheriting this grade provides the event `onMarkupRendered` which will trigger the event binding. This event is automatically +fired by `gpii.handlebars.templateAware` when rendering, making this a useful mix in grade for handlebars work. You can also +fire the `onDomBind` event to trigger binding. Similar to the `binding` options detailed above, this component adds a +section `markupEventBindings` for attaching events to selectors. + +Currently, all events are standard jQuery events. In the future other event models may be supported. The `args` listed +will be passed to the jQuery event, meaning you can use infusion syntax to pass a components invoker as the first +argument to be the callback. + +| Option | Type | Description | +| ------------------ | -------- | ----------- | +| `selectors` | `{Object}` | You must define one or more [selectors](http://docs.fluidproject.org/infusion/development/tutorial-gettingStartedWithInfusion/ViewComponents.html#selectors) that can be used in a binding. | +| `markupEventBindings` | `{Object}` | Defines the relationship between selectors and event bindings. This takes a string or array of strings listing DOM events, and a set of arguments to be passed to the event listener. | + +#### Example: Attaching an invoker to the `click` and `keydown` events. + + markupEventBindings: { + productListLinks: { + method: ["click", "keydown"] + args: ["{that}.selectProduct"] // This invoker will be passed the DOM Event as it's first argument + } + } + +For a single event we can use just the string: + + markupEventBindings: { + productListLinks: { + method: "click", + args: ["{that}.selectProduct"] + } + } + ## Tests To run the tests in this package, run `npm test`. In addition to the normal test output, a test coverage report will be diff --git a/src/js/common-event-bindings.js b/src/js/common-event-bindings.js new file mode 100644 index 0000000..71b594d --- /dev/null +++ b/src/js/common-event-bindings.js @@ -0,0 +1,165 @@ +/* global fluid */ +(function () { + "use strict"; + + /** + * GPII Binder Markup Events Grade + * This grade allows binding typical HTML events such as + * mouse clicks and keypress events to selectors each time + * the markup is rendered for a component. (Meaning they will + * also apply if a component is rerendered after a model refresh + * or similar situation) + * + * It adds a new area to a grades options called `markupEventBindings` + * which allows binding `selectors` to jQuery events. Theoretically + * other event constructs could be supported in the future, but only + * jQuery events are implemented at the time of writing. + * + * Binding is initiated when the components `onMarkupRendered` event is + * fired. If you are using a grade derived from `gpii.handlebars.templateAware` + * this event will be automatically fired when the handlebars markup is + * rendered. + * + * Example usage of adding a click handler to a selector productListLinks. + * ``` + * markupEventBindings: { + * productListLinks: { + * // type: jQuery <- Defaults to jQuery but could be configured ITF + * method: "click", + * args: ["{that}.selectProduct"] + * } + * } + * ``` + */ + fluid.defaults("gpii.binder.bindMarkupEvents", { + mergePolicy: { + decorators: "noexpand" + }, + events: { + onDomBind: null, + onDomUnbind: null, + onMarkupRendered: null + }, + listeners: { + onMarkupRendered: "{that}.events.onDomBind.fire({that}, {that}.container)", + onDestroy: "{that}.events.onDomUnbind.fire({that}, {that}.container)", + onDomBind: "fluid.decoratorViewComponent.processDecorators({that}, {that}.options.markupEventBindings)" + } + }); + + /** + * + * Markup Event Binding + * + * @typedef {Object} MarkupEventBinding + * @property {String} type - Currently the only supported value is `jQuery`. This property + * can also be omitted, in which case it will default to `jQuery`. + * @property {String|Array} method - The DOM Event we are binding to such as `click`. If we + * want to listen for multiple events in the same binding this can be an array of event + * types such as `["click", "keypress"]`. + * @property {Array} args - A list of arguments to be passed to the event. This supports the + * usual range of Fluid IoC syntax. Typically, this will be an invoker on the component to be + * called when the event is triggered. + */ + + /** + * + * Markup Event Bindings + * + * This is an object containing mappings of selector names to `MarkupEventBinding` + * declarations. Each key should be a name corresponding to a selector in the + * components `selectors` option block. + * + * @typedef {Object} MarkupEventBindings + */ + + fluid.registerNamespace("fluid.decoratorViewComponent"); + + // + // The methods below might be generic enough to go straight to infusion + // + + /** + * + * Expands string encoded arguments to the event invoker, filling + * in any compact string versions of infusion invokers. + * + * @param {gpii.binder.bindMarkupEvents} that - Any infusion component with sub-grade `bindMarkupEvents`. + * @param {String|Object} arg - A single argument being passed to the event invoker. + * @param {String} name - The name for the invoker. + * @return {Object} The expanded argument. + */ + fluid.expandCompoundArg = function (that, arg, name) { + var expanded = arg; + if (typeof(arg) === "string") { + if (arg.indexOf("(") !== -1) { + var invokerec = fluid.compactStringToRec(arg, "invoker"); + // TODO: perhaps a a courtesy we could expose {node} or even {this} + expanded = fluid.makeInvoker(that, invokerec, name); + } else { + expanded = fluid.expandImmediate(arg, that); + } + } + return expanded; + }; + + /** + * + * Processes a single markup event binding decorator of event type jQuery. + * Currently the only type of event supported is jQuery events, so this does + * all the work. + * + * @param {MarkupEventBinding} dec - The single binding decorator being processed. + * @param {DOMNode} node - The node we are listening to for the specified events. + * @param {gpii.binder.bindMarkupEvents} that - Any infusion component with sub-grade `bindMarkupEvents`. + * @param {String} name - Name that will be used for the invoker created to handle + * this event. + * @return {jQuery[]} Array of jQuery objects which the events attached to them. + */ + fluid.processjQueryDecorator = function (dec, node, that, name) { + var args = fluid.makeArray(dec.args); + var expanded = fluid.transform(args, function (arg, index) { + return fluid.expandCompoundArg(that, arg, name + " argument " + index); + }); + fluid.log("Got expanded value of ", expanded, " for jQuery decorator"); + // Support for listing multiple methods in an array, or just a single string method + var methods = [dec.method]; + var togo = []; + if (fluid.isArrayable(dec.method)) { + methods = dec.method; + } + fluid.each(methods, function (method) { + var func = node[method]; + togo.push(func.apply(node, expanded)); + }); + return togo; + }; + + /** + * + * Function to process the markup binding decorators and create the events described + * by them. The markup needs to be rendered and settled before this can be called. + * + * @param {gpii.binder.bindMarkupEvents} that - Any component inheriting from `bindMarkupEvents`. + * @param {MarkupEventBindings} decorators - Markup Event Binding decorators on the component. + */ + fluid.decoratorViewComponent.processDecorators = function (that, decorators) { + fluid.each(decorators, function (val, key) { + var node = that.locate(key); + if (node.length > 0) { + var name = "Decorator for DOM node with selector " + key + " for component " + fluid.dumpThat(that); + // val can be an array to support multiple event handlers + var handlerDecs = fluid.isArrayable(val) ? val : [val]; + fluid.each(handlerDecs, function (nextVal) { + var decs = fluid.makeArray(nextVal); + fluid.each(decs, function (dec) { + // If no type is specified default to jQuery + if (!dec.type || dec.type === "jQuery") { + fluid.processjQueryDecorator(dec, node, that, name); + } + }); + }); + } + }); + }; +})(); diff --git a/tests/static/all-tests.html b/tests/static/all-tests.html index 51c7fd3..79690f7 100644 --- a/tests/static/all-tests.html +++ b/tests/static/all-tests.html @@ -25,7 +25,9 @@ "/tests-binder-select.html", "/tests-binder-short.html", "/tests-binder-textarea.html", - "/tests-binder-transforms.html" + "/tests-binder-transforms.html", + "/tests-binder-commonEventBindings.html", + "/tests-binder-commonEventBindingsOverride.html" ] }; diff --git a/tests/static/js/jqunit/jqUnit-binder-commonEventBindings.js b/tests/static/js/jqunit/jqUnit-binder-commonEventBindings.js new file mode 100644 index 0000000..f16f92e --- /dev/null +++ b/tests/static/js/jqunit/jqUnit-binder-commonEventBindings.js @@ -0,0 +1,158 @@ +/* globals fluid, jqUnit */ +(function () { + "use strict"; + var gpii = fluid.registerNamespace("gpii"); + + // Component to test support for common event bindings + fluid.defaults("gpii.tests.binder.commonEventBindings", { + gradeNames: ["gpii.tests.binder.base", "gpii.binder.bindMarkupEvents"], + bindings: { + }, + selectors: { + inputButton: "#input-button", + inputKeydown: "#input-button", + paragraphClick: ".paragraph-click", + multipleEventInputButton: "#input-multiple-events-button", + multipleEventHandlerInputButton: "#input-multiple-handlers-events-button" + }, + markupEventBindings: { + inputButton: { + method: "click", + args: ["{that}.handleInputClick"] + }, + inputKeydown: { + method: "keydown", + args: ["{that}.handleInputClick"] + }, + paragraphClick: { + method: "click", + args: ["gpii.tests.binder.commonEventBindings.handleParagraphClick({arguments}.0)"] + }, + multipleEventInputButton: { + method: ["click", "keydown"], + args: ["{that}.handleInputClick"] + }, + multipleEventHandlerInputButton: [ + { + method: "click", + args: ["{that}.handleInputClick"] + }, + { + method: "keydown", + args: ["{that}.handleInputKeydown"] + } + ] + }, + invokers: { + handleInputClick: { + funcName: "gpii.tests.binder.commonEventBindings.handleInputClick", + args: ["{arguments}.0"] + }, + handleInputKeydown: { + funcName: "gpii.tests.binder.commonEventBindings.handleInputKeydown", + args: ["{arguments}.0"] + } + }, + listeners: { + "onCreate.markupEventBindings": "{that}.events.onMarkupRendered.fire()" + } + }); + + gpii.tests.binder.commonEventBindings.handleInputClick = function (event) { + jqUnit.assertEquals("We just clicked an input...", "INPUT", event.target.nodeName); + }; + + gpii.tests.binder.commonEventBindings.handleInputKeydown = function (event) { + jqUnit.assertEquals("We just keydowned an input...", "INPUT", event.target.nodeName); + }; + + gpii.tests.binder.commonEventBindings.handleParagraphClick = function (event) { + jqUnit.assertEquals("We just clicked a paragraph...", "P", event.target.nodeName); + }; + + fluid.defaults("gpii.tests.binder.commonEventBindings.caseHolder", { + gradeNames: ["gpii.tests.binder.caseHolder"], + rawModules: [{ + name: "Testing support for common event bindings...", + tests: [ + { + name: "Test markup event binding for click on an input button", + type: "test", + expect: 1, + sequence: [ + { + func: "gpii.tests.binder.clickSelector", + args: ["#input-button"] + } + ] + }, + { + name: "Test markup event binding for keydown on an input button", + type: "test", + expect: 1, + sequence: [ + { + func: "gpii.tests.binder.keydownSelector", + args: ["#input-button"] + } + ] + }, + { + name: "Test markup event binding for click on a paragraph with a class", + type: "test", + expect: 1, + sequence: [ + { + func: "gpii.tests.binder.clickSelector", + args: [".paragraph-click"] + } + ] + }, + { + name: "Test markup event binding for both `click` and `keydown` on an input button", + type: "test", + expect: 2, + sequence: [ + { + func: "gpii.tests.binder.clickSelector", + args: ["#input-multiple-events-button"] + }, + { + func: "gpii.tests.binder.keydownSelector", + args: ["#input-multiple-events-button"] + } + ] + }, + { + name: "Test markup event binding for both `click` and `keydown` on an input button, each using a different handler", + type: "test", + expect: 2, + sequence: [ + { + func: "gpii.tests.binder.clickSelector", + args: ["#input-multiple-handlers-events-button"] + }, + { + func: "gpii.tests.binder.keydownSelector", + args: ["#input-multiple-handlers-events-button"] + } + ] + } + ] + }] + }); + + fluid.defaults("gpii.tests.binder.commonEventBindings.environment", { + gradeNames: ["gpii.tests.binder.environment"], + markupFixture: ".viewport-common-event-bindings", + binderGradeNames: ["gpii.tests.binder.commonEventBindings"], + moduleName: "Testing common event bindings", + components: { + commonEventBindingsTests: { + type: "gpii.tests.binder.commonEventBindings.caseHolder" + } + } + }); + + gpii.tests.binder.commonEventBindings.environment(); +})(); diff --git a/tests/static/js/jqunit/jqUnit-binder-commonEventBindingsOverride.js b/tests/static/js/jqunit/jqUnit-binder-commonEventBindingsOverride.js new file mode 100644 index 0000000..be96d5b --- /dev/null +++ b/tests/static/js/jqunit/jqUnit-binder-commonEventBindingsOverride.js @@ -0,0 +1,177 @@ +/* globals fluid, jqUnit */ +(function () { + "use strict"; + var gpii = fluid.registerNamespace("gpii"); + + // Component to test support for common event bindings + fluid.defaults("gpii.tests.binder.commonEventBindings", { + gradeNames: ["gpii.tests.binder.base", "gpii.binder.bindMarkupEvents"], + bindings: { + }, + selectors: { + inputButton: "#input-button", + inputKeydown: "#input-button", + paragraphClick: ".paragraph-click", + multipleEventInputButton: "#input-multiple-events-button", + multipleEventHandlerInputButton: "#input-multiple-handlers-events-button" + }, + markupEventBindings: { + inputButton: { + method: "click", + args: ["{that}.handleInputClick"] + }, + inputKeydown: { + method: "keydown", + args: ["{that}.handleInputClick"] + }, + paragraphClick: { + method: "click", + args: ["{that}.handleParagraphClick"] + }, + multipleEventInputButton: { + method: ["click"], + args: ["{that}.handleInputClick"] + } + }, + invokers: { + handleInputClick: { + funcName: "gpii.tests.binder.commonEventBindings.handleInputClick", + args: ["{arguments}.0"] + }, + handleInputKeydown: { + funcName: "gpii.tests.binder.commonEventBindings.handleInputKeydown", + args: ["{arguments}.0"] + }, + handleParagraphClick: { + funcName: "gpii.tests.binder.commonEventBindings.handleParagraphClick", + args: ["{arguments}.0"] + } + }, + listeners: { + "onCreate.markupEventBindings": "{that}.events.onMarkupRendered.fire()" + } + }); + + gpii.tests.binder.commonEventBindings.handleInputClick = function (event) { + jqUnit.assertEquals("We just clicked an input...", "INPUT", event.target.nodeName); + }; + + gpii.tests.binder.commonEventBindings.handleInputKeydown = function (event) { + jqUnit.assertEquals("We just keydowned an input...", "INPUT", event.target.nodeName); + }; + + gpii.tests.binder.commonEventBindings.handleParagraphClick = function (event) { + jqUnit.assertEquals("We just clicked a paragraph...", "P", event.target.nodeName); + }; + + fluid.defaults("gpii.tests.binder.commonEventBindingsOverride.caseHolder", { + gradeNames: ["gpii.tests.binder.caseHolder"], + rawModules: [{ + name: "Testing support for overriding common event bindings in sub grades...", + tests: [ + { + name: "Test markup event binding for click on an input button", + type: "test", + expect: 1, + sequence: [ + { + func: "gpii.tests.binder.clickSelector", + args: ["#input-button"] + } + ] + }, + { + name: "Test markup event binding for keydown on an input button", + type: "test", + expect: 1, + sequence: [ + { + func: "gpii.tests.binder.keydownSelector", + args: ["#input-button"] + } + ] + }, + { + name: "Test markup event binding for click on a paragraph with a class", + type: "test", + expect: 1, + sequence: [ + { + func: "gpii.tests.binder.clickSelector", + args: [".paragraph-click"] + } + ] + }, + { + name: "Test markup event binding for both `click` and `keydown` on an input button", + type: "test", + expect: 2, + sequence: [ + { + func: "gpii.tests.binder.clickSelector", + args: ["#input-multiple-events-button"] + }, + { + func: "gpii.tests.binder.keydownSelector", + args: ["#input-multiple-events-button"] + } + ] + }, + { + name: "Test markup event binding for both `click` and `keydown` on an input button, each using a different handler", + type: "test", + expect: 2, + sequence: [ + { + func: "gpii.tests.binder.clickSelector", + args: ["#input-multiple-handlers-events-button"] + }, + { + func: "gpii.tests.binder.keydownSelector", + args: ["#input-multiple-handlers-events-button"] + } + ] + } + ] + }] + }); + + // + // Test for overriding binder markup events + // + fluid.defaults("gpii.tests.binder.commonEventBindingsOverride", { + gradeNames: ["gpii.tests.binder.commonEventBindings"], + markupEventBindings: { + // In the original grade this only had 1 method, overriding with 2 + multipleEventInputButton: { + method: ["click", "keydown"], + args: ["{that}.handleInputClick"] + }, + // This is a new set of events that was not in the original grade + multipleEventHandlerInputButton: [ + { + method: "click", + args: ["{that}.handleInputClick"] + }, + { + method: "keydown", + args: ["{that}.handleInputKeydown"] + } + ] + } + }); + + fluid.defaults("gpii.tests.binder.commonEventBindingsOverride.environment", { + gradeNames: ["gpii.tests.binder.environment"], + markupFixture: ".viewport-common-event-bindings", + binderGradeNames: ["gpii.tests.binder.commonEventBindingsOverride"], + moduleName: "Testing common event bindings", + components: { + commonEventBindingsOverrideTests: { + type: "gpii.tests.binder.commonEventBindingsOverride.caseHolder" + } + } + }); + + gpii.tests.binder.commonEventBindingsOverride.environment(); +})(); diff --git a/tests/static/js/tests-fixtures.js b/tests/static/js/tests-fixtures.js index 5c0e9de..aa02566 100644 --- a/tests/static/js/tests-fixtures.js +++ b/tests/static/js/tests-fixtures.js @@ -54,6 +54,11 @@ $(selector).click(); }; + // Client-side function to keydown a selector + gpii.tests.binder.keydownSelector = function (selector) { + $(selector).keydown(); + }; + // Client side one-shot element test, which can use most jqUnit functions. gpii.tests.binder.testElement = function (fnName, message, expected, selector) { var value = gpii.tests.binder.getElementValue(selector); diff --git a/tests/static/tests-binder-commonEventBindings.html b/tests/static/tests-binder-commonEventBindings.html new file mode 100644 index 0000000..3889ef7 --- /dev/null +++ b/tests/static/tests-binder-commonEventBindings.html @@ -0,0 +1,54 @@ + + + Unit Tests for Binder Module Common Event Bindings button support... + + + + + + + + + + + + + + + + + + + + + + + + + + + +

"Common Event Bindings" Component Tests

+

+
+

+
    + + + + + + + + diff --git a/tests/static/tests-binder-commonEventBindingsOverride.html b/tests/static/tests-binder-commonEventBindingsOverride.html new file mode 100644 index 0000000..9f09fcd --- /dev/null +++ b/tests/static/tests-binder-commonEventBindingsOverride.html @@ -0,0 +1,53 @@ + + + Unit Tests for Overriding Binder Module Common Event Bindings button support... + + + + + + + + + + + + + + + + + + + + + + + + + + +

    "Common Event Bindings" Component Tests

    +

    +
    +

    +
      + + + + + + + +