From 92dd05d06ddeb2a9434df6038c432e6b167c1c99 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Sun, 7 Jun 2015 10:30:27 +0200 Subject: Click on the page to escape the last action This is a generalization of what we had for closing a popup by clicking outside of it. It now works for inlinedForms and detailsPane as well. --- client/lib/escapeActions.js | 157 +++++++++++++++++++++++++++++++++++++++++++ client/lib/keyboard.js | 69 ------------------- client/lib/multiSelection.js | 9 +-- client/lib/popup.js | 25 ++----- 4 files changed, 167 insertions(+), 93 deletions(-) create mode 100644 client/lib/escapeActions.js (limited to 'client/lib') diff --git a/client/lib/escapeActions.js b/client/lib/escapeActions.js new file mode 100644 index 00000000..3759f441 --- /dev/null +++ b/client/lib/escapeActions.js @@ -0,0 +1,157 @@ +// Pressing `Escape` should close the last opened “element” and only the last +// one. Components can register themselves using a label a condition, and an +// action. This is used by Popup or inlinedForm for instance. When we press +// escape we execute the action which have a valid condition and his the highest +// in the label hierarchy. +EscapeActions = { + _actions: [], + + // Executed in order + hierarchy: [ + 'textcomplete', + 'popup', + 'inlinedForm', + 'detailsPane', + 'multiselection', + 'sidebarView' + ], + + register: function(label, action, condition, options) { + condition = condition || function() { return true; }; + options = options || {}; + + // XXX Rewrite this with ES6: .push({ priority, condition, action }) + var priority = this.hierarchy.indexOf(label); + if (priority === -1) { + throw Error('You must define the label in the EscapeActions hierarchy'); + } + + this._actions.push({ + priority: priority, + condition: condition, + action: action, + noClickEscapeOn: options.noClickEscapeOn + }); + // XXX Rewrite this with ES6: => function + this._actions = _.sortBy(this._actions, function(a) { return a.priority; }); + }, + + executeLowest: function() { + return this._execute({ + multipleAction: false + }); + }, + + executeAll: function() { + return this._execute({ + multipleActions: true + }); + }, + + executeUpTo: function(maxLabel) { + return this._execute({ + maxLabel: maxLabel, + multipleActions: true + }); + }, + + clickExecute: function(evt, maxLabel) { + return this._execute({ + maxLabel: maxLabel, + multipleActions: false, + evt: evt + }); + }, + + _stopClick: function(action, clickTarget) { + if (! _.isString(action.noClickEscapeOn)) + return false; + else + return $(clickTarget).closest(action.noClickEscapeOn).length > 0; + }, + + _execute: function(options) { + var maxLabel = options.maxLabel; + var evt = options.evt || {}; + var multipleActions = options.multipleActions; + + var maxPriority, currentAction; + var executedAtLeastOne = false; + if (! maxLabel) + maxPriority = Infinity; + else + maxPriority = this.hierarchy.indexOf(maxLabel); + + for (var i = 0; i < this._actions.length; i++) { + currentAction = this._actions[i]; + if (currentAction.priority > maxPriority) + return executedAtLeastOne; + + if (evt.type === 'click' && this._stopClick(currentAction, evt.target)) + return executedAtLeastOne; + + if (currentAction.condition()) { + currentAction.action(evt); + executedAtLeastOne = true; + if (! multipleActions) + return executedAtLeastOne; + } + } + return executedAtLeastOne; + } +}; + +// MouseTrap plugin bindGlobal plugin. Adds a bindGlobal method to Mousetrap +// that allows you to bind specific keyboard shortcuts that will still work +// inside a text input field. +// +// usage: +// Mousetrap.bindGlobal('ctrl+s', _saveChanges); +// +// source: +// https://github.com/ccampbell/mousetrap/tree/master/plugins/global-bind +var _globalCallbacks = {}; +var _originalStopCallback = Mousetrap.stopCallback; + +Mousetrap.stopCallback = function(e, element, combo, sequence) { + var self = this; + + if (self.paused) { + return true; + } + + if (_globalCallbacks[combo] || _globalCallbacks[sequence]) { + return false; + } + + return _originalStopCallback.call(self, e, element, combo); +}; + +Mousetrap.bindGlobal = function(keys, callback, action) { + var self = this; + self.bind(keys, callback, action); + + if (keys instanceof Array) { + for (var i = 0; i < keys.length; i++) { + _globalCallbacks[keys[i]] = true; + } + return; + } + + _globalCallbacks[keys] = true; +}; + +// Pressing escape to execute one escape action. We use `bindGloabal` vecause +// the shortcut sould work on textarea and inputs as well. +Mousetrap.bindGlobal('esc', function() { + EscapeActions.executeLowest(); +}); + +// On a left click on the document, we try to exectute one escape action (eg, +// close the popup). We don't execute any action if the user has clicked on a +// link or a button. +$(document).on('click', function(evt) { + if (evt.which === 1 && $(evt.target).closest('a,button').length === 0) { + EscapeActions.clickExecute(evt, 'detailsPane'); + } +}); diff --git a/client/lib/keyboard.js b/client/lib/keyboard.js index 8b105c28..bd78390a 100644 --- a/client/lib/keyboard.js +++ b/client/lib/keyboard.js @@ -33,72 +33,3 @@ Mousetrap.bind(['down', 'up'], function(evt, key) { Utils.goCardId(nextCardId); } }); - -// Pressing `Escape` should close the last opened “element” and only the last -// one. Components can register themselves using a label a condition, and an -// action. This is used by Popup or inlinedForm for instance. When we press -// escape we execute the action which have a condition is valid and his the the -// highest in the label hierarchy. -EscapeActions = { - _actions: [], - - // Executed in order - hierarchy: [ - 'textcomplete', - 'popup', - 'inlinedForm', - 'multiselection-disable', - 'sidebarView', - 'detailsPane', - 'multiselection-reset' - ], - - register: function(label, action, condition) { - if (_.isUndefined(condition)) - condition = function() { return true; }; - - // XXX Rewrite this with ES6: .push({ priority, condition, action }) - var priority = this.hierarchy.indexOf(label); - if (priority === -1) { - throw Error('You must define the label in the EscapeActions hierarchy'); - } - this._actions.push({ - priority: priority, - condition: condition, - action: action - }); - // XXX Rewrite this with ES6: => function - this._actions = _.sortBy(this._actions, function(a) { return a.priority; }); - }, - - executeLowest: function() { - var topActiveAction = _.find(this._actions, function(a) { - return !! a.condition(); - }); - return topActiveAction && topActiveAction.action(); - }, - - executeLowerThan: function(label) { - var maxPriority, currentAction; - if (! label) - maxPriority = Infinity; - else - maxPriority = this.hierarchy.indexOf(label); - - for (var i = 0; i < this._actions.length; i++) { - currentAction = this._actions[i]; - if (currentAction.priority > maxPriority) - return; - if (!! currentAction.condition()) - currentAction.action(); - } - }, - - executeAll: function() { - return this.executeLowerThan(); - } -}; - -Mousetrap.bind('esc', function() { - EscapeActions.executeLowest(); -}); diff --git a/client/lib/multiSelection.js b/client/lib/multiSelection.js index 53c16da0..2f96e199 100644 --- a/client/lib/multiSelection.js +++ b/client/lib/multiSelection.js @@ -78,7 +78,7 @@ MultiSelection = { activate: function() { if (! this.isActive()) { - EscapeActions.executeLowerThan('detailsPane'); + EscapeActions.executeUpTo('detailsPane'); this._isActive.set(true); Sidebar.setView(this.sidebarView); Tracker.flush(); @@ -91,6 +91,7 @@ MultiSelection = { if (Sidebar && Sidebar.getView() === this.sidebarView) { Sidebar.setView(); } + this.reset(); } }, @@ -149,11 +150,7 @@ MultiSelection = { Blaze.registerHelper('MultiSelection', MultiSelection); -EscapeActions.register('multiselection-disable', +EscapeActions.register('multiselection', function() { MultiSelection.disable(); }, function() { return MultiSelection.isActive(); } ); - -EscapeActions.register('multiselection-reset', - function() { MultiSelection.reset(); } -); diff --git a/client/lib/popup.js b/client/lib/popup.js index fe8b581b..a1f4def2 100644 --- a/client/lib/popup.js +++ b/client/lib/popup.js @@ -40,11 +40,8 @@ Popup = { self._stack = []; openerElement = evt.currentTarget; } - $(openerElement).addClass('is-active'); - // We modify the event to prevent the popup being closed when the event - // bubble up to the document element. - evt.originalEvent.clickInPopup = true; + $(openerElement).addClass('is-active'); evt.preventDefault(); // We push our popup data to the stack. The top of the stack is always @@ -201,19 +198,11 @@ Popup = { } }; -// We automatically close a potential opened popup on any left click on the -// document. To avoid closing it unexpectedly we modify the bubbled event in -// case the click event happen in the popup or in a button that open a popup. -$(document).on('click', function(evt) { - if (evt.which === 1 && ! (evt.originalEvent && - evt.originalEvent.clickInPopup)) { - Popup.close(); - } -}); - -// Press escape to go back, or close the popup. -var bindPopup = function(f) { return _.bind(f, Popup); }; +// We close a potential opened popup on any left click on the document, or go +// one step back by pressing escape. EscapeActions.register('popup', - bindPopup(Popup.back), - bindPopup(Popup.isOpen) + function(evt) { Popup[evt.type === 'click' ? 'close' : 'back'](); }, + _.bind(Popup.isOpen, Popup), { + noClickEscapeOn: '.js-pop-over' + } ); -- cgit v1.2.3-1-g7c22