diff --git a/ui/action-target.mod/action-target.js b/ui/action-target.mod/action-target.js
new file mode 100644
index 000000000..7823d047f
--- /dev/null
+++ b/ui/action-target.mod/action-target.js
@@ -0,0 +1,433 @@
+ /*global require, exports*/
+
+/**
+ * @module mod/ui/action-target
+ * @requires mod/ui/component
+ * @requires mod/composer/press-composer
+ * @requires collections/map
+ */
+const { Control } = require("ui/control");
+var Component = require("../component").Component,
+ KeyComposer = require("../../composer/key-composer").KeyComposer,
+ PressComposer = require("../../composer/press-composer").PressComposer,
+ Map = require("core/collections/map");
+
+/**
+ * @class ActionTarget
+ * @extends Control
+ */
+var ActionTarget = exports.ActionTarget = Control.specialize( /** @lends ActionTarget.prototype # */ {
+
+ /**
+ * @private
+ * @property {Map} value
+ * @default null
+ */
+ _detail: {
+ value: null
+ },
+
+ /**
+ * The data property of the action event.
+ *
+ * @returns {Map}
+ */
+ detail: {
+ get: function () {
+ if (this._detail === null || this._detail === undefined) {
+ this._detail = new Map();
+ }
+ return this._detail;
+ }
+ },
+
+ /**
+ * Creates an action event with custom data.
+ *
+ * @function
+ * @returns {CustomEvent}
+ */
+ createActionEvent: {
+ value: function () {
+ var actionEvent = document.createEvent("CustomEvent"),
+ eventDetail;
+
+ eventDetail = this._detail;
+ actionEvent.initCustomEvent("action", true, true, eventDetail);
+ return actionEvent;
+ }
+ },
+
+ /**
+ * @function
+ * @fires ActionTarget#action
+ */
+ dispatchActionEvent: {
+ value: function () {
+ return this.dispatchEvent(this.createActionEvent());
+ }
+ },
+
+ /**
+ * Convenience property to toggle enabled state.
+ */
+ disabled: {
+ get: function () {
+ return !this.enabled;
+ },
+ set: function (value) {
+ if (typeof value === "boolean") {
+ this.enabled = !value;
+ }
+ }
+ },
+
+ debounceOptions: {
+ value: {
+ leading: true,
+ trailing: false
+ }
+ },
+
+ _debounceThreshold: {
+ value: 300
+ },
+
+ _debounced: {
+ value: false
+ },
+
+ debounceThreshold: {
+ get: function () {
+ return this._debounceThreshold;
+ },
+ set: function (value) {
+ this._debounceThreshold = Number(value);
+
+ if (this._debounced) {
+ this.dispatchActionEvent = this.debounce(
+ this.dispatchActionEvent.bind(this),
+ this._debounceThreshold,
+ this.debounceOptions
+ );
+ }
+ }
+ },
+
+ debounced: {
+ get: function () {
+ return this._debounced;
+ },
+ set: function (value) {
+ this._debounced = Boolean(value);
+
+ if (this._debounced) {
+ this.dispatchActionEvent = this.debounce(
+ this.dispatchActionEvent.bind(this),
+ this._debounceThreshold,
+ this.debounceOptions
+ );
+ } else {
+ this.dispatchActionEvent = this.constructor.prototype.dispatchActionEvent;
+ }
+ }
+ },
+
+ _promise: {
+ value: undefined
+ },
+
+ promise: {
+ get: function () {
+ return this._promise;
+ },
+ set: function (promise) {
+ var shouldClearPendingState,
+ currentTrackedPromise;
+
+ if (this._promise === promise) {
+ return;
+ }
+
+ shouldClearPendingState = !!this._promise;
+ this._promise = promise;
+
+ if (promise) {
+ this.classList.add("mod--pending");
+ currentTrackedPromise = promise;
+
+ promise.finally(function () {
+ if (this._promise === currentTrackedPromise) {
+ this.classList.remove("mod--pending");
+ this._promise = undefined;
+ }
+ }.bind(this));
+ } else if (shouldClearPendingState) {
+ this.classList.remove("mod--pending");
+ }
+ }
+ },
+
+ /**
+ * @constructs
+ */
+ constructor: {
+ value: function ActionTarget() {
+ this._pressComposer.defineBinding("longPressThreshold", {"<-": "holdThreshold", source: this});
+ }
+ },
+
+ /**
+ * Enables or disables the Button from user input. When this property is
+ * set to `false`, the "mod--disabled" CSS style is applied to the
+ * button's DOM element during the next draw cycle. When set to `true` the
+ * "mod--disabled" CSS class is removed from the element's class
+ * list.
+ * @property {boolean} value
+ */
+ enabled: {
+ value: true
+ },
+
+ /**
+ * @private
+ */
+ _preventFocus: {
+ value: false
+ },
+
+ /**
+ * Specifies whether the button should receive focus or not.
+ *
+ * @property {boolean}
+ * @default false
+ */
+ preventFocus: {
+ get: function () {
+ return this._preventFocus;
+ },
+ set: function (value) {
+ this._preventFocus = !!value;
+ this.needsDraw = true;
+ }
+ },
+
+ acceptsActiveTarget: {
+ value: function () {
+ return ! this._preventFocus;
+ }
+ },
+
+ willBecomeActiveTarget: {
+ value: function (previousActiveTarget) {
+
+ }
+ },
+
+
+
+ /**
+ * The amount of time in milliseconds the user must press and hold the
+ * button a `longAction` event is dispatched. The default is 1 second.
+ * @property {number} value
+ * @default 1000
+ */
+ holdThreshold: {
+ value: 1000
+ },
+
+ /**
+ * @property {PressComposer} value
+ * @default null
+ * @private
+ */
+ __pressComposer: {
+ value: null
+ },
+
+ _pressComposer: {
+ get: function () {
+ if (!this.__pressComposer) {
+ this.__pressComposer = new PressComposer();
+ this.addComposer(this.__pressComposer);
+ }
+
+ return this.__pressComposer;
+ }
+ },
+
+ __spaceKeyComposer: {
+ value: null
+ },
+
+ _spaceKeyComposer: {
+ get: function () {
+ if (!this.__spaceKeyComposer) {
+ this.__spaceKeyComposer = KeyComposer.createKey(this, "space", "space");
+ }
+
+ return this.__spaceKeyComposer;
+ }
+ },
+
+ __enterKeyComposer: {
+ value: null
+ },
+
+ _enterKeyComposer: {
+ get: function () {
+ if (!this.__enterKeyComposer) {
+ this.__enterKeyComposer = KeyComposer.createKey(this, "enter", "enter");
+ }
+
+ return this.__enterKeyComposer;
+ }
+ },
+
+ /**
+ * @private
+ */
+ _active: {
+ value: false
+ },
+
+ /**
+ * This property is true when the button is being interacted with, either
+ * through mouse click or touch event, otherwise false.
+ *
+ * @property {boolean}
+ * @default false
+ */
+ active: {
+ get: function () {
+ return this._active;
+ },
+ set: function (value) {
+ this._active = value;
+ this.needsDraw = true;
+ }
+ },
+
+ prepareForActivationEvents: {
+ value: function () {
+ this._pressComposer.addEventListener("pressStart", this, false);
+ this._pressComposer.addEventListener("press", this, false);
+ this._pressComposer.addEventListener("pressCancel", this, false);
+ this._spaceKeyComposer.addEventListener("keyPress", this, false);
+ this._enterKeyComposer.addEventListener("keyPress", this, false);
+ }
+ },
+
+ // Optimisation
+ addEventListener: {
+ value: function (type, listener, useCapture) {
+ Component.prototype.addEventListener.call(this, type, listener, useCapture);
+ if (type === "longAction") {
+ this._pressComposer.addEventListener("longPress", this, false);
+ }
+ }
+ },
+
+ // Handlers
+
+ /**
+ * Called when the user starts interacting with the component.
+ *
+ * @private
+ */
+ handlePressStart: {
+ value: function (event) {
+ if (this._promise) {
+ return;
+ }
+
+ this.active = true;
+
+ if (event.touch) {
+ // Prevent default on touchmove so that if we are inside a scroller,
+ // it scrolls and not the webpage
+ document.addEventListener("touchmove", this, false);
+ }
+
+ if (!this._preventFocus) {
+ this._element.focus();
+ }
+ }
+ },
+
+ /**
+ * Called when the user has interacted with the button.
+ *
+ * @private
+ */
+ handlePress: {
+ value: function (event) {
+ if (this._promise) {
+ return;
+ }
+
+ this.active = false;
+ this.dispatchActionEvent();
+ document.removeEventListener("touchmove", this, false);
+ }
+ },
+
+ handleKeyPress: {
+ value: function (event) {
+ if (this._promise) {
+ return;
+ }
+
+ if (event.identifier === "space" || event.identifier === "enter") {
+ this.active = false;
+ this.dispatchActionEvent();
+ }
+ }
+ },
+
+ handleKeyup: {
+ value: function (event) {
+ // action event on spacebar
+ if (event.keyCode === 32) {
+ this.active = false;
+ this.dispatchActionEvent();
+ }
+ }
+ },
+
+ handleLongPress: {
+ value: function (event) {
+ if (this._promise) {
+ return;
+ }
+
+ // When we fire the "longAction" event we don't want to fire the
+ // "action" event as well.
+ this._pressComposer.cancelPress();
+
+ var longActionEvent = document.createEvent("CustomEvent");
+ longActionEvent.initCustomEvent("longAction", true, true, null);
+ this.dispatchEvent(longActionEvent);
+ }
+ },
+
+ /**
+ * Called when all interaction is over.
+ * @private
+ */
+ handlePressCancel: {
+ value: function (event) {
+ this.active = false;
+ document.removeEventListener("touchmove", this, false);
+ }
+ },
+
+ /**
+ * @private
+ */
+ handleTouchmove: {
+ value: function (event) {
+ event.preventDefault();
+ }
+ }
+});
diff --git a/ui/action-target.mod/action-target.mjson b/ui/action-target.mod/action-target.mjson
new file mode 100644
index 000000000..b4c52fffe
--- /dev/null
+++ b/ui/action-target.mod/action-target.mjson
@@ -0,0 +1,109 @@
+{
+ "objectDescriptor_actionTarget_enabled": {
+ "prototype": "core/meta/property-descriptor",
+ "values": {
+ "name": "enabled",
+ "objectDescriptor": {
+ "@": "root"
+ },
+ "valueType": "boolean"
+ }
+ },
+ "objectDescriptor_actionTarget_preventFocus": {
+ "prototype": "core/meta/property-descriptor",
+ "values": {
+ "name": "preventFocus",
+ "objectDescriptor": {
+ "@": "root"
+ },
+ "valueType": "boolean"
+ }
+ },
+ "objectDescriptor_actionTarget_holdThreshold": {
+ "prototype": "core/meta/property-descriptor",
+ "values": {
+ "name": "holdThreshold",
+ "objectDescriptor": {
+ "@": "root"
+ },
+ "valueType": "number"
+ }
+ },
+ "objectDescriptor_actionTarget_active": {
+ "prototype": "core/meta/property-descriptor",
+ "values": {
+ "name": "active",
+ "objectDescriptor": {
+ "@": "root"
+ },
+ "valueType": "boolean"
+ }
+ },
+ "objectDescriptor_actionTarget_detail": {
+ "prototype": "core/meta/property-descriptor",
+ "values": {
+ "name": "detail",
+ "objectDescriptor": {
+ "@": "root"
+ },
+ "valueType": "object",
+ "readOnly": true,
+ "collectionValueType": "dict"
+ }
+ },
+ "objectDescriptor_component_reference": {
+ "object": "ui/component.mjson"
+ },
+ "root": {
+ "prototype": "core/meta/module-object-descriptor",
+ "values": {
+ "name": "ActionTarget",
+ "parent": {
+ "@": "objectDescriptor_component_reference"
+ },
+ "propertyDescriptors": [
+ {
+ "@": "objectDescriptor_actionTarget_enabled"
+ },
+ {
+ "@": "objectDescriptor_actionTarget_preventFocus"
+ },
+ {
+ "@": "objectDescriptor_actionTarget_holdThreshold"
+ },
+ {
+ "@": "objectDescriptor_actionTarget_active"
+ },
+ {
+ "@": "objectDescriptor_actionTarget_detail"
+ }
+ ],
+ "propertyDescriptorGroups": {
+ "ActionTarget": [
+ {
+ "@": "objectDescriptor_actionTarget_enabled"
+ },
+ {
+ "@": "objectDescriptor_actionTarget_preventFocus"
+ },
+ {
+ "@": "objectDescriptor_actionTarget_holdThreshold"
+ },
+ {
+ "@": "objectDescriptor_actionTarget_active"
+ },
+ {
+ "@": "objectDescriptor_actionTarget_detail"
+ }
+ ]
+ },
+ "objectDescriptorModule": {
+ "%": "ui/action-target.mod/action-target.mjson"
+ },
+ "exportName": "ActionTarget",
+ "module": {
+ "%": "ui/action-target.mod"
+ }
+ }
+ }
+}
diff --git a/ui/button.mod/button.js b/ui/button.mod/button.js
index 5f7a01549..997f2a3de 100644
--- a/ui/button.mod/button.js
+++ b/ui/button.mod/button.js
@@ -2,10 +2,7 @@
const { VisualOrientation } = require("core/enums/visual-orientation");
const { VisualPosition } = require("core/enums/visual-position");
-const { PressComposer } = require("composer/press-composer");
-const { KeyComposer } = require("composer/key-composer");
-const { Control } = require("ui/control");
-const { Montage } = require("core/core");
+const { ActionTarget } = require("ui/action-target.mod/action-target");
// TODO: migrate away from using undefinedGet and undefinedSet
@@ -13,7 +10,7 @@ const { Montage } = require("core/core");
* Wraps a native <button> or <input[type="button"]> HTML element.
* The element's standard attributes are exposed as bindable properties.
* @class module:"mod/ui/native/button.mod".Button
- * @extends module:mod/ui/control.Control
+ * @extends module:mod/ui/action-target.mod/action-target
* @fires action
* @fires hold
*
@@ -46,7 +43,7 @@ const { Montage } = require("core/core");
* }
*
*/
-const Button = (exports.Button = class Button extends Control {
+const Button = (exports.Button = class Button extends ActionTarget {
/** @lends module:"mod/ui/native/button.mod".Button# */
// <---- Static ---->
@@ -57,72 +54,6 @@ const Button = (exports.Button = class Button extends Control {
// <---- Properties ---->
- static {
- /**
- * Options for debouncing the action event.
- * Immediately invokes the function and then ignores any calls made
- * within the threshold period.
- * @type {Object}
- * @default { leading: true, trailing: false }
- */
- Montage.defineProperties(this.prototype, {
- debounceOptions: {
- value: {
- leading: true,
- trailing: false,
- },
- },
-
- _debounceThreshold: { value: 300 }, // milliseconds
-
- _debounced: { value: false },
- });
- }
-
- get debounceThreshold() {
- return this._debounceThreshold;
- }
-
- /**
- * The debounce threshold in milliseconds.
- * @type {number}
- * @default 300
- */
- set debounceThreshold(value) {
- this._debounceThreshold = Number(value);
-
- if (this._debounced) {
- this.dispatchActionEvent = this.debounce(
- this.dispatchActionEvent.bind(this),
- this._debounceThreshold,
- this.debounceOptions
- );
- }
- }
-
- get debounced() {
- return this._debounced;
- }
-
- /**
- * Indicates whether the action event is debounced.
- * @type {boolean}
- * @default false
- */
- set debounced(value) {
- this._debounced = Boolean(value);
-
- if (this._debounced) {
- this.dispatchActionEvent = this.debounce(
- this.dispatchActionEvent.bind(this),
- this._debounceThreshold,
- this.debounceOptions
- );
- } else {
- this.dispatchActionEvent = Button.prototype.dispatchActionEvent;
- }
- }
-
_visualPosition = VisualPosition.start;
get visualPosition() {
@@ -213,188 +144,6 @@ const Button = (exports.Button = class Button extends Control {
}
}
- _promise = undefined;
-
- get promise() {
- return this._promise;
- }
-
- set promise(promise) {
- // Only proceed if the new promise is different from the current one
- if (this._promise === promise) return;
-
- const shouldClearPendingState = !!this._promise;
- this._promise = promise;
-
- if (promise) {
- // Set up pending state when promise is set
- this.classList.add("mod--pending");
-
- // Store reference to track this specific promise
- const currentTrackedPromise = promise;
-
- // Clear state when promise resolves/rejects
- // TODO: we should propably add an error state?...
- promise.finally(() => {
- // Only clear if this is still the active promise
- if (this._promise === currentTrackedPromise) {
- this.classList.remove("mod--pending");
- this._promise = undefined;
- }
- });
- } else if (shouldClearPendingState) {
- // Clear pending state when the current promise is set to null
- this.classList.remove("mod--pending");
- }
- }
-
- /**
- * The amount of time in milliseconds the user must press and hold
- * the button a hold event is dispatched.
- * The default is 1 second.
- * @type {number}
- * @default 1000
- */
- get holdThreshold() {
- return this._pressComposer.longPressThreshold;
- }
-
- set holdThreshold(value) {
- this._pressComposer.longPressThreshold = value;
- }
-
- __pressComposer = null;
-
- get _pressComposer() {
- if (!this.__pressComposer) {
- this.__pressComposer = new PressComposer();
- this.addComposer(this.__pressComposer);
- }
-
- return this.__pressComposer;
- }
-
- __spaceKeyComposer = null;
-
- get _spaceKeyComposer() {
- if (!this.__spaceKeyComposer) {
- this.__spaceKeyComposer = KeyComposer.createKey(this, "space", "space");
- }
-
- return this.__spaceKeyComposer;
- }
-
- __enterKeyComposer = null;
-
- get _enterKeyComposer() {
- if (!this.__enterKeyComposer) {
- this.__enterKeyComposer = KeyComposer.createKey(this, "enter", "enter");
- }
-
- return this.__enterKeyComposer;
- }
-
- /**
- * Prepares the component for activation events.
- * @override
- * @protected
- */
- prepareForActivationEvents() {
- this._pressComposer.addEventListener("pressStart", this, false);
- this._spaceKeyComposer.addEventListener("keyPress", this, false);
- this._enterKeyComposer.addEventListener("keyPress", this, false);
- }
-
- /**
- * Override addEventListener for optimization
- * @override
- * @protected
- * @param {String} type - The event type
- * @param {Function} listener - The event listener
- * @param {boolean} useCapture - The useCapture flag
- */
- addEventListener(type, listener, useCapture) {
- super.addEventListener(type, listener, useCapture);
-
- if (type === "longAction") {
- this._pressComposer.addEventListener("longPress", this, false);
- }
- }
-
- // <---- Event Handlers ---->
-
- /**
- * Dispatching the action event on spacebar & enter when the button is focused.
- * @param {MutableEvent} mutableEvent - The event object
- * @protected
- * @fires action
- */
- handleKeyPress(mutableEvent) {
- const { identifier } = mutableEvent;
-
- if (identifier === "space" || identifier === "enter") {
- this.active = false;
- this.dispatchActionEvent();
- }
- }
-
- /**
- * Called when the user starts interacting with the component.
- * @protected
- * @param {MutableEvent} mutableEvent - The event object
- */
- handlePressStart(mutableEvent) {
- if (!this._promise) {
- this.active = true;
- this._addEventListeners();
- }
- }
-
- /**
- * Called when the user has interacted with the button.
- * @protected
- * @param {MutableEvent} mutableEvent - The event object
- * @fires action
- */
- handlePress(mutableEvent) {
- if (!this._promise) {
- this.active = false;
- this.dispatchActionEvent(event.details);
- this._removeEventListeners();
- }
- }
-
- /**
- * Called when the user has interacted with the button for a long time.
- * @protected
- * @param {MutableEvent} mutableEvent - The event object
- * @fires longAction
- */
- handleLongPress(mutableEvent) {
- if (!this._promise) {
- // When we fire the "hold" event we don't want to fire the
- // "action" event as well.
- this._pressComposer.cancelPress();
- this._removeEventListeners();
-
- const longActionEvent = document.createEvent("CustomEvent");
-
- // FIXME: InitCustomEvent is deprecated
- longActionEvent.initCustomEvent("longAction", true, true, null);
- this.dispatchEvent(longActionEvent);
- }
- }
-
- /**
- * Called when all interaction is over.
- * @protected
- * @param {MutableEvent} mutableEvent - The event object
- */
- handlePressCancel(mutableEvent) {
- this.active = false;
- this._removeEventListeners();
- }
-
// <---- Life Cycle ---->
enterDocument(firstDraw) {
@@ -435,30 +184,7 @@ const Button = (exports.Button = class Button extends Control {
// <---- Private ---->
- /**
- * Adds event listeners to the button.
- * @private
- */
- _addEventListeners() {
- this._pressComposer.addEventListener("press", this, false);
- this._pressComposer.addEventListener("pressCancel", this, false);
-
- // FIXME: @benoit: we should maybe have a flag for this kind of event.
- // can be tricky with the event delegation for example if we don't add it.
- // same issue for: the pressComposer and the translate composer.
- this._pressComposer.addEventListener("longPress", this, false);
- }
-
- /**
- * Removes event listeners from the button.
- * @private
- */
- _removeEventListeners() {
- this._pressComposer.removeEventListener("press", this, false);
- this._pressComposer.removeEventListener("pressCancel", this, false);
- this._pressComposer.removeEventListener("longPress", this, false);
- }
-
+
/**
* Applies the current image position's styling by updating CSS classes
* @private
diff --git a/ui/button.mod/button.mjson b/ui/button.mod/button.mjson
index 4f61169e0..85ad3e06c 100644
--- a/ui/button.mod/button.mjson
+++ b/ui/button.mod/button.mjson
@@ -141,8 +141,8 @@
"helpKey": ""
}
},
- "objectDescriptor_control_reference": {
- "object": "ui/control.mjson"
+ "objectDescriptor_action_target_reference": {
+ "object": "ui/action-target.mod/action-target.mjson"
},
"root": {
"prototype": "core/meta/module-object-descriptor",
@@ -150,7 +150,7 @@
"name": "button",
"customPrototype": false,
"parent": {
- "@": "objectDescriptor_control_reference"
+ "@": "objectDescriptor_action_target_reference"
},
"propertyDescriptors": [
{