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": [ {