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.
This commit is contained in:
Maxime Quandalle 2015-06-07 10:30:27 +02:00
parent 12919cbfc6
commit 92dd05d06d
17 changed files with 199 additions and 145 deletions

View file

@ -1,7 +1,7 @@
template(name="headerBoard")
h1.header-board-menu(
class="{{#if currentUser.isBoardMember}}is-clickable js-edit-board-title{{/if}}")
= title
h1.header-board-menu
a(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}")
= title
.board-header-btns.left
unless isSandstorm
@ -12,7 +12,7 @@ template(name="headerBoard")
if showStarCounter
span {{_ 'board-nb-stars' stars}}
a.board-header-btn.js-change-visibility(class="{{#unless currentUser.isBoardAdmin}}no-edit{{/unless}}")
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}")
i.fa(class="{{#if isPublic}}fa-globe{{else}}fa-lock{{/if}}")
span {{_ permission}}

View file

@ -43,6 +43,7 @@ Router.route('/boards/:boardId/:slug/:cardId', {
Sidebar.hide();
}
});
EscapeActions.executeUpTo('popup');
var params = this.params;
Session.set('currentBoard', params.boardId);
Session.set('currentCard', params.cardId);
@ -55,9 +56,3 @@ Router.route('/boards/:boardId/:slug/:cardId', {
return Boards.findOne(this.params.boardId);
}
});
// Close the card details pane by pressing escape
EscapeActions.register('detailsPane',
function() { Utils.goBoardId(Session.get('currentBoard')); },
function() { return ! Session.equals('currentCard', null); }
);

View file

@ -94,3 +94,11 @@ Template.moveCardPopup.events({
Popup.close();
}
});
// Close the card details pane by pressing escape
EscapeActions.register('detailsPane',
function() { Utils.goBoardId(Session.get('currentBoard')); },
function() { return ! Session.equals('currentCard', null); }, {
noClickEscapeOn: '.js-card-details'
}
);

View file

@ -1,6 +1,6 @@
template(name='inlinedForm')
if isOpen.get
form(id=id class=classNames)
form.js-inlined-form(id=id class=classNames)
+Template.contentBlock
else
+Template.elseBlock

View file

@ -36,7 +36,7 @@ BlazeComponent.extendComponent({
open: function() {
// Close currently opened form, if any
EscapeActions.executeLowerThan('inlinedForm');
EscapeActions.executeUpTo('inlinedForm');
this.isOpen.set(true);
currentlyOpenedForm.set(this);
},
@ -61,18 +61,6 @@ BlazeComponent.extendComponent({
'click .js-close-inlined-form': this.close,
'click .js-open-inlined-form': this.open,
// Close the inlined form by pressing escape.
//
// Keydown (and not keypress) in necessary here because the `keyCode`
// property is consistent in all browsers, (there is not keyCode for the
// `keypress` event in firefox)
'keydown form input, keydown form textarea': function(evt) {
if (evt.keyCode === 27) {
evt.preventDefault();
EscapeActions.executeLowest();
}
},
// Pressing Ctrl+Enter should submit the form
'keydown form textarea': function(evt) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
@ -98,5 +86,7 @@ BlazeComponent.extendComponent({
// Press escape to close the currently opened inlinedForm
EscapeActions.register('inlinedForm',
function() { currentlyOpenedForm.get().close(); },
function() { return currentlyOpenedForm.get() !== null; }
function() { return currentlyOpenedForm.get() !== null; }, {
noClickEscapeOn: '.js-inlined-form'
}
);

View file

@ -50,7 +50,7 @@ BlazeComponent.extendComponent({
placeholder: 'minicard-wrapper placeholder',
start: function(evt, ui) {
ui.placeholder.height(ui.helper.height());
EscapeActions.executeLowerThan('popup');
EscapeActions.executeUpTo('popup');
boardComponent.setIsDragging(true);
},
stop: function(evt, ui) {

View file

@ -12,16 +12,15 @@
font-size: 12px
display: flex
#header-user-bar
#header-user-bar,
ul li
color: darken(white, 17%)
a, .fa
.fa
color: inherit
text-decoration: none
&:hover
color: white
a:hover, a.is-active
color: white
ul
flex: 1
@ -76,9 +75,6 @@
float: left
border-radius: 3px
&.is-clickable
cursor: pointer
.board-header-btns
display: block
margin-top: 3px

View file

@ -18,11 +18,6 @@ function whichTransitionEvent() {
var transitionEvent = whichTransitionEvent();
Popup.template.events({
click: function(evt) {
if (evt.originalEvent) {
evt.originalEvent.clickInPopup = true;
}
},
'click .js-back-view': function() {
Popup.back();
},

View file

@ -1,4 +1,4 @@
.pop-over(
.pop-over.js-pop-over(
class="{{#unless title}}miniprofile{{/unless}}"
class=currentBoard.colorClass
class="{{#unless title}}no-title{{/unless}}"

View file

@ -111,7 +111,7 @@ BlazeComponent.extendComponent({
snap: false,
snapMode: 'both',
start: function() {
EscapeActions.executeLowerThan('popup');
EscapeActions.executeUpTo('popup');
}
});
});

View file

@ -1,12 +1,12 @@
template(name="headerUserBar")
a#header-user-bar
.header-user-bar-name.js-open-header-member-menu
#header-user-bar
a.header-user-bar-name.js-open-header-member-menu
i.fa.fa-chevron-down
if currentUser.profile.name
= currentUser.profile.name
else
= currentUser.username
.header-user-bar-avatar.js-change-avatar
a.header-user-bar-avatar.js-change-avatar
+userAvatar(user=currentUser)
template(name="memberMenuPopup")

View file

@ -24,7 +24,7 @@ Router.configure({
return this.redirect('atSignIn');
}
// We want to execute our EscapeActions.executeLowerThan method any time the
// We want to execute our EscapeActions.executeUpTo method any time the
// route is changed, but not if the stays the same but only the parameters
// change (eg when a user is navigation from a card A to a card B). Iron-
// Router onBeforeAction is a reactive context (which is a bad desig choice

157
client/lib/escapeActions.js Normal file
View file

@ -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');
}
});

View file

@ -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();
});

View file

@ -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(); }
);

View file

@ -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'
}
);

View file

@ -63,16 +63,12 @@ h3, h4, h5, h6
color: #aa8f09
a
color: #444
color: inherit
cursor: pointer
text-decoration: none
&:hover
color: #111
&.disabled,
&.disabled:hover
color: #8c8c8c
&.is-disabled,
&.is-disabled:hover
cursor: default
text-decoration: none